diff --git a/.context/admin/questionnaire-analytics.md b/.context/admin/questionnaire-analytics.md index 1aff0a70..80a333af 100644 --- a/.context/admin/questionnaire-analytics.md +++ b/.context/admin/questionnaire-analytics.md @@ -78,3 +78,38 @@ query (`from`, `to`, `tagIds`). Rate limiting is the automatic section cap (read | `…/versions/[vid]/analytics/cost` | `QuestionnaireCostResult` | Endpoint builders: `API.APP.QUESTIONNAIRES.versionAnalytics{Distributions,Funnel,Cost}(id, vid)`. + +## Exports (F8.2) + +The record-level companion to the aggregate views above: download a version's **completed** +session results. Two buttons on the analytics page (`export-buttons.tsx`, next to the version +selector) hit one route, carrying the **same `from`/`to`/`tagIds` filter** the page is showing — so +an export matches the view. + +| Format | Shape | +| -------- | --------------------------------------------------------------------------------------- | +| **CSV** | One row per session × question (every question; unanswered slots are empty value cells) | +| **JSON** | The full session graph — answers + provenance + per-turn transcript | + +- **Scope** — only **completed**, non-preview sessions whose `createdAt` falls in the window. Status + is still surfaced as a column/field. Capped at `MAX_EXPORT_SESSIONS` (5000); over-cap exports set + `capped: true` (JSON) and log a warning. +- **CSV** is the lossy, spreadsheet-friendly view: every cell is run through `csvEscape` + (RFC-4180 + formula-injection guard). Booleans render `true`/`false`, multi-choice joins with `, `. +- **JSON** is the faithful, machine-readable graph, returned **bare** (no API success envelope) so the + downloaded file is the data itself. + +**Anonymous-mode contract.** When the version's `AppQuestionnaireConfig.anonymousMode = true`, the +loader nulls every `respondentName` **and** drops every session's `turns` array (raw respondent +messages never reach the export). Honoured at the data boundary (`results-loader.ts`), not just the +UI. Answer _values_ are always present in both formats — anonymity is about not linking data to a +person, not redacting the survey data (mirrors the F7.4 PDF export). + +| Endpoint | Query | Returns | +| ------------------------- | -------------------------------- | ------------------------------- | +| `…/versions/[vid]/export` | `from`, `to`, `tagIds`, `format` | CSV text / `ResultsExportModel` | + +`format=csv\|json` (default `json`). Admin-only, version-scoped, master-flag-gated. Bulk read — a +dedicated `exportLimiter` sub-cap (10/min/user) on top of the section tier. Endpoint builder: +`API.APP.QUESTIONNAIRES.versionExport(id, vid)`. Source: `lib/app/questionnaire/export/results-*.ts` +and `app/api/v1/app/questionnaires/[id]/versions/[vid]/export/route.ts`. diff --git a/.context/app/planning/features/f8.2.md b/.context/app/planning/features/f8.2.md new file mode 100644 index 00000000..71c6c74e --- /dev/null +++ b/.context/app/planning/features/f8.2.md @@ -0,0 +1,86 @@ +--- +feature: F8.2 +title: Result exports +phase: P8 — Admin analytics, exports, anonymous mode +status: in flight +owner: TBD +deps: F8.1 (analytics scope + filter), F4.2 (answer slots), F4.6 (session events), F6.1 (turns) +opened: 2026-06-08 +plan: .context/app/planning/development-plan.md#f82--result-exports +docs: .context/admin/questionnaire-analytics.md +--- + +# F8.2 — Result exports + +> Committable tracker for **F8.2**. The record-level companion to F8.1's aggregate views: +> download a version's **completed** session results as CSV (one row per session × question) +> or JSON (the full session graph — answers + provenance + turns). Both honour anonymous mode. +> Read-only, admin-only, no new models, no migration. Gated by `APP_QUESTIONNAIRES_ENABLED`. + +## Intent + +F8.1 made completed sessions readable in aggregate (distributions / funnel / cost), deliberately +never surfacing individual answer values. F8.2 adds the **record-level** read-side: the actual +results, downloadable. CSV is the lossy, spreadsheet-friendly transcript; JSON is the faithful, +machine-readable graph. The export reuses F8.1's exact filter so it mirrors what the admin is +viewing, and is the first surface to carry raw answer values out — so the anonymous-mode contract +is enforced at the data boundary here, ahead of F8.3's cross-surface audit. + +## Decisions (confirmed with the user) + +- **Anonymous mode → null identity + drop turns.** Respondent identity is always nulled when + `anonymousMode = true` (mirrors the F7.4 PDF export). _Additionally_, the JSON export drops the + `turns` array entirely — raw `userMessage`/`agentResponse` can carry incidental free-text PII. + Answer _values_ are always present in both formats: anonymity is about not linking data to a + person, not redacting the survey data. +- **Completed sessions only.** Export includes only non-preview sessions with `status = 'completed'` + whose `createdAt` falls in the window. Status is still surfaced as a CSV column / JSON field. +- **One route, `?format=csv|json`.** A single GET endpoint mirroring the orchestration + conversation-export route; two UI buttons hit it. Default format is JSON. +- **Reuse, don't rebuild.** The F8.1 scope/query contract (`questionnaireAnalyticsQuerySchema`, + `resolveAnalyticsScope`), `csvEscape`, the single-session PDF loader's anonymous stance, and the + `exportLimiter` bulk-read sub-cap are all reused as-is. No migration. + +## Build shape (branch `feat/F8.2-result-exports`) + +- **Export library** (pure serialisers + Prisma-at-the-seam loader) — `lib/app/questionnaire/export/`: + `results-types.ts` (client-safe model), `results-loader.ts` (`loadResultsExport` — batched cousin + of the single-session PDF loader; nulls identity + drops turns when anonymous; one batched + `user.findMany` for names; `MAX_EXPORT_SESSIONS = 5000` cap → `capped`), `results-serialize.ts` + (`toResultsCsv` one-row-per-session×question via `csvEscape` + `renderAnswerValue`; `toResultsJson` + bare model), `results-query.ts` (`resultsExportQuerySchema` = analytics filter + `format`). +- **API route** (admin-only, master-flag-gated, version-scoped, `exportLimiter` sub-cap) — + `app/api/v1/app/questionnaires/[id]/versions/[vid]/export/route.ts`; endpoint constant + `API.APP.QUESTIONNAIRES.versionExport(id, vid)` in `lib/api/endpoints.ts`. +- **UI** — `components/admin/questionnaires/analytics/export-buttons.tsx` (client island; blob + download via server `Content-Disposition`; CSV + JSON buttons; FieldHelp note on anonymous-mode + redaction), wired into `app/admin/questionnaires/[id]/analytics/page.tsx` beside the version + selector, passing the page's existing filter query. + +## Tests + +- Unit (`tests/unit/lib/app/questionnaire/export/`): `results-serialize` — CSV header/row-per- + session×question, empty cells for unanswered slots, `csvEscape` formula-injection, `renderAnswerValue` + per kind, JSON fidelity, anonymous pass-through; `results-loader` — completed/non-preview window + filter, batched name resolution (only when not anonymous), anonymous nulls identity + drops turns + without querying users, answer→turn-ordinal mapping, `capped`, tag filter. 17 tests. +- Integration (`tests/integration/api/v1/app/questionnaires/version-export.test.ts`): flag-gate / + auth / version-scope / bad-date 400; default-JSON vs `?format=csv` content-type + `attachment` + filename + `no-store`; resolved scope reaches the loader; `exportLimiter` 429 leaves the loader + untouched. 9 tests. + +## Known limitations / deferred + +- **No PDF/XLSX, no scheduled/emailed exports** — PDF already exists per-session (F7.4); the rest is + out of scope. +- **Non-completed sessions are not exportable** by design (final-results view). +- **Over-cap exports are silently capped at 5000 sessions** — `capped: true` in JSON + a server-side + warning log; CSV can't carry the flag. +- The full cross-surface anonymous-mode PII audit (every read path, integration tests that flip the + flag) is **F8.3**. + +## Docs and CHANGELOG + +- Admin doc: `.context/admin/questionnaire-analytics.md` (Exports section). +- **No CHANGELOG entry** — the CHANGELOG tracks only the Sunrise platform surface; ConQuest app + routes/models are out of scope. diff --git a/app/admin/questionnaires/[id]/analytics/page.tsx b/app/admin/questionnaires/[id]/analytics/page.tsx index e177d46a..952af742 100644 --- a/app/admin/questionnaires/[id]/analytics/page.tsx +++ b/app/admin/questionnaires/[id]/analytics/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { AnalyticsView } from '@/components/admin/questionnaires/analytics/analytics-view'; +import { ExportButtons } from '@/components/admin/questionnaires/analytics/export-buttons'; import { API } from '@/lib/api/endpoints'; import { parseApiResponse, serverFetch } from '@/lib/api/server-fetch'; import { logger } from '@/lib/logging'; @@ -169,28 +170,31 @@ export default async function QuestionnaireAnalyticsPage({ params, searchParams ) : ( <> {/* Version selector — SSR links that set ?v= on this sub-route. */} -
- {detail.versions.map((ver) => { - const active = ver.id === selected.id; - return ( - - v{ver.versionNumber} - - {' '} - · {ver.status} - - - ); - })} +
+
+ {detail.versions.map((ver) => { + const active = ver.id === selected.id; + return ( + + v{ver.versionNumber} + + {' '} + · {ver.status} + + + ); + })} +
+
( + async (request, session, { params }) => { + // Bulk-read sub-cap (10/min/user) on top of the section tier — like the + // orchestration conversation export. + const rl = exportLimiter.check(`export:user:${session.user.id}`); + if (!rl.success) return createRateLimitResponse(rl); + + const log = await getRouteLogger(request); + const { id, vid } = await params; + + const scoped = await loadScopedVersion(id, vid); + if (!scoped) { + return errorResponse('Questionnaire version not found', { code: 'NOT_FOUND', status: 404 }); + } + + const { searchParams } = new URL(request.url); + const query = validateQueryParams(searchParams, resultsExportQuerySchema); + const scope = resolveAnalyticsScope(vid, query); + + const model = await loadResultsExport(scope); + if (!model) { + return errorResponse('Questionnaire version not found', { code: 'NOT_FOUND', status: 404 }); + } + + log.info('Questionnaire results export', { + versionId: vid, + format: query.format, + sessionCount: model.sessions.length, + capped: model.capped, + }); + + const stem = `results-${slugify(model.questionnaireTitle)}-v${model.versionNumber}-${new Date().toISOString().slice(0, 10)}`; + + if (query.format === 'csv') { + return new Response(toResultsCsv(model), { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${stem}.csv"`, + 'Cache-Control': 'no-store', + }, + }); + } + + return new Response(JSON.stringify(toResultsJson(model)), { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="${stem}.json"`, + 'Cache-Control': 'no-store', + }, + }); + } +); + +export const GET = withQuestionnairesEnabled(handleGet); diff --git a/components/admin/questionnaires/analytics/export-buttons.tsx b/components/admin/questionnaires/analytics/export-buttons.tsx new file mode 100644 index 00000000..0c15a85b --- /dev/null +++ b/components/admin/questionnaires/analytics/export-buttons.tsx @@ -0,0 +1,100 @@ +'use client'; + +/** + * Result-export buttons for the analytics page (F8.2). + * + * Two buttons — CSV and JSON — that download the **completed-session** results for the + * selected version through the F8.2 export route, carrying the same date/tag filter the + * page is showing (so the export matches the view). Blob download via the server-supplied + * `Content-Disposition` filename, mirroring the orchestration agents export. + * + * Anonymous-mode versions omit respondent identity and per-turn transcripts from both + * formats — surfaced to the admin via the inline note + FieldHelp. + */ + +import { useCallback, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { FieldHelp } from '@/components/ui/field-help'; +import { API } from '@/lib/api/endpoints'; +import type { ResultsExportFormat } from '@/lib/app/questionnaire/export/results-query'; + +export interface ExportButtonsProps { + questionnaireId: string; + versionId: string; + /** The shared analytics filter query string (`?from=…&to=…&tagIds=…`) or `''`. */ + query: string; +} + +export function ExportButtons({ questionnaireId, versionId, query }: ExportButtonsProps) { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + const download = useCallback( + async (format: ResultsExportFormat) => { + setBusy(format); + setError(null); + try { + const base = API.APP.QUESTIONNAIRES.versionExport(questionnaireId, versionId); + const url = `${base}${query ? `${query}&` : '?'}format=${format}`; + const res = await fetch(url, { credentials: 'same-origin' }); + if (res.status === 429) throw new Error('Too many exports — wait a minute and retry.'); + if (!res.ok) throw new Error('Export failed. Try again in a moment.'); + + const disposition = res.headers.get('Content-Disposition') ?? ''; + const match = /filename="?([^";]+)"?/i.exec(disposition); + const filename = match?.[1] ?? `results-${new Date().toISOString().slice(0, 10)}.${format}`; + + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Export failed. Try again in a moment.'); + } finally { + setBusy(null); + } + }, + [questionnaireId, versionId, query] + ); + + return ( +
+
+ + Export completed sessions + + Downloads this version’s completed sessions, filtered by the date window and tags + above. CSV is one row per session × question; JSON is the full session graph (answers, + provenance, and turns). Anonymous-mode versions omit respondent identity and per-turn + transcripts from both formats. + + + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 90bbb7cb..be83b451 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -439,6 +439,9 @@ export const API = { /** Per-version cost actuals from `AiCostLog` (GET — F8.1). */ versionAnalyticsCost: (id: string, versionId: string): string => `/api/v1/app/questionnaires/${id}/versions/${versionId}/analytics/cost`, + /** Completed-session results export, CSV or JSON via `?format=` (GET — F8.2). */ + versionExport: (id: string, versionId: string): string => + `/api/v1/app/questionnaires/${id}/versions/${versionId}/export`, /** Invitations for a questionnaire (GET list, POST send single/bulk — F3.2). */ invitations: (id: string): string => `/api/v1/app/questionnaires/${id}/invitations`, /** Single invitation (PATCH revoke — F3.2). */ diff --git a/lib/app/questionnaire/export/results-loader.ts b/lib/app/questionnaire/export/results-loader.ts new file mode 100644 index 00000000..1d24e3c7 --- /dev/null +++ b/lib/app/questionnaire/export/results-loader.ts @@ -0,0 +1,231 @@ +/** + * Record-level result export — DB read seam (F8.2). + * + * Loads a version's **completed, non-preview** sessions in the analytics window into the + * pure {@link ResultsExportModel} the serialisers consume. Built on the same scope the + * F8.1 analytics endpoints take (date window + optional tag filter), so an export + * mirrors exactly what the admin is viewing. A batched cousin of the single-session PDF + * loader (`questionnaire-sessions/_lib/session-export.ts`): same anonymous-mode stance, + * many sessions at once, no per-session identity lookup (no N+1). + * + * Anonymous mode (`AppQuestionnaireConfig.anonymousMode`): respondent identity is never + * queried, and every session's `turns` is dropped to `[]` — raw respondent prose never + * reaches the export. Both honoured here, at the data boundary, not just the UI. + */ + +import { prisma } from '@/lib/db/client'; +import { logger } from '@/lib/logging'; +import { + ANSWER_PROVENANCES, + QUESTION_TYPES, + SESSION_STATUSES, + narrowToEnum, + type AnswerProvenance, + type QuestionType, + type SessionStatus, +} from '@/lib/app/questionnaire/types'; +import type { PanelRefinementEntry } from '@/lib/app/questionnaire/panel/types'; +import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics'; +import type { + ExportAnswer, + ExportQuestion, + ExportSession, + ExportTurn, + ResultsExportModel, +} from '@/lib/app/questionnaire/export/results-types'; + +/** Cap completed sessions per export to bound memory; `capped` flags an over-cap match. */ +export const MAX_EXPORT_SESSIONS = 5000; + +/** Cast a stored `refinementHistory` Json column back to our entry array. */ +function asRefinementHistory(value: unknown): PanelRefinementEntry[] { + return Array.isArray(value) ? (value as PanelRefinementEntry[]) : []; +} + +/** + * Load the completed-session results for a version's export. Returns null when the + * version doesn't exist (the route already 404s via `loadScopedVersion`, but the loader + * stays self-contained for its own metadata). + */ +export async function loadResultsExport(scope: AnalyticsScope): Promise { + const range = { from: scope.from.toISOString(), to: scope.to.toISOString() }; + + // 1. Version header metadata + the anonymous-mode flag. + const version = await prisma.appQuestionnaireVersion.findUnique({ + where: { id: scope.versionId }, + select: { + versionNumber: true, + config: { select: { anonymousMode: true } }, + questionnaire: { select: { title: true } }, + }, + }); + if (!version) return null; + const anonymous = version.config?.anonymousMode ?? false; + + // 2. The version's question slots (optionally tag-filtered), in display order. + const slots = await prisma.appQuestionSlot.findMany({ + where: { + versionId: scope.versionId, + ...(scope.tagIds.length > 0 ? { tags: { some: { tagId: { in: scope.tagIds } } } } : {}), + }, + select: { + id: true, + key: true, + prompt: true, + type: true, + required: true, + section: { select: { title: true } }, + }, + orderBy: [{ section: { ordinal: 'asc' } }, { ordinal: 'asc' }], + }); + + const questions: ExportQuestion[] = slots.map((slot) => ({ + questionId: slot.id, + key: slot.key, + prompt: slot.prompt, + type: narrowToEnum(slot.type, QUESTION_TYPES, 'free_text'), + sectionTitle: slot.section.title, + required: slot.required, + })); + + // 3. Completed, non-preview sessions in the window — chronological, capped. + const where = { + versionId: scope.versionId, + isPreview: false, + status: 'completed' as const, + createdAt: { gte: scope.from, lt: scope.to }, + }; + const [rows, totalMatching] = await Promise.all([ + prisma.appQuestionnaireSession.findMany({ + where, + orderBy: { createdAt: 'asc' }, + take: MAX_EXPORT_SESSIONS, + select: { + id: true, + status: true, + createdAt: true, + updatedAt: true, + respondentUserId: true, + answers: { + select: { + value: true, + confidence: true, + provenanceLabel: true, + provenanceItems: true, + rationale: true, + refinementHistory: true, + lastUpdatedTurnId: true, + questionSlot: { select: { key: true } }, + }, + }, + // Loaded unconditionally so answers can resolve their capturing-turn ordinal, + // but the prose fields are dropped from the *output* in anonymous mode (below) + // — raw respondent messages never leave the server. + turns: { + orderBy: { ordinal: 'asc' }, + select: { + id: true, + ordinal: true, + userMessage: true, + agentResponse: true, + targetedQuestionId: true, + toolCalls: true, + sideEffectAnswerIds: true, + costUsd: true, + createdAt: true, + }, + }, + events: { + where: { toStatus: 'completed' }, + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + }, + }), + prisma.appQuestionnaireSession.count({ where }), + ]); + + const capped = totalMatching > MAX_EXPORT_SESSIONS; + if (capped) { + logger.warn('Questionnaire results export capped', { + versionId: scope.versionId, + totalMatching, + cap: MAX_EXPORT_SESSIONS, + }); + } + + // 4. Batch-resolve respondent names — one query, only when not anonymous. + const nameById = new Map(); + if (!anonymous) { + const ids = [ + ...new Set(rows.map((r) => r.respondentUserId).filter((id): id is string => !!id)), + ]; + if (ids.length > 0) { + const users = await prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, name: true }, + }); + for (const u of users) nameById.set(u.id, u.name ?? null); + } + } + + const sessions: ExportSession[] = rows.map((row) => { + const turnOrdinal = new Map(row.turns.map((t) => [t.id, t.ordinal])); + + const answers: ExportAnswer[] = row.answers.map((a) => ({ + questionKey: a.questionSlot.key, + value: a.value, + confidence: a.confidence, + provenanceLabel: narrowToEnum( + a.provenanceLabel, + ANSWER_PROVENANCES, + 'direct' + ), + provenanceItems: a.provenanceItems ?? null, + rationale: a.rationale, + refinementHistory: asRefinementHistory(a.refinementHistory), + lastUpdatedTurnOrdinal: + a.lastUpdatedTurnId != null ? (turnOrdinal.get(a.lastUpdatedTurnId) ?? null) : null, + })); + + const turns: ExportTurn[] = anonymous + ? [] + : row.turns.map((t) => ({ + ordinal: t.ordinal, + userMessage: t.userMessage, + agentResponse: t.agentResponse, + targetedQuestionId: t.targetedQuestionId, + toolCalls: t.toolCalls, + sideEffectAnswerIds: t.sideEffectAnswerIds, + costUsd: t.costUsd, + createdAt: t.createdAt.toISOString(), + })); + + const status = narrowToEnum(row.status, SESSION_STATUSES, 'completed'); + const completedAt = row.events[0]?.createdAt.toISOString() ?? row.updatedAt.toISOString(); + const respondentName = + anonymous || !row.respondentUserId ? null : (nameById.get(row.respondentUserId) ?? null); + + return { + id: row.id, + status, + createdAt: row.createdAt.toISOString(), + completedAt, + respondentName, + answers, + turns, + }; + }); + + return { + versionId: scope.versionId, + versionNumber: version.versionNumber, + questionnaireTitle: version.questionnaire.title, + range, + anonymous, + capped, + questions, + sessions, + }; +} diff --git a/lib/app/questionnaire/export/results-query.ts b/lib/app/questionnaire/export/results-query.ts new file mode 100644 index 00000000..8894e33b --- /dev/null +++ b/lib/app/questionnaire/export/results-query.ts @@ -0,0 +1,23 @@ +/** + * Query contract for the F8.2 result-export endpoint. + * + * Extends the shared F8.1 analytics filter (date window + tag filter) with a `format` + * selector, so a single GET route serves both CSV and JSON and an export mirrors exactly + * the analytics view the admin is filtering. `resolveAnalyticsScope` (reused as-is) + * turns the validated query into the concrete window the loader takes. + */ + +import { z } from 'zod'; + +import { questionnaireAnalyticsQuerySchema } from '@/lib/app/questionnaire/analytics'; + +/** Export output formats. */ +export const RESULTS_EXPORT_FORMATS = ['csv', 'json'] as const; +export type ResultsExportFormat = (typeof RESULTS_EXPORT_FORMATS)[number]; + +/** Analytics filter + a `format` selector (defaults to JSON). */ +export const resultsExportQuerySchema = questionnaireAnalyticsQuerySchema.extend({ + format: z.enum(RESULTS_EXPORT_FORMATS).default('json'), +}); + +export type ResultsExportQuery = z.infer; diff --git a/lib/app/questionnaire/export/results-serialize.ts b/lib/app/questionnaire/export/results-serialize.ts new file mode 100644 index 00000000..cdefd723 --- /dev/null +++ b/lib/app/questionnaire/export/results-serialize.ts @@ -0,0 +1,89 @@ +/** + * Record-level result export — CSV / JSON serialisers (F8.2). + * + * Pure: a {@link ResultsExportModel} in, a string (CSV) or plain object (JSON) out — no + * Prisma, no Next, no clock. The loader has already applied the anonymous-mode contract + * (null respondent, empty turns), so these just shape the output. + * + * - CSV is **one row per session × question** — the lossy, spreadsheet-friendly view. + * Every question appears for every session; an unanswered slot is an empty value cell. + * - JSON is the **full session graph** (answers + provenance + turns), the faithful + * machine-readable view. Returned as a bare object (no API envelope) so the downloaded + * file is the data itself. + */ + +import { csvEscape } from '@/lib/api/csv'; +import type { ResultsExportModel } from '@/lib/app/questionnaire/export/results-types'; + +/** The CSV header, in column order. Exported so tests assert against one source of truth. */ +export const RESULTS_CSV_COLUMNS = [ + 'session_id', + 'session_status', + 'created_at', + 'completed_at', + 'respondent_name', + 'section_title', + 'question_key', + 'question_prompt', + 'question_type', + 'answer_value', + 'confidence', + 'provenance_label', +] as const; + +/** + * Stringify a stored answer value for a CSV cell. Faithful over pretty: arrays join with + * `, ` (multi-choice), objects fall back to JSON, null/empty become an empty cell (so an + * unanswered slot and an empty answer read the same — blank). Booleans stay `true`/`false` + * rather than display labels, since CSV is the data view. + */ +export function renderAnswerValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return value.map((v) => renderAnswerValue(v)).join(', '); + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' || typeof value === 'bigint') return value.toString(); + try { + return JSON.stringify(value) ?? ''; + } catch { + return ''; + } +} + +/** Serialise the model to CSV — one row per session × question. */ +export function toResultsCsv(model: ResultsExportModel): string { + const lines: string[] = [RESULTS_CSV_COLUMNS.join(',')]; + + for (const session of model.sessions) { + const answerByKey = new Map(session.answers.map((a) => [a.questionKey, a])); + for (const question of model.questions) { + const answer = answerByKey.get(question.key); + lines.push( + [ + csvEscape(session.id), + csvEscape(session.status), + csvEscape(session.createdAt), + csvEscape(session.completedAt ?? ''), + csvEscape(session.respondentName ?? ''), + csvEscape(question.sectionTitle), + csvEscape(question.key), + csvEscape(question.prompt), + csvEscape(question.type), + csvEscape(answer ? renderAnswerValue(answer.value) : ''), + csvEscape(answer?.confidence != null ? String(answer.confidence) : ''), + csvEscape(answer?.provenanceLabel ?? ''), + ].join(',') + ); + } + } + + return lines.join('\n'); +} + +/** + * Serialise the model to the JSON export object — the full session graph. Returned bare + * (not wrapped in the API success envelope) so the downloaded file is the data itself. + */ +export function toResultsJson(model: ResultsExportModel): ResultsExportModel { + return model; +} diff --git a/lib/app/questionnaire/export/results-types.ts b/lib/app/questionnaire/export/results-types.ts new file mode 100644 index 00000000..46619cd1 --- /dev/null +++ b/lib/app/questionnaire/export/results-types.ts @@ -0,0 +1,85 @@ +/** + * Record-level result export — model types (F8.2). + * + * The shape the multi-session export loader produces and the CSV / JSON serialisers + * consume. Pure and client-safe (no Prisma, no Next) — dates cross as ISO strings, the + * answer `value` stays the raw stored Json. Sibling to the F8.1 aggregate `views`: that + * surface is counts-only by design; this one is the record-level companion, so it must + * honour the anonymous-mode contract itself. + * + * Anonymous-mode contract (resolved in the loader, not here): + * - `respondentName` is null on every session. + * - `turns` is `[]` on every session — raw respondent prose never reaches the export. + * Answer *values* are always present in both formats: anonymity is about not linking + * data to a person, not redacting the survey data itself (mirrors the PDF export). + */ + +import type { AnalyticsRange } from '@/lib/app/questionnaire/analytics'; +import type { AnswerProvenance, QuestionType, SessionStatus } from '@/lib/app/questionnaire/types'; +import type { PanelRefinementEntry } from '@/lib/app/questionnaire/panel/types'; + +/** One question column in the export, in display order (section ordinal → slot ordinal). */ +export interface ExportQuestion { + questionId: string; + key: string; + prompt: string; + type: QuestionType; + sectionTitle: string; + required: boolean; +} + +/** One captured answer within a session, keyed back to its question slot. */ +export interface ExportAnswer { + questionKey: string; + /** Raw stored value — string | number | boolean | string[] | object. */ + value: unknown; + confidence: number | null; + provenanceLabel: AnswerProvenance; + /** Sunrise `ProvenanceItem[]` (reserved; not populated by the current write path). */ + provenanceItems: unknown; + rationale: string | null; + refinementHistory: PanelRefinementEntry[]; + /** 1-based ordinal of the turn that last captured this slot, or null when unmapped. */ + lastUpdatedTurnOrdinal: number | null; +} + +/** One persisted respondent turn. Omitted entirely (empty array) in anonymous mode. */ +export interface ExportTurn { + ordinal: number; + userMessage: string; + agentResponse: string; + targetedQuestionId: string | null; + /** Ordered capability outcomes — `[{ slug, success, code?, latencyMs? }]`. */ + toolCalls: unknown; + /** `AppAnswerSlot.id[]` this turn created/updated. */ + sideEffectAnswerIds: unknown; + costUsd: number | null; + createdAt: string; +} + +/** One completed session's full record. */ +export interface ExportSession { + id: string; + status: SessionStatus; + createdAt: string; + completedAt: string | null; + /** Null when the version is anonymous or the respondent is unknown. */ + respondentName: string | null; + answers: ExportAnswer[]; + /** Empty in anonymous mode. */ + turns: ExportTurn[]; +} + +/** The full export payload — questions (columns) + completed sessions (rows). */ +export interface ResultsExportModel { + versionId: string; + versionNumber: number; + questionnaireTitle: string; + range: AnalyticsRange; + /** The version's `anonymousMode` flag, echoed so consumers can label the export. */ + anonymous: boolean; + /** True when more completed sessions matched than the export cap returned. */ + capped: boolean; + questions: ExportQuestion[]; + sessions: ExportSession[]; +} diff --git a/tests/integration/api/v1/app/questionnaires/version-export.test.ts b/tests/integration/api/v1/app/questionnaires/version-export.test.ts new file mode 100644 index 00000000..12b2fb4c --- /dev/null +++ b/tests/integration/api/v1/app/questionnaires/version-export.test.ts @@ -0,0 +1,211 @@ +/** + * Integration test: result-export route (F8.2). + * + * Pins the route shell — flag-gate → rate-limit → auth → version-scope → query-validation + * → loader → format serialisation — for GET .../versions/:vid/export. The DB loader is + * stubbed (unit-tested separately) but the REAL serialisers run, so the CSV/JSON bodies + * and the download headers are exercised end to end: + * - 404 flag off (before auth); 401 unauth; 403 non-admin; 404 unknown version + * - 400 on a bad date query + * - default format = JSON; `?format=csv` → text/csv + attachment .csv + * - the resolved scope (version + parsed tags) reaches the loader + * - 429 from the export sub-cap, loader untouched + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +// ─── Mocks (hoisted) ────────────────────────────────────────────────────────── + +vi.mock('@/lib/feature-flags', () => ({ isFeatureEnabled: vi.fn() })); +vi.mock('@/lib/auth/config', () => ({ auth: { api: { getSession: vi.fn() } } })); +vi.mock('next/headers', () => ({ headers: vi.fn(() => Promise.resolve(new Headers())) })); + +const prismaMock = vi.hoisted(() => ({ + appQuestionnaireVersion: { findFirst: vi.fn() }, +})); +vi.mock('@/lib/db/client', () => ({ prisma: prismaMock })); + +const loaderMock = vi.hoisted(() => ({ loadResultsExport: vi.fn() })); +vi.mock('@/lib/app/questionnaire/export/results-loader', () => loaderMock); + +const limiterMock = vi.hoisted(() => ({ + check: vi.fn<() => { success: boolean; limit?: number; remaining?: number; reset?: number }>( + () => ({ success: true }) + ), +})); +vi.mock('@/lib/security/rate-limit', async (importOriginal) => ({ + ...(await importOriginal()), + exportLimiter: limiterMock, +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { GET } from '@/app/api/v1/app/questionnaires/[id]/versions/[vid]/export/route'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { auth } from '@/lib/auth/config'; +import { APP_QUESTIONNAIRES_FLAG } from '@/lib/app/questionnaire/constants'; +import type { ResultsExportModel } from '@/lib/app/questionnaire/export/results-types'; +import { + mockAdminUser, + mockAuthenticatedUser, + mockUnauthenticatedUser, +} from '@/tests/helpers/auth'; + +type Mock = ReturnType; + +const BASE = 'http://localhost:3000/api/v1/app/questionnaires/qn-1/versions/v1/export'; +const PARAMS = { id: 'qn-1', vid: 'v1' }; + +function req(search = ''): NextRequest { + return { url: `${BASE}${search}`, headers: new Headers() } as unknown as NextRequest; +} +function ctx() { + return { params: Promise.resolve(PARAMS) }; +} +function setAuth(session: ReturnType | null) { + (auth.api.getSession as unknown as Mock).mockResolvedValue(session); +} + +const MODEL: ResultsExportModel = { + versionId: 'v1', + versionNumber: 2, + questionnaireTitle: 'Onboarding Survey', + range: { from: '2026-01-01T00:00:00.000Z', to: '2026-02-01T00:00:00.000Z' }, + anonymous: false, + capped: false, + questions: [ + { + questionId: 'q1', + key: 'role', + prompt: 'Your role?', + type: 'free_text', + sectionTitle: 'About', + required: true, + }, + ], + sessions: [ + { + id: 's1', + status: 'completed', + createdAt: '2026-01-10T09:00:00.000Z', + completedAt: '2026-01-10T09:30:00.000Z', + respondentName: 'Ada', + answers: [ + { + questionKey: 'role', + value: 'Engineer', + confidence: 0.9, + provenanceLabel: 'direct', + provenanceItems: null, + rationale: null, + refinementHistory: [], + lastUpdatedTurnOrdinal: 1, + }, + ], + turns: [], + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isFeatureEnabled).mockImplementation((flag) => + Promise.resolve(flag === APP_QUESTIONNAIRES_FLAG) + ); + setAuth(mockAdminUser()); + limiterMock.check.mockReturnValue({ success: true }); + prismaMock.appQuestionnaireVersion.findFirst.mockResolvedValue({ + id: 'v1', + questionnaireId: 'qn-1', + versionNumber: 2, + status: 'launched', + }); + loaderMock.loadResultsExport.mockResolvedValue(MODEL); +}); + +describe('GET versions/:vid/export', () => { + it('404s when the master flag is off, before auth', async () => { + vi.mocked(isFeatureEnabled).mockResolvedValue(false); + setAuth(null); + const res = await GET(req(), ctx()); + expect(res.status).toBe(404); + expect(loaderMock.loadResultsExport).not.toHaveBeenCalled(); + }); + + it('401s when unauthenticated', async () => { + setAuth(mockUnauthenticatedUser()); + expect((await GET(req(), ctx())).status).toBe(401); + }); + + it('403s for a non-admin', async () => { + setAuth(mockAuthenticatedUser()); + expect((await GET(req(), ctx())).status).toBe(403); + }); + + it('404s with the error envelope when the version does not resolve', async () => { + prismaMock.appQuestionnaireVersion.findFirst.mockResolvedValue(null); + const res = await GET(req(), ctx()); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.error?.code).toBe('NOT_FOUND'); + expect(loaderMock.loadResultsExport).not.toHaveBeenCalled(); + }); + + it('400s on an invalid date query', async () => { + const res = await GET(req('?from=not-a-date'), ctx()); + expect(res.status).toBe(400); + expect(loaderMock.loadResultsExport).not.toHaveBeenCalled(); + }); + + it('defaults to a JSON download carrying the full model', async () => { + const res = await GET(req(), ctx()); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('application/json'); + expect(res.headers.get('Content-Disposition')).toMatch( + /attachment; filename="results-onboarding-survey-v2-.*\.json"/ + ); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + const body = await res.json(); + expect(body.versionId).toBe('v1'); + expect(body.sessions[0].answers[0].value).toBe('Engineer'); + }); + + it('serves a CSV download with the analytics header row when format=csv', async () => { + const res = await GET(req('?format=csv'), ctx()); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toContain('text/csv'); + expect(res.headers.get('Content-Disposition')).toMatch( + /attachment; filename="results-onboarding-survey-v2-.*\.csv"/ + ); + const text = await res.text(); + const [header, firstRow] = text.split('\n'); + expect(header).toBe( + 'session_id,session_status,created_at,completed_at,respondent_name,section_title,question_key,question_prompt,question_type,answer_value,confidence,provenance_label' + ); + expect(firstRow).toContain('s1,completed,'); + expect(firstRow).toContain('Engineer'); + }); + + it('passes the resolved scope (version + parsed tags) to the loader', async () => { + await GET(req('?tagIds=t1,t2'), ctx()); + const scope = loaderMock.loadResultsExport.mock.calls[0][0]; + expect(scope.versionId).toBe('v1'); + expect(scope.tagIds).toEqual(['t1', 't2']); + expect(scope.from).toBeInstanceOf(Date); + expect(scope.to).toBeInstanceOf(Date); + }); + + it('429s from the export sub-cap without touching the loader', async () => { + limiterMock.check.mockReturnValue({ + success: false, + limit: 10, + remaining: 0, + reset: Math.floor(Date.UTC(2030, 0, 1) / 1000), + }); + const res = await GET(req(), ctx()); + expect(res.status).toBe(429); + expect(loaderMock.loadResultsExport).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/components/admin/questionnaires/analytics/export-buttons.test.tsx b/tests/unit/components/admin/questionnaires/analytics/export-buttons.test.tsx new file mode 100644 index 00000000..e3e01a6f --- /dev/null +++ b/tests/unit/components/admin/questionnaires/analytics/export-buttons.test.tsx @@ -0,0 +1,108 @@ +/** + * ExportButtons component tests (F8.2). + * + * Anti-green-bar: asserts the buttons hit the version-export endpoint with the page's + * current filter query plus the right `format`, trigger a blob download using the + * server-supplied `Content-Disposition` filename, and surface the 429 (rate-limited) + * and generic failure cases inline without downloading. + * + * @see components/admin/questionnaires/analytics/export-buttons.tsx + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ExportButtons } from '@/components/admin/questionnaires/analytics/export-buttons'; +import { API } from '@/lib/api/endpoints'; + +const BASE = API.APP.QUESTIONNAIRES.versionExport('qn-1', 'v1'); + +interface ClickCapture { + href: string; + download: string; +} +let lastClick: ClickCapture | null = null; + +function mockResponse(over: Partial & { dispositionName?: string } = {}): Response { + const name = over.dispositionName ?? 'results-onboarding-v2-2026-01-10.csv'; + return { + ok: over.ok ?? true, + status: over.status ?? 200, + headers: { + get: (k: string) => + k.toLowerCase() === 'content-disposition' ? `attachment; filename="${name}"` : null, + }, + blob: async () => new Blob(['payload']), + } as unknown as Response; +} + +beforeEach(() => { + lastClick = null; + vi.stubGlobal( + 'URL', + Object.assign(URL, { + createObjectURL: vi.fn(() => 'blob:mock'), + revokeObjectURL: vi.fn(), + }) + ); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement + ) { + lastClick = { href: this.href, download: this.download }; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('ExportButtons', () => { + it('fetches CSV with the current filter query appended before format, and downloads the named file', async () => { + const fetchMock = vi.fn().mockResolvedValue(mockResponse()); + vi.stubGlobal('fetch', fetchMock); + + render(); + await userEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(fetchMock).toHaveBeenCalledWith(`${BASE}?from=2026-01-01&format=csv`, { + credentials: 'same-origin', + }); + await waitFor(() => expect(lastClick).not.toBeNull()); + expect(lastClick!.download).toBe('results-onboarding-v2-2026-01-10.csv'); + }); + + it('starts the query string with ? when no filter is active (JSON)', async () => { + const fetchMock = vi.fn().mockResolvedValue(mockResponse({ dispositionName: 'x.json' })); + vi.stubGlobal('fetch', fetchMock); + + render(); + await userEvent.click(screen.getByRole('button', { name: 'Export JSON' })); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith(`${BASE}?format=json`, expect.anything()) + ); + }); + + it('surfaces a rate-limit message on 429 and does not download', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ ok: false, status: 429 }))); + + render(); + await userEvent.click(screen.getByRole('button', { name: 'Export CSV' })); + + expect(await screen.findByText(/too many exports/i)).toBeInTheDocument(); + expect(lastClick).toBeNull(); + }); + + it('surfaces a generic failure on a non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ ok: false, status: 500 }))); + + render(); + await userEvent.click(screen.getByRole('button', { name: 'Export JSON' })); + + expect(await screen.findByText(/export failed/i)).toBeInTheDocument(); + expect(lastClick).toBeNull(); + }); +}); diff --git a/tests/unit/lib/api/endpoints.test.ts b/tests/unit/lib/api/endpoints.test.ts index 58d9db3d..fcfeeb46 100644 --- a/tests/unit/lib/api/endpoints.test.ts +++ b/tests/unit/lib/api/endpoints.test.ts @@ -1100,6 +1100,7 @@ describe('API Endpoints', () => { expect(Q.versionCostEstimate('qn1', 'v1')).toBe(`${base}/qn1/versions/v1/cost-estimate`); expect(Q.versionNextQuestion('qn1', 'v1')).toBe(`${base}/qn1/versions/v1/next-question`); expect(Q.versionEmbedQuestions('qn1', 'v1')).toBe(`${base}/qn1/versions/v1/embed-questions`); + expect(Q.versionExport('qn1', 'v1')).toBe(`${base}/qn1/versions/v1/export`); }); it('builds section/question/tag paths', () => { diff --git a/tests/unit/lib/app/questionnaire/export/results-loader.test.ts b/tests/unit/lib/app/questionnaire/export/results-loader.test.ts new file mode 100644 index 00000000..7c842516 --- /dev/null +++ b/tests/unit/lib/app/questionnaire/export/results-loader.test.ts @@ -0,0 +1,280 @@ +/** + * Unit test: result-export DB loader (F8.2). + * + * Mocks Prisma and pins the load contract that the serialisers depend on: + * - only completed, non-preview sessions in the window are queried; + * - respondent names are batch-resolved (one query) and ONLY when not anonymous; + * - anonymous mode nulls every respondent name AND drops every session's turns, + * while still resolving answer→turn ordinals (identity gone, audit math intact); + * - the over-cap `capped` flag. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const versionFindUnique = vi.fn(); +const slotFindMany = vi.fn(); +const sessionFindMany = vi.fn(); +const sessionCount = vi.fn(); +const userFindMany = vi.fn(); + +vi.mock('@/lib/db/client', () => ({ + prisma: { + appQuestionnaireVersion: { findUnique: (...a: unknown[]) => versionFindUnique(...a) }, + appQuestionSlot: { findMany: (...a: unknown[]) => slotFindMany(...a) }, + appQuestionnaireSession: { + findMany: (...a: unknown[]) => sessionFindMany(...a), + count: (...a: unknown[]) => sessionCount(...a), + }, + user: { findMany: (...a: unknown[]) => userFindMany(...a) }, + }, +})); + +import { + loadResultsExport, + MAX_EXPORT_SESSIONS, +} from '@/lib/app/questionnaire/export/results-loader'; +import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics'; + +const scope: AnalyticsScope = { + versionId: 'v1', + from: new Date('2026-01-01T00:00:00.000Z'), + to: new Date('2026-02-01T00:00:00.000Z'), + tagIds: [], +}; + +const SLOTS = [ + { + id: 'q1', + key: 'role', + prompt: 'Your role?', + type: 'free_text', + required: true, + section: { title: 'About you' }, + }, +]; + +/** One completed session with a single answer captured on turn ordinal 2. */ +function sessionRow(over: Record = {}) { + return { + id: 's1', + status: 'completed', + createdAt: new Date('2026-01-10T09:00:00.000Z'), + updatedAt: new Date('2026-01-10T09:40:00.000Z'), + respondentUserId: 'u1', + answers: [ + { + value: 'Engineer', + confidence: 0.8, + provenanceLabel: 'direct', + provenanceItems: null, + rationale: null, + refinementHistory: [], + lastUpdatedTurnId: 't2', + questionSlot: { key: 'role' }, + }, + ], + turns: [ + { + id: 't1', + ordinal: 1, + userMessage: 'hi', + agentResponse: 'hello', + targetedQuestionId: null, + toolCalls: [], + sideEffectAnswerIds: [], + costUsd: null, + createdAt: new Date('2026-01-10T09:01:00.000Z'), + }, + { + id: 't2', + ordinal: 2, + userMessage: 'I am an engineer', + agentResponse: 'noted', + targetedQuestionId: 'q1', + toolCalls: [], + sideEffectAnswerIds: ['a1'], + costUsd: 0.02, + createdAt: new Date('2026-01-10T09:02:00.000Z'), + }, + ], + events: [{ createdAt: new Date('2026-01-10T09:30:00.000Z') }], + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + versionFindUnique.mockResolvedValue({ + versionNumber: 3, + config: { anonymousMode: false }, + questionnaire: { title: 'Onboarding' }, + }); + slotFindMany.mockResolvedValue(SLOTS); + sessionFindMany.mockResolvedValue([sessionRow()]); + sessionCount.mockResolvedValue(1); + userFindMany.mockResolvedValue([{ id: 'u1', name: 'Ada Lovelace' }]); +}); + +describe('loadResultsExport', () => { + it('returns null when the version does not exist', async () => { + versionFindUnique.mockResolvedValue(null); + expect(await loadResultsExport(scope)).toBeNull(); + }); + + it('queries only completed, non-preview sessions inside the window', async () => { + await loadResultsExport(scope); + const where = sessionFindMany.mock.calls[0][0].where; + expect(where).toMatchObject({ + versionId: 'v1', + isPreview: false, + status: 'completed', + createdAt: { gte: scope.from, lt: scope.to }, + }); + }); + + it('resolves respondent names in one batched query when not anonymous', async () => { + const model = await loadResultsExport(scope); + expect(userFindMany).toHaveBeenCalledTimes(1); + expect(userFindMany.mock.calls[0][0].where).toEqual({ id: { in: ['u1'] } }); + expect(model!.sessions[0].respondentName).toBe('Ada Lovelace'); + }); + + it('maps an answer to the ordinal of the turn that last captured it', async () => { + const model = await loadResultsExport(scope); + expect(model!.sessions[0].answers[0].lastUpdatedTurnOrdinal).toBe(2); + }); + + it('populates the full turn graph when not anonymous', async () => { + const model = await loadResultsExport(scope); + expect(model!.sessions[0].turns).toHaveLength(2); + expect(model!.sessions[0].turns[1].userMessage).toBe('I am an engineer'); + }); + + it('nulls identity and drops turns in anonymous mode, without querying users', async () => { + versionFindUnique.mockResolvedValue({ + versionNumber: 3, + config: { anonymousMode: true }, + questionnaire: { title: 'Onboarding' }, + }); + const model = await loadResultsExport(scope); + expect(model!.anonymous).toBe(true); + expect(model!.sessions[0].respondentName).toBeNull(); + expect(model!.sessions[0].turns).toEqual([]); + // Identity is never queried in anonymous mode. + expect(userFindMany).not.toHaveBeenCalled(); + // Answer→turn ordinal math still works (turns were loaded, just not surfaced). + expect(model!.sessions[0].answers[0].lastUpdatedTurnOrdinal).toBe(2); + }); + + it('flags `capped` when more sessions match than the cap returns', async () => { + sessionCount.mockResolvedValue(MAX_EXPORT_SESSIONS + 5); + const model = await loadResultsExport(scope); + expect(model!.capped).toBe(true); + expect(sessionFindMany.mock.calls[0][0].take).toBe(MAX_EXPORT_SESSIONS); + }); + + it('applies the tag filter to the slot query when tagIds are present', async () => { + await loadResultsExport({ ...scope, tagIds: ['t1', 't2'] }); + const where = slotFindMany.mock.calls[0][0].where; + expect(where.tags).toEqual({ some: { tagId: { in: ['t1', 't2'] } } }); + }); + + it('handles config-less versions, null/unknown respondents, and absent/unmapped turn links', async () => { + const created = new Date('2026-01-10T09:00:00.000Z'); + const updated = new Date('2026-01-10T09:40:00.000Z'); + // No `config` row → anonymousMode defaults to false. + versionFindUnique.mockResolvedValue({ + versionNumber: 1, + config: null, + questionnaire: { title: 'X' }, + }); + // The known user has a null name; the other respondent id isn't returned at all. + userFindMany.mockResolvedValue([{ id: 'u-known', name: null }]); + sessionFindMany.mockResolvedValue([ + { + // Logged-out anonymous: no respondent, no completion event, junk refinement history, + // provenanceItems present, answer not linked to a turn. + id: 's1', + status: 'completed', + createdAt: created, + updatedAt: updated, + respondentUserId: null, + answers: [ + { + value: 'v', + confidence: null, + provenanceLabel: 'refined', + provenanceItems: { a: 1 }, + rationale: null, + refinementHistory: 'not-an-array', + lastUpdatedTurnId: null, + questionSlot: { key: 'role' }, + }, + ], + turns: [], + events: [], + }, + { + // Known respondent (name null) whose answer points at a turn that isn't loaded. + id: 's2', + status: 'completed', + createdAt: created, + updatedAt: updated, + respondentUserId: 'u-known', + answers: [ + { + value: 'v', + confidence: 0.5, + provenanceLabel: 'direct', + provenanceItems: null, + rationale: null, + refinementHistory: [], + lastUpdatedTurnId: 't-missing', + questionSlot: { key: 'role' }, + }, + ], + turns: [ + { + id: 't1', + ordinal: 1, + userMessage: 'm', + agentResponse: 'a', + targetedQuestionId: null, + toolCalls: [], + sideEffectAnswerIds: [], + costUsd: null, + createdAt: created, + }, + ], + events: [], + }, + { + // Respondent id that the user lookup doesn't resolve. + id: 's3', + status: 'completed', + createdAt: created, + updatedAt: updated, + respondentUserId: 'u-gone', + answers: [], + turns: [], + events: [], + }, + ]); + sessionCount.mockResolvedValue(3); + + const model = await loadResultsExport(scope); + expect(model!.anonymous).toBe(false); // config null → false + // s1: null respondent, junk history → [], non-null provenanceItems passthrough, no turn link, + // and completedAt falls back to updatedAt when there's no completion event. + expect(model!.sessions[0].respondentName).toBeNull(); + expect(model!.sessions[0].answers[0].refinementHistory).toEqual([]); + expect(model!.sessions[0].answers[0].provenanceItems).toEqual({ a: 1 }); + expect(model!.sessions[0].answers[0].lastUpdatedTurnOrdinal).toBeNull(); + expect(model!.sessions[0].completedAt).toBe(updated.toISOString()); + // s2: known user with a null name → null; answer→turn link misses → null ordinal. + expect(model!.sessions[1].respondentName).toBeNull(); + expect(model!.sessions[1].answers[0].lastUpdatedTurnOrdinal).toBeNull(); + // s3: unresolved respondent id → null. + expect(model!.sessions[2].respondentName).toBeNull(); + }); +}); diff --git a/tests/unit/lib/app/questionnaire/export/results-query.test.ts b/tests/unit/lib/app/questionnaire/export/results-query.test.ts new file mode 100644 index 00000000..2b4c6c9f --- /dev/null +++ b/tests/unit/lib/app/questionnaire/export/results-query.test.ts @@ -0,0 +1,38 @@ +/** + * Unit test: result-export query contract (F8.2). + * + * The export route reuses the F8.1 analytics filter and adds a `format` selector. Pin + * the additions: format defaults to JSON, only csv/json are accepted, and the inherited + * date/tag fields still validate (a bad date is rejected). + */ + +import { describe, it, expect } from 'vitest'; + +import { + RESULTS_EXPORT_FORMATS, + resultsExportQuerySchema, +} from '@/lib/app/questionnaire/export/results-query'; + +describe('resultsExportQuerySchema', () => { + it('defaults format to json when omitted', () => { + const parsed = resultsExportQuerySchema.parse({}); + expect(parsed.format).toBe('json'); + }); + + it('accepts each supported format', () => { + for (const format of RESULTS_EXPORT_FORMATS) { + expect(resultsExportQuerySchema.parse({ format }).format).toBe(format); + } + }); + + it('rejects an unsupported format', () => { + expect(resultsExportQuerySchema.safeParse({ format: 'pdf' }).success).toBe(false); + }); + + it('still inherits and validates the analytics date/tag filter', () => { + const ok = resultsExportQuerySchema.parse({ from: '2026-01-01', tagIds: 't1,t2' }); + expect(ok.from).toBe('2026-01-01'); + expect(ok.tagIds).toBe('t1,t2'); + expect(resultsExportQuerySchema.safeParse({ from: 'not-a-date' }).success).toBe(false); + }); +}); diff --git a/tests/unit/lib/app/questionnaire/export/results-serialize.test.ts b/tests/unit/lib/app/questionnaire/export/results-serialize.test.ts new file mode 100644 index 00000000..6254df2e --- /dev/null +++ b/tests/unit/lib/app/questionnaire/export/results-serialize.test.ts @@ -0,0 +1,183 @@ +/** + * Unit test: result-export serialisers (F8.2). + * + * Pure model-in / string-or-object-out. Asserts the CSV is one row per session × + * question (unanswered slots → empty cells), that every cell is run through the + * formula-injection-safe `csvEscape`, that `renderAnswerValue` shapes each value kind, + * and that the JSON export is the faithful graph (turns + provenance preserved). The + * anonymous-mode redaction itself lives in the loader; here we confirm the serialisers + * carry through whatever the model says (null respondent → empty cell, `turns: []`). + */ + +import { describe, it, expect } from 'vitest'; + +import { + RESULTS_CSV_COLUMNS, + renderAnswerValue, + toResultsCsv, + toResultsJson, +} from '@/lib/app/questionnaire/export/results-serialize'; +import type { ResultsExportModel } from '@/lib/app/questionnaire/export/results-types'; + +function model(overrides: Partial = {}): ResultsExportModel { + return { + versionId: 'v1', + versionNumber: 2, + questionnaireTitle: 'Onboarding', + range: { from: '2026-01-01T00:00:00.000Z', to: '2026-02-01T00:00:00.000Z' }, + anonymous: false, + capped: false, + questions: [ + { + questionId: 'q1', + key: 'role', + prompt: 'Your role?', + type: 'free_text', + sectionTitle: 'About you', + required: true, + }, + { + questionId: 'q2', + key: 'rating', + prompt: 'Rate us', + type: 'numeric', + sectionTitle: 'Feedback', + required: false, + }, + ], + sessions: [ + { + id: 's1', + status: 'completed', + createdAt: '2026-01-10T09:00:00.000Z', + completedAt: '2026-01-10T09:30:00.000Z', + respondentName: 'Ada Lovelace', + answers: [ + { + questionKey: 'role', + value: 'Engineer', + confidence: 0.9, + provenanceLabel: 'direct', + provenanceItems: null, + rationale: null, + refinementHistory: [], + lastUpdatedTurnOrdinal: 1, + }, + // q2 (rating) deliberately unanswered for this session. + ], + turns: [ + { + ordinal: 1, + userMessage: "I'm an engineer", + agentResponse: 'Thanks!', + targetedQuestionId: 'q1', + toolCalls: [], + sideEffectAnswerIds: ['a1'], + costUsd: 0.01, + createdAt: '2026-01-10T09:01:00.000Z', + }, + ], + }, + { + id: 's2', + status: 'completed', + createdAt: '2026-01-11T09:00:00.000Z', + completedAt: '2026-01-11T09:30:00.000Z', + respondentName: null, + answers: [ + { + questionKey: 'rating', + value: 5, + confidence: null, + provenanceLabel: 'inferred', + provenanceItems: null, + rationale: 'mentioned in passing', + refinementHistory: [], + lastUpdatedTurnOrdinal: null, + }, + ], + turns: [], + }, + ], + ...overrides, + }; +} + +describe('renderAnswerValue', () => { + it('renders each value kind faithfully (data view, not display labels)', () => { + expect(renderAnswerValue(null)).toBe(''); + expect(renderAnswerValue(undefined)).toBe(''); + expect(renderAnswerValue('')).toBe(''); + expect(renderAnswerValue('hello')).toBe('hello'); + expect(renderAnswerValue(42)).toBe('42'); + expect(renderAnswerValue(true)).toBe('true'); + expect(renderAnswerValue(false)).toBe('false'); + expect(renderAnswerValue(['a', 'b', 'c'])).toBe('a, b, c'); + expect(renderAnswerValue({ city: 'London' })).toBe('{"city":"London"}'); + }); +}); + +describe('toResultsCsv', () => { + it('emits the canonical header row', () => { + const [header] = toResultsCsv(model()).split('\n'); + expect(header).toBe(RESULTS_CSV_COLUMNS.join(',')); + }); + + it('emits exactly one row per session × question (header excluded)', () => { + const lines = toResultsCsv(model()).split('\n'); + // 1 header + 2 sessions × 2 questions = 5 lines. + expect(lines).toHaveLength(5); + }); + + it('leaves answer cells empty for unanswered slots', () => { + const lines = toResultsCsv(model()).split('\n'); + // Session s1 / question rating (q2) is unanswered: trailing answer/confidence/prov empty. + const ratingRow = lines.find((l) => l.startsWith('s1,') && l.includes(',rating,')); + expect(ratingRow).toBeDefined(); + expect(ratingRow!.endsWith(',,,')).toBe(true); + }); + + it('renders an answered cell with value, confidence and provenance', () => { + const lines = toResultsCsv(model()).split('\n'); + const roleRow = lines.find((l) => l.startsWith('s1,') && l.includes(',role,')); + expect(roleRow).toContain('Engineer'); + expect(roleRow).toContain('0.9'); + expect(roleRow).toContain('direct'); + }); + + it('renders an empty respondent_name cell when the name is null', () => { + const csv = toResultsCsv(model()); + const s2Row = csv.split('\n').find((l) => l.startsWith('s2,')); + // respondent_name is the 5th column; with empty completed/respondent it appears blank. + expect(s2Row).toContain('s2,completed,'); + expect(s2Row).not.toContain('Ada Lovelace'); + }); + + it('neutralises formula-injection in answer values', () => { + const m = model(); + m.sessions[0].answers[0].value = '=HYPERLINK("http://evil")'; + const csv = toResultsCsv(m); + // csvEscape prefixes a leading-trigger value with a quote, and the comma forces RFC quoting. + expect(csv).toContain(`"'=HYPERLINK(""http://evil"")"`); + }); +}); + +describe('toResultsJson', () => { + it('returns the faithful session graph including turns and provenance', () => { + const out = toResultsJson(model()); + expect(out.versionId).toBe('v1'); + expect(out.sessions[0].turns[0].userMessage).toBe("I'm an engineer"); + expect(out.sessions[0].answers[0].provenanceLabel).toBe('direct'); + }); + + it('carries through an anonymous model untouched (null respondent, empty turns)', () => { + const anon = model({ + anonymous: true, + sessions: model().sessions.map((s) => ({ ...s, respondentName: null, turns: [] })), + }); + const out = toResultsJson(anon); + expect(out.anonymous).toBe(true); + expect(out.sessions.every((s) => s.respondentName === null)).toBe(true); + expect(out.sessions.every((s) => s.turns.length === 0)).toBe(true); + }); +});