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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .context/admin/questionnaire-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
86 changes: 86 additions & 0 deletions .context/app/planning/features/f8.2.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 26 additions & 22 deletions app/admin/questionnaires/[id]/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -169,28 +170,31 @@ export default async function QuestionnaireAnalyticsPage({ params, searchParams
) : (
<>
{/* Version selector — SSR links that set ?v= on this sub-route. */}
<div className="flex flex-wrap gap-2 border-b pb-3">
{detail.versions.map((ver) => {
const active = ver.id === selected.id;
return (
<Link
key={ver.id}
href={`/admin/questionnaires/${id}/analytics?v=${ver.id}`}
scroll={false}
className={
active
? 'bg-primary text-primary-foreground rounded-md px-3 py-1.5 text-sm font-medium'
: 'hover:bg-accent rounded-md border px-3 py-1.5 text-sm'
}
>
v{ver.versionNumber}
<span className={active ? 'opacity-80' : 'text-muted-foreground'}>
{' '}
· {ver.status}
</span>
</Link>
);
})}
<div className="flex flex-wrap items-start justify-between gap-3 border-b pb-3">
<div className="flex flex-wrap gap-2">
{detail.versions.map((ver) => {
const active = ver.id === selected.id;
return (
<Link
key={ver.id}
href={`/admin/questionnaires/${id}/analytics?v=${ver.id}`}
scroll={false}
className={
active
? 'bg-primary text-primary-foreground rounded-md px-3 py-1.5 text-sm font-medium'
: 'hover:bg-accent rounded-md border px-3 py-1.5 text-sm'
}
>
v{ver.versionNumber}
<span className={active ? 'opacity-80' : 'text-muted-foreground'}>
{' '}
· {ver.status}
</span>
</Link>
);
})}
</div>
<ExportButtons questionnaireId={id} versionId={selected.id} query={query} />
</div>

<AnalyticsView
Expand Down
93 changes: 93 additions & 0 deletions app/api/v1/app/questionnaires/[id]/versions/[vid]/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Result exports (F8.2).
*
* GET /api/v1/app/questionnaires/:id/versions/:vid/export?format=csv|json
* Admin-only. Downloads a version's **completed** session results in CSV (one row per
* session × question) or JSON (the full session graph: answers + provenance + turns).
* Reuses the F8.1 analytics filter (`from`/`to`/`tagIds`), so an export mirrors exactly
* what the admin is viewing. Master-flag-gated and version-scoped.
*
* Anonymous mode (`AppQuestionnaireConfig.anonymousMode`) is honoured in the loader:
* respondent identity is nulled and the `turns` array is dropped from the payload.
*
* Bulk read — a dedicated `exportLimiter` sub-cap sits on top of the section tier.
*/

import { errorResponse } from '@/lib/api/responses';
import { getRouteLogger } from '@/lib/api/context';
import { withAdminAuth } from '@/lib/auth/guards';
import { validateQueryParams } from '@/lib/api/validation';
import { exportLimiter, createRateLimitResponse } from '@/lib/security/rate-limit';

import { withQuestionnairesEnabled } from '@/lib/app/questionnaire/feature-flag';
import { resolveAnalyticsScope } from '@/lib/app/questionnaire/analytics';
import { resultsExportQuerySchema } from '@/lib/app/questionnaire/export/results-query';
import { loadResultsExport } from '@/lib/app/questionnaire/export/results-loader';
import { toResultsCsv, toResultsJson } from '@/lib/app/questionnaire/export/results-serialize';
import { loadScopedVersion } from '@/app/api/v1/app/questionnaires/_lib/authoring-routes';

/** Slugify a title for a filename: lower-case, alphanumerics → single hyphens. */
function slugify(title: string): string {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || 'questionnaire';
}

const handleGet = withAdminAuth<{ id: string; vid: string }>(
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);
100 changes: 100 additions & 0 deletions components/admin/questionnaires/analytics/export-buttons.tsx
Original file line number Diff line number Diff line change
@@ -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<ResultsExportFormat | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex items-center gap-1 text-xs">
Export completed sessions
<FieldHelp title="Result exports">
Downloads this version&rsquo;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.
</FieldHelp>
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() => void download('csv')}
>
{busy === 'csv' ? 'Exporting…' : 'Export CSV'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() => void download('json')}
>
{busy === 'json' ? 'Exporting…' : 'Export JSON'}
</Button>
</div>
{error && <p className="text-destructive text-xs">{error}</p>}
</div>
);
}
3 changes: 3 additions & 0 deletions lib/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading