Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1,504 changes: 1,504 additions & 0 deletions docs/superpowers/plans/2026-06-01-activity-logging-expansion.md

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions docs/superpowers/specs/2026-06-01-activity-logging-expansion-design.md
Original file line number Diff line number Diff line change
@@ -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: <line desc> })`.

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=<type>&user=<userId>`.
- `load` parses params, builds `where` conditions, and returns the filtered, ordered, limited rows plus the option lists for the controls.
- Type control: a `<select>` grouped by category (Auth / Play / Wins / Admin) with an "All" default.
- User control: a `<select>` 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.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions src/lib/activityMeta.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
117 changes: 117 additions & 0 deletions src/lib/activityMeta.ts
Original file line number Diff line number Diff line change
@@ -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<ActivityType, ActivityCategory> = {
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<ActivityType, string> = {
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<string, ActivityCategory>)[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;
Loading