From 526b316c5355928b4b64bc3bbbe2aa31c9fe6d90 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Tue, 9 Jun 2026 09:48:21 +0100 Subject: [PATCH] feat(questionnaires): F8.3 anonymous-mode hardening + respondent profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make anonymousMode a real PII contract honoured at every data boundary, and add the respondent profile-collection capability the contract gates. - AppRespondentProfileSnapshot model (+ migration); both User and session FKs onDelete: Cascade so eraseUser() removes snapshots natively - Capture seam in createSessionFromInvitation: validates + writes snapshot only on non-anonymous invitation path; anonymous/version-direct/no-login never capture (D1 — no row, not an empty row) - Respondent profile-start form, gated onto the start page via loadStartContext - Read-path redaction across exports (results loader/serialize, session-export, PDF document, build-session-export-model): profile nulled when anonymous - Analytics hardening: k-anonymity (K=5) cohort suppression in cost/funnel/ distributions; cost drops per-session table when anonymous; admin panels render suppressed states - Privacy constants module (analytics/privacy.ts), docs (anonymous-mode.md), tracker (features/f8.3.md) No CHANGELOG entry — app-owned models/routes are outside the Sunrise platform surface. --- .context/app/planning/development-plan.md | 4 +- .context/app/planning/features/f8.3.md | 104 ++++ .context/app/questionnaire/README.md | 1 + .context/app/questionnaire/anonymous-mode.md | 73 +++ .context/app/questionnaire/configuration.md | 5 + .context/app/questionnaire/schema.md | 14 + .context/privacy/data-erasure.md | 7 + app/(protected)/questionnaires/start/page.tsx | 16 + .../app/questionnaire-sessions/_lib/create.ts | 86 ++- .../_lib/session-export.ts | 14 + .../v1/app/questionnaire-sessions/route.ts | 22 +- .../analytics/completion-funnel-panel.tsx | 12 + .../questionnaires/analytics/cost-panel.tsx | 11 +- .../analytics/question-distribution-panel.tsx | 17 + .../export/session-pdf-document.tsx | 15 + .../profile/profile-start-form.tsx | 171 +++++ lib/app/questionnaire/analytics/cost.ts | 58 +- .../questionnaire/analytics/distributions.ts | 50 +- lib/app/questionnaire/analytics/funnel.ts | 23 +- lib/app/questionnaire/analytics/index.ts | 5 + lib/app/questionnaire/analytics/privacy.ts | 31 + lib/app/questionnaire/analytics/views.ts | 27 +- .../questionnaire/chat/resumable-session.ts | 39 ++ lib/app/questionnaire/chat/start-context.ts | 59 ++ .../export/build-session-export-model.ts | 6 + .../questionnaire/export/results-loader.ts | 7 + .../questionnaire/export/results-serialize.ts | 5 + lib/app/questionnaire/export/results-types.ts | 10 + lib/app/questionnaire/export/types.ts | 7 + .../questionnaire/profile/profile-values.ts | 111 ++++ .../migration.sql | 33 + prisma/schema/app-questionnaire.prisma | 43 +- prisma/schema/auth.prisma | 3 + .../create-route.test.ts | 16 +- .../export-pdf-route.test.ts | 1 + .../profile-snapshot.test.ts | 225 +++++++ .../session-export.test.ts | 16 + .../session-export-pdf-route.test.ts | 1 + .../app/questionnaires/version-export.test.ts | 3 +- .../questionnaires/start/page.test.tsx | 80 +++ .../pdf-response.test.ts | 1 + .../render-session-pdf.test.tsx | 1 + .../profile/profile-start-form.test.tsx | 589 ++++++++++++++++++ .../app/questionnaire/analytics/cost.test.ts | 81 ++- .../analytics/distributions.test.ts | 78 ++- .../questionnaire/analytics/funnel.test.ts | 52 +- .../questionnaire/chat/start-context.test.ts | 214 +++++++ .../export/build-session-export-model.test.ts | 7 +- .../export/results-serialize.test.ts | 21 +- .../profile/profile-values.test.ts | 90 +++ .../prisma/app-questionnaire-schema.test.ts | 45 ++ 51 files changed, 2510 insertions(+), 100 deletions(-) create mode 100644 .context/app/planning/features/f8.3.md create mode 100644 .context/app/questionnaire/anonymous-mode.md create mode 100644 components/app/questionnaire/profile/profile-start-form.tsx create mode 100644 lib/app/questionnaire/analytics/privacy.ts create mode 100644 lib/app/questionnaire/chat/resumable-session.ts create mode 100644 lib/app/questionnaire/chat/start-context.ts create mode 100644 lib/app/questionnaire/profile/profile-values.ts create mode 100644 prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql create mode 100644 tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts create mode 100644 tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx create mode 100644 tests/unit/lib/app/questionnaire/chat/start-context.test.ts create mode 100644 tests/unit/lib/app/questionnaire/profile/profile-values.test.ts diff --git a/.context/app/planning/development-plan.md b/.context/app/planning/development-plan.md index 51f1c6cf..dab2980f 100644 --- a/.context/app/planning/development-plan.md +++ b/.context/app/planning/development-plan.md @@ -667,13 +667,13 @@ _Indicative tasks:_ ### F8.3 — Anonymous-mode hardening -_Status:_ not started · _Size:_ ~1–2 PRs · _Owner:_ TBD · _Deps:_ F8.1, F8.2 + any surface that touches session data +_Status:_ in flight ([tracker](./features/f8.3.md)) · _Size:_ ~1–2 PRs · _Owner:_ TBD · _Deps:_ F8.1, F8.2 + any surface that touches session data Verification pass across every surface that touches session data, ensuring no PII leak when `anonymousMode = true`. Flag-gating tightened where needed. _Indicative tasks:_ -- Audit every read path that touches `AppQuestionnaireUserProfile` for anonymous-mode gating. +- Audit every read path that touches `AppRespondentProfileSnapshot` for anonymous-mode gating. - Audit exports + analytics + admin UI. - Integration tests that flip the flag and assert PII absence on every surface. - Documentation of the anonymous-mode contract. diff --git a/.context/app/planning/features/f8.3.md b/.context/app/planning/features/f8.3.md new file mode 100644 index 00000000..d97209f7 --- /dev/null +++ b/.context/app/planning/features/f8.3.md @@ -0,0 +1,104 @@ +--- +feature: F8.3 +title: Anonymous-mode hardening (+ respondent profile collection) +phase: P8 — Admin analytics, exports, anonymous mode +status: in flight +owner: TBD +deps: F8.1 (analytics surfaces), F8.2 (result exports), F7.4 (PDF export), F6.1 (sessions) +opened: 2026-06-09 +plan: .context/app/planning/development-plan.md#f83--anonymous-mode-hardening +docs: .context/app/questionnaire/anonymous-mode.md +--- + +# F8.3 — Anonymous-mode hardening (+ respondent profile collection) + +> Committable tracker for **F8.3**. The cross-surface PII pass: guarantee no respondent +> identity leaks when a version's `anonymousMode = true`, across exports, analytics, and +> the admin UI. Pulls in the previously-unbuilt **respondent profile collection** (model + +> form + capture) so the snapshot is the thing anonymous mode must gate. Gated by +> `APP_QUESTIONNAIRES_ENABLED`. + +## Intent + +`anonymousMode` had, until now, meant "open / no-invitation" — and the authenticated +anonymous-direct path still bound `respondentUserId` ("the admin-side identity redaction is +a later phase", per the F6.1 seam comment). F8.3 is that phase. It makes the flag a real +PII contract honoured at every data boundary, and adds the profile-collection capability +(net-new — no form, capture, or storage existed) so there's a respondent profile for the +contract to gate. + +## Decisions (confirmed with the user) + +- **Full scope, including the form.** Build the `AppRespondentProfileSnapshot` model, the + capture seam, the read-path redaction, AND the respondent-facing profile form — not just + the server contract. +- **D1 — no row, not an empty row.** An anonymous session writes **no** snapshot at all. + Absence is the strongest, most testable invariant (a test asserts `create` was never + called). Read paths additionally null the profile when anonymous (defence in depth). +- **Form only on the non-anonymous surface.** The profile form renders only for a + non-anonymous version with profile fields (`loadStartContext` gate); the anonymous / + no-login flow never shows it. So `anonymousMode = true` ⇒ no PII collected at all. +- **Analytics: guard + k-anonymity.** `K_ANONYMITY_THRESHOLD = 5`. Below that many + non-preview sessions, granular detail is withheld (distributions detail, funnel counts, + cost top-sessions). Aggregate spend is always returned (no identity). Cost additionally + drops the per-session table whenever anonymous (session ids re-identify). +- **Modelled `User` FK with `onDelete: Cascade`.** The snapshot is PII, so it breaks the + deferred-UG-1 plain-scalar posture and cascades natively — `eraseUser()` needs no hook. + +## Build shape (branch `feat/F8.3-anonymous-mode-hardening`) + +- **Privacy constants** — `lib/app/questionnaire/analytics/privacy.ts`: + `K_ANONYMITY_THRESHOLD = 5`, `isCohortSuppressed(n)`. Pure / client-safe so admin panels + import the threshold for labels. Re-exported from `analytics/index.ts`. +- **Model + migration** — `AppRespondentProfileSnapshot` in `app-questionnaire.prisma` + (migration `20260609062611_app_respondent_profile_snapshot`, `--create-only` + phantom + pgvector DROPs stripped). Reverse relation on `AppQuestionnaireSession` and on `User`. +- **Profile validation** — `lib/app/questionnaire/profile/profile-values.ts`: + `validateProfileValues` (strict — rejects unknown keys, enforces required, coerces + types), `parseProfileFields`, `asProfileValues`. Reused by the form and the seam. +- **Capture seam** — `questionnaire-sessions/_lib/create.ts`: `createSessionFromInvitation` + threads `profileValues`, validates, and writes the snapshot inside the create transaction + — only when non-anonymous and values present. The version-direct and no-login paths never + capture. Route (`questionnaire-sessions/route.ts`) accepts `profileValues` on the + invitation body. +- **Respondent form** — `components/app/questionnaire/profile/profile-start-form.tsx` + (react-hook-form + Zod + `FieldHelp`), gated onto the start page by + `lib/app/questionnaire/chat/start-context.ts` (`loadStartContext`). +- **Read-path redaction** — `profile` carried + gated in `export/results-loader.ts` (+ + `results-types.ts`, `results-serialize.ts` `respondent_profile` column), + `questionnaire-sessions/_lib/session-export.ts`, `export/build-session-export-model.ts` (+ + `types.ts`), rendered in `components/app/questionnaire/export/session-pdf-document.tsx`. +- **Analytics hardening** — `analytics/{cost,funnel,distributions}.ts` gain cohort + suppression + (cost) the anonymous guard; new `suppressed` / `topSessionsSuppressed` + result fields + `{ kind: 'suppressed' }` distribution variant in `views.ts`; admin panels + render the suppressed states. + +## Anonymous-mode contract + +The canonical statement of the invariant, the per-surface gate table, the snapshot rule, +k-anonymity, and the erasure cascade lives in +[`../../app/questionnaire/anonymous-mode.md`](../../app/questionnaire/anonymous-mode.md). + +## Tests + +- `tests/integration/.../questionnaire-sessions/profile-snapshot.test.ts` — capture + invariants (the core: anonymous never writes a snapshot; invalid/empty rejected/skipped; + no capture on resume). +- `tests/unit/lib/app/questionnaire/profile/profile-values.test.ts` — the validator. +- `tests/unit/lib/app/questionnaire/analytics/{cost,funnel,distributions}.test.ts` — + suppression + anonymous guard (existing cohorts bumped to ≥5; suppression tests added). +- `tests/unit/lib/app/questionnaire/export/*` + `session-export.test.ts` — profile + surfaced when not anonymous, dropped when anonymous. +- `tests/unit/prisma/app-questionnaire-schema.test.ts` — model shape + **both FKs + `ON DELETE CASCADE`** (the GDPR erasure contract). + +## Erasure + +`AppRespondentProfileSnapshot.user` is `onDelete: Cascade`, so `eraseUser()` removes the +snapshot via the native cascade — no cleanup hook. Noted in +[`../../privacy/data-erasure.md`](../../privacy/data-erasure.md). + +## No CHANGELOG entry + +App-owned models/routes are not part of the Sunrise platform surface — per the repo's +platform-scoped CHANGELOG policy, F8.3 adds no `CHANGELOG.md` bullet. diff --git a/.context/app/questionnaire/README.md b/.context/app/questionnaire/README.md index 3385f656..7cfaa210 100644 --- a/.context/app/questionnaire/README.md +++ b/.context/app/questionnaire/README.md @@ -27,6 +27,7 @@ plan and feature trackers, see [`../planning/`](../planning/); for the platform | [`per-turn-orchestrator.md`](./per-turn-orchestrator.md) | The live streaming turn loop — pure orchestrator, SSE route, 3 access scenarios (incl. no-login anonymous), streamed offers (F6.1) | | [`cost-cap-enforcement.md`](./cost-cap-enforcement.md) | Per-session USD budget at the turn boundary — soft wrap-up nudge at 90%, hard 402 + auto-pause at 100%, summed turn cost, dark-launch flag (F6.3) | | [`answer-slot-panel.md`](./answer-slot-panel.md) | The live respondent answer panel beside the chat — `GET …/answers` read endpoint, scope config, confidence language, Revisit wiring (F7.2) | +| [`anonymous-mode.md`](./anonymous-mode.md) | The cross-surface PII contract — per-surface gates, the profile snapshot rule, k-anonymity suppression, erasure cascade (F8.3) | ## Where the code lives diff --git a/.context/app/questionnaire/anonymous-mode.md b/.context/app/questionnaire/anonymous-mode.md new file mode 100644 index 00000000..9c09ce1a --- /dev/null +++ b/.context/app/questionnaire/anonymous-mode.md @@ -0,0 +1,73 @@ +# Anonymous mode — the PII contract (F8.3) + +A version-level boolean, `AppQuestionnaireConfig.anonymousMode` (default `false`), governs +whether respondent identity may be **collected, persisted, or surfaced** for that version. +F8.3 hardens the guarantee across every surface that touches session data and adds the +respondent **profile snapshot** (collected only on the non-anonymous surface). + +## The invariant + +When `anonymousMode = true`, for that version: + +- **No identity is persisted that links a session to a person.** Authenticated + anonymous-direct and no-login sessions bind `respondentUserId = null` / mint a signed + token; profile fields are never collected. +- **No identity reaches any admin read surface.** Respondent name, the profile snapshot, + and raw conversational turns are all dropped at the **data boundary** (the loader / + aggregator), not merely hidden in the UI. +- **Granular analytics that could re-identify a small cohort are withheld** (k-anonymity). + +Anonymity is about not linking data to a person — it is **not** about redacting the survey +data itself. Structured answer _values_ are always exported (they're the point of the +export); what's withheld is identity, free-text prose, and small-cohort detail. + +## Per-surface gates + +| Surface | File | Behaviour when `anonymousMode = true` | +| ---------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Authed-direct session create | `questionnaire-sessions/_lib/create.ts` | `respondentUserId` bound, but **no profile snapshot** ever written | +| No-login session create | `questionnaire-sessions/_lib/create.ts` (`createAnonymousSession`) | `respondentUserId = null`; no profile | +| Profile capture | `create.ts` (`resolveProfileCapture`) | Skipped entirely — short-circuits on the anonymous flag | +| Single-session PDF | `questionnaire-sessions/_lib/session-export.ts` + `export/build-session-export-model.ts` | Identity query skipped; `respondent` and `profile` null | +| Bulk CSV/JSON export | `export/results-loader.ts` | Names skipped; `turns = []`; `profile = null` per session | +| Distributions analytics | `analytics/distributions.ts` | Identity-free by construction; small-cohort detail suppressed (below) | +| Funnel analytics | `analytics/funnel.ts` | Counts-only; small-cohort counts suppressed | +| Cost analytics | `analytics/cost.ts` | Per-session spend table dropped (session ids are a re-identification handle) | +| Invitations | `questionnaires/[id]/invitations/_lib/read.ts` | Orthogonal — invitations are the _invited_ (non-anonymous) surface; an anonymous version has none | + +## The profile snapshot rule + +`AppRespondentProfileSnapshot` (1:1 with a session) holds the `profileFields` values a +respondent supplied at session start. **Decision D1 — no row, not an empty row:** an +anonymous session writes **no** snapshot at all. Absence is the strongest, most testable +invariant — a test asserts `appRespondentProfileSnapshot.create` was never called, and +there is structurally no PII at rest. Read paths additionally null the profile when +anonymous, as defence in depth. + +Capture happens only on the **invitation** surface (always non-anonymous), at session +create, inside the same transaction. The respondent form lives at +`components/app/questionnaire/profile/profile-start-form.tsx`, gated by +`loadStartContext` so it renders only for a non-anonymous version with profile fields. + +## k-anonymity suppression + +`K_ANONYMITY_THRESHOLD = 5` (`analytics/privacy.ts`, client-safe so admin panels label it). +Below this many non-preview sessions, granular analytics detail is withheld — a tiny +sample can re-identify an individual answer. Applied at the aggregator: + +- **distributions** — per-question `detail` becomes `{ kind: 'suppressed' }`, counts zeroed, + result `suppressed: true`. +- **funnel** — all stage + anonymous counts zeroed, `suppressed: true`. +- **cost** — the top-spend-session table emptied (`topSessionsSuppressed: true`); aggregate + spend (total / by-capability / trend) carries no identity and is always returned. + +An empty cohort (`0` sessions) is **not** "suppressed" — it genuinely has no data. + +## Erasure + +`AppRespondentProfileSnapshot` is the **first** questionnaire model with a modelled `User` +FK (the deferred-UG-1 "plain String, no relation" posture is deliberately broken because +this row IS personal data). Both FKs declare `onDelete: Cascade`: the session FK (owned +data) and the user FK (personal data). The user cascade means `eraseUser()`'s +`prisma.user.delete()` removes the snapshot natively — **no erasure hook needed**. See +[`../../privacy/data-erasure.md`](../../privacy/data-erasure.md). diff --git a/.context/app/questionnaire/configuration.md b/.context/app/questionnaire/configuration.md index 4af53477..bb3ea3c7 100644 --- a/.context/app/questionnaire/configuration.md +++ b/.context/app/questionnaire/configuration.md @@ -59,6 +59,11 @@ needs no separate model (the same shape precedent as `audience` / `typeConfig`). `key` is a unique lowercase slug; `options` is required (non-empty, distinct) for `select` and forbidden for every other type. +The values a respondent supplies for these fields are collected at session start and +persisted to `AppRespondentProfileSnapshot` — **only on the non-anonymous surface**. When +`anonymousMode = true` no profile is collected, stored, or surfaced. See +[`anonymous-mode.md`](./anonymous-mode.md) for the full PII contract (F8.3). + ## Lazy materialization No config row exists until the admin first saves — this keeps the F1.1 ingest path diff --git a/.context/app/questionnaire/schema.md b/.context/app/questionnaire/schema.md index 4da989d3..8cba1b76 100644 --- a/.context/app/questionnaire/schema.md +++ b/.context/app/questionnaire/schema.md @@ -271,4 +271,18 @@ questionSlotId)` (the upsert unique), with `value Json`, `provenanceLabel`, Migration hand-stripped of the phantom pgvector DROPs (schema-shape test guards the strip). See [`per-turn-orchestrator.md`](./per-turn-orchestrator.md). +### Respondent profile snapshot (F8.3 — P8) + +- **`AppRespondentProfileSnapshot`** (F8.3, migration + `20260609062611_app_respondent_profile_snapshot`) — the `profileFields` values a + respondent supplied at session start, 1:1 with a session (`sessionId @unique`). `values +Json` (keyed by field `key`), `respondentUserId String?` denormalised from the session. + **The first questionnaire model with a modelled `User` FK** — the deferred-UG-1 + "plain String, no `@relation`" posture is deliberately broken because this row IS personal + data and must cascade on erasure. Both FKs `onDelete: Cascade`: the session FK (owned + data) and the user FK (so `eraseUser()` removes it natively, no hook). **Never written for + an anonymous session** (no row, not an empty row). Migration hand-stripped of the phantom + pgvector DROPs (schema-shape test guards the strip + asserts both cascades). See + [`anonymous-mode.md`](./anonymous-mode.md). + _Later phases extend this file. Each documents its models here as it lands._ diff --git a/.context/privacy/data-erasure.md b/.context/privacy/data-erasure.md index c7bcdbc1..834c99aa 100644 --- a/.context/privacy/data-erasure.md +++ b/.context/privacy/data-erasure.md @@ -137,6 +137,13 @@ Two failure modes if the migration FK is wrong: So the migration FK with an explicit `ON DELETE` is **mandatory**, not optional. +**ConQuest exception — a modelled `User` FK.** `AppRespondentProfileSnapshot` (F8.3, +respondent profile values, PII) deliberately breaks the plain-scalar pattern: it adds a +real `@relation` with a reverse field on `User`, FK `onDelete: Cascade`. Because it +cascades natively, `eraseUser()` removes it with no cleanup hook. It is the one app table +that _is_ caught by the schema-level `@relation onDelete` review. See +[`../app/questionnaire/anonymous-mode.md`](../app/questionnaire/anonymous-mode.md). + ### What the FK cascade can't do — register a cleanup hook A `CASCADE` FK is erased automatically by `prisma.user.delete()`. But, exactly as diff --git a/app/(protected)/questionnaires/start/page.tsx b/app/(protected)/questionnaires/start/page.tsx index 71145a78..a5ac540d 100644 --- a/app/(protected)/questionnaires/start/page.tsx +++ b/app/(protected)/questionnaires/start/page.tsx @@ -10,6 +10,8 @@ import { createOrResumeAuthedSession, type AuthedSessionRequest, } from '@/lib/app/questionnaire/chat/session-bootstrap'; +import { loadStartContext } from '@/lib/app/questionnaire/chat/start-context'; +import { ProfileStartForm } from '@/components/app/questionnaire/profile/profile-start-form'; export const metadata: Metadata = { title: 'Start questionnaire', @@ -53,6 +55,20 @@ export default async function StartQuestionnairePage({ ? `?invitationToken=${encodeURIComponent(sp.invitationToken)}` : `?versionId=${encodeURIComponent(sp.versionId ?? '')}`; clearInvalidSession(`/questionnaires/start${query}`); + return null; // unreachable — clearInvalidSession redirects + } + + // F8.3: a non-anonymous questionnaire with profile fields collects them BEFORE the + // session is created. The form posts the values back into the create route (which + // writes the snapshot atomically); a resumable session skips straight to the chat. + const context = await loadStartContext(request, session.user.id); + if (context.kind === 'resume') { + redirect(`/questionnaires/${context.sessionId}`); + } + if (context.kind === 'needs-profile' && 'invitationToken' in request) { + return ( + + ); } const result = await createOrResumeAuthedSession(request); diff --git a/app/api/v1/app/questionnaire-sessions/_lib/create.ts b/app/api/v1/app/questionnaire-sessions/_lib/create.ts index 7ab2bf9f..a5a4d0f2 100644 --- a/app/api/v1/app/questionnaire-sessions/_lib/create.ts +++ b/app/api/v1/app/questionnaire-sessions/_lib/create.ts @@ -19,8 +19,15 @@ */ import { prisma } from '@/lib/db/client'; +import type { Prisma } from '@prisma/client'; import { hashInvitationToken } from '@/lib/app/questionnaire/invitations'; +import { findResumableSession } from '@/lib/app/questionnaire/chat/resumable-session'; import { recordSessionCreated } from '@/app/api/v1/app/questionnaires/_lib/sessions'; +import { + parseProfileFields, + validateProfileValues, + type ProfileValues, +} from '@/lib/app/questionnaire/profile/profile-values'; /** A created-or-resumed session, or a typed failure the route maps to an HTTP status. */ export type CreateSessionResult = @@ -32,22 +39,44 @@ export type CreateSessionResult = } | { ok: false; status: number; code: string; message: string }; -/** Find a respondent's existing non-terminal real session for a version (resume target). */ -async function findResumableSession( - versionId: string, - respondentUserId: string -): Promise<{ id: string; status: string; versionId: string } | null> { - const row = await prisma.appQuestionnaireSession.findFirst({ - where: { - versionId, - respondentUserId, - isPreview: false, - status: { in: ['active', 'paused'] }, - }, - orderBy: { createdAt: 'desc' }, - select: { id: true, status: true, versionId: true }, +/** + * Validate a respondent's profile submission against a version's configured fields, + * for the NON-anonymous capture seam. Anonymous mode short-circuits to "nothing to + * capture" — anonymous sessions never collect profile data (the F8.3 invariant). + * + * The server is the enforcing boundary, not the form: an OMITTED `profileValues` is + * validated as an empty submission, so a version with a required field rejects with a + * 400 even when a direct API caller sends no values (the form always submits them). + * Versions with no fields, or only optional fields the caller omitted, capture nothing. + * Returns the values to persist (or null), or a typed 400 to reject. + */ +function resolveProfileCapture( + anonymous: boolean, + profileFieldsJson: unknown, + rawValues: Record | undefined +): + | { ok: true; values: ProfileValues | null } + | { ok: false; status: number; code: string; message: string } { + if (anonymous) return { ok: true, values: null }; + const fields = parseProfileFields(profileFieldsJson); + if (fields.length === 0) return { ok: true, values: null }; + const result = validateProfileValues(fields, rawValues ?? {}); + if (!result.ok) { + return { ok: false, status: 400, code: 'INVALID_PROFILE', message: result.message }; + } + return { ok: true, values: Object.keys(result.values).length > 0 ? result.values : null }; +} + +/** Persist a profile snapshot inside the session-create transaction (non-anonymous only). */ +async function writeProfileSnapshot( + tx: Prisma.TransactionClient, + sessionId: string, + respondentUserId: string | null, + values: ProfileValues +): Promise { + await tx.appRespondentProfileSnapshot.create({ + data: { sessionId, respondentUserId, values }, }); - return row; } /** @@ -57,7 +86,8 @@ async function findResumableSession( */ export async function createSessionFromInvitation( token: string, - respondentUserId: string + respondentUserId: string, + profileValues?: Record ): Promise { const invitation = await prisma.appQuestionnaireInvitation.findUnique({ where: { tokenHash: hashInvitationToken(token) }, @@ -66,7 +96,12 @@ export async function createSessionFromInvitation( userId: true, status: true, versionId: true, - version: { select: { status: true } }, + version: { + select: { + status: true, + config: { select: { anonymousMode: true, profileFields: true } }, + }, + }, }, }); @@ -100,6 +135,16 @@ export async function createSessionFromInvitation( }; } + // Validate any profile submission against the version's fields before any write — an + // invitation surface is never anonymous, so its profile fields (if any) are collected. + const anonymous = invitation.version.config?.anonymousMode ?? false; + const capture = resolveProfileCapture( + anonymous, + invitation.version.config?.profileFields, + profileValues + ); + if (!capture.ok) return capture; + const existing = await findResumableSession(invitation.versionId, respondentUserId); if (existing) return { ok: true, session: existing, resumed: true }; @@ -114,6 +159,9 @@ export async function createSessionFromInvitation( select: { id: true, status: true, versionId: true }, }); await recordSessionCreated(created.id, { tx }); + // Profile snapshot is captured once, on first start (skipped on resume above). + if (capture.values) + await writeProfileSnapshot(tx, created.id, respondentUserId, capture.values); // Advance the invitation lifecycle on first start; a re-entry (already `started`) is a no-op. if (invitation.status === 'registered') { await tx.appQuestionnaireInvitation.update({ @@ -146,7 +194,9 @@ export async function createSessionForVersion( return { ok: false, status: 404, code: 'NOT_FOUND', message: 'Questionnaire not found' }; } // Direct (no-invitation) starts are only for the anonymous-mode surface; an - // invitation-gated questionnaire requires the invitation path. + // invitation-gated questionnaire requires the invitation path. Because this surface is + // anonymous by definition, NO profile snapshot is ever captured here (F8.3 invariant) — + // only the invitation path (always non-anonymous) collects profile data. if (!version.config?.anonymousMode) { return { ok: false, diff --git a/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts b/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts index 75733afd..364d1999 100644 --- a/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts +++ b/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts @@ -33,6 +33,10 @@ import { buildSessionExportModel, type SessionExportInput, } from '@/lib/app/questionnaire/export/build-session-export-model'; +import { + asProfileValues, + type ProfileValues, +} from '@/lib/app/questionnaire/profile/profile-values'; import type { SessionExportModel } from '@/lib/app/questionnaire/export/types'; /** Raw demo-client theme columns (or null when the questionnaire is unattributed). */ @@ -55,6 +59,8 @@ export interface LoadedSessionExport { audience: AudienceShape | null; anonymous: boolean; respondentName: string | null; + /** Collected profile values, or null when anonymous / none collected (identifying). */ + profile: ProfileValues | null; completedAt: string | null; theme: RawTheme; status: SessionStatus; @@ -112,6 +118,9 @@ export async function loadSessionExport(sessionId: string): Promise { const result = 'invitationToken' in body - ? await createSessionFromInvitation(body.invitationToken, respondentUserId) + ? await createSessionFromInvitation( + body.invitationToken, + respondentUserId, + body.profileValues + ) : await createSessionForVersion(body.versionId, respondentUserId); if (!result.ok) { diff --git a/components/admin/questionnaires/analytics/completion-funnel-panel.tsx b/components/admin/questionnaires/analytics/completion-funnel-panel.tsx index 250ef8a7..21ff093f 100644 --- a/components/admin/questionnaires/analytics/completion-funnel-panel.tsx +++ b/components/admin/questionnaires/analytics/completion-funnel-panel.tsx @@ -10,6 +10,9 @@ */ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +// Import the threshold from the pure `privacy` leaf, not the barrel — the barrel +// re-exports the Prisma-coupled aggregators, which must not enter this client bundle. +import { K_ANONYMITY_THRESHOLD } from '@/lib/app/questionnaire/analytics/privacy'; import type { CompletionFunnelResult, FunnelStage } from '@/lib/app/questionnaire/analytics'; function pct(n: number): string { @@ -48,6 +51,15 @@ export function CompletionFunnelPanel({ data }: { data: CompletionFunnelResult | return

Funnel data could not be loaded.

; } + if (data.suppressed) { + return ( +

+ The completion funnel is hidden to protect respondent privacy — fewer than{' '} + {K_ANONYMITY_THRESHOLD} respondents in this window. +

+ ); + } + const invited = data.stages[0]?.count ?? 0; return ( diff --git a/components/admin/questionnaires/analytics/cost-panel.tsx b/components/admin/questionnaires/analytics/cost-panel.tsx index c01b6cfb..ca6847e2 100644 --- a/components/admin/questionnaires/analytics/cost-panel.tsx +++ b/components/admin/questionnaires/analytics/cost-panel.tsx @@ -28,6 +28,9 @@ import { } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { formatUsd } from '@/lib/utils/format-currency'; +// Import the threshold from the pure `privacy` leaf, not the barrel — the barrel +// re-exports the Prisma-coupled aggregators, which must not enter this client bundle. +import { K_ANONYMITY_THRESHOLD } from '@/lib/app/questionnaire/analytics/privacy'; import type { QuestionnaireCostResult } from '@/lib/app/questionnaire/analytics'; function StatCard({ label, value }: { label: string; value: string }) { @@ -122,7 +125,13 @@ export function CostPanel({ data }: { data: QuestionnaireCostResult | null }) { Top sessions by spend - {data.topSessions.length === 0 ? ( + {data.topSessionsSuppressed ? ( +

+ Per-session spend is hidden to protect respondent privacy — the questionnaire is + anonymous or has fewer than {K_ANONYMITY_THRESHOLD} sessions. Totals above are + unaffected. +

+ ) : data.topSessions.length === 0 ? (

No respondent session spend yet.

) : ( diff --git a/components/admin/questionnaires/analytics/question-distribution-panel.tsx b/components/admin/questionnaires/analytics/question-distribution-panel.tsx index 381fa3a2..811d70b2 100644 --- a/components/admin/questionnaires/analytics/question-distribution-panel.tsx +++ b/components/admin/questionnaires/analytics/question-distribution-panel.tsx @@ -13,6 +13,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { TagChip } from '@/components/admin/questionnaires/tag-chip'; import { QUESTION_TYPE_LABELS } from '@/lib/app/questionnaire/types'; +// Import the threshold from the pure `privacy` leaf, not the barrel — the barrel +// re-exports the Prisma-coupled aggregators, which must not enter this client bundle. +import { K_ANONYMITY_THRESHOLD } from '@/lib/app/questionnaire/analytics/privacy'; import type { DistributionDetail, QuestionDistribution, @@ -52,6 +55,13 @@ function DetailBody({ detail }: { detail: DistributionDetail }) {

); + case 'suppressed': + return ( +

+ Hidden to protect respondent privacy (small sample). +

+ ); + case 'choice': { const max = Math.max(1, ...detail.buckets.map((b) => b.count)); return ( @@ -178,6 +188,13 @@ export function QuestionDistributionPanel({ data }: { data: QuestionDistribution {data.totalSessions} session{data.totalSessions === 1 ? '' : 's'} in range ·{' '} {data.completedSessions} completed

+ {data.suppressed && ( +

+ Per-question answer detail is hidden to protect respondent privacy — fewer than{' '} + {K_ANONYMITY_THRESHOLD} sessions in this window. Response rates return once the sample + grows. +

+ )} {data.questions.length === 0 ? (

No questions match the current filter. diff --git a/components/app/questionnaire/export/session-pdf-document.tsx b/components/app/questionnaire/export/session-pdf-document.tsx index 29dab677..1fa4cc69 100644 --- a/components/app/questionnaire/export/session-pdf-document.tsx +++ b/components/app/questionnaire/export/session-pdf-document.tsx @@ -146,6 +146,12 @@ function formatConfidence(confidence: number | null): string | null { return `${Math.round(confidence * 100)}% confidence`; } +/** Humanise a profile-field key slug (`job_title` → `Job title`) for the header label. */ +function humaniseKey(key: string): string { + const spaced = key.replace(/[_-]+/g, ' ').trim(); + return spaced.length === 0 ? key : spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + /** One question slot — answered or "Not answered", with provenance/rationale/history. */ function SlotBlock({ slot }: { slot: PanelSlotView }) { const confidence = formatConfidence(slot.confidence); @@ -218,6 +224,15 @@ export function SessionPdfDocument({ model }: SessionPdfDocumentProps) { Respondent: {respondentLabel} + {/* Collected profile (F8.3) — only present for a non-anonymous session; the + model builder forces it to null in anonymous mode. */} + {model.profile && + Object.entries(model.profile).map(([key, value]) => ( + + {`${humaniseKey(key)}: `} + {String(value)} + + ))} Completed: {formatDate(model.completedAt)} diff --git a/components/app/questionnaire/profile/profile-start-form.tsx b/components/app/questionnaire/profile/profile-start-form.tsx new file mode 100644 index 00000000..afb127c7 --- /dev/null +++ b/components/app/questionnaire/profile/profile-start-form.tsx @@ -0,0 +1,171 @@ +'use client'; + +/** + * Respondent profile form (F8.3) — collected at session start on the authenticated + * (non-anonymous) surface, before the chat begins. The fields are admin-authored + * (`profileFields`); this renders one input per field and POSTs the values into the + * session-create route, which writes the `AppRespondentProfileSnapshot` atomically and + * returns the new session id. On success we navigate to the chat. + * + * The anonymous surface never renders this — `loadStartContext` only returns + * `needs-profile` for a non-anonymous version with profile fields, so no PII is ever + * collected for an anonymous questionnaire. + */ + +import { useState } from 'react'; +import { useForm, type Resolver } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/navigation'; +import { z } from 'zod'; + +import { apiClient, APIClientError } from '@/lib/api/client'; +import { API } from '@/lib/api/endpoints'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { FormError } from '@/components/forms/form-error'; +import { FieldHelp } from '@/components/ui/field-help'; +import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types'; + +type FormValues = Record; + +/** Build the client form schema. Values are strings (inputs); the server coerces/validates. */ +function buildFormSchema(fields: ProfileFieldConfig[]): z.ZodObject { + const shape: Record = {}; + for (const field of fields) { + let base: z.ZodTypeAny; + switch (field.type) { + case 'email': + base = z.string().trim().email('Enter a valid email address'); + break; + case 'number': + base = z + .string() + .trim() + .regex(/^-?\d+(\.\d+)?$/, 'Enter a number'); + break; + case 'select': + base = z.string().min(1, 'Select an option'); + break; + case 'text': + default: + base = z.string().trim().min(1, 'This field is required'); + break; + } + // Optional fields accept an empty string (rendered blank, stripped before submit). + shape[field.key] = field.required ? base : z.union([base, z.literal('')]); + } + return z.object(shape); +} + +export interface ProfileStartFormProps { + /** The invitation token the session is created from. */ + invitationToken: string; + /** The admin-authored fields to collect, in order. */ + fields: ProfileFieldConfig[]; +} + +export function ProfileStartForm({ invitationToken, fields }: ProfileStartFormProps) { + const router = useRouter(); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(buildFormSchema(fields)) as Resolver, + mode: 'onTouched', + defaultValues: Object.fromEntries(fields.map((f) => [f.key, ''])), + }); + + const onSubmit = async (data: FormValues) => { + setSubmitting(true); + setError(null); + + // Strip empty (untouched optional) values; the server validates the rest against + // the version's profile fields and coerces number/select types. + const profileValues: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'string' && value.trim() !== '') profileValues[key] = value.trim(); + } + + try { + const result = await apiClient.post<{ session: { id: string } }>( + API.APP.QUESTIONNAIRE_SESSIONS.ROOT, + { body: { invitationToken, profileValues } } + ); + router.push(`/questionnaires/${result.session.id}`); + } catch (err) { + setSubmitting(false); + setError( + err instanceof APIClientError + ? err.message + : 'We could not start your questionnaire. Please try again.' + ); + } + }; + + return ( +

+

Before you begin

+

+ A few quick details to go with your responses. +

+ +
void handleSubmit(onSubmit)(e)} className="mt-6 space-y-4"> + {fields.map((field) => ( +
+ + + {field.type === 'select' ? ( + + ) : ( + + )} + + +
+ ))} + + {error && ( +
{error}
+ )} + + + +
+ ); +} diff --git a/lib/app/questionnaire/analytics/cost.ts b/lib/app/questionnaire/analytics/cost.ts index f0a95514..dbb2de2e 100644 --- a/lib/app/questionnaire/analytics/cost.ts +++ b/lib/app/questionnaire/analytics/cost.ts @@ -19,6 +19,7 @@ import { prisma } from '@/lib/db/client'; import { narrowToEnum, SESSION_STATUSES, type SessionStatus } from '@/lib/app/questionnaire/types'; +import { isCohortSuppressed } from '@/lib/app/questionnaire/analytics/privacy'; import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics/query-schema'; import type { CostCapabilityBucket, @@ -76,11 +77,27 @@ export async function getQuestionnaireCostBreakdown( const range = { from: scope.from.toISOString(), to: scope.to.toISOString() }; // Non-preview sessions for the version (all time — cost rows are date-filtered by - // their own `createdAt`). Carries status + createdAt for the top-sessions table. - const sessions = await prisma.appQuestionnaireSession.findMany({ - where: { versionId: scope.versionId, isPreview: false }, - select: { id: true, status: true, createdAt: true }, - }); + // their own `createdAt`). Carries status + createdAt for the top-sessions table. The + // anonymous-mode flag is read alongside; the two reads are independent, so run them + // together. + const [sessions, config] = await Promise.all([ + prisma.appQuestionnaireSession.findMany({ + where: { versionId: scope.versionId, isPreview: false }, + select: { id: true, status: true, createdAt: true }, + }), + prisma.appQuestionnaireConfig.findUnique({ + where: { versionId: scope.versionId }, + select: { anonymousMode: true }, + }), + ]); + + // F8.3: the per-session spend table exposes session ids (a re-identification handle). + // Withhold it when the version is anonymous, or the cohort is below the k-anonymity + // threshold. Aggregate spend (total / by-capability / trend) carries no identity and + // is always returned. + const anonymous = config?.anonymousMode ?? false; + const topSessionsSuppressed = anonymous || isCohortSuppressed(sessions.length); + const sessionMeta = new Map(sessions.map((s) => [s.id, s])); const sessionIds = sessions.map((s) => s.id); const hasSessions = sessionIds.length > 0; @@ -184,20 +201,22 @@ export async function getQuestionnaireCostBreakdown( .map(([key, v]) => ({ key, label: labelFor(key), costUsd: v.costUsd, callCount: v.callCount })) .sort((a, b) => b.costUsd - a.costUsd); - const topSessions: SessionCostRow[] = [...sessionCost.entries()] - .map(([sessionId, costUsd]) => { - const meta = sessionMeta.get(sessionId); - return { - sessionId, - status: meta - ? narrowToEnum(meta.status, SESSION_STATUSES, 'active') - : 'active', - costUsd, - createdAt: (meta?.createdAt ?? scope.from).toISOString(), - }; - }) - .sort((a, b) => b.costUsd - a.costUsd) - .slice(0, TOP_SESSIONS_LIMIT); + const topSessions: SessionCostRow[] = topSessionsSuppressed + ? [] + : [...sessionCost.entries()] + .map(([sessionId, costUsd]) => { + const meta = sessionMeta.get(sessionId); + return { + sessionId, + status: meta + ? narrowToEnum(meta.status, SESSION_STATUSES, 'active') + : 'active', + costUsd, + createdAt: (meta?.createdAt ?? scope.from).toISOString(), + }; + }) + .sort((a, b) => b.costUsd - a.costUsd) + .slice(0, TOP_SESSIONS_LIMIT); return { versionId: scope.versionId, @@ -208,5 +227,6 @@ export async function getQuestionnaireCostBreakdown( byCapability: capabilityBuckets, trend, topSessions, + topSessionsSuppressed, }; } diff --git a/lib/app/questionnaire/analytics/distributions.ts b/lib/app/questionnaire/analytics/distributions.ts index 66880f2b..e04c7269 100644 --- a/lib/app/questionnaire/analytics/distributions.ts +++ b/lib/app/questionnaire/analytics/distributions.ts @@ -18,11 +18,13 @@ import { ANSWER_PROVENANCES, QUESTION_TYPE_LABELS, narrowToEnum, + TAG_COLORS, type AnswerProvenance, type QuestionType, QUESTION_TYPES, } from '@/lib/app/questionnaire/types'; import { typeConfigSchemaFor } from '@/lib/app/questionnaire/authoring/type-config-schema'; +import { isCohortSuppressed } from '@/lib/app/questionnaire/analytics/privacy'; import type { TagColor } from '@/lib/app/questionnaire/types'; import type { TagView } from '@/lib/app/questionnaire/views'; import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics/query-schema'; @@ -292,20 +294,26 @@ export async function getQuestionDistributions( bucket.provenance[label] += 1; } + // F8.3: below the k-anonymity threshold a per-question distribution over a handful of + // sessions can re-identify a respondent's exact answer, so withhold all per-question + // detail and zero the counts. The question structure (prompt/type/section/tags) stays — + // only the response data is suppressed. An empty cohort (0) is not "suppressed". + const suppressed = isCohortSuppressed(totalSessions); + const questions: QuestionDistribution[] = slots.map((slot) => { const type = narrowToEnum(slot.type, QUESTION_TYPES, 'free_text'); const bucket = byQuestion.get(slot.id)!; - const answeredCount = bucket.values.length; - const avgConfidence = - bucket.confidences.length > 0 - ? bucket.confidences.reduce((a, b) => a + b, 0) / bucket.confidences.length - : null; const tags: TagView[] = slot.tags.map((t) => ({ id: t.tag.id, label: t.tag.label, - color: (t.tag.color as TagColor | null) ?? null, + // `color` is a free String? column constrained to the allowlist at write time; + // validate membership before narrowing rather than blindly casting the DB value. + color: + t.tag.color !== null && (TAG_COLORS as readonly string[]).includes(t.tag.color) + ? (t.tag.color as TagColor) + : null, })); - return { + const base = { questionId: slot.id, key: slot.key, prompt: slot.prompt, @@ -313,6 +321,25 @@ export async function getQuestionDistributions( sectionTitle: slot.section.title, required: slot.required, tags, + }; + if (suppressed) { + return { + ...base, + answeredCount: 0, + unansweredCount: 0, + responseRate: 0, + avgConfidence: null, + provenance: emptyProvenance(), + detail: { kind: 'suppressed' }, + }; + } + const answeredCount = bucket.values.length; + const avgConfidence = + bucket.confidences.length > 0 + ? bucket.confidences.reduce((a, b) => a + b, 0) / bucket.confidences.length + : null; + return { + ...base, answeredCount, unansweredCount: Math.max(0, totalSessions - answeredCount), responseRate: totalSessions > 0 ? answeredCount / totalSessions : 0, @@ -322,7 +349,14 @@ export async function getQuestionDistributions( }; }); - return { versionId: scope.versionId, range, totalSessions, completedSessions, questions }; + return { + versionId: scope.versionId, + range, + totalSessions, + completedSessions, + suppressed, + questions, + }; } /** Re-exported for the read view / tests that label types. */ diff --git a/lib/app/questionnaire/analytics/funnel.ts b/lib/app/questionnaire/analytics/funnel.ts index bc9a0824..311ceb85 100644 --- a/lib/app/questionnaire/analytics/funnel.ts +++ b/lib/app/questionnaire/analytics/funnel.ts @@ -15,6 +15,7 @@ */ import { prisma } from '@/lib/db/client'; +import { isCohortSuppressed } from '@/lib/app/questionnaire/analytics/privacy'; import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics/query-schema'; import type { CompletionFunnelResult, @@ -108,10 +109,28 @@ export async function getCompletionFunnel(scope: AnalyticsScope): Promise 0 && total < K_ANONYMITY_THRESHOLD; +} diff --git a/lib/app/questionnaire/analytics/views.ts b/lib/app/questionnaire/analytics/views.ts index faa5df40..3fd59ee8 100644 --- a/lib/app/questionnaire/analytics/views.ts +++ b/lib/app/questionnaire/analytics/views.ts @@ -61,6 +61,8 @@ export interface HistogramBin { /** * The type-appropriate shape of a question's answer distribution. `free_text` * carries no value detail by design (PII / not meaningful as a distribution). + * `suppressed` carries none either — the whole surface is below the k-anonymity + * threshold (F8.3), so no per-question detail is emitted. */ export type DistributionDetail = | { kind: 'choice'; buckets: ValueBucket[]; otherCount: number } @@ -74,7 +76,8 @@ export type DistributionDetail = falseCount: number; } | { kind: 'date'; buckets: { label: string; count: number }[] } - | { kind: 'free_text' }; + | { kind: 'free_text' } + | { kind: 'suppressed' }; /** One question's distribution over the non-preview sessions in scope. */ export interface QuestionDistribution { @@ -104,6 +107,13 @@ export interface QuestionDistributionsResult { totalSessions: number; /** Of those, how many reached `completed`. */ completedSessions: number; + /** + * True when the cohort is non-empty but below the k-anonymity threshold + * ({@link K_ANONYMITY_THRESHOLD}): per-question `detail` is withheld (every question's + * `detail.kind` is `suppressed` and its counts are zeroed) so a tiny sample can't + * re-identify a respondent. F8.3, applied at the aggregator boundary. + */ + suppressed: boolean; questions: QuestionDistribution[]; } @@ -137,6 +147,12 @@ export interface CompletionFunnelResult { started: number; completed: number; }; + /** + * True when the funnel cohort is non-empty but below the k-anonymity threshold + * ({@link K_ANONYMITY_THRESHOLD}): all stage counts and anonymous counts are zeroed so + * a tiny cohort can't re-identify who reached a stage. F8.3, at the aggregator. + */ + suppressed: boolean; } /* ── Cost actuals ───────────────────────────────────────────────────────── */ @@ -175,6 +191,13 @@ export interface QuestionnaireCostResult { byCapability: CostCapabilityBucket[]; /** Daily total spend across the window, ascending. */ trend: CostDayPoint[]; - /** Highest-spend respondent sessions, descending (capped). */ + /** Highest-spend respondent sessions, descending (capped). Empty when suppressed. */ topSessions: SessionCostRow[]; + /** + * True when the per-session spend table was withheld (F8.3): the version is anonymous + * (session ids are a re-identification handle), or the cohort is below the k-anonymity + * threshold ({@link K_ANONYMITY_THRESHOLD}). Aggregate spend (total / by-capability / + * trend) is always returned — it carries no per-respondent identity. + */ + topSessionsSuppressed: boolean; } diff --git a/lib/app/questionnaire/chat/resumable-session.ts b/lib/app/questionnaire/chat/resumable-session.ts new file mode 100644 index 00000000..3cf9aa2a --- /dev/null +++ b/lib/app/questionnaire/chat/resumable-session.ts @@ -0,0 +1,39 @@ +/** + * The single definition of a respondent's "resumable" session (F8.3). + * + * A respondent resumes — rather than starts afresh — when they already have a + * non-preview, non-terminal session for the version. Both the create route + * (`questionnaire-sessions/_lib/create.ts`) and the pre-create resolver + * (`chat/start-context.ts`) must agree on this rule, or the start page would redirect + * to a session the create route wouldn't resume (or vice-versa). Keep it here so the + * rule lives in one place. Server-only. + */ + +import { prisma } from '@/lib/db/client'; + +/** A resumable session's identifying fields. */ +export interface ResumableSession { + id: string; + status: string; + versionId: string; +} + +/** + * Find a respondent's existing non-terminal real session for a version (the resume + * target), or null if none. Most-recent first; preview sessions are excluded. + */ +export async function findResumableSession( + versionId: string, + respondentUserId: string +): Promise { + return prisma.appQuestionnaireSession.findFirst({ + where: { + versionId, + respondentUserId, + isPreview: false, + status: { in: ['active', 'paused'] }, + }, + orderBy: { createdAt: 'desc' }, + select: { id: true, status: true, versionId: true }, + }); +} diff --git a/lib/app/questionnaire/chat/start-context.ts b/lib/app/questionnaire/chat/start-context.ts new file mode 100644 index 00000000..af23f7a5 --- /dev/null +++ b/lib/app/questionnaire/chat/start-context.ts @@ -0,0 +1,59 @@ +/** + * Pre-create start context for the authenticated respondent surface (F8.3). + * + * The `start` server component normally creates/resumes a session and redirects straight + * to the chat. When the questionnaire collects a respondent profile (non-anonymous, with + * `profileFields`) and the caller has no session yet, we must instead show the profile + * form FIRST, then create the session with the collected values. This read-only resolver + * decides which of those three things to do, without writing anything. + * + * Only the invitation surface collects a profile — the version-direct surface is the + * anonymous "anyone may answer" path, which never collects one. Server-only. + */ + +import { prisma } from '@/lib/db/client'; +import { hashInvitationToken } from '@/lib/app/questionnaire/invitations'; +import { parseProfileFields } from '@/lib/app/questionnaire/profile/profile-values'; +import { findResumableSession } from '@/lib/app/questionnaire/chat/resumable-session'; +import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types'; +import type { AuthedSessionRequest } from '@/lib/app/questionnaire/chat/session-bootstrap'; + +export type StartContext = + /** A resumable session already exists — skip the form and go straight to chat. */ + | { kind: 'resume'; sessionId: string } + /** Non-anonymous version with profile fields and no session yet — collect first. */ + | { kind: 'needs-profile'; profileFields: ProfileFieldConfig[] } + /** Nothing to collect — create/resume immediately (anonymous or no profile fields). */ + | { kind: 'start-now' }; + +/** + * Resolve whether the `start` page should collect a profile before creating the session. + * Any unresolvable / non-collecting case falls through to `start-now`, letting the create + * route own validation and error reporting (this resolver never blocks the happy path). + */ +export async function loadStartContext( + request: AuthedSessionRequest, + respondentUserId: string +): Promise { + // Only the invitation surface is non-anonymous; a version-direct request is the + // anonymous path, which never collects a profile. + if (!('invitationToken' in request)) return { kind: 'start-now' }; + + const invitation = await prisma.appQuestionnaireInvitation.findUnique({ + where: { tokenHash: hashInvitationToken(request.invitationToken) }, + select: { + versionId: true, + version: { select: { config: { select: { anonymousMode: true, profileFields: true } } } }, + }, + }); + if (!invitation) return { kind: 'start-now' }; + + const anonymous = invitation.version.config?.anonymousMode ?? false; + const profileFields = parseProfileFields(invitation.version.config?.profileFields); + if (anonymous || profileFields.length === 0) return { kind: 'start-now' }; + + const existing = await findResumableSession(invitation.versionId, respondentUserId); + if (existing) return { kind: 'resume', sessionId: existing.id }; + + return { kind: 'needs-profile', profileFields }; +} diff --git a/lib/app/questionnaire/export/build-session-export-model.ts b/lib/app/questionnaire/export/build-session-export-model.ts index 95465530..a63a6dc3 100644 --- a/lib/app/questionnaire/export/build-session-export-model.ts +++ b/lib/app/questionnaire/export/build-session-export-model.ts @@ -24,6 +24,7 @@ import { type PanelSectionInput, } from '@/lib/app/questionnaire/panel/answer-panel'; import { resolveTheme, type DemoClientTheme } from '@/lib/app/questionnaire/theming'; +import type { ProfileValues } from '@/lib/app/questionnaire/profile/profile-values'; import type { SessionExportModel } from '@/lib/app/questionnaire/export/types'; /** The plain inputs the DB seam hands the builder. */ @@ -37,6 +38,8 @@ export interface SessionExportInput { anonymous: boolean; /** Respondent display name (or null); dropped when `anonymous`. */ respondentName: string | null; + /** Collected profile values (or null); dropped when `anonymous`, same as the name. */ + profile: ProfileValues | null; /** ISO completion timestamp (or null when the session isn't completed). */ completedAt: string | null; /** ISO generation timestamp (the seam stamps it; the builder has no clock). */ @@ -74,6 +77,8 @@ export function buildSessionExportModel(input: SessionExportInput): SessionExpor const respondent = input.anonymous || !input.respondentName ? null : { name: input.respondentName }; + // Profile is identity — dropped in anonymous mode, same rule as the respondent name. + const profile = input.anonymous ? null : input.profile; return { questionnaireTitle: input.questionnaireTitle, @@ -81,6 +86,7 @@ export function buildSessionExportModel(input: SessionExportInput): SessionExpor goal: input.goal, audienceSummary: summariseAudience(input.audience), respondent, + profile, anonymous: input.anonymous, completedAt: input.completedAt, generatedAt: input.generatedAt, diff --git a/lib/app/questionnaire/export/results-loader.ts b/lib/app/questionnaire/export/results-loader.ts index 1d24e3c7..6747b35a 100644 --- a/lib/app/questionnaire/export/results-loader.ts +++ b/lib/app/questionnaire/export/results-loader.ts @@ -25,6 +25,7 @@ import { type SessionStatus, } from '@/lib/app/questionnaire/types'; import type { PanelRefinementEntry } from '@/lib/app/questionnaire/panel/types'; +import { asProfileValues } from '@/lib/app/questionnaire/profile/profile-values'; import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics'; import type { ExportAnswer, @@ -106,6 +107,9 @@ export async function loadResultsExport(scope: AnalyticsScope): Promise [a.questionKey, a])); + // Collected profile (F8.3) as a single JSON cell — empty in anonymous mode / when + // none were collected. Repeated per row like respondent_name (the spreadsheet view). + const profileCell = session.profile ? JSON.stringify(session.profile) : ''; for (const question of model.questions) { const answer = answerByKey.get(question.key); lines.push( @@ -65,6 +69,7 @@ export function toResultsCsv(model: ResultsExportModel): string { csvEscape(session.createdAt), csvEscape(session.completedAt ?? ''), csvEscape(session.respondentName ?? ''), + csvEscape(profileCell), csvEscape(question.sectionTitle), csvEscape(question.key), csvEscape(question.prompt), diff --git a/lib/app/questionnaire/export/results-types.ts b/lib/app/questionnaire/export/results-types.ts index 46619cd1..c7532dc6 100644 --- a/lib/app/questionnaire/export/results-types.ts +++ b/lib/app/questionnaire/export/results-types.ts @@ -9,6 +9,9 @@ * * Anonymous-mode contract (resolved in the loader, not here): * - `respondentName` is null on every session. + * - `profile` is null on every session — the respondent profile snapshot (F8.3) is + * identifying data, so it never reaches an anonymous export (and is never even + * written for an anonymous 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). @@ -17,6 +20,7 @@ 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'; +import type { ProfileValues } from '@/lib/app/questionnaire/profile/profile-values'; /** One question column in the export, in display order (section ordinal → slot ordinal). */ export interface ExportQuestion { @@ -65,6 +69,12 @@ export interface ExportSession { completedAt: string | null; /** Null when the version is anonymous or the respondent is unknown. */ respondentName: string | null; + /** + * The profile-field values the respondent supplied at session start (keyed by field + * `key`), or null when the version is anonymous or none were collected. Identifying + * data — null on every session in an anonymous export. + */ + profile: ProfileValues | null; answers: ExportAnswer[]; /** Empty in anonymous mode. */ turns: ExportTurn[]; diff --git a/lib/app/questionnaire/export/types.ts b/lib/app/questionnaire/export/types.ts index cd3d1e78..4f4fc9ec 100644 --- a/lib/app/questionnaire/export/types.ts +++ b/lib/app/questionnaire/export/types.ts @@ -20,6 +20,7 @@ import type { ResolvedTheme } from '@/lib/app/questionnaire/theming'; import type { PanelSectionView } from '@/lib/app/questionnaire/panel/types'; +import type { ProfileValues } from '@/lib/app/questionnaire/profile/profile-values'; /** The respondent identity shown in the PDF header (name only — never email). */ export interface ExportRespondent { @@ -42,6 +43,12 @@ export interface SessionExportModel { * "Anonymous respondent". */ respondent: ExportRespondent | null; + /** + * The profile-field values the respondent supplied at session start (keyed by field + * `key`), or null when anonymous OR none were collected. Identifying data — the + * builder forces this to null in anonymous mode, the same as `respondent`. + */ + profile: ProfileValues | null; /** True when the version is configured `anonymousMode` (drives the header copy). */ anonymous: boolean; /** ISO timestamp the session completed, or null when not yet completed. */ diff --git a/lib/app/questionnaire/profile/profile-values.ts b/lib/app/questionnaire/profile/profile-values.ts new file mode 100644 index 00000000..f01a6d98 --- /dev/null +++ b/lib/app/questionnaire/profile/profile-values.ts @@ -0,0 +1,111 @@ +/** + * Respondent profile-value collection — pure validation (F8.3). + * + * The admin authors a version's `profileFields` (name/email/role/custom) on the + * config; at session start a respondent supplies a value for each. This module turns + * the stored field config into a Zod schema and validates a respondent's raw + * submission against it, so the same rules guard both the client form + * (`components/app/questionnaire/profile/`) and the server capture seam + * (`questionnaire-sessions/_lib/create.ts`). + * + * Pure: Zod only, no Prisma / Next. The capture seam is the enforcing boundary — + * `.strict()` rejects unknown keys so a client can't smuggle arbitrary PII into the + * snapshot's `values`, and required fields must be present. + * + * ANONYMOUS MODE: never reaches here — the capture seam skips collection entirely when + * `anonymousMode = true`, so no profile values are validated, stored, or surfaced. + */ + +import { z } from 'zod'; + +import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types'; +import { profileFieldSchema } from '@/lib/app/questionnaire/authoring/config-schema'; + +/** The collected profile, keyed by field `key`. Values are strings or numbers. */ +export type ProfileValues = Record; + +/** Outcome of validating a raw submission against a version's `profileFields`. */ +export type ProfileValuesResult = + | { ok: true; values: ProfileValues } + | { ok: false; message: string }; + +/** + * Cast a stored `AppRespondentProfileSnapshot.values` Json column back to + * {@link ProfileValues}. Returns null for an absent/non-object column. Mirrors the + * `asAudience` / `asRefinementHistory` JSON-column readers in the export seams — the + * value was validated on write, so this is a shape guard, not re-validation. + */ +export function asProfileValues(value: unknown): ProfileValues | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as ProfileValues; +} + +/** + * Parse the stored `AppQuestionnaireConfig.profileFields` Json column back to typed + * configs, dropping silently to `[]` on a malformed column (the read view already + * resolves defaults; the capture seam treats "no fields" as "nothing to collect"). + */ +export function parseProfileFields(json: unknown): ProfileFieldConfig[] { + const parsed = z.array(profileFieldSchema).safeParse(json); + return parsed.success ? parsed.data : []; +} + +/** Build the per-field value schema for one configured profile field. */ +function fieldValueSchema(field: ProfileFieldConfig): z.ZodTypeAny { + switch (field.type) { + case 'email': + return z.string().trim().email('Enter a valid email address').max(320); + case 'number': + return z.coerce.number().finite('Enter a valid number'); + case 'select': { + const options = field.options ?? []; + // A select always has ≥1 option (config rule); guard the empty case so a + // malformed config degrades to a free string rather than throwing. + return options.length > 0 ? z.enum([...options]) : z.string().trim().min(1); + } + case 'text': + default: + return z.string().trim().min(1).max(2000); + } +} + +/** + * Build a strict object schema from a version's profile fields: required fields are + * mandatory, optional ones may be omitted, and no unknown keys are allowed. + */ +export function buildProfileValuesSchema(fields: ProfileFieldConfig[]): z.ZodTypeAny { + const shape: Record = {}; + for (const field of fields) { + const base = fieldValueSchema(field); + shape[field.key] = field.required ? base : base.optional(); + } + return z.object(shape).strict(); +} + +/** + * Validate a raw respondent submission against a version's profile fields. Empty + * strings / nulls are treated as "not supplied" (dropped before validation), so an + * omitted optional field passes and a blank required field fails. Returns the cleaned + * {@link ProfileValues} on success, or the first issue's message on failure. + */ +export function validateProfileValues( + fields: ProfileFieldConfig[], + raw: unknown +): ProfileValuesResult { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, message: 'Profile values must be an object' }; + } + + const cleaned: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (value === null || value === undefined) continue; + if (typeof value === 'string' && value.trim() === '') continue; + cleaned[key] = value; + } + + const parsed = buildProfileValuesSchema(fields).safeParse(cleaned); + if (!parsed.success) { + return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid profile values' }; + } + return { ok: true, values: parsed.data as ProfileValues }; +} diff --git a/prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql b/prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql new file mode 100644 index 00000000..e59a966a --- /dev/null +++ b/prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql @@ -0,0 +1,33 @@ +-- F8.3: respondent profile snapshot (PII, 1:1 with a non-anonymous session). +-- +-- The Prisma-generated diff also emitted phantom DROPs for four raw-SQL objects it +-- cannot model (the pgvector HNSW / search indexes +-- idx_ai_knowledge_chunk_search_vector, idx_knowledge_embedding, +-- idx_message_embedding, idx_app_question_slot_embedding, and the +-- ai_knowledge_chunk.searchVector default). Those were stripped — this migration +-- only creates the new table. See the drift warnings in the schema + the +-- app-migration create-only discipline. + +-- CreateTable +CREATE TABLE "app_respondent_profile_snapshot" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "respondentUserId" TEXT, + "values" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "app_respondent_profile_snapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "app_respondent_profile_snapshot_sessionId_key" ON "app_respondent_profile_snapshot"("sessionId"); + +-- CreateIndex +CREATE INDEX "app_respondent_profile_snapshot_respondentUserId_idx" ON "app_respondent_profile_snapshot"("respondentUserId"); + +-- AddForeignKey +ALTER TABLE "app_respondent_profile_snapshot" ADD CONSTRAINT "app_respondent_profile_snapshot_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "app_questionnaire_session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "app_respondent_profile_snapshot" ADD CONSTRAINT "app_respondent_profile_snapshot_respondentUserId_fkey" FOREIGN KEY ("respondentUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema/app-questionnaire.prisma b/prisma/schema/app-questionnaire.prisma index 7c3831d6..d3f8a897 100644 --- a/prisma/schema/app-questionnaire.prisma +++ b/prisma/schema/app-questionnaire.prisma @@ -413,10 +413,11 @@ model AppQuestionnaireSession { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - version AppQuestionnaireVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) - answers AppAnswerSlot[] - events AppQuestionnaireSessionEvent[] - turns AppQuestionnaireTurn[] + version AppQuestionnaireVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + answers AppAnswerSlot[] + events AppQuestionnaireSessionEvent[] + turns AppQuestionnaireTurn[] + profileSnapshot AppRespondentProfileSnapshot? @@index([versionId]) @@index([versionId, isPreview]) @@ -431,6 +432,40 @@ model AppQuestionnaireSession { @@map("app_questionnaire_session") } +/// The profile-field values a respondent supplied at session start (F8.3), 1:1 with a +/// NON-anonymous session. PII by construction: `values` holds the answers to the +/// admin-authored `AppQuestionnaireConfig.profileFields` (name/email/role/custom), +/// keyed by field `key`. +/// +/// ⚠️ ANONYMOUS-MODE INVARIANT ⚠️ +/// NO row is ever written for a session whose version config has +/// `anonymousMode = true`. The capture seam (`questionnaire-sessions/_lib/create.ts`) +/// skips the write entirely in anonymous mode — absence, not an empty row (the +/// strongest, most testable guarantee). Read paths additionally omit the snapshot when +/// anonymous, as defence in depth. See `.context/app/questionnaire/anonymous-mode.md`. +/// +/// This is the FIRST questionnaire model with a modelled `User` FK — the deferred-UG-1 +/// "plain String, no @relation" posture used elsewhere is deliberately broken here +/// because this row IS personal data and MUST cascade on erasure. `respondentUserId` +/// is denormalised from the session so the cascade has a direct FK; it carries +/// `onDelete: Cascade` so `prisma.user.delete()` (via `eraseUser()`) removes the +/// snapshot natively — no erasure hook needed. The `session` FK also cascades, so +/// deleting a session removes its snapshot. +model AppRespondentProfileSnapshot { + id String @id @default(cuid()) + sessionId String @unique + respondentUserId String? // denormalised from the session; null for no-login (which never writes a row anyway) + values Json @default("{}") // { [profileFieldKey]: string | number } — the collected PII + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + session AppQuestionnaireSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user User? @relation(fields: [respondentUserId], references: [id], onDelete: Cascade) + + @@index([respondentUserId]) + @@map("app_respondent_profile_snapshot") +} + /// One captured answer for a question slot within a session. F4.2 produces the /// extraction INTENTS; F4.4 is the first writer (via the refine-answer route) and /// owns the refinement update: the value/provenance change plus an appended diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma index ccac42ea..9b7571eb 100644 --- a/prisma/schema/auth.prisma +++ b/prisma/schema/auth.prisma @@ -70,6 +70,9 @@ model User { mcpApiKeys McpApiKey[] @relation("McpApiKeyCreator") mcpExposedPrompts McpExposedPrompt[] @relation("McpPromptCreator") + // ConQuest app relations — personal data, cascades on erasure (F8.3). + appRespondentProfileSnapshots AppRespondentProfileSnapshot[] + @@unique([email]) @@index([role]) @@index([role, accountType]) diff --git a/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts index ee90d69d..3cf27bed 100644 --- a/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts +++ b/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts @@ -115,7 +115,8 @@ describe('dispatch', () => { expect(res.status).toBe(201); expect(createMock.createSessionFromInvitation).toHaveBeenCalledWith( 'tok_abcdefghij', - 'cmjbv4i3x00003wsloputgwul' + 'cmjbv4i3x00003wsloputgwul', + undefined // no profileValues on this body ); expect(createMock.createSessionForVersion).not.toHaveBeenCalled(); const body = await res.json(); @@ -123,6 +124,19 @@ describe('dispatch', () => { expect(body.meta.resumed).toBe(false); }); + it('forwards profileValues from an invitationToken body (F8.3)', async () => { + const res = await POST( + req({ invitationToken: 'tok_abcdefghij', profileValues: { team: 'Analytics' } }), + undefined + ); + expect(res.status).toBe(201); + expect(createMock.createSessionFromInvitation).toHaveBeenCalledWith( + 'tok_abcdefghij', + 'cmjbv4i3x00003wsloputgwul', + { team: 'Analytics' } + ); + }); + it('routes a versionId body to createSessionForVersion', async () => { const res = await POST(req({ versionId: 'v1' }), undefined); expect(res.status).toBe(201); diff --git a/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts index 2618a9b9..c12e31bb 100644 --- a/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts +++ b/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts @@ -56,6 +56,7 @@ function model(over: Partial = {}): SessionExportModel { audienceSummary: null, respondent: { name: 'Ada' }, anonymous: false, + profile: null, completedAt: '2026-06-01T10:00:00.000Z', generatedAt: '2026-06-07T12:00:00.000Z', theme: { ctaColor: '#000', accentColor: '#000', logoUrl: null, welcomeCopy: 'hi' }, diff --git a/tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts new file mode 100644 index 00000000..974b0853 --- /dev/null +++ b/tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts @@ -0,0 +1,225 @@ +/** + * Integration test: the F8.3 respondent profile-snapshot capture seam. + * + * The load-bearing anonymous-mode invariant: a snapshot is written ONLY for a + * non-anonymous session that supplied valid profile values; it is NEVER written for an + * anonymous session (no-login or authed-anonymous), and invalid/empty submissions are + * rejected or skipped. Prisma + the `recordSessionCreated` seam are mocked; `$transaction` + * runs its callback against a tx mock that records `appRespondentProfileSnapshot.create`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const tx = { + appQuestionnaireSession: { create: vi.fn() }, + appQuestionnaireInvitation: { update: vi.fn() }, + appRespondentProfileSnapshot: { create: vi.fn() }, + }; + const prisma = { + $transaction: vi.fn((cb: (t: typeof tx) => unknown) => cb(tx)), + appQuestionnaireInvitation: { findUnique: vi.fn() }, + appQuestionnaireVersion: { findUnique: vi.fn() }, + appQuestionnaireSession: { findFirst: vi.fn() }, + }; + return { tx, prisma }; +}); +vi.mock('@/lib/db/client', () => ({ prisma: mocks.prisma })); + +const seamMock = vi.hoisted(() => ({ recordSessionCreated: vi.fn() })); +vi.mock('@/app/api/v1/app/questionnaires/_lib/sessions', () => seamMock); + +import { + createAnonymousSession, + createSessionForVersion, + createSessionFromInvitation, +} from '@/app/api/v1/app/questionnaire-sessions/_lib/create'; + +type Mock = ReturnType; +const USER = 'user-1'; +const NEW_SESSION = { id: 'sess-new', status: 'active', versionId: 'v1' }; + +/** A profile field config (parsed from the JSON column at capture time). */ +const PROFILE_FIELDS = [ + { key: 'team', label: 'Team', type: 'text', required: true }, + { + key: 'seniority', + label: 'Seniority', + type: 'select', + required: false, + options: ['junior', 'senior'], + }, +]; + +/** An invitation whose version collects a profile (non-anonymous) by default. */ +const invitation = (over: Record = {}) => ({ + id: 'inv-1', + userId: USER, + status: 'registered', + versionId: 'v1', + version: { + status: 'launched', + config: { anonymousMode: false, profileFields: PROFILE_FIELDS }, + ...((over.version as Record) ?? {}), + }, + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + (mocks.tx.appQuestionnaireSession.create as Mock).mockResolvedValue(NEW_SESSION); + (mocks.tx.appQuestionnaireInvitation.update as Mock).mockResolvedValue({}); + (mocks.tx.appRespondentProfileSnapshot.create as Mock).mockResolvedValue({}); + (mocks.prisma.appQuestionnaireSession.findFirst as Mock).mockResolvedValue(null); + (seamMock.recordSessionCreated as Mock).mockResolvedValue(undefined); +}); + +describe('profile snapshot capture (non-anonymous invitation surface)', () => { + it('writes a snapshot with the validated values inside the create transaction', async () => { + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation()); + + const result = await createSessionFromInvitation('tok', USER, { + team: 'Analytics', + seniority: 'senior', + }); + + expect(result).toMatchObject({ ok: true, resumed: false }); + expect(mocks.tx.appRespondentProfileSnapshot.create).toHaveBeenCalledWith({ + data: { + sessionId: 'sess-new', + respondentUserId: USER, + values: { team: 'Analytics', seniority: 'senior' }, + }, + }); + }); + + it('rejects unknown profile keys with a 400 and writes nothing', async () => { + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation()); + + const result = await createSessionFromInvitation('tok', USER, { + team: 'Analytics', + smuggled: 'pii@evil.com', + }); + + expect(result).toMatchObject({ ok: false, status: 400, code: 'INVALID_PROFILE' }); + expect(mocks.tx.appQuestionnaireSession.create).not.toHaveBeenCalled(); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('rejects a missing required field with a 400', async () => { + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation()); + + // `team` is required; only the optional field is supplied. + const result = await createSessionFromInvitation('tok', USER, { seniority: 'junior' }); + + expect(result).toMatchObject({ ok: false, status: 400, code: 'INVALID_PROFILE' }); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('writes no snapshot row when the submission is empty (no empty rows)', async () => { + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue( + invitation({ + version: { status: 'launched', config: { anonymousMode: false, profileFields: [] } }, + }) + ); + + const result = await createSessionFromInvitation('tok', USER, {}); + + expect(result).toMatchObject({ ok: true }); + expect(mocks.tx.appQuestionnaireSession.create).toHaveBeenCalledTimes(1); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('rejects with a 400 (and creates nothing) when a required field is configured but no profileValues are sent', async () => { + // The server is the enforcing boundary, not the form: an omitted payload is + // validated as an empty submission, so the required `team` field still rejects — + // a direct API caller can't bypass required collection by dropping the key. + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation()); + + const result = await createSessionFromInvitation('tok', USER); // no profileValues arg + + expect(result).toMatchObject({ ok: false, status: 400, code: 'INVALID_PROFILE' }); + expect(mocks.tx.appQuestionnaireSession.create).not.toHaveBeenCalled(); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('creates the session with no snapshot when no profileValues are sent and all fields are optional', async () => { + // Only-optional fields + an omitted payload is a legitimate "nothing to capture": + // the session is created and no snapshot row is written. + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue( + invitation({ + version: { + status: 'launched', + config: { + anonymousMode: false, + profileFields: [ + { key: 'seniority', label: 'Seniority', type: 'text', required: false }, + ], + }, + }, + }) + ); + + const result = await createSessionFromInvitation('tok', USER); // no profileValues arg + + expect(result).toMatchObject({ ok: true }); + expect(mocks.tx.appQuestionnaireSession.create).toHaveBeenCalledTimes(1); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('does not capture a profile on resume (snapshot is written once, at first start)', async () => { + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation()); + (mocks.prisma.appQuestionnaireSession.findFirst as Mock).mockResolvedValue({ + id: 'sess-existing', + status: 'paused', + versionId: 'v1', + }); + + const result = await createSessionFromInvitation('tok', USER, { team: 'Analytics' }); + + expect(result).toMatchObject({ ok: true, resumed: true }); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); +}); + +describe('profile snapshot is NEVER written in anonymous mode (F8.3 invariant)', () => { + it('skips capture when the invitation version is anonymousMode, even with values supplied', async () => { + (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue( + invitation({ + version: { + status: 'launched', + config: { anonymousMode: true, profileFields: PROFILE_FIELDS }, + }, + }) + ); + + const result = await createSessionFromInvitation('tok', USER, { team: 'Analytics' }); + + expect(result).toMatchObject({ ok: true }); + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('never captures a profile on the authed anonymous-direct surface', async () => { + (mocks.prisma.appQuestionnaireVersion.findUnique as Mock).mockResolvedValue({ + id: 'v1', + status: 'launched', + config: { anonymousMode: true }, + }); + + await createSessionForVersion('v1', USER); + + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); + + it('never captures a profile on the no-login anonymous surface', async () => { + (mocks.prisma.appQuestionnaireVersion.findUnique as Mock).mockResolvedValue({ + id: 'v1', + status: 'launched', + config: { anonymousMode: true }, + }); + + await createAnonymousSession('v1'); + + expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts index dfc06ba7..3a1a1b7a 100644 --- a/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts +++ b/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts @@ -265,6 +265,21 @@ describe('loadSessionExport', () => { expect(findUser).not.toHaveBeenCalled(); }); + it('surfaces the profile snapshot when not anonymous (F8.3)', async () => { + findSession.mockResolvedValue(row({ profileSnapshot: { values: { team: 'Analytics' } } })); + const loaded = await loadSessionExport('sess-1'); + expect(loaded?.profile).toEqual({ team: 'Analytics' }); + }); + + it('drops the profile snapshot entirely in anonymous mode (F8.3)', async () => { + findSession.mockResolvedValue( + rowWithVersion({ config: { anonymousMode: true } }) + // even if a snapshot row somehow existed, anonymous output must omit it + ); + const loaded = await loadSessionExport('sess-1'); + expect(loaded?.profile).toBeNull(); + }); + it('defaults anonymous to false when the version has no config row', async () => { findSession.mockResolvedValue(rowWithVersion({ config: null })); const loaded = await loadSessionExport('sess-1'); @@ -319,6 +334,7 @@ describe('buildSessionExportPdfModel', () => { audience: { description: 'New hires' }, anonymous: false, respondentName: 'Ada Lovelace', + profile: null, completedAt: '2026-06-02T10:30:00.000Z', theme: { ctaColor: '#111111', diff --git a/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts b/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts index 4d6297b7..3e5b9be2 100644 --- a/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts +++ b/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts @@ -55,6 +55,7 @@ function model(over: Partial = {}): SessionExportModel { audienceSummary: null, respondent: null, anonymous: true, + profile: null, completedAt: null, generatedAt: '2026-06-07T12:00:00.000Z', theme: { ctaColor: '#000', accentColor: '#000', logoUrl: null, welcomeCopy: 'hi' }, diff --git a/tests/integration/api/v1/app/questionnaires/version-export.test.ts b/tests/integration/api/v1/app/questionnaires/version-export.test.ts index 12b2fb4c..acd8abc3 100644 --- a/tests/integration/api/v1/app/questionnaires/version-export.test.ts +++ b/tests/integration/api/v1/app/questionnaires/version-export.test.ts @@ -91,6 +91,7 @@ const MODEL: ResultsExportModel = { createdAt: '2026-01-10T09:00:00.000Z', completedAt: '2026-01-10T09:30:00.000Z', respondentName: 'Ada', + profile: null, answers: [ { questionKey: 'role', @@ -182,7 +183,7 @@ describe('GET versions/:vid/export', () => { 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' + 'session_id,session_status,created_at,completed_at,respondent_name,respondent_profile,section_title,question_key,question_prompt,question_type,answer_value,confidence,provenance_label' ); expect(firstRow).toContain('s1,completed,'); expect(firstRow).toContain('Engineer'); diff --git a/tests/unit/app/(protected)/questionnaires/start/page.test.tsx b/tests/unit/app/(protected)/questionnaires/start/page.test.tsx index 5ed3b560..55656362 100644 --- a/tests/unit/app/(protected)/questionnaires/start/page.test.tsx +++ b/tests/unit/app/(protected)/questionnaires/start/page.test.tsx @@ -64,11 +64,31 @@ vi.mock('@/lib/app/questionnaire/chat/session-bootstrap', () => ({ createOrResumeAuthedSession: vi.fn(), })); +/** + * Mock the F8.3 pre-create resolver — the page calls this (which hits Prisma) + * before bootstrap to decide whether to collect a profile first. Defaulted to + * `start-now` (the legacy straight-to-chat path); branch tests override it. + */ +vi.mock('@/lib/app/questionnaire/chat/start-context', () => ({ + loadStartContext: vi.fn(), +})); + +/** + * Mock the profile form — a client component the page renders for the + * `needs-profile` branch. We only assert it is reached, not its internals. + */ +vi.mock('@/components/app/questionnaire/profile/profile-start-form', () => ({ + ProfileStartForm: vi.fn(({ invitationToken }: { invitationToken: string }) => ( +
+ )), +})); + import StartQuestionnairePage, { metadata } from '@/app/(protected)/questionnaires/start/page'; import { getServerSession } from '@/lib/auth/utils'; import { clearInvalidSession } from '@/lib/auth/clear-session'; import { isLiveSessionsEnabled } from '@/lib/app/questionnaire/feature-flag'; import { createOrResumeAuthedSession } from '@/lib/app/questionnaire/chat/session-bootstrap'; +import { loadStartContext } from '@/lib/app/questionnaire/chat/start-context'; import { redirect } from 'next/navigation'; // --------------------------------------------------------------------------- @@ -110,6 +130,7 @@ describe('StartQuestionnairePage', () => { // Happy-path defaults vi.mocked(isLiveSessionsEnabled).mockResolvedValue(true); vi.mocked(getServerSession).mockResolvedValue(MOCK_SESSION); + vi.mocked(loadStartContext).mockResolvedValue({ kind: 'start-now' }); vi.mocked(createOrResumeAuthedSession).mockResolvedValue({ ok: true, sessionId: 's1', @@ -307,4 +328,63 @@ describe('StartQuestionnairePage', () => { expect(screen.getByText(message)).toBeInTheDocument(); }); }); + + // ------------------------------------------------------------------------- + // F8.3 pre-create profile resolution + // ------------------------------------------------------------------------- + + describe('profile resolution (F8.3)', () => { + it('redirects straight to the chat when a resumable session already exists', async () => { + // Arrange: resolver finds a non-terminal session — skip the form entirely + vi.mocked(loadStartContext).mockResolvedValue({ kind: 'resume', sessionId: 'resumed_1' }); + + // Act & Assert: page redirects without creating a new session + await expect( + StartQuestionnairePage({ + searchParams: makeSearchParams({ invitationToken: 'tok_xyz' }), + }) + ).rejects.toThrow('NEXT_REDIRECT:/questionnaires/resumed_1'); + expect(redirect).toHaveBeenCalledWith('/questionnaires/resumed_1'); + // Bootstrap is bypassed — the existing session is reused + expect(createOrResumeAuthedSession).not.toHaveBeenCalled(); + }); + + it('renders the profile form when the version needs a profile and no session exists', async () => { + // Arrange: resolver asks for profile collection before session creation + vi.mocked(loadStartContext).mockResolvedValue({ + kind: 'needs-profile', + profileFields: [{ key: 'full_name', label: 'Full name', type: 'text', required: true }], + }); + + // Act + const Component = await StartQuestionnairePage({ + searchParams: makeSearchParams({ invitationToken: 'tok_xyz' }), + }); + render(Component); + + // Assert: the form is rendered with the invitation token; bootstrap is deferred + // to the form's submit (the page does not create the session itself here) + const form = screen.getByTestId('profile-start-form'); + expect(form).toHaveAttribute('data-token', 'tok_xyz'); + expect(createOrResumeAuthedSession).not.toHaveBeenCalled(); + }); + + it('falls through to bootstrap for a needs-profile context on the versionId surface', async () => { + // Arrange: needs-profile but the request is versionId (no invitationToken) — the + // page guards the form render on `'invitationToken' in request`, so it must NOT + // render the form and instead create the session normally. + vi.mocked(loadStartContext).mockResolvedValue({ + kind: 'needs-profile', + profileFields: [{ key: 'full_name', label: 'Full name', type: 'text', required: true }], + }); + + // Act & Assert: version-direct path creates the session despite needs-profile + await expect( + StartQuestionnairePage({ + searchParams: makeSearchParams({ versionId: 'ver_abc' }), + }) + ).rejects.toThrow('NEXT_REDIRECT:/questionnaires/s1'); + expect(createOrResumeAuthedSession).toHaveBeenCalledWith({ versionId: 'ver_abc' }); + }); + }); }); diff --git a/tests/unit/app/questionnaire-sessions/pdf-response.test.ts b/tests/unit/app/questionnaire-sessions/pdf-response.test.ts index 16ed8dad..87441522 100644 --- a/tests/unit/app/questionnaire-sessions/pdf-response.test.ts +++ b/tests/unit/app/questionnaire-sessions/pdf-response.test.ts @@ -23,6 +23,7 @@ function model(over: Partial = {}): SessionExportModel { audienceSummary: null, respondent: null, anonymous: false, + profile: null, completedAt: null, generatedAt: '2026-06-07T12:00:00.000Z', theme: { ctaColor: '#000', accentColor: '#000', logoUrl: null, welcomeCopy: '' }, diff --git a/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx b/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx index 1682d120..e6684f8d 100644 --- a/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx +++ b/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx @@ -25,6 +25,7 @@ function model( audience: { description: 'New hires' }, anonymous: false, respondentName: 'Ada Lovelace', + profile: null, completedAt: '2026-06-01T10:00:00.000Z', generatedAt: '2026-06-07T12:00:00.000Z', theme: null, diff --git a/tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx b/tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx new file mode 100644 index 00000000..af03cb74 --- /dev/null +++ b/tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx @@ -0,0 +1,589 @@ +/** + * ProfileStartForm Component Tests (F8.3) + * + * Tests the profile form rendered before a non-anonymous session starts. + * The form renders one input per admin-authored ProfileFieldConfig, validates + * with Zod, POSTs to the session-create route via apiClient, and navigates to + * the chat on success. + * + * Test Coverage: + * - Rendering: one input per field type (text/email/number/select) with labels + * - Rendering: required vs optional field markers + * - Rendering: select field renders all options + * - Zod validation: submitting with empty required field shows error, no API call + * - Zod validation: email type rejects non-email values + * - Zod validation: number type rejects non-numeric values + * - Happy path: valid submit calls apiClient.post with correct endpoint + payload + token + * - Happy path: on success, router.push navigates to /questionnaires/ + * - API failure: APIClientError message shown, no navigation + * - API failure: unexpected error shows fallback message, no navigation + * - Loading state: button shows "Starting…" while submitting + * - Loading state: button disabled while submitting + * + * @see components/app/questionnaire/profile/profile-start-form.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 { ProfileStartForm } from '@/components/app/questionnaire/profile/profile-start-form'; +import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types'; + +// --------------------------------------------------------------------------- +// Module-level mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/api/client', () => ({ + apiClient: { + post: vi.fn(), + }, + APIClientError: class APIClientError extends Error { + code?: string; + status?: number; + details?: Record; + constructor( + message: string, + code?: string, + status?: number, + details?: Record + ) { + super(message); + this.name = 'APIClientError'; + this.code = code; + this.status = status; + this.details = details; + } + }, +})); + +// next/navigation is globally mocked in tests/setup.ts; we override useRouter +// per-describe to capture the push spy. + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A minimal set of fields covering all four types. */ +const MIXED_FIELDS: ProfileFieldConfig[] = [ + { key: 'name', label: 'Full Name', type: 'text', required: true }, + { key: 'email', label: 'Email', type: 'email', required: true }, + { key: 'age', label: 'Age', type: 'number', required: false }, + { + key: 'role', + label: 'Role', + type: 'select', + required: true, + options: ['Engineer', 'Designer', 'Manager'], + }, +]; + +const INVITATION_TOKEN = 'tok_test_abc123'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('components/app/questionnaire/profile/profile-start-form', () => { + const mockPush = vi.fn(); + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useRouter } = await import('next/navigation'); + vi.mocked(useRouter).mockReturnValue({ + push: mockPush, + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + describe('rendering', () => { + it('renders one labelled input for each text/email/number field', () => { + // Arrange & Act + const { container } = render( + + ); + + // Assert: one input element per non-select field keyed by the id the component assigns + // (id="profile-{key}"). We query by id because the Label wraps a FieldHelp button whose + // aria-label also matches the label text, so getByLabelText would find multiple elements. + expect(container.querySelector('#profile-name')).toBeInTheDocument(); + expect(container.querySelector('#profile-email')).toBeInTheDocument(); + expect(container.querySelector('#profile-age')).toBeInTheDocument(); + }); + + it('renders the correct input type for email fields', () => { + // Arrange & Act + const { container } = render( + + ); + + // Assert: the email input has type="email" + const emailInput = container.querySelector('#profile-email'); + expect(emailInput).toHaveAttribute('type', 'email'); + }); + + it('renders the correct input type for number fields', () => { + // Arrange & Act + const { container } = render( + + ); + + // Assert: the number input has type="number" + const ageInput = container.querySelector('#profile-age'); + expect(ageInput).toHaveAttribute('type', 'number'); + }); + + it('renders a