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. */}
-
(
+ 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);
+ });
+});