From 95859a7693cebc707b89668e2d41bea558d07474 Mon Sep 17 00:00:00 2001 From: acharlop Date: Wed, 14 Oct 2026 14:26:51 +0200 Subject: [PATCH 1/2] feat(game): WhoCards as sole controller + 90-day retention script Privacy screen names WhoCards as controller, says the host sees only who joined, and promises answers + recap emails go 90 days after the workshop. Adds pnpm --filter website game:retention (dry-run unless --apply). Closes #317 --- apps/website/package.json | 4 +- apps/website/scripts/game-retention.ts | 44 +++++++ .../website/src/server/game/retention.test.ts | 118 ++++++++++++++++++ apps/website/src/server/game/retention.ts | 67 ++++++++++ docs/plans/2026-10-10-sdd-workshop-game.md | 9 +- packages/game/src/player-copy.ts | 5 +- pnpm-lock.yaml | 3 + 7 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 apps/website/scripts/game-retention.ts create mode 100644 apps/website/src/server/game/retention.test.ts create mode 100644 apps/website/src/server/game/retention.ts diff --git a/apps/website/package.json b/apps/website/package.json index 3d32748..c857426 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -20,7 +20,8 @@ "db:migrate": "pnpm with-env drizzle-kit migrate", "db:push": "pnpm with-env drizzle-kit push", "db:studio": "pnpm with-env drizzle-kit studio", - "reconcile:resend": "pnpm with-env tsx scripts/reconcile-resend.ts" + "reconcile:resend": "pnpm with-env tsx scripts/reconcile-resend.ts", + "game:retention": "pnpm with-env tsx scripts/game-retention.ts" }, "dependencies": { "@whocards/api": "workspace:*", @@ -93,6 +94,7 @@ "prettier": "^3.9.10", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.8.1", + "tsx": "^4.23.15", "typescript": "~5.9.3", "vitest": "catalog:" } diff --git a/apps/website/scripts/game-retention.ts b/apps/website/scripts/game-retention.ts new file mode 100644 index 0000000..b45f928 --- /dev/null +++ b/apps/website/scripts/game-retention.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env tsx +// Deletes game answers and player emails 90 days after the workshop (#317). Dry-run unless --apply. +// pnpm --filter website game:retention +// pnpm --filter website game:retention -- --apply + +import {dbEnv} from '@whocards/env/db' +import {drizzle} from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import {findExpiredGames, purgeGames, RETENTION_DAYS} from '../src/server/game/retention' + +const apply = process.argv.includes('--apply') +const client = postgres(dbEnv.DB_URL) +const db = drizzle(client) + +async function main() { + const games = await findExpiredGames(db, new Date()) + console.log( + `[game-retention] ${apply ? 'APPLY' : 'DRY-RUN (pass --apply to delete)'}: ${games.length} game(s) past ${RETENTION_DAYS} days` + ) + for (const game of games) { + console.log( + ` ${game.code} ${game.lastActivity.toISOString().slice(0, 10)} responses=${game.responses} emails=${game.emails} ${game.title}` + ) + } + if (apply) { + await purgeGames( + db, + games.map((game) => game.id) + ) + console.log('[game-retention] Deleted.') + } +} + +try { + await main() +} catch (error: unknown) { + console.error( + '[game-retention] Failed:', + error instanceof Error ? error.message : 'unknown error' + ) + process.exitCode = 1 +} finally { + await client.end() +} diff --git a/apps/website/src/server/game/retention.test.ts b/apps/website/src/server/game/retention.test.ts new file mode 100644 index 0000000..439c10b --- /dev/null +++ b/apps/website/src/server/game/retention.test.ts @@ -0,0 +1,118 @@ +import {PGlite} from '@electric-sql/pglite' +import * as gameSchema from '@whocards/game/db' +import { + gamePair, + gamePlayer, + gameResponse, + gameRound, + gameTurn, + hostedGame, +} from '@whocards/game/db' +import {generateDrizzleJson, generateMigration} from 'drizzle-kit/api' +import {asc} from 'drizzle-orm' +import {drizzle} from 'drizzle-orm/pglite' +import {beforeAll, beforeEach, describe, expect, it} from 'vitest' +import {findExpiredGames, purgeGames} from './retention' + +const client = new PGlite() +const db = drizzle(client) +const now = new Date('2027-01-13T12:00:00Z') +const daysAgo = (days: number) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000) + +beforeAll(async () => { + const statements = await generateMigration( + generateDrizzleJson({}), + generateDrizzleJson({...gameSchema}) + ) + for (const statement of statements) await client.exec(statement) +}) + +beforeEach(async () => { + await client.exec('TRUNCATE hosted_game CASCADE') +}) + +const seedGame = async (code: string, created: Date, roundEnds: Date | null) => { + const [game] = await db + .insert(hostedGame) + .values({code, title: code, status: 'ended', createdAt: created}) + .returning() + const gameId = game?.id ?? '' + if (!roundEnds) return gameId + const [round] = await db + .insert(gameRound) + .values({gameId, n: 1, deckSlug: 'library', turnSeconds: 60, status: 'done', endsAt: roundEnds}) + .returning() + const [pair] = await db + .insert(gamePair) + .values({roundId: round?.id ?? ''}) + .returning() + const players = await db + .insert(gamePlayer) + .values([ + { + gameId, + name: 'Ann', + language: 'en', + tokenHash: `${code}-a`, + pairCode: 'AAAA', + email: 'a@x.io', + }, + {gameId, name: 'Bo', language: 'en', tokenHash: `${code}-b`, pairCode: 'BBBB'}, + ]) + .returning() + for (const player of players) { + const [turn] = await db + .insert(gameTurn) + .values({ + pairId: pair?.id ?? '', + playerId: player.id, + questionId: 'q', + startedAt: roundEnds, + finishedAt: roundEnds, + }) + .returning() + await db.insert(gameResponse).values({ + turnId: turn?.id ?? '', + playerId: player.id, + language: 'en', + transcript: 'hi', + durationMs: 1000, + }) + } + return gameId +} + +describe('game retention', () => { + it('selects only games whose last activity is over 90 days old', async () => { + await seedGame('OLD', daysAgo(120), daysAgo(91)) + await seedGame('EDGE', daysAgo(120), daysAgo(89)) + await seedGame('NEW', daysAgo(10), daysAgo(10)) + await seedGame('EMPTY', daysAgo(200), null) + + const expired = await findExpiredGames(db, now) + + expect(expired.map(({code, responses, emails}) => ({code, responses, emails}))).toEqual([ + {code: 'OLD', responses: 2, emails: 1}, + ]) + }) + + it('deletes responses and emails for purged games only', async () => { + const old = await seedGame('OLD', daysAgo(120), daysAgo(91)) + await seedGame('NEW', daysAgo(10), daysAgo(10)) + + await purgeGames(db, [old]) + + expect(await findExpiredGames(db, now)).toEqual([]) + expect(await db.select({playerId: gameResponse.playerId}).from(gameResponse)).toHaveLength(2) + const players = await db + .select({code: gamePlayer.tokenHash, email: gamePlayer.email}) + .from(gamePlayer) + .orderBy(asc(gamePlayer.tokenHash)) + expect(players).toEqual([ + {code: 'NEW-a', email: 'a@x.io'}, + {code: 'NEW-b', email: null}, + {code: 'OLD-a', email: null}, + {code: 'OLD-b', email: null}, + ]) + }) +}) diff --git a/apps/website/src/server/game/retention.ts b/apps/website/src/server/game/retention.ts new file mode 100644 index 0000000..a5d1a9a --- /dev/null +++ b/apps/website/src/server/game/retention.ts @@ -0,0 +1,67 @@ +import {gamePlayer, gameResponse, gameRound, hostedGame} from '@whocards/game/db' +import {eq, inArray, sql} from 'drizzle-orm' +import type {PgDatabase, PgQueryResultHKT} from 'drizzle-orm/pg-core' + +/** Promised on the player privacy screen: answers and recap emails go 90 days after the workshop. */ +export const RETENTION_DAYS = 90 + +export type ExpiredGame = { + id: string + code: string + title: string + lastActivity: Date + responses: number + emails: number +} + +const DAY_MS = 24 * 60 * 60 * 1000 + +/** Games whose last activity is past retention and that still hold responses or emails. */ +export const findExpiredGames = async < + T extends PgQueryResultHKT, + S extends Record, +>( + db: PgDatabase, + now: Date +): Promise => { + const cutoff = new Date(now.getTime() - RETENTION_DAYS * DAY_MS) + // Status is ignored on purpose: a game the host never ended still has to expire. + const lastActivity = sql`greatest(${hostedGame.createdAt}, max(${gameRound.startedAt}), max(${gameRound.endsAt}))` + const rows = await db + .select({ + id: hostedGame.id, + code: hostedGame.code, + title: hostedGame.title, + lastActivity: lastActivity.mapWith((value: string | Date) => new Date(value)), + responses: + sql`(select count(*) from ${gameResponse} join ${gamePlayer} on ${gamePlayer.id} = ${gameResponse.playerId} where ${gamePlayer.gameId} = ${hostedGame.id})`.mapWith( + Number + ), + emails: + sql`(select count(*) from ${gamePlayer} where ${gamePlayer.gameId} = ${hostedGame.id} and ${gamePlayer.email} is not null)`.mapWith( + Number + ), + }) + .from(hostedGame) + .leftJoin(gameRound, eq(gameRound.gameId, hostedGame.id)) + .groupBy(hostedGame.id) + .having(sql`${lastActivity} < ${cutoff.toISOString()}::timestamptz`) + .orderBy(hostedGame.createdAt) + return rows.filter((row) => row.responses > 0 || row.emails > 0) +} + +/** Deletes every response and clears every player email in the given games. */ +export const purgeGames = async >( + db: PgDatabase, + gameIds: string[] +): Promise => { + if (gameIds.length === 0) return + await db.transaction(async (tx) => { + const players = tx + .select({id: gamePlayer.id}) + .from(gamePlayer) + .where(inArray(gamePlayer.gameId, gameIds)) + await tx.delete(gameResponse).where(inArray(gameResponse.playerId, players)) + await tx.update(gamePlayer).set({email: null}).where(inArray(gamePlayer.gameId, gameIds)) + }) +} diff --git a/docs/plans/2026-10-10-sdd-workshop-game.md b/docs/plans/2026-10-10-sdd-workshop-game.md index 0c82f62..cf29875 100644 --- a/docs/plans/2026-10-10-sdd-workshop-game.md +++ b/docs/plans/2026-10-10-sdd-workshop-game.md @@ -20,7 +20,7 @@ Event **Thu 2026-10-15**. **Go/no-go Wed 2026-10-14** rehearsal: not rehearsed o | T6 | Player UI: join + phone check, pair (show/scan/type code), card + turn lock, timer, decline flow | P0 | T4, T5 | | T7 | Host UI: rounds, deck + timer per round, start/end, live counts, join QR | P0 | T4, T5 | | T8 | Recorder + screen wake lock, on-device STT + cleanup, review screen, share choices, API fallback | P0 | T1, T6 | -| T9 | Privacy notice (joint controllers), consent copy, My answers (view, download, delete) | P0 | T2 | +| T9 | Privacy notice (single controller), consent copy, My answers (view, download, delete) | P0 | T2 | | T10 | Rehearsal: 6–10 phones, iOS + Android, noisy room, throttled Wi-Fi, power-saving on | P0 | T6–T9 | | T11 | Paper fallback kit: printed cards, reflection slip | P0 | — | | T15 | Recap email that night: stats, template, random question, host preview + send | P0 | T4, T7, T9 | @@ -132,7 +132,6 @@ flowchart TB | Not rehearsed by 2026-10-14 (five days) | H / H | Paper fallback T11 always ready; cut list above | | iOS kills backgrounded tab | H / M | Screen wake lock while recording; audio to IndexedDB; resume | | On-device STT slow on older phones | M / M | Transcribe between turns; API fallback with a yes | -| Joint controllers without written arrangement | M / M | Short Art. 26 note: who answers requests; notice names both | | Engine overfit to this event | M / M | Own package, pure logic, event config is data | | Two permission prompts eat round time | H / L | 30 s phone check at join; typed code fallback | | Odd headcount / nobody new left | certain / L | Trio (facilitator setting); relax no-repeat after timeout | @@ -144,7 +143,7 @@ flowchart TB 2. **Live updates:** no long polling. Answers from phone, timer on phone, state on every call, waiting screens refresh. 3. **Host auth:** secret link now; facilitator accounts later. 4. **Where:** website now. Rules, API, hooks in packages → cheap move to `apps/app` / mobile. TanStack Start move is its own track, doesn't block this. -5. **Controller:** Avi + Tamas / Unloop, joint. +5. **Controller:** WhoCards, sole controller. Host sees who joined, never answers. 6. **Date:** Thu 2026-10-15. Headcount unknown, plan for 60. 7. **Odd headcount:** trio, facilitator setting. 8. **Timer end:** soft nudge + 30 s, facilitator setting. @@ -258,11 +257,11 @@ Data access: My answers on device (view, edit, download .txt/.json, delete); mag ### GDPR checklist -- Joint controllers (Avi + Tamas / Unloop): short Art. 26 arrangement on who answers requests; notice names both. +- Single controller (WhoCards); notice names it, hello@whocards.cc answers requests. Host is not a recipient (sees counts only). - Plain-words privacy notice under the event, linked from join and every consent prompt. - Art. 9 explicit consent (work/wellbeing answers may reveal health or beliefs); per answer, revocable. - Processors + regions: Netlify, Supabase (region?), Resend, STT API vendor if used, Anthropic for report; name US transfer basis. -- Retention: audio deleted after transcription; transcripts 30 days after report; game + pairing data with the game. +- Retention: audio deleted after transcription; answers + recap emails 90 days after the workshop (`pnpm --filter website game:retention`); game + pairing data with the game. - Third parties: names replaced before upload; report never quotes identifiable lines. - One-page DPIA-lite kept with this plan. - No transcripts on projector or live to facilitators. diff --git a/packages/game/src/player-copy.ts b/packages/game/src/player-copy.ts index 3c9d3ee..a3659da 100644 --- a/packages/game/src/player-copy.ts +++ b/packages/game/src/player-copy.ts @@ -101,14 +101,15 @@ const en: PlayerCopy = { decline: 'Play without recording', back: 'Back', }, - // TODO(#317): final joint-controller wording from Avi and Tamas before the event. privacy: { title: 'Your data', lines: [ - 'Avi Charlop and Tamas / Unloop run this game together and are joint controllers of your data.', + 'WhoCards is responsible for your data.', + 'The workshop host sees only who joined, not your answers.', 'If you record, OpenAI turns your audio into text for us. The audio is not kept.', 'An answer is stored only when you tap Save. Delete it any time in My answers.', 'Your email is used only for the recap.', + 'Answers and recap emails are deleted 90 days after the workshop.', 'Questions or deletion requests: hello@whocards.cc', ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc8377..e1ed6d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -798,6 +798,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.8.1 version: 0.8.1(prettier-plugin-astro@0.14.1)(prettier@3.9.10) + tsx: + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ~5.9.3 version: 5.9.3 From 359e47110beee74ae76870daedc7b7927f20782b Mon Sep 17 00:00:00 2001 From: acharlop Date: Wed, 14 Oct 2026 14:28:02 +0200 Subject: [PATCH 2/2] chore(game): trim retention script header --- apps/website/scripts/game-retention.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/website/scripts/game-retention.ts b/apps/website/scripts/game-retention.ts index b45f928..24a17ae 100644 --- a/apps/website/scripts/game-retention.ts +++ b/apps/website/scripts/game-retention.ts @@ -1,7 +1,5 @@ #!/usr/bin/env tsx // Deletes game answers and player emails 90 days after the workshop (#317). Dry-run unless --apply. -// pnpm --filter website game:retention -// pnpm --filter website game:retention -- --apply import {dbEnv} from '@whocards/env/db' import {drizzle} from 'drizzle-orm/postgres-js'