Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions db/migrations/0001_netlify_db.sql
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 11 additions & 0 deletions docs/storage-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
22 changes: 20 additions & 2 deletions netlify/functions/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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({
Expand All @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion server/access-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type MagicLinkScope = {
role: UserRole;
};

type MagicLinkTokenStore = {
export type MagicLinkTokenStore = {
readTokens(): Promise<MagicLinkTokenRecord[]>;
writeTokens(tokens: MagicLinkTokenRecord[]): Promise<void>;
};
Expand Down
Loading
Loading