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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ dist-ssr
*.local
.DS_Store
*.tsbuildinfo

# Local Netlify folder
.netlify
54 changes: 22 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,44 +37,34 @@ runs linting, builds the app, uploads the Pages artifact, and deploys from

### Alternate Node/Netlify deployment

The static GitHub Pages deployment remains unchanged. For a stateful local Node
deployment, build both the frontend and server, then run the compiled HTTP
server:
The static GitHub Pages deployment remains unchanged. For stateful local
development, install the Netlify CLI and run Netlify Dev so functions and
Netlify Database use the same runtime model as deploys:

```bash
npm run build:node
npm run start:node
netlify dev
```

`build:node` and `build:netlify` build the frontend with `VITE_DATA_LAYER=remote`,
`VITE_BASE_PATH=/`, and `VITE_API_BASE_URL=/api`, so the same React app uses the
Node endpoints instead of bundled mutable sample data. The Node server serves
`dist` with SPA fallback and exposes JSON endpoints at `/api/passport`,
`/api/kids`, `/api/wheel-prizes`, and `/api/prizes-kid`. It stores writable event data in
`server/data`, seeded from `src/data` when files are missing. Set
`KID_A_DATA_DIR` to use a different local data directory.
`build:netlify` builds the frontend with `VITE_DATA_LAYER=remote`,
`VITE_BASE_PATH=/`, and `VITE_API_BASE_URL=/api`, so the same React app uses
function endpoints instead of bundled mutable sample data.

`netlify.toml` also routes those endpoints to `netlify/functions/api.ts`.
Netlify Functions use Netlify Blobs for durable production writes while keeping
the same frontend API contract. On first read, the blob store is seeded from the
committed JSON data in `server/data` or `src/data`. Passports are stored as one
blob per kid at `passports/{kidId}.json`, so completing an activity only writes
that kid's passport. Prize catalog settings are stored in one shared blob, and
prize awards are stored by kid and exposed through kid-scoped API responses.

The default blob store name is `kid-a-data`. Set `KID_A_BLOBS_STORE` in Netlify
to use a different store name. Netlify automatically provides the Blobs runtime
context to the function; local Node deployments continue to use `server/data`
and `KID_A_DATA_DIR`. Staff magic-link tokens are role-scoped and stored as
SHA-256 hashes in
the same blob store under `admin/magic-tokens.json`, or in
`server/data/magicTokens.json` for local Node. Set `ADMIN_PASSWORD` to enable
the `/admin` page to generate 1-day desk, wheel, or activity-specific lead
links by default; the duration in days can be changed when generating a link. The
`build:gh-pages` static deployment still uses bundled sample data and does not
call the remote endpoints; it exposes built-in demo links for the same roles.
See [`docs/storage-json.md`](docs/storage-json.md) for the JSON storage layout
and concurrency notes.
Netlify Functions use Netlify DB/Postgres for writable state and staff
magic-link token hashes. Netlify applies migrations from
`netlify/database/migrations`, and the function seeds an empty DB from committed
JSON in `src/data`. To force a non-production reset later, run:

```bash
NETLIFY_DB_URL=... npm run data:reset-db
```

Set `ADMIN_PASSWORD` to enable the `/admin` page to generate 1-day desk, wheel,
or activity-specific lead links by default; the duration in days can be changed
when generating a link. The `build:gh-pages` static deployment still uses
bundled sample data and does not call the remote endpoints; it exposes built-in
demo links for the same roles. See [`docs/storage-json.md`](docs/storage-json.md)
for the DB storage layout.

Set `KID_A_ADMIN_TOKEN` to enable protected admin backup and restore endpoints.
The export includes `exportedAt`, `passports`, `wheelPrizes`, and `prizesWon`.
Expand Down
87 changes: 21 additions & 66 deletions docs/storage-json.md
Original file line number Diff line number Diff line change
@@ -1,77 +1,32 @@
# Storage JSON reference
# Storage reference

This app has two writable storage backends:
The app uses Netlify DB/Postgres as the only writable server-side storage
backend. On startup, the server applies DB migrations and seeds an empty DB from
committed JSON files in `src/data`.

- Local Node deployments use JSON files in `server/data`, seeded from `src/data`
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`.
- Netlify deployments can read from one backend and dual-write to Blobs and DB
by setting `KID_A_STORE_BACKEND=dual`. `KID_A_STORE_READ=blob` keeps Blob
reads while shadow-writing DB; `KID_A_STORE_READ=db` reads from DB instead.
Magic-link tokens always follow the same backend mode as app data.
## Runtime data

Netlify Blobs are optimized for reads and infrequent writes. Avoid
read-modify-write on shared JSON blobs for high-frequency event data because
same-key writes are last-write-wins and reads may be stale.

Server code should write through the command-oriented store methods in
`server/store.ts` instead of calling generic snapshot mutators from API
handlers. This keeps IDs and timestamps explicit where needed and prepares the
app for DB and dual-write backends.

## Current documents

| Data | Local Node JSON | Netlify Blob key | Pattern | Notes |
| --- | --- | --- | --- | --- |
| Conference settings | `server/data/conference.json` | `conference.json` | Common document | Static event metadata such as title and kid ID prefix. |
| Kids | `server/data/kids.json` | `kids.json` | Common document | Writable today. Registration sends the client's last known kid ID and retries if a stale read would reuse it. Future work should move this to per-kid blobs. |
| Passport activities | `server/data/passportActivities.json` | `passports/{kidId}.json` | Per kid | Netlify writes only the changed kid passport. The local JSON remains an aggregate map keyed by kid ID. |
| Wheel prize catalog | `server/data/wheel-prizes.json` | `wheel-prizes.json` | Common document | Shared prize settings and stock cache. Writes are infrequent admin/wheel operations. |
| Prize awards | `server/data/prizes-won.json` | `prizes-kid/{kidId}.json` | Per kid | `/prizes-kid` writes only the selected kid's awards. Netlify still reads legacy `prizes-won.json` for seed/admin compatibility and dedupes by award ID. |
| Magic link tokens | `server/data/magicTokens.json` | `admin/magic-tokens.json` | Common document | Stores SHA-256 token hashes and role scopes. |
| Seed marker | N/A | `seeded-v1.json` | Common marker | Marks that the Netlify blob store was seeded. |

## API write patterns

| Endpoint | Writes | Current pattern |
| Data | DB table | Notes |
| --- | --- | --- |
| `POST /kids` | Kids and initial passport | Common `kids.json` plus per-kid passport. Uses `lastKnownKidId` retry to avoid reusing the ID seen by the client. |
| `POST /passport` | One kid passport | Per kid `passports/{kidId}.json`. |
| `POST /prizes-kid` | One kid's prize awards | Per kid `prizes-kid/{kidId}.json`. |
| `POST /wheel-prizes` | Prize catalog | Common `wheel-prizes.json`. |
| `POST /admin/import` | Passports, awards, catalog | Bulk restore. Rewrites local aggregates and refreshes per-kid blob mirrors where supported. Treat as maintenance-mode only. |
| `POST /admin/magic-links` | Magic tokens | Common `admin/magic-tokens.json`. |
| Conference settings | `conference_settings` | Singleton event metadata such as title and kid ID prefix. |
| Kids | `kids` | Kid IDs are app-owned strings; the DB does not use generated user-facing IDs. |
| Passport activities | `passport_activities` | Stores the per-kid passport rows and optional completion timestamp. |
| Wheel prize catalog | `prizes` | Stores prize settings. `given` is derived from awards. |
| Prize awards | `prize_awards` | Stores awarded prizes and source metadata. |
| Magic link tokens | `magic_link_tokens` | Stores SHA-256 token hashes and role scopes. |

## Follow-up migration targets
## Resetting non-production data

1. Reset Blob and DB writable state from committed seed JSON.
2. Enable config-driven dual writes with Blob reads.
3. Switch reads to DB after live verification.
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.

The app is not in production yet, so existing Blob and DB testing data can be
discarded. To apply the DB schema, reset both stores from committed seed JSON,
and clear magic-link tokens in both places, run:
The app is not in production yet, so testing data can be discarded. To force a
reset from committed seed JSON and clear magic-link tokens, run:

```sh
NETLIFY_DATABASE_URL=... npm run data:reset-stores
NETLIFY_DB_URL=... npm run data:reset-db
```

After reset, start the rollout with Blob reads and dual writes:
## Schema

```sh
KID_A_STORE_BACKEND=dual
KID_A_STORE_READ=blob
KID_A_DUAL_WRITE_STRICT=true
```
The DB schema lives in `netlify/database/migrations/0001_netlify_db.sql` so
Netlify Database can detect and apply it during deploys. 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.
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import globals from 'globals';
import tseslint from 'typescript-eslint';

export default tseslint.config(
{ ignores: ['dist', 'dist-server'] },
{ ignores: ['.netlify', 'dist', 'dist-server'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
Expand Down
59 changes: 5 additions & 54 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,12 @@

[functions]
directory = "netlify/functions"
included_files = ["server/data/*.json", "src/data/*.json"]
included_files = ["netlify/database/migrations/*.sql", "src/data/*.json"]

[[redirects]]
from = "/api/passport"
to = "/.netlify/functions/api/passport"
status = 200
force = true

[[redirects]]
from = "/api/wheel-prizes"
to = "/.netlify/functions/api/wheel-prizes"
status = 200
force = true

[[redirects]]
from = "/api/prizes-kid"
to = "/.netlify/functions/api/prizes-kid"
status = 200
force = true

[[redirects]]
from = "/api/kids"
to = "/.netlify/functions/api/kids"
status = 200
force = true

[[redirects]]
from = "/api/auth/session"
to = "/.netlify/functions/api/auth/session"
status = 200
force = true

[[redirects]]
from = "/api/admin/session"
to = "/.netlify/functions/api/admin/session"
status = 200
force = true

[[redirects]]
from = "/api/admin/magic-links"
to = "/.netlify/functions/api/admin/magic-links"
status = 200
force = true

[[redirects]]
from = "/api/admin/export"
to = "/.netlify/functions/api/admin/export"
status = 200
force = true

[[redirects]]
from = "/api/admin/import"
to = "/.netlify/functions/api/admin/import"
status = 200
force = true
[dev]
command = "npm run dev:remote"
targetPort = 5173
port = 8888

[[redirects]]
from = "/kid-a"
Expand Down
95 changes: 19 additions & 76 deletions netlify/functions/api.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,32 @@
import { connectLambda } from '@netlify/blobs';
import { createBlobMagicTokenStore, setMagicTokenStore } from '../../server/access-tokens.js';
import { setMagicTokenStore } from '../../server/access-tokens.js';
import { handleApiRequest } from '../../server/api.js';
import { createBlobStore } from '../../server/blob-store.js';
import { ensureDbInitialized } from '../../server/db-bootstrap.js';
import { createDbMagicTokenStore, createDbStore } from '../../server/db-store.js';
import { createDualMagicTokenStore, createDualStore } from '../../server/dual-store.js';
import { setStoreAdapter } from '../../server/store.js';

type NetlifyEvent = {
blobs?: string;
body?: string | null;
headers?: Record<string, string | undefined>;
httpMethod: string;
rawUrl?: string;
path: string;
};

function definedHeaders(headers: NetlifyEvent['headers']) {
return Object.fromEntries(
Object.entries(headers ?? {}).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
),
);
}

function configureStores() {
const storeBackend = process.env.KID_A_STORE_BACKEND ?? 'blob';
const storeRead = process.env.KID_A_STORE_READ ?? 'blob';
const strictDualWrites = process.env.KID_A_DUAL_WRITE_STRICT !== 'false';

if (storeBackend !== 'blob' && storeBackend !== 'db' && storeBackend !== 'dual') {
throw new Error('KID_A_STORE_BACKEND must be blob, db, or dual');
}

if (storeRead !== 'blob' && storeRead !== 'db') {
throw new Error('KID_A_STORE_READ must be blob or db');
}

if (storeBackend === 'dual') {
const blobStore = createBlobStore();
const dbStore = createDbStore();
const blobTokenStore = createBlobMagicTokenStore();
const dbTokenStore = createDbMagicTokenStore();
const primary = storeRead === 'db' ? dbStore : blobStore;
const secondary = storeRead === 'db' ? blobStore : dbStore;
const primaryTokenStore = storeRead === 'db' ? dbTokenStore : blobTokenStore;
const secondaryTokenStore = storeRead === 'db' ? blobTokenStore : dbTokenStore;

setStoreAdapter(
createDualStore({ primary, secondary, strict: strictDualWrites }),
);
setMagicTokenStore(
createDualMagicTokenStore({
primary: primaryTokenStore,
secondary: secondaryTokenStore,
strict: strictDualWrites,
}),
);
return;
}

setStoreAdapter(storeBackend === 'db' ? createDbStore() : createBlobStore());
setMagicTokenStore(
storeBackend === 'db' ? createDbMagicTokenStore() : createBlobMagicTokenStore(),
);
setStoreAdapter(createDbStore());
setMagicTokenStore(createDbMagicTokenStore());
}

export async function handler(event: NetlifyEvent) {
if (event.blobs) {
connectLambda({
blobs: event.blobs,
headers: definedHeaders(event.headers),
});
}

export default async function handler(request: Request) {
await ensureDbInitialized();
configureStores();
const response = await handleApiRequest({
body: event.body,
headers: event.headers,
method: event.httpMethod,
url: event.rawUrl ?? event.path,
body: request.method === 'GET' || request.method === 'HEAD'
? undefined
: await request.text(),
headers: Object.fromEntries(request.headers),
method: request.method,
url: request.url,
});

return {
body: response.body,
return new Response(response.body, {
headers: response.headers,
statusCode: response.status,
};
status: response.status,
});
}

export const config = {
path: '/api/*',
};
Loading
Loading