diff --git a/netlify/database/migrations/0003_drop_prize_award_source.sql b/netlify/database/migrations/0003_drop_prize_award_source.sql new file mode 100644 index 0000000..586586d --- /dev/null +++ b/netlify/database/migrations/0003_drop_prize_award_source.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS prize_awards_passport_completion_per_kid_idx; + +ALTER TABLE prize_awards + DROP COLUMN IF EXISTS source; diff --git a/server/api.ts b/server/api.ts index 1ce3683..3373c9a 100644 --- a/server/api.ts +++ b/server/api.ts @@ -22,7 +22,6 @@ import type { PassportActivity, Prize, PrizeAward, - PrizeAwardSource, PrizeKind, StoreData, UserRole, @@ -74,6 +73,7 @@ const kidGenders = new Set(['boy', 'girl', 'preferNotToSay']); const prizeKinds = new Set(['final', 'normal', 'valuable']); const staffRoles = new Set(['desk', 'lead', 'wheel']); const supportedLocales = new Set(['en', 'es']); +const normalPrizeKinds = new Set(['normal', 'valuable']); export function normalizeApiPath(pathname: string) { if (pathname.startsWith('/.netlify/functions/api/')) { @@ -362,8 +362,13 @@ function getPassportWheelShotSummary(snapshot: StoreData, kidId: string) { Math.max(passportActivities.length - 1, 0), ); const earnedShots = Math.floor(spinEligibleActivities / 4); + const finalPrizeIds = new Set( + snapshot.prizes + .filter((prize) => prize.kind === 'final') + .map((prize) => prize.id), + ); const usedShots = snapshot.prizeAwards.filter( - (award) => award.kidId === kidId && award.source === 'wheel', + (award) => award.kidId === kidId && !finalPrizeIds.has(award.prizeId), ).length; return { @@ -544,14 +549,11 @@ function normalizeBackupPrizes(value: unknown): Prize[] { function normalizeBackupPrizeAwards(value: unknown): PrizeAward[] { return asArray(value, 'prizesWon').map((entry, index) => { const award = asObject(entry, `prizesWon.${index}`); - const source = normalizeAwardSource(award.source); - return { awardedAt: asString(award.awardedAt, `prizesWon.${index}.awardedAt`), id: asString(award.id, `prizesWon.${index}.id`), kidId: asString(award.kidId, `prizesWon.${index}.kidId`), prizeId: asString(award.prizeId, `prizesWon.${index}.prizeId`), - ...(source ? { source } : {}), }; }); } @@ -861,7 +863,7 @@ async function handleWheelPrizes( const stock = url.searchParams.get('stock')?.trim(); if (!stock) { - if (typeof body.title !== 'string' || !body.title.trim()) { + if (typeof body.title !== 'string') { throw new HttpError(400, 'title is required when stock is omitted'); } @@ -892,8 +894,15 @@ async function handleWheelPrizes( const title = body.title === undefined ? prize.title : String(body.title).trim(); - if (!title) { - throw new HttpError(400, 'title cannot be empty'); + if (!title && prize.given === 0) { + return jsonResponse( + request, + 200, + await savePrize({ + prizeId: stock, + type: 'deleteIfUnawarded', + }), + ); } try { @@ -917,18 +926,6 @@ async function handleWheelPrizes( throw error; } } -function normalizeAwardSource(value: unknown) { - if (value === undefined || value === null || value === '') { - return undefined; - } - - if (value !== 'passportCompletion' && value !== 'wheel') { - throw new HttpError(400, 'source must be passportCompletion or wheel'); - } - - return value as PrizeAwardSource; -} - async function handlePrizesKid( request: ApiRequest, url: URL, @@ -951,7 +948,6 @@ async function handlePrizesKid( await requireMagicLink(request, url, ['wheel']); - const body = parseJsonBody(request.body); const stock = url.searchParams.get('stock')?.trim(); if (!stock) { @@ -960,15 +956,23 @@ async function handlePrizesKid( const snapshot = await readSnapshot(); const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); - const source = normalizeAwardSource(body.source); + const prize = snapshot.prizes.find((entry) => entry.id === stock); + const awardKind = prize?.kind === 'final' ? 'passport-complete' : 'wheel'; + + if (prize && normalPrizeKinds.has(prize.kind)) { + const shotSummary = getPassportWheelShotSummary(snapshot, kidId); + + if (shotSummary.availableShots <= 0) { + throw new HttpError(409, `Kid has no wheel shots available: ${kidId}`); + } + } try { const response = await awardPrize({ - awardId: `${kidId}-${source === 'passportCompletion' ? 'passport-complete' : 'wheel'}-${randomUUID()}`, + awardId: `${kidId}-${awardKind}-${randomUUID()}`, awardedAt: new Date().toISOString(), kidId, prizeId: stock, - ...(source ? { source } : {}), }); return jsonResponse(request, 200, response); diff --git a/server/db-bootstrap.ts b/server/db-bootstrap.ts index 6a277b9..b26816a 100644 --- a/server/db-bootstrap.ts +++ b/server/db-bootstrap.ts @@ -52,6 +52,33 @@ function splitSqlStatements(sqlText: string) { .filter(Boolean); } +function isLegacyPrizeAwardSourceIndex(statement: string) { + return statement.includes('prize_awards_passport_completion_per_kid_idx') && + statement.includes('WHERE source'); +} + +async function hasPrizeAwardSourceColumn(sql: SqlClient) { + const rows = (await sql` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'prize_awards' + AND column_name = 'source' + ) AS has_source + `) as Array<{ has_source: boolean }>; + + return rows[0]?.has_source === true; +} + +async function shouldApplySchemaStatement(sql: SqlClient, statement: string) { + if (!isLegacyPrizeAwardSourceIndex(statement)) { + return true; + } + + return hasPrizeAwardSourceColumn(sql); +} + export async function applyDbSchema(sql: SqlClient = createSqlClient()) { const migrationFiles = (await readdir(migrationsDir)) .filter((fileName) => fileName.endsWith('.sql')) @@ -61,6 +88,10 @@ export async function applyDbSchema(sql: SqlClient = createSqlClient()) { const sqlText = await readFile(path.join(migrationsDir, migrationFile), 'utf8'); for (const statement of splitSqlStatements(sqlText)) { + if (!(await shouldApplySchemaStatement(sql, statement))) { + continue; + } + await sql.query(statement); } } diff --git a/server/db-store.ts b/server/db-store.ts index 2eb4477..c31352c 100644 --- a/server/db-store.ts +++ b/server/db-store.ts @@ -196,14 +196,11 @@ function mapPrize(row: Row): Prize { } 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'] } : {}), }; } @@ -292,12 +289,11 @@ function prizeAwardQueries(tx: TransactionSql, prizeAwards: PrizeAward[]) { tx`DELETE FROM prize_awards`, ...prizeAwards.map( (award) => tx` - INSERT INTO prize_awards (id, kid_id, prize_id, source, awarded_at) + INSERT INTO prize_awards (id, kid_id, prize_id, awarded_at) VALUES ( ${award.id}, ${award.kidId}, ${award.prizeId}, - ${award.source ?? null}, ${award.awardedAt}::timestamptz ) ON CONFLICT DO NOTHING @@ -352,10 +348,10 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter 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 + ORDER BY p.created_at, p.id `, sql` - SELECT id, kid_id, prize_id, source, awarded_at::text AS awarded_at + SELECT id, kid_id, prize_id, awarded_at::text AS awarded_at FROM prize_awards ORDER BY awarded_at, id `, @@ -491,6 +487,21 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter throw new Error('Unable to allocate a fresh prize id'); } + if (command.type === 'deleteIfUnawarded') { + await sql` + DELETE FROM prizes + WHERE id = ${command.prizeId} + AND NOT EXISTS ( + SELECT 1 + FROM prize_awards + WHERE prize_awards.prize_id = prizes.id + ) + `; + + const snapshot = await readSnapshot(); + return prizeResponse(snapshot); + } + const rows = (await sql` UPDATE prizes SET title = COALESCE(${command.title ?? null}, title), @@ -519,28 +530,27 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter } async function awardPrize(command: AwardPrizeCommand) { - const source = command.source ?? null; const rows = (await sql` WITH locked_prize AS ( - SELECT id, initial_units + SELECT id, kind, 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' + FROM locked_prize, prize_awards existing_award + JOIN prizes awarded_prize ON awarded_prize.id = existing_award.prize_id + WHERE locked_prize.kind = 'final' + AND existing_award.kid_id = ${command.kidId} + AND awarded_prize.kind = 'final' ), inserted AS ( - INSERT INTO prize_awards (id, kid_id, prize_id, source, awarded_at) + INSERT INTO prize_awards (id, kid_id, prize_id, 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) diff --git a/server/store.ts b/server/store.ts index 5cea215..bc74137 100644 --- a/server/store.ts +++ b/server/store.ts @@ -28,6 +28,10 @@ export type SavePrizeCommand = prizeKind?: Prize['kind']; title?: string; type: 'update'; + } + | { + prizeId: string; + type: 'deleteIfUnawarded'; }; export type PrizeMutationResult = { @@ -41,7 +45,6 @@ export type AwardPrizeCommand = { awardedAt: string; kidId: string; prizeId: string; - source?: PrizeAward['source']; }; export type WritableStoreData = Pick< diff --git a/server/types.ts b/server/types.ts index 91ae63b..7e87719 100644 --- a/server/types.ts +++ b/server/types.ts @@ -29,14 +29,11 @@ export type Prize = { title: string; }; -export type PrizeAwardSource = 'passportCompletion' | 'wheel'; - export type PrizeAward = { awardedAt: string; id: string; kidId: string; prizeId: string; - source?: PrizeAwardSource; }; export type UserRole = 'desk' | 'lead' | 'wheel'; diff --git a/src/contexts/DataLayerContext.tsx b/src/contexts/DataLayerContext.tsx index 877dc31..18653ae 100644 --- a/src/contexts/DataLayerContext.tsx +++ b/src/contexts/DataLayerContext.tsx @@ -20,7 +20,6 @@ import { clonePrizes, getPrizeGiven, getPrizeRemaining, - isWheelAward, syncPrizeGivenCache, type Activity, type ConferenceData, @@ -88,7 +87,7 @@ type DataLayerContextValue = { accessSessionStatus: AccessSessionStatus; activities: Activity[]; addRegisteredKid: (registration: RegistrationInput) => Promise; - addPrize: (title: string) => Prize; + addPrize: (title?: string) => Prize; awardPassportCompletionPrize: (kidId: string) => PrizeAward; awardPrizeToKid: (kidId: string, prizeId: string) => PrizeAward; conference: ConferenceData; @@ -316,6 +315,16 @@ export function DataLayerProvider({ children }: PropsWithChildren) { ...clonePrizeAwards(awards), ]); }; + const applyRemoteWheelShotAward = (kidId: string, summary: WheelShotSummary) => { + setRemoteWheelShotSummariesByKid((currentSummaries) => ({ + ...currentSummaries, + [kidId]: { + availableShots: Math.max(summary.availableShots - 1, 0), + earnedShots: summary.earnedShots, + usedShots: summary.usedShots + 1, + }, + })); + }; useEffect(() => { const token = getActiveMagicLinkToken(); @@ -463,13 +472,8 @@ export function DataLayerProvider({ children }: PropsWithChildren) { return registeredKid; }; - const addPrize = (title: string) => { + const addPrize = (title = '') => { const trimmedTitle = title.trim(); - - if (!trimmedTitle) { - throw new Error('Prize title cannot be empty'); - } - const createdPrize: Prize = { given: 0, id: createPrizeId(prizeList), @@ -562,12 +566,16 @@ export function DataLayerProvider({ children }: PropsWithChildren) { const remoteSummary = remoteWheelShotSummariesByKid[kidId]; if (isRemoteDataLayer && remoteSummary) { + const usedShots = Math.max( + remoteSummary.usedShots, + awards.filter((award) => award.prizeKind !== 'final').length, + ); return { - ...remoteSummary, + availableShots: Math.max(remoteSummary.earnedShots - usedShots, 0), awards, - completionAward: awards.find( - (award) => award.source === 'passportCompletion', - ), + completionAward: awards.find((award) => award.prizeKind === 'final'), + earnedShots: remoteSummary.earnedShots, + usedShots, }; } @@ -576,11 +584,9 @@ export function DataLayerProvider({ children }: PropsWithChildren) { return { availableShots: 0, awards, - completionAward: awards.find( - (award) => award.source === 'passportCompletion', - ), + completionAward: awards.find((award) => award.prizeKind === 'final'), earnedShots: 0, - usedShots: awards.filter(isWheelAward).length, + usedShots: awards.filter((award) => award.prizeKind !== 'final').length, }; } @@ -595,14 +601,12 @@ export function DataLayerProvider({ children }: PropsWithChildren) { Math.max(kidActivities.length - 1, 0), ); const earnedShots = Math.floor(spinEligibleActivities / 4); - const usedShots = awards.filter(isWheelAward).length; + const usedShots = awards.filter((award) => award.prizeKind !== 'final').length; return { availableShots: Math.max(earnedShots - usedShots, 0), awards, - completionAward: awards.find( - (award) => award.source === 'passportCompletion', - ), + completionAward: awards.find((award) => award.prizeKind === 'final'), earnedShots, usedShots, }; @@ -800,13 +804,18 @@ export function DataLayerProvider({ children }: PropsWithChildren) { ); if (isRemoteDataLayer) { + applyRemoteWheelShotAward(kidId, shotSummary); saveRemotePrizeAward(kidId, prizeId) .then((awards) => { applyRemotePrizeAwards(kidId, awards); + loadRemotePassportForKid(kidId); refreshPrizes(); }) .catch((error) => { console.error('Unable to save remote prize award.', error); + loadRemotePrizeAwardsForKid(kidId); + loadRemotePassportForKid(kidId); + refreshPrizes(); }); } @@ -827,20 +836,25 @@ export function DataLayerProvider({ children }: PropsWithChildren) { throw new Error(`Passport is not complete: ${kidId}`); } + const prize = prizes.find((entry) => entry.kind === 'final'); + + if (!prize) { + throw new Error('No final prize configured'); + } + + const finalPrizeIds = new Set( + prizes + .filter((entry) => entry.kind === 'final') + .map((entry) => entry.id), + ); const existingAward = prizeAwards.find( - (award) => award.kidId === kidId && award.source === 'passportCompletion', + (award) => award.kidId === kidId && finalPrizeIds.has(award.prizeId), ); if (existingAward) { return existingAward; } - const prize = prizes.find((entry) => entry.kind === 'final'); - - if (!prize) { - throw new Error('No final prize configured'); - } - if (getPrizeRemaining(prize) <= 0) { throw new Error(`Prize is out of stock: ${prize.id}`); } @@ -850,7 +864,6 @@ export function DataLayerProvider({ children }: PropsWithChildren) { id: `${kidId}-passport-complete-${Date.now()}`, kidId, prizeId: prize.id, - source: 'passportCompletion', }; const nextPrizeAwards = [...prizeAwards, award]; @@ -864,7 +877,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { ); if (isRemoteDataLayer) { - saveRemotePrizeAward(kidId, prize.id, 'passportCompletion') + saveRemotePrizeAward(kidId, prize.id) .then((awards) => { applyRemotePrizeAwards(kidId, awards); refreshPrizes(); @@ -888,23 +901,24 @@ export function DataLayerProvider({ children }: PropsWithChildren) { } const title = updates.title ?? prize.title; + const given = getPrizeGiven(prizeAwards, prizeId); const initialUnits = Math.max( normalizePrizeCount(updates.initialUnits ?? prize.initialUnits), - getPrizeGiven(prizeAwards, prizeId), + given, ); - if (!title.trim()) { - throw new Error(`Prize title cannot be empty: ${prizeId}`); + if (!title.trim() && given === 0) { + return undefined; } return { ...prize, - given: getPrizeGiven(prizeAwards, prizeId), + given, initialUnits, kind: updates.kind ?? prize.kind, title: title.trim(), }; - }); + }).filter((prize): prize is Prize => prize !== undefined); }); if (isRemoteDataLayer) { diff --git a/src/data/data-model.ts b/src/data/data-model.ts index f878fc2..332aa30 100644 --- a/src/data/data-model.ts +++ b/src/data/data-model.ts @@ -37,14 +37,11 @@ export type Prize = { }; export type PrizeSettingsUpdate = Partial>; -export type PrizeAwardSource = 'passportCompletion' | 'wheel'; - export type PrizeAward = { awardedAt: string; id: string; kidId: string; prizeId: string; - source?: PrizeAwardSource; }; export type PrizeAwardRecord = PrizeAward & { @@ -123,10 +120,6 @@ export function getPrizeGiven(prizeAwards: PrizeAward[], prizeId: string) { return prizeAwards.filter((award) => award.prizeId === prizeId).length; } -export function isWheelAward(award: PrizeAward) { - return (award.source ?? 'wheel') === 'wheel'; -} - export function syncPrizeGivenCache( prizes: Prize[], prizeAwards: PrizeAward[], diff --git a/src/data/prizeAwards.json b/src/data/prizeAwards.json index aa4c19d..14205cd 100644 --- a/src/data/prizeAwards.json +++ b/src/data/prizeAwards.json @@ -3,21 +3,18 @@ "id": "26OSK0003-wheel-1", "kidId": "26OSK0003", "prizeId": "stickers", - "awardedAt": "2026-06-13T09:30:00.000Z", - "source": "wheel" + "awardedAt": "2026-06-13T09:30:00.000Z" }, { "id": "26OSK0003-wheel-2", "kidId": "26OSK0003", "prizeId": "badges", - "awardedAt": "2026-06-13T10:05:00.000Z", - "source": "wheel" + "awardedAt": "2026-06-13T10:05:00.000Z" }, { "id": "26OSK0003-wheel-3", "kidId": "26OSK0003", "prizeId": "notebook", - "awardedAt": "2026-06-13T10:42:00.000Z", - "source": "wheel" + "awardedAt": "2026-06-13T10:42:00.000Z" } ] diff --git a/src/data/remote-data-client.ts b/src/data/remote-data-client.ts index fd3ce52..25daba2 100644 --- a/src/data/remote-data-client.ts +++ b/src/data/remote-data-client.ts @@ -3,7 +3,6 @@ import type { PassportData, Prize, PrizeAward, - PrizeAwardSource, PrizeSettingsUpdate, UserRole, } from './data-model'; @@ -273,7 +272,6 @@ export async function saveRemotePrize( export async function saveRemotePrizeAward( kidId: string, prizeId: string, - source?: PrizeAwardSource, ) { const response = await fetch( buildApiUrl( @@ -282,7 +280,6 @@ export async function saveRemotePrizeAward( )}`, ), { - body: JSON.stringify({ source }), headers: { 'Content-Type': 'application/json', ...magicLinkRequestHeaders(), diff --git a/src/pages/WheelPage.tsx b/src/pages/WheelPage.tsx index c704615..535f0a7 100644 --- a/src/pages/WheelPage.tsx +++ b/src/pages/WheelPage.tsx @@ -193,7 +193,7 @@ export function WheelPage() { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setIsPrizeManagerOpen(false); + closePrizeManager(); } }; @@ -533,9 +533,15 @@ export function WheelPage() { setManagementError(''); }; const addManagedPrize = () => { - addPrize(t('wheel.manage.newPrize')); + addPrize(); setManagementError(''); }; + const closePrizeManager = () => { + prizes + .filter((prize) => !prize.title.trim() && prize.given === 0) + .forEach((prize) => updatePrize(prize.id, { title: '' })); + setIsPrizeManagerOpen(false); + }; if (accessSessionStatus.state === 'loading' || currentUser.role !== 'wheel') { return null; @@ -715,7 +721,7 @@ export function WheelPage() {
setIsPrizeManagerOpen(false)} + onClick={closePrizeManager} >
setIsPrizeManagerOpen(false)} + onClick={closePrizeManager} > {t('wheel.manage.close')} @@ -763,11 +769,6 @@ export function WheelPage() { { - if (!event.target.value.trim()) { - setManagementError(t('wheel.manage.error.title')); - return; - } - updateManagedPrize(prize.id, { title: event.target.value, });