diff --git a/docs/site-map.md b/docs/site-map.md index 6bf0e68da..f93e7cd9a 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -545,6 +545,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## API routes - `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. +- `/api/answer-feedback` - Route discovered from app directory Source: `src/app/api/answer-feedback/route.ts`. - `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`. - `/api/differentials` - Route discovered from app directory Source: `src/app/api/differentials/route.ts`. - `/api/differentials/[slug]` - Route discovered from app directory Source: `src/app/api/differentials/[slug]/route.ts`. @@ -553,6 +554,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/documents/[id]` - Document detail operations. Source: `src/app/api/documents/[id]/route.ts`. - `/api/documents/[id]/labels` - Document label operations. Source: `src/app/api/documents/[id]/labels/route.ts`. - `/api/documents/[id]/reindex` - Single-document reindex operation. Source: `src/app/api/documents/[id]/reindex/route.ts`. +- `/api/documents/[id]/reviews` - Route discovered from app directory Source: `src/app/api/documents/[id]/reviews/route.ts`. - `/api/documents/[id]/search` - Search within one document. Source: `src/app/api/documents/[id]/search/route.ts`. - `/api/documents/[id]/signed-url` - Private document signed URL. Source: `src/app/api/documents/[id]/signed-url/route.ts`. - `/api/documents/[id]/summarize` - Document summary operation. Source: `src/app/api/documents/[id]/summarize/route.ts`. diff --git a/src/app/api/answer-feedback/route.ts b/src/app/api/answer-feedback/route.ts new file mode 100644 index 000000000..8bfb8fbd3 --- /dev/null +++ b/src/app/api/answer-feedback/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; +import { isDemoMode } from "@/lib/env"; +import { jsonError, PublicApiError } from "@/lib/http"; +import { publicAccessContext } from "@/lib/public-api-access"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +const uuid = z.string().uuid(); +const bodySchema = z + .object({ + interactionId: uuid, + feedbackCategory: z.enum([ + "verified", + "needs_correction", + "source_insufficient", + "wrong_source", + "missing_source", + "unsupported_answer", + "numeric_error", + "outdated_guidance", + ]), + answerHash: z.string().regex(/^[a-f0-9]{64}$/), + citedSourceIds: z.array(uuid).max(80).optional().default([]), + sourceIds: z.array(uuid).max(80).optional().default([]), + route: z.string().trim().max(100).nullable().optional().default(null), + model: z.string().trim().max(100).nullable().optional().default(null), + providerRequestIds: z.array(z.string().trim().min(1).max(200)).max(10).optional().default([]), + }) + .strict(); + +export async function POST(request: Request) { + try { + if (isDemoMode()) + return NextResponse.json({ error: "Answer feedback is unavailable in demo mode." }, { status: 400 }); + const body = await parseJsonBody(request, bodySchema, "Invalid answer feedback."); + const supabase = createAdminClient(); + const access = await publicAccessContext(request, supabase); + const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "answer_feedback", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + if (rateLimit.limited) return rateLimitJsonResponse("Too many feedback requests. Retry shortly.", rateLimit); + + const { error } = await supabase.from("rag_answer_feedback").insert({ + interaction_id: body.interactionId, + owner_id: access.ownerId ?? null, + feedback_category: body.feedbackCategory, + answer_hash: body.answerHash, + cited_source_ids: [...new Set(body.citedSourceIds)], + source_ids: [...new Set(body.sourceIds)], + route: body.route, + model: body.model, + provider_request_ids: [...new Set(body.providerRequestIds)], + }); + if (error) { + if (error.code === "23505") { + return NextResponse.json({ error: "Feedback has already been recorded for this answer." }, { status: 409 }); + } + throw new PublicApiError("Answer feedback could not be saved.", 500, { code: "feedback_insert_failed" }); + } + return NextResponse.json({ ok: true }, { status: 201 }); + } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); + if (error instanceof PublicApiError) return jsonError(error, error.status); + return jsonError(error, 500); + } +} diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 1d815afd1..e68a05c68 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; @@ -71,13 +72,14 @@ function buildDemoAnswerPayload(body: AnswerRequestBody, fallbackReason?: string } export async function POST(request: Request) { + const interactionId = randomUUID(); const routeStartedAt = Date.now(); let body: AnswerRequestBody | null = null; try { const answerBody = await parseJsonBody(request, answerSchema, "Invalid answer request."); body = answerBody; if (isDemoMode()) { - return NextResponse.json(buildDemoAnswerPayload(answerBody)); + return NextResponse.json({ ...buildDemoAnswerPayload(answerBody), interactionId }); } const supabase = createAdminClient(); @@ -111,6 +113,7 @@ export async function POST(request: Request) { degradedMode: answerDegradedModeSignal(), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }), + interactionId, }); } @@ -165,6 +168,7 @@ export async function POST(request: Request) { degradedMode: answerDegradedModeSignal(answer), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: warnings, + interactionId, }); } @@ -182,6 +186,7 @@ export async function POST(request: Request) { degradedMode: answerDegradedModeSignal(answer), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: warnings, + interactionId, }, serverTiming ? { headers: { "Server-Timing": serverTiming } } : undefined, ); @@ -199,9 +204,10 @@ export async function POST(request: Request) { const fallbackBody = body; const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null; if (fallbackBody && fallbackReason) { - return NextResponse.json(buildDemoAnswerPayload(fallbackBody, fallbackReason), { - headers: { "X-Clinical-KB-Fallback": fallbackReason }, - }); + return NextResponse.json( + { ...buildDemoAnswerPayload(fallbackBody, fallbackReason), interactionId }, + { headers: { "X-Clinical-KB-Fallback": fallbackReason } }, + ); } return jsonError( new PublicApiError("Answer generation failed. Retry with a narrower question.", 500, { code: error.name }), diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index d47e89346..7e5c2bcdb 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; @@ -126,6 +127,7 @@ function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signal?: AbortSignal) { const ownerId = accessScope.ownerId; const encoder = new TextEncoder(); + const interactionId = randomUUID(); return new Response( new ReadableStream({ @@ -169,6 +171,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa degradedMode: answerDegradedModeSignal(), scope: { ...scope, queryMode: body.queryMode }, sourceGovernanceWarnings: sourceGovernanceWarnings({ results: [] }), + interactionId, }); return; } @@ -226,6 +229,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa degradedMode: answerDegradedModeSignal(answer), scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, + interactionId, }); return; } @@ -241,6 +245,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa degradedMode: answerDegradedModeSignal(answer), scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, + interactionId, }); } catch (error) { logStreamError(error); @@ -249,7 +254,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa // error — the UI's answer search uses this route, not /api/answer. const fallbackReason = nonProductionSupabaseDemoFallbackReason(error); if (fallbackReason) { - send("final", buildDemoStreamAnswer(body, fallbackReason)); + send("final", { ...buildDemoStreamAnswer(body, fallbackReason), interactionId }); return; } const streamError = streamErrorPayload(error); diff --git a/src/app/api/documents/[id]/reviews/route.ts b/src/app/api/documents/[id]/reviews/route.ts new file mode 100644 index 000000000..0c234bcf1 --- /dev/null +++ b/src/app/api/documents/[id]/reviews/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { env, isDemoMode } from "@/lib/env"; +import { jsonError, PublicApiError } from "@/lib/http"; +import { + checkIngestionMutationSafety, + hasActiveAgentEnrichmentJob, + ingestionMutationSafetyPayload, +} from "@/lib/ingestion-mutation-safety"; +import { invalidateRagCachesForOwner } from "@/lib/rag"; +import { isValidReviewDate, sourceReviewDecisionSchema } from "@/lib/source-review"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseJsonBody } from "@/lib/validation/body"; +import { parseRouteParams } from "@/lib/validation/params"; + +export const runtime = "nodejs"; + +const paramsSchema = z.object({ id: z.string().uuid() }); +const bodySchema = z + .object({ + decision: sourceReviewDecisionSchema, + reason: z.string().trim().min(3).max(2000), + evidenceReferences: z.array(z.string().trim().min(1).max(500)).max(30).optional().default([]), + reviewDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine(isValidReviewDate, "Review date must be a valid date that is not in the future.") + .nullable() + .optional() + .default(null), + replacementDocumentId: z.string().uuid().nullable().optional().default(null), + }) + .strict() + .superRefine((value, context) => { + if ((value.decision === "approved" || value.decision === "locally_reviewed") && !value.evidenceReferences.length) { + context.addIssue({ + code: "custom", + path: ["evidenceReferences"], + message: "Evidence is required for promotion.", + }); + } + if (value.decision === "superseded" && !value.replacementDocumentId) { + context.addIssue({ code: "custom", path: ["replacementDocumentId"], message: "A replacement is required." }); + } + }); + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id: rawId } = await params; + const { id } = parseRouteParams({ id: rawId }, paramsSchema, "Invalid document id."); + if (isDemoMode()) + return NextResponse.json({ error: "Source reviews are unavailable in demo mode." }, { status: 400 }); + + const supabase = createAdminClient(); + const user = await requireAuthenticatedUser(request, supabase); + const body = await parseJsonBody(request, bodySchema, "Invalid source review."); + const rateLimit = await consumeApiRateLimit({ supabase, ownerId: user.id, bucket: "source_review" }); + if (rateLimit.limited) return rateLimitJsonResponse("Too many source review requests. Retry shortly.", rateLimit); + + const promotesSource = body.decision === "approved" || body.decision === "locally_reviewed"; + if (promotesSource) { + const { data: document, error: documentError } = await supabase + .from("documents") + .select("id,status") + .eq("id", id) + .eq("owner_id", user.id) + .maybeSingle(); + if (documentError) throw new Error(documentError.message); + if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); + if (document.status !== "indexed") { + return NextResponse.json({ error: "Source promotion requires completed document indexing." }, { status: 409 }); + } + + const safety = await checkIngestionMutationSafety({ + supabase, + documentIds: [id], + action: "Source promotion", + checkActiveJobs: true, + staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, + }); + if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); + + const enrichmentActive = await hasActiveAgentEnrichmentJob({ + supabase, + documentId: id, + staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, + }); + if (enrichmentActive) { + return NextResponse.json({ error: "Source promotion is paused while enrichment is active." }, { status: 409 }); + } + } + + const { data, error } = await supabase.rpc("record_source_review", { + p_document_id: id, + p_reviewer_id: user.id, + p_decision: body.decision, + p_reason: body.reason, + p_evidence_references: body.evidenceReferences, + p_review_date: body.reviewDate, + p_replacement_document_id: body.replacementDocumentId, + }); + if (error) { + if (/document not found/i.test(error.message)) { + return NextResponse.json({ error: "Document not found." }, { status: 404 }); + } + throw new PublicApiError("Source review could not be recorded.", 400, { code: "source_review_rejected" }); + } + + invalidateRagCachesForOwner(user.id); + return NextResponse.json({ review: data }, { status: 201 }); + } catch (error) { + if (error instanceof AuthenticationError) return unauthorizedResponse(); + if (error instanceof PublicApiError) return jsonError(error, error.status); + return jsonError(error, 500); + } +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2fde2d1e8..7d8a99c52 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2556,7 +2556,11 @@ export function ClinicalDashboard({ async function submitAnswerFeedback(feedbackType: AnswerFeedbackType) { if (!answer || pendingFeedback) return; if (clientDemoMode) { - setActionNotice({ tone: "warning", message: "Answer review is available after signing in to a real library." }); + setActionNotice({ tone: "warning", message: "Answer review is unavailable for synthetic demo answers." }); + return; + } + if (!answer.interactionId) { + setActionNotice({ tone: "warning", message: "This answer predates traceable feedback. Run the question again." }); return; } @@ -2564,37 +2568,29 @@ export function ClinicalDashboard({ try { const sourceChunkIds = Array.from(new Set(sources.map((source) => source.id).filter(Boolean))); const citedChunkIds = Array.from(new Set(answer.citations.map((citation) => citation.chunk_id).filter(Boolean))); - const sourceFiles = Array.from( - new Set([ - ...sources.map((source) => source.file_name).filter(Boolean), - ...answer.citations.map((citation) => citation.file_name).filter(Boolean), - ]), - ); - const response = await fetch("/api/eval-cases", { + const digest = await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(answer.answer)); + const answerHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + const response = await fetch("/api/answer-feedback", { method: "POST", headers: { "Content-Type": "application/json", ...authorizationHeader, }, body: JSON.stringify({ - query, - feedbackType, - rating: feedbackType === "verified" ? "good" : "needs_fixing", - answer: answer.answer, - queryMode, - queryClass: answer.queryClass, - filters: compactScopeFilters(scopeFilters), - sourceChunkIds, - citedChunkIds, - sourceFiles, - sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message), - unverifiedNumericTokens: answer.unverifiedNumericTokens ?? [], + interactionId: answer.interactionId, + feedbackCategory: feedbackType, + answerHash, + sourceIds: sourceChunkIds, + citedSourceIds: citedChunkIds, + route: answer.routingMode ?? null, + model: answer.modelUsed ?? null, + providerRequestIds: Array.from(new Set(answer.openAIRequestIds ?? [])).slice(0, 10), }), }); if (response.status === 401) { markSessionExpired(); - setActionNotice({ tone: "warning", message: "Sign in again before saving answer review." }); + setActionNotice({ tone: "warning", message: "The session could not be validated for feedback." }); return; } if (!response.ok) { @@ -2603,10 +2599,7 @@ export function ClinicalDashboard({ } setActionNotice({ tone: "success", - message: - feedbackType === "verified" - ? "Verified answer saved for eval coverage." - : "Answer issue saved for eval coverage.", + message: feedbackType === "verified" ? "Verified answer feedback saved." : "Answer issue feedback saved.", }); } catch (feedbackError) { setActionNotice({ diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 3fb53f15e..06ecfe7f0 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -37,6 +37,8 @@ export type ApiRateLimitBucket = | "document_summarize" | "document_reindex" | "bulk_reindex" + | "source_review" + | "answer_feedback" | "registry"; export type ApiRateLimitResult = { @@ -55,6 +57,8 @@ const apiRateLimitDefaults = { document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, + source_review: { limit: 30, windowSeconds: 60 }, + answer_feedback: { limit: 30, windowSeconds: 60 }, registry: { limit: 120, windowSeconds: 60 }, } as const satisfies Record; @@ -63,6 +67,7 @@ const anonymousApiRateLimitDefaults: Partial; diff --git a/src/lib/source-review.ts b/src/lib/source-review.ts new file mode 100644 index 000000000..99643c5dd --- /dev/null +++ b/src/lib/source-review.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const sourceReviewDecisionSchema = z.enum([ + "locally_reviewed", + "approved", + "rejected", + "decommissioned", + "superseded", +]); + +export type SourceReviewDecision = z.infer; + +function perthCalendarDate(now = new Date()) { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Australia/Perth", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} + +export function isValidReviewDate(value: string, now = new Date()) { + const parsed = new Date(`${value}T00:00:00.000Z`); + return ( + Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value && value <= perthCalendarDate(now) + ); +} diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index f3fb5b476..336e98e27 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -1527,6 +1527,48 @@ export type Database = { }; Relationships: []; }; + rag_answer_feedback: { + Row: { + answer_hash: string; + cited_source_ids: string[]; + created_at: string; + feedback_category: string; + id: string; + interaction_id: string; + model: string | null; + owner_id: string | null; + provider_request_ids: string[]; + route: string | null; + source_ids: string[]; + }; + Insert: { + answer_hash: string; + cited_source_ids?: string[]; + created_at?: string; + feedback_category: string; + id?: string; + interaction_id: string; + model?: string | null; + owner_id?: string | null; + provider_request_ids?: string[]; + route?: string | null; + source_ids?: string[]; + }; + Update: { + answer_hash?: string; + cited_source_ids?: string[]; + created_at?: string; + feedback_category?: string; + id?: string; + interaction_id?: string; + model?: string | null; + owner_id?: string | null; + provider_request_ids?: string[]; + route?: string | null; + source_ids?: string[]; + }; + Relationships: []; + }; rag_queries: { Row: { answer: string | null; @@ -1925,6 +1967,54 @@ export type Database = { }, ]; }; + source_review_events: { + Row: { + created_at: string; + decision: string; + document_id: string; + evidence_references: string[]; + id: string; + new_document_status: string; + new_validation_status: string; + prior_document_status: string; + prior_validation_status: string; + reason: string; + replacement_document_id: string | null; + review_date: string | null; + reviewer_id: string; + }; + Insert: { + created_at?: string; + decision: string; + document_id: string; + evidence_references?: string[]; + id?: string; + new_document_status: string; + new_validation_status: string; + prior_document_status: string; + prior_validation_status: string; + reason: string; + replacement_document_id?: string | null; + review_date?: string | null; + reviewer_id: string; + }; + Update: { + created_at?: string; + decision?: string; + document_id?: string; + evidence_references?: string[]; + id?: string; + new_document_status?: string; + new_validation_status?: string; + prior_document_status?: string; + prior_validation_status?: string; + reason?: string; + replacement_document_id?: string | null; + review_date?: string | null; + reviewer_id?: string; + }; + Relationships: []; + }; storage_cleanup_jobs: { Row: { attempts: number; @@ -2017,6 +2107,22 @@ export type Database = { }; Returns: undefined; }; + record_source_review: { + Args: { + p_decision: string; + p_document_id: string; + p_evidence_references?: string[]; + p_reason: string; + p_replacement_document_id?: string | null; + p_review_date?: string | null; + p_reviewer_id: string; + }; + Returns: Json; + }; + purge_expired_rag_response_cache: { + Args: { p_limit?: number }; + Returns: number; + }; consume_api_subject_rate_limit: { Args: { p_subject_key: string; diff --git a/src/lib/types.ts b/src/lib/types.ts index 91c0e34b1..6d7bf05e7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -913,6 +913,7 @@ export type ComparisonMatrix = { }; export type RagAnswer = { + interactionId?: string; answer: string; grounded: boolean; confidence: "high" | "medium" | "low" | "unsupported"; diff --git a/tests/answer-feedback-route.test.ts b/tests/answer-feedback-route.test.ts new file mode 100644 index 000000000..71a34bae0 --- /dev/null +++ b/tests/answer-feedback-route.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const interactionId = "11111111-1111-4111-8111-111111111111"; +const sourceId = "22222222-2222-4222-8222-222222222222"; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +describe("answer feedback route", () => { + it("accepts privacy-minimised anonymous feedback", async () => { + const insert = vi.fn(async () => ({ error: null })); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ from: vi.fn(() => ({ insert })) }), + })); + vi.doMock("@/lib/public-api-access", () => ({ + publicAccessContext: vi.fn(async () => ({ + authenticated: false, + ownerId: undefined, + rateLimitSubject: { kind: "anonymous", subjectKey: "anon:test" }, + })), + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + allowRateLimitInMemoryFallbackOnUnavailable: () => true, + consumeSubjectApiRateLimit: vi.fn(async () => ({ limited: false })), + rateLimitJsonResponse: vi.fn(), + })); + + const { POST } = await import("../src/app/api/answer-feedback/route"); + const response = await POST( + new Request("http://localhost/api/answer-feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + interactionId, + feedbackCategory: "needs_correction", + answerHash: "a".repeat(64), + sourceIds: [sourceId], + citedSourceIds: [sourceId], + }), + }), + ); + + expect(response.status).toBe(201); + expect(insert).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_id: interactionId, + owner_id: null, + answer_hash: "a".repeat(64), + feedback_category: "needs_correction", + }), + ); + }); + + it("maps PostgreSQL unique violations to a duplicate-feedback response", async () => { + const insert = vi.fn(async () => ({ error: { code: "23505", message: "localized constraint failure" } })); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ from: vi.fn(() => ({ insert })) }), + })); + vi.doMock("@/lib/public-api-access", () => ({ + publicAccessContext: vi.fn(async () => ({ + authenticated: false, + ownerId: undefined, + rateLimitSubject: { kind: "anonymous", subjectKey: "anon:test" }, + })), + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + allowRateLimitInMemoryFallbackOnUnavailable: () => true, + consumeSubjectApiRateLimit: vi.fn(async () => ({ limited: false })), + rateLimitJsonResponse: vi.fn(), + })); + + const { POST } = await import("../src/app/api/answer-feedback/route"); + const response = await POST( + new Request("http://localhost/api/answer-feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + interactionId, + feedbackCategory: "verified", + answerHash: "b".repeat(64), + }), + }), + ); + + expect(response.status).toBe(409); + }); +}); diff --git a/tests/source-review-route.test.ts b/tests/source-review-route.test.ts new file mode 100644 index 000000000..916d92b27 --- /dev/null +++ b/tests/source-review-route.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const documentId = "11111111-1111-4111-8111-111111111111"; +const userId = "22222222-2222-4222-8222-222222222222"; + +afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); +}); + +function request(body: unknown) { + return new Request(`http://localhost/api/documents/${documentId}/reviews`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function documentQuery(status = "indexed") { + const query = { + select: vi.fn(), + eq: vi.fn(), + maybeSingle: vi.fn(async () => ({ data: { id: documentId, status }, error: null })), + }; + query.select.mockReturnValue(query); + query.eq.mockReturnValue(query); + return query; +} + +function mockAuthenticatedUser() { + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => ({ id: userId })), + unauthorizedResponse: vi.fn(), + })); +} + +describe("source review route", () => { + it("rejects approval without evidence", async () => { + vi.doMock("@/lib/env", () => ({ env: { WORKER_STALE_AFTER_MINUTES: 15 }, isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({}) })); + mockAuthenticatedUser(); + const { POST } = await import("../src/app/api/documents/[id]/reviews/route"); + const response = await POST(request({ decision: "approved", reason: "Reviewed source" }), { + params: Promise.resolve({ id: documentId }), + }); + expect(response.status).toBe(400); + }); + + it("rejects future and invalid calendar review dates", async () => { + vi.doMock("@/lib/env", () => ({ env: { WORKER_STALE_AFTER_MINUTES: 15 }, isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({}) })); + mockAuthenticatedUser(); + + const { POST } = await import("../src/app/api/documents/[id]/reviews/route"); + for (const reviewDate of ["2999-01-01", "2026-02-30"]) { + const response = await POST( + request({ + decision: "approved", + reason: "Reviewed against the signed local policy", + evidenceReferences: ["policy-signoff-2026-07"], + reviewDate, + }), + { params: Promise.resolve({ id: documentId }) }, + ); + expect(response.status).toBe(400); + } + }); + + it("rejects source promotion before indexing completes", async () => { + const rpc = vi.fn(); + const query = documentQuery("processing"); + vi.doMock("@/lib/env", () => ({ env: { WORKER_STALE_AFTER_MINUTES: 15 }, isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ from: vi.fn(() => query), rpc }), + })); + mockAuthenticatedUser(); + vi.doMock("@/lib/api-rate-limit", () => ({ + consumeApiRateLimit: vi.fn(async () => ({ limited: false })), + rateLimitJsonResponse: vi.fn(), + })); + + const { POST } = await import("../src/app/api/documents/[id]/reviews/route"); + const response = await POST( + request({ + decision: "approved", + reason: "Reviewed against the signed local policy", + evidenceReferences: ["policy-signoff-2026-07"], + }), + { params: Promise.resolve({ id: documentId }) }, + ); + + expect(response.status).toBe(409); + expect(rpc).not.toHaveBeenCalled(); + }); + + it("records an evidence-bearing review transaction and then invalidates caches", async () => { + const rpc = vi.fn(async () => ({ data: { id: "review-1" }, error: null })); + const invalidateRagCachesForOwner = vi.fn(); + const checkIngestionMutationSafety = vi.fn(async () => ({ + ok: true as const, + checkedAt: "2026-07-13T00:00:00.000Z", + reason: "ready" as const, + message: "safe", + activeJobs: [] as [], + staleProcessingJobs: [] as [], + })); + const hasActiveAgentEnrichmentJob = vi.fn(async () => false); + const query = documentQuery(); + vi.doMock("@/lib/env", () => ({ env: { WORKER_STALE_AFTER_MINUTES: 15 }, isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ from: vi.fn(() => query), rpc }), + })); + mockAuthenticatedUser(); + vi.doMock("@/lib/api-rate-limit", () => ({ + consumeApiRateLimit: vi.fn(async () => ({ limited: false })), + rateLimitJsonResponse: vi.fn(), + })); + vi.doMock("@/lib/ingestion-mutation-safety", () => ({ + checkIngestionMutationSafety, + hasActiveAgentEnrichmentJob, + ingestionMutationSafetyPayload: vi.fn(), + })); + vi.doMock("@/lib/rag", () => ({ invalidateRagCachesForOwner })); + + const { POST } = await import("../src/app/api/documents/[id]/reviews/route"); + const response = await POST( + request({ + decision: "approved", + reason: "Reviewed against the signed local policy", + evidenceReferences: ["policy-signoff-2026-07"], + reviewDate: "2026-07-11", + }), + { params: Promise.resolve({ id: documentId }) }, + ); + + expect(response.status).toBe(201); + expect(rpc).toHaveBeenCalledWith("record_source_review", expect.objectContaining({ p_reviewer_id: userId })); + expect(checkIngestionMutationSafety).toHaveBeenCalledWith(expect.objectContaining({ documentIds: [documentId] })); + expect(hasActiveAgentEnrichmentJob).toHaveBeenCalledWith(expect.objectContaining({ documentId })); + expect(invalidateRagCachesForOwner).toHaveBeenCalledWith(userId); + }); +});