fix(coach): feedback references coach_messages, drops plaintext column

Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.

Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.

Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)

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-11 02:38:17 +02:00
1 parent deadc73863
commit 650f150353
5 files changed
+182 -41

No files matched your search

+46 -9
View File
@@ -85,9 +85,8 @@ describe("POST /api/insights/chat/messages/:id/feedback", () => {
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 { POST } =
await import("@/app/api/insights/chat/messages/[id]/feedback/route");
const res = await (
POST as (
req: Request,
@@ -118,6 +117,46 @@ describe("POST /api/insights/chat/messages/:id/feedback", () => {
expect(rows[0].metricSourceType).toBe("coach:tone=warm:verbosity=default");
expect(rows[0].providerType).toBe("codex");
expect(rows[0].promptVersion).toBe("4.23.0");
// Sr-H3 reconcile: the FK now carries the message id and the
// legacy plaintext snapshot column stays NULL so the encrypted-
// at-rest invariant of `coach_messages.encryptedContent` survives.
expect(rows[0].coachMessageId).toBe(message.id);
expect(rows[0].recommendationText).toBeNull();
});
it("dedupes a second submission for the same message via the partial unique index", async () => {
const prisma = getPrismaClient();
const user = await seedSession("coach-feedback-dedup");
const message = await seedAssistantMessage(user.id, "Reply.");
const { POST } =
await import("@/app/api/insights/chat/messages/[id]/feedback/route");
const post = (id: string) =>
(
POST as (
req: Request,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>
)(
new Request(
`http://localhost/api/insights/chat/messages/${id}/feedback`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating: "helpful" }),
},
),
{ params: Promise.resolve({ id }) },
);
const first = await post(message.id);
expect(first.status).toBe(201);
const second = await post(message.id);
expect(second.status).toBe(409);
const rows = await prisma.recommendationFeedback.findMany({
where: { userId: user.id },
});
expect(rows).toHaveLength(1);
});
it("returns 404 when the message belongs to another user", async () => {
@@ -127,9 +166,8 @@ describe("POST /api/insights/chat/messages/:id/feedback", () => {
const otherMessage = await seedAssistantMessage(otherUser.id, "private");
await seedSession("attacker-h7");
const { POST } = await import(
"@/app/api/insights/chat/messages/[id]/feedback/route"
);
const { POST } =
await import("@/app/api/insights/chat/messages/[id]/feedback/route");
const res = await (
POST as (
req: Request,
@@ -153,9 +191,8 @@ describe("POST /api/insights/chat/messages/:id/feedback", () => {
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 { POST } =
await import("@/app/api/insights/chat/messages/[id]/feedback/route");
const res = await (
POST as (
req: Request,