From 80043205f41fa1792c13f9674352736cf1f652e6 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 11:39:15 +0200 Subject: [PATCH 1/5] Fix wheel shot and prize stock handling Ensure wheel prize awards consume available shots by deriving usage from non-final prize awards instead of a stored source column. Allow stock management to create blank prize rows and delete empty unawarded prizes when saved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../0002_drop_prize_award_source.sql | 4 ++ server/api.ts | 43 +++++------ server/db-store.ts | 40 +++++++---- server/store.ts | 5 +- server/types.ts | 3 - src/contexts/DataLayerContext.tsx | 71 ++++++++++--------- src/data/data-model.ts | 7 -- src/data/prizeAwards.json | 9 +-- src/data/remote-data-client.ts | 3 - src/pages/WheelPage.tsx | 19 ++--- 10 files changed, 103 insertions(+), 101 deletions(-) create mode 100644 netlify/database/migrations/0002_drop_prize_award_source.sql diff --git a/netlify/database/migrations/0002_drop_prize_award_source.sql b/netlify/database/migrations/0002_drop_prize_award_source.sql new file mode 100644 index 0000000..586586d --- /dev/null +++ b/netlify/database/migrations/0002_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..3c7afc8 100644 --- a/server/api.ts +++ b/server/api.ts @@ -22,7 +22,6 @@ import type { PassportActivity, Prize, PrizeAward, - PrizeAwardSource, PrizeKind, StoreData, UserRole, @@ -362,8 +361,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 +548,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 +862,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 +893,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 +925,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 +947,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 +955,15 @@ 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'; 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-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..f20e5ee 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), @@ -565,9 +569,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { return { ...remoteSummary, awards, - completionAward: awards.find( - (award) => award.source === 'passportCompletion', - ), + completionAward: awards.find((award) => award.prizeKind === 'final'), }; } @@ -576,11 +578,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 +595,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,9 +798,11 @@ export function DataLayerProvider({ children }: PropsWithChildren) { ); if (isRemoteDataLayer) { + applyRemoteWheelShotAward(kidId, shotSummary); saveRemotePrizeAward(kidId, prizeId) .then((awards) => { applyRemotePrizeAwards(kidId, awards); + loadRemotePassportForKid(kidId); refreshPrizes(); }) .catch((error) => { @@ -827,20 +827,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 +855,6 @@ export function DataLayerProvider({ children }: PropsWithChildren) { id: `${kidId}-passport-complete-${Date.now()}`, kidId, prizeId: prize.id, - source: 'passportCompletion', }; const nextPrizeAwards = [...prizeAwards, award]; @@ -864,7 +868,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 +892,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, }); From 3858a4803dbc348223b9d8c12540c803b5c0c270 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 11:46:51 +0200 Subject: [PATCH 2/5] Guard remote wheel awards by available shots Merge remote shot summaries with persisted award counts so reloads cannot show previous awards with a zero used-shot counter. Reject remote wheel award requests when the kid has no shots left and refresh remote state if a save fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- server/api.ts | 9 +++++++++ src/contexts/DataLayerContext.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/api.ts b/server/api.ts index 3c7afc8..3373c9a 100644 --- a/server/api.ts +++ b/server/api.ts @@ -73,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/')) { @@ -958,6 +959,14 @@ async function handlePrizesKid( 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}-${awardKind}-${randomUUID()}`, diff --git a/src/contexts/DataLayerContext.tsx b/src/contexts/DataLayerContext.tsx index f20e5ee..18653ae 100644 --- a/src/contexts/DataLayerContext.tsx +++ b/src/contexts/DataLayerContext.tsx @@ -566,10 +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.prizeKind === 'final'), + earnedShots: remoteSummary.earnedShots, + usedShots, }; } @@ -807,6 +813,9 @@ export function DataLayerProvider({ children }: PropsWithChildren) { }) .catch((error) => { console.error('Unable to save remote prize award.', error); + loadRemotePrizeAwardsForKid(kidId); + loadRemotePassportForKid(kidId); + refreshPrizes(); }); } From 4bd904303905384f6bc8e2f25a732189e9cb9ccb Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 11:55:04 +0200 Subject: [PATCH 3/5] Renumber prize award source migration Move the prize_awards.source drop migration to 0003 so it no longer conflicts with the existing 0002 passport cleanup migration during Netlify Database migration validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...op_prize_award_source.sql => 0003_drop_prize_award_source.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename netlify/database/migrations/{0002_drop_prize_award_source.sql => 0003_drop_prize_award_source.sql} (100%) diff --git a/netlify/database/migrations/0002_drop_prize_award_source.sql b/netlify/database/migrations/0003_drop_prize_award_source.sql similarity index 100% rename from netlify/database/migrations/0002_drop_prize_award_source.sql rename to netlify/database/migrations/0003_drop_prize_award_source.sql From 0b661056f34b9a76f7518efb18f1fcfdc17a916f Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 11:58:18 +0200 Subject: [PATCH 4/5] Remove source from base prize award schema Keep the replayed Netlify DB bootstrap schema consistent with the later migration that drops prize_awards.source, so cold starts do not recreate source-dependent indexes after the column is gone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- netlify/database/migrations/0001_netlify_db.sql | 5 ----- 1 file changed, 5 deletions(-) diff --git a/netlify/database/migrations/0001_netlify_db.sql b/netlify/database/migrations/0001_netlify_db.sql index 9cac60b..2f71b1c 100644 --- a/netlify/database/migrations/0001_netlify_db.sql +++ b/netlify/database/migrations/0001_netlify_db.sql @@ -33,7 +33,6 @@ 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 ); @@ -54,9 +53,5 @@ CREATE INDEX IF NOT EXISTS prize_awards_kid_id_idx 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); From b1c21f9204ce5a5480dc1b8cdcdd26dc875701c4 Mon Sep 17 00:00:00 2001 From: pablonete Date: Fri, 26 Jun 2026 12:00:36 +0200 Subject: [PATCH 5/5] Keep applied DB migration immutable Restore 0001_netlify_db.sql to its original contents and move the source-column compatibility handling into DB bootstrap, skipping only the legacy source-based index when the source column has already been dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../database/migrations/0001_netlify_db.sql | 5 +++ server/db-bootstrap.ts | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/netlify/database/migrations/0001_netlify_db.sql b/netlify/database/migrations/0001_netlify_db.sql index 2f71b1c..9cac60b 100644 --- a/netlify/database/migrations/0001_netlify_db.sql +++ b/netlify/database/migrations/0001_netlify_db.sql @@ -33,6 +33,7 @@ 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 ); @@ -53,5 +54,9 @@ CREATE INDEX IF NOT EXISTS prize_awards_kid_id_idx 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/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); } }