diff --git a/docs/ai/api.md b/docs/ai/api.md index a8d1eaf..28af62b 100644 --- a/docs/ai/api.md +++ b/docs/ai/api.md @@ -107,8 +107,10 @@ Authenticates with the paired `email` + `licenseKey` query parameters and validates only that the key belongs to the account (not that the license is active/entitled). Returns the user's Blocks with start/end times and the IDs of the selected Proclamations (content is fetched separately via -`/api/v2/proclamations`). Rate limited to 10 requests per minute per IP. -Failure messages follow the existing v2 shape: +`/api/v2/proclamations`). Overnight blocks (end clock time before start) are +supported and flagged with `overnight: true` plus a wrapping `durationMinutes`. +Rate limited to 10 requests per minute per IP. Failure messages follow the +existing v2 shape: `not-authorized` or `this key doesn't appear to belong to you`. --- diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9eb3568..bfed06a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -245,7 +245,9 @@ paths: pairing. Returns every Block with its start/end times and the ordered list of - selected Proclamation IDs. Proclamation content is not included; the + selected Proclamation IDs. Overnight blocks (an end clock time before + the start) are supported and flagged with `overnight: true` and a + wrapping `durationMinutes`. Proclamation content is not included; the kiosk fetches it separately via `/api/v2/proclamations`. operationId: getSchedule parameters: @@ -498,7 +500,17 @@ components: format: email ScheduleBlock: type: object - required: [id, startTime, startMinutes, endTime, endMinutes, proclamationIds] + required: + [ + id, + startTime, + startMinutes, + endTime, + endMinutes, + overnight, + durationMinutes, + proclamationIds + ] properties: id: type: string @@ -521,6 +533,13 @@ components: minimum: 0 maximum: 1440 description: Minutes from midnight. + overnight: + type: boolean + description: True when the end clock time is at or before the start, i.e. the block crosses midnight. + durationMinutes: + type: integer + minimum: 1 + description: Block length in minutes. An overnight block counts the wrap past midnight as the next day. proclamationIds: type: array description: IDs of the selected Proclamations, in play order. diff --git a/src/lib/server/v2-serializers.ts b/src/lib/server/v2-serializers.ts index 1325e0c..7aa6a74 100644 --- a/src/lib/server/v2-serializers.ts +++ b/src/lib/server/v2-serializers.ts @@ -1,4 +1,4 @@ -import { toHm } from '$lib/schedule-utils'; +import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils'; // Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()` // map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these @@ -147,6 +147,8 @@ export type V2ScheduleBlock = { startMinutes: number; endTime: string; endMinutes: number; + overnight: boolean; + durationMinutes: number; proclamationIds: string[]; }; @@ -156,7 +158,9 @@ export type V2Schedule = { }; /** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are - * returned; the kiosk fetches proclamation content separately. */ + * returned; the kiosk fetches proclamation content separately. `overnight` + * marks a range that crosses midnight (end clock time before start), and + * `durationMinutes` counts the wrap as the next day. */ export function serializeScheduleBlock( block: { id: string; startMinutes: number; endMinutes: number }, proclamationIds: string[] @@ -167,6 +171,8 @@ export function serializeScheduleBlock( startMinutes: block.startMinutes, endTime: toHm(block.endMinutes), endMinutes: block.endMinutes, + overnight: isOvernight(block), + durationMinutes: blockDuration(block), proclamationIds }; } diff --git a/src/routes/api/v2/schedule/schedule.spec.ts b/src/routes/api/v2/schedule/schedule.spec.ts index 76870cc..de18046 100644 --- a/src/routes/api/v2/schedule/schedule.spec.ts +++ b/src/routes/api/v2/schedule/schedule.spec.ts @@ -86,7 +86,7 @@ describe('GET /api/v2/schedule auth', () => { }); describe('GET /api/v2/schedule success', () => { - it('returns only proclamation IDs even when the key is inactive', async () => { + it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => { licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', @@ -97,8 +97,8 @@ describe('GET /api/v2/schedule success', () => { blockFindMany.mockResolvedValue([ { id: 'block-1', - startMinutes: 360, - endMinutes: 480, + startMinutes: 21 * 60, + endMinutes: 9 * 60, proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }] } ]); @@ -111,10 +111,12 @@ describe('GET /api/v2/schedule success', () => { blocks: [ { id: 'block-1', - startTime: '06:00', - startMinutes: 360, - endTime: '08:00', - endMinutes: 480, + startTime: '21:00', + startMinutes: 1260, + endTime: '09:00', + endMinutes: 540, + overnight: true, + durationMinutes: 720, proclamationIds: ['p1', 'p2'] } ] @@ -134,9 +136,20 @@ describe('GET /api/v2/schedule success', () => { ]); const response = await call({ email: 'jane@example.com', licenseKey: KEY }); - const body = (await response.json()) as { blocks: { proclamationIds: string[] }[] }; + const body = (await response.json()) as { + blocks: { proclamationIds: string[]; overnight: boolean; durationMinutes: number }[]; + }; - expect(body.blocks[0].proclamationIds).toEqual(['p1']); + expect(body.blocks[0]).toEqual({ + id: 'block-1', + startTime: '00:00', + startMinutes: 0, + endTime: '01:00', + endMinutes: 60, + overnight: false, + durationMinutes: 60, + proclamationIds: ['p1'] + }); }); it('accepts the legacy `key` query alias and an email-only license', async () => {