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
4 changes: 3 additions & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down Expand Up @@ -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:"
}
Expand Down
42 changes: 42 additions & 0 deletions apps/website/scripts/game-retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env tsx
// Deletes game answers and player emails 90 days after the workshop (#317). Dry-run unless --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()
}
118 changes: 118 additions & 0 deletions apps/website/src/server/game/retention.test.ts
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Abandoned games lack coverage

seedGame always creates ended games, and its no-round case returns before adding any players or emails. The existing tests still pass when the creation-date fallback for games without rounds is disabled, so they do not protect cleanup of email addresses from abandoned games. Current cleanup works for that scenario; this is a non-blocking regression-coverage gap.

Add an old, non-ended game with an email but no rounds. Verify that selection finds it and cleanup clears it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Isolated mutation validation script

  • Runs the original tests and both comparisons using disposable copied sources, captures executed source and output, and verifies cleanup without modifying tracked retention files.

Abandoned no-round game regression test

  • Seeds an old lobby game with an email and no rounds, then asserts selection and email purge, providing the missing coverage.

Original retention tests with current code

  • Executed the original retention suite and captured its source and output; both tests pass.

Existing retention tests with no-round fallback disabled

  • Executed unchanged copied tests against the mutated copied module; both tests still pass, confirming the coverage gap.

Abandoned-game selection and purge with current code

  • Executed the supplemental test against current code; the old no-round lobby game is selected and its email is cleared.

Abandoned-game regression test with fallback disabled

  • Executed the same supplemental test against the mutation; selection returns no games and the assertion fails, proving the added test detects the regression.

Complete validation run and cleanup output

  • Captured the validation driver's four test runs and successful cleanup checks; the expected pass/pass/pass/fail comparison completed without tracked source edits.

View artifacts

T-Rex Ran code and verified through T-Rex


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},
])
})
})
67 changes: 67 additions & 0 deletions apps/website/src/server/game/retention.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
>(
db: PgDatabase<T, S>,
now: Date
): Promise<ExpiredGame[]> => {
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 <T extends PgQueryResultHKT, S extends Record<string, unknown>>(
db: PgDatabase<T, S>,
gameIds: string[]
): Promise<void> => {
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))
Comment on lines +59 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Resumed games lose answers

purgeGames trusts IDs selected earlier without checking whether those games still qualify. A host can start a new round in an old game left live after findExpiredGames returns. Although its activity date is now recent, cleanup still deletes all its saved answers and clears its recap email addresses. This was reproduced with a successful round start followed by cleanup using the earlier selection.

Lock each game using the same lock as startRound, then recheck its activity date before deleting. This data-loss issue must be fixed before merging.

T-Rex Ran code and verified through T-Rex

})
}
9 changes: 4 additions & 5 deletions docs/plans/2026-10-10-sdd-workshop-game.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions packages/game/src/player-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

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

Loading