Files
portal/src/routes/api/v2/schedule/+server.ts
T
NeilClack 3bb8e0189b Portal: per-license schedule service, shared validator, and key-only GET
Implements the shared validateSchedule validator (daily + weekly, midnight
spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a
per-License-Key schedule service (get/set type, CRUD blocks, weekly
prefill with confirmation), the content-hash version/ETag serializer, the
new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null),
and additive evolution of the legacy GET /api/v2/schedule. Adds validator,
serializer (against docs/fixtures), endpoint ETag/304, and service tests.
2026-09-29 10:23:10 -05:00

68 lines
2.4 KiB
TypeScript

import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
import { buildSchedulePayload } from '$lib/server/v2-serializers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function scheduleType(value: string | null | undefined) {
return value === 'daily' || value === 'weekly' ? value : null;
}
/**
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
* `email` + `licenseKey` query parameters and validates only that the pair
* belong together — license active/entitlement is deliberately not checked
* here, since an invalid license would already lock the kiosk out.
*
* Extended additively with `version`, `type`, `daily`, and `weekly`. `email`
* and `blocks` keep their exact previous meaning (daily flattening; empty for
* weekly), so old Kiosks are unaffected. No ETag/304 here.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `schedule:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const email = (url.searchParams.get('email') ?? '').trim().toLowerCase();
const licenseKey = (
url.searchParams.get('licenseKey') ??
url.searchParams.get('key') ??
''
).trim();
if (!email || !licenseKey || email.length > 254 || !EMAIL_RE.test(email)) {
throw error(400, 'not-authorized');
}
const storedKey = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
if (!storedKey) {
throw error(400, 'not-authorized');
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
throw error(400, 'not-authorized');
}
// Licenses created before accounts were linked may carry only an email.
const belongs =
storedKey.userId === user.id ||
(storedKey.userId === null && storedKey.email?.toLowerCase() === email);
if (!belongs) {
throw error(400, "this key doesn't appear to belong to you");
}
const schedule = await getScheduleForLicense(storedKey.id);
const type = scheduleType(schedule?.type);
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
const payload = buildSchedulePayload(type, blocks);
return jsonOk({ email, ...payload });
};