feat(api): /api/insights/feedback persists thumbs with provider attribution

v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.

Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.

The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.

Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.

Tests:
  - 7 unit tests for the Zod request schema (valid path, severity
    poisoning, time-range poisoning, empty fields, stringified bool,
    extra-field stripping)
  - 5 unit tests for `pickProviderType()` priority + fallback
  - 4 integration tests: 201 happy path with provider attribution,
    409 dedup, 422 validation, fallback to unknown when no audit row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:30:06 +02:00
1 parent badc893853
commit 8255e3e49a
6 files changed
+591

No files matched your search

+103
View File
@@ -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<unknown>(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));