From c3fd47d877e5844f4cffdf0002a5a4f5e4050cb4 Mon Sep 17 00:00:00 2001 From: Yoyojesus Date: Mon, 1 Jun 2026 22:07:44 -0400 Subject: [PATCH] feat(activity): expand logging with more events, filterable feed, export & purge Capture new event types (tile_uncomplete, bingo_win, card_reshuffle, and admin verify/unverify/reset + tile create/update/delete/bulk_add). Admin-action targets are recorded in the detail field, so no schema migration is needed. Admin feed: - filter by type (grouped) and by user, via URL query params - "Load more" pagination using a growing limit - live updates through depends('app:activity') + livePoll - badges/labels for every event type Export & retention: - admin-only GET /admin/activity/export (CSV/JSON, honors active filters) - slide-to-confirm purge action (clear all, or older than a chosen date) bingo_win is detected on the false->true transition (after-state re-query minus the toggled tile), so it logs exactly once per win. New logActivity call sites keep the best-effort contract: a logging failure never breaks the action. Adds vitest with unit coverage for the pure logic (event metadata, win transition, filter/purge parsing, CSV/JSON serialization). Co-Authored-By: Claude Opus 4.8 --- bun.lock | 51 + .../2026-06-01-activity-logging-expansion.md | 1504 +++++++++++++++++ ...06-01-activity-logging-expansion-design.md | 171 ++ package.json | 7 +- src/lib/activityMeta.test.ts | 56 + src/lib/activityMeta.ts | 117 ++ src/lib/bingo.test.ts | 40 + src/lib/bingo.ts | 28 + src/lib/server/activity.ts | 3 +- src/lib/server/activityExport.test.ts | 41 + src/lib/server/activityExport.ts | 38 + src/lib/server/activityQuery.test.ts | 56 + src/lib/server/activityQuery.ts | 39 + src/routes/admin/activity/+page.server.ts | 54 +- src/routes/admin/activity/+page.svelte | 156 +- src/routes/admin/activity/export/+server.ts | 51 + src/routes/admin/tiles/+page.server.ts | 22 +- src/routes/admin/users/[id]/+page.server.ts | 23 +- src/routes/bingo/+page.server.ts | 41 +- vitest.config.ts | 11 + 20 files changed, 2478 insertions(+), 31 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-01-activity-logging-expansion.md create mode 100644 docs/superpowers/specs/2026-06-01-activity-logging-expansion-design.md create mode 100644 src/lib/activityMeta.test.ts create mode 100644 src/lib/activityMeta.ts create mode 100644 src/lib/bingo.test.ts create mode 100644 src/lib/server/activityExport.test.ts create mode 100644 src/lib/server/activityExport.ts create mode 100644 src/lib/server/activityQuery.test.ts create mode 100644 src/lib/server/activityQuery.ts create mode 100644 src/routes/admin/activity/export/+server.ts create mode 100644 vitest.config.ts diff --git a/bun.lock b/bun.lock index 3f2e1d0..d9aa0fc 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vite": "^6.0.7", + "vitest": "^4.1.8", }, }, }, @@ -222,8 +223,12 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], @@ -232,10 +237,26 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], + + "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="], + + "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "better-auth": ["better-auth@1.6.11", "", { "dependencies": { "@better-auth/core": "1.6.11", "@better-auth/drizzle-adapter": "1.6.11", "@better-auth/kysely-adapter": "1.6.11", "@better-auth/memory-adapter": "1.6.11", "@better-auth/mongo-adapter": "1.6.11", "@better-auth/prisma-adapter": "1.6.11", "@better-auth/telemetry": "1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ=="], @@ -246,12 +267,16 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -274,6 +299,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -284,6 +311,8 @@ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -352,8 +381,12 @@ "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -380,6 +413,8 @@ "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -388,6 +423,10 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "svelte": ["svelte@5.55.9", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg=="], @@ -398,8 +437,14 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -410,8 +455,12 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -432,6 +481,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "drizzle-kit/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], diff --git a/docs/superpowers/plans/2026-06-01-activity-logging-expansion.md b/docs/superpowers/plans/2026-06-01-activity-logging-expansion.md new file mode 100644 index 0000000..581fd36 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-activity-logging-expansion.md @@ -0,0 +1,1504 @@ +# Activity Logging Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand the existing activity log with more event types, a filterable/paginated/live admin feed, and CSV/JSON export plus manual purge. + +**Architecture:** New event types reuse the existing best-effort `logActivity` writer and the free-text `type`/`detail` columns (no schema migration). Pure, testable logic (event metadata, bingo-win transition, filter parsing, export serialization) lives in small focused modules covered by vitest. Server actions/endpoints and Svelte UI wire those helpers in; UI/DB wiring is verified by `svelte-check` and manual testing. + +**Tech Stack:** SvelteKit 2 (Svelte 5 runes), Drizzle ORM + Postgres, Better Auth, Bun, TailwindCSS, vitest (added in Task 1). + +> **Commits:** This repo's owner controls all commits (do NOT run `git commit`). Each task ends with a verification checkpoint; stage changes if you like, but leave committing to the user. + +--- + +## File Structure + +**New (pure, unit-tested):** +- `src/lib/activityMeta.ts` — canonical `ActivityType` union, category mapping, display labels, badge classes, type groups. Shared client + server. +- `src/lib/server/activityQuery.ts` — `buildActivityFilters(URLSearchParams)` and `parsePurgeRequest(...)`. +- `src/lib/server/activityExport.ts` — `toCsv(rows)` / `toJson(rows)`. + +**New (wiring):** +- `src/routes/admin/activity/export/+server.ts` — admin-only GET export endpoint. +- Test files: `src/lib/activityMeta.test.ts`, `src/lib/bingo.test.ts`, `src/lib/server/activityQuery.test.ts`, `src/lib/server/activityExport.test.ts`. +- `vitest.config.ts`. + +**Modified:** +- `src/lib/bingo.ts` — add `describeWinLine` + `bingoWinTransition` pure helpers. +- `src/lib/server/activity.ts` — re-export expanded `ActivityType` from `activityMeta`. +- `src/routes/bingo/+page.server.ts` — log `tile_uncomplete`, `bingo_win`, `card_reshuffle`. +- `src/routes/admin/users/[id]/+page.server.ts` — log `admin_verify` / `admin_unverify` / `admin_reset`. +- `src/routes/admin/tiles/+page.server.ts` — log `tile_create` / `tile_update` / `tile_delete` / `tile_bulk_add`. +- `src/routes/admin/activity/+page.server.ts` — filters, growing limit, `depends`, distinct-user list, `purge` action. +- `src/routes/admin/activity/+page.svelte` — filter/export/purge controls, live poll, new labels/badges. +- `package.json` — add `test` scripts + vitest devDependency. + +--- + +## Task 1: Set up vitest + +**Files:** +- Create: `vitest.config.ts` +- Modify: `package.json` (scripts + devDependencies) + +- [ ] **Step 1: Install vitest** + +Run: `bun add -d vitest` +Expected: vitest added under devDependencies in `package.json`. + +- [ ] **Step 2: Create standalone vitest config** + +The unit-tested modules import nothing from `$lib`/`$env`, so a plain config (no SvelteKit plugin) is enough and avoids alias/runtime complications. Create `vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node' + } +}); +``` + +- [ ] **Step 3: Add test scripts** + +In `package.json` `scripts`, add: + +```json + "test": "vitest run", + "test:watch": "vitest" +``` + +- [ ] **Step 4: Add a smoke test and run it** + +Create `src/lib/smoke.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; + +describe('vitest', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +Run: `bun run test` +Expected: 1 passing test. + +- [ ] **Step 5: Remove the smoke test** + +Delete `src/lib/smoke.test.ts` (it was only to prove the harness works). + +- [ ] **Step 6: Checkpoint** — `bun run test` exits cleanly (no test files now, or 0 failures). Leave committing to the user. + +--- + +## Task 2: Activity metadata module + +**Files:** +- Create: `src/lib/activityMeta.ts` +- Test: `src/lib/activityMeta.test.ts` +- Modify: `src/lib/server/activity.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/activityMeta.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { + ACTIVITY_TYPES, + TYPE_GROUPS, + TYPE_LABEL, + categoryOf, + eventLabel, + badgeClass +} from './activityMeta'; + +describe('categoryOf', () => { + it('maps known types to categories', () => { + expect(categoryOf('login')).toBe('auth'); + expect(categoryOf('tile_complete')).toBe('play'); + expect(categoryOf('bingo_win')).toBe('wins'); + expect(categoryOf('admin_reset')).toBe('admin'); + }); + + it('returns "other" for unknown types', () => { + expect(categoryOf('something_else')).toBe('other'); + }); +}); + +describe('eventLabel', () => { + it('formats player events', () => { + expect(eventLabel('tile_complete', 'Wear a hat')).toBe('Completed "Wear a hat"'); + expect(eventLabel('tile_uncomplete', 'Wear a hat')).toBe('Un-marked "Wear a hat"'); + expect(eventLabel('card_reshuffle', null)).toBe('Reshuffled card'); + expect(eventLabel('bingo_win', 'Row 3')).toBe('Bingo! (Row 3)'); + expect(eventLabel('bingo_win', null)).toBe('Bingo!'); + }); + + it('formats admin events with the target in detail', () => { + expect(eventLabel('admin_verify', 'Alice')).toBe('Verified Alice'); + expect(eventLabel('admin_reset', 'Alice')).toBe("Reset Alice's board"); + }); + + it('falls back to the raw type for unknown types', () => { + expect(eventLabel('mystery', null)).toBe('mystery'); + }); +}); + +describe('completeness', () => { + it('every type has a category, label, and badge class', () => { + for (const t of ACTIVITY_TYPES) { + expect(categoryOf(t)).not.toBe('other'); + expect(TYPE_LABEL[t]).toBeTruthy(); + expect(badgeClass(t)).toMatch(/border/); + } + }); + + it('TYPE_GROUPS covers exactly the full type set', () => { + const grouped = TYPE_GROUPS.flatMap((g) => g.types).sort(); + expect(grouped).toEqual([...ACTIVITY_TYPES].sort()); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test src/lib/activityMeta.test.ts` +Expected: FAIL — cannot resolve `./activityMeta`. + +- [ ] **Step 3: Implement the module** + +Create `src/lib/activityMeta.ts`: + +```ts +export type ActivityCategory = 'auth' | 'play' | 'wins' | 'admin'; + +export const ACTIVITY_TYPES = [ + 'login', + 'logout', + 'tile_complete', + 'tile_uncomplete', + 'card_reshuffle', + 'bingo_win', + 'admin_verify', + 'admin_unverify', + 'admin_reset', + 'tile_create', + 'tile_update', + 'tile_delete', + 'tile_bulk_add' +] as const; + +export type ActivityType = (typeof ACTIVITY_TYPES)[number]; + +const CATEGORY: Record = { + login: 'auth', + logout: 'auth', + tile_complete: 'play', + tile_uncomplete: 'play', + card_reshuffle: 'play', + bingo_win: 'wins', + admin_verify: 'admin', + admin_unverify: 'admin', + admin_reset: 'admin', + tile_create: 'admin', + tile_update: 'admin', + tile_delete: 'admin', + tile_bulk_add: 'admin' +}; + +export const TYPE_LABEL: Record = { + login: 'Login', + logout: 'Logout', + tile_complete: 'Tile completed', + tile_uncomplete: 'Tile un-marked', + card_reshuffle: 'Card reshuffle', + bingo_win: 'Bingo win', + admin_verify: 'Admin: verify', + admin_unverify: 'Admin: un-verify', + admin_reset: 'Admin: reset board', + tile_create: 'Admin: create tile', + tile_update: 'Admin: edit tile', + tile_delete: 'Admin: delete tile', + tile_bulk_add: 'Admin: bulk add tiles' +}; + +export const TYPE_GROUPS: { label: string; category: ActivityCategory; types: ActivityType[] }[] = [ + { label: 'Auth', category: 'auth', types: ['login', 'logout'] }, + { label: 'Play', category: 'play', types: ['tile_complete', 'tile_uncomplete', 'card_reshuffle'] }, + { label: 'Wins', category: 'wins', types: ['bingo_win'] }, + { + label: 'Admin', + category: 'admin', + types: ['admin_verify', 'admin_unverify', 'admin_reset', 'tile_create', 'tile_update', 'tile_delete', 'tile_bulk_add'] + } +]; + +export function categoryOf(type: string): ActivityCategory | 'other' { + return (CATEGORY as Record)[type] ?? 'other'; +} + +export function eventLabel(type: string, detail: string | null): string { + switch (type) { + case 'login': + return 'Signed in'; + case 'logout': + return 'Signed out'; + case 'tile_complete': + return `Completed "${detail ?? ''}"`; + case 'tile_uncomplete': + return `Un-marked "${detail ?? ''}"`; + case 'card_reshuffle': + return 'Reshuffled card'; + case 'bingo_win': + return detail ? `Bingo! (${detail})` : 'Bingo!'; + case 'admin_verify': + return `Verified ${detail ?? 'a player'}`; + case 'admin_unverify': + return `Un-verified ${detail ?? 'a player'}`; + case 'admin_reset': + return `Reset ${detail ?? 'a player'}'s board`; + case 'tile_create': + return `Created tile "${detail ?? ''}"`; + case 'tile_update': + return `Edited tile "${detail ?? ''}"`; + case 'tile_delete': + return `Deleted tile "${detail ?? ''}"`; + case 'tile_bulk_add': + return `Bulk-added ${detail ?? ''}`; + default: + return type; + } +} + +export function badgeClass(type: string): string { + switch (categoryOf(type)) { + case 'wins': + return 'bg-amber-500/20 border border-amber-400/40 text-amber-200'; + case 'play': + return 'bg-emerald-500/20 border border-emerald-400/40 text-emerald-200'; + case 'admin': + return 'bg-sky-500/20 border border-sky-400/40 text-sky-200'; + case 'auth': + default: + return 'bg-white/5 border border-white/10 text-slate-300'; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test src/lib/activityMeta.test.ts` +Expected: PASS. + +- [ ] **Step 5: Re-export the type from the writer module** + +In `src/lib/server/activity.ts`, replace the local union with a re-export so `activityMeta` is the single source of truth. Change the top of the file: + +Old: +```ts +export type ActivityType = 'login' | 'logout' | 'tile_complete'; +``` + +New: +```ts +import type { ActivityType } from '$lib/activityMeta'; +export type { ActivityType }; +``` + +Leave the rest of `activity.ts` unchanged (the `logActivity` body still references `input.type` etc.). + +- [ ] **Step 6: Type-check** + +Run: `bun run check` +Expected: no new errors from `activity.ts` / `activityMeta.ts`. + +- [ ] **Step 7: Checkpoint** — tests pass, `check` clean. + +--- + +## Task 3: Bingo win transition helpers + +**Files:** +- Modify: `src/lib/bingo.ts` +- Test: `src/lib/bingo.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/bingo.test.ts`. Recall the 5×5 layout: positions `0..24`, row `r` is `[5r..5r+4]`, column `c` is `[c, c+5, c+10, c+15, c+20]`, main diagonal `[0,6,12,18,24]`, anti-diagonal `[4,8,12,16,20]`. + +```ts +import { describe, expect, it } from 'vitest'; +import { describeWinLine, bingoWinTransition } from './bingo'; + +describe('describeWinLine', () => { + it('names rows, columns, and diagonals', () => { + expect(describeWinLine([0, 1, 2, 3, 4])).toBe('Row 1'); + expect(describeWinLine([10, 11, 12, 13, 14])).toBe('Row 3'); + expect(describeWinLine([2, 7, 12, 17, 22])).toBe('Column 3'); + expect(describeWinLine([0, 6, 12, 18, 24])).toBe('Diagonal ↘'); + expect(describeWinLine([4, 8, 12, 16, 20])).toBe('Diagonal ↗'); + }); +}); + +describe('bingoWinTransition', () => { + const row1 = new Set([0, 1, 2, 3]); // row 1 missing position 4 + + it('fires once when a line is completed', () => { + const before = row1; + const after = new Set([0, 1, 2, 3, 4]); + expect(bingoWinTransition(before, after)).toEqual({ justWon: true, lineLabel: 'Row 1' }); + }); + + it('does not fire when already in bingo and another tile is marked', () => { + const before = new Set([0, 1, 2, 3, 4]); // already row 1 + const after = new Set([0, 1, 2, 3, 4, 7]); + expect(bingoWinTransition(before, after)).toEqual({ justWon: false, lineLabel: null }); + }); + + it('does not fire when no line is complete', () => { + const before = new Set([0, 1]); + const after = new Set([0, 1, 2]); + expect(bingoWinTransition(before, after)).toEqual({ justWon: false, lineLabel: null }); + }); + + it('fires again after losing and regaining a bingo', () => { + // Lost: dropped from a full row back to partial + const before = new Set([0, 1, 2, 3]); + const after = new Set([0, 1, 2, 3, 4]); + expect(bingoWinTransition(before, after).justWon).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test src/lib/bingo.test.ts` +Expected: FAIL — `describeWinLine`/`bingoWinTransition` are not exported. + +- [ ] **Step 3: Implement the helpers** + +Append to `src/lib/bingo.ts` (after `detectBingo`): + +```ts +export function describeWinLine(line: number[]): string { + const rows = line.map((p) => Math.floor(p / GRID_SIZE)); + const cols = line.map((p) => p % GRID_SIZE); + if (rows.every((r) => r === rows[0])) return `Row ${rows[0] + 1}`; + if (cols.every((c) => c === cols[0])) return `Column ${cols[0] + 1}`; + if (line.every((p, i) => p === i * GRID_SIZE + i)) return 'Diagonal ↘'; + return 'Diagonal ↗'; +} + +/** + * Detects the moment a player crosses into a bingo. Returns justWon=true only on + * the false -> true transition, so marking further tiles while already winning + * does not re-fire. lineLabel describes the first newly-completed line. + */ +export function bingoWinTransition( + before: Set, + after: Set +): { justWon: boolean; lineLabel: string | null } { + const beforeRes = detectBingo(before); + const afterRes = detectBingo(after); + if (!afterRes.hasBingo || beforeRes.hasBingo) { + return { justWon: false, lineLabel: null }; + } + const beforeKeys = new Set(beforeRes.winningLines.map((l) => l.join(','))); + const newLine = afterRes.winningLines.find((l) => !beforeKeys.has(l.join(','))); + return { justWon: true, lineLabel: newLine ? describeWinLine(newLine) : null }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test src/lib/bingo.test.ts` +Expected: PASS. + +- [ ] **Step 5: Checkpoint** — tests pass. + +--- + +## Task 4: Filter and purge parsing + +**Files:** +- Create: `src/lib/server/activityQuery.ts` +- Test: `src/lib/server/activityQuery.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/activityQuery.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { buildActivityFilters, parsePurgeRequest, DEFAULT_LIMIT, MAX_LIMIT } from './activityQuery'; + +const params = (q: string) => new URLSearchParams(q); + +describe('buildActivityFilters', () => { + it('defaults to no filters and the default limit', () => { + expect(buildActivityFilters(params(''))).toEqual({ + type: null, + userId: null, + limit: DEFAULT_LIMIT + }); + }); + + it('accepts a known type and a user id', () => { + expect(buildActivityFilters(params('type=bingo_win&user=abc123'))).toEqual({ + type: 'bingo_win', + userId: 'abc123', + limit: DEFAULT_LIMIT + }); + }); + + it('rejects an unknown type', () => { + expect(buildActivityFilters(params('type=not_a_type')).type).toBeNull(); + }); + + it('clamps the limit to MAX_LIMIT and ignores junk', () => { + expect(buildActivityFilters(params('limit=999999')).limit).toBe(MAX_LIMIT); + expect(buildActivityFilters(params('limit=abc')).limit).toBe(DEFAULT_LIMIT); + expect(buildActivityFilters(params('limit=-5')).limit).toBe(DEFAULT_LIMIT); + expect(buildActivityFilters(params('limit=400')).limit).toBe(400); + }); +}); + +describe('parsePurgeRequest', () => { + it('parses purge-all', () => { + expect(parsePurgeRequest('all', null)).toEqual({ mode: 'all' }); + }); + + it('parses purge-older with a valid date', () => { + const result = parsePurgeRequest('older', '2026-01-01'); + expect(result).not.toBeNull(); + expect(result && result.mode).toBe('older'); + expect(result && 'before' in result && result.before instanceof Date).toBe(true); + }); + + it('rejects older without a date or with an invalid date', () => { + expect(parsePurgeRequest('older', null)).toBeNull(); + expect(parsePurgeRequest('older', 'not-a-date')).toBeNull(); + }); + + it('rejects unknown modes', () => { + expect(parsePurgeRequest('nuke', null)).toBeNull(); + expect(parsePurgeRequest(null, null)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test src/lib/server/activityQuery.test.ts` +Expected: FAIL — cannot resolve `./activityQuery`. + +- [ ] **Step 3: Implement the module** + +Create `src/lib/server/activityQuery.ts`: + +```ts +import { ACTIVITY_TYPES } from '$lib/activityMeta'; + +export type ActivityFilters = { + type: string | null; + userId: string | null; + limit: number; +}; + +export const DEFAULT_LIMIT = 200; +export const LIMIT_STEP = 200; +export const MAX_LIMIT = 5000; + +export function buildActivityFilters(params: URLSearchParams): ActivityFilters { + const typeRaw = params.get('type'); + const type = typeRaw && (ACTIVITY_TYPES as readonly string[]).includes(typeRaw) ? typeRaw : null; + + const userRaw = params.get('user'); + const userId = userRaw && userRaw.length > 0 ? userRaw : null; + + const limitRaw = Number(params.get('limit')); + const limit = + Number.isInteger(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, MAX_LIMIT) : DEFAULT_LIMIT; + + return { type, userId, limit }; +} + +export type PurgeRequest = { mode: 'all' } | { mode: 'older'; before: Date }; + +export function parsePurgeRequest( + mode: string | null, + before: string | null +): PurgeRequest | null { + if (mode === 'all') return { mode: 'all' }; + if (mode === 'older') { + if (!before) return null; + const d = new Date(before); + if (Number.isNaN(d.getTime())) return null; + return { mode: 'older', before: d }; + } + return null; +} +``` + +Note: this file imports from `$lib/activityMeta`. Since the test imports it through the same alias-free relative path and `activityMeta` itself has no `$lib`/`$env` imports, vitest resolves it via the standalone config only if the `$lib` alias is available. If the test fails to resolve `$lib`, add an alias to `vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { alias: { $lib: path.resolve('./src/lib') } }, + test: { include: ['src/**/*.test.ts'], environment: 'node' } +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test src/lib/server/activityQuery.test.ts` +Expected: PASS. If it failed on `$lib` resolution, apply the alias above and re-run. + +- [ ] **Step 5: Checkpoint** — tests pass. + +--- + +## Task 5: Export serialization + +**Files:** +- Create: `src/lib/server/activityExport.ts` +- Test: `src/lib/server/activityExport.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/activityExport.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { toCsv, toJson, type ExportRow } from './activityExport'; + +const rows: ExportRow[] = [ + { createdAt: '2026-06-01T12:00:00.000Z', userName: 'Alice', type: 'bingo_win', detail: 'Row 3' }, + { createdAt: '2026-06-01T12:01:00.000Z', userName: 'Bob, Jr', type: 'tile_complete', detail: 'Say "hi"' }, + { createdAt: '2026-06-01T12:02:00.000Z', userName: 'Cleo', type: 'login', detail: null } +]; + +describe('toCsv', () => { + it('writes a header and one row per event', () => { + const lines = toCsv(rows).split('\n'); + expect(lines[0]).toBe('created_at,user_name,type,detail'); + expect(lines).toHaveLength(4); + }); + + it('quotes cells containing commas, quotes, or newlines', () => { + const csv = toCsv(rows); + expect(csv).toContain('"Bob, Jr"'); + expect(csv).toContain('"Say ""hi"""'); + }); + + it('renders null detail as an empty cell', () => { + const last = toCsv(rows).split('\n')[3]; + expect(last.endsWith(',')).toBe(true); + }); +}); + +describe('toJson', () => { + it('emits an array of normalized rows', () => { + const parsed = JSON.parse(toJson(rows)); + expect(parsed).toHaveLength(3); + expect(parsed[0]).toEqual({ + createdAt: '2026-06-01T12:00:00.000Z', + userName: 'Alice', + type: 'bingo_win', + detail: 'Row 3' + }); + expect(parsed[2].detail).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run test src/lib/server/activityExport.test.ts` +Expected: FAIL — cannot resolve `./activityExport`. + +- [ ] **Step 3: Implement the module** + +Create `src/lib/server/activityExport.ts`: + +```ts +export type ExportRow = { + createdAt: Date | string; + userName: string; + type: string; + detail: string | null; +}; + +function csvCell(value: string): string { + return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} + +export function toCsv(rows: ExportRow[]): string { + const lines = ['created_at,user_name,type,detail']; + for (const r of rows) { + lines.push( + [ + new Date(r.createdAt).toISOString(), + csvCell(r.userName), + csvCell(r.type), + csvCell(r.detail ?? '') + ].join(',') + ); + } + return lines.join('\n'); +} + +export function toJson(rows: ExportRow[]): string { + return JSON.stringify( + rows.map((r) => ({ + createdAt: new Date(r.createdAt).toISOString(), + userName: r.userName, + type: r.type, + detail: r.detail ?? null + })), + null, + 2 + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun run test src/lib/server/activityExport.test.ts` +Expected: PASS. + +- [ ] **Step 5: Checkpoint** — all unit tests pass: `bun run test`. + +--- + +## Task 6: Log player events (un-complete, bingo win, reshuffle) + +**Files:** +- Modify: `src/routes/bingo/+page.server.ts` + +No new unit tests (DB/action wiring; the pure win logic is already covered by Task 3). Verified by `svelte-check` + manual play. + +- [ ] **Step 1: Add a board-positions helper** + +In `src/routes/bingo/+page.server.ts`, add this helper near the top (after the existing `ensureCardSeed` function). It mirrors the `load` logic to produce the set of completed grid positions for a user: + +```ts +async function boardPositions(userId: string): Promise<{ + ordered: { id: string; isFreeSpace: boolean }[]; + positions: Set; +}> { + const tiles = await db + .select({ id: bingoTile.id, position: bingoTile.position, isFreeSpace: bingoTile.isFreeSpace }) + .from(bingoTile) + .orderBy(bingoTile.position); + const [u] = await db + .select({ cardSeed: user.cardSeed }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + const progress = await db + .select({ tileId: bingoProgress.tileId }) + .from(bingoProgress) + .where(eq(bingoProgress.userId, userId)); + const completed = new Set(progress.map((p) => p.tileId)); + const ordered = shuffleTilesForUser(tiles, u?.cardSeed ?? null); + const positions = new Set(); + ordered.forEach((t, idx) => { + if (completed.has(t.id) || t.isFreeSpace) positions.add(idx); + }); + return { ordered, positions }; +} +``` + +- [ ] **Step 2: Import the win helper** + +At the top of the file, update the bingo import to include `bingoWinTransition`: + +Old: +```ts +import { detectBingo } from '$lib/bingo'; +``` + +New: +```ts +import { detectBingo, bingoWinTransition } from '$lib/bingo'; +``` + +- [ ] **Step 3: Log tile_uncomplete in the delete branch** + +In the `toggle` action, the `if (existing.length)` branch deletes progress and returns. Add a log call after the delete, before `return`: + +Old: +```ts + if (existing.length) { + await db + .delete(bingoProgress) + .where(and(eq(bingoProgress.userId, locals.user.id), eq(bingoProgress.tileId, tileId))); + return { ok: true, completed: false }; + } +``` + +New: +```ts + if (existing.length) { + await db + .delete(bingoProgress) + .where(and(eq(bingoProgress.userId, locals.user.id), eq(bingoProgress.tileId, tileId))); + await logActivity({ userId: locals.user.id, type: 'tile_uncomplete', detail: tile.label }); + return { ok: true, completed: false }; + } +``` + +- [ ] **Step 4: Log bingo_win after the insert branch** + +After the existing `tile_complete` log in the insert branch, compute the transition and log a win when crossing into bingo: + +Old: +```ts + await logActivity({ userId: locals.user.id, type: 'tile_complete', detail: tile.label }); + + return { ok: true, completed: true }; +``` + +New: +```ts + await logActivity({ userId: locals.user.id, type: 'tile_complete', detail: tile.label }); + + const { ordered, positions } = await boardPositions(locals.user.id); + const toggledIdx = ordered.findIndex((t) => t.id === tileId); + if (toggledIdx >= 0) { + const before = new Set(positions); + before.delete(toggledIdx); + const { justWon, lineLabel } = bingoWinTransition(before, positions); + if (justWon) { + await logActivity({ userId: locals.user.id, type: 'bingo_win', detail: lineLabel }); + } + } + + return { ok: true, completed: true }; +``` + +- [ ] **Step 5: Log card_reshuffle on self-reset** + +The `reset` action regenerates the seed (a reshuffle). Add a log call: + +Old: +```ts + reset: async ({ locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + await resetUserBoard(locals.user.id, true); + return { ok: true, reset: true }; + } +``` + +New: +```ts + reset: async ({ locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + await resetUserBoard(locals.user.id, true); + await logActivity({ userId: locals.user.id, type: 'card_reshuffle' }); + return { ok: true, reset: true }; + } +``` + +- [ ] **Step 6: Type-check** + +Run: `bun run check` +Expected: no errors in `bingo/+page.server.ts`. + +- [ ] **Step 7: Checkpoint** — `check` clean. + +--- + +## Task 7: Log admin user actions + +**Files:** +- Modify: `src/routes/admin/users/[id]/+page.server.ts` + +- [ ] **Step 1: Import logActivity** + +At the top of the file, add: + +```ts +import { logActivity } from '$lib/server/activity'; +``` + +- [ ] **Step 2: Log admin_verify** + +In the `verify` action, the target is selected as `{ id, cardSeed }`. Add `name` to that select and log after the update: + +Old: +```ts + const [target] = await db + .select({ id: user.id, cardSeed: user.cardSeed }) + .from(user) + .where(eq(user.id, params.id)) + .limit(1); + if (!target) return fail(404, { message: 'User not found' }); +``` + +New: +```ts + const [target] = await db + .select({ id: user.id, name: user.name, cardSeed: user.cardSeed }) + .from(user) + .where(eq(user.id, params.id)) + .limit(1); + if (!target) return fail(404, { message: 'User not found' }); +``` + +Then, at the end of `verify`, after the `db.update(...)` that sets `bingoVerifiedAt` and before `return { ok: true, verified: true };`: + +```ts + await logActivity({ userId: locals.user.id, type: 'admin_verify', detail: target.name }); +``` + +(`locals.user` is already narrowed non-null by the `if (!locals.user) throw error(403, ...)` guard just above that update.) + +- [ ] **Step 3: Log admin_unverify** + +The `unverify` action currently does not select the target. Add a name lookup and log. Replace the body: + +Old: +```ts + unverify: async ({ params, locals }) => { + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + await db + .update(user) + .set({ bingoVerifiedAt: null, bingoVerifiedBy: null, updatedAt: new Date() }) + .where(eq(user.id, params.id)); + return { ok: true, verified: false }; + }, +``` + +New: +```ts + unverify: async ({ params, locals }) => { + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); + const [target] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, params.id)) + .limit(1); + await db + .update(user) + .set({ bingoVerifiedAt: null, bingoVerifiedBy: null, updatedAt: new Date() }) + .where(eq(user.id, params.id)); + await logActivity({ userId: locals.user.id, type: 'admin_unverify', detail: target?.name ?? null }); + return { ok: true, verified: false }; + }, +``` + +- [ ] **Step 4: Log admin_reset** + +Replace the `reset` action body similarly: + +Old: +```ts + reset: async ({ params, locals }) => { + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + await db.delete(bingoProgress).where(eq(bingoProgress.userId, params.id)); + await db + .update(user) + .set({ + bingoVerifiedAt: null, + bingoVerifiedBy: null, + cardSeed: randomUUID(), + updatedAt: new Date() + }) + .where(eq(user.id, params.id)); + return { ok: true, reset: true }; + } +``` + +New: +```ts + reset: async ({ params, locals }) => { + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); + const [target] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, params.id)) + .limit(1); + await db.delete(bingoProgress).where(eq(bingoProgress.userId, params.id)); + await db + .update(user) + .set({ + bingoVerifiedAt: null, + bingoVerifiedBy: null, + cardSeed: randomUUID(), + updatedAt: new Date() + }) + .where(eq(user.id, params.id)); + await logActivity({ userId: locals.user.id, type: 'admin_reset', detail: target?.name ?? null }); + return { ok: true, reset: true }; + } +``` + +- [ ] **Step 5: Type-check** + +Run: `bun run check` +Expected: no errors in `admin/users/[id]/+page.server.ts`. + +- [ ] **Step 6: Checkpoint** — `check` clean. + +--- + +## Task 8: Log admin tile actions + +**Files:** +- Modify: `src/routes/admin/tiles/+page.server.ts` + +- [ ] **Step 1: Import logActivity** + +At the top, add: + +```ts +import { logActivity } from '$lib/server/activity'; +``` + +- [ ] **Step 2: Log tile_update** + +In the `update` action, after the `db.update(bingoTile)...` call and before `return { ok: true };`: + +```ts + await logActivity({ userId: locals.user.id, type: 'tile_update', detail: label }); +``` + +(`locals.user` is non-null after the `isAdmin` guard; if `bun run check` complains, change the guard to `if (!isAdmin(locals.user) || !locals.user)`.) + +- [ ] **Step 3: Log tile_create** + +In the `create` action, after the `db.insert(bingoTile)...` call and before `return { ok: true };`: + +```ts + await logActivity({ userId: locals.user.id, type: 'tile_create', detail: label }); +``` + +- [ ] **Step 4: Log tile_delete** + +The `delete` action only has the id. Fetch the label before deleting, then log. Replace the body: + +Old: +```ts + delete: async ({ request, locals }) => { + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + const form = await request.formData(); + const id = parseTileId(form); + if (!id) return fail(400, { message: 'id required' }); + await db.delete(bingoTile).where(eq(bingoTile.id, id)); + return { ok: true }; + }, +``` + +New: +```ts + delete: async ({ request, locals }) => { + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); + const form = await request.formData(); + const id = parseTileId(form); + if (!id) return fail(400, { message: 'id required' }); + const [tile] = await db + .select({ label: bingoTile.label }) + .from(bingoTile) + .where(eq(bingoTile.id, id)) + .limit(1); + await db.delete(bingoTile).where(eq(bingoTile.id, id)); + await logActivity({ userId: locals.user.id, type: 'tile_delete', detail: tile?.label ?? null }); + return { ok: true }; + }, +``` + +- [ ] **Step 5: Log tile_bulk_add** + +In the `bulkAdd` action, after the transaction that inserts the labels and before `return { ok: true, form: 'bulkAdd', added: labels.length };`: + +```ts + await logActivity({ + userId: locals.user.id, + type: 'tile_bulk_add', + detail: `${labels.length} tiles` + }); +``` + +If `locals.user` is flagged possibly-null here, also update that action's guard to `if (!isAdmin(locals.user) || !locals.user)`. + +- [ ] **Step 6: Type-check** + +Run: `bun run check` +Expected: no errors in `admin/tiles/+page.server.ts`. + +- [ ] **Step 7: Checkpoint** — `check` clean. + +--- + +## Task 9: Feed load with filters, limit, live dependency, and purge + +**Files:** +- Modify: `src/routes/admin/activity/+page.server.ts` + +- [ ] **Step 1: Replace the file with the filtered load + purge action** + +Replace the entire contents of `src/routes/admin/activity/+page.server.ts` with: + +```ts +// src/routes/admin/activity/+page.server.ts +import { error, fail } from '@sveltejs/kit'; +import { and, desc, eq, lt } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { activityLog, user } from '$lib/server/db/schema'; +import { isAdmin } from '$lib/server/admin'; +import { buildActivityFilters, parsePurgeRequest } from '$lib/server/activityQuery'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, depends }) => { + depends('app:activity'); + + const { type, userId, limit } = buildActivityFilters(url.searchParams); + + const conditions = []; + if (type) conditions.push(eq(activityLog.type, type)); + if (userId) conditions.push(eq(activityLog.userId, userId)); + const where = conditions.length ? and(...conditions) : undefined; + + const events = await db + .select({ + id: activityLog.id, + type: activityLog.type, + detail: activityLog.detail, + createdAt: activityLog.createdAt, + userName: user.name, + userImage: user.image + }) + .from(activityLog) + .innerJoin(user, eq(activityLog.userId, user.id)) + .where(where) + .orderBy(desc(activityLog.createdAt)) + .limit(limit); + + // Distinct users that appear in the log, for the user filter dropdown. + const users = await db + .selectDistinct({ id: user.id, name: user.name }) + .from(activityLog) + .innerJoin(user, eq(activityLog.userId, user.id)) + .orderBy(user.name); + + return { + events, + users, + filters: { type, userId, limit }, + hasMore: events.length === limit + }; +}; + +export const actions: Actions = { + purge: async ({ request, locals }) => { + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + const form = await request.formData(); + const req = parsePurgeRequest( + form.get('mode')?.toString() ?? null, + form.get('before')?.toString() ?? null + ); + if (!req) return fail(400, { message: 'Invalid purge request' }); + + if (req.mode === 'all') { + await db.delete(activityLog); + } else { + await db.delete(activityLog).where(lt(activityLog.createdAt, req.before)); + } + return { ok: true, purged: true }; + } +}; +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run check` +Expected: no errors. (If `selectDistinct` is flagged, confirm drizzle-orm 0.38 exposes it — it does; the chain mirrors `.select`.) + +- [ ] **Step 3: Checkpoint** — `check` clean. + +--- + +## Task 10: Export endpoint + +**Files:** +- Create: `src/routes/admin/activity/export/+server.ts` + +- [ ] **Step 1: Create the endpoint** + +Create `src/routes/admin/activity/export/+server.ts`: + +```ts +import { error } from '@sveltejs/kit'; +import { and, desc, eq } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { activityLog, user } from '$lib/server/db/schema'; +import { isAdmin } from '$lib/server/admin'; +import { buildActivityFilters } from '$lib/server/activityQuery'; +import { toCsv, toJson } from '$lib/server/activityExport'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, locals }) => { + // The /admin layout guard does not run for standalone endpoints, so check here. + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + + const { type, userId } = buildActivityFilters(url.searchParams); + const format = url.searchParams.get('format') === 'json' ? 'json' : 'csv'; + + const conditions = []; + if (type) conditions.push(eq(activityLog.type, type)); + if (userId) conditions.push(eq(activityLog.userId, userId)); + const where = conditions.length ? and(...conditions) : undefined; + + const rows = await db + .select({ + createdAt: activityLog.createdAt, + userName: user.name, + type: activityLog.type, + detail: activityLog.detail + }) + .from(activityLog) + .innerJoin(user, eq(activityLog.userId, user.id)) + .where(where) + .orderBy(desc(activityLog.createdAt)); + + const stamp = new Date().toISOString().slice(0, 10); + + if (format === 'json') { + return new Response(toJson(rows), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="activity-${stamp}.json"` + } + }); + } + + return new Response(toCsv(rows), { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="activity-${stamp}.csv"` + } + }); +}; +``` + +- [ ] **Step 2: Type-check** + +Run: `bun run check` +Expected: no errors. + +- [ ] **Step 3: Checkpoint** — `check` clean. + +--- + +## Task 11: Feed UI (filters, live poll, export, purge, new labels) + +**Files:** +- Modify: `src/routes/admin/activity/+page.svelte` + +- [ ] **Step 1: Replace the page with the controls-enabled version** + +Replace the entire contents of `src/routes/admin/activity/+page.svelte` with: + +```svelte + + + +
+

Activity

+

+ Showing {data.events.length}{data.hasMore ? '+' : ''} events +

+
+ + +
+ + + + + +
+ + +
+ Purge activity log +
{ + // SlideToConfirm triggers submit; nothing to prevent here. + }} + > +
+ + + +
+ { + (document.querySelector('form[action="?/purge"]') as HTMLFormElement)?.requestSubmit(); + }} + /> + +
+ +{#if data.events.length === 0} +

+ No activity matches these filters. +

+{:else} + +
+ {#each data.events as e (e.id)} +
+
+ {#if e.userImage} + + {:else} + + {initials(e.userName)} + + {/if} +
+
{e.userName}
+
{when(e.createdAt)}
+
+
+
+ + {eventLabel(e.type, e.detail)} + +
+
+ {/each} +
+ + + + + {#if data.hasMore} +
+ +
+ {/if} +{/if} +``` + +Notes for the implementer: +- `livePoll` is imported from `$lib/livePoll.svelte` (the runes module; the file is `livePoll.svelte.ts`, imported without the `.ts`). +- `LIMIT_STEP` is imported from `$lib/server/activityQuery`. This is a server module, but only the `LIMIT_STEP` numeric constant is referenced; it pulls no server-only runtime in. If `svelte-check` or the bundler objects to importing from a `server` path in a component, move `DEFAULT_LIMIT`/`LIMIT_STEP` constants into `$lib/activityMeta.ts` and import them there instead, updating `activityQuery.ts` to re-import them. +- The `exportHref` derived builds `/admin/activity/export?...&format=csv`. Confirm the resulting URL has exactly one `?` and `format` appended correctly during manual testing; simplify the helper if the double-`queryWith` call reads awkwardly. + +- [ ] **Step 2: Type-check** + +Run: `bun run check` +Expected: no errors. Resolve any `$lib/server` import objection per the note above if it appears. + +- [ ] **Step 3: Checkpoint** — `check` clean. + +--- + +## Task 12: Full verification + +- [ ] **Step 1: Run all unit tests** + +Run: `bun run test` +Expected: all tests in `activityMeta`, `bingo`, `activityQuery`, `activityExport` pass. + +- [ ] **Step 2: Type-check the whole project** + +Run: `bun run check` +Expected: 0 errors. + +- [ ] **Step 3: Manual smoke test (requires a running DB + dev server)** + +Run: `bun run dev`, sign in as an admin, then verify: +- Mark a tile to bingo: feed shows `tile_complete` rows and a single `bingo_win` with the winning line; marking more tiles does not add more `bingo_win` rows. +- Un-mark a tile: a `tile_uncomplete` row appears. +- Reset your card: a `card_reshuffle` row appears. +- As admin, verify/un-verify/reset a player and create/edit/delete/bulk-add tiles: matching `admin_*` / `tile_*` rows appear with the target name/label in the event text. +- On `/admin/activity`: filter by type and by user (URL updates, list narrows); "Load more" grows the list; leaving the tab and returning refreshes (live poll). +- Click Export CSV and Export JSON: files download, honoring the active filters. +- Purge "older than" a date and purge "everything" (behind the slide-to-confirm): rows are removed. + +- [ ] **Step 4: Final checkpoint** — report results to the user and leave committing to them. + +--- + +## Self-Review Notes + +- **Spec coverage:** New event types (Task 6-8) cover tile_uncomplete, bingo_win, card_reshuffle, admin verify/unverify/reset, tile create/update/delete/bulk_add. Admin feed filter-by-type/user, pagination (growing limit), live updates (Task 9 + 11). Export CSV/JSON (Task 5 + 10 + 11). Manual purge all/older-than (Task 4 + 9 + 11). Auto-pruning intentionally excluded per spec. Target-in-detail (no schema change) honored. All spec sections map to tasks. +- **Open points from spec:** `bingo_win` line description implemented via `describeWinLine` with a `null`/"Bingo!" fallback in `eventLabel`. Purge action is not itself logged (matches spec default). +- **Type consistency:** `ActivityType` is defined once in `activityMeta.ts` and re-exported by `activity.ts`; `bingoWinTransition`/`describeWinLine`, `buildActivityFilters`/`parsePurgeRequest`, `toCsv`/`toJson`, and `ExportRow` names are used identically across tasks and tests. diff --git a/docs/superpowers/specs/2026-06-01-activity-logging-expansion-design.md b/docs/superpowers/specs/2026-06-01-activity-logging-expansion-design.md new file mode 100644 index 0000000..a22bb0b --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-activity-logging-expansion-design.md @@ -0,0 +1,171 @@ +# Activity Logging Expansion — Design + +Date: 2026-06-01 + +## Background + +The app already has an append-only activity log: + +- `activity_log` table: `id`, `user_id` (FK → user, cascade), `type` (free text), `detail` (nullable text), `created_at` (indexed). +- `logActivity({ userId, type, detail })` in `src/lib/server/activity.ts` — best-effort, swallows its own errors so logging never breaks auth or gameplay. +- Currently captures `login`, `logout`, `tile_complete`. +- Admin feed at `/admin/activity` loads the latest 200 events joined to `user`, no controls. + +This design expands the log in three directions: more event types, a better admin feed (filter / paginate / live), and export + purge. It deliberately avoids richer per-event metadata (no IP/user-agent, no JSON detail, no schema migration). + +## Goals + +1. Capture more player and admin actions. +2. Make the admin feed filterable, paginated, and live-updating. +3. Allow admins to export the (filtered) log and purge old entries. + +## Non-goals + +- No `target_user_id` column or structured JSON detail. Admin-action targets are stored as plain text in `detail`. +- No automatic retention/pruning on a schedule. +- No IP address, user agent, or other request metadata. + +## 1. New event types + +The `type` column remains free text. The `ActivityType` union in `src/lib/server/activity.ts` is expanded so call sites are type-checked: + +```ts +export type ActivityType = + | 'login' + | 'logout' + | 'tile_complete' + | 'tile_uncomplete' + | 'bingo_win' + | 'card_reshuffle' + | 'admin_verify' + | 'admin_unverify' + | 'admin_reset' + | 'tile_create' + | 'tile_update' + | 'tile_delete' + | 'tile_bulk_add'; +``` + +For category-based UI (filter grouping, badge styling), a helper maps each type to a category: + +| Category | Types | +|----------|-------| +| Auth | `login`, `logout` | +| Play | `tile_complete`, `tile_uncomplete`, `card_reshuffle` | +| Wins | `bingo_win` | +| Admin | `admin_verify`, `admin_unverify`, `admin_reset`, `tile_create`, `tile_update`, `tile_delete`, `tile_bulk_add` | + +### Where each event is logged + +| Type | Location | `userId` | `detail` | +|------|----------|----------|----------| +| `tile_uncomplete` | `bingo/+page.server.ts` `toggle`, delete branch | player | tile label | +| `bingo_win` | `bingo/+page.server.ts` `toggle`, after insert | player | winning line description (e.g. `Row 3`) | +| `card_reshuffle` | `bingo/+page.server.ts` `reset` | player | null | +| `admin_verify` | `admin/users/[id]/+page.server.ts` `verify` | admin | target user name | +| `admin_unverify` | `admin/users/[id]/+page.server.ts` `unverify` | admin | target user name | +| `admin_reset` | `admin/users/[id]/+page.server.ts` `reset` | admin | target user name | +| `tile_create` | `admin/tiles/+page.server.ts` `create` | admin | tile label | +| `tile_update` | `admin/tiles/+page.server.ts` `update` | admin | tile label | +| `tile_delete` | `admin/tiles/+page.server.ts` `delete` | admin | tile label | +| `tile_bulk_add` | `admin/tiles/+page.server.ts` `bulkAdd` | admin | count added (e.g. `42 tiles`) | + +Notes: + +- `tile_complete` stays where it is. +- For admin actions, the actor (`userId`) is the admin; the affected user/tile is named in `detail`. Admin user-action handlers already load the target row, so the target name is available (or one extra small select where it is not). +- For `tile_update` / `tile_delete`, the label is fetched/known in the action before mutating so it can be recorded. + +### Bingo win detection + +The `toggle` action does not currently run `detectBingo`. To log `bingo_win` exactly once (on the transition into a bingo, not on every later tile): + +1. After inserting the new `bingoProgress` row, load the player's tiles + completed set and compute `completedPositions` (same logic as the page `load`). +2. Run `detectBingo(completedPositions)` → `afterHasBingo`. +3. Compute the "before" set by removing the just-added tile's position, run `detectBingo` → `beforeHasBingo`. +4. If `afterHasBingo && !beforeHasBingo`, call `logActivity({ type: 'bingo_win', detail: })`. + +The winning line description is derived from `winningPositions` (e.g. which row/column/diagonal). If a precise description is awkward, fall back to `detail: null` and a generic "Bingo!" label in the UI; correctness of the single-fire transition is the priority. + +All new `logActivity` calls follow the existing best-effort contract: a logging failure must never fail the action. + +## 2. Admin feed UI + +File: `src/routes/admin/activity/+page.server.ts` and `+page.svelte`. + +### Filtering + +- URL query params drive the query: `?type=&user=`. +- `load` parses params, builds `where` conditions, and returns the filtered, ordered, limited rows plus the option lists for the controls. +- Type control: a `` populated from the distinct users that appear in the log (`select distinct user join`), with an "All" default. +- Changing a control updates the URL (client-side navigation), which re-runs `load`. + +### Pagination + +- A `limit` query param, default 200, with a "Load more" control that increases it (200 → 400 → …). +- `load` applies `.limit(limit)`. The response includes whether more rows likely exist (e.g. returned count === limit) to decide whether to show "Load more". +- Growing-limit is chosen over cursors because it composes cleanly with live polling (each poll re-fetches the current top-N) and the data volume is small for an admin tool. + +### Live updates + +- `load` calls `depends('app:activity')`. +- `+page.svelte` calls `livePoll('app:activity')` once at init (existing leaderboard pattern). On each interval the load re-runs with the current URL params, so new matching events appear at the top. + +### Rendering + +- `label(type, detail)` and `badgeClass(type)` are extended to cover all new types, using the category mapping for badge colors. +- The existing mobile-card / desktop-table layouts are reused; only the controls bar (filters, export buttons, purge, load-more) is added above the list. + +## 3. Export and purge + +### Export + +- New endpoint: `src/routes/admin/activity/export/+server.ts`, `GET`. +- Admin-only (reuse `isAdmin(locals.user)`; 403 otherwise). +- Query params: `format=csv|json` plus the same `type` / `user` filters as the feed. No `limit` — exports the full filtered set (ordered newest first). +- CSV columns: `created_at`, `user_name`, `type`, `detail`. JSON: array of the same fields. +- Response sets `Content-Disposition: attachment` with a sensible filename (e.g. `activity-YYYY-MM-DD.csv`). +- The feed shows two buttons (CSV / JSON) that link to this endpoint with the current filter params appended. + +### Purge + +- A `purge` form action on `admin/activity/+page.server.ts`, admin-only. +- Modes: + - Clear all: deletes every row. + - Clear older-than: a date input; deletes rows with `created_at < chosen date`. +- The UI requires an explicit confirmation step before submitting (e.g. a confirm dialog or a slide-to-confirm control, consistent with the existing `SlideToConfirm` component if suitable). +- Purge itself is an admin action; whether to log the purge is a minor open point (see below). + +## Data flow summary + +``` +Player marks tile -> toggle insert -> logActivity(tile_complete) + -> detect transition -> logActivity(bingo_win) [once] +Player unmarks tile -> toggle delete -> logActivity(tile_uncomplete) +Player resets card -> reset -> logActivity(card_reshuffle) +Admin verify/reset -> users/[id] -> logActivity(admin_*, detail = target name) +Admin edits tiles -> tiles actions -> logActivity(tile_*, detail = label/count) + +Admin feed -> load(type,user,limit) + depends('app:activity') + livePoll +Export -> GET /admin/activity/export?format&type&user (full filtered set) +Purge -> action purge(all | older-than date) +``` + +## Error handling + +- All `logActivity` calls are best-effort and never throw (unchanged contract). +- Export and purge enforce `isAdmin`; non-admins get 403. +- Filter params are validated/sanitized in `load`; unknown values fall back to "All". + +## Testing + +- Unit: bingo-win transition logic (false→true fires once; staying in bingo does not re-fire; losing and regaining fires again). +- Unit: filter `where`-clause construction for type/user/limit and the older-than purge predicate. +- Unit: CSV/JSON serialization of a sample row set. +- Manual: feed filtering, load-more, live update, CSV/JSON download, purge-all and purge-older-than with confirmation. + +## Open points (minor, default chosen) + +- Winning-line description for `bingo_win`: implement if straightforward, else `null` + generic "Bingo!" label. +- Logging the purge action itself: default to NOT logging it (purge is about clearing the log; a surviving self-entry is confusing). Revisit if an audit trail of purges is wanted. diff --git a/package.json b/package.json index ab2945f..c1e6e0a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", - "db:seed": "bun run src/lib/server/db/seed.ts" + "db:seed": "bun run src/lib/server/db/seed.ts", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.12", @@ -26,7 +28,8 @@ "svelte-check": "^4.1.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^4.1.8" }, "dependencies": { "better-auth": "^1.1.13", diff --git a/src/lib/activityMeta.test.ts b/src/lib/activityMeta.test.ts new file mode 100644 index 0000000..aa7d432 --- /dev/null +++ b/src/lib/activityMeta.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + ACTIVITY_TYPES, + TYPE_GROUPS, + TYPE_LABEL, + categoryOf, + eventLabel, + badgeClass +} from './activityMeta'; + +describe('categoryOf', () => { + it('maps known types to categories', () => { + expect(categoryOf('login')).toBe('auth'); + expect(categoryOf('tile_complete')).toBe('play'); + expect(categoryOf('bingo_win')).toBe('wins'); + expect(categoryOf('admin_reset')).toBe('admin'); + }); + + it('returns "other" for unknown types', () => { + expect(categoryOf('something_else')).toBe('other'); + }); +}); + +describe('eventLabel', () => { + it('formats player events', () => { + expect(eventLabel('tile_complete', 'Wear a hat')).toBe('Completed "Wear a hat"'); + expect(eventLabel('tile_uncomplete', 'Wear a hat')).toBe('Un-marked "Wear a hat"'); + expect(eventLabel('card_reshuffle', null)).toBe('Reshuffled card'); + expect(eventLabel('bingo_win', 'Row 3')).toBe('Bingo! (Row 3)'); + expect(eventLabel('bingo_win', null)).toBe('Bingo!'); + }); + + it('formats admin events with the target in detail', () => { + expect(eventLabel('admin_verify', 'Alice')).toBe('Verified Alice'); + expect(eventLabel('admin_reset', 'Alice')).toBe("Reset Alice's board"); + }); + + it('falls back to the raw type for unknown types', () => { + expect(eventLabel('mystery', null)).toBe('mystery'); + }); +}); + +describe('completeness', () => { + it('every type has a category, label, and badge class', () => { + for (const t of ACTIVITY_TYPES) { + expect(categoryOf(t)).not.toBe('other'); + expect(TYPE_LABEL[t]).toBeTruthy(); + expect(badgeClass(t)).toMatch(/border/); + } + }); + + it('TYPE_GROUPS covers exactly the full type set', () => { + const grouped = TYPE_GROUPS.flatMap((g) => g.types).sort(); + expect(grouped).toEqual([...ACTIVITY_TYPES].sort()); + }); +}); diff --git a/src/lib/activityMeta.ts b/src/lib/activityMeta.ts new file mode 100644 index 0000000..bc0708b --- /dev/null +++ b/src/lib/activityMeta.ts @@ -0,0 +1,117 @@ +export type ActivityCategory = 'auth' | 'play' | 'wins' | 'admin'; + +export const ACTIVITY_TYPES = [ + 'login', + 'logout', + 'tile_complete', + 'tile_uncomplete', + 'card_reshuffle', + 'bingo_win', + 'admin_verify', + 'admin_unverify', + 'admin_reset', + 'tile_create', + 'tile_update', + 'tile_delete', + 'tile_bulk_add' +] as const; + +export type ActivityType = (typeof ACTIVITY_TYPES)[number]; + +const CATEGORY: Record = { + login: 'auth', + logout: 'auth', + tile_complete: 'play', + tile_uncomplete: 'play', + card_reshuffle: 'play', + bingo_win: 'wins', + admin_verify: 'admin', + admin_unverify: 'admin', + admin_reset: 'admin', + tile_create: 'admin', + tile_update: 'admin', + tile_delete: 'admin', + tile_bulk_add: 'admin' +}; + +export const TYPE_LABEL: Record = { + login: 'Login', + logout: 'Logout', + tile_complete: 'Tile completed', + tile_uncomplete: 'Tile un-marked', + card_reshuffle: 'Card reshuffle', + bingo_win: 'Bingo win', + admin_verify: 'Admin: verify', + admin_unverify: 'Admin: un-verify', + admin_reset: 'Admin: reset board', + tile_create: 'Admin: create tile', + tile_update: 'Admin: edit tile', + tile_delete: 'Admin: delete tile', + tile_bulk_add: 'Admin: bulk add tiles' +}; + +export const TYPE_GROUPS: { label: string; category: ActivityCategory; types: ActivityType[] }[] = [ + { label: 'Auth', category: 'auth', types: ['login', 'logout'] }, + { label: 'Play', category: 'play', types: ['tile_complete', 'tile_uncomplete', 'card_reshuffle'] }, + { label: 'Wins', category: 'wins', types: ['bingo_win'] }, + { + label: 'Admin', + category: 'admin', + types: ['admin_verify', 'admin_unverify', 'admin_reset', 'tile_create', 'tile_update', 'tile_delete', 'tile_bulk_add'] + } +]; + +export function categoryOf(type: string): ActivityCategory | 'other' { + return (CATEGORY as Record)[type] ?? 'other'; +} + +export function eventLabel(type: string, detail: string | null): string { + switch (type) { + case 'login': + return 'Signed in'; + case 'logout': + return 'Signed out'; + case 'tile_complete': + return `Completed "${detail ?? ''}"`; + case 'tile_uncomplete': + return `Un-marked "${detail ?? ''}"`; + case 'card_reshuffle': + return 'Reshuffled card'; + case 'bingo_win': + return detail ? `Bingo! (${detail})` : 'Bingo!'; + case 'admin_verify': + return `Verified ${detail ?? 'a player'}`; + case 'admin_unverify': + return `Un-verified ${detail ?? 'a player'}`; + case 'admin_reset': + return `Reset ${detail ?? 'a player'}'s board`; + case 'tile_create': + return `Created tile "${detail ?? ''}"`; + case 'tile_update': + return `Edited tile "${detail ?? ''}"`; + case 'tile_delete': + return `Deleted tile "${detail ?? ''}"`; + case 'tile_bulk_add': + return `Bulk-added ${detail ?? ''}`; + default: + return type; + } +} + +export function badgeClass(type: string): string { + switch (categoryOf(type)) { + case 'wins': + return 'bg-amber-500/20 border border-amber-400/40 text-amber-200'; + case 'play': + return 'bg-emerald-500/20 border border-emerald-400/40 text-emerald-200'; + case 'admin': + return 'bg-sky-500/20 border border-sky-400/40 text-sky-200'; + case 'auth': + default: + return 'bg-white/5 border border-white/10 text-slate-300'; + } +} + +export const DEFAULT_LIMIT = 200; +export const LIMIT_STEP = 200; +export const MAX_LIMIT = 5000; diff --git a/src/lib/bingo.test.ts b/src/lib/bingo.test.ts new file mode 100644 index 0000000..350784e --- /dev/null +++ b/src/lib/bingo.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { describeWinLine, bingoWinTransition } from './bingo'; + +describe('describeWinLine', () => { + it('names rows, columns, and diagonals', () => { + expect(describeWinLine([0, 1, 2, 3, 4])).toBe('Row 1'); + expect(describeWinLine([10, 11, 12, 13, 14])).toBe('Row 3'); + expect(describeWinLine([2, 7, 12, 17, 22])).toBe('Column 3'); + expect(describeWinLine([0, 6, 12, 18, 24])).toBe('Diagonal ↘'); + expect(describeWinLine([4, 8, 12, 16, 20])).toBe('Diagonal ↗'); + }); +}); + +describe('bingoWinTransition', () => { + const row1 = new Set([0, 1, 2, 3]); // row 1 missing position 4 + + it('fires once when a line is completed', () => { + const before = row1; + const after = new Set([0, 1, 2, 3, 4]); + expect(bingoWinTransition(before, after)).toEqual({ justWon: true, lineLabel: 'Row 1' }); + }); + + it('does not fire when already in bingo and another tile is marked', () => { + const before = new Set([0, 1, 2, 3, 4]); // already row 1 + const after = new Set([0, 1, 2, 3, 4, 7]); + expect(bingoWinTransition(before, after)).toEqual({ justWon: false, lineLabel: null }); + }); + + it('does not fire when no line is complete', () => { + const before = new Set([0, 1]); + const after = new Set([0, 1, 2]); + expect(bingoWinTransition(before, after)).toEqual({ justWon: false, lineLabel: null }); + }); + + it('fires again after losing and regaining a bingo', () => { + const before = new Set([0, 1, 2, 3]); + const after = new Set([0, 1, 2, 3, 4]); + expect(bingoWinTransition(before, after).justWon).toBe(true); + }); +}); diff --git a/src/lib/bingo.ts b/src/lib/bingo.ts index 197d08e..4345867 100644 --- a/src/lib/bingo.ts +++ b/src/lib/bingo.ts @@ -30,3 +30,31 @@ export function detectBingo(completedPositions: Set): { const winningPositions = new Set(winningLines.flat()); return { hasBingo: winningLines.length > 0, winningLines, winningPositions }; } + +export function describeWinLine(line: number[]): string { + const rows = line.map((p) => Math.floor(p / GRID_SIZE)); + const cols = line.map((p) => p % GRID_SIZE); + if (rows.every((r) => r === rows[0])) return `Row ${rows[0] + 1}`; + if (cols.every((c) => c === cols[0])) return `Column ${cols[0] + 1}`; + if (line.every((p, i) => p === i * GRID_SIZE + i)) return 'Diagonal ↘'; + return 'Diagonal ↗'; +} + +/** + * Detects the moment a player crosses into a bingo. Returns justWon=true only on + * the false -> true transition, so marking further tiles while already winning + * does not re-fire. lineLabel describes the first newly-completed line. + */ +export function bingoWinTransition( + before: Set, + after: Set +): { justWon: boolean; lineLabel: string | null } { + const beforeRes = detectBingo(before); + const afterRes = detectBingo(after); + if (!afterRes.hasBingo || beforeRes.hasBingo) { + return { justWon: false, lineLabel: null }; + } + const beforeKeys = new Set(beforeRes.winningLines.map((l) => l.join(','))); + const newLine = afterRes.winningLines.find((l) => !beforeKeys.has(l.join(','))); + return { justWon: true, lineLabel: newLine ? describeWinLine(newLine) : null }; +} diff --git a/src/lib/server/activity.ts b/src/lib/server/activity.ts index 340553c..4a76248 100644 --- a/src/lib/server/activity.ts +++ b/src/lib/server/activity.ts @@ -3,7 +3,8 @@ import { randomUUID } from 'node:crypto'; import { db } from './db'; import { activityLog } from './db/schema'; -export type ActivityType = 'login' | 'logout' | 'tile_complete'; +import type { ActivityType } from '$lib/activityMeta'; +export type { ActivityType }; /** * Best-effort append to the activity log. Never throws: a logging failure must not diff --git a/src/lib/server/activityExport.test.ts b/src/lib/server/activityExport.test.ts new file mode 100644 index 0000000..dd7a5ea --- /dev/null +++ b/src/lib/server/activityExport.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { toCsv, toJson, type ExportRow } from './activityExport'; + +const rows: ExportRow[] = [ + { createdAt: '2026-06-01T12:00:00.000Z', userName: 'Alice', type: 'bingo_win', detail: 'Row 3' }, + { createdAt: '2026-06-01T12:01:00.000Z', userName: 'Bob, Jr', type: 'tile_complete', detail: 'Say "hi"' }, + { createdAt: '2026-06-01T12:02:00.000Z', userName: 'Cleo', type: 'login', detail: null } +]; + +describe('toCsv', () => { + it('writes a header and one row per event', () => { + const lines = toCsv(rows).split('\n'); + expect(lines[0]).toBe('created_at,user_name,type,detail'); + expect(lines).toHaveLength(4); + }); + + it('quotes cells containing commas, quotes, or newlines', () => { + const csv = toCsv(rows); + expect(csv).toContain('"Bob, Jr"'); + expect(csv).toContain('"Say ""hi"""'); + }); + + it('renders null detail as an empty cell', () => { + const last = toCsv(rows).split('\n')[3]; + expect(last.endsWith(',')).toBe(true); + }); +}); + +describe('toJson', () => { + it('emits an array of normalized rows', () => { + const parsed = JSON.parse(toJson(rows)); + expect(parsed).toHaveLength(3); + expect(parsed[0]).toEqual({ + createdAt: '2026-06-01T12:00:00.000Z', + userName: 'Alice', + type: 'bingo_win', + detail: 'Row 3' + }); + expect(parsed[2].detail).toBeNull(); + }); +}); diff --git a/src/lib/server/activityExport.ts b/src/lib/server/activityExport.ts new file mode 100644 index 0000000..6239a5d --- /dev/null +++ b/src/lib/server/activityExport.ts @@ -0,0 +1,38 @@ +export type ExportRow = { + createdAt: Date | string; + userName: string; + type: string; + detail: string | null; +}; + +function csvCell(value: string): string { + return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} + +export function toCsv(rows: ExportRow[]): string { + const lines = ['created_at,user_name,type,detail']; + for (const r of rows) { + lines.push( + [ + new Date(r.createdAt).toISOString(), + csvCell(r.userName), + csvCell(r.type), + csvCell(r.detail ?? '') + ].join(',') + ); + } + return lines.join('\n'); +} + +export function toJson(rows: ExportRow[]): string { + return JSON.stringify( + rows.map((r) => ({ + createdAt: new Date(r.createdAt).toISOString(), + userName: r.userName, + type: r.type, + detail: r.detail ?? null + })), + null, + 2 + ); +} diff --git a/src/lib/server/activityQuery.test.ts b/src/lib/server/activityQuery.test.ts new file mode 100644 index 0000000..e98479e --- /dev/null +++ b/src/lib/server/activityQuery.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { buildActivityFilters, parsePurgeRequest, DEFAULT_LIMIT, MAX_LIMIT } from './activityQuery'; + +const params = (q: string) => new URLSearchParams(q); + +describe('buildActivityFilters', () => { + it('defaults to no filters and the default limit', () => { + expect(buildActivityFilters(params(''))).toEqual({ + type: null, + userId: null, + limit: DEFAULT_LIMIT + }); + }); + + it('accepts a known type and a user id', () => { + expect(buildActivityFilters(params('type=bingo_win&user=abc123'))).toEqual({ + type: 'bingo_win', + userId: 'abc123', + limit: DEFAULT_LIMIT + }); + }); + + it('rejects an unknown type', () => { + expect(buildActivityFilters(params('type=not_a_type')).type).toBeNull(); + }); + + it('clamps the limit to MAX_LIMIT and ignores junk', () => { + expect(buildActivityFilters(params('limit=999999')).limit).toBe(MAX_LIMIT); + expect(buildActivityFilters(params('limit=abc')).limit).toBe(DEFAULT_LIMIT); + expect(buildActivityFilters(params('limit=-5')).limit).toBe(DEFAULT_LIMIT); + expect(buildActivityFilters(params('limit=400')).limit).toBe(400); + }); +}); + +describe('parsePurgeRequest', () => { + it('parses purge-all', () => { + expect(parsePurgeRequest('all', null)).toEqual({ mode: 'all' }); + }); + + it('parses purge-older with a valid date', () => { + const result = parsePurgeRequest('older', '2026-01-01'); + expect(result).not.toBeNull(); + expect(result && result.mode).toBe('older'); + expect(result && 'before' in result && result.before instanceof Date).toBe(true); + }); + + it('rejects older without a date or with an invalid date', () => { + expect(parsePurgeRequest('older', null)).toBeNull(); + expect(parsePurgeRequest('older', 'not-a-date')).toBeNull(); + }); + + it('rejects unknown modes', () => { + expect(parsePurgeRequest('nuke', null)).toBeNull(); + expect(parsePurgeRequest(null, null)).toBeNull(); + }); +}); diff --git a/src/lib/server/activityQuery.ts b/src/lib/server/activityQuery.ts new file mode 100644 index 0000000..0389b0e --- /dev/null +++ b/src/lib/server/activityQuery.ts @@ -0,0 +1,39 @@ +import { ACTIVITY_TYPES, DEFAULT_LIMIT, LIMIT_STEP, MAX_LIMIT } from '$lib/activityMeta'; + +export type ActivityFilters = { + type: string | null; + userId: string | null; + limit: number; +}; + +export { DEFAULT_LIMIT, LIMIT_STEP, MAX_LIMIT }; + +export function buildActivityFilters(params: URLSearchParams): ActivityFilters { + const typeRaw = params.get('type'); + const type = typeRaw && (ACTIVITY_TYPES as readonly string[]).includes(typeRaw) ? typeRaw : null; + + const userRaw = params.get('user'); + const userId = userRaw && userRaw.length > 0 ? userRaw : null; + + const limitRaw = Number(params.get('limit')); + const limit = + Number.isInteger(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, MAX_LIMIT) : DEFAULT_LIMIT; + + return { type, userId, limit }; +} + +export type PurgeRequest = { mode: 'all' } | { mode: 'older'; before: Date }; + +export function parsePurgeRequest( + mode: string | null, + before: string | null +): PurgeRequest | null { + if (mode === 'all') return { mode: 'all' }; + if (mode === 'older') { + if (!before) return null; + const d = new Date(before); + if (Number.isNaN(d.getTime())) return null; + return { mode: 'older', before: d }; + } + return null; +} diff --git a/src/routes/admin/activity/+page.server.ts b/src/routes/admin/activity/+page.server.ts index 02e5896..0b2b4c5 100644 --- a/src/routes/admin/activity/+page.server.ts +++ b/src/routes/admin/activity/+page.server.ts @@ -1,10 +1,22 @@ // src/routes/admin/activity/+page.server.ts -import { desc, eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { and, desc, eq, lt } from 'drizzle-orm'; import { db } from '$lib/server/db'; import { activityLog, user } from '$lib/server/db/schema'; -import type { PageServerLoad } from './$types'; +import { isAdmin } from '$lib/server/admin'; +import { buildActivityFilters, parsePurgeRequest } from '$lib/server/activityQuery'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url, depends }) => { + depends('app:activity'); + + const { type, userId, limit } = buildActivityFilters(url.searchParams); + + const conditions = []; + if (type) conditions.push(eq(activityLog.type, type)); + if (userId) conditions.push(eq(activityLog.userId, userId)); + const where = conditions.length ? and(...conditions) : undefined; -export const load: PageServerLoad = async () => { const events = await db .select({ id: activityLog.id, @@ -16,8 +28,40 @@ export const load: PageServerLoad = async () => { }) .from(activityLog) .innerJoin(user, eq(activityLog.userId, user.id)) + .where(where) .orderBy(desc(activityLog.createdAt)) - .limit(200); + .limit(limit); + + // Distinct users that appear in the log, for the user filter dropdown. + const users = await db + .selectDistinct({ id: user.id, name: user.name }) + .from(activityLog) + .innerJoin(user, eq(activityLog.userId, user.id)) + .orderBy(user.name); + + return { + events, + users, + filters: { type, userId, limit }, + hasMore: events.length === limit + }; +}; + +export const actions: Actions = { + purge: async ({ request, locals }) => { + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + const form = await request.formData(); + const req = parsePurgeRequest( + form.get('mode')?.toString() ?? null, + form.get('before')?.toString() ?? null + ); + if (!req) return fail(400, { message: 'Invalid purge request' }); - return { events }; + if (req.mode === 'all') { + await db.delete(activityLog); + } else { + await db.delete(activityLog).where(lt(activityLog.createdAt, req.before)); + } + return { ok: true, purged: true }; + } }; diff --git a/src/routes/admin/activity/+page.svelte b/src/routes/admin/activity/+page.svelte index fb1b6db..66f0df6 100644 --- a/src/routes/admin/activity/+page.svelte +++ b/src/routes/admin/activity/+page.svelte @@ -1,7 +1,18 @@

Activity

-

{data.events.length} most recent events

+

+ Showing {data.events.length}{data.hasMore ? '+' : ''} events +

+ +
+ + + + + +
+ + +
+ Purge activity log +
+
+ + + +
+ purgeForm.requestSubmit()} + /> + +
+ {#if data.events.length === 0}

- No activity yet. + No activity matches these filters.

{:else} @@ -60,7 +178,7 @@
- {label(e.type, e.detail)} + {eventLabel(e.type, e.detail)}
@@ -97,7 +215,7 @@ - {label(e.type, e.detail)} + {eventLabel(e.type, e.detail)} @@ -105,4 +223,16 @@ + + {#if data.hasMore} +
+ +
+ {/if} {/if} diff --git a/src/routes/admin/activity/export/+server.ts b/src/routes/admin/activity/export/+server.ts new file mode 100644 index 0000000..03ecbc3 --- /dev/null +++ b/src/routes/admin/activity/export/+server.ts @@ -0,0 +1,51 @@ +import { error } from '@sveltejs/kit'; +import { and, desc, eq } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { activityLog, user } from '$lib/server/db/schema'; +import { isAdmin } from '$lib/server/admin'; +import { buildActivityFilters } from '$lib/server/activityQuery'; +import { toCsv, toJson } from '$lib/server/activityExport'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, locals }) => { + // The /admin layout guard does not run for standalone endpoints, so check here. + if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + + const { type, userId } = buildActivityFilters(url.searchParams); + const format = url.searchParams.get('format') === 'json' ? 'json' : 'csv'; + + const conditions = []; + if (type) conditions.push(eq(activityLog.type, type)); + if (userId) conditions.push(eq(activityLog.userId, userId)); + const where = conditions.length ? and(...conditions) : undefined; + + const rows = await db + .select({ + createdAt: activityLog.createdAt, + userName: user.name, + type: activityLog.type, + detail: activityLog.detail + }) + .from(activityLog) + .innerJoin(user, eq(activityLog.userId, user.id)) + .where(where) + .orderBy(desc(activityLog.createdAt)); + + const stamp = new Date().toISOString().slice(0, 10); + + if (format === 'json') { + return new Response(toJson(rows), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="activity-${stamp}.json"` + } + }); + } + + return new Response(toCsv(rows), { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="activity-${stamp}.csv"` + } + }); +}; diff --git a/src/routes/admin/tiles/+page.server.ts b/src/routes/admin/tiles/+page.server.ts index c28739c..5defbe4 100644 --- a/src/routes/admin/tiles/+page.server.ts +++ b/src/routes/admin/tiles/+page.server.ts @@ -5,6 +5,7 @@ import { db } from '$lib/server/db'; import { bingoTile } from '$lib/server/db/schema'; import { GRID_SIZE } from '$lib/bingo'; import { isAdmin } from '$lib/server/admin'; +import { logActivity } from '$lib/server/activity'; import type { Actions, PageServerLoad } from './$types'; const TARGET_TILES = GRID_SIZE * GRID_SIZE; @@ -35,7 +36,7 @@ function parseLabels(text: string): string[] { export const actions: Actions = { update: async ({ request, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); const form = await request.formData(); const id = parseTileId(form); if (!id) return fail(400, { message: 'id required' }); @@ -55,11 +56,12 @@ export const actions: Actions = { .update(bingoTile) .set({ label, position, isActive, isFreeSpace }) .where(eq(bingoTile.id, id)); + await logActivity({ userId: locals.user.id, type: 'tile_update', detail: label }); return { ok: true }; }, create: async ({ request, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); const form = await request.formData(); const label = String(form.get('label') ?? '').trim(); if (!label) return fail(400, { message: 'label required' }); @@ -75,20 +77,27 @@ export const actions: Actions = { isActive: true, isFreeSpace: false }); + await logActivity({ userId: locals.user.id, type: 'tile_create', detail: label }); return { ok: true }; }, delete: async ({ request, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); const form = await request.formData(); const id = parseTileId(form); if (!id) return fail(400, { message: 'id required' }); + const [tile] = await db + .select({ label: bingoTile.label }) + .from(bingoTile) + .where(eq(bingoTile.id, id)) + .limit(1); await db.delete(bingoTile).where(eq(bingoTile.id, id)); + await logActivity({ userId: locals.user.id, type: 'tile_delete', detail: tile?.label ?? null }); return { ok: true }; }, bulkAdd: async ({ request, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); const form = await request.formData(); const file = form.get('file'); if (!(file instanceof File) || file.size === 0) { @@ -131,6 +140,11 @@ export const actions: Actions = { ); }); + await logActivity({ + userId: locals.user.id, + type: 'tile_bulk_add', + detail: `${labels.length} tiles` + }); return { ok: true, form: 'bulkAdd', added: labels.length }; } }; diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 133f41e..dd1ee4d 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -6,6 +6,7 @@ import { bingoProgress, bingoTile, user } from '$lib/server/db/schema'; import { detectBingo } from '$lib/bingo'; import { shuffleTilesForUser } from '$lib/server/cardShuffle'; import { isAdmin } from '$lib/server/admin'; +import { logActivity } from '$lib/server/activity'; import type { Actions, PageServerLoad } from './$types'; async function loadBingoState(targetId: string, seed: string | null) { @@ -63,10 +64,10 @@ export const load: PageServerLoad = async ({ params }) => { export const actions: Actions = { verify: async ({ params, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); const [target] = await db - .select({ id: user.id, cardSeed: user.cardSeed }) + .select({ id: user.id, name: user.name, cardSeed: user.cardSeed }) .from(user) .where(eq(user.id, params.id)) .limit(1); @@ -77,7 +78,6 @@ export const actions: Actions = { return fail(400, { message: 'Player no longer has a bingo — refresh and re-check.' }); } - if (!locals.user) throw error(403, 'Admin access required'); await db .update(user) .set({ @@ -86,20 +86,32 @@ export const actions: Actions = { updatedAt: new Date() }) .where(eq(user.id, target.id)); + await logActivity({ userId: locals.user.id, type: 'admin_verify', detail: target.name }); return { ok: true, verified: true }; }, unverify: async ({ params, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); + const [target] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, params.id)) + .limit(1); await db .update(user) .set({ bingoVerifiedAt: null, bingoVerifiedBy: null, updatedAt: new Date() }) .where(eq(user.id, params.id)); + await logActivity({ userId: locals.user.id, type: 'admin_unverify', detail: target?.name ?? null }); return { ok: true, verified: false }; }, reset: async ({ params, locals }) => { - if (!isAdmin(locals.user)) throw error(403, 'Admin access required'); + if (!isAdmin(locals.user) || !locals.user) throw error(403, 'Admin access required'); + const [target] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, params.id)) + .limit(1); await db.delete(bingoProgress).where(eq(bingoProgress.userId, params.id)); await db .update(user) @@ -110,6 +122,7 @@ export const actions: Actions = { updatedAt: new Date() }) .where(eq(user.id, params.id)); + await logActivity({ userId: locals.user.id, type: 'admin_reset', detail: target?.name ?? null }); return { ok: true, reset: true }; } }; diff --git a/src/routes/bingo/+page.server.ts b/src/routes/bingo/+page.server.ts index 86fe37a..5f5b7f3 100644 --- a/src/routes/bingo/+page.server.ts +++ b/src/routes/bingo/+page.server.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto'; import { db } from '$lib/server/db'; import { bingoProgress, bingoTile, user } from '$lib/server/db/schema'; import { sql } from 'drizzle-orm'; -import { detectBingo } from '$lib/bingo'; +import { detectBingo, bingoWinTransition } from '$lib/bingo'; import { shuffleTilesForUser } from '$lib/server/cardShuffle'; import type { Actions, PageServerLoad } from './$types'; import { logActivity } from '$lib/server/activity'; @@ -22,6 +22,32 @@ async function resetUserBoard(userId: string, regenerateSeed: boolean): Promise< .where(eq(user.id, userId)); } +async function boardPositions(userId: string): Promise<{ + ordered: { id: string; isFreeSpace: boolean }[]; + positions: Set; +}> { + const tiles = await db + .select({ id: bingoTile.id, position: bingoTile.position, isFreeSpace: bingoTile.isFreeSpace }) + .from(bingoTile) + .orderBy(bingoTile.position); + const [u] = await db + .select({ cardSeed: user.cardSeed }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + const progress = await db + .select({ tileId: bingoProgress.tileId }) + .from(bingoProgress) + .where(eq(bingoProgress.userId, userId)); + const completed = new Set(progress.map((p) => p.tileId)); + const ordered = shuffleTilesForUser(tiles, u?.cardSeed ?? null); + const positions = new Set(); + ordered.forEach((t, idx) => { + if (completed.has(t.id) || t.isFreeSpace) positions.add(idx); + }); + return { ordered, positions }; +} + async function ensureCardSeed(userId: string, existing: string | null): Promise { if (existing) return existing; const seed = randomUUID(); @@ -102,6 +128,7 @@ export const actions: Actions = { await db .delete(bingoProgress) .where(and(eq(bingoProgress.userId, locals.user.id), eq(bingoProgress.tileId, tileId))); + await logActivity({ userId: locals.user.id, type: 'tile_uncomplete', detail: tile.label }); return { ok: true, completed: false }; } @@ -113,12 +140,24 @@ export const actions: Actions = { await logActivity({ userId: locals.user.id, type: 'tile_complete', detail: tile.label }); + const { ordered, positions } = await boardPositions(locals.user.id); + const toggledIdx = ordered.findIndex((t) => t.id === tileId); + if (toggledIdx >= 0) { + const before = new Set(positions); + before.delete(toggledIdx); + const { justWon, lineLabel } = bingoWinTransition(before, positions); + if (justWon) { + await logActivity({ userId: locals.user.id, type: 'bingo_win', detail: lineLabel }); + } + } + return { ok: true, completed: true }; }, reset: async ({ locals }) => { if (!locals.user) throw error(401, 'Unauthorized'); await resetUserBoard(locals.user.id, true); + await logActivity({ userId: locals.user.id, type: 'card_reshuffle' }); return { ok: true, reset: true }; } }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..95c5b8a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { alias: { $lib: path.resolve('./src/lib') } }, + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + passWithNoTests: true + } +});