diff --git a/docs/decisions/0003-report-export-routing-and-page-routes.md b/docs/decisions/0003-report-export-routing-and-page-routes.md new file mode 100644 index 00000000..2fad7286 --- /dev/null +++ b/docs/decisions/0003-report-export-routing-and-page-routes.md @@ -0,0 +1,89 @@ +--- +status: 'accepted' +date: 2026-06-08 +decision-makers: santosral +consulted: +informed: +--- + +# Nested per-report download endpoints and separate per-report page routes + +## Context and Problem Statement + +The user profile report adds a second report to the admin **Generate Report** area (`/admin/reports`) alongside the existing quiz report, plus a second download endpoint. Two structural choices follow: how to shape the download routes, and how to present the two reports. How should each be done without over-engineering for two reports? + +## Decision Drivers + +- Single-purpose, easy-to-read handlers and page loads. +- A clear "report exports" family in the route tree. +- Server-side pagination kept working per report. +- Pages and API endpoints kept in separate route trees. +- No speculative abstraction for what is currently two reports (rule of three). + +## Considered Options + +Download routing: + +- Nested per-report endpoints — `admin/api/download/quiz` and `admin/api/download/user-profile` +- A single endpoint with a `?type=` query-param discriminator +- A shared config/registry the endpoints derive from + +Page presentation: + +- Separate per-report page routes with a shared layout nav — `admin/reports/quiz` and `admin/reports/user-profile` +- Inline tabs driven by a `?tab=quiz|user-profile` query param on one page +- A reusable `Tabs` component + +## Decision Outcome + +Chosen options: "Nested per-report download endpoints" and "Separate per-report page routes". + +Each download is its own single-purpose handler under `admin/api/download/{quiz,user-profile}`, keeping the export family obvious in the tree and keeping downloads — which are API endpoints that stream a file — in the API route tree, separate from the page routes. + +Each report preview is its own page route under `admin/(protected)/reports/{quiz,user-profile}`. A shared `+layout.svelte` renders the "Generate Report" header and the report nav (two links, active by pathname), and `/admin/reports` redirects to the quiz report (the default). Because each page's load runs only its own queries, server-side pagination keeps working per report without tab-branching or a discriminated-union page payload. + +No reusable `Tabs` component is introduced: the nav is two links in a layout, with no ARIA/keyboard widget complexity, and two reports do not meet the rule of three. + +### Consequences + +- Good, because each page load and each download handler stays single-purpose. +- Good, because per-page loading keeps server-side pagination working for both reports, with no discriminated-union payload or tab branching. +- Good, because pages and API endpoints remain in cleanly separated route trees. +- Good, because no abstraction is built ahead of need. +- Bad, because there is minor duplication between the two page routes, the two endpoints, and the two nav links; acceptable at two, to be revisited at three. + +### Confirmation + +Spec B has separate page routes whose loads each query only their own dataset; the new `download/user-profile` endpoint exists as a sibling of `download/quiz`; a shared reports layout renders the nav and `/admin/reports` redirects to the default; reviewers confirm no shared discriminator/registry and no reusable `Tabs` component were introduced. + +## Pros and Cons of the Options + +### Nested per-report endpoints + +- Good, because each handler is single-purpose and discoverable, and downloads stay in the API tree. +- Bad, because a little setup is repeated per endpoint. + +### Single endpoint with `?type=` discriminator + +- Good, because one file. +- Bad, because it becomes a multi-purpose branchy handler. + +### Shared config/registry + +- Good, because exports become data. +- Bad, because it is over-engineering for two reports (YAGNI). + +### Separate per-report page routes + +- Good, because each page is single-purpose, its load has no tab branching or union payload, and pages stay separate from API endpoints. +- Bad, because a little setup, and the nav, is repeated per page. + +### Inline query-param tabs + +- Good, because there is only one page route. +- Bad, because the load branches per tab and the page carries a discriminated-union payload. + +### Reusable `Tabs` component + +- Good, because it would centralize tab behavior. +- Bad, because two simple reports do not justify it (rule of three). diff --git a/docs/decisions/0004-card-grid-report-index.md b/docs/decisions/0004-card-grid-report-index.md new file mode 100644 index 00000000..08b5b10b --- /dev/null +++ b/docs/decisions/0004-card-grid-report-index.md @@ -0,0 +1,129 @@ +--- +status: 'accepted' +date: 2026-06-10 +decision-makers: santosral +consulted: +informed: +--- + +# Card-grid report index with per-page back navigation + +## Context and Problem Statement + +[ADR-0003](./0003-report-export-routing-and-page-routes.md) presents the two +reports in the admin **Generate Report** area as a shared layout nav — two links, +active by pathname — with `/admin/reports` redirecting to the default report. That +ADR deliberately deferred anything richer until a third report appeared ("minor +duplication … acceptable at two, to be revisited at three"). + +We now want the report-selection UI to stay readable as more reports are added, +rather than grow a horizontal nav strip that crowds or overflows. This revisits +only the **page-presentation** half of ADR-0003; its download-routing decision +(nested per-report endpoints) is unchanged and carries forward. + +## Decision Drivers + +- A selection UI whose readability does not degrade as the report count grows. +- No added complexity for the two reports that exist today. +- Self-contained, explicit report pages that are easy to read in isolation. +- Consistency with the codebase's preference for small duplication over + speculative abstraction at low counts (the rule-of-three stance of ADR-0003). +- The admin sidebar already links to the reports area, so that entry point should + land somewhere useful rather than bounce through a redirect. + +## Considered Options + +- A card-grid landing page with per-page back navigation +- Keep the shared layout tab nav and default-report redirect (status quo, ADR-0003) +- A scrollable / overflowing horizontal tab strip +- A single dropdown selector for the active report +- A left sidebar list of reports + +## Decision Outcome + +Chosen option: "A card-grid landing page with per-page back navigation." + +The reports area entry point becomes a landing page that presents each report as a +card (title and a one-line description); selecting a card opens that report's own +page. The default-report redirect is removed — the landing page is the +destination — and the shared tab nav is dropped, since the grid is now the +switcher. Each report page carries a back affordance to the landing page. + +The set of reports shown on the landing page is inline presentational data +co-located with the landing page, not a shared registry the endpoints or pages +derive behaviour from: each report page and each download endpoint stays +independent, exactly as in ADR-0003. The back affordance likewise lives on each +report page rather than in shared chrome, keeping every page readable top to +bottom and avoiding a layout that has to special-case its landing route. + +A card grid of two cards is no more complex than two nav links, and an inline +list of two entries is the same shape as the nav array it replaces, so this is a +lateral presentation choice — not abstraction ahead of need — whose layout simply +does not crowd as the report count rises. + +### Consequences + +- Good, because the landing grid stays readable as reports are added, where a + horizontal nav strip would crowd or overflow. +- Good, because the change adds no complexity at the current count of two: a grid + of two cards and a two-entry list match the prior two-link nav. +- Good, because each report page is self-contained — its own content and its own + back affordance — with no shared layout branching on which child is the landing + route. +- Good, because the sidebar entry now lands on a useful overview instead of an + immediate redirect. +- Bad, because the back affordance is repeated per report page and the landing + list gains one entry per report — small duplication, accepted at low counts and + to be revisited (a shared back-link element, or promoting the list to its own + module) when a third-plus report makes it nag. +- Bad, because reaching a report now passes through the landing page rather than a + direct default — one extra step, traded for a clearer overview. + +### Confirmation + +The reports landing route renders one card per report and no longer redirects; +each report page renders a back affordance to the landing route and the shared tab +nav is gone; reviewers confirm the report list is inline presentational data (no +shared registry) and that the nested per-report download endpoints from ADR-0003 +are untouched. + +## Pros and Cons of the Options + +### Card-grid landing page with per-page back navigation + +- Good, because a grid scales to more reports without the layout crowding. +- Good, because it is equal in complexity to the prior nav at two reports. +- Good, because pages stay explicit and self-contained. +- Bad, because the back affordance and a list entry are repeated per report. + +### Keep the shared layout tab nav and redirect (status quo) + +- Good, because it is already built and is minimal for two reports. +- Bad, because a horizontal nav strip crowds and eventually overflows as reports + are added — the very thing this decision sets out to avoid. + +### Scrollable / overflowing horizontal tab strip + +- Good, because it is a small change from the status quo. +- Bad, because horizontal scrolling hides reports off-screen and reads poorly; + it postpones rather than solves the crowding. + +### Single dropdown selector + +- Good, because it is compact and scales to many reports. +- Bad, because it hides the list behind a control and shows no description, making + reports less discoverable than cards. + +### Left sidebar list + +- Good, because a vertical list scales cleanly. +- Bad, because it adds a second persistent nav column inside a page that already + sits next to the admin sidebar, competing for the same affordance. + +## More Information + +Supersedes the page-presentation decision of +[ADR-0003](./0003-report-export-routing-and-page-routes.md); that ADR's +download-routing decision remains accepted. The chosen outcome is realised in the +[User Profile Report spec](../superpowers/specs/2026-06-08-user-profile-report-design.md), +which states the presentation as built and links back here for the rationale. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b1c0095f..37adc14e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -37,4 +37,5 @@ the ADR; the spec consumes its outcome. - [0001 — Stream report exports end-to-end with ExcelJS via a generic helper](./0001-stream-report-exports-with-exceljs.md) — accepted - [0002 — Read streaming exports with keyset cursor pagination on the primary key](./0002-keyset-cursor-pagination-on-primary-key.md) — accepted -- [0003 — Nested per-report download routes and inline query-param tabs](./0003-report-export-routing-and-tabs.md) — accepted +- [0003 — Nested per-report download endpoints and separate per-report page routes](./0003-report-export-routing-and-page-routes.md) — accepted +- [0004 — Card-grid report index with per-page back navigation](./0004-card-grid-report-index.md) — accepted diff --git a/docs/superpowers/plans/2026-06-08-streaming-report-exports.md b/docs/superpowers/plans/2026-06-08-streaming-report-exports.md index 02f6a9da..5b1a8608 100644 --- a/docs/superpowers/plans/2026-06-08-streaming-report-exports.md +++ b/docs/superpowers/plans/2026-06-08-streaming-report-exports.md @@ -8,7 +8,7 @@ **Tech Stack:** SvelteKit (adapter-node, Node 24), Svelte 5, Prisma (pg adapter), `exceljs`, Vitest. Package manager: `pnpm`. -**Scope:** This is **PR1 of 2** (Spec A — [streaming foundation + quiz migration](../specs/2026-06-08-streaming-report-exports-design.md)). The onboarding report (Spec B) is a separate plan, stacked on this branch, written after PR1 lands. Decisions: [ADR-0001](../../decisions/0001-stream-report-exports-with-exceljs.md) (streaming via generic helper), [ADR-0002](../../decisions/0002-keyset-cursor-pagination-on-primary-key.md) (keyset cursor on primary key). +**Scope:** This is **PR1 of 2** (Spec A — [streaming foundation + quiz migration](../specs/2026-06-08-streaming-report-exports-design.md)). The user profile report (Spec B) is a separate plan, stacked on this branch, written after PR1 lands. Decisions: [ADR-0001](../../decisions/0001-stream-report-exports-with-exceljs.md) (streaming via generic helper), [ADR-0002](../../decisions/0002-keyset-cursor-pagination-on-primary-key.md) (keyset cursor on primary key). **Conventions (from CLAUDE.md + project memory):** @@ -88,7 +88,7 @@ git commit -m "chore: add exceljs dependency" ## Task 2: Shared pure utilities (`sanitizeSpreadsheetCell`, `formatTimestamp`) -Two pure helpers co-located in `reports/helpers.ts`, both shared by the quiz export and the onboarding report (Spec B): `sanitizeSpreadsheetCell` neutralizes CSV/formula injection by prefixing a leading formula-trigger character with `'`; `formatTimestamp` formats a `Date` as a `DDMMYYYYHHmmss` filename prefix. +Two pure helpers co-located in `reports/helpers.ts`, both shared by the quiz export and the user profile report (Spec B): `sanitizeSpreadsheetCell` neutralizes CSV/formula injection by prefixing a leading formula-trigger character with `'`; `formatTimestamp` formats a `Date` as a `DDMMYYYYHHmmss` filename prefix. **Files:** diff --git a/docs/superpowers/plans/2026-06-08-user-profile-report.md b/docs/superpowers/plans/2026-06-08-user-profile-report.md new file mode 100644 index 00000000..4a22c81f --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-user-profile-report.md @@ -0,0 +1,723 @@ +# User Profile Report 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:** Add a "User Profile Report" to the admin reports area as its own page route, with a paginated preview and a streamed `.xlsx` download, reusing Spec A's `generateReport` helper. Split the existing quiz report into a sibling page route under a shared layout. + +**Architecture:** Two sibling page routes — `admin/(protected)/reports/quiz` and `admin/(protected)/reports/user-profile` — share a `+layout.svelte` that renders the "Generate Report" header and nav (active by pathname); `/admin/reports` redirects to the quiz report. Each page's load runs only its own queries, so server-side pagination works per report with no tab branching or discriminated-union payload. Downloads stay in the API tree as single-purpose endpoints: `admin/api/download/quiz` (unchanged) and a new `admin/api/download/user-profile`. Per [ADR-0003](../../decisions/0003-report-export-routing-and-page-routes.md). + +**Tech Stack:** SvelteKit (adapter-node) + Svelte 5 runes, Prisma (pg adapter), ExcelJS streaming (`generateReport`), Vitest, pnpm. + +**Source docs:** [Spec B](../specs/2026-06-08-user-profile-report-design.md), [ADR-0003](../../decisions/0003-report-export-routing-and-page-routes.md). Depends on Spec A (already landed on this branch: `src/lib/server/reports/{generateReport,helpers}.ts`, `admin/api/download/quiz/+server.ts`, `admin/(protected)/reports/+page.{server.ts,svelte}`). + +**Naming:** route/folder/endpoint segment is `user-profile` (matches the `UserProfile` model); user-facing strings (nav label, sheet name, filename) read "User Profile Report" per #571. + +--- + +## gh workflow (document-only; run nothing until the user explicitly approves) + +Per `CLAUDE.md`, the gh steps are established up front. **Do not create a branch, commit, push, or PR until the user asks.** + +- [ ] **Read the issue:** `gh issue view 571` +- [ ] **Create the feature branch** (confirm the name with the user first): `git checkout -b feat/user-profile-report` +- [ ] **Open a draft PR** after the first commit, body per `.github/PULL_REQUEST_TEMPLATE.md`: + + ```bash + gh pr create --draft --title "feat: add user profile report export" --body "$(cat <<'EOF' + ## 🚀 Summary + + This PR adds a User Profile Report page to the admin reports area, listing each onboarded user with their content preferences and subscription status, downloadable as a streamed .xlsx. + + ## ✏️ Changes + + - Split admin reports into sibling page routes (quiz, user-profile) under a shared layout nav + - Add the user-profile report page with paginated preview + - Add `GET /admin/api/download/user-profile` streaming export reusing `generateReport` + EOF + )" + ``` + +- [ ] **Mark ready** as the final step (see Task 4): `gh pr ready` + +Conventional commits **without scope**, **title only** (no body). + +--- + +## File structure + +| File | Responsibility | Action | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ----------- | +| `src/routes/admin/api/download/user-profile/+server.ts` | New streaming endpoint: all onboarded users → `.xlsx` via `generateReport` | Create | +| `src/routes/admin/api/download/user-profile/server.test.ts` | Endpoint unit tests | Create | +| `src/routes/admin/(protected)/reports/+layout.svelte` | Shared "Generate Report" header + report nav (active by pathname) | Create | +| `src/routes/admin/(protected)/reports/+page.server.ts` | Redirect `/admin/reports` → `/admin/reports/quiz` | Replace | +| `src/routes/admin/(protected)/reports/quiz/+page.server.ts` | Quiz report load (relocated, unchanged) | Move | +| `src/routes/admin/(protected)/reports/quiz/+page.svelte` | Quiz report UI (relocated; header/wrapper removed) | Move + edit | +| `src/routes/admin/(protected)/reports/user-profile/+page.server.ts` | User-profile report load | Create | +| `src/routes/admin/(protected)/reports/user-profile/+page.server.test.ts` | Load unit tests | Create | +| `src/routes/admin/(protected)/reports/user-profile/+page.svelte` | User-profile report UI | Create | + +The quiz download endpoint (`admin/api/download/quiz/+server.ts`) and its test are **unchanged**. + +--- + +## Task 1: User-profile download endpoint + +**Files:** + +- Create: `src/routes/admin/api/download/user-profile/+server.ts` +- Test: `src/routes/admin/api/download/user-profile/server.test.ts` + +Mirrors `admin/api/download/quiz/+server.ts` and its test. No query params, no quiz-title lookup — streams all `UserProfile` rows, keyset-paginated on `userId`. + +- [ ] **Step 1: Write the failing endpoint test** + +Create `src/routes/admin/api/download/user-profile/server.test.ts`: + +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { GenerateReportOptions } from '$lib/server/reports'; + +import { GET } from './+server.js'; + +interface UserProfileReportRow { + userId: string; + isSubscribed: boolean; + user: { name: string; email: string }; + interests: { collection: { title: string } }[]; +} + +const { mockGenerateReport, mockFindMany } = vi.hoisted(() => ({ + mockGenerateReport: + vi.fn<(options: GenerateReportOptions) => Response>(), + mockFindMany: vi.fn(), +})); + +vi.mock('$lib/server/reports', async (importActual) => { + const actual = await importActual(); + return { ...actual, generateReport: mockGenerateReport }; +}); + +vi.mock('$lib/server/db.js', () => ({ + db: { + userProfile: { findMany: mockFindMany }, + }, +})); + +const silentLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +}; +silentLogger.child.mockReturnValue(silentLogger); + +const buildEvent = (url: string, user: { id: string } | null) => + ({ + locals: { logger: silentLogger, session: { user } }, + url: new URL(url), + }) as unknown as Parameters[0]; + +beforeEach(() => { + vi.clearAllMocks(); + silentLogger.child.mockReturnValue(silentLogger); + mockGenerateReport.mockReturnValue(new Response('ok')); +}); + +describe('GET /admin/api/download/user-profile', () => { + test('returns 401 and does not stream when unauthenticated', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', null); + + const response = await GET(event); + + expect(response.status).toBe(401); + expect(mockGenerateReport).not.toHaveBeenCalled(); + }); + + test('declares the report columns, sheet name, and filename', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.header)).toEqual([ + 'Name', + 'Email', + 'Content Preferences', + 'Subscribed?', + ]); + expect(options.sheetName).toBe('User Profile Report'); + expect(options.filename).toMatch(/^\d{14}_user_profile_report\.xlsx$/); + }); + + test('maps a profile to row values with comma-joined preferences', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + const record = { + userId: 'u1', + isSubscribed: true, + user: { name: 'Ann', email: 'a@x.co' }, + interests: [{ collection: { title: 'AI' } }, { collection: { title: 'Math' } }], + }; + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.value(record))).toEqual([ + 'Ann', + 'a@x.co', + 'AI, Math', + 'Yes', + ]); + }); + + test('renders blank preferences and No when a profile has no interests', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + const record = { + userId: 'u2', + isSubscribed: false, + user: { name: 'Bob', email: 'b@x.co' }, + interests: [], + }; + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.value(record))).toEqual(['Bob', 'b@x.co', '', 'No']); + }); + + test('fetchBatch advances the keyset cursor over all profiles', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + const fullBatch = Array.from({ length: 100 }, (_, i) => ({ userId: `u-${i}` })); + mockFindMany.mockResolvedValueOnce(fullBatch).mockResolvedValueOnce([{ userId: 'u-100' }]); + + await GET(event); + const { fetchBatch } = mockGenerateReport.mock.calls[0][0]; + const first = await fetchBatch(undefined); + const second = await fetchBatch('u-99'); + + expect(mockFindMany.mock.calls[0][0]).toMatchObject({ orderBy: { userId: 'asc' }, take: 100 }); + expect(mockFindMany.mock.calls[0][0]).not.toHaveProperty('cursor'); + expect(first.nextCursor).toBe('u-99'); + expect(mockFindMany.mock.calls[1][0]).toMatchObject({ skip: 1, cursor: { userId: 'u-99' } }); + expect(second.nextCursor).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm test run src/routes/admin/api/download/user-profile/server.test.ts` +Expected: FAIL — cannot resolve `./+server.js` (module not created yet). + +- [ ] **Step 3: Write the endpoint** + +Create `src/routes/admin/api/download/user-profile/+server.ts`: + +```ts +import { json } from '@sveltejs/kit'; + +import { db, type UserProfileFindManyArgs, type UserProfileGetPayload } from '$lib/server/db.js'; +import { formatTimestamp, generateReport } from '$lib/server/reports'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_download_user_profile_report' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return json(null, { status: 401 }); + } + + const batchSize = 100; + + const recordArgs = { + select: { + userId: true, + isSubscribed: true, + user: { select: { name: true, email: true } }, + interests: { select: { collection: { select: { title: true } } } }, + }, + orderBy: { userId: 'asc' }, + take: batchSize, + } satisfies UserProfileFindManyArgs; + + type UserProfileRow = UserProfileGetPayload; + + const filename = `${formatTimestamp(new Date())}_user_profile_report.xlsx`; + + return generateReport({ + filename, + sheetName: 'User Profile Report', + columns: [ + { header: 'Name', value: (row) => row.user.name }, + { header: 'Email', value: (row) => row.user.email }, + { + header: 'Content Preferences', + value: (row) => row.interests.map((interest) => interest.collection.title).join(', '), + }, + { header: 'Subscribed?', value: (row) => (row.isSubscribed ? 'Yes' : 'No') }, + ], + fetchBatch: async (cursor) => { + const rows = await db.userProfile.findMany({ + ...recordArgs, + ...(cursor && { skip: 1, cursor: { userId: cursor } }), + }); + const nextCursor = rows.length === batchSize ? rows[rows.length - 1].userId : undefined; + return { rows, nextCursor }; + }, + onError: (err) => logger.error({ err }, 'Failed while streaming user profile report'), + }); +}; +``` + +Notes: + +- `recordArgs` keys follow SQL clause order (`select`, `orderBy`, `take`); no `where` (every onboarded user is exported), no base `skip`. +- Keyset cursor is on `userId` (the `UserProfile` primary key), per [ADR-0002](../../decisions/0002-keyset-cursor-pagination-on-primary-key.md). +- `UserProfileFindManyArgs` / `UserProfileGetPayload` are re-exported from `$lib/server/db.js`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm test run src/routes/admin/api/download/user-profile/server.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/admin/api/download/user-profile/ +git commit -m "feat: add user profile report download endpoint" +``` + +--- + +## Task 2: Reports layout, redirect, and quiz page relocation + +**Files:** + +- Create: `src/routes/admin/(protected)/reports/+layout.svelte` +- Replace: `src/routes/admin/(protected)/reports/+page.server.ts` (now a redirect) +- Move: `reports/+page.server.ts` → `reports/quiz/+page.server.ts` (unchanged), `reports/+page.svelte` → `reports/quiz/+page.svelte` (header/wrapper removed) + +The `.svelte` edits are authored/validated with the **svelte-file-editor** (Svelte MCP). Constraints: arrow functions only, no `?.`, do not typecast `page.data`. + +- [ ] **Step 1: Relocate the quiz page files** + +```bash +mkdir -p "src/routes/admin/(protected)/reports/quiz" +git mv "src/routes/admin/(protected)/reports/+page.server.ts" "src/routes/admin/(protected)/reports/quiz/+page.server.ts" +git mv "src/routes/admin/(protected)/reports/+page.svelte" "src/routes/admin/(protected)/reports/quiz/+page.svelte" +``` + +The moved `reports/quiz/+page.server.ts` needs **no content change** — its quiz load is correct as-is. + +- [ ] **Step 2: Trim the relocated quiz page (header + outer wrapper now live in the layout)** + +Edit `src/routes/admin/(protected)/reports/quiz/+page.svelte` — the ` + +
+
+ Generate Report +
+ +
+ {#each tabs as tab (tab.href)} + + {tab.label} + + {/each} +
+ + {@render children()} +
+``` + +- [ ] **Step 4: Replace the reports root load with a redirect** + +Replace the entire contents of `src/routes/admin/(protected)/reports/+page.server.ts`: + +```ts +import { redirect } from '@sveltejs/kit'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = () => { + throw redirect(307, '/admin/reports/quiz'); +}; +``` + +- [ ] **Step 5: Validate and verify the quiz report still works** + +Validate both `.svelte` files with the Svelte MCP autofixer, then: +Run: `pnpm check` +Expected: 0 errors, 0 warnings. + +Run: `pnpm test run src/routes/admin/api/download/quiz/server.test.ts` +Expected: PASS — the quiz endpoint test is unaffected by the page move. + +Optional manual smoke: `pnpm dev`, open `/admin/reports` → redirects to `/admin/reports/quiz`; the nav shows "Quiz Report" active; the quiz dropdown/preview/download still work. + +- [ ] **Step 6: Commit** + +```bash +git add "src/routes/admin/(protected)/reports/" +git commit -m "feat: split admin reports into per-report page routes" +``` + +--- + +## Task 3: User-profile report page + +**Files:** + +- Create: `src/routes/admin/(protected)/reports/user-profile/+page.server.ts` +- Test: `src/routes/admin/(protected)/reports/user-profile/+page.server.test.ts` +- Create: `src/routes/admin/(protected)/reports/user-profile/+page.svelte` + +- [ ] **Step 1: Write the failing load test** + +Create `src/routes/admin/(protected)/reports/user-profile/+page.server.test.ts`: + +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { load } from './+page.server.js'; + +const { mockProfileFindMany, mockProfileCount } = vi.hoisted(() => ({ + mockProfileFindMany: vi.fn(), + mockProfileCount: vi.fn(), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + userProfile: { findMany: mockProfileFindMany, count: mockProfileCount }, + }, +})); + +const silentLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +}; +silentLogger.child.mockReturnValue(silentLogger); + +const buildEvent = (url: string) => + ({ + locals: { logger: silentLogger, session: { user: { id: 'admin-1' } } }, + url: new URL(url), + }) as unknown as Parameters[0]; + +beforeEach(() => { + vi.clearAllMocks(); + silentLogger.child.mockReturnValue(silentLogger); + mockProfileFindMany.mockResolvedValue([]); + mockProfileCount.mockResolvedValue(0); +}); + +describe('user-profile report load', () => { + test('returns onboarding records ordered by user name', async () => { + const profiles = [ + { userId: 'u1', isSubscribed: true, user: { name: 'Ann', email: 'a@x.co' }, interests: [] }, + ]; + mockProfileFindMany.mockResolvedValue(profiles); + mockProfileCount.mockResolvedValue(1); + const event = buildEvent('http://localhost/admin/reports/user-profile'); + + const data = await load(event); + + expect(data.records).toEqual(profiles); + expect(data.totalCount).toBe(1); + expect(mockProfileFindMany.mock.calls[0][0]).toMatchObject({ + orderBy: { user: { name: 'asc' } }, + skip: 0, + take: 10, + }); + }); + + test('paginates records with skip', async () => { + const event = buildEvent('http://localhost/admin/reports/user-profile?page=3'); + + await load(event); + + expect(mockProfileFindMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 10 }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm test run "src/routes/admin/(protected)/reports/user-profile/+page.server.test.ts"` +Expected: FAIL — cannot resolve `./+page.server.js`. + +- [ ] **Step 3: Write the load** + +Create `src/routes/admin/(protected)/reports/user-profile/+page.server.ts`: + +```ts +import { error, redirect } from '@sveltejs/kit'; + +import { db, type UserProfileFindManyArgs, type UserProfileGetPayload } from '$lib/server/db.js'; + +import type { PageServerLoad } from './$types'; + +const PAGE_SIZE = 10; + +export const load: PageServerLoad = async (event) => { + const logger = event.locals.logger.child({ handler: 'page_load_user_profile_report' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + throw redirect(303, '/admin'); + } + + const currentPage = Number(event.url.searchParams.get('page')) || 1; + const skip = (currentPage - 1) * PAGE_SIZE; + + const recordArgs = { + select: { + userId: true, + isSubscribed: true, + user: { + select: { name: true, email: true }, + }, + interests: { + select: { collection: { select: { title: true } } }, + }, + }, + orderBy: { user: { name: 'asc' } }, + skip, + take: PAGE_SIZE, + } satisfies UserProfileFindManyArgs; + + try { + const [records, totalCount] = await Promise.all([ + db.userProfile.findMany(recordArgs), + db.userProfile.count(), + ]); + + return { + records: records as UserProfileGetPayload[], + totalCount, + currentPage, + pageSize: PAGE_SIZE, + }; + } catch (err) { + logger.error({ err }, 'Failed to fetch user profile report data'); + throw error(500); + } +}; +``` + +(`recordArgs` keys mirror the sibling quiz load's order — `select`, `orderBy`, `skip`, `take`. No `where`: every onboarded user is listed.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm test run "src/routes/admin/(protected)/reports/user-profile/+page.server.test.ts"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Write the page UI** + +Create `src/routes/admin/(protected)/reports/user-profile/+page.svelte` (author/validate with the svelte-file-editor): + +```svelte + + +
+
+ User Profile Report + + Onboarded users with their content preferences and subscription status. + +
+
+ +
+ + + {#if data.totalCount > data.pageSize} + + {/if} + + +
+ + + Download XLSX + +
+``` + +- [ ] **Step 6: Validate and lint** + +Run: `pnpm check` +Expected: 0 errors, 0 warnings. + +Run: `pnpm exec eslint "src/routes/admin/(protected)/reports/user-profile"` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add "src/routes/admin/(protected)/reports/user-profile/" +git commit -m "feat: add user profile report page" +``` + +--- + +## Task 4: Full verification and mark ready + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite once** + +Run: `pnpm test run` +Expected: PASS — including the new endpoint test, the new load test, and the existing quiz endpoint test. + +- [ ] **Step 2: Type + Svelte check** + +Run: `pnpm check` +Expected: 0 errors, 0 warnings. + +- [ ] **Step 3: Lint the change set** + +Run: `pnpm exec eslint "src/routes/admin/api/download/user-profile" "src/routes/admin/(protected)/reports"` +Expected: clean. + +- [ ] **Step 4: Manual smoke (optional, dev DB)** + +Start `pnpm dev`: `/admin/reports` redirects to the quiz report; the nav switches between "Quiz Report" and "User Profile Report"; the user-profile page renders its preview table + paginator and "Download XLSX" returns an `.xlsx` with the four columns. (User-profile rows require `UserProfile` data — seed/create a profile if the dev DB has none.) + +- [ ] **Step 5: Push and mark the draft PR ready** + +```bash +git push -u origin feat/user-profile-report +gh pr ready +``` + +--- + +## Self-review (author checklist — completed) + +- **Spec coverage:** Shared layout + nav (component 1) → Task 2. Redirect (component 2) → Task 2. Relocated quiz page (component 3) → Task 2. User-profile load (component 4) → Task 3. User-profile UI (component 5) → Task 3. Download endpoint (component 6) → Task 1. Columns Name/Email/Content Preferences/Subscribed → Tasks 1 & 3. Comma-joined topics + Yes/No → Tasks 1 & 3 (with explicit blank-preferences test). 401 defense-in-depth → Task 1. Filename `…_user_profile_report.xlsx`, sheet `User Profile Report` → Task 1. Keyset on `userId`; preview ordered by `user.name` → Tasks 1 & 3. Empty-set `emptyMessage` → Task 3 (`"No onboarded users found"`). Injection / `no-store` / streaming → inherited from `generateReport` (Spec A), unchanged. +- **Placeholder scan:** none — every code/command step is concrete. +- **Type consistency:** `UserProfileRow` = `UserProfileGetPayload`; the page's `rows`/`columns` use `userId`, `isSubscribed`, `user.{name,email}`, `interests[].collection.title` consistently across endpoint, load, and component. No discriminated union — each page route has its own single-purpose load. +- **Route consistency:** page routes `reports/quiz`, `reports/user-profile`; download endpoints `api/download/quiz` (unchanged), `api/download/user-profile`; nav hrefs and redirect target match these exactly. diff --git a/docs/superpowers/plans/2026-06-10-card-grid-report-index.md b/docs/superpowers/plans/2026-06-10-card-grid-report-index.md new file mode 100644 index 00000000..2319aa04 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-card-grid-report-index.md @@ -0,0 +1,214 @@ +# Card-Grid Report Index 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:** Replace the admin reports tab strip with a card-grid landing page at `/admin/reports`, and give each report page a "Back to reports" link. + +**Architecture:** `/admin/reports` becomes a static card-grid index built from an inline `ReportLink[]` co-located with the page; the shared layout is slimmed to just the page container; the default-report redirect is deleted; each report page (quiz, user-profile) gains a "← Back to reports" link. No report's data load, query, or download endpoint changes. Realises [ADR-0004](../../decisions/0004-card-grid-report-index.md) per [Spec C](../specs/2026-06-10-card-grid-report-index-design.md). + +**Tech Stack:** SvelteKit (adapter-node), Svelte 5 runes, Tailwind, `@lucide/svelte` icons. No new dependencies. + +--- + +## gh workflow + +> **Document-only.** Per CLAUDE.md, these commands are documented here but run **only when the user explicitly asks**. Create no branch, commit, push, or PR before then. + +- **Issue:** `gh issue view 571` — context for the reports area (the second report that motivated multi-report nav). +- **Branch:** reuse the existing `feat/user-profile-report` branch (decided: Spec C ships in the same branch/PR as the user-profile report). No new branch. +- **Draft PR:** the `feat/user-profile-report` PR covers both the user-profile report (Spec B) and this card-grid restructure (Spec C). If the draft PR does not yet exist, create it with `gh pr create --draft` using a body per `.github/PULL_REQUEST_TEMPLATE.md`. +- **Final step (after all tasks):** `gh pr ready` to mark the PR ready for review. + +## File structure + +| File | Change | Responsibility | +| ---------------------------------------------------------------- | ---------- | --------------------------------------------------------------- | +| `src/routes/admin/(protected)/reports/+page.svelte` | **Create** | Card-grid index; inline `ReportLink[]` → one card per report. | +| `src/routes/admin/(protected)/reports/+page.server.ts` | **Delete** | Removes the 307 redirect to `/admin/reports/quiz`. | +| `src/routes/admin/(protected)/reports/+layout.svelte` | **Modify** | Slim to the shared page container only (drop header + tab nav). | +| `src/routes/admin/(protected)/reports/quiz/+page.svelte` | **Modify** | Add "← Back to reports" link above content. | +| `src/routes/admin/(protected)/reports/user-profile/+page.svelte` | **Modify** | Add "← Back to reports" link above content. | + +**Testing note:** This is a pure presentation/routing change with no new server logic (the only server edit is deleting a redirect). The repo does not component-test route `+page.svelte`/`+layout.svelte` files — route logic is covered by `+page.server.test.ts`/`server.test.ts`, and this adds none. Verification is `pnpm check` + a `pnpm dev` smoke, per Spec C's Testing section. The existing quiz and user-profile server tests must stay green. + +--- + +### Task 1: Card-grid index, slim layout, delete redirect + +Done as one task so every committed state is coherent — the "Generate Report" heading moves from the layout into the new index page, and the redirect is removed so the index actually renders. Doing these separately would leave an intermediate commit with a duplicated heading or a still-redirecting route. + +**Files:** + +- Create: `src/routes/admin/(protected)/reports/+page.svelte` +- Delete: `src/routes/admin/(protected)/reports/+page.server.ts` +- Modify: `src/routes/admin/(protected)/reports/+layout.svelte` + +- [ ] **Step 1: Slim the shared layout** + +Replace the entire contents of `src/routes/admin/(protected)/reports/+layout.svelte` with: + +```svelte + + +
+ {@render children()} +
+``` + +(Removes the `Generate Report` header, the `tabs` array, the `page` import, and the tab-nav block; keeps the `mx-auto … flex flex-col gap-6` container so report pages keep their vertical spacing.) + +- [ ] **Step 2: Create the card-grid index page** + +Create `src/routes/admin/(protected)/reports/+page.svelte` with: + +```svelte + + +
+ Generate Report + Choose a report to preview and download. +
+ +
+ {#each reports as report (report.href)} + + {report.title} + {report.description} + + {/each} +
+``` + +The page is static — no `+page.server.ts`, no `data` prop. The `ReportLink` shape is the inline presentational list (not a shared registry); adding a report later is one new entry plus its route. + +> **Scope note:** Spec C attributes the second card (User Profile Report) to Spec B's index entry. Because this combined PR ships Spec B + Spec C together and the user-profile report already exists on this branch, both entries land here — otherwise the grid would not link to an existing report. The end state intentionally has both cards. + +- [ ] **Step 3: Delete the redirect load** + +Run: `git rm "src/routes/admin/(protected)/reports/+page.server.ts"` +Expected: the file is removed; `/admin/reports` now resolves to the new index page instead of 307-redirecting. + +- [ ] **Step 4: Type-check** + +Run: `pnpm check` +Expected: PASS, no errors or warnings (the layout no longer references `page`; the index page references no `$types` data). + +- [ ] **Step 5: Smoke test** + +Run: `pnpm dev`, then open `/admin/reports` (signed in as an admin). +Expected: the page shows the "Generate Report" heading and a two-card grid (Quiz Report, User Profile Report); no tab strip; no redirect. Clicking a card opens that report's page. + +- [ ] **Step 6: Commit** + +```bash +git add "src/routes/admin/(protected)/reports/+page.svelte" "src/routes/admin/(protected)/reports/+layout.svelte" "src/routes/admin/(protected)/reports/+page.server.ts" +git commit -m "feat: card-grid report index" +``` + +--- + +### Task 2: "Back to reports" link on each report page + +**Files:** + +- Modify: `src/routes/admin/(protected)/reports/quiz/+page.svelte` +- Modify: `src/routes/admin/(protected)/reports/user-profile/+page.svelte` + +- [ ] **Step 1: Add the link to the quiz page** + +In `src/routes/admin/(protected)/reports/quiz/+page.svelte`, add `ArrowLeft` to the existing `@lucide/svelte` import: + +```svelte +import {(ArrowLeft, FileSpreadsheet)} from '@lucide/svelte'; +``` + +Then insert the back link as the first element in the markup, immediately before the `
` block: + +```svelte + + + Back to reports + +``` + +- [ ] **Step 2: Add the link to the user-profile page** + +In `src/routes/admin/(protected)/reports/user-profile/+page.svelte`, add `ArrowLeft` to the existing `@lucide/svelte` import: + +```svelte +import {(ArrowLeft, FileSpreadsheet)} from '@lucide/svelte'; +``` + +Then insert the same back link as the first element in the markup, immediately before the `
` block: + +```svelte + + + Back to reports + +``` + +The link is intentionally duplicated per page rather than hoisted into shared chrome (per ADR-0004; revisit at the third-plus report). + +- [ ] **Step 3: Type-check** + +Run: `pnpm check` +Expected: PASS, no errors or warnings. + +- [ ] **Step 4: Smoke test** + +Run (or keep running): `pnpm dev`, then open `/admin/reports/quiz` and `/admin/reports/user-profile`. +Expected: each page shows a "← Back to reports" link above its content that returns to the card grid. + +- [ ] **Step 5: Commit** + +```bash +git add "src/routes/admin/(protected)/reports/quiz/+page.svelte" "src/routes/admin/(protected)/reports/user-profile/+page.svelte" +git commit -m "feat: add back-to-reports link on report pages" +``` + +--- + +### Final verification + +- [ ] **Run the full check + test suite** + +Run: `pnpm check && pnpm test run` +Expected: type check passes; existing quiz and user-profile server-load/endpoint tests stay green (this change touches no server logic). + +- [ ] **Mark the PR ready (when the user asks)** + +Run: `gh pr ready` +Expected: the `feat/user-profile-report` PR moves from draft to ready for review. diff --git a/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md b/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md index b8c56d95..7ea71ded 100644 --- a/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md +++ b/docs/superpowers/specs/2026-06-08-streaming-report-exports-design.md @@ -2,13 +2,13 @@ **Status:** Approved **Issue:** none -**Depends on / Related:** foundation for [Onboarding Report](./2026-06-08-onboarding-report-design.md) (Spec B). This is Spec A; Spec B builds on the helper defined here. +**Depends on / Related:** foundation for [User Profile Report](./2026-06-08-user-profile-report-design.md) (Spec B). This is Spec A; Spec B builds on the helper defined here. ## Overview -The admin quiz export (`admin/api/download`) loads every matching row into memory, builds the whole workbook, and serializes it to a single buffer before sending, so memory scales linearly with row count. As data grows, a single export can spike memory and destabilize the server. There is also no reusable export path, so the upcoming onboarding report would otherwise copy the same buffered pattern. This spec replaces that with a memory-bounded, end-to-end streaming export built on ExcelJS: rows are read from the database in cursor-batched pages straight into an ExcelJS `WorkbookWriter`, whose output is piped to the HTTP response, so neither the full result set nor the full file is ever held in memory. The streaming logic lives in one reusable helper, fixing the memory model once ahead of the onboarding report (Spec B) adding a second export. +The admin quiz export (`admin/api/download`) loads every matching row into memory, builds the whole workbook, and serializes it to a single buffer before sending, so memory scales linearly with row count. As data grows, a single export can spike memory and destabilize the server. There is also no reusable export path, so the upcoming user profile report would otherwise copy the same buffered pattern. This spec replaces that with a memory-bounded, end-to-end streaming export built on ExcelJS: rows are read from the database in cursor-batched pages straight into an ExcelJS `WorkbookWriter`, whose output is piped to the HTTP response, so neither the full result set nor the full file is ever held in memory. The streaming logic lives in one reusable helper, fixing the memory model once ahead of the user profile report (Spec B) adding a second export. -Scope: the **existing quiz export only**. The onboarding report (Spec B) reuses the helper introduced here; no UI/pagination changes to the reports page and no change to the report's columns or filename format (output is equivalent to today, minus row ordering — see Architecture). +Scope: the **existing quiz export only**. The user profile report (Spec B) reuses the helper introduced here; no UI/pagination changes to the reports page and no change to the report's columns or filename format (output is equivalent to today, minus row ordering — see Architecture). The deploy adapter is `@sveltejs/adapter-node` on Node 24, so streaming `Response` bodies (via `Readable.toWeb`) are supported. `xlsx` (SheetJS) is currently imported in exactly one place — `src/routes/admin/api/download/+server.ts` — and vendored as a local tarball (`file:vendor/xlsx-0.20.3.tgz`), so migrating the quiz export removes that dependency entirely; `exceljs` is not yet a dependency and is added by this work. @@ -84,7 +84,7 @@ generateReport(options: GenerateReportOptions): Respon - **Guarantees:** returns 14 digits — day, month, year, hours, minutes, seconds, each zero-padded, in local time. Carries no business meaning and no filename suffix; each caller assembles its own filename around the prefix. - **Requires:** a `Date`. -Shared by both exports (this report and the onboarding report, Spec B); co-located with `sanitizeSpreadsheetCell` in `reports/helpers.ts`. The per-report filename suffix (e.g. `_user_report.xlsx`) stays in each endpoint. +Shared by both exports (this report and the user profile report, Spec B); co-located with `sanitizeSpreadsheetCell` in `reports/helpers.ts`. The per-report filename suffix (e.g. `_user_report.xlsx`) stays in each endpoint. Information-hiding test: an endpoint declares only its columns, `fetchBatch`, and (optionally) `onError`; the streaming loop, PassThrough bridge, headers, commit, and stream-destroy-on-error are all internal to the helper and can change without touching consumers. The helper always owns the recovery (destroying the stream); it notifies the caller through `onError` only when one is provided, so logging stays out of the helper. diff --git a/docs/superpowers/specs/2026-06-08-user-profile-report-design.md b/docs/superpowers/specs/2026-06-08-user-profile-report-design.md new file mode 100644 index 00000000..7d8f9073 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-user-profile-report-design.md @@ -0,0 +1,117 @@ +# User Profile Report — Design + +**Status:** Approved +**Issue:** [String-sg/onward#571](https://github.com/String-sg/onward/issues/571) — Expose subscriber list and content preference on Glow admin +**Depends on / Related:** [Streaming Report Exports](./2026-06-08-streaming-report-exports-design.md) (Spec A) and [Card-Grid Report Index](./2026-06-10-card-grid-report-index-design.md) (Spec C). This is Spec B; it reuses the `generateReport` helper, the `sanitizeSpreadsheetCell` sanitizer, and the already-nested `download/quiz` route that Spec A introduces, and it plugs a new report into the card-grid reports area that Spec C restructures. Spec A and Spec C land first. + +**Naming:** the route/folder/endpoint segment is `user-profile` (matching the `UserProfile` model); the user-facing label, sheet name, and filename use "User Profile Report" per #571. + +## Overview + +Add a "User Profile Report" as a new report in the admin **Generate Report** area, alongside the existing "Quiz Report". The area's card-grid index, shared layout, and "← Back to reports" pattern are owned by Spec C; this spec is purely additive — it adds the `user-profile` sibling page route (`/admin/reports/user-profile`), a card entry in the index, and the user-profile download endpoint. The page previews its rows with server-side pagination, carries a "← Back to reports" link per Spec C's pattern, and offers an `.xlsx` download. The user-profile download streams all onboarded users (no filter) via Spec A's `generateReport` helper. + +Scope: this report reuses Spec A's export mechanism unchanged (no new streaming path) and Spec C's reports-area structure unchanged (no presentation/routing decisions of its own). + +## Goals + +- **Goal:** an admin can view and download the onboarded-user list with each user's content preferences and subscription status. +- **Goal:** reuse Spec A's streaming export and sanitizer — no second export mechanism. + +## Requirements (from #571) + +Table format, one row per onboarded user: + +| Column | Source | Format | +| ------------------- | ------------------------------------------ | --------------------------- | +| Name | `UserProfile.user.name` | text | +| Email | `UserProfile.user.email` | text | +| Content Preferences | `UserProfile.interests[].collection.title` | topic titles joined by `, ` | +| Subscribed? | `UserProfile.isSubscribed` | `Yes` / `No` | + +Out of scope: learning frequency, onboarded date (explicitly excluded — not in the issue). + +## Data model + +A user is "onboarded" once a `UserProfile` row exists. Relevant Prisma models (`prisma/schema.prisma`): + +- `UserProfile` — `userId`, `isSubscribed`, relation `user` (→ `User.name`, `User.email`), relation `interests` (→ `UserInterest[]`). +- `UserInterest` — composite key `(userId, collectionId)`, relation `collection` (→ `Collection.title`). + +Generated types `UserProfileFindManyArgs` / `UserProfileGetPayload` are re-exported from `$lib/server/db.js`, matching the pattern the quiz report uses with `LearningJourney*`. + +## Architecture + +> Decision: nested per-report download endpoints (see [ADR-0003](../../decisions/0003-report-export-routing-and-page-routes.md)). The reports-area presentation (card-grid index with per-page back navigation, see [ADR-0004](../../decisions/0004-card-grid-report-index.md)) is owned by [Spec C](./2026-06-10-card-grid-report-index-design.md); this spec consumes that structure unchanged. + +**Downloads (API tree).** Spec A already moved the quiz download to `src/routes/admin/api/download/quiz/+server.ts`. This spec adds the sibling `src/routes/admin/api/download/user-profile/+server.ts`, keeping each handler single-purpose and the `download/{quiz,user-profile}` hierarchy a clear "report exports" family. Downloads stream a file and are API endpoints, so they stay in the `admin/api` tree, separate from the page routes. + +**Pages (route tree).** Spec C restructures `src/routes/admin/(protected)/reports/` into a card-grid index (`reports/+page.svelte`), a slim shared `+layout.svelte`, and a relocated `reports/quiz/` sibling. This spec adds one more sibling, `reports/user-profile/` (new), and one card entry to the index's report list. The user-profile page renders a "← Back to reports" link above its content per Spec C's pattern. Its load runs only its own queries, so server-side pagination works without a discriminated-union page payload or tab branching. + +## Contracts & boundaries + +This spec introduces no new reusable contract; it **consumes** Spec A's boundaries: + +- `generateReport(options)` — owns streaming, response headers, `Cache-Control: no-store`, per-cell `sanitizeSpreadsheetCell`, and destroying the stream on error. It takes no `RequestEvent` and is logger-agnostic. The user-profile endpoint supplies its `columns`, a `fetchBatch` closure, and an `onError` callback (which logs through the endpoint's handler logger). + +New external contract: + +### `GET /admin/api/download/user-profile` + +- **Does:** streams all onboarded users as an `.xlsx` download. +- **Use:** plain `GET`; no query params. +- **Depends on:** `generateReport`, `db.userProfile`. +- **Guarantees:** auth-gated; streamed, memory-bounded response with the four required columns. +- **Requires:** an authenticated admin session (enforced centrally and per-handler). + +## Components / changes + +The reports-area shell (slim `+layout.svelte`, card-grid index `reports/+page.svelte`, deleted redirect, relocated `reports/quiz/`) is owned by [Spec C](./2026-06-10-card-grid-report-index-design.md). This spec adds the following on top of it, plus one `ReportLink` entry for the user-profile card in Spec C's index list. + +### 1. `src/routes/admin/(protected)/reports/user-profile/+page.server.ts` (new, load) + +- Paginated `db.userProfile.findMany` + `db.userProfile.count`, reusing `PAGE_SIZE = 10`, `orderBy: { user: { name: 'asc' } }`, selecting `isSubscribed`, `user { name, email }`, `interests { collection { title } }`. + +### 2. `src/routes/admin/(protected)/reports/user-profile/+page.svelte` (new, UI) + +- "← Back to reports" link above the content (per [Spec C](./2026-06-10-card-grid-report-index-design.md)'s pattern). +- `Table` with columns Name, Email, Content Preferences, Subscribed?. +- `Paginator` (shown when `totalCount > pageSize`). +- Download XLSX `LinkButton` → `/admin/api/download/user-profile` (`data-sveltekit-reload`). +- Row mapping: `contentPreferences = interests.map((i) => i.collection.title).join(', ')`, `subscribed = isSubscribed ? 'Yes' : 'No'`. + +### 3. `src/routes/admin/api/download/user-profile/+server.ts` (new) + +Uses Spec A's `generateReport` helper (which owns streaming, headers, `Cache-Control: no-store`, per-cell `sanitizeSpreadsheetCell`, and destroying the stream on error): + +- Auth check (401 if no `user`) — defense-in-depth atop the `/admin` hook guard. +- `columns`: Name, Email, Content Preferences, Subscribed?. +- `fetchBatch`: Prisma keyset cursor on `UserProfile.userId` (no filter — all onboarded users), ordered by `userId`, selecting `isSubscribed`, `user { name, email }`, `interests { collection { title } }`. +- `onError`: logs through the endpoint's handler logger; the helper stays logger-agnostic. +- Column values: `Content Preferences = interests.map((i) => i.collection.title).join(', ')`; `Subscribed? = isSubscribed ? 'Yes' : 'No'`. +- Filename `DDMMYYYYHHmmss_user_profile_report.xlsx`, sheet `"User Profile Report"`. + +The on-screen preview (component 1) orders by `user.name` for readability; the download orders by `userId` per Spec A's keyset batching ([ADR-0002](../../decisions/0002-keyset-cursor-pagination-on-primary-key.md)). The two intentionally differ — the admin can sort the downloaded file in Excel. + +## Error handling + +- Unauthenticated request → 401 before any streaming (defense-in-depth atop the `/admin` hook guard). +- Mid-stream failures: `generateReport` destroys the stream and calls the endpoint's `onError` (which logs); see Spec A's Error handling and [ADR-0001](../../decisions/0001-stream-report-exports-with-exceljs.md). +- Load failures surface through SvelteKit's normal error path; each page renders its own report. + +## Security considerations + +Threats assessed via STRIDE / OWASP Top 10. This feature exports PII (names, emails, preferences), so disclosure and injection are the focus. + +- **Access control (OWASP A01 — Broken Access Control; STRIDE: Elevation of Privilege, Information Disclosure).** Already enforced centrally: `src/routes/admin/hooks.server.ts` gates all `/admin/**` paths (unauthenticated → login redirect; inactive admin → signed out). The new `/admin/api/download/user-profile` route inherits this guard. The per-handler `if (!user) return 401` is kept as defense-in-depth, mirroring the quiz endpoint. +- **CSV / formula injection (OWASP A03 — Injection).** `user.name` is end-user-controlled (Google OAuth profile). A value like `=HYPERLINK(...)` or `=cmd|...` becomes a live formula when an admin opens the `.xlsx`. Neutralized by Spec A's `sanitizeSpreadsheetCell`, which `generateReport` applies to every string cell — so the user-profile export inherits the protection. +- **Information disclosure (STRIDE: Information Disclosure).** `generateReport` sets `Cache-Control: no-store` on the response so PII is not cached by browsers/proxies. Data minimization: only the four required fields are selected. +- **CSRF / Tampering.** N/A — the download is a read-only `GET` with no state mutation and takes no user input. +- **Denial of Service (STRIDE: DoS).** Addressed by Spec A: `generateReport` reads cursor-batched pages and streams output, so neither the full result set nor the file is held in memory. + +## Testing + +Mirror the existing quiz-report tests, AAA pattern with inline setup (no shared helpers). `generateReport` and `sanitizeSpreadsheetCell` are covered by Spec A; this spec tests the user-profile-specific pieces: + +- Load (`user-profile/+page.server.ts`): returns onboarding records; pagination (skip); ordering by `user.name`. +- Endpoint: 401 when unauthenticated; correct columns; `fetchBatch` cursor advances across batches over all profiles; comma-joined topics; subscribed `Yes`/`No`; blank preferences when a user has no interests. +- Boundary conditions: empty result set renders the table's `emptyMessage`; only users with a `UserProfile` row appear (the intended population). diff --git a/docs/superpowers/specs/2026-06-10-card-grid-report-index-design.md b/docs/superpowers/specs/2026-06-10-card-grid-report-index-design.md new file mode 100644 index 00000000..b120bc8e --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-card-grid-report-index-design.md @@ -0,0 +1,107 @@ +# Card-Grid Report Index — Design + +**Status:** Approved +**Rationale (ADR):** [ADR-0004 — Card-grid report index with per-page back navigation](../../decisions/0004-card-grid-report-index.md), which supersedes the page-presentation decision of [ADR-0003](../../decisions/0003-report-export-routing-and-page-routes.md) (its download-routing decision carries forward unchanged). +**Related:** [User Profile Report](./2026-06-08-user-profile-report-design.md) (Spec B) adds the second report card on top of this structure; [Streaming Report Exports](./2026-06-08-streaming-report-exports-design.md) (Spec A) owns the export mechanism. This spec is the reports-area restructure; it lands before Spec B. + +## Overview + +The admin **Generate Report** area (`/admin/reports`) currently presents its reports as a horizontal tab strip in a shared layout, with `/admin/reports` redirecting to the quiz report (the default). That strip crowds and eventually overflows as reports are added. + +This spec restructures the area into a **card-grid landing page**: `/admin/reports` becomes an index that lists each report as a card (title and one-line description); selecting a card opens that report's own sibling page; each report page carries a "← Back to reports" link to the index. The default-report redirect is removed and the tab nav is dropped — the grid is the switcher. + +At restructure time the area holds a single report (quiz), which is relocated from the `reports/` root into its own `reports/quiz/` sibling so the index sits at the root. The grid renders one card. [Spec B](./2026-06-08-user-profile-report-design.md) immediately adds the second card (user-profile). + +Scope: this is a presentation/routing change only. No report's data load, query, or download endpoint changes. + +## Goals + +- **Goal:** the report-selection UI stays readable as reports are added, instead of growing a crowding nav strip. +- **Goal:** each report page is self-contained — its own content and its own back affordance — with no shared layout branching on which child is the index. +- **Non-goal:** no shared report registry; the report list is inline presentational data (per [ADR-0004](../../decisions/0004-card-grid-report-index.md)). + +## Architecture + +> Decision: card-grid report index with per-page back navigation (see [ADR-0004](../../decisions/0004-card-grid-report-index.md)). The nested per-report download endpoints from [ADR-0003](../../decisions/0003-report-export-routing-and-page-routes.md) are untouched. + +The reports area is reshaped under `src/routes/admin/(protected)/reports/`: + +- The shared `+layout.svelte` is slimmed to **only** the page container — no header, no tab nav, no reusable `Tabs` component. It renders the active page via the `children` snippet. +- `reports/+page.svelte` becomes the **index**: a "Generate Report" heading plus a responsive grid of report cards. The cards are built from an inline list of report links co-located with the page — the same shape as the former tab array, relocated. The page has no `+page.server.ts` and no data load. +- The former redirect at `reports/+page.server.ts` is **deleted** — the index is now the destination for the admin sidebar's "Generate Report" entry. +- The existing quiz report (`+page.server.ts` + `+page.svelte`) is **relocated** unchanged from the `reports/` root into `reports/quiz/`, and gains a "← Back to reports" link above its content. + +Each report page (now and in future) renders its own back affordance to the index rather than sharing a back link in layout chrome, keeping every page readable top to bottom and the layout free of any "is this the index route?" special-casing. + +## Contracts & boundaries + +This spec introduces no new reusable or external contract — it is a presentation/routing change. Its units are presentational: + +### Unit: report-index card list + +- **Does:** lists the area's reports as cards (title, one-line description, link to the report page). +- **Use:** the index page maps a static, co-located array of report links to cards. +- **Depends on:** nothing server-side — no load, no Prisma, no endpoint. + +The list is inline presentational data, **not** a shared registry the pages or download endpoints derive behaviour from. Its element shape (declaration-level): + +```ts +interface ReportLink { + title: string; + description: string; + href: string; +} +``` + +Adding a report is one new `ReportLink` entry plus its route — no abstraction to extend. + +### Unit: per-page back affordance + +- **Does:** returns the admin from a report page to the index. +- **Use:** each report page renders a "← Back to reports" link to `/admin/reports`, above its content. +- **Depends on:** nothing — a static link; intentionally duplicated per page rather than hoisted into shared chrome (per [ADR-0004](../../decisions/0004-card-grid-report-index.md); revisit at the third-plus report). + +### Unit: reports layout container + +- **Does:** provides the shared page container for the index and every report page. +- **Use:** renders the `children` snippet inside `mx-auto max-w-6xl …`. +- **Depends on:** nothing — no header, no nav, no data load. + +## Components / changes + +### 1. `src/routes/admin/(protected)/reports/+layout.svelte` (modified) + +- Slimmed to only the shared page container, rendering the active page via the `children` snippet. The "Generate Report" header and the tab nav are removed. No data load. + +### 2. `src/routes/admin/(protected)/reports/+page.svelte` (new, index) + +- The "Generate Report" landing page: a heading plus a responsive grid of report cards, each card showing a report's title and one-line description and linking to its page. +- Cards are built from an inline `ReportLink[]` co-located with the page. At this point the list has one entry (quiz); Spec B adds the second. +- Static presentational markup — no `+page.server.ts` and no data load. + +### 3. `src/routes/admin/(protected)/reports/+page.server.ts` (deleted) + +- The redirect to the default report is removed; `/admin/reports` now renders the index directly. + +### 4. `src/routes/admin/(protected)/reports/quiz/+page.server.ts` and `+page.svelte` (relocated) + +- The existing quiz-report load and UI, moved unchanged from the former `reports/` root (quiz dropdown + paginated `learningJourney`; preview table, paginator, and download link → `/admin/api/download/quiz?quizId=...`), with a "← Back to reports" link added above the content. + +## Data flow + +1. The admin sidebar's "Generate Report" entry links to `/admin/reports`, which now renders the card-grid index directly (no redirect). +2. Selecting a card navigates to that report's sibling page (`reports//`), which runs its own load and renders its preview + download. +3. The report page's "← Back to reports" link returns to `/admin/reports`. + +## Error handling + +No new error paths. Removing the redirect means `/admin/reports` resolves to the index page (a 200 with the grid) instead of a 307. Report-page load failures continue to surface through SvelteKit's normal error path, each page rendering its own report. Auth is unchanged: `src/routes/admin/hooks.server.ts` still gates all `/admin/**` paths centrally. + +## Testing + +This spec is a pure presentation/routing change with no new server logic — the only server-side edit is deleting the redirect load. The repo does not component-test route `+page.svelte` / `+layout.svelte` files (route logic is covered by `+page.server.test.ts` / `server.test.ts`; only `$lib` components have `@testing-library/svelte` tests), and this spec adds no new route logic. So verification is: + +- `pnpm check` — svelte-check + type check passes after the redirect load and its `$types` import are removed and the index/layout markup changes land. +- Manual smoke (`pnpm dev`): `/admin/reports` renders the card grid (no redirect to `/admin/reports/quiz`); each card links to its report; each report page shows a "← Back to reports" link that returns to the index. + +No new automated tests are introduced — there is no server logic to cover, and adding route-page component tests would introduce a pattern the repo does not use. The relocated quiz report keeps its existing server-load coverage unchanged. diff --git a/docs/superpowers/specs/TEMPLATE.md b/docs/superpowers/specs/TEMPLATE.md index 98cc93a6..7e53e04c 100644 --- a/docs/superpowers/specs/TEMPLATE.md +++ b/docs/superpowers/specs/TEMPLATE.md @@ -21,7 +21,6 @@ Delete these comments in the real spec. --> **Status:** Draft -**Issue:** **Depends on / Related:** ## Overview diff --git a/src/lib/components/Table/Table.svelte b/src/lib/components/Table/Table.svelte index b104672b..af7aa117 100644 --- a/src/lib/components/Table/Table.svelte +++ b/src/lib/components/Table/Table.svelte @@ -21,7 +21,12 @@
{#each columns as column (column.key)} - {/each} @@ -36,7 +41,7 @@ onclick={() => onrowclick?.(item)} > {#each columns as column (column.key)} - {/each} diff --git a/src/lib/components/Table/types.ts b/src/lib/components/Table/types.ts index bfd913ef..2e0340a0 100644 --- a/src/lib/components/Table/types.ts +++ b/src/lib/components/Table/types.ts @@ -3,4 +3,6 @@ export interface Column { key: keyof T & string; /** The label to display in the header */ label: string; + /** Optional extra classes applied to the column's header and cells */ + class?: string; } diff --git a/src/routes/admin/(protected)/reports/+layout.svelte b/src/routes/admin/(protected)/reports/+layout.svelte new file mode 100644 index 00000000..576815bc --- /dev/null +++ b/src/routes/admin/(protected)/reports/+layout.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children()} +
diff --git a/src/routes/admin/(protected)/reports/+page.svelte b/src/routes/admin/(protected)/reports/+page.svelte index 2db78f49..c20c0657 100644 --- a/src/routes/admin/(protected)/reports/+page.svelte +++ b/src/routes/admin/(protected)/reports/+page.svelte @@ -1,109 +1,37 @@ -
-
- Generate Report -
- -
-
-
- Quiz Report - - Select a quiz to preview results and download the report. - -
- -
- - -
-
-
- - {#if selectedId} -
-
+ {column.label} + {item[column.key]}
- - {#if data.totalCount > data.pageSize} - - {/if} - +
+ Generate Report + Choose a report to preview and download. +
-
- - - Download XLSX - -
- {/if} +
+ {#each reports as report (report.href)} + + {report.title} + {report.description} + + {/each}
diff --git a/src/routes/admin/(protected)/reports/+page.server.ts b/src/routes/admin/(protected)/reports/quiz/+page.server.ts similarity index 100% rename from src/routes/admin/(protected)/reports/+page.server.ts rename to src/routes/admin/(protected)/reports/quiz/+page.server.ts diff --git a/src/routes/admin/(protected)/reports/quiz/+page.svelte b/src/routes/admin/(protected)/reports/quiz/+page.svelte new file mode 100644 index 00000000..18aaacb7 --- /dev/null +++ b/src/routes/admin/(protected)/reports/quiz/+page.svelte @@ -0,0 +1,111 @@ + + + + + Back to reports + + +
+
+
+ Quiz Report + + Select a quiz to preview results and download the report. + +
+ +
+ + +
+
+
+ +{#if selectedId} +
+
+ + {#if data.totalCount > data.pageSize} + + {/if} + + +
+ + + Download XLSX + +
+{/if} diff --git a/src/routes/admin/(protected)/reports/user-profile/+page.server.ts b/src/routes/admin/(protected)/reports/user-profile/+page.server.ts new file mode 100644 index 00000000..6c038cba --- /dev/null +++ b/src/routes/admin/(protected)/reports/user-profile/+page.server.ts @@ -0,0 +1,53 @@ +import { error, redirect } from '@sveltejs/kit'; + +import { db, type UserProfileFindManyArgs, type UserProfileGetPayload } from '$lib/server/db.js'; + +import type { PageServerLoad } from './$types'; + +const PAGE_SIZE = 10; + +export const load: PageServerLoad = async (event) => { + const logger = event.locals.logger.child({ handler: 'page_load_user_profile_report' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + throw redirect(303, '/admin'); + } + + const currentPage = Number(event.url.searchParams.get('page')) || 1; + const skip = (currentPage - 1) * PAGE_SIZE; + + const recordArgs = { + select: { + userId: true, + isSubscribed: true, + user: { + select: { name: true, email: true }, + }, + interests: { + select: { collection: { select: { title: true } } }, + }, + }, + orderBy: { user: { name: 'asc' } }, + skip, + take: PAGE_SIZE, + } satisfies UserProfileFindManyArgs; + + try { + const [records, totalCount] = await Promise.all([ + db.userProfile.findMany(recordArgs), + db.userProfile.count(), + ]); + + return { + records: records as UserProfileGetPayload[], + totalCount, + currentPage, + pageSize: PAGE_SIZE, + }; + } catch (err) { + logger.error({ err }, 'Failed to fetch user profile report data'); + throw error(500); + } +}; diff --git a/src/routes/admin/(protected)/reports/user-profile/+page.svelte b/src/routes/admin/(protected)/reports/user-profile/+page.svelte new file mode 100644 index 00000000..584055dc --- /dev/null +++ b/src/routes/admin/(protected)/reports/user-profile/+page.svelte @@ -0,0 +1,73 @@ + + + + + Back to reports + + +
+
+ User Profile Report + + Onboarded users with their content preferences and subscription status. + +
+
+ +
+
+ + {#if data.totalCount > data.pageSize} + + {/if} + + +
+ + + Download XLSX + +
diff --git a/src/routes/admin/(protected)/reports/user-profile/page.server.test.ts b/src/routes/admin/(protected)/reports/user-profile/page.server.test.ts new file mode 100644 index 00000000..1bdcf83e --- /dev/null +++ b/src/routes/admin/(protected)/reports/user-profile/page.server.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { load } from './+page.server.js'; + +const { mockProfileFindMany, mockProfileCount } = vi.hoisted(() => ({ + mockProfileFindMany: vi.fn(), + mockProfileCount: vi.fn(), +})); + +vi.mock('$lib/server/db.js', () => ({ + db: { + userProfile: { findMany: mockProfileFindMany, count: mockProfileCount }, + }, +})); + +const silentLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +}; +silentLogger.child.mockReturnValue(silentLogger); + +const buildEvent = (url: string) => + ({ + locals: { logger: silentLogger, session: { user: { id: 'admin-1' } } }, + url: new URL(url), + }) as unknown as Parameters[0]; + +beforeEach(() => { + vi.clearAllMocks(); + silentLogger.child.mockReturnValue(silentLogger); + mockProfileFindMany.mockResolvedValue([]); + mockProfileCount.mockResolvedValue(0); +}); + +describe('user-profile report load', () => { + test('returns onboarding records ordered by user name', async () => { + const profiles = [ + { userId: 'u1', isSubscribed: true, user: { name: 'Ann', email: 'a@x.co' }, interests: [] }, + ]; + mockProfileFindMany.mockResolvedValue(profiles); + mockProfileCount.mockResolvedValue(1); + const event = buildEvent('http://localhost/admin/reports/user-profile'); + + const data = await load(event); + if (!data) { + throw new Error('expected the load to return report data'); + } + + expect(data.records).toEqual(profiles); + expect(data.totalCount).toBe(1); + expect(mockProfileFindMany.mock.calls[0][0]).toMatchObject({ + orderBy: { user: { name: 'asc' } }, + skip: 0, + take: 10, + }); + }); + + test('paginates records with skip', async () => { + const event = buildEvent('http://localhost/admin/reports/user-profile?page=3'); + + await load(event); + + expect(mockProfileFindMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 10 }); + }); +}); diff --git a/src/routes/admin/api/download/user-profile/+server.ts b/src/routes/admin/api/download/user-profile/+server.ts new file mode 100644 index 00000000..fab09d33 --- /dev/null +++ b/src/routes/admin/api/download/user-profile/+server.ts @@ -0,0 +1,56 @@ +import { json } from '@sveltejs/kit'; + +import { db, type UserProfileFindManyArgs, type UserProfileGetPayload } from '$lib/server/db.js'; +import { formatTimestamp, generateReport } from '$lib/server/reports'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async (event) => { + const logger = event.locals.logger.child({ handler: 'api_download_user_profile_report' }); + + const { user } = event.locals.session; + if (!user) { + logger.warn('User not authenticated'); + return json(null, { status: 401 }); + } + + const batchSize = 100; + + const recordArgs = { + select: { + userId: true, + isSubscribed: true, + user: { select: { name: true, email: true } }, + interests: { select: { collection: { select: { title: true } } } }, + }, + orderBy: { userId: 'asc' }, + take: batchSize, + } satisfies UserProfileFindManyArgs; + + type UserProfileRow = UserProfileGetPayload; + + const filename = `${formatTimestamp(new Date())}_user_profile_report.xlsx`; + + return generateReport({ + filename, + sheetName: 'User Profile Report', + columns: [ + { header: 'Name', value: (row) => row.user.name }, + { header: 'Email', value: (row) => row.user.email }, + { + header: 'Content Preferences', + value: (row) => row.interests.map((interest) => interest.collection.title).join(', '), + }, + { header: 'Subscribed?', value: (row) => (row.isSubscribed ? 'Yes' : 'No') }, + ], + fetchBatch: async (cursor) => { + const rows = await db.userProfile.findMany({ + ...recordArgs, + ...(cursor && { skip: 1, cursor: { userId: cursor } }), + }); + const nextCursor = rows.length === batchSize ? rows[rows.length - 1].userId : undefined; + return { rows, nextCursor }; + }, + onError: (err) => logger.error({ err }, 'Failed while streaming user profile report'), + }); +}; diff --git a/src/routes/admin/api/download/user-profile/server.test.ts b/src/routes/admin/api/download/user-profile/server.test.ts new file mode 100644 index 00000000..9a15db0c --- /dev/null +++ b/src/routes/admin/api/download/user-profile/server.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { GenerateReportOptions } from '$lib/server/reports'; + +import { GET } from './+server.js'; + +interface UserProfileReportRow { + userId: string; + isSubscribed: boolean; + user: { name: string; email: string }; + interests: { collection: { title: string } }[]; +} + +const { mockGenerateReport, mockFindMany } = vi.hoisted(() => ({ + mockGenerateReport: + vi.fn<(options: GenerateReportOptions) => Response>(), + mockFindMany: vi.fn(), +})); + +vi.mock('$lib/server/reports', async (importActual) => { + const actual = await importActual(); + return { ...actual, generateReport: mockGenerateReport }; +}); + +vi.mock('$lib/server/db.js', () => ({ + db: { + userProfile: { findMany: mockFindMany }, + }, +})); + +const silentLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn(), +}; +silentLogger.child.mockReturnValue(silentLogger); + +const buildEvent = (url: string, user: { id: string } | null) => + ({ + locals: { logger: silentLogger, session: { user } }, + url: new URL(url), + }) as unknown as Parameters[0]; + +beforeEach(() => { + vi.clearAllMocks(); + silentLogger.child.mockReturnValue(silentLogger); + mockGenerateReport.mockReturnValue(new Response('ok')); +}); + +describe('GET /admin/api/download/user-profile', () => { + test('returns 401 and does not stream when unauthenticated', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', null); + + const response = await GET(event); + + expect(response.status).toBe(401); + expect(mockGenerateReport).not.toHaveBeenCalled(); + }); + + test('declares the report columns, sheet name, and filename', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.header)).toEqual([ + 'Name', + 'Email', + 'Content Preferences', + 'Subscribed?', + ]); + expect(options.sheetName).toBe('User Profile Report'); + expect(options.filename).toMatch(/^\d{14}_user_profile_report\.xlsx$/); + }); + + test('maps a profile to row values with comma-joined preferences', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + const record = { + userId: 'u1', + isSubscribed: true, + user: { name: 'Ann', email: 'a@x.co' }, + interests: [{ collection: { title: 'AI' } }, { collection: { title: 'Math' } }], + }; + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.value(record))).toEqual([ + 'Ann', + 'a@x.co', + 'AI, Math', + 'Yes', + ]); + }); + + test('renders blank preferences and No when a profile has no interests', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + const record = { + userId: 'u2', + isSubscribed: false, + user: { name: 'Bob', email: 'b@x.co' }, + interests: [], + }; + + await GET(event); + + const options = mockGenerateReport.mock.calls[0][0]; + expect(options.columns.map((c) => c.value(record))).toEqual(['Bob', 'b@x.co', '', 'No']); + }); + + test('fetchBatch advances the keyset cursor over all profiles', async () => { + const event = buildEvent('http://localhost/admin/api/download/user-profile', { id: 'admin-1' }); + const fullBatch = Array.from({ length: 100 }, (_, i) => ({ userId: `u-${i}` })); + mockFindMany.mockResolvedValueOnce(fullBatch).mockResolvedValueOnce([{ userId: 'u-100' }]); + + await GET(event); + const { fetchBatch } = mockGenerateReport.mock.calls[0][0]; + const first = await fetchBatch(undefined); + const second = await fetchBatch('u-99'); + + expect(mockFindMany.mock.calls[0][0]).toMatchObject({ orderBy: { userId: 'asc' }, take: 100 }); + expect(mockFindMany.mock.calls[0][0]).not.toHaveProperty('cursor'); + expect(first.nextCursor).toBe('u-99'); + expect(mockFindMany.mock.calls[1][0]).toMatchObject({ skip: 1, cursor: { userId: 'u-99' } }); + expect(second.nextCursor).toBeUndefined(); + }); +});