` — admin view onto the v1.4.23 H7 Coach
+ * helpful/unhelpful aggregate. Reads the same feedback-aggregator row
+ * that the AI Quality view consumes; this section just slices the
+ * Coach-only buckets so the operator can see prompt-version × helpful-
+ * rate × n at a glance.
+ *
+ * First useful question this answers in the v1.4.23 first-week window:
+ * "Is the v1.4.22 Coach prose rewrite (PROMPT_VERSION 4.22.0 → 4.23.x)
+ * landing well, or did the warm tone overshoot?" If the helpful-rate
+ * for the new prompt version drops below 50 % within the first 100
+ * ratings, v1.4.24 walks the persona back.
+ *
+ * Empty state when the aggregator hasn't run or no Coach feedback has
+ * landed in the rolling window. Cross-user aggregation only — the
+ * admin view never exposes per-user prose.
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { Loader2, Sparkles } from "lucide-react";
+
+import { useTranslations, useFormatters } from "@/lib/i18n/context";
+
+interface CoachFeedbackBucket {
+ promptVersion: string;
+ tone: string;
+ verbosity: string;
+ helpful: number;
+ notHelpful: number;
+ total: number;
+ helpfulRate: number;
+}
+
+interface FeedbackSummary {
+ generatedAt: string;
+ windowDays: number;
+ coachBuckets?: CoachFeedbackBucket[];
+}
+
+interface CoachFeedbackResponse {
+ data: { summary: FeedbackSummary | null } | null;
+ error?: string | null;
+}
+
+function helpfulRateColour(rate: number): string {
+ if (rate >= 0.8) return "text-dracula-green";
+ if (rate >= 0.5) return "text-dracula-yellow";
+ return "text-dracula-orange";
+}
+
+export function CoachFeedbackSection() {
+ const { t } = useTranslations();
+ const fmt = useFormatters();
+
+ const query = useQuery({
+ queryKey: ["admin", "coach-feedback"],
+ queryFn: async () => {
+ const res = await fetch("/api/admin/ai-quality");
+ const json = (await res.json()) as CoachFeedbackResponse;
+ if (!res.ok) throw new Error(json.error ?? "coach_feedback_failed");
+ return json.data?.summary ?? null;
+ },
+ });
+
+ if (query.isLoading) {
+ return (
+
+
+
+ {t("admin.coachFeedback.loading")}
+
+
+ );
+ }
+
+ if (query.isError) {
+ return (
+
+ {t("admin.coachFeedback.loadError")}
+
+ );
+ }
+
+ const summary = query.data;
+ const buckets = summary?.coachBuckets ?? [];
+
+ if (!summary || buckets.length === 0) {
+ return (
+
+
+
+
+ {t("admin.coachFeedback.title")}
+
+
+
+ {t("admin.coachFeedback.empty")}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {t("admin.coachFeedback.title")}
+
+
+
+ {t("admin.coachFeedback.windowLabel", {
+ days: String(summary.windowDays),
+ })}
+ {" · "}
+ {t("admin.coachFeedback.generatedAtLabel")}{" "}
+ {fmt.dateTime(summary.generatedAt)}
+
+
+
+
+
+
+
+ |
+ {t("admin.coachFeedback.colPromptVersion")}
+ |
+
+ {t("admin.coachFeedback.colTone")}
+ |
+
+ {t("admin.coachFeedback.colVerbosity")}
+ |
+
+ {t("admin.coachFeedback.colHelpful")}
+ |
+
+ {t("admin.coachFeedback.colNotHelpful")}
+ |
+
+ {t("admin.coachFeedback.colN")}
+ |
+
+ {t("admin.coachFeedback.colHelpfulRate")}
+ |
+
+
+
+ {buckets.map((bucket, idx) => {
+ const ratePct = Math.round(bucket.helpfulRate * 100);
+ return (
+
+ |
+ {bucket.promptVersion}
+ |
+ {bucket.tone} |
+ {bucket.verbosity} |
+ {bucket.helpful} |
+
+ {bucket.notHelpful}
+ |
+ {bucket.total} |
+
+ {ratePct}%
+ |
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts
index 68d13b470..895ddfded 100644
--- a/src/components/admin/section-slugs.ts
+++ b/src/components/admin/section-slugs.ts
@@ -15,6 +15,7 @@ export const ADMIN_SECTION_SLUGS = [
"services",
"integrations",
"ai-quality",
+ "coach-feedback",
"feedback",
"reminders",
"users",
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index beccb1b7e..629801cdc 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -1,8 +1,15 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { Bot, ChevronRight, Sparkles, User } from "lucide-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ Bot,
+ ChevronRight,
+ Sparkles,
+ ThumbsDown,
+ ThumbsUp,
+ User,
+} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -180,6 +187,7 @@ export function MessageThread({
metricSource={m.metricSource}
providerType={m.providerType}
evidenceDefaultOpen={evidenceDefaultOpen}
+ messageId={m.id}
/>
);
})}
@@ -228,6 +236,12 @@ interface ChatBubbleProps {
errorCode?: string | null;
/** v1.4.23 H4 — when true the evidence `` mounts open. */
evidenceDefaultOpen?: boolean;
+ /**
+ * v1.4.23 H7 — present only on persisted assistant messages.
+ * Streaming bubbles (no message id yet) skip the thumbs row so the
+ * user can't rate before the message lands on disk.
+ */
+ messageId?: string;
}
function ChatBubble({
@@ -238,6 +252,7 @@ function ChatBubble({
inProgress,
errorCode,
evidenceDefaultOpen,
+ messageId,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -383,7 +398,87 @@ function ChatBubble({
)}
+ {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted
+ assistant messages get the row (skipped for refusals,
+ errors, in-flight stream bubbles). The aggregator buckets
+ the rating by (promptVersion, tone, verbosity). */}
+ {messageId &&
+ !inProgress &&
+ !errorCode &&
+ providerType !== "refusal" && (
+
+ )}
);
}
+
+interface CoachMessageFeedbackProps {
+ messageId: string;
+}
+
+function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
+ const { t } = useTranslations();
+ const [submittedRating, setSubmittedRating] = useState<
+ "helpful" | "unhelpful" | null
+ >(null);
+
+ const submit = useMutation({
+ mutationFn: async (rating: "helpful" | "unhelpful") => {
+ const res = await fetch(
+ `/api/insights/chat/messages/${messageId}/feedback`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating }),
+ },
+ );
+ // Treat 409 (already_rated) as a successful no-op so the user
+ // never sees an error toast for double-clicking the same chip.
+ if (!res.ok && res.status !== 409) {
+ throw new Error("coach-feedback.failed");
+ }
+ return rating;
+ },
+ onSuccess: (rating) => setSubmittedRating(rating),
+ });
+
+ if (submittedRating) {
+ return (
+
+ {t("insights.coach.feedbackThanks")}
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/lib/ai/feedback-attribution.ts b/src/lib/ai/feedback-attribution.ts
index 315148550..a00760942 100644
--- a/src/lib/ai/feedback-attribution.ts
+++ b/src/lib/ai/feedback-attribution.ts
@@ -81,3 +81,65 @@ export async function resolveFeedbackAttribution(
promptVersion: PROMPT_VERSION,
};
}
+
+/**
+ * v1.4.23 H7 — attribution shape for a Coach assistant-message
+ * thumbs row. The polymorphic `RecommendationFeedback` table reuses
+ * its existing slots for Coach rows:
+ * - `recommendationId` carries the Coach message id
+ * - `recommendationText` carries the assistant prose snapshot
+ * - `recommendationSeverity` is always "coach" so dashboards can
+ * filter / group on the literal
+ * - `metricSourceType` encodes the user's active prefs at the
+ * moment of the rating: `coach:tone=warm:verbosity=default`. The
+ * aggregator buckets on this field so a future audit can slice
+ * helpful-rate per (tone, verbosity) without a join.
+ * - `metricSourceTimeRange` is always "single_message"
+ * - `target_type` is "coach"
+ */
+export interface CoachFeedbackAttribution extends FeedbackAttribution {
+ metricSourceType: string;
+}
+
+export function buildCoachMetricSourceType(
+ tone: string,
+ verbosity: string,
+): string {
+ return `coach:tone=${tone}:verbosity=${verbosity}`;
+}
+
+/**
+ * Resolve the (providerType, promptVersion, metricSourceType) tuple
+ * for a Coach feedback row. Reads the user's latest persisted
+ * Coach assistant message to recover the providerType + promptVersion
+ * stamped on it, and queries the user's active `coachPrefsJson` for
+ * the tone/verbosity slice. Falls back to safe defaults when either
+ * lookup misses.
+ */
+export async function resolveCoachFeedbackAttribution(
+ userId: string,
+ messageId: string,
+): Promise {
+ const [message, userRow] = await Promise.all([
+ prisma.coachMessage.findFirst({
+ where: { id: messageId, conversation: { userId } },
+ select: { providerType: true, promptVersion: true },
+ }),
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: { coachPrefsJson: true },
+ }),
+ ]);
+
+ // Lazy-import to avoid circular imports — coach-prefs validation
+ // doesn't depend on attribution but the inverse would create a cycle.
+ const { parseCoachPrefs } = await import("@/lib/validations/coach-prefs");
+ const prefs = parseCoachPrefs(userRow?.coachPrefsJson);
+
+ return {
+ providerType:
+ message?.providerType ?? FEEDBACK_ATTRIBUTION_FALLBACK.providerType,
+ promptVersion: message?.promptVersion ?? PROMPT_VERSION,
+ metricSourceType: buildCoachMetricSourceType(prefs.tone, prefs.verbosity),
+ };
+}
diff --git a/src/lib/jobs/__tests__/feedback-aggregator.test.ts b/src/lib/jobs/__tests__/feedback-aggregator.test.ts
index dde4a2a5f..fb998cdb9 100644
--- a/src/lib/jobs/__tests__/feedback-aggregator.test.ts
+++ b/src/lib/jobs/__tests__/feedback-aggregator.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
aggregateRecommendationFeedback,
+ buildCoachFeedbackBuckets,
buildFeedbackBuckets,
DEFAULT_FEEDBACK_AGGREGATION_WINDOW_DAYS,
} from "../feedback-aggregator";
@@ -12,6 +13,7 @@ interface FeedbackRow {
providerType: string;
promptVersion: string;
helpful: boolean;
+ targetType?: string;
}
function makePrismaMock(rows: FeedbackRow[]) {
@@ -165,5 +167,98 @@ describe("aggregateRecommendationFeedback", () => {
expect(upsertCall.update.adminAiInsightsFeedbackSummary.buckets).toEqual(
[],
);
+ // v1.4.23 H7 — coachBuckets always present, empty by default.
+ expect(
+ upsertCall.update.adminAiInsightsFeedbackSummary.coachBuckets,
+ ).toEqual([]);
+ });
+});
+
+/**
+ * v1.4.23 H7 — Coach-only bucket view. The aggregator slices the
+ * unified RecommendationFeedback rows on `targetType === "coach"` and
+ * groups by (promptVersion, tone, verbosity).
+ */
+describe("buildCoachFeedbackBuckets", () => {
+ it("ignores non-Coach rows", () => {
+ const rows: FeedbackRow[] = [
+ {
+ recommendationSeverity: "important",
+ metricSourceType: "bloodPressure",
+ providerType: "codex",
+ promptVersion: "4.16.0",
+ helpful: true,
+ targetType: "recommendation",
+ },
+ ];
+ expect(buildCoachFeedbackBuckets(rows)).toEqual([]);
+ });
+
+ it("groups Coach rows by (promptVersion, tone, verbosity)", () => {
+ const rows: FeedbackRow[] = [
+ {
+ recommendationSeverity: "coach",
+ metricSourceType: "coach:tone=warm:verbosity=default",
+ providerType: "codex",
+ promptVersion: "4.23.0",
+ helpful: true,
+ targetType: "coach",
+ },
+ {
+ recommendationSeverity: "coach",
+ metricSourceType: "coach:tone=warm:verbosity=default",
+ providerType: "codex",
+ promptVersion: "4.23.0",
+ helpful: true,
+ targetType: "coach",
+ },
+ {
+ recommendationSeverity: "coach",
+ metricSourceType: "coach:tone=warm:verbosity=default",
+ providerType: "codex",
+ promptVersion: "4.23.0",
+ helpful: false,
+ targetType: "coach",
+ },
+ {
+ recommendationSeverity: "coach",
+ metricSourceType: "coach:tone=concise:verbosity=brief",
+ providerType: "codex",
+ promptVersion: "4.23.0",
+ helpful: true,
+ targetType: "coach",
+ },
+ ];
+ const buckets = buildCoachFeedbackBuckets(rows);
+ expect(buckets).toHaveLength(2);
+ const warm = buckets.find(
+ (b) => b.tone === "warm" && b.verbosity === "default",
+ );
+ expect(warm).toBeDefined();
+ expect(warm?.helpful).toBe(2);
+ expect(warm?.notHelpful).toBe(1);
+ expect(warm?.helpfulRate).toBeCloseTo(0.67, 2);
+ const concise = buckets.find(
+ (b) => b.tone === "concise" && b.verbosity === "brief",
+ );
+ expect(concise?.total).toBe(1);
+ expect(concise?.helpfulRate).toBe(1);
+ });
+
+ it("falls back to tone=unknown when metricSourceType doesn't match the encoded shape", () => {
+ const rows: FeedbackRow[] = [
+ {
+ recommendationSeverity: "coach",
+ metricSourceType: "weird-shape",
+ providerType: "codex",
+ promptVersion: "4.23.0",
+ helpful: false,
+ targetType: "coach",
+ },
+ ];
+ const buckets = buildCoachFeedbackBuckets(rows);
+ expect(buckets).toHaveLength(1);
+ expect(buckets[0].tone).toBe("unknown");
+ expect(buckets[0].verbosity).toBe("unknown");
});
});
diff --git a/src/lib/jobs/feedback-aggregator.ts b/src/lib/jobs/feedback-aggregator.ts
index d551ced42..6e4d7ec8b 100644
--- a/src/lib/jobs/feedback-aggregator.ts
+++ b/src/lib/jobs/feedback-aggregator.ts
@@ -35,12 +35,41 @@ export interface FeedbackBucket {
total: number;
/** Fraction in [0..1], rounded to 2 decimals. Always 0 when total=0. */
helpfulRate: number;
+ /**
+ * v1.4.23 H7 — surface origin so the admin dashboard can group
+ * recommendation rows separately from Coach assistant-message rows
+ * (the existing buckets blended both before this field landed).
+ */
+ targetType: string;
+}
+
+/**
+ * v1.4.23 H7 — slim view onto the Coach-only rows so the admin
+ * coach-feedback dashboard can render the first-week aggregate without
+ * re-deriving the slice from the recommendation buckets.
+ */
+export interface CoachFeedbackBucket {
+ promptVersion: string;
+ /** Encoded as `tone=warm|neutral|concise` */
+ tone: string;
+ /** Encoded as `verbosity=brief|default|detailed` */
+ verbosity: string;
+ helpful: number;
+ notHelpful: number;
+ total: number;
+ helpfulRate: number;
}
export interface FeedbackAggregationSummary {
generatedAt: string;
windowDays: number;
buckets: FeedbackBucket[];
+ /**
+ * v1.4.23 H7 — Coach assistant-message buckets sliced by
+ * (promptVersion, tone, verbosity). Empty array when the
+ * aggregation window has no Coach feedback rows.
+ */
+ coachBuckets: CoachFeedbackBucket[];
}
interface MinimalFeedbackRow {
@@ -49,6 +78,13 @@ interface MinimalFeedbackRow {
providerType: string;
promptVersion: string;
helpful: boolean;
+ /**
+ * v1.4.23 H7 — present on every row from the new schema. Defaults to
+ * "recommendation" for legacy rows so the bucketing still works
+ * during the upgrade window when the migration has run but the route
+ * hasn't started writing the field yet.
+ */
+ targetType?: string;
}
/**
@@ -67,7 +103,9 @@ export function buildFeedbackBuckets(
): FeedbackBucket[] {
const map = new Map();
for (const row of rows) {
+ const targetType = row.targetType ?? "recommendation";
const key = [
+ targetType,
row.recommendationSeverity,
row.metricSourceType,
row.providerType,
@@ -88,6 +126,7 @@ export function buildFeedbackBuckets(
notHelpful: row.helpful ? 0 : 1,
total: 1,
helpfulRate: 0,
+ targetType,
});
}
}
@@ -100,6 +139,7 @@ export function buildFeedbackBuckets(
buckets.sort((a, b) => {
return (
+ a.targetType.localeCompare(b.targetType) ||
a.severity.localeCompare(b.severity) ||
a.metricSourceType.localeCompare(b.metricSourceType) ||
a.providerType.localeCompare(b.providerType) ||
@@ -110,6 +150,69 @@ export function buildFeedbackBuckets(
return buckets;
}
+/**
+ * v1.4.23 H7 — derive the Coach-only bucket view from the unified
+ * recommendation feedback rows. The Coach route writes the user's
+ * active prefs into `metricSourceType` as
+ * `coach:tone=:verbosity=`; this helper parses that back out so
+ * the admin dashboard can group on the typed dimensions without
+ * re-running a regex per row.
+ *
+ * Rows whose `targetType !== "coach"` are ignored. Rows whose
+ * `metricSourceType` does not match the encoded shape land under
+ * `tone=unknown:verbosity=unknown` so an early-write pre-migration row
+ * still contributes to a bucket rather than silently dropping.
+ */
+export function buildCoachFeedbackBuckets(
+ rows: MinimalFeedbackRow[],
+): CoachFeedbackBucket[] {
+ const map = new Map();
+ for (const row of rows) {
+ if ((row.targetType ?? "recommendation") !== "coach") continue;
+ const { tone, verbosity } = parseCoachMetricSource(row.metricSourceType);
+ const key = `${row.promptVersion}|${tone}|${verbosity}`;
+ const existing = map.get(key);
+ if (existing) {
+ if (row.helpful) existing.helpful += 1;
+ else existing.notHelpful += 1;
+ existing.total += 1;
+ } else {
+ map.set(key, {
+ promptVersion: row.promptVersion,
+ tone,
+ verbosity,
+ helpful: row.helpful ? 1 : 0,
+ notHelpful: row.helpful ? 0 : 1,
+ total: 1,
+ helpfulRate: 0,
+ });
+ }
+ }
+ const buckets = [...map.values()];
+ for (const b of buckets) {
+ b.helpfulRate =
+ b.total === 0 ? 0 : Math.round((b.helpful / b.total) * 100) / 100;
+ }
+ buckets.sort(
+ (a, b) =>
+ a.promptVersion.localeCompare(b.promptVersion) ||
+ a.tone.localeCompare(b.tone) ||
+ a.verbosity.localeCompare(b.verbosity),
+ );
+ return buckets;
+}
+
+const COACH_METRIC_SOURCE_RE = /^coach:tone=([^:]+):verbosity=([^:]+)$/;
+
+function parseCoachMetricSource(metricSourceType: string): {
+ tone: string;
+ verbosity: string;
+} {
+ const match = metricSourceType.match(COACH_METRIC_SOURCE_RE);
+ if (!match) return { tone: "unknown", verbosity: "unknown" };
+ return { tone: match[1], verbosity: match[2] };
+}
+
export interface AggregateOptions {
/** Override "now" for deterministic tests. Defaults to `new Date()`. */
now?: Date;
@@ -139,6 +242,7 @@ export async function aggregateRecommendationFeedback(
providerType: true,
promptVersion: true,
helpful: true,
+ targetType: true,
},
});
@@ -146,6 +250,7 @@ export async function aggregateRecommendationFeedback(
generatedAt: now.toISOString(),
windowDays,
buckets: buildFeedbackBuckets(rows),
+ coachBuckets: buildCoachFeedbackBuckets(rows),
};
// The singleton AppSettings row is auto-created on first install
diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts
index 7eea9af99..aa263f9ee 100644
--- a/src/lib/openapi/routes.ts
+++ b/src/lib/openapi/routes.ts
@@ -103,6 +103,17 @@ coachPrefsSchema.meta({
"Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.",
});
+const coachMessageFeedbackBody = z
+ .object({
+ rating: z.enum(["helpful", "unhelpful"]),
+ reason: z.string().min(1).max(200).optional(),
+ })
+ .meta({
+ id: "CoachMessageFeedbackRequest",
+ description:
+ "Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.",
+ });
+
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
const passkeyLoginVerifyRequest = z
@@ -526,6 +537,45 @@ export const openApiPaths: NonNullable = {
},
},
},
+ "/api/insights/chat/messages/{id}/feedback": {
+ post: {
+ tags: ["Insights"],
+ summary: "Rate a Coach assistant message",
+ description:
+ "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).",
+ requestBody: {
+ required: true,
+ content: {
+ "application/json": { schema: coachMessageFeedbackBody },
+ },
+ },
+ responses: {
+ "201": {
+ description: "Feedback saved.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ z.object({
+ id: z.string(),
+ createdAt: z.iso.datetime({ offset: true }),
+ }),
+ "CoachMessageFeedbackResponse",
+ ),
+ },
+ },
+ },
+ "404": {
+ description: "Message not found or not owned by the caller.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ "409": {
+ description: "Caller has already rated this message text.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/insights/comprehensive": {
get: {
tags: ["Insights"],
diff --git a/tests/integration/coach-feedback.test.ts b/tests/integration/coach-feedback.test.ts
new file mode 100644
index 000000000..eca885a48
--- /dev/null
+++ b/tests/integration/coach-feedback.test.ts
@@ -0,0 +1,177 @@
+/**
+ * v1.4.23 H7 — Coach assistant-message thumbs feedback route.
+ *
+ * Round-trip: seed a session + a coach conversation + an assistant
+ * message, POST a thumbs rating, assert a `RecommendationFeedback` row
+ * landed with `targetType="coach"` and the encoded prefs metric source.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { encrypt } from "@/lib/crypto";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+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 seedSession(username: string) {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: { username, email: `${username}@example.test` },
+ });
+ const session = await prisma.session.create({
+ data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return user;
+}
+
+async function seedAssistantMessage(userId: string, content: string) {
+ const prisma = getPrismaClient();
+ const conversation = await prisma.coachConversation.create({
+ data: { userId, title: "Test conversation" },
+ });
+ const ciphertext = encrypt(content);
+ return prisma.coachMessage.create({
+ data: {
+ conversationId: conversation.id,
+ role: "assistant",
+ encryptedContent: Buffer.from(ciphertext, "utf8"),
+ providerType: "codex",
+ promptVersion: "4.23.0",
+ },
+ });
+}
+
+describe("POST /api/insights/chat/messages/:id/feedback", () => {
+ it("persists a thumbs-up with target_type=coach and the encoded metric source", async () => {
+ const prisma = getPrismaClient();
+ const user = await seedSession("coach-feedback-h7");
+ const message = await seedAssistantMessage(user.id, "Your last week...");
+
+ const { POST } = await import(
+ "@/app/api/insights/chat/messages/[id]/feedback/route"
+ );
+ const res = await (
+ POST as (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise
+ )(
+ new Request(
+ `http://localhost/api/insights/chat/messages/${message.id}/feedback`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating: "helpful" }),
+ },
+ ),
+ { params: Promise.resolve({ id: message.id }) },
+ );
+ expect(res.status).toBe(201);
+
+ const rows = await prisma.recommendationFeedback.findMany({
+ where: { userId: user.id },
+ });
+ expect(rows).toHaveLength(1);
+ expect(rows[0].targetType).toBe("coach");
+ expect(rows[0].helpful).toBe(true);
+ expect(rows[0].recommendationSeverity).toBe("coach");
+ // Default prefs encode as warm/default — the user has no
+ // coachPrefsJson row so the route falls back to defaults.
+ expect(rows[0].metricSourceType).toBe("coach:tone=warm:verbosity=default");
+ expect(rows[0].providerType).toBe("codex");
+ expect(rows[0].promptVersion).toBe("4.23.0");
+ });
+
+ it("returns 404 when the message belongs to another user", async () => {
+ const otherUser = await getPrismaClient().user.create({
+ data: { username: "other-coach-h7" },
+ });
+ const otherMessage = await seedAssistantMessage(otherUser.id, "private");
+ await seedSession("attacker-h7");
+
+ const { POST } = await import(
+ "@/app/api/insights/chat/messages/[id]/feedback/route"
+ );
+ const res = await (
+ POST as (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise
+ )(
+ new Request(
+ `http://localhost/api/insights/chat/messages/${otherMessage.id}/feedback`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating: "helpful" }),
+ },
+ ),
+ { params: Promise.resolve({ id: otherMessage.id }) },
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it("rejects an unknown rating value with 422", async () => {
+ const user = await seedSession("coach-feedback-422");
+ const message = await seedAssistantMessage(user.id, "Reply.");
+
+ const { POST } = await import(
+ "@/app/api/insights/chat/messages/[id]/feedback/route"
+ );
+ const res = await (
+ POST as (
+ req: Request,
+ ctx: { params: Promise<{ id: string }> },
+ ) => Promise
+ )(
+ new Request(
+ `http://localhost/api/insights/chat/messages/${message.id}/feedback`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating: "meh" }),
+ },
+ ),
+ { params: Promise.resolve({ id: message.id }) },
+ );
+ expect(res.status).toBe(422);
+ });
+});