Repository navigation
feat(game): WhoCards sole controller + 90-day retention script #340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| } |
| 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) | ||
|
|
||
| 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}, | ||
| ]) | ||
| }) | ||
| }) | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Lock each game using the same lock as |
||
| }) | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seedGamealways 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
Abandoned no-round game regression test
Original retention tests with current code
Existing retention tests with no-round fallback disabled
Abandoned-game selection and purge with current code
Abandoned-game regression test with fallback disabled
Complete validation run and cleanup output