From bfdef4e5b7b90b4c85ee27241f7d07ae380bc1d7 Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 22:57:41 +0200 Subject: [PATCH] Add Netlify DB store adapter Add the initial Netlify DB schema and a Postgres-backed store implementation for kids, passports, prizes, prize awards, and magic-link tokens. Wire Netlify functions to opt into DB-backed stores with explicit backend config while keeping blob storage as the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- db/migrations/0001_netlify_db.sql | 62 +++ docs/storage-json.md | 11 + netlify/functions/api.ts | 22 +- package-lock.json | 19 + package.json | 1 + server/access-tokens.ts | 2 +- server/db-store.ts | 722 ++++++++++++++++++++++++++++++ 7 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 db/migrations/0001_netlify_db.sql create mode 100644 server/db-store.ts diff --git a/db/migrations/0001_netlify_db.sql b/db/migrations/0001_netlify_db.sql new file mode 100644 index 0000000..9cac60b --- /dev/null +++ b/db/migrations/0001_netlify_db.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS conference_settings ( + id text PRIMARY KEY DEFAULT 'default', + kid_id_prefix text NOT NULL, + short_name text NOT NULL, + title text NOT NULL +); + +CREATE TABLE IF NOT EXISTS kids ( + id text PRIMARY KEY, + name text NOT NULL, + age integer NOT NULL CHECK (age > 0), + gender text NOT NULL, + language text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS passport_activities ( + kid_id text NOT NULL, + activity_id integer NOT NULL, + completed_at timestamptz, + PRIMARY KEY (kid_id, activity_id) +); + +CREATE TABLE IF NOT EXISTS prizes ( + id text PRIMARY KEY, + title text NOT NULL, + kind text NOT NULL CHECK (kind IN ('final', 'normal', 'valuable')), + initial_units integer NOT NULL CHECK (initial_units >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS prize_awards ( + id text PRIMARY KEY, + kid_id text NOT NULL, + prize_id text NOT NULL, + source text CHECK (source IS NULL OR source IN ('passportCompletion', 'wheel')), + awarded_at timestamptz NOT NULL +); + +CREATE TABLE IF NOT EXISTS magic_link_tokens ( + token_hash text PRIMARY KEY, + role text NOT NULL CHECK (role IN ('desk', 'lead', 'wheel')), + activity_id integer, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS passport_activities_kid_id_idx + ON passport_activities (kid_id); + +CREATE INDEX IF NOT EXISTS prize_awards_kid_id_idx + ON prize_awards (kid_id); + +CREATE INDEX IF NOT EXISTS prize_awards_prize_id_idx + ON prize_awards (prize_id); + +CREATE UNIQUE INDEX IF NOT EXISTS prize_awards_passport_completion_per_kid_idx + ON prize_awards (kid_id) + WHERE source = 'passportCompletion'; + +CREATE INDEX IF NOT EXISTS magic_link_tokens_expires_at_idx + ON magic_link_tokens (expires_at); diff --git a/docs/storage-json.md b/docs/storage-json.md index fd24dd4..9cb0e07 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -6,6 +6,9 @@ This app has two writable storage backends: when files are missing. - Netlify deployments use Netlify Blobs in the `kid-a-data` store by default, seeded from committed JSON files in `server/data` or `src/data`. +- Netlify deployments can use Netlify DB/Postgres by setting + `KID_A_STORE_BACKEND=db`. Magic-link tokens follow the same backend unless + `KID_A_TOKEN_BACKEND` is set explicitly. Netlify Blobs are optimized for reads and infrequent writes. Avoid read-modify-write on shared JSON blobs for high-frequency event data because @@ -45,3 +48,11 @@ app for DB and dual-write backends. 2. Add migration and verification tooling from file/blob JSON to DB rows. 3. Enable config-driven dual writes before switching reads to DB. 4. Keep blobs as a rollback target until DB reads are verified. + +## Netlify DB schema + +The first DB migration lives in `db/migrations/0001_netlify_db.sql`. It keeps +app-owned IDs for kids, prizes, prize awards, and magic-link token hashes; the +database does not auto-generate user-facing IDs or maintain a separate kid ID +counter table. The DB adapter derives prize `given` counts from `prize_awards` +and builds passport responses from `passport_activities` rows. diff --git a/netlify/functions/api.ts b/netlify/functions/api.ts index 3632c99..d350873 100644 --- a/netlify/functions/api.ts +++ b/netlify/functions/api.ts @@ -2,6 +2,7 @@ import { connectLambda } from '@netlify/blobs'; import { createBlobMagicTokenStore, setMagicTokenStore } from '../../server/access-tokens.js'; import { handleApiRequest } from '../../server/api.js'; import { createBlobStore } from '../../server/blob-store.js'; +import { createDbMagicTokenStore, createDbStore } from '../../server/db-store.js'; import { setStoreAdapter } from '../../server/store.js'; type NetlifyEvent = { @@ -21,6 +22,24 @@ function definedHeaders(headers: NetlifyEvent['headers']) { ); } +function configureStores() { + const storeBackend = process.env.KID_A_STORE_BACKEND ?? 'blob'; + const tokenBackend = process.env.KID_A_TOKEN_BACKEND ?? storeBackend; + + if (storeBackend !== 'blob' && storeBackend !== 'db') { + throw new Error('KID_A_STORE_BACKEND must be blob or db'); + } + + if (tokenBackend !== 'blob' && tokenBackend !== 'db') { + throw new Error('KID_A_TOKEN_BACKEND must be blob or db'); + } + + setStoreAdapter(storeBackend === 'db' ? createDbStore() : createBlobStore()); + setMagicTokenStore( + tokenBackend === 'db' ? createDbMagicTokenStore() : createBlobMagicTokenStore(), + ); +} + export async function handler(event: NetlifyEvent) { if (event.blobs) { connectLambda({ @@ -29,8 +48,7 @@ export async function handler(event: NetlifyEvent) { }); } - setStoreAdapter(createBlobStore()); - setMagicTokenStore(createBlobMagicTokenStore()); + configureStores(); const response = await handleApiRequest({ body: event.body, headers: event.headers, diff --git a/package-lock.json b/package-lock.json index 534b879..d984e6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@netlify/blobs": "^10.7.9", + "@netlify/neon": "^0.1.2", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", "jsqr": "^1.4.0", @@ -296,6 +297,15 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@netlify/blobs": { "version": "10.7.9", "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-10.7.9.tgz", @@ -335,6 +345,15 @@ "node": "^18.14.0 || >=20" } }, + "node_modules/@netlify/neon": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@netlify/neon/-/neon-0.1.2.tgz", + "integrity": "sha512-F8+Dnu+nvGZruskblFwTopv/CUbHi2VOP4bJpDhRo85UFawZ/wMPmxnWQ0CLYwdAERU1OYoQyXOQM19HxAHjQQ==", + "license": "ISC", + "dependencies": { + "@neondatabase/serverless": "1.x" + } + }, "node_modules/@netlify/otel": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.3.tgz", diff --git a/package.json b/package.json index 013319c..61f7b0b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@netlify/blobs": "^10.7.9", + "@netlify/neon": "^0.1.2", "@primer/octicons-react": "^19.28.1", "@types/qrcode": "^1.5.6", "jsqr": "^1.4.0", diff --git a/server/access-tokens.ts b/server/access-tokens.ts index ecbd446..61a3255 100644 --- a/server/access-tokens.ts +++ b/server/access-tokens.ts @@ -23,7 +23,7 @@ export type MagicLinkScope = { role: UserRole; }; -type MagicLinkTokenStore = { +export type MagicLinkTokenStore = { readTokens(): Promise; writeTokens(tokens: MagicLinkTokenRecord[]): Promise; }; diff --git a/server/db-store.ts b/server/db-store.ts new file mode 100644 index 0000000..56f8b56 --- /dev/null +++ b/server/db-store.ts @@ -0,0 +1,722 @@ +import { neon } from '@netlify/neon'; +import type { + NeonQueryFunctionInTransaction, + NeonQueryInTransaction, +} from '@neondatabase/serverless'; +import type { + MagicLinkTokenRecord, + MagicLinkTokenStore, +} from './access-tokens.js'; +import { + KidIdAllocationError, + PrizeOutOfStockError, + syncPrizeGivenCache, + UnknownPrizeError, + type AwardPrizeCommand, + type CompletePassportActivityCommand, + type RegisterKidCommand, + type SavePrizeCommand, + type StoreAdapter, + type StoreFile, + type WritableStoreData, +} from './store.js'; +import type { + ConferenceData, + Kid, + PassportActivitiesByKid, + PassportActivity, + Prize, + PrizeAward, + StoreData, +} from './types.js'; + +type SqlClient = ReturnType; +type TransactionSql = NeonQueryFunctionInTransaction; +type Row = Record; + +const kidRegistrationRetryDelayMs = 250; +const maxKidRegistrationAttempts = 20; +const generatedIdLookahead = 1000; + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function asString(value: unknown, label: string) { + if (typeof value !== 'string') { + throw new Error(`Expected ${label} to be a string`); + } + + return value; +} + +function asOptionalString(value: unknown, label: string) { + if (value === null || value === undefined) { + return undefined; + } + + return asString(value, label); +} + +function asNumber(value: unknown, label: string) { + if (typeof value !== 'number') { + throw new Error(`Expected ${label} to be a number`); + } + + return value; +} + +function asBoolean(value: unknown, label: string) { + if (typeof value !== 'boolean') { + throw new Error(`Expected ${label} to be a boolean`); + } + + return value; +} + +function mapConference(row: Row | undefined): ConferenceData { + if (!row) { + throw new Error('Missing Netlify DB conference_settings row'); + } + + return { + kidIdPrefix: asString(row.kid_id_prefix, 'conference_settings.kid_id_prefix'), + shortName: asString(row.short_name, 'conference_settings.short_name'), + title: asString(row.title, 'conference_settings.title'), + }; +} + +function mapKid(row: Row): Kid { + return { + age: asNumber(row.age, 'kids.age'), + gender: asString(row.gender, 'kids.gender'), + id: asString(row.id, 'kids.id'), + language: asString(row.language, 'kids.language'), + name: asString(row.name, 'kids.name'), + }; +} + +function mapPrize(row: Row): Prize { + return { + given: asNumber(row.given, 'prizes.given'), + id: asString(row.id, 'prizes.id'), + initialUnits: asNumber(row.initial_units, 'prizes.initial_units'), + kind: asString(row.kind, 'prizes.kind') as Prize['kind'], + title: asString(row.title, 'prizes.title'), + }; +} + +function mapPrizeAward(row: Row): PrizeAward { + const source = asOptionalString(row.source, 'prize_awards.source'); + + return { + awardedAt: asString(row.awarded_at, 'prize_awards.awarded_at'), + id: asString(row.id, 'prize_awards.id'), + kidId: asString(row.kid_id, 'prize_awards.kid_id'), + prizeId: asString(row.prize_id, 'prize_awards.prize_id'), + ...(source ? { source: source as PrizeAward['source'] } : {}), + }; +} + +function mapMagicToken(row: Row): MagicLinkTokenRecord { + const activityId = + row.activity_id === null || row.activity_id === undefined + ? undefined + : asNumber(row.activity_id, 'magic_link_tokens.activity_id'); + + return { + ...(activityId ? { activityId } : {}), + createdAt: asString(row.created_at, 'magic_link_tokens.created_at'), + expiresAt: asString(row.expires_at, 'magic_link_tokens.expires_at'), + role: asString(row.role, 'magic_link_tokens.role') as MagicLinkTokenRecord['role'], + tokenHash: asString(row.token_hash, 'magic_link_tokens.token_hash'), + }; +} + +function getPassportTemplate(passportActivitiesByKid: PassportActivitiesByKid) { + return ( + Object.values(passportActivitiesByKid)[0]?.map((activity) => ({ + id: activity.id, + })) ?? [] + ); +} + +function buildPassports(kids: Kid[], passportRows: Row[]): PassportActivitiesByKid { + const passports: PassportActivitiesByKid = {}; + + for (const row of passportRows) { + const kidId = asString(row.kid_id, 'passport_activities.kid_id'); + const activity: PassportActivity = { + id: asNumber(row.activity_id, 'passport_activities.activity_id'), + ...(row.completed_at + ? { + completedAt: asString( + row.completed_at, + 'passport_activities.completed_at', + ), + } + : {}), + }; + + passports[kidId] ??= []; + passports[kidId].push(activity); + } + + for (const passport of Object.values(passports)) { + passport.sort((left, right) => left.id - right.id); + } + + const template = getPassportTemplate(passports); + for (const kid of kids) { + passports[kid.id] ??= template.map((activity) => ({ ...activity })); + } + + return passports; +} + +function prizeResponse(snapshot: StoreData, prizeId?: string) { + return { + prize: prizeId ? snapshot.prizes.find((prize) => prize.id === prizeId) : undefined, + prizeAwards: snapshot.prizeAwards, + prizes: snapshot.prizes, + }; +} + +function passportQueries( + tx: TransactionSql, + kidId: string, + passport: PassportActivity[], +) { + return [ + tx`DELETE FROM passport_activities WHERE kid_id = ${kidId}`, + ...passport.map( + (activity) => tx` + INSERT INTO passport_activities (kid_id, activity_id, completed_at) + VALUES (${kidId}, ${activity.id}, ${activity.completedAt ?? null}::timestamptz) + `, + ), + ]; +} + +function prizeAwardQueries(tx: TransactionSql, prizeAwards: PrizeAward[]) { + return [ + tx`DELETE FROM prize_awards`, + ...prizeAwards.map( + (award) => tx` + INSERT INTO prize_awards (id, kid_id, prize_id, source, awarded_at) + VALUES ( + ${award.id}, + ${award.kidId}, + ${award.prizeId}, + ${award.source ?? null}, + ${award.awardedAt}::timestamptz + ) + ON CONFLICT DO NOTHING + `, + ), + ]; +} + +function prizeQueries(tx: TransactionSql, prizes: Prize[]) { + return [ + tx`DELETE FROM prizes`, + ...prizes.map( + (prize) => tx` + INSERT INTO prizes (id, title, kind, initial_units) + VALUES (${prize.id}, ${prize.title}, ${prize.kind}, ${prize.initialUnits}) + ON CONFLICT (id) DO UPDATE + SET title = EXCLUDED.title, + kind = EXCLUDED.kind, + initial_units = EXCLUDED.initial_units + `, + ), + ]; +} + +async function writeStoreFiles( + sql: SqlClient, + snapshot: StoreData, + changedFiles: readonly StoreFile[], +) { + await sql.transaction((tx) => { + const queries: NeonQueryInTransaction[] = []; + + if (changedFiles.includes('conference')) { + queries.push(tx` + INSERT INTO conference_settings (id, kid_id_prefix, short_name, title) + VALUES ( + 'default', + ${snapshot.conference.kidIdPrefix}, + ${snapshot.conference.shortName}, + ${snapshot.conference.title} + ) + ON CONFLICT (id) DO UPDATE + SET kid_id_prefix = EXCLUDED.kid_id_prefix, + short_name = EXCLUDED.short_name, + title = EXCLUDED.title + `); + } + + if (changedFiles.includes('kids')) { + queries.push(tx`DELETE FROM kids`); + queries.push( + ...snapshot.kids.map( + (kid) => tx` + INSERT INTO kids (id, name, age, gender, language) + VALUES (${kid.id}, ${kid.name}, ${kid.age}, ${kid.gender}, ${kid.language}) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + age = EXCLUDED.age, + gender = EXCLUDED.gender, + language = EXCLUDED.language + `, + ), + ); + } + + if (changedFiles.includes('prizeAwards')) { + queries.push(...prizeAwardQueries(tx, snapshot.prizeAwards)); + } + + if (changedFiles.includes('prizes')) { + queries.push(...prizeQueries(tx, snapshot.prizes)); + } + + if (changedFiles.includes('passportActivitiesByKid')) { + queries.push(tx`DELETE FROM passport_activities`); + for (const [kidId, passport] of Object.entries(snapshot.passportActivitiesByKid)) { + queries.push( + ...passport.map( + (activity) => tx` + INSERT INTO passport_activities (kid_id, activity_id, completed_at) + VALUES ( + ${kidId}, + ${activity.id}, + ${activity.completedAt ?? null}::timestamptz + ) + `, + ), + ); + } + } + + return queries; + }); +} + +async function replacePassport( + sql: SqlClient, + kidId: string, + passport: PassportActivity[], +) { + await sql.transaction((tx) => passportQueries(tx, kidId, passport)); +} + +async function replacePrizeAwardsForKid( + sql: SqlClient, + kidId: string, + prizeAwards: PrizeAward[], +) { + await sql.transaction((tx) => [ + tx`DELETE FROM prize_awards WHERE kid_id = ${kidId}`, + ...prizeAwards.map( + (award) => tx` + INSERT INTO prize_awards (id, kid_id, prize_id, source, awarded_at) + VALUES ( + ${award.id}, + ${award.kidId}, + ${award.prizeId}, + ${award.source ?? null}, + ${award.awardedAt}::timestamptz + ) + ON CONFLICT DO NOTHING + `, + ), + ]); +} + +export function createDbStore(sql: SqlClient = neon()): StoreAdapter { + async function readSnapshot(): Promise { + const [conferenceRows, kidRows, passportRows, prizeRows, prizeAwardRows] = + await Promise.all([ + sql` + SELECT kid_id_prefix, short_name, title + FROM conference_settings + WHERE id = 'default' + `, + sql` + SELECT id, name, age, gender, language + FROM kids + ORDER BY id + `, + sql` + SELECT kid_id, activity_id, completed_at::text AS completed_at + FROM passport_activities + ORDER BY kid_id, activity_id + `, + sql` + SELECT + p.id, + p.title, + p.kind, + p.initial_units, + COALESCE(COUNT(a.id), 0)::integer AS given + FROM prizes p + LEFT JOIN prize_awards a ON a.prize_id = p.id + GROUP BY p.id, p.title, p.kind, p.initial_units + ORDER BY p.id + `, + sql` + SELECT id, kid_id, prize_id, source, awarded_at::text AS awarded_at + FROM prize_awards + ORDER BY awarded_at, id + `, + ]); + + const kids = (kidRows as Row[]).map(mapKid); + + return { + conference: mapConference((conferenceRows as Row[])[0]), + kids, + passportActivitiesByKid: buildPassports(kids, passportRows as Row[]), + prizeAwards: (prizeAwardRows as Row[]).map(mapPrizeAward), + prizes: (prizeRows as Row[]).map(mapPrize), + }; + } + + async function registerKid(command: RegisterKidCommand) { + for (let attempt = 1; attempt <= maxKidRegistrationAttempts; attempt += 1) { + const rows = (await sql` + WITH settings AS ( + SELECT kid_id_prefix + FROM conference_settings + WHERE id = 'default' + ), + bounds AS ( + SELECT (COUNT(*)::integer + 1) AS start_at + FROM kids + ), + candidate AS ( + SELECT + settings.kid_id_prefix || + lpad(candidate_sequence::text, 4, '0') AS id + FROM settings, bounds, + generate_series( + bounds.start_at, + bounds.start_at + ${generatedIdLookahead} + ) AS candidate(candidate_sequence) + WHERE NOT EXISTS ( + SELECT 1 + FROM kids + WHERE lower(kids.id) = lower( + settings.kid_id_prefix || + lpad(candidate_sequence::text, 4, '0') + ) + ) + ORDER BY candidate_sequence + LIMIT 1 + ), + inserted_kid AS ( + INSERT INTO kids (id, name, age, gender, language) + SELECT + candidate.id, + ${command.name}, + ${command.age}, + ${command.gender}, + ${command.language} + FROM candidate + WHERE lower(candidate.id) <> ${command.lastKnownKidId ?? ''} + ON CONFLICT DO NOTHING + RETURNING id, name, age, gender, language + ), + template_kid AS ( + SELECT kid_id + FROM passport_activities + ORDER BY kid_id + LIMIT 1 + ), + inserted_passport AS ( + INSERT INTO passport_activities (kid_id, activity_id) + SELECT inserted_kid.id, passport_activities.activity_id + FROM inserted_kid + JOIN template_kid ON true + JOIN passport_activities + ON passport_activities.kid_id = template_kid.kid_id + ON CONFLICT DO NOTHING + ) + SELECT id, name, age, gender, language + FROM inserted_kid + `) as Row[]; + + if (rows[0]) { + return mapKid(rows[0]); + } + + if (attempt < maxKidRegistrationAttempts) { + await delay(kidRegistrationRetryDelayMs); + } + } + + throw new KidIdAllocationError(); + } + + async function completePassportActivity(command: CompletePassportActivityCommand) { + await sql` + INSERT INTO passport_activities (kid_id, activity_id, completed_at) + VALUES (${command.kidId}, ${command.activityId}, ${command.completedAt}::timestamptz) + ON CONFLICT (kid_id, activity_id) DO UPDATE + SET completed_at = COALESCE( + passport_activities.completed_at, + EXCLUDED.completed_at + ) + `; + + const snapshot = await readSnapshot(); + return snapshot.passportActivitiesByKid[command.kidId] ?? []; + } + + async function savePrize(command: SavePrizeCommand) { + if (command.type === 'create') { + let createdPrizeId: string | undefined; + + for (let attempt = 1; attempt <= maxKidRegistrationAttempts; attempt += 1) { + const rows = (await sql` + WITH bounds AS ( + SELECT (COUNT(*)::integer + 1) AS start_at + FROM prizes + ), + candidate AS ( + SELECT 'prize-' || candidate_sequence::text AS id + FROM bounds, + generate_series( + bounds.start_at, + bounds.start_at + ${generatedIdLookahead} + ) AS candidate(candidate_sequence) + WHERE NOT EXISTS ( + SELECT 1 + FROM prizes + WHERE prizes.id = 'prize-' || candidate_sequence::text + ) + ORDER BY candidate_sequence + LIMIT 1 + ) + INSERT INTO prizes (id, title, kind, initial_units) + SELECT candidate.id, ${command.title}, 'normal', ${command.initialUnits} + FROM candidate + ON CONFLICT DO NOTHING + RETURNING id + `) as Row[]; + + createdPrizeId = asOptionalString(rows[0]?.id, 'prizes.id'); + + if (createdPrizeId) { + const snapshot = await readSnapshot(); + return prizeResponse(snapshot, createdPrizeId); + } + } + + throw new Error('Unable to allocate a fresh prize id'); + } + + const rows = (await sql` + UPDATE prizes + SET title = COALESCE(${command.title ?? null}, title), + kind = COALESCE(${command.prizeKind ?? null}, kind), + initial_units = CASE + WHEN ${command.initialUnits ?? null}::integer IS NULL THEN initial_units + ELSE GREATEST( + ${command.initialUnits ?? null}::integer, + ( + SELECT COUNT(*)::integer + FROM prize_awards + WHERE prize_awards.prize_id = prizes.id + ) + ) + END + WHERE id = ${command.prizeId} + RETURNING id + `) as Row[]; + + if (!rows[0]) { + throw new UnknownPrizeError(command.prizeId); + } + + const snapshot = await readSnapshot(); + return prizeResponse(snapshot, command.prizeId); + } + + async function awardPrize(command: AwardPrizeCommand) { + const source = command.source ?? null; + const rows = (await sql` + WITH locked_prize AS ( + SELECT id, initial_units + FROM prizes + WHERE id = ${command.prizeId} + FOR UPDATE + ), + existing_passport_completion AS ( + SELECT 1 + FROM prize_awards + WHERE ${source} = 'passportCompletion' + AND kid_id = ${command.kidId} + AND source = 'passportCompletion' + ), + inserted AS ( + INSERT INTO prize_awards (id, kid_id, prize_id, source, awarded_at) + SELECT + ${command.awardId}, + ${command.kidId}, + locked_prize.id, + ${source}, + ${command.awardedAt}::timestamptz + FROM locked_prize + WHERE NOT EXISTS (SELECT 1 FROM existing_passport_completion) + AND ( + SELECT COUNT(*)::integer + FROM prize_awards + WHERE prize_awards.prize_id = locked_prize.id + ) < locked_prize.initial_units + ON CONFLICT DO NOTHING + RETURNING id + ) + SELECT + EXISTS (SELECT 1 FROM locked_prize) AS prize_exists, + EXISTS (SELECT 1 FROM existing_passport_completion) AS already_awarded, + (SELECT COUNT(*)::integer FROM inserted) AS inserted_count, + COALESCE((SELECT initial_units FROM locked_prize), 0)::integer AS initial_units, + ( + SELECT COUNT(*)::integer + FROM prize_awards + WHERE prize_id = ${command.prizeId} + ) AS given + `) as Row[]; + const result = rows[0]; + + if (!result || !asBoolean(result.prize_exists, 'prize_exists')) { + throw new UnknownPrizeError(command.prizeId); + } + + const insertedCount = asNumber(result.inserted_count, 'inserted_count'); + const alreadyAwarded = asBoolean(result.already_awarded, 'already_awarded'); + const given = asNumber(result.given, 'given'); + const initialUnits = asNumber(result.initial_units, 'initial_units'); + + if (insertedCount === 0 && !alreadyAwarded && given >= initialUnits) { + throw new PrizeOutOfStockError(command.prizeId); + } + + const snapshot = await readSnapshot(); + return snapshot.prizeAwards.filter((award) => award.kidId === command.kidId); + } + + async function restoreWritableData(data: WritableStoreData) { + const snapshot = await readSnapshot(); + await writeStoreFiles( + sql, + { + ...snapshot, + passportActivitiesByKid: data.passportActivitiesByKid, + prizeAwards: data.prizeAwards, + prizes: syncPrizeGivenCache(data.prizes, data.prizeAwards), + }, + ['passportActivitiesByKid', 'prizeAwards', 'prizes'], + ); + + return readSnapshot(); + } + + async function updatePassportForKid( + kidId: string, + mutator: (snapshot: StoreData) => T | Promise, + ) { + const snapshot = await readSnapshot(); + const result = await mutator(snapshot); + await replacePassport(sql, kidId, snapshot.passportActivitiesByKid[kidId] ?? []); + return result; + } + + async function updatePrizeAwardsForKid( + kidId: string, + mutator: (snapshot: StoreData) => T | Promise, + ) { + const snapshot = await readSnapshot(); + const result = await mutator(snapshot); + await replacePrizeAwardsForKid( + sql, + kidId, + snapshot.prizeAwards.filter((award) => award.kidId === kidId), + ); + return result; + } + + async function updateSnapshot( + mutator: (snapshot: StoreData) => T | Promise, + changedFiles: readonly StoreFile[], + ) { + const snapshot = await readSnapshot(); + const result = await mutator(snapshot); + await writeStoreFiles(sql, snapshot, changedFiles); + return result; + } + + return { + awardPrize, + completePassportActivity, + readSnapshot, + registerKid, + restoreWritableData, + savePrize, + updatePassportForKid, + updatePrizeAwardsForKid, + updateSnapshot, + }; +} + +export function createDbMagicTokenStore(sql: SqlClient = neon()) { + return { + async readTokens() { + const rows = (await sql` + SELECT + token_hash, + role, + activity_id, + created_at::text AS created_at, + expires_at::text AS expires_at + FROM magic_link_tokens + ORDER BY created_at + `) as Row[]; + + return rows.map(mapMagicToken); + }, + async writeTokens(tokens: MagicLinkTokenRecord[]) { + await sql.transaction((tx) => [ + tx`DELETE FROM magic_link_tokens`, + ...tokens.map( + (token) => tx` + INSERT INTO magic_link_tokens ( + token_hash, + role, + activity_id, + created_at, + expires_at + ) + VALUES ( + ${token.tokenHash}, + ${token.role}, + ${token.activityId ?? null}, + ${token.createdAt}::timestamptz, + ${token.expiresAt}::timestamptz + ) + ON CONFLICT (token_hash) DO UPDATE + SET role = EXCLUDED.role, + activity_id = EXCLUDED.activity_id, + created_at = EXCLUDED.created_at, + expires_at = EXCLUDED.expires_at + `, + ), + ]); + }, + } satisfies MagicLinkTokenStore; +}