From c176b889410488c9dcf5ac1631bd93126899bbae Mon Sep 17 00:00:00 2001 From: John Durrant Date: Sun, 7 Jun 2026 17:13:10 +0100 Subject: [PATCH 1/8] feat(app): demo-client branding preview + plan-header refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces a demo client's configured brand back to the admin: a new DemoClientThemePreview (reuses resolveTheme() + the escaped --app-logo-url background from BrandThemeProvider) renders colour swatches + logo on the demo-clients list (compact, configured fields only), the detail page (full resolved brand), and live in the edit form. Closes the gap where an admin could set four theme fields and see nothing back. Also refreshes the stale development-plan.md header (P6/P7 were merged but read "not started") and records the 2026-06-07 deferred-item audit + the five gap-fills in the decisions log. Item 0 + Item 1 of the deferred-gaps plan. App surface — no CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .context/app/planning/development-plan.md | 22 +-- .context/app/questionnaire/demo-clients.md | 11 +- app/admin/demo-clients/[id]/page.tsx | 8 ++ .../admin/demo-clients/demo-client-form.tsx | 21 +++ .../demo-client-theme-preview.tsx | 126 ++++++++++++++++++ .../admin/demo-clients/demo-clients-table.tsx | 7 +- .../demo-client-theme-preview.test.tsx | 109 +++++++++++++++ 7 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 components/admin/demo-clients/demo-client-theme-preview.tsx create mode 100644 tests/unit/components/admin/demo-clients/demo-client-theme-preview.test.tsx diff --git a/.context/app/planning/development-plan.md b/.context/app/planning/development-plan.md index 8f6637e3..92cf419f 100644 --- a/.context/app/planning/development-plan.md +++ b/.context/app/planning/development-plan.md @@ -21,14 +21,14 @@ supersedes: Conversational Questionnaire Phases.md ## Project -| Field | Value | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Name | Conversational Questionnaire | -| Repo | `human-centric-engineering/conquest` (forked from `human-centric-engineering/sunrise` at v0.0.1) | -| Host platform | Sunrise v0.0.1 | -| Lead | Simon Holmes | -| Status | `building` — P0 done (F0.1, PR #10); P1 done (F1.1 ingestion: PR #13/#14/#15); P2.5 done (F2.5.1, #21); P2 done (F2.1 #18/#19, F2.2 #23, F2.3 #24, F2.4 #25); P3 done (F3.1 #26, F3.2 #27, F3.3 #28, F3.4 #29); P4 done (F4.1 #30, F4.2 #32, F4.3 #33, F4.4 #34, F4.5 #35, F4.6 #36 merged); P5 done (F5.1 judge agents #37; F5.2 evaluation run persistence #38; F5.3 suggestion review #39); P6 in progress (F6.1 per-turn orchestrator + streaming done on `feat/f6.1-per-turn-orchestrator` — 6 PRs; F6.2 voice input done on `feat/f6.2-voice-input`) | -| Opened | 2026-05-30 | +| Field | Value | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Conversational Questionnaire | +| Repo | `human-centric-engineering/conquest` (forked from `human-centric-engineering/sunrise` at v0.0.1) | +| Host platform | Sunrise v0.0.1 | +| Lead | Simon Holmes | +| Status | `building` — P0 done (F0.1, PR #10); P1 done (F1.1 ingestion: PR #13/#14/#15); P2.5 done (F2.5.1, #21); P2 done (F2.1 #18/#19, F2.2 #23, F2.3 #24, F2.4 #25); P3 done (F3.1 #26, F3.2 #27, F3.3 #28, F3.4 #29); P4 done (F4.1 #30, F4.2 #32, F4.3 #33, F4.4 #34, F4.5 #35, F4.6 #36); P5 done (F5.1 #37, F5.2 #38, F5.3 #39); P6 done (F6.1 #40, F6.2 #41, F6.3 #42, F6.4 #43); P7 done (F7.1 #44, F7.2 #45, F7.3 #46, F7.4 #47) + admin upload-questionnaire UI (#48). **Next: P8** (analytics, exports, anonymous-mode hardening), then P9. A 2026-06-07 audit found five deferred items whose home feature shipped but which were never delivered — being closed on `feat/close-deferred-gaps` (see decisions log). | +| Opened | 2026-05-30 | --- @@ -142,8 +142,8 @@ The build moves from scaffolding → ingestion → admin manage → demo brandin | **P3** | Configuration, invitations, and cost estimation | done | Per-version config; invitation flow; pre-launch cost estimate. All four features merged: F3.1 config (#26); F3.2 invitation flow (#27); F3.3 cost estimation (#28); F3.4 demo-client invitation branding (#29). | | **P4** | Conversational engine (non-streaming) | in flight | Selection · extraction · contradiction · completion logic, exercised without the streaming surface. F4.1 selection strategies (#30) + F4.2 answer extraction (#32) + F4.3 contradiction detection (#33) + F4.4 answer refinement (#34) + F4.5 completion logic (#35) merged; F4.6 session state machine in flight. | | **P5** | Design-time evaluation (agents-as-judges) | done | Judges score a questionnaire against goal/audience; suggestion review queue. F5.1 judge agents done (app-native dispatch — `evaluate-structure` capability + 7 `kind='judge'` seeds + preview route); F5.2 run persistence done (synchronous run route + app-owned run/finding models + admin run history); F5.3 review queue done (structured `proposedEdit` ops on the findings contract + apply-through-fork engine + read-time staleness + interactive queue UI). | -| **P6** | Conversational session (streaming) | not started | Per-turn orchestrator over streaming chat; voice + attachments. | -| **P7** | User-facing conversational UI | not started | Split-screen chat + answer-slot panel; polish; PDF export. | +| **P6** | Conversational session (streaming) | done | F6.1 per-turn orchestrator + streaming (#40); F6.2 voice input (#41); F6.3 cost-cap enforcement (#42); F6.4 demo session reset (#43). _Attachment input deferred → see audit gap-fill (Item 5)._ | +| **P7** | User-facing conversational UI | done | F7.1 chat surface (#44); F7.2 answer-slot panel (#45); F7.3 session lifecycle UX (#46); F7.4 PDF export (#47); admin upload-questionnaire UI (#48). _Attachment input + invite claim-via-login deferred → see audit gap-fills._ | | **P8** | Admin analytics, exports, anonymous mode | not started | Dashboards, CSV/JSON export, anonymous-mode handling. | | **P9** | Hardening + forking docs | not started | Runbook, flag inventory, `forking.md`, concurrent-session sanity. | @@ -798,6 +798,8 @@ Claude reads this doc for intent and the original [[Conversational Questionnaire Append-only. Newest at the top. Each entry: date, decision, context, link. +- **2026-06-07 — Deferred-item audit + gap-fills (`feat/close-deferred-gaps`).** An audit against the merged code (PRs #10–#48) found the plan header was stale (it said "P6 in progress / P7 not started" when both phases were fully merged) and surfaced **five items that were deferred to a home feature which has since shipped, but were never actually delivered** — the same shape as the admin upload-questionnaire UI (#48), which had fallen between F1.1 (API-only) and the P2/P7 UI work with no owning feature. All five verified against the code, not the trackers. **(1) Invite claim-via-existing-login** — F3.2 returns `409 ACCOUNT_EXISTS`; claim-via-login was deferred to P7 (done) and never built. **(2) Live `refinementHistory` append** — F4.4 built the model; F6.1 deferred wiring `persistRefinement` into the live turn loop (only the F4.4 preview route appends), so real sessions silently drop the history trail P8 will read. **(3) Clone-for-client admin utility** — parked at "promote once F2.2 + F3.1 exist" (done since P3); never built. **(4) Attachment input** — an F7.1 indicative task that shipped out-of-scope, blocked on an F4.2 content-parts extension that was itself deferred in F6.1; voice was wired (F6.2/F7.1) but attachments never were. **(5) `every_n_turns` contradiction cadence** — a cost knob deferred to "F4.6's real loop" (F6.1); detection still runs every turn. **Also delivered with this branch: the demo-client branding preview** — the admin sets four theme fields (CTA/accent colour, logo, welcome copy) but the admin UI never showed them back; a new `DemoClientThemePreview` (reuses `resolveTheme()` + the escaped `--app-logo-url` background) now renders swatches/logo on the demo-clients list, detail page, and live in the edit form. The five gap-fills land as one-PR-each commits on the branch, ordered by priority (correctness 1–2 before P8). Each gets a `planning/features/fX.Y.md` tracker as it ships. (These are app-internal deferrals, not Sunrise-platform gaps, so they're tracked here in the decisions log — not in `upstream-gaps.md`, which is platform-scoped.) + - **2026-06-06 — F7.3 session lifecycle UX (on `feat/F7.3-session-lifecycle-ux`).** The respondent's window onto the session lifecycle: pause/resume, the completion-offer → submit flow, the cost-cap state, and the anonymous-mode indicator. 2 PRs. **The gap it closed:** a live respondent session had **no path to `completed`** — the orchestrator streamed a completion offer ("Would you like to submit?") as prose but nothing recorded acceptance (the only `completed` transition was the admin-only `/complete` preview route); F7.3's respondent `submit` route is now that path. **Decisions (confirmed with the user):** **offer signal = a `GET …/status` read, not a stream frame** — the F6.1 SSE stream carries no offer marker, so rather than reopen that contract (the F7.2 precedent) a new status endpoint returns a respondent-safe `SessionStatusView` (completion kind + coverage + a coarse cost **tier**, never raw spend + `anonymous`) and the UI refetches it on the same `onTurnSettled` that drives the answer panel; the Submit affordance shows when `completion.kind === 'offer'`. **Submit gate is upstream** — `assessCompletion` only returns `offer` once the required-questions gate is clear, so the Submit affordance never appears with a required question blank; the submit route just re-asserts the offer state via the F4.5 `resolveCompletion('accept', …, { run: false })` with **no contradiction sweep** (contradictions surface live via F4.3 during chat), the lone exception being the existing "a question-cap-reached session can always submit" behaviour, honoured as-is. **Pause is signed-in only** — a no-login session's token is client-only (lost on reload), so a deliberate pause has nowhere durable to resume from; the `lifecycle` route refuses anonymous callers `403 PAUSE_NOT_PERMITTED` (they still see system states), and `canPause`/`canResume` mirror that in the UI (a budget-paused `hard`-tier session is non-resumable). **Backend (PR1):** pure `session/status-view.ts` (`buildSessionStatusView` + `canSubmitSession`); `_lib/session-status.ts` reuses `buildTurnContext` so the assessment is byte-identical to the live turn's; three routes `GET status` / `POST lifecycle {pause|resume}` / `POST submit` under `questionnaire-sessions/[id]/`, all reusing `resolveTurnAccess`, gate order flag→load→access→action, illegal transition→409; registry entries. **Frontend (PR2):** stream hook gains `completed` (+ `BLOCKING_STATUSES`) and an imperative `applyStatus`; a new `useSessionLifecycle` hook (status view + pause/resume/submit + derived flags) lifted into `SessionWorkspace` alongside the F7.2 stream+panel hooks (the shared `onTurnSettled` now refetches both panel and status); `lifecycle/` components `SessionLifecycleBar` (anon badge, pause/resume, soft-cost hint — renders nothing when empty), `CompletionOffer` (Submit CTA + dismiss), `SessionComplete` (themed confirmation replacing the workspace on `completed`); authed page SSR-seeds the status view and maps it to the initial chat status (budget-pause→`cost_capped`, respondent pause→resumable `not_active`, completed→confirmation), anon boot fetches on mount. **No migration** (every status + event exists from F4.6); gated entirely by the existing `APP_QUESTIONNAIRES_LIVE_SESSIONS_ENABLED`. **Tests:** pure builder unit; the three routes at integration (gate order, both access modes, anon-pause 403, submit idempotency/409s/happy path, illegal-transition→409); `use-session-lifecycle` + `applyStatus` hook tests; lifecycle component tests. `npm run validate` green. **No CHANGELOG entry** (app surface). Tracker: `planning/features/f7.3.md`. Docs: new `session-lifecycle.md` + updated `per-turn-orchestrator.md` / `answer-slot-panel.md`. - **2026-06-06 — F7.2 answer-slot panel (on `feat/F7.2-answer-slot-panel`).** The live panel beside the F7.1 chat showing answer slots as the conversation fills them in — confidence, provenance, refinement history, per-slot Revisit. 2 PRs. **Decisions (confirmed with the user):** **read endpoint, not a stream event** — the F6.1 SSE stream emits only `start/content/warning/done/error` (answers persist server-side before `done`), so rather than extend that contract the panel reads a new `GET /api/v1/app/questionnaire-sessions/:id/answers` and refetches when each turn settles; the route reuses `resolveTurnAccess` (authed owner OR anonymous `X-Session-Token`) so it serves both respondent kinds, gate order flag→load→access, no status gate (paused/completed still show answers), no extra limiter (read). **Panel scope is an admin config setting** — new per-version `answerSlotPanelScope` (`full_progress | answered_only`, default `full_progress`) added via the F3.1 7-step config path; `answered_only` filters in the **pure builder** (`buildAnswerPanelView`) so pending prompts are never sent to the client; `totalCount` still reflects the whole version so the header reads "N captured" honestly. **Click = inline-expand + a confirm-gated Revisit** that re-asks through the same turn loop (a true scroll-to-turn was rejected — it would force touching `QuestionnaireChat` and has a turn-ordinal alignment hazard). **Rationale shown quietly** in the expanded view. **Live wiring:** a new `SessionWorkspace` client parent lifts the single `useQuestionnaireSessionStream` so chat + panel share it — an additive `onTurnSettled` option (fires on a clean settle to `idle`) drives `panel.refetch`, and the shared stream is what lets the panel's Revisit call `stream.sendMessage(...)`; `QuestionnaireChat` was refactored to **take `stream` as a prop** (hook moved up), render otherwise unchanged. **Layout:** both pages render `SessionWorkspace` inside the existing `BrandThemeProvider` (panel inherits brand vars), `lg:grid-cols-[1fr_22rem]`, panel `hidden lg:flex` so `< lg` keeps the full-width F7.1 chat; authed page SSR-seeds the panel (`loadAnswerPanelState`), the anonymous page can't (client-only token) so it skeletons briefly. **Migration** `…_add_config_answer_panel_scope` (one ADD COLUMN, create-only + phantom pgvector DDL stripped, drift 11/11). **Pure core** (`lib/app/questionnaire/panel/{types,answer-panel,confidence}.ts`) Prisma-free; the `_lib/answer-panel.ts` seam does one query incl. a turn-id→ordinal map for `answeredAtTurnIndex`; authoring internals (`weight`, tags, config) never projected. Confidence reads as a quiet semantic dot (high/moderate/low/unscored, the admin eval-chip thresholds), never a raw number. **Tests:** pure builder + confidence bands (unit); the answers route (gate order incl. flag-before-load, both access modes, scope projection, no-leak) at integration; panel/indicator/provenance/history component tests + the `use-answer-panel` hook + the `onTurnSettled` wiring; schema-shape + config-schema extended. `npm run validate` green; 3861 affected tests pass. **No CHANGELOG entry** (app surface). Tracker: `planning/features/f7.2.md`. Docs: `answer-slot-panel.md` + `configuration.md`. diff --git a/.context/app/questionnaire/demo-clients.md b/.context/app/questionnaire/demo-clients.md index b4eea4d1..7060cca9 100644 --- a/.context/app/questionnaire/demo-clients.md +++ b/.context/app/questionnaire/demo-clients.md @@ -115,7 +115,16 @@ a typed-confirmation guard and an anonymous-mode refusal. See while attributed). - Both forms carry an **"Invitation branding"** fieldset (F3.4): CTA colour, accent colour, logo URL, welcome copy — each optional with a ``; blank = the - Sunrise default. + Sunrise default. The edit form shows a **live ``** under the + fieldset (valid inputs only — a half-typed hex shows the default, not a broken + swatch). +- **Brand preview (``).** Surfaces the configured brand back + to the admin — the gap that a client could set four theme fields and see nothing. + Reuses `resolveTheme()` and the same escaped `--app-logo-url` background as + `BrandThemeProvider` (never a raw ``). Two modes: **compact** on the list's + _Branding_ column (a swatch/thumbnail only for fields actually set; "Default" when + none) and **full** on the detail page / live form preview (the resolved brand the + respondent sees, defaults filled). - The questionnaire detail page (`/admin/questionnaires/:id`) carries the attribution `` picker (active clients + the current one). diff --git a/app/admin/demo-clients/[id]/page.tsx b/app/admin/demo-clients/[id]/page.tsx index 518e364f..6fd68460 100644 --- a/app/admin/demo-clients/[id]/page.tsx +++ b/app/admin/demo-clients/[id]/page.tsx @@ -14,6 +14,7 @@ import { ChevronLeft } from 'lucide-react'; import { DemoClientForm } from '@/components/admin/demo-clients/demo-client-form'; import { DemoClientActions } from '@/components/admin/demo-clients/demo-client-actions'; +import { DemoClientThemePreview } from '@/components/admin/demo-clients/demo-client-theme-preview'; import { API } from '@/lib/api/endpoints'; import { parseApiResponse, serverFetch } from '@/lib/api/server-fetch'; import { logger } from '@/lib/logging'; @@ -71,6 +72,13 @@ export default async function DemoClientDetailPage({ params }: PageProps) { /> + {/* DEMO-ONLY (F3.4 gap-fill): the resolved brand a respondent will see, from the + saved values. Blank fields fall back to the Sunrise defaults. */} +
+

Brand preview

+ +
+ ); diff --git a/components/admin/demo-clients/demo-client-form.tsx b/components/admin/demo-clients/demo-client-form.tsx index 47ed8bff..fc9bb256 100644 --- a/components/admin/demo-clients/demo-client-form.tsx +++ b/components/admin/demo-clients/demo-client-form.tsx @@ -27,6 +27,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { FieldHelp } from '@/components/ui/field-help'; import { FormError } from '@/components/forms/form-error'; +import { DemoClientThemePreview } from '@/components/admin/demo-clients/demo-client-theme-preview'; import { DEMO_CLIENT_SLUG_MAX_LENGTH, DEMO_CLIENT_SLUG_PATTERN, @@ -103,6 +104,21 @@ export function DemoClientForm({ client }: DemoClientFormProps) { const isActive = watch('isActive'); + // Live brand preview: reflect only valid inputs (a half-typed hex / non-https URL + // shows the default rather than a broken swatch); blank → null → Sunrise default. + const [ctaColor, accentColor, logoUrl, welcomeCopy] = watch([ + 'ctaColor', + 'accentColor', + 'logoUrl', + 'welcomeCopy', + ]); + const livePreviewTheme = { + ctaColor: HEX_COLOR_PATTERN.test(ctaColor.trim()) ? ctaColor.trim() : null, + accentColor: HEX_COLOR_PATTERN.test(accentColor.trim()) ? accentColor.trim() : null, + logoUrl: isHttpsUrl(logoUrl.trim()) ? logoUrl.trim() : null, + welcomeCopy: welcomeCopy.trim() === '' ? null : welcomeCopy.trim(), + }; + const onSubmit = async (values: FormValues) => { setIsLoading(true); setError(null); @@ -286,6 +302,11 @@ export function DemoClientForm({ client }: DemoClientFormProps) { /> + +
+

Preview

+ +
{error && ( diff --git a/components/admin/demo-clients/demo-client-theme-preview.tsx b/components/admin/demo-clients/demo-client-theme-preview.tsx new file mode 100644 index 00000000..b0762b06 --- /dev/null +++ b/components/admin/demo-clients/demo-client-theme-preview.tsx @@ -0,0 +1,126 @@ +/** + * DEMO-ONLY (F3.4 gap-fill): visual preview of a demo client's configured brand. + * + * An admin sets four theme fields on a demo client (CTA colour, accent colour, logo + * URL, welcome copy) but the admin UI never showed them back — there was no visual + * confirmation of what the prospect will see. This renders that brand: colour swatches, + * a logo thumbnail, and (in full mode) the welcome copy. + * + * Reuses the theming module rather than re-deriving anything: `resolveTheme()` fills + * nulls with the Sunrise defaults, and the logo thumbnail uses the same escaped + * `--app-logo-url` background approach as {@link BrandThemeProvider} (never a raw + * ``), keeping that sink's CSS-injection hardening. + * + * Two modes: + * - `compact` (table rows): show a swatch / thumbnail only for fields the client has + * *actually configured* (non-null) — "once they've been configured"; an unthemed + * client renders a muted "Default". + * - full (detail page): the resolved brand the respondent will see — both colour + * swatches with their hex, the logo (or "No logo"), and the welcome copy. + * + * Pure presentational, no client-only APIs, so it renders in both the server detail + * page and the `'use client'` table/form. A fork that strips demo tenancy drops it. + */ + +import type { CSSProperties } from 'react'; + +import { cn } from '@/lib/utils'; +import { + resolveTheme, + themeToCssVariables, + type DemoClientTheme, +} from '@/lib/app/questionnaire/theming'; + +interface DemoClientThemePreviewProps { + /** The four nullable theme columns (a `DemoClientView` is structurally compatible). */ + theme: DemoClientTheme; + /** Table-row variant: configured fields only, no labels. */ + compact?: boolean; + className?: string; +} + +function Swatch({ color, label, compact }: { color: string; label?: string; compact?: boolean }) { + return ( + + + {label && {label}} + + ); +} + +function LogoThumb({ logoUrl, compact }: { logoUrl: string; compact?: boolean }) { + // Reuse the escaped url("…") the theming sink produces, applied as a background so a + // hostile stored value can't break out of url() (mirrors BrandThemeProvider). + const style = themeToCssVariables({ + ctaColor: '', + accentColor: '', + logoUrl, + welcomeCopy: '', + }) as CSSProperties; + return ( + + ); +} + +export function DemoClientThemePreview({ + theme, + compact = false, + className, +}: DemoClientThemePreviewProps) { + const configured = + theme.ctaColor !== null || + theme.accentColor !== null || + theme.logoUrl !== null || + theme.welcomeCopy !== null; + + // Compact (table): show only what the admin actually configured. + if (compact) { + if (!configured) { + return Default; + } + return ( + + {theme.ctaColor && } + {theme.accentColor && } + {theme.logoUrl && } + {!theme.ctaColor && !theme.accentColor && !theme.logoUrl && ( + // Only welcome copy is set — nothing visual to swatch. + Welcome copy + )} + + ); + } + + // Full (detail / live preview): the resolved brand the respondent will see. + const resolved = resolveTheme(theme); + return ( +
+
+ + + + {resolved.logoUrl ? ( + + ) : ( + No logo + )} + +
+

“{resolved.welcomeCopy}”

+ {!configured && ( +

+ Nothing configured — these are the Sunrise defaults. +

+ )} +
+ ); +} diff --git a/components/admin/demo-clients/demo-clients-table.tsx b/components/admin/demo-clients/demo-clients-table.tsx index cd868d94..4e2304d2 100644 --- a/components/admin/demo-clients/demo-clients-table.tsx +++ b/components/admin/demo-clients/demo-clients-table.tsx @@ -19,6 +19,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { DemoClientThemePreview } from '@/components/admin/demo-clients/demo-client-theme-preview'; import type { DemoClientView } from '@/lib/app/questionnaire/demo-clients'; function formatDate(iso: string): string { @@ -43,6 +44,7 @@ export function DemoClientsTable({ clients }: DemoClientsTableProps) { Name Slug + Branding Status Questionnaires Created @@ -51,7 +53,7 @@ export function DemoClientsTable({ clients }: DemoClientsTableProps) { {clients.length === 0 ? ( - + No demo clients yet. Create one to attribute questionnaires to a prospect. @@ -66,6 +68,9 @@ export function DemoClientsTable({ clients }: DemoClientsTableProps) { {client.slug} + + + {client.isActive ? 'Active' : 'Inactive'} diff --git a/tests/unit/components/admin/demo-clients/demo-client-theme-preview.test.tsx b/tests/unit/components/admin/demo-clients/demo-client-theme-preview.test.tsx new file mode 100644 index 00000000..c6fe12ad --- /dev/null +++ b/tests/unit/components/admin/demo-clients/demo-client-theme-preview.test.tsx @@ -0,0 +1,109 @@ +/** + * DemoClientThemePreview — admin-facing visual preview of a demo client's brand. + * + * compact mode shows only configured fields ("once they've been configured"); full + * mode shows the resolved brand (defaults filled). Logo uses the escaped + * `--app-logo-url` background, never a raw . + * + * @see components/admin/demo-clients/demo-client-theme-preview.tsx + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { DemoClientThemePreview } from '@/components/admin/demo-clients/demo-client-theme-preview'; +import { SUNRISE_THEME_DEFAULTS, type DemoClientTheme } from '@/lib/app/questionnaire/theming'; + +const UNCONFIGURED: DemoClientTheme = { + ctaColor: null, + accentColor: null, + logoUrl: null, + welcomeCopy: null, +}; + +function logoBox(container: HTMLElement): HTMLElement | null { + return container.querySelector('[aria-label="Brand logo"]'); +} + +describe('DemoClientThemePreview — compact (table)', () => { + it('renders a muted "Default" when nothing is configured', () => { + render(); + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('renders a swatch for each configured colour and the logo thumbnail', () => { + const { container } = render( + + ); + // two colour swatches (bordered round spans) carry the raw hex as a background + const swatches = container.querySelectorAll('span[style*="background-color"]'); + expect(swatches).toHaveLength(2); + expect((swatches[0] as HTMLElement).style.backgroundColor).toBe('#112233'); + + const logo = logoBox(container); + expect(logo).not.toBeNull(); + expect((logo as HTMLElement).style.getPropertyValue('--app-logo-url')).toBe( + 'url("https://example.com/logo.png")' + ); + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + }); + + it('shows a "Welcome copy" hint when only welcome copy is configured (nothing to swatch)', () => { + const { container } = render( + + ); + expect(screen.getByText('Welcome copy')).toBeInTheDocument(); + expect(container.querySelectorAll('span[style*="background-color"]')).toHaveLength(0); + expect(logoBox(container)).toBeNull(); + }); +}); + +describe('DemoClientThemePreview — full (detail / live preview)', () => { + it('renders the resolved cta/accent hex and welcome copy, with "No logo" when unset', () => { + render(); + + // Sunrise defaults fill the gaps. + expect(screen.getByText(`CTA ${SUNRISE_THEME_DEFAULTS.ctaColor}`)).toBeInTheDocument(); + expect(screen.getByText(`Accent ${SUNRISE_THEME_DEFAULTS.accentColor}`)).toBeInTheDocument(); + expect(screen.getByText(`“${SUNRISE_THEME_DEFAULTS.welcomeCopy}”`)).toBeInTheDocument(); + expect(screen.getByText('No logo')).toBeInTheDocument(); + expect( + screen.getByText('Nothing configured — these are the Sunrise defaults.') + ).toBeInTheDocument(); + }); + + it('renders the configured logo thumbnail and drops the defaults hint', () => { + const { container } = render( + + ); + expect(screen.getByText('CTA #abcdef')).toBeInTheDocument(); + // accentColor null → resolved to the Sunrise default + expect(screen.getByText(`Accent ${SUNRISE_THEME_DEFAULTS.accentColor}`)).toBeInTheDocument(); + expect(screen.getByText('“Welcome aboard”')).toBeInTheDocument(); + expect((logoBox(container) as HTMLElement).style.getPropertyValue('--app-logo-url')).toBe( + 'url("https://example.com/brand.svg")' + ); + expect(screen.queryByText('No logo')).not.toBeInTheDocument(); + expect( + screen.queryByText('Nothing configured — these are the Sunrise defaults.') + ).not.toBeInTheDocument(); + }); +}); From 4a527d7e3a7424cbb7b75ccf7ecd2808f659407a Mon Sep 17 00:00:00 2001 From: John Durrant Date: Sun, 7 Jun 2026 17:17:19 +0100 Subject: [PATCH 2/8] fix(app): append refinementHistory in the live turn loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live per-turn persistence (persistTurn) wrote a refined answer's corrected value via the upsert seam but never appended to AppAnswerSlot.refinementHistory — only the F4.4 preview route did. Real respondent sessions therefore silently dropped the "evolved across turns" provenance trail that P8 analytics/exports read. persistTurn now takes the full F4.4 path for refinements: loadAnswerSlot → applyRefinement → persistRefinement, so the history grows with the pre-change value/provenance/source. A refinement targeting a slot with no captured answer (shouldn't happen) falls back to a plain refined-provenance upsert rather than skipping or throwing. Item 2 of the deferred-gaps plan. App surface — no CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .context/app/planning/features/f6.1.md | 7 +- .../questionnaire/per-turn-orchestrator.md | 6 +- .../questionnaire-sessions/_lib/turn-run.ts | 38 ++++-- .../questionnaire-sessions/turn-run.test.ts | 111 +++++++++++++++++- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/.context/app/planning/features/f6.1.md b/.context/app/planning/features/f6.1.md index 2829635f..a4c5bd84 100644 --- a/.context/app/planning/features/f6.1.md +++ b/.context/app/planning/features/f6.1.md @@ -79,8 +79,11 @@ event) and of `AppQuestionnaireTurn` (firing `AppAnswerSlot.lastUpdatedTurnId`). - **Attachment input** (extraction over an uploaded image/doc) — needs extending the F4.2 extraction capability to accept content parts; tracked for F6.1 PR8 / a follow-up. -- **Full refinementHistory append** on refine persistence (PR4 persists the corrected value - via the upsert seam; the history append through `persistRefinement` is a follow-up). +- ~~**Full refinementHistory append** on refine persistence (PR4 persists the corrected value + via the upsert seam; the history append through `persistRefinement` is a follow-up).~~ + **Closed 2026-06-07** (deferred-gaps audit, Item 2): `persistTurn` now takes the full F4.4 + path (`loadAnswerSlot` → `applyRefinement` → `persistRefinement`), so live sessions append + to `refinementHistory`. - **Aggregate per-turn token usage** on the `done` event (the capabilities log their own `AiCostLog` rows; the turn record carries summed offer cost only). - **Admin-side anonymous redaction** (`anonId`, redacted session views) — a later phase. diff --git a/.context/app/questionnaire/per-turn-orchestrator.md b/.context/app/questionnaire/per-turn-orchestrator.md index e267f1af..6e2e76c4 100644 --- a/.context/app/questionnaire/per-turn-orchestrator.md +++ b/.context/app/questionnaire/per-turn-orchestrator.md @@ -53,7 +53,11 @@ question prompts are deterministic). cost; the route delegates with `yield*`. - **Persistence** `_lib/turn-run.ts` (`persistTurn`) writes answer side-effects through the F4.4 slot seam, then `recordTurn` (firing `lastUpdatedTurnId`). A post-response write - failure is logged, not retro-failed onto the streamed reply. + failure is logged, not retro-failed onto the streamed reply. Refinements take the F4.4 + path in full — `loadAnswerSlot` → `applyRefinement` → `persistRefinement` — so a live + session **appends to `refinementHistory`** (the corrected value plus the pre-change + value/provenance/source), not just the new value. A refinement targeting a slot with no + captured answer (shouldn't happen) falls back to a plain `refined`-provenance upsert. ## Routes & access diff --git a/app/api/v1/app/questionnaire-sessions/_lib/turn-run.ts b/app/api/v1/app/questionnaire-sessions/_lib/turn-run.ts index 62d127c8..7471cb59 100644 --- a/app/api/v1/app/questionnaire-sessions/_lib/turn-run.ts +++ b/app/api/v1/app/questionnaire-sessions/_lib/turn-run.ts @@ -8,8 +8,13 @@ import type { AnswerSlotIntent } from '@/lib/app/questionnaire/extraction/types'; import type { RefinementDecision } from '@/lib/app/questionnaire/refinement/types'; +import { applyRefinement } from '@/lib/app/questionnaire/refinement'; import type { ToolCallRecord } from '@/lib/app/questionnaire/orchestrator'; -import { upsertAnswerSlot } from '@/app/api/v1/app/questionnaires/_lib/answer-slots'; +import { + loadAnswerSlot, + persistRefinement, + upsertAnswerSlot, +} from '@/app/api/v1/app/questionnaires/_lib/answer-slots'; import { recordTurn } from '@/app/api/v1/app/questionnaires/_lib/turns'; /** @@ -46,15 +51,28 @@ export async function persistTurn(opts: { for (const decision of opts.refinements) { const slotId = opts.keyToSlotId.get(decision.slotKey); if (!slotId) continue; - // PR4 persists the corrected value with `refined` provenance via the upsert seam; the - // full refinementHistory append (persistRefinement) is a follow-up. - const id = await upsertAnswerSlot(opts.sessionId, slotId, { - value: decision.newValue, - provenance: 'refined', - rationale: decision.rationale, - confidence: decision.confidence, - }); - if (!sideEffectAnswerIds.includes(id)) sideEffectAnswerIds.push(id); + // Mirror the F4.4 refine-answer route: load the existing answer, merge it via the + // pure `applyRefinement`, and write the new value + provenance + the *appended* + // refinementHistory back. The live loop previously persisted only the corrected + // value (history append was an F6.1 follow-up), so real sessions silently dropped + // the "evolved across turns" audit trail the preview route keeps. + const loaded = await loadAnswerSlot(opts.sessionId, slotId); + if (loaded) { + const refined = applyRefinement(loaded.existing, decision); + await persistRefinement(loaded.id, refined); + if (!sideEffectAnswerIds.includes(loaded.id)) sideEffectAnswerIds.push(loaded.id); + } else { + // Defensive: a refinement targeting a slot with no captured answer (shouldn't + // happen — refinement acts on prior answers). Fall back to a plain upsert so the + // value isn't lost, rather than skip it or throw. + const id = await upsertAnswerSlot(opts.sessionId, slotId, { + value: decision.newValue, + provenance: 'refined', + rationale: decision.rationale, + confidence: decision.confidence, + }); + if (!sideEffectAnswerIds.includes(id)) sideEffectAnswerIds.push(id); + } } return recordTurn({ diff --git a/tests/integration/api/v1/app/questionnaire-sessions/turn-run.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/turn-run.test.ts index 24f3329d..baf0871a 100644 --- a/tests/integration/api/v1/app/questionnaire-sessions/turn-run.test.ts +++ b/tests/integration/api/v1/app/questionnaire-sessions/turn-run.test.ts @@ -8,18 +8,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const seamMock = vi.hoisted(() => ({ upsertAnswerSlot: vi.fn(), recordTurn: vi.fn() })); +const seamMock = vi.hoisted(() => ({ + upsertAnswerSlot: vi.fn(), + loadAnswerSlot: vi.fn(), + persistRefinement: vi.fn(), + recordTurn: vi.fn(), +})); vi.mock('@/app/api/v1/app/questionnaires/_lib/answer-slots', () => ({ upsertAnswerSlot: seamMock.upsertAnswerSlot, + loadAnswerSlot: seamMock.loadAnswerSlot, + persistRefinement: seamMock.persistRefinement, })); vi.mock('@/app/api/v1/app/questionnaires/_lib/turns', () => ({ recordTurn: seamMock.recordTurn })); import { persistTurn } from '@/app/api/v1/app/questionnaire-sessions/_lib/turn-run'; import type { AnswerSlotIntent } from '@/lib/app/questionnaire/extraction/types'; -import type { RefinementDecision } from '@/lib/app/questionnaire/refinement/types'; +import type { + ExistingAnswerView, + RefinementDecision, +} from '@/lib/app/questionnaire/refinement/types'; type Mock = ReturnType; +/** A loaded answer row, shaped as the answer-slots seam returns it. */ +const loaded = (id: string, existing: Partial = {}) => ({ + id, + existing: { + slotKey: 'role', + value: 'first', + provenance: 'direct' as const, + confidence: 0.9, + refinementHistory: [], + ...existing, + }, +}); + const intent = (slotKey: string, value: unknown): AnswerSlotIntent => ({ slotKey, questionType: 'free_text', @@ -43,6 +66,8 @@ const decision = (slotKey: string, newValue: unknown): RefinementDecision => ({ beforeEach(() => { vi.clearAllMocks(); (seamMock.recordTurn as Mock).mockResolvedValue('turn-1'); + // Default: no existing answer for a refinement (overridden per-test). + (seamMock.loadAnswerSlot as Mock).mockResolvedValue(null); }); describe('persistTurn', () => { @@ -95,9 +120,12 @@ describe('persistTurn', () => { expect(seamMock.recordTurn).toHaveBeenCalledWith(expect.objectContaining({ costUsd: null })); }); - it('persists a refinement with refined provenance and dedupes a doubly-touched slot', async () => { - // The same slot is both upserted and refined → one id, not two. + it('refines through persistRefinement and dedupes a doubly-touched slot', async () => { + // The same slot is both upserted and refined → loadAnswerSlot resolves to the + // upserted row, persistRefinement writes it back, and the id appears once. (seamMock.upsertAnswerSlot as Mock).mockResolvedValue('ans-q1'); + (seamMock.loadAnswerSlot as Mock).mockResolvedValue(loaded('ans-q1')); + await persistTurn({ sessionId: 'sess-1', userMessage: 'm', @@ -109,7 +137,78 @@ describe('persistTurn', () => { refinements: [decision('role', 'corrected')], keyToSlotId: new Map([['role', 'slot-q1']]), }); - expect(seamMock.upsertAnswerSlot).toHaveBeenLastCalledWith( + + expect(seamMock.loadAnswerSlot).toHaveBeenCalledWith('sess-1', 'slot-q1'); + expect(seamMock.persistRefinement).toHaveBeenCalledWith( + 'ans-q1', + expect.objectContaining({ value: 'corrected', provenance: 'refined' }) + ); + expect(seamMock.recordTurn).toHaveBeenCalledWith( + expect.objectContaining({ sideEffectAnswerIds: ['ans-q1'] }) + ); + }); + + it('appends the prior value to refinementHistory on a live refinement (the gap this closes)', async () => { + // An existing answer with one prior history entry; a refine should grow it to two, + // capturing the pre-change value/provenance + the decision source. + (seamMock.loadAnswerSlot as Mock).mockResolvedValue( + loaded('ans-q1', { + value: 'marketing', + provenance: 'direct', + refinementHistory: [ + { + previousValue: null, + previousProvenance: 'direct', + newValue: 'marketing', + rationale: 'initial capture', + source: 'correction', + }, + ], + }) + ); + + await persistTurn({ + sessionId: 'sess-1', + userMessage: 'actually, sales', + agentResponse: 'r', + targetedQuestionId: null, + toolCalls: [], + costUsd: 0, + upserts: [], + refinements: [decision('role', 'sales')], + keyToSlotId: new Map([['role', 'slot-q1']]), + }); + + expect(seamMock.upsertAnswerSlot).not.toHaveBeenCalled(); + const [, refined] = (seamMock.persistRefinement as Mock).mock.calls[0]; + expect(refined.refinementHistory).toHaveLength(2); + expect(refined.refinementHistory[1]).toMatchObject({ + previousValue: 'marketing', + previousProvenance: 'direct', + newValue: 'sales', + source: 'contradiction', + }); + }); + + it('falls back to a plain upsert when the refined slot has no existing answer', async () => { + // Defensive path: loadAnswerSlot returns null (default) → value is persisted via + // upsert with `refined` provenance rather than skipped or thrown. + (seamMock.upsertAnswerSlot as Mock).mockResolvedValue('ans-q1'); + + await persistTurn({ + sessionId: 'sess-1', + userMessage: 'm', + agentResponse: 'r', + targetedQuestionId: null, + toolCalls: [], + costUsd: 0, + upserts: [], + refinements: [decision('role', 'corrected')], + keyToSlotId: new Map([['role', 'slot-q1']]), + }); + + expect(seamMock.persistRefinement).not.toHaveBeenCalled(); + expect(seamMock.upsertAnswerSlot).toHaveBeenCalledWith( 'sess-1', 'slot-q1', expect.objectContaining({ value: 'corrected', provenance: 'refined' }) @@ -131,6 +230,8 @@ describe('persistTurn', () => { refinements: [decision('stale', 'x')], keyToSlotId: new Map([['role', 'slot-q1']]), }); + expect(seamMock.loadAnswerSlot).not.toHaveBeenCalled(); + expect(seamMock.persistRefinement).not.toHaveBeenCalled(); expect(seamMock.upsertAnswerSlot).not.toHaveBeenCalled(); expect(seamMock.recordTurn).toHaveBeenCalledWith( expect.objectContaining({ sideEffectAnswerIds: [] }) From 68a6832911ce60d29517996c248220b0aa6419c1 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Sun, 7 Jun 2026 17:22:50 +0100 Subject: [PATCH 3/8] feat(app): claim a questionnaire invite via existing login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an invited email already had an account, accept returned a dead-end 409 ACCOUNT_EXISTS (claim-via-login was deferred to P7 and never built). Now an existing email claims the invitation by signing in: the supplied password is verified via signInEmail (a wrong one is 401 INVALID_CREDENTIALS, binding nothing) and the invitation binds to that existing account. Binding moved to after sign-in so a failed credential never half-registers. metadata now reports accountExists so the landing form asks for the existing password ("sign in to claim") instead of offering to set a new one. Security: accountExists is disclosed only to a valid token-holder for their invited email; password attempts are bounded by acceptInviteLimiter + the better-auth sign-in limiter; binding requires a verified sign-in. Item 3 of the deferred-gaps plan. App surface — no CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .context/app/planning/features/f3.2.md | 7 +- .context/app/questionnaire/invitations.md | 22 ++-- app/api/v1/app/invitations/accept/route.ts | 110 ++++++++++-------- app/api/v1/app/invitations/metadata/route.ts | 9 ++ .../forms/questionnaire-invite-form.tsx | 61 ++++++---- lib/app/questionnaire/invitations/views.ts | 6 + .../v1/app/invitations/public-routes.test.ts | 51 ++++++-- .../forms/questionnaire-invite-form.test.tsx | 76 ++++++++++++ 8 files changed, 256 insertions(+), 86 deletions(-) create mode 100644 tests/unit/components/forms/questionnaire-invite-form.test.tsx diff --git a/.context/app/planning/features/f3.2.md b/.context/app/planning/features/f3.2.md index 2c414f47..ac1d0719 100644 --- a/.context/app/planning/features/f3.2.md +++ b/.context/app/planning/features/f3.2.md @@ -179,5 +179,8 @@ the development-plan F3.2 status to done and add a decisions-log entry. `started`/`completed` transitions (P6/P7 sessions). Demo-client invitation branding + `demoClientId` denormalisation + themed email (**F3.4**). Pre-launch cost estimation -(**F3.3**). Claim-an-invite-via-existing-login (deferred; returns `409 ACCOUNT_EXISTS`). -Anonymous-mode session entry (P6/P7). `questionnaireId` denormalisation on the invitation. +(**F3.3**). ~~Claim-an-invite-via-existing-login (deferred; returns `409 ACCOUNT_EXISTS`).~~ +**Closed 2026-06-07** (deferred-gaps audit, Item 3): an already-registered email now +claims the invitation by signing in (verified via `signInEmail`, `401` on a wrong +password), bound to the existing account; `metadata` reports `accountExists` to drive the +form branch. Anonymous-mode session entry (P6/P7). `questionnaireId` denormalisation on the invitation. diff --git a/.context/app/questionnaire/invitations.md b/.context/app/questionnaire/invitations.md index 6a62ddb5..704bdef2 100644 --- a/.context/app/questionnaire/invitations.md +++ b/.context/app/questionnaire/invitations.md @@ -100,14 +100,20 @@ the request — the response is a per-recipient result array (`sent`/`skipped`/` Public, token-gated (F3.2 PR2 — no auth guard, rate-limited): -| Route | Method | Purpose | -| ---------------------------------- | ------ | ------------------------------------------------------------------------------ | -| `/api/v1/app/invitations/metadata` | GET | Validate token → `{ questionnaireTitle, inviteeName, status }`, mark `opened`. | -| `/api/v1/app/invitations/accept` | POST | Register (better-auth `signUpEmail`) + bind `userId` → `registered`. | - -Accept reuses the platform `accept-invite` machinery (sign-up → set `emailVerified` -→ sign-in → forward Set-Cookie for auto-login). An already-registered email returns -`409 ACCOUNT_EXISTS` (claim-via-login deferred to P7). +| Route | Method | Purpose | +| ---------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- | +| `/api/v1/app/invitations/metadata` | GET | Validate token → `{ questionnaireTitle, inviteeName, status, accountExists }`, mark `opened`. | +| `/api/v1/app/invitations/accept` | POST | New email: register (`signUpEmail`) + bind. Existing email: sign-in-and-claim + bind → `registered`. | + +Accept reuses the platform `accept-invite` machinery (set `emailVerified` → sign-in → +forward Set-Cookie for auto-login). **A fresh email** registers a new account. **An +already-registered email claims the invitation by signing in** — the supplied password +is verified via `signInEmail` (a wrong one is `401 INVALID_CREDENTIALS`, binding +nothing) and the invitation is bound to that existing account; binding happens _after_ +sign-in so a failed credential never half-registers. The metadata route reports +`accountExists` so the landing form asks for the existing password ("sign in to claim") +instead of offering to set a new one. _(Closed 2026-06-07, deferred-gaps audit Item 3 — +was the P7-deferred `409 ACCOUNT_EXISTS` dead-end.)_ ## Admin UI diff --git a/app/api/v1/app/invitations/accept/route.ts b/app/api/v1/app/invitations/accept/route.ts index 7fc1e944..4fe9f357 100644 --- a/app/api/v1/app/invitations/accept/route.ts +++ b/app/api/v1/app/invitations/accept/route.ts @@ -9,9 +9,11 @@ * and forward the session cookies so the respondent is logged in. * * The email is taken from the invitation row, not the request — the token is the - * sole credential. An already-registered email returns `409 ACCOUNT_EXISTS` - * (claim-via-login is deferred to P7). Flag-gate first; `acceptInviteLimiter` - * sub-cap guards against token brute force. + * sole credential. If the invited email **already has an account**, the respondent + * claims the invitation by signing in: the supplied password is verified (a wrong one + * is `401 INVALID_CREDENTIALS`) and the invitation is bound to that existing account — + * no second account, no dead-end. Flag-gate first; `acceptInviteLimiter` sub-cap guards + * against token / password brute force. */ import type { NextRequest } from 'next/server'; @@ -83,63 +85,62 @@ async function acceptInvitation( }); } - // The account is keyed on the invitation's email — an existing account can't be - // claimed through this flow (deferred to P7). + // The account is keyed on the invitation's email. A fresh email registers a new + // account; an existing one claims the invitation by signing in (verified below). const existing = await prisma.user.findUnique({ where: { email: invitation.email }, select: { id: true }, }); - if (existing) { - return errorResponse('An account already exists for this email — please sign in', { - code: 'ACCOUNT_EXISTS', - status: 409, - }); - } - // 1. Create the user (better-auth, scrypt-hashed password). - let newUserId: string; - try { - const signup = await auth.api.signUpEmail({ - body: { - name: body.name ?? invitation.name ?? invitation.email, - email: invitation.email, - password: body.password, - }, - }); - newUserId = signup.user.id; - } catch (err) { - // A racing signup (email taken between the check and here) lands here too. - log.warn('Invitation signup failed', { invitationId: invitation.id, error: String(err) }); - return errorResponse('Could not create your account — it may already exist', { - code: 'ACCOUNT_EXISTS', - status: 409, - }); + let userId: string; + if (existing) { + // Claim-via-existing-login: bind to the existing account. The password is proven + // by the sign-in step below (which also issues the session), so we don't create or + // mutate the user here. + userId = existing.id; + } else { + // 1. Create the user (better-auth, scrypt-hashed password). + try { + const signup = await auth.api.signUpEmail({ + body: { + name: body.name ?? invitation.name ?? invitation.email, + email: invitation.email, + password: body.password, + }, + }); + userId = signup.user.id; + } catch (err) { + // A racing signup (email taken between the check and here) lands here too. + log.warn('Invitation signup failed', { invitationId: invitation.id, error: String(err) }); + return errorResponse('Could not create your account — it may already exist', { + code: 'ACCOUNT_EXISTS', + status: 409, + }); + } + // 2. Accepting the invitation proves email ownership (fresh account only). + await prisma.user.update({ where: { id: userId }, data: { emailVerified: true } }); } - // 2. Accepting the invitation proves email ownership. - await prisma.user.update({ where: { id: newUserId }, data: { emailVerified: true } }); - - // 3. Bind the invitation to the new account: registered. - await prisma.appQuestionnaireInvitation.update({ - where: { id: invitation.id }, - data: { - userId: newUserId, - status: 'registered', - registeredAt: new Date(), - ...(invitation.openedAt ? {} : { openedAt: new Date() }), - }, - }); - log.info('Invitation registered', { invitationId: invitation.id, userId: newUserId }); - - // 4. Sign in and forward the session cookies (auto-login), as the platform flow does. + // 3. Sign in and forward the session cookies (auto-login), as the platform flow does. + // For an existing account this is also the credential check — a wrong password is + // the respondent's failure mode, surfaced as 401 so nothing gets bound. const signInResponse = await auth.api.signInEmail({ body: { email: invitation.email, password: body.password }, asResponse: true, }); if (!signInResponse.ok) { + if (existing) { + log.warn('Invitation claim sign-in failed (bad credentials)', { + invitationId: invitation.id, + }); + return errorResponse( + 'That password is incorrect. Enter the password for your existing account to claim this invitation.', + { code: 'INVALID_CREDENTIALS', status: 401 } + ); + } log.error('Sign-in after invitation accept failed', undefined, { invitationId: invitation.id, - userId: newUserId, + userId, }); return errorResponse('Account created but sign-in failed — please log in', { code: 'INTERNAL_ERROR', @@ -147,6 +148,23 @@ async function acceptInvitation( }); } + // 4. Bind the invitation to the account: registered. After sign-in, so a wrong + // password on the claim path never binds. + await prisma.appQuestionnaireInvitation.update({ + where: { id: invitation.id }, + data: { + userId, + status: 'registered', + registeredAt: new Date(), + ...(invitation.openedAt ? {} : { openedAt: new Date() }), + }, + }); + log.info('Invitation registered', { + invitationId: invitation.id, + userId, + claimed: Boolean(existing), + }); + const response = successResponse({ message: 'Registered. Redirecting…' }, undefined, { status: 200, headers: getRateLimitHeaders(rateLimit), diff --git a/app/api/v1/app/invitations/metadata/route.ts b/app/api/v1/app/invitations/metadata/route.ts index b5d95b50..cb886cfc 100644 --- a/app/api/v1/app/invitations/metadata/route.ts +++ b/app/api/v1/app/invitations/metadata/route.ts @@ -68,11 +68,20 @@ async function readMetadata( log.info('Invitation opened', { invitationId: invitation.id }); } + // Whether the invited email already has an account — drives the "sign in to claim" + // vs "set a password" branch on the landing form. Safe to disclose to a valid token + // holder (they were invited to this exact email). + const account = await prisma.user.findUnique({ + where: { email: invitation.email }, + select: { id: true }, + }); + const view: InvitationLandingView = { questionnaireTitle: invitation.questionnaireTitle, inviteeName: invitation.name, status, expiresAt: invitation.expiresAt.toISOString(), + accountExists: account !== null, }; return successResponse(view); } diff --git a/components/forms/questionnaire-invite-form.tsx b/components/forms/questionnaire-invite-form.tsx index 6413690f..a24b181d 100644 --- a/components/forms/questionnaire-invite-form.tsx +++ b/components/forms/questionnaire-invite-form.tsx @@ -85,16 +85,27 @@ export function QuestionnaireInviteForm() { }; }, [token]); + // An invited email that already has an account *claims* the invitation by signing + // in with its existing password — no new account, no second password to set. + const claiming = state.kind === 'valid' && state.landing.accountExists; + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); setError(null); - if (password.length < 8) { - setError('Password must be at least 8 characters.'); - return; - } - if (password !== confirm) { - setError('Passwords do not match.'); - return; + if (claiming) { + if (password.length === 0) { + setError('Enter the password for your existing account.'); + return; + } + } else { + if (password.length < 8) { + setError('Password must be at least 8 characters.'); + return; + } + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } } setSubmitting(true); @@ -143,39 +154,43 @@ export function QuestionnaireInviteForm() {
void handleSubmit(e)} className="space-y-4">

You've been invited to complete {state.landing.questionnaireTitle} - {state.landing.inviteeName ? `, ${state.landing.inviteeName}` : ''}. Set a password to - register and begin. + {state.landing.inviteeName ? `, ${state.landing.inviteeName}` : ''}.{' '} + {claiming + ? 'You already have an account — sign in with your password to claim this invitation and begin.' + : 'Set a password to register and begin.'}

- + setPassword(e.target.value)} - autoComplete="new-password" + autoComplete={claiming ? 'current-password' : 'new-password'} disabled={submitting} required />
-
- - setConfirm(e.target.value)} - autoComplete="new-password" - disabled={submitting} - required - /> -
+ {!claiming && ( +
+ + setConfirm(e.target.value)} + autoComplete="new-password" + disabled={submitting} + required + /> +
+ )} {error && } ); diff --git a/lib/app/questionnaire/invitations/views.ts b/lib/app/questionnaire/invitations/views.ts index 0d367de8..c95394aa 100644 --- a/lib/app/questionnaire/invitations/views.ts +++ b/lib/app/questionnaire/invitations/views.ts @@ -46,4 +46,10 @@ export interface InvitationLandingView { inviteeName: string | null; status: AppInvitationStatus; expiresAt: string; + /** + * True when the invited email already has an account. The landing form uses it to + * ask for the existing password ("sign in to claim") instead of offering to set a + * new one. Disclosed only to a valid token holder, who was invited to that email. + */ + accountExists: boolean; } diff --git a/tests/integration/api/v1/app/invitations/public-routes.test.ts b/tests/integration/api/v1/app/invitations/public-routes.test.ts index cfa62eed..9d4c53ec 100644 --- a/tests/integration/api/v1/app/invitations/public-routes.test.ts +++ b/tests/integration/api/v1/app/invitations/public-routes.test.ts @@ -132,6 +132,7 @@ describe('GET metadata', () => { inviteeName: 'Alice', status: 'opened', expiresAt: expect.any(String), // ISO string — part of the landing-view contract + accountExists: false, // no user for this email → "set a password" branch }); expect(prismaMock.appQuestionnaireInvitation.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { tokenHash: TOKEN_HASH } }) @@ -152,6 +153,16 @@ describe('GET metadata', () => { expect(res.status).toBe(200); expect(prismaMock.appQuestionnaireInvitation.update).not.toHaveBeenCalled(); }); + + it('reports accountExists when the invited email already has an account', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-existing' }); + const res = await metadataGET(metaReq(TOKEN)); + expect(res.status).toBe(200); + expect((await res.json()).data.accountExists).toBe(true); + expect(prismaMock.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { email: 'alice@example.com' } }) + ); + }); }); describe('POST accept', () => { @@ -190,11 +201,35 @@ describe('POST accept', () => { expect(data).not.toHaveProperty('openedAt'); }); - it('409s when an account already exists for the email', async () => { + it('claims the invitation for an existing account via sign-in (no new account)', async () => { prismaMock.user.findUnique.mockResolvedValue({ id: 'user-existing' }); const res = await acceptPOST(acceptReq({ token: TOKEN, password: 'longenough1' })); - expect(res.status).toBe(409); - expect((await res.json()).error.code).toBe('ACCOUNT_EXISTS'); + expect(res.status).toBe(200); + // No signup, no emailVerified mutation — we bind to the existing account. + expect(auth.api.signUpEmail).not.toHaveBeenCalled(); + expect(prismaMock.user.update).not.toHaveBeenCalled(); + // The supplied password is verified by signInEmail, then bound to the existing id. + expect(auth.api.signInEmail).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ email: 'alice@example.com' }) }) + ); + expect(prismaMock.appQuestionnaireInvitation.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 'user-existing', status: 'registered' }), + }) + ); + expect(res.headers.getSetCookie().some((c) => c.startsWith('session='))).toBe(true); + }); + + it('401s on a wrong password when claiming an existing account, binding nothing', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-existing' }); + (auth.api.signInEmail as unknown as Mock).mockResolvedValue( + new Response(null, { status: 401 }) + ); + const res = await acceptPOST(acceptReq({ token: TOKEN, password: 'wrongpass1' })); + expect(res.status).toBe(401); + expect((await res.json()).error.code).toBe('INVALID_CREDENTIALS'); + // A bad credential must not bind the invitation. + expect(prismaMock.appQuestionnaireInvitation.update).not.toHaveBeenCalled(); expect(auth.api.signUpEmail).not.toHaveBeenCalled(); }); @@ -229,16 +264,18 @@ describe('POST accept', () => { expect((await res.json()).error.code).toBe('INVITATION_REVOKED'); }); - it('500s when sign-in fails after the account is created', async () => { + it('500s when sign-in fails after the account is created, binding nothing', async () => { (auth.api.signInEmail as unknown as Mock).mockResolvedValue( new Response(null, { status: 401 }) ); const res = await acceptPOST(acceptReq({ token: TOKEN, password: 'longenough1' })); expect(res.status).toBe(500); - // The account + binding still happened — only the auto-login leg failed. - expect(prismaMock.appQuestionnaireInvitation.update).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ status: 'registered' }) }) + // The account was created + verified, but binding now happens *after* sign-in, so a + // failed auto-login leaves the invitation unbound (not a half-registered state). + expect(prismaMock.user.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { emailVerified: true } }) ); + expect(prismaMock.appQuestionnaireInvitation.update).not.toHaveBeenCalled(); }); it('429s when the accept rate limit is exceeded, before any work', async () => { diff --git a/tests/unit/components/forms/questionnaire-invite-form.test.tsx b/tests/unit/components/forms/questionnaire-invite-form.test.tsx new file mode 100644 index 00000000..a265931d --- /dev/null +++ b/tests/unit/components/forms/questionnaire-invite-form.test.tsx @@ -0,0 +1,76 @@ +/** + * QuestionnaireInviteForm — respondent invitation landing form. + * + * Covers the claim-via-existing-login branch (F3.2 gap-fill): when the metadata + * endpoint reports `accountExists`, the form asks for the existing password ("sign in + * to claim") instead of offering to set a new one (no confirm field). + * + * @see components/forms/questionnaire-invite-form.tsx + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +import { QuestionnaireInviteForm } from '@/components/forms/questionnaire-invite-form'; +import type { InvitationLandingView } from '@/lib/app/questionnaire/invitations'; + +vi.mock('next/navigation', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn(), refresh: vi.fn() })), + useSearchParams: vi.fn(() => new URLSearchParams('token=plain-token')), +})); + +function metadataResponse(over: Partial = {}): Response { + const view: InvitationLandingView = { + questionnaireTitle: 'Customer Satisfaction', + inviteeName: 'Alice', + status: 'opened', + expiresAt: new Date(Date.now() + 86400_000).toISOString(), + accountExists: false, + ...over, + }; + return new Response(JSON.stringify({ success: true, data: view }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve(metadataResponse())) + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('QuestionnaireInviteForm', () => { + it('offers a set-password + confirm flow when no account exists', async () => { + render(); + await waitFor(() => + expect(screen.getByText(/Set a password to register/i)).toBeInTheDocument() + ); + + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Confirm password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Register & begin/i })).toBeInTheDocument(); + }); + + it('asks for the existing password (no confirm) when the email already has an account', async () => { + (fetch as unknown as ReturnType).mockResolvedValue( + metadataResponse({ accountExists: true }) + ); + render(); + await waitFor(() => + expect(screen.getByText(/sign in with your password to claim/i)).toBeInTheDocument() + ); + + expect(screen.getByLabelText('Your password')).toBeInTheDocument(); + expect(screen.queryByLabelText('Confirm password')).not.toBeInTheDocument(); + const pw = screen.getByLabelText('Your password'); + expect(pw).toHaveAttribute('autocomplete', 'current-password'); + expect(screen.getByRole('button', { name: /Sign in & begin/i })).toBeInTheDocument(); + }); +}); From b2142c4434d89126212a634d1fdb86faa678af7a Mon Sep 17 00:00:00 2001 From: John Durrant Date: Sun, 7 Jun 2026 17:34:08 +0100 Subject: [PATCH 4/8] feat(app): clone-for-client admin utility (DEMO-ONLY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/v1/app/questionnaires/:id/clone-for-client + a "Clone for client" dialog on the detail page: duplicate a questionnaire's current version (launched else latest) into a new draft questionnaire attributed to a chosen demo client, so the same questionnaire is re-usable for the next prospect. Closes the P2.5-deferred clone-for-client, unblocked since P3 gave it F2.2 tags + F3.1 config. Extracts the version deep-copy (sections/slots/tags/config) from fork.ts into a shared _lib/copy-version-graph.ts, single-sourced by the version-fork and the clone route so they can't drift (fork's tests are the regression net). Copies goal/audience + the newest source-doc provenance; does not copy sessions, invitations, evaluation runs, or extraction-change records. Item 4 of the deferred-gaps plan. App surface — no CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .context/app/planning/development-plan.md | 2 +- .context/app/questionnaire/demo-clients.md | 19 +- app/admin/questionnaires/[id]/page.tsx | 4 + .../[id]/clone-for-client/route.ts | 179 ++++++++++++++ .../questionnaires/_lib/copy-version-graph.ts | 186 ++++++++++++++ app/api/v1/app/questionnaires/_lib/fork.ts | 140 +---------- .../clone-for-client-dialog.tsx | 178 ++++++++++++++ lib/api/endpoints.ts | 2 + .../questionnaires/clone-for-client.test.ts | 229 ++++++++++++++++++ 9 files changed, 804 insertions(+), 135 deletions(-) create mode 100644 app/api/v1/app/questionnaires/[id]/clone-for-client/route.ts create mode 100644 app/api/v1/app/questionnaires/_lib/copy-version-graph.ts create mode 100644 components/admin/questionnaires/clone-for-client-dialog.tsx create mode 100644 tests/integration/api/v1/app/questionnaires/clone-for-client.test.ts diff --git a/.context/app/planning/development-plan.md b/.context/app/planning/development-plan.md index 92cf419f..04fa146e 100644 --- a/.context/app/planning/development-plan.md +++ b/.context/app/planning/development-plan.md @@ -306,7 +306,7 @@ The rest of what was P2.5 lands where its dependency is built. Every piece stays | Tenant-scoped user routing (resolve tenant from URL / invite token) | Needs user-facing routes + invite tokens | **F7.1** (P7) | | Theming application points (landing / completion pages) | Needs P7 user pages | **F7.1 / F7.3** (P7) | | Demo session reset (`reset-sessions`) | Needs the full session graph | **F6.4** (P6) | -| Clone-for-client admin utility | Needs tags (F2.2) + config (F3.1) | **P3+**, promote once both exist | +| Clone-for-client admin utility | Needs tags (F2.2) + config (F3.1) | **done 2026-06-07** (deferred-gaps audit, Item 4) | | Demo content seed (`010-demo-content.ts`) | Needs a complete themeable vertical | **F9.4** (P9) | --- diff --git a/.context/app/questionnaire/demo-clients.md b/.context/app/questionnaire/demo-clients.md index 7060cca9..ed6fcd15 100644 --- a/.context/app/questionnaire/demo-clients.md +++ b/.context/app/questionnaire/demo-clients.md @@ -126,11 +126,28 @@ a typed-confirmation guard and an anonymous-mode refusal. See none) and **full** on the detail page / live form preview (the resolved brand the respondent sees, defaults filled). - The questionnaire detail page (`/admin/questionnaires/:id`) carries the - attribution `` picker (active clients + the current one). + attribution `` picker (active clients + the current one) and a + **``** "Clone for client" action (below). Nav entry registered via `initAppNav()` (seam 4 — no sidebar edit). The admin shell itself is **not** themed (that's for the end-user surface in P7). +## Clone for client + +`POST /api/v1/app/questionnaires/:id/clone-for-client` `{ targetDemoClientId, nameSuffix? }` +(DEMO-ONLY) duplicates a questionnaire's **current** version (launched if present, else +the highest-numbered) into a brand-new questionnaire as a fresh `draft` v1, attributed to +the chosen demo client (`null` = a generic, unattributed copy) — so the same questionnaire +is re-usable for the next prospect. The structural copy (config + sections/slots + tag +vocabulary + assignments) is single-sourced with the F2.1 version-fork via +`_lib/copy-version-graph.ts` (`copyVersionGraph`); goal/audience are copied onto the new +v1; the newest source-document row is copied as provenance. **Not** copied: sessions, +invitations, evaluation runs, extraction-change records (a clone starts fresh). The new +title is `""`, the suffix defaulting to the client name (or +"Copy"). Admin-only, flag-gated, audited as `questionnaire.clone_for_client`. _(Built +2026-06-07, deferred-gaps audit Item 4 — the relocated P2.5 clone-for-client, unblocked +once F2.2 tags + F3.1 config existed.)_ + ## Fork guidance Everything demo-only is grep-isolated under the `DEMO-ONLY` marker: diff --git a/app/admin/questionnaires/[id]/page.tsx b/app/admin/questionnaires/[id]/page.tsx index cfd7dc37..94f5a6a7 100644 --- a/app/admin/questionnaires/[id]/page.tsx +++ b/app/admin/questionnaires/[id]/page.tsx @@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'; import { VersionGraph } from '@/components/admin/questionnaires/version-graph'; import { VersionEditor } from '@/components/admin/questionnaires/version-editor'; import { ReingestDialog } from '@/components/admin/questionnaires/reingest-dialog'; +import { CloneForClientDialog } from '@/components/admin/questionnaires/clone-for-client-dialog'; import { QUESTIONNAIRE_STATUS_BADGE } from '@/components/admin/questionnaires/status-badge'; import { DemoClientAssign } from '@/components/admin/demo-clients/demo-client-assign'; import { Badge } from '@/components/ui/badge'; @@ -192,6 +193,9 @@ export default async function QuestionnaireDetailPage({ params, searchParams }: versionNumber={selected.versionNumber} /> )} + {/* Clone-for-client (DEMO-ONLY) — duplicate the current version for another + prospect; available regardless of the selected version's status. */} + {graph && ( + + + + Clone for another client + + Copies this questionnaire's current version — structure, tags, and configuration — + into a new draft questionnaire attributed to the chosen demo client. Sessions, + invitations, and evaluation runs are not copied. + + + +
void handleSubmit(e)} className="space-y-4"> +
+ + +
+ +
+ + setNameSuffix(e.target.value)} + disabled={busy} + placeholder="Defaults to the client name" + maxLength={120} + /> +
+ + {error &&

{error}

} + + + + +
+
+ + ); +} diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 3ba4afe6..fdedfdad 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -361,6 +361,8 @@ export const API = { /** Re-ingest a replacement source doc into a draft version (POST multipart). */ versionReingest: (id: string, versionId: string): string => `/api/v1/app/questionnaires/${id}/versions/${versionId}/reingest`, + /** Clone the questionnaire's current version into a new questionnaire for a demo client (POST — DEMO-ONLY). */ + cloneForClient: (id: string): string => `/api/v1/app/questionnaires/${id}/clone-for-client`, /** Version run-time configuration (PATCH partial config — F3.1). */ versionConfig: (id: string, versionId: string): string => `/api/v1/app/questionnaires/${id}/versions/${versionId}/config`, diff --git a/tests/integration/api/v1/app/questionnaires/clone-for-client.test.ts b/tests/integration/api/v1/app/questionnaires/clone-for-client.test.ts new file mode 100644 index 00000000..07907d2f --- /dev/null +++ b/tests/integration/api/v1/app/questionnaires/clone-for-client.test.ts @@ -0,0 +1,229 @@ +/** + * Integration test: POST /api/v1/app/questionnaires/:id/clone-for-client (DEMO-ONLY, + * deferred-gaps Item 4). + * + * Exercises the route's HTTP orchestration with the boundaries mocked: flag gate, + * admin auth, Zod body, source 404, current-version resolution (launched-else-latest), + * target-client 404, the transactional create + `copyVersionGraph` call, source-doc + * copy, and the admin audit. The deep copy itself is single-sourced via + * `copyVersionGraph` (covered by the fork tests) and stubbed here. + * + * Covers: 404 flag-off · 401 · 403 · 400 bad body · 404 unknown questionnaire · 404 + * no-version · 404 unknown target client · 201 happy (attributed) · 201 generic (None) + * · launched-preferred-over-latest · audit content. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +// ─── Mocks (hoisted) ────────────────────────────────────────────────────────── + +vi.mock('@/lib/feature-flags', () => ({ isFeatureEnabled: vi.fn() })); +vi.mock('@/lib/auth/config', () => ({ auth: { api: { getSession: vi.fn() } } })); +vi.mock('next/headers', () => ({ headers: vi.fn(() => Promise.resolve(new Headers())) })); +vi.mock('@/lib/security/ip', () => ({ getClientIP: vi.fn(() => '203.0.113.7') })); +vi.mock('@/lib/orchestration/audit/admin-audit-logger', () => ({ logAdminAction: vi.fn() })); + +const txMock = vi.hoisted(() => ({ + appQuestionnaire: { create: vi.fn() }, + appQuestionnaireVersion: { create: vi.fn() }, + appQuestionnaireSourceDocument: { findFirst: vi.fn(), create: vi.fn() }, +})); +vi.mock('@/lib/db/client', () => ({ + prisma: { + appQuestionnaire: { findUnique: vi.fn() }, + appQuestionnaireVersion: { findFirst: vi.fn() }, + appDemoClient: { findUnique: vi.fn() }, + }, +})); +vi.mock('@/lib/db/utils', () => ({ + executeTransaction: vi.fn(async (cb: (tx: typeof txMock) => unknown) => cb(txMock)), +})); +vi.mock('@/app/api/v1/app/questionnaires/_lib/copy-version-graph', () => ({ + copyVersionGraph: vi.fn(async () => ({ + sectionIdMap: new Map(), + questionIdMap: new Map(), + tagIdMap: new Map(), + })), +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { POST } from '@/app/api/v1/app/questionnaires/[id]/clone-for-client/route'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { auth } from '@/lib/auth/config'; +import { prisma } from '@/lib/db/client'; +import { logAdminAction } from '@/lib/orchestration/audit/admin-audit-logger'; +import { copyVersionGraph } from '@/app/api/v1/app/questionnaires/_lib/copy-version-graph'; +import { + mockAdminUser, + mockAuthenticatedUser, + mockUnauthenticatedUser, +} from '@/tests/helpers/auth'; + +type Mock = ReturnType; + +function ctx(id: string): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) }; +} + +function req(body: unknown): NextRequest { + return { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + url: 'http://localhost:3000/api/v1/app/questionnaires/qn-1/clone-for-client', + json: async () => body, + } as unknown as NextRequest; +} + +const VERSION = { + id: 'ver-launched', + goal: 'Understand satisfaction', + audience: { role: 'customer' }, + goalProvenance: 'inferred', + audienceProvenance: { role: 'inferred' }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + (isFeatureEnabled as Mock).mockResolvedValue(true); + (auth.api.getSession as unknown as Mock).mockResolvedValue(mockAdminUser()); + (prisma.appQuestionnaire.findUnique as Mock).mockResolvedValue({ + id: 'qn-1', + title: 'Customer NPS', + }); + // Default: a launched version exists. + (prisma.appQuestionnaireVersion.findFirst as Mock).mockResolvedValue(VERSION); + (prisma.appDemoClient.findUnique as Mock).mockResolvedValue({ name: 'Acme Bank' }); + txMock.appQuestionnaire.create.mockResolvedValue({ id: 'qn-new' }); + txMock.appQuestionnaireVersion.create.mockResolvedValue({ id: 'ver-new' }); + txMock.appQuestionnaireSourceDocument.findFirst.mockResolvedValue(null); + txMock.appQuestionnaireSourceDocument.create.mockResolvedValue({ id: 'doc-new' }); +}); + +describe('POST …/clone-for-client — gate and auth', () => { + it('404s when the app flag is off, before any work', async () => { + (isFeatureEnabled as Mock).mockResolvedValue(false); + const res = await POST(req({ targetDemoClientId: null }), ctx('qn-1')); + expect(res.status).toBe(404); + expect(prisma.appQuestionnaire.findUnique).not.toHaveBeenCalled(); + }); + + it('401s when unauthenticated', async () => { + (auth.api.getSession as unknown as Mock).mockResolvedValue(mockUnauthenticatedUser()); + expect((await POST(req({ targetDemoClientId: null }), ctx('qn-1'))).status).toBe(401); + }); + + it('403s for a non-admin', async () => { + (auth.api.getSession as unknown as Mock).mockResolvedValue(mockAuthenticatedUser('USER')); + expect((await POST(req({ targetDemoClientId: null }), ctx('qn-1'))).status).toBe(403); + }); + + it('400s on a malformed body (missing targetDemoClientId)', async () => { + expect((await POST(req({}), ctx('qn-1'))).status).toBe(400); + }); +}); + +describe('POST …/clone-for-client — resolution', () => { + it('404s when the source questionnaire is unknown', async () => { + (prisma.appQuestionnaire.findUnique as Mock).mockResolvedValue(null); + const res = await POST(req({ targetDemoClientId: null }), ctx('qn-x')); + expect(res.status).toBe(404); + expect((await res.json()).error.code).toBe('NOT_FOUND'); + }); + + it('404s when the questionnaire has no version to clone', async () => { + (prisma.appQuestionnaireVersion.findFirst as Mock).mockResolvedValue(null); + const res = await POST(req({ targetDemoClientId: null }), ctx('qn-1')); + expect(res.status).toBe(404); + }); + + it('404s when the target demo client does not exist', async () => { + (prisma.appDemoClient.findUnique as Mock).mockResolvedValue(null); + const res = await POST(req({ targetDemoClientId: 'dc-missing' }), ctx('qn-1')); + expect(res.status).toBe(404); + expect((await res.json()).error.code).toBe('DEMO_CLIENT_NOT_FOUND'); + }); + + it('prefers the launched version over a later draft', async () => { + await POST(req({ targetDemoClientId: null }), ctx('qn-1')); + // The first version lookup filters on status: 'launched'. + expect((prisma.appQuestionnaireVersion.findFirst as Mock).mock.calls[0][0]).toMatchObject({ + where: { questionnaireId: 'qn-1', status: 'launched' }, + }); + expect(copyVersionGraph).toHaveBeenCalledWith(expect.anything(), 'ver-launched', 'ver-new'); + }); +}); + +describe('POST …/clone-for-client — happy path', () => { + it('clones into a new attributed draft, copies the graph, and audits', async () => { + const res = await POST(req({ targetDemoClientId: 'dc-1', nameSuffix: 'Pilot' }), ctx('qn-1')); + expect(res.status).toBe(201); + expect(await res.json()).toMatchObject({ + success: true, + data: { questionnaireId: 'qn-new', versionId: 'ver-new' }, + }); + + // New questionnaire created as a draft attributed to the target client, title + // suffixed (explicit suffix wins over the client name). + expect(txMock.appQuestionnaire.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Customer NPS — Pilot', + status: 'draft', + demoClientId: 'dc-1', + }), + }) + ); + // Fresh v1, goal/audience copied from the source version. + expect(txMock.appQuestionnaireVersion.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + questionnaireId: 'qn-new', + versionNumber: 1, + status: 'draft', + goal: 'Understand satisfaction', + }), + }) + ); + expect(copyVersionGraph).toHaveBeenCalledWith(expect.anything(), 'ver-launched', 'ver-new'); + expect(logAdminAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'questionnaire.clone_for_client', + entityId: 'qn-new', + metadata: expect.objectContaining({ + sourceQuestionnaireId: 'qn-1', + targetDemoClientId: 'dc-1', + }), + }) + ); + }); + + it('clones generically (None) without attribution and defaults the title to "Copy"', async () => { + const res = await POST(req({ targetDemoClientId: null }), ctx('qn-1')); + expect(res.status).toBe(201); + // No client lookup when unattributed. + expect(prisma.appDemoClient.findUnique).not.toHaveBeenCalled(); + const data = txMock.appQuestionnaire.create.mock.calls[0][0].data; + expect(data).not.toHaveProperty('demoClientId'); + expect(data.title).toBe('Customer NPS — Copy'); + }); + + it('copies the source document provenance when one exists', async () => { + txMock.appQuestionnaireSourceDocument.findFirst.mockResolvedValue({ + fileName: 'survey.pdf', + fileHash: 'abc', + byteSize: 1234, + mimeType: 'application/pdf', + pageCount: 3, + warnings: ['note'], + extractedText: 'Q1...', + }); + await POST(req({ targetDemoClientId: null }), ctx('qn-1')); + expect(txMock.appQuestionnaireSourceDocument.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ versionId: 'ver-new', fileName: 'survey.pdf' }), + }) + ); + }); +}); From 3d2dcf96256711ee01c91a84ab3ec159d1b49882 Mon Sep 17 00:00:00 2001 From: John Durrant Date: Sun, 7 Jun 2026 17:56:57 +0100 Subject: [PATCH 5/8] feat(app): attachment input for live questionnaire turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the F7.1-listed attachment input, which shipped out-of-scope blocked on an F4.2 capability extension that was itself deferred in F6.1 (voice was wired; attachments never were). - Answer-extractor capability accepts an `attachments` arg: the prompt builder turns the user turn into multimodal content parts (text + image/document parts, mirroring the platform chat message builder), and a pre-call assertModelSupportsAttachments gate returns a typed attachments_not_supported error rather than silently dropping the attached answer. redactProvenance records only an attachmentCount. - The /messages route + orchestrator TurnState thread attachments through to the extractor. - The chat composer gains a flag-gated paperclip affordance (file chips, 10-file / 5MB client caps); the stream hook sends attachments in the body. - Dark-launch sub-flag APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED (seed 024, off), ANDed with master + live-sessions. With it off the affordance is hidden and the route ignores sent attachments — the paid multimodal path stays shut. Item 5 of the deferred-gaps plan. App surface — no CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .context/app/planning/development-plan.md | 4 +- .context/app/planning/features/f6.1.md | 8 +- .context/app/planning/features/f7.1.md | 4 +- .../questionnaire/per-turn-orchestrator.md | 18 +++ .../questionnaires/[sessionId]/page.tsx | 10 +- app/(public)/q/[versionId]/page.tsx | 10 +- .../[id]/messages/route.ts | 32 +++-- .../_lib/turn-invokers.ts | 3 + .../chat/anonymous-session-boot.tsx | 4 + .../questionnaire/chat/questionnaire-chat.tsx | 117 +++++++++++++++++- .../app/questionnaire/session-workspace.tsx | 4 + .../capabilities/extract-answer-slots.ts | 45 ++++++- lib/app/questionnaire/constants.ts | 12 ++ .../extraction/extraction-prompt.ts | 41 +++++- lib/app/questionnaire/extraction/types.ts | 14 +++ lib/app/questionnaire/feature-flag.ts | 21 ++++ lib/app/questionnaire/orchestrator/types.ts | 13 ++ lib/hooks/use-questionnaire-session-stream.ts | 18 ++- .../024-attachment-input-flag.ts | 45 +++++++ .../questionnaire/answer-capability.test.ts | 54 +++++++- .../chat/questionnaire-chat.test.tsx | 13 +- .../app/questionnaire/extraction/_fixtures.ts | 3 + .../extraction/extraction-prompt.test.ts | 49 ++++++++ 23 files changed, 511 insertions(+), 31 deletions(-) create mode 100644 prisma/seeds/app-questionnaire/024-attachment-input-flag.ts diff --git a/.context/app/planning/development-plan.md b/.context/app/planning/development-plan.md index 04fa146e..51f1c6cf 100644 --- a/.context/app/planning/development-plan.md +++ b/.context/app/planning/development-plan.md @@ -142,8 +142,8 @@ The build moves from scaffolding → ingestion → admin manage → demo brandin | **P3** | Configuration, invitations, and cost estimation | done | Per-version config; invitation flow; pre-launch cost estimate. All four features merged: F3.1 config (#26); F3.2 invitation flow (#27); F3.3 cost estimation (#28); F3.4 demo-client invitation branding (#29). | | **P4** | Conversational engine (non-streaming) | in flight | Selection · extraction · contradiction · completion logic, exercised without the streaming surface. F4.1 selection strategies (#30) + F4.2 answer extraction (#32) + F4.3 contradiction detection (#33) + F4.4 answer refinement (#34) + F4.5 completion logic (#35) merged; F4.6 session state machine in flight. | | **P5** | Design-time evaluation (agents-as-judges) | done | Judges score a questionnaire against goal/audience; suggestion review queue. F5.1 judge agents done (app-native dispatch — `evaluate-structure` capability + 7 `kind='judge'` seeds + preview route); F5.2 run persistence done (synchronous run route + app-owned run/finding models + admin run history); F5.3 review queue done (structured `proposedEdit` ops on the findings contract + apply-through-fork engine + read-time staleness + interactive queue UI). | -| **P6** | Conversational session (streaming) | done | F6.1 per-turn orchestrator + streaming (#40); F6.2 voice input (#41); F6.3 cost-cap enforcement (#42); F6.4 demo session reset (#43). _Attachment input deferred → see audit gap-fill (Item 5)._ | -| **P7** | User-facing conversational UI | done | F7.1 chat surface (#44); F7.2 answer-slot panel (#45); F7.3 session lifecycle UX (#46); F7.4 PDF export (#47); admin upload-questionnaire UI (#48). _Attachment input + invite claim-via-login deferred → see audit gap-fills._ | +| **P6** | Conversational session (streaming) | done | F6.1 per-turn orchestrator + streaming (#40); F6.2 voice input (#41); F6.3 cost-cap enforcement (#42); F6.4 demo session reset (#43). _Attachment input closed by the 2026-06-07 audit gap-fill (Item 5)._ | +| **P7** | User-facing conversational UI | done | F7.1 chat surface (#44); F7.2 answer-slot panel (#45); F7.3 session lifecycle UX (#46); F7.4 PDF export (#47); admin upload-questionnaire UI (#48). _Attachment input + invite claim-via-login closed by the 2026-06-07 audit gap-fills._ | | **P8** | Admin analytics, exports, anonymous mode | not started | Dashboards, CSV/JSON export, anonymous-mode handling. | | **P9** | Hardening + forking docs | not started | Runbook, flag inventory, `forking.md`, concurrent-session sanity. | diff --git a/.context/app/planning/features/f6.1.md b/.context/app/planning/features/f6.1.md index a4c5bd84..ec0b8dc6 100644 --- a/.context/app/planning/features/f6.1.md +++ b/.context/app/planning/features/f6.1.md @@ -77,8 +77,12 @@ event) and of `AppQuestionnaireTurn` (firing `AppAnswerSlot.lastUpdatedTurnId`). ## Deferred / follow-ups -- **Attachment input** (extraction over an uploaded image/doc) — needs extending the F4.2 - extraction capability to accept content parts; tracked for F6.1 PR8 / a follow-up. +- ~~**Attachment input** (extraction over an uploaded image/doc) — needs extending the F4.2 + extraction capability to accept content parts; tracked for F6.1 PR8 / a follow-up.~~ + **Closed 2026-06-07** (deferred-gaps audit, Item 5): the answer-extractor capability accepts + `attachments` (content parts + a model-capability gate), the `/messages` route + orchestrator + thread them, and the chat surface has a flag-gated affordance + (`APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED`, seed 024). - ~~**Full refinementHistory append** on refine persistence (PR4 persists the corrected value via the upsert seam; the history append through `persistRefinement` is a follow-up).~~ **Closed 2026-06-07** (deferred-gaps audit, Item 2): `persistTurn` now takes the full F4.4 diff --git a/.context/app/planning/features/f7.1.md b/.context/app/planning/features/f7.1.md index 3a878a1c..0791c9e6 100644 --- a/.context/app/planning/features/f7.1.md +++ b/.context/app/planning/features/f7.1.md @@ -84,7 +84,9 @@ branding, and protect the sales-critical happy path with an end-to-end test. - **F7.3** — session lifecycle UX (pause/resume controls, completion-offer accept flow, explicit cost-cap surfacing beyond the terminal panel). - **F7.4** — PDF export. -- **Attachment input** — blocked on an F4.2 extension. +- ~~**Attachment input** — blocked on an F4.2 extension.~~ **Closed 2026-06-07** (deferred-gaps + audit, Item 5): the F4.2 capability now accepts content parts and the chat composer has a + flag-gated attachment affordance (`APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED`). - **Authenticated e2e** — needs a login storage-state fixture (`globalSetup`); tracked in the e2e README. ## Tests diff --git a/.context/app/questionnaire/per-turn-orchestrator.md b/.context/app/questionnaire/per-turn-orchestrator.md index 6e2e76c4..26edff7c 100644 --- a/.context/app/questionnaire/per-turn-orchestrator.md +++ b/.context/app/questionnaire/per-turn-orchestrator.md @@ -113,6 +113,19 @@ invariant:** no audio bytes or transcript are persisted — the only happy-path row. The client sends the returned transcript through the normal text `/messages` path, so P7 can wire Sunrise's `` (which expects an endpoint returning `{ text }`) at it verbatim. +### Attachment input + +The `/messages` body accepts an optional `attachments` array (the platform `chatAttachmentSchema` +— up to 10 files, ~5 MB each / ~25 MB combined; images + PDF/DOCX/text). When the attachment +sub-flag is on, the route threads them onto `TurnState.attachments`; the extraction invoker forwards +them to the answer-extractor capability, whose prompt builder turns the user turn into multimodal +content parts (`text` + one `image`/`document` part per file — the same conversion as the platform +chat message builder) so the model reads the file alongside the message. Before the LLM call the +capability runs `assertModelSupportsAttachments(provider, model, [vision?|documents?])` and returns a +typed `attachments_not_supported` error if the resolved model lacks the modality (no silent text-only +extraction that would drop the attached answer). The `redactProvenance` row records only an +`attachmentCount`, never the bytes. + ### No-login anonymous tokens `_lib/session-access-token.ts` mints/verifies a stateless HMAC token (`{ sessionId, expiresAt }` @@ -135,6 +148,11 @@ existing precedent is the embed widget (`lib/embed/auth.ts`). useful once the respondent can send it through the live `/messages` loop (with live-sessions off that route 404s, so transcription would be a dead but still-paid call). Off → the transcribe route 404s before auth (`withVoiceInputEnabled`). +- Attachment input has its own dark-launch sub-flag `APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED` + (seed 024, off by default), also ANDed with master + live-sessions (`isAttachmentInputEnabled`). + Unlike voice it doesn't gate a dedicated route — the chat surface hides the affordance and the + `/messages` route **ignores** any attachments a client sends while it's off, so the paid + multimodal path stays shut. ## See also diff --git a/app/(protected)/questionnaires/[sessionId]/page.tsx b/app/(protected)/questionnaires/[sessionId]/page.tsx index 3b38317d..b5b0f8f1 100644 --- a/app/(protected)/questionnaires/[sessionId]/page.tsx +++ b/app/(protected)/questionnaires/[sessionId]/page.tsx @@ -4,7 +4,11 @@ import { notFound } from 'next/navigation'; import { getServerSession } from '@/lib/auth/utils'; import { clearInvalidSession } from '@/lib/auth/clear-session'; import { prisma } from '@/lib/db/client'; -import { isLiveSessionsEnabled, isVoiceInputEnabled } from '@/lib/app/questionnaire/feature-flag'; +import { + isAttachmentInputEnabled, + isLiveSessionsEnabled, + isVoiceInputEnabled, +} from '@/lib/app/questionnaire/feature-flag'; import { SessionWorkspace } from '@/components/app/questionnaire/session-workspace'; import { BrandThemeProvider } from '@/components/app/questionnaire/chat/brand-theme-provider'; import { buildWelcomeTurns } from '@/lib/app/questionnaire/chat/greeting'; @@ -82,8 +86,9 @@ export default async function QuestionnaireSessionPage({ // panel + lifecycle status are SSR-seeded here (the user is already verified as owner), // so they paint with no fetch flash; the live updates after each turn come from the // client hooks. - const [voiceInputEnabled, theme, panel, status] = await Promise.all([ + const [voiceInputEnabled, attachmentInputEnabled, theme, panel, status] = await Promise.all([ isVoiceInputEnabled(), + isAttachmentInputEnabled(), resolveThemeForSession(sessionId), loadAnswerPanelState(sessionId), loadSessionStatus(sessionId), @@ -100,6 +105,7 @@ export default async function QuestionnaireSessionPage({ initialPanel={panel?.view} initialStatusView={status?.view} voiceInputEnabled={voiceInputEnabled} + attachmentInputEnabled={attachmentInputEnabled} /> diff --git a/app/(public)/q/[versionId]/page.tsx b/app/(public)/q/[versionId]/page.tsx index aed06de1..c4417933 100644 --- a/app/(public)/q/[versionId]/page.tsx +++ b/app/(public)/q/[versionId]/page.tsx @@ -1,7 +1,11 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { isLiveSessionsEnabled, isVoiceInputEnabled } from '@/lib/app/questionnaire/feature-flag'; +import { + isAttachmentInputEnabled, + isLiveSessionsEnabled, + isVoiceInputEnabled, +} from '@/lib/app/questionnaire/feature-flag'; import { AnonymousSessionBoot } from '@/components/app/questionnaire/chat/anonymous-session-boot'; import { BrandThemeProvider } from '@/components/app/questionnaire/chat/brand-theme-provider'; import { resolveThemeForVersion } from '@/lib/app/questionnaire/chat/theme'; @@ -28,8 +32,9 @@ export default async function PublicQuestionnairePage({ const { versionId } = await params; // Independent reads — resolve in parallel rather than serialising two DB round-trips. - const [voiceInputEnabled, theme] = await Promise.all([ + const [voiceInputEnabled, attachmentInputEnabled, theme] = await Promise.all([ isVoiceInputEnabled(), + isAttachmentInputEnabled(), resolveThemeForVersion(versionId), ]); @@ -39,6 +44,7 @@ export default async function PublicQuestionnairePage({ diff --git a/app/api/v1/app/questionnaire-sessions/[id]/messages/route.ts b/app/api/v1/app/questionnaire-sessions/[id]/messages/route.ts index 67f7fbbd..e74e19b2 100644 --- a/app/api/v1/app/questionnaire-sessions/[id]/messages/route.ts +++ b/app/api/v1/app/questionnaire-sessions/[id]/messages/route.ts @@ -26,6 +26,7 @@ import { errorResponse } from '@/lib/api/responses'; import { getRouteLogger } from '@/lib/api/context'; import { handleAPIError } from '@/lib/api/errors'; import { validateRequestBody } from '@/lib/api/validation'; +import { chatAttachmentsArraySchema } from '@/lib/validations/orchestration'; import { createRateLimitResponse } from '@/lib/security/rate-limit'; import { @@ -33,6 +34,7 @@ import { isAnswerExtractionEnabled, isAnswerRefinementEnabled, isCompletionEnabled, + isAttachmentInputEnabled, isContradictionDetectionEnabled, isCostCapEnforcementEnabled, withLiveSessionsEnabled, @@ -53,7 +55,11 @@ import { persistTurn } from '@/app/api/v1/app/questionnaire-sessions/_lib/turn-r import { streamOfferMessage } from '@/app/api/v1/app/questionnaire-sessions/_lib/offer-stream'; import { resolveTurnAccess } from '@/app/api/v1/app/questionnaire-sessions/_lib/turn-access'; -const bodySchema = z.object({ message: z.string().min(1).max(10_000) }); +const bodySchema = z.object({ + message: z.string().min(1).max(10_000), + /** Optional files attached to this turn (images/documents) — read by the extractor. */ + attachments: chatAttachmentsArraySchema.optional(), +}); /** Chunk text into small pieces for a streamed feel (true token streaming is PR5). */ function chunkText(text: string, size = 48): string[] { @@ -142,18 +148,28 @@ async function handleMessage( } // Resolve the per-step flags (async DB reads) up front, so the pure core stays sync. - const [extraction, contradiction, refinement, completion, adaptive] = await Promise.all([ - isAnswerExtractionEnabled(), - isContradictionDetectionEnabled(), - isAnswerRefinementEnabled(), - isCompletionEnabled(), - isAdaptiveSelectionEnabled(), - ]); + const [extraction, contradiction, refinement, completion, adaptive, attachmentInput] = + await Promise.all([ + isAnswerExtractionEnabled(), + isContradictionDetectionEnabled(), + isAnswerRefinementEnabled(), + isCompletionEnabled(), + isAdaptiveSelectionEnabled(), + isAttachmentInputEnabled(), + ]); + + // Attachments only flow when the sub-flag is on (dark-launch): with it off, a client + // that sends attachments anyway gets a text-only turn — the paid multimodal path stays shut. + const attachments = + attachmentInput && body.attachments && body.attachments.length > 0 + ? body.attachments + : undefined; const state: TurnState = { ...loaded.base, userMessage: body.message, flags: { extraction, contradiction, refinement, completion }, + ...(attachments ? { attachments } : {}), ...(costPressure ? { costPressure } : {}), }; diff --git a/app/api/v1/app/questionnaire-sessions/_lib/turn-invokers.ts b/app/api/v1/app/questionnaire-sessions/_lib/turn-invokers.ts index 7bcb14cd..d7476b67 100644 --- a/app/api/v1/app/questionnaire-sessions/_lib/turn-invokers.ts +++ b/app/api/v1/app/questionnaire-sessions/_lib/turn-invokers.ts @@ -104,6 +104,9 @@ export async function buildTurnInvokers(opts: { confidence: a.confidence ?? null, })), ...(state.recentMessages.length > 0 ? { recentMessages: state.recentMessages } : {}), + ...(state.attachments && state.attachments.length > 0 + ? { attachments: state.attachments } + : {}), sessionId: state.sessionId, }, { diff --git a/components/app/questionnaire/chat/anonymous-session-boot.tsx b/components/app/questionnaire/chat/anonymous-session-boot.tsx index cae5132f..008dbfe4 100644 --- a/components/app/questionnaire/chat/anonymous-session-boot.tsx +++ b/components/app/questionnaire/chat/anonymous-session-boot.tsx @@ -27,6 +27,8 @@ interface AnonymousSessionBootProps { welcomeCopy?: string; /** Show the voice-input affordance (gated server-side on the voice flag). */ voiceInputEnabled?: boolean; + /** Show the attachment affordance (gated server-side on the attachment-input flag). */ + attachmentInputEnabled?: boolean; } interface StoredAnonSession { @@ -73,6 +75,7 @@ export function AnonymousSessionBoot({ versionId, welcomeCopy, voiceInputEnabled = false, + attachmentInputEnabled = false, }: AnonymousSessionBootProps) { const [state, setState] = useState({ phase: 'creating' }); // Guard against React 19 StrictMode's double-invoke minting two sessions in dev. @@ -167,6 +170,7 @@ export function AnonymousSessionBoot({ accessToken={state.accessToken} initialTurns={buildWelcomeTurns({ welcomeCopy })} voiceInputEnabled={voiceInputEnabled} + attachmentInputEnabled={attachmentInputEnabled} /> ); } diff --git a/components/app/questionnaire/chat/questionnaire-chat.tsx b/components/app/questionnaire/chat/questionnaire-chat.tsx index 43f24d5f..df5011ad 100644 --- a/components/app/questionnaire/chat/questionnaire-chat.tsx +++ b/components/app/questionnaire/chat/questionnaire-chat.tsx @@ -20,7 +20,7 @@ */ import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; -import { SendHorizontal } from 'lucide-react'; +import { Paperclip, SendHorizontal, X } from 'lucide-react'; import Markdown from 'react-markdown'; import { cn } from '@/lib/utils'; @@ -29,9 +29,34 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { ThinkingIndicator } from '@/components/admin/orchestration/chat/thinking-indicator'; import { MicButton } from '@/components/admin/orchestration/chat/mic-button'; -import type { UseQuestionnaireSessionStreamReturn } from '@/lib/hooks/use-questionnaire-session-stream'; +import type { + MessageAttachment, + UseQuestionnaireSessionStreamReturn, +} from '@/lib/hooks/use-questionnaire-session-stream'; import { ChatErrorPanel } from '@/components/app/questionnaire/chat/chat-error-panel'; +/** Media types the respondent may attach — mirrors the platform `chatAttachmentSchema`. */ +const ATTACHMENT_ACCEPT = + 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain,text/csv,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document'; +const MAX_ATTACHMENTS = 10; +/** ~5 MB per file — matches the server's per-attachment cap before base64 expansion. */ +const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; + +/** Read a File into the base64 `{ name, mediaType, data }` the `/messages` route accepts. */ +function readAttachment(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Could not read file')); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + // Strip the `data:;base64,` prefix — the schema wants raw base64. + const data = result.slice(result.indexOf(',') + 1); + resolve({ name: file.name, mediaType: file.type, data }); + }; + reader.readAsDataURL(file); + }); +} + export interface QuestionnaireChatProps { /** The session id powering `/questionnaire-sessions/:id/messages` (used by the mic). */ sessionId: string; @@ -41,6 +66,8 @@ export interface QuestionnaireChatProps { stream: UseQuestionnaireSessionStreamReturn; /** Show the voice-input affordance (gated server-side on the voice flag). */ voiceInputEnabled?: boolean; + /** Show the attachment affordance (gated server-side on the attachment-input flag). */ + attachmentInputEnabled?: boolean; className?: string; } @@ -79,6 +106,7 @@ export function QuestionnaireChat({ accessToken, stream, voiceInputEnabled = false, + attachmentInputEnabled = false, className, }: QuestionnaireChatProps) { const { @@ -95,6 +123,9 @@ export function QuestionnaireChat({ const [input, setInput] = useState(''); const [voiceError, setVoiceError] = useState(null); + const [attachments, setAttachments] = useState([]); + const [attachError, setAttachError] = useState(null); + const fileRef = useRef(null); const bottomRef = useRef(null); // Keep the latest turn / streaming tail in view. @@ -105,8 +136,37 @@ export function QuestionnaireChat({ const handleSend = () => { if (!canSend || input.trim().length === 0) return; setVoiceError(null); - void sendMessage(input); + void sendMessage(input, attachments.length > 0 ? attachments : undefined); setInput(''); + setAttachments([]); + setAttachError(null); + }; + + const handleFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + setAttachError(null); + const room = MAX_ATTACHMENTS - attachments.length; + const picked = Array.from(files).slice(0, room); + if (Array.from(files).length > room) { + setAttachError(`At most ${MAX_ATTACHMENTS} files per message.`); + } + try { + const tooBig = picked.find((f) => f.size > MAX_ATTACHMENT_BYTES); + if (tooBig) { + setAttachError(`"${tooBig.name}" is over the 5 MB limit.`); + return; + } + const read = await Promise.all(picked.map(readAttachment)); + setAttachments((prev) => [...prev, ...read]); + } catch { + setAttachError('Could not read that file. Try another.'); + } finally { + if (fileRef.current) fileRef.current.value = ''; + } + }; + + const removeAttachment = (index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)); }; const handleKeyDown = (e: KeyboardEvent) => { @@ -184,6 +244,29 @@ export function QuestionnaireChat({ {!isTerminal && (
+ {/* Pending attachment chips */} + {attachmentInputEnabled && attachments.length > 0 && ( +
    + {attachments.map((att, i) => ( +
  • +
  • + ))} +
+ )}