diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts new file mode 100644 index 000000000..9da640866 --- /dev/null +++ b/src/app/api/insights/feedback/route.ts @@ -0,0 +1,103 @@ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import { resolveFeedbackAttribution } from "@/lib/ai/feedback-attribution"; +import { withIdempotency } from "@/lib/idempotency"; +import { annotate } from "@/lib/logging/context"; +import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback"; + +/** + * v1.4.16 phase B5e — POST /api/insights/feedback. + * + * Per-recommendation thumbs-up / thumbs-down. The body contains the + * rec id + snapshot text + severity + metric-source attributes the + * client just rendered. Server-side fills providerType + promptVersion + * via `resolveFeedbackAttribution()` so the client cannot tamper with + * the slice the daily aggregator computes. + * + * The endpoint is idempotent via `withIdempotency()` — a retry with + * the same `Idempotency-Key` for the same `(userId, method, path)` + * replays the cached response with `X-Idempotent-Replay: true`. + * + * 201 on insert; 409 on the `(userId, recommendationId, + * recommendationText)` unique violation (the user already rated this + * exact rec text). 422 on a validation failure. + */ +async function handlePost(request: NextRequest) { + const { user } = await requireAuth(); + + const { data: rawBody, error } = await safeJson(request); + if (error) return error; + + const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "insights.recommendation.feedback" }, + meta: { outcome: "validation_failed" }, + }); + return apiError("Invalid feedback payload", 422); + } + const body = parsed.data; + + const attribution = await resolveFeedbackAttribution(user.id); + + try { + const created = await prisma.recommendationFeedback.create({ + data: { + userId: user.id, + recommendationId: body.recommendationId, + recommendationText: body.recommendationText, + recommendationSeverity: body.recommendationSeverity, + metricSourceType: body.metricSourceType, + metricSourceTimeRange: body.metricSourceTimeRange, + helpful: body.helpful, + providerType: attribution.providerType, + promptVersion: attribution.promptVersion, + }, + select: { id: true, createdAt: true }, + }); + + await auditLog("insights.recommendation.feedback", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + feedbackId: created.id, + recommendationId: body.recommendationId, + severity: body.recommendationSeverity, + helpful: body.helpful, + providerType: attribution.providerType, + promptVersion: attribution.promptVersion, + }, + }); + + annotate({ + action: { name: "insights.recommendation.feedback" }, + meta: { + outcome: "created", + severity: body.recommendationSeverity, + helpful: body.helpful, + providerType: attribution.providerType, + }, + }); + + return apiSuccess({ id: created.id, createdAt: created.createdAt }, 201); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + annotate({ + action: { name: "insights.recommendation.feedback" }, + meta: { outcome: "already_rated" }, + }); + return apiError("already_rated", 409); + } + throw err; + } +} + +export const POST = apiHandler(withIdempotency(handlePost)); diff --git a/src/lib/ai/__tests__/feedback-attribution.test.ts b/src/lib/ai/__tests__/feedback-attribution.test.ts new file mode 100644 index 000000000..954f0e156 --- /dev/null +++ b/src/lib/ai/__tests__/feedback-attribution.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { + FEEDBACK_ATTRIBUTION_FALLBACK, + pickProviderType, +} from "../feedback-attribution"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; + +describe("pickProviderType", () => { + it("prefers chainProviderType when present", () => { + // chainProviderType captures the runner's chosen entry across the + // multi-provider chain (B5b). When a fallback fires, the working + // provider differs from the user's primary — the feedback row must + // reflect the working provider, not the configured one. + const got = pickProviderType({ + chainProviderType: "openai", + providerType: "codex", + }); + expect(got).toBe("openai"); + }); + + it("falls back to providerType when chainProviderType is missing", () => { + const got = pickProviderType({ providerType: "codex" }); + expect(got).toBe("codex"); + }); + + it("falls back to 'unknown' when neither field is present", () => { + const got = pickProviderType({}); + expect(got).toBe("unknown"); + }); + + it("ignores non-string values", () => { + const got = pickProviderType({ + chainProviderType: 123 as unknown as string, + providerType: { name: "evil" } as unknown as string, + }); + expect(got).toBe("unknown"); + }); +}); + +describe("FEEDBACK_ATTRIBUTION_FALLBACK", () => { + it("uses 'unknown' for the providerType so the aggregator groups separately", () => { + expect(FEEDBACK_ATTRIBUTION_FALLBACK.providerType).toBe("unknown"); + }); + + it("snapshots the current PROMPT_VERSION so the row is persistable", () => { + expect(FEEDBACK_ATTRIBUTION_FALLBACK.promptVersion).toBe(PROMPT_VERSION); + }); +}); diff --git a/src/lib/ai/feedback-attribution.ts b/src/lib/ai/feedback-attribution.ts new file mode 100644 index 000000000..74c856c57 --- /dev/null +++ b/src/lib/ai/feedback-attribution.ts @@ -0,0 +1,86 @@ +/** + * v1.4.16 phase B5e — server-side resolver for the provider/prompt + * attribution that gets stamped on every `RecommendationFeedback` row. + * + * The client cannot supply these fields — that would let any user + * poison the daily aggregator's slice (`(severity x provider x prompt) + * helpful-rate`) by claiming a thumbs-down was attributed to a rival + * provider. So the API endpoint reads the user's most recent + * `insights.generate` audit row, pulls `chainProviderType` (or its + * legacy fallback `providerType`) plus the current `PROMPT_VERSION` + * constant, and stamps those on the row. + * + * If no audit row exists yet (the user is rating a per-status insight + * that pre-dates the comprehensive generate flow), we fall back to + * "unknown" + the current PROMPT_VERSION so the row is still + * persistable and the aggregator can group "unknown" separately. + */ +import { prisma } from "@/lib/db"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; + +export interface FeedbackAttribution { + providerType: string; + promptVersion: string; +} + +/** Default attribution when no `insights.generate` row exists yet. */ +export const FEEDBACK_ATTRIBUTION_FALLBACK: Readonly = { + providerType: "unknown", + promptVersion: PROMPT_VERSION, +}; + +interface AuditDetailsShape { + providerType?: unknown; + chainProviderType?: unknown; +} + +function parseAuditDetails(raw: string | null): AuditDetailsShape { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as AuditDetailsShape; + } catch { + // Treat unparseable details as absent — the fallback applies. + } + return {}; +} + +/** + * Pure helper exported for unit tests so we can pin the priority order + * (chainProviderType > providerType > "unknown") without hitting the + * DB. The current PROMPT_VERSION constant is always returned because + * the generator itself doesn't yet stash a per-payload prompt version + * (research §3.B notes this is a v1.4.17 ratchet item; reading the + * constant snapshots whatever's deployed when the rating fires). + */ +export function pickProviderType(details: AuditDetailsShape): string { + if (typeof details.chainProviderType === "string") { + return details.chainProviderType; + } + if (typeof details.providerType === "string") return details.providerType; + return FEEDBACK_ATTRIBUTION_FALLBACK.providerType; +} + +/** + * Resolve the (providerType, promptVersion) tuple to stamp on a + * `RecommendationFeedback` row. Reads the latest `insights.generate` + * audit row for the user. Falls back to the safe defaults when no + * audit row exists. + */ +export async function resolveFeedbackAttribution( + userId: string, +): Promise { + const latest = await prisma.auditLog.findFirst({ + where: { userId, action: "insights.generate" }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + + if (!latest) return { ...FEEDBACK_ATTRIBUTION_FALLBACK }; + + const details = parseAuditDetails(latest.details); + return { + providerType: pickProviderType(details), + promptVersion: PROMPT_VERSION, + }; +} diff --git a/src/lib/validations/__tests__/recommendation-feedback.test.ts b/src/lib/validations/__tests__/recommendation-feedback.test.ts new file mode 100644 index 000000000..ba297d0e7 --- /dev/null +++ b/src/lib/validations/__tests__/recommendation-feedback.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { recommendationFeedbackRequestSchema } from "../recommendation-feedback"; + +describe("recommendationFeedbackRequestSchema", () => { + const baseValid = { + recommendationId: "rec-1", + recommendationText: "Discuss home BP log with your physician.", + recommendationSeverity: "important" as const, + metricSourceType: "bloodPressure", + metricSourceTimeRange: "last7days" as const, + helpful: true, + }; + + it("accepts a valid payload", () => { + const parsed = recommendationFeedbackRequestSchema.safeParse(baseValid); + expect(parsed.success).toBe(true); + }); + + it("rejects an unknown severity (defence against poisoning the aggregator)", () => { + const parsed = recommendationFeedbackRequestSchema.safeParse({ + ...baseValid, + recommendationSeverity: "wat", + }); + expect(parsed.success).toBe(false); + }); + + it("rejects an unknown time-range value", () => { + const parsed = recommendationFeedbackRequestSchema.safeParse({ + ...baseValid, + metricSourceTimeRange: "lastFortnight", + }); + expect(parsed.success).toBe(false); + }); + + it("rejects an empty recommendation id", () => { + const parsed = recommendationFeedbackRequestSchema.safeParse({ + ...baseValid, + recommendationId: "", + }); + expect(parsed.success).toBe(false); + }); + + it("rejects an empty recommendation text", () => { + const parsed = recommendationFeedbackRequestSchema.safeParse({ + ...baseValid, + recommendationText: "", + }); + expect(parsed.success).toBe(false); + }); + + it("rejects a non-boolean helpful field (catches stringified-boolean clients)", () => { + const parsed = recommendationFeedbackRequestSchema.safeParse({ + ...baseValid, + helpful: "true", + }); + expect(parsed.success).toBe(false); + }); + + it("rejects extra fields when stripping isn't desired (helpful is the only thumbs verb)", () => { + // Zod default behaviour: passthrough is OFF; unknown keys are + // dropped. We don't use .strict() so a future field can be added + // additively, but the parsed value must not carry the extra field. + const parsed = recommendationFeedbackRequestSchema.safeParse({ + ...baseValid, + providerType: "codex", + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect("providerType" in parsed.data).toBe(false); + } + }); +}); diff --git a/src/lib/validations/recommendation-feedback.ts b/src/lib/validations/recommendation-feedback.ts new file mode 100644 index 000000000..d4728fd9e --- /dev/null +++ b/src/lib/validations/recommendation-feedback.ts @@ -0,0 +1,53 @@ +import { z } from "zod/v4"; + +/** + * v1.4.16 phase B5e — request body for `POST /api/insights/feedback`. + * + * Mirrors the `RecommendationFeedback` Prisma model fields the client + * is allowed to specify. `providerType` and `promptVersion` are NOT + * part of the request shape — the server fills them from the user's + * most recent cached payload + the current PROMPT_VERSION constant + * so the client can't tamper with the attribution that the daily + * aggregator slices on for the admin AI quality dashboard. + * + * Severity + dataWindow are constrained to the same vocabulary as the + * AI schema (`aiRecommendationSchema` / `aiRecommendationRationaleSchema`) + * so a bogus rating attempt with `severity = "wat"` is rejected at the + * edge instead of poisoning the aggregator. + */ +export const recommendationFeedbackSeverity = z.enum([ + "info", + "suggestion", + "important", + "urgent", +]); + +export const recommendationFeedbackTimeRange = z.enum([ + "last7days", + "last30days", + "last90days", + "allTime", +]); + +export const recommendationFeedbackRequestSchema = z.object({ + /** + * Stable per-payload rec id. Dedup happens on + * (userId, recommendationId, recommendationText). + */ + recommendationId: z.string().min(1).max(200), + /** + * Snapshot of the rec text at submission time. Dedup partner so a + * regeneration that rewrites the same id with new text counts as a + * different row — that's intentional, the aggregator wants per-text + * signal not per-id signal. + */ + recommendationText: z.string().min(1).max(2000), + recommendationSeverity: recommendationFeedbackSeverity, + metricSourceType: z.string().min(1).max(80), + metricSourceTimeRange: recommendationFeedbackTimeRange, + helpful: z.boolean(), +}); + +export type RecommendationFeedbackRequest = z.infer< + typeof recommendationFeedbackRequestSchema +>; diff --git a/tests/integration/insights-feedback.test.ts b/tests/integration/insights-feedback.test.ts new file mode 100644 index 000000000..3d4cef0a1 --- /dev/null +++ b/tests/integration/insights-feedback.test.ts @@ -0,0 +1,229 @@ +/** + * v1.4.16 phase B5e — integration coverage for POST /api/insights/feedback. + * + * Three scenarios pinned end-to-end against the postgres testcontainer: + * 1. POST a valid payload → 201, row persisted with server-filled + * providerType (from the latest insights.generate audit row) + + * promptVersion (from the PROMPT_VERSION constant). Audit log + * entry written with action `insights.recommendation.feedback`. + * 2. Re-POST the same (recId, recText) → 409, no second row written. + * 3. Validation failure (bad severity) → 422, no row written. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedUserWithAuditedGeneration(): Promise<{ + userId: string; +}> { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "feedback-user", + email: "feedback@example.test", + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + // Seed a recent insights.generate audit row so the feedback endpoint + // can pull `chainProviderType` from it for provider attribution. + await prisma.auditLog.create({ + data: { + userId: user.id, + action: "insights.generate", + details: JSON.stringify({ + privacyMode: "aggregated", + providerType: "openai", + chainProviderType: "codex", + fallbackHopCount: 0, + tokensUsed: 1234, + model: "gpt-4o-mini", + }), + }, + }); + + return { userId: user.id }; +} + +const baseBody = { + recommendationId: "rec-1", + recommendationText: "Discuss home BP log with your physician.", + recommendationSeverity: "important", + metricSourceType: "bloodPressure", + metricSourceTimeRange: "last7days", + helpful: true, +}; + +interface FeedbackEnvelope { + data: { id: string; createdAt: string } | null; + error?: string | null; +} + +describe("POST /api/insights/feedback — integration", () => { + it("persists a valid feedback row with server-filled provider attribution", async () => { + const { userId } = await seedUserWithAuditedGeneration(); + const { POST } = await import("@/app/api/insights/feedback/route"); + + const res = await (POST as (req: Request) => Promise)( + new Request("http://localhost/api/insights/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(baseBody), + }), + ); + expect(res.status).toBe(201); + const env = (await res.json()) as FeedbackEnvelope; + expect(env.data?.id).toBeTruthy(); + + const prisma = getPrismaClient(); + const rows = await prisma.recommendationFeedback.findMany({ + where: { userId }, + }); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.recommendationId).toBe(baseBody.recommendationId); + expect(row.recommendationText).toBe(baseBody.recommendationText); + expect(row.recommendationSeverity).toBe(baseBody.recommendationSeverity); + expect(row.metricSourceType).toBe(baseBody.metricSourceType); + expect(row.metricSourceTimeRange).toBe(baseBody.metricSourceTimeRange); + expect(row.helpful).toBe(true); + // Server fills providerType + promptVersion — the body never + // supplied them. + expect(row.providerType).toBe("codex"); + expect(row.promptVersion).toBe(PROMPT_VERSION); + + // Audit row was written with the right action. + const audit = await prisma.auditLog.findFirst({ + where: { userId, action: "insights.recommendation.feedback" }, + }); + expect(audit).not.toBeNull(); + }); + + it("rejects a duplicate (recId, recText) with 409", async () => { + const { userId } = await seedUserWithAuditedGeneration(); + const { POST } = await import("@/app/api/insights/feedback/route"); + + const first = await (POST as (req: Request) => Promise)( + new Request("http://localhost/api/insights/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(baseBody), + }), + ); + expect(first.status).toBe(201); + + const second = await (POST as (req: Request) => Promise)( + new Request("http://localhost/api/insights/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(baseBody), + }), + ); + expect(second.status).toBe(409); + + const prisma = getPrismaClient(); + expect( + await prisma.recommendationFeedback.count({ where: { userId } }), + ).toBe(1); + }); + + it("rejects a bad severity with 422 and writes no row", async () => { + const { userId } = await seedUserWithAuditedGeneration(); + const { POST } = await import("@/app/api/insights/feedback/route"); + + const res = await (POST as (req: Request) => Promise)( + new Request("http://localhost/api/insights/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...baseBody, recommendationSeverity: "wat" }), + }), + ); + expect(res.status).toBe(422); + + const prisma = getPrismaClient(); + expect( + await prisma.recommendationFeedback.count({ where: { userId } }), + ).toBe(0); + }); + + it("falls back to providerType=unknown when no insights.generate audit row exists", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "fallback-user", + email: "fallback-feedback@example.test", + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + const { POST } = await import("@/app/api/insights/feedback/route"); + const res = await (POST as (req: Request) => Promise)( + new Request("http://localhost/api/insights/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(baseBody), + }), + ); + expect(res.status).toBe(201); + + const row = await prisma.recommendationFeedback.findFirst({ + where: { userId: user.id }, + }); + expect(row?.providerType).toBe("unknown"); + expect(row?.promptVersion).toBe(PROMPT_VERSION); + }); +});