feat(db): RecommendationFeedback model with GDPR cascade + provider attribution

v1.4.16 phase B5e: ship the table that backs per-recommendation
thumbs-up / thumbs-down feedback. Each row captures (userId, recId,
recText) with snapshotted severity + metric-source attributes plus the
provider + prompt-version that produced the rec — server-fills the
attribution from the user's most recent cached payload so the client
can't tamper.

Dedup key (userId, recommendationId, recommendationText) lets a
regeneration that rewrites the same id with new text re-rate cleanly
while blocking accidental double-submission of the same (id + text).

Indexes: (userId, createdAt) for the per-user timeline and
(severity, helpful, createdAt) for the daily aggregator's slice. Both
are needed by the v1.4.16 admin AI quality preview.

GDPR: ON DELETE CASCADE through users; cascade-delete integration
test now seeds and asserts a feedback row.

Same migration adds AppSettings.adminAiInsightsFeedbackSummary — the
JSON blob the daily aggregator (next commit) writes a per-(severity x
provider x prompt) helpful-rate to. Null until the aggregator runs
or until there is feedback in the 30-day window.

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:25:57 +02:00
1 parent 170c62aac9
commit badc893853
3 files changed
+132

No files matched your search

@@ -0,0 +1,56 @@
-- v1.4.16 phase B5e: per-recommendation thumbs-up/thumbs-down feedback.
--
-- Each row captures one rating event from one user against one
-- recommendation. The dedup key (userId, recommendationId,
-- recommendationText) lets a regeneration that rewrites the same id
-- with new text re-rate cleanly while preventing accidental double-
-- submission of the same (id + text) pair.
--
-- Provider attribution (providerType + promptVersion) is filled
-- server-side from the user's most recent cached insight payload, so
-- the client can't tamper with which provider / prompt version a
-- thumbs-down is attributed to. The aggregator slices on these to
-- power the admin AI quality dashboard.
--
-- Indexes:
-- (userId, createdAt) — per-user timeline lookups
-- (severity, helpful, createdAt) — aggregator's cross-user slice
-- The unique key doubles as a (userId, recommendationId, text) lookup.
--
-- GDPR: ON DELETE CASCADE through users → personal data follows the
-- account; covered in tests/integration/cascade-delete.test.ts.
CREATE TABLE "recommendation_feedback" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"recommendation_id" TEXT NOT NULL,
"recommendation_text" TEXT NOT NULL,
"recommendation_severity" TEXT NOT NULL,
"metric_source_type" TEXT NOT NULL,
"metric_source_time_range" TEXT NOT NULL,
"helpful" BOOLEAN NOT NULL,
"provider_type" TEXT NOT NULL,
"prompt_version" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "recommendation_feedback_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "rec_feedback_user_rec_text_key"
ON "recommendation_feedback" ("user_id", "recommendation_id", "recommendation_text");
CREATE INDEX "recommendation_feedback_user_id_created_at_idx"
ON "recommendation_feedback" ("user_id", "created_at");
CREATE INDEX "recommendation_feedback_recommendation_severity_helpful_cre_idx"
ON "recommendation_feedback" ("recommendation_severity", "helpful", "created_at");
ALTER TABLE "recommendation_feedback"
ADD CONSTRAINT "recommendation_feedback_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- v1.4.16 phase B5e: daily-aggregated feedback summary written by the
-- pg-boss `feedback-aggregator` job. Read by /admin/ai-quality.
ALTER TABLE "app_settings"
ADD COLUMN "admin_ai_insights_feedback_summary" JSONB;
+57
View File
@@ -129,6 +129,7 @@ model User {
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
@@ -547,6 +548,20 @@ model AppSettings {
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
// v1.4.16 phase B5e — daily-aggregated rec-feedback summary written
// by `feedback-aggregator.ts`. Shape:
// {
// "generatedAt": "2026-05-09T04:00:00.000Z",
// "windowDays": 30,
// "buckets": [
// { "severity": "important", "metricSourceType": "bloodPressure",
// "providerType": "codex", "promptVersion": "4.16.0",
// "helpful": 12, "notHelpful": 3, "total": 15, "helpfulRate": 0.8 }
// ]
// }
// Null = aggregator hasn't run yet OR there's no feedback in the window.
adminAiInsightsFeedbackSummary Json? @map("admin_ai_insights_feedback_summary")
@@map("app_settings")
}
@@ -792,3 +807,45 @@ model HostMetric {
@@index([capturedAt])
@@map("host_metrics")
}
// ─── Recommendation Feedback (v1.4.16 phase B5e) ───────────────
//
// Per-recommendation thumbs-up / thumbs-down rating. Captured client-
// side under each rec card; persisted with provider attribution
// (providerType + promptVersion) so the daily aggregator can slice
// helpful-rate per (severity x provider x prompt) for the admin AI
// quality dashboard.
//
// Default-on for the single-user case (only the user's ratings shape
// the user's future prompts); cross-user aggregate stays admin-only
// (no prompt-tuning flow yet — that ratchet is v1.4.17).
//
// Privacy: server-side fills providerType + promptVersion from the
// user's most recent cached payload (User.insightsCachedText) so the
// client can't tamper with the attribution.
//
// `(userId, recommendationId, recommendationText)` is the dedup key.
// recommendationText snapshots the rec at submission time so a re-
// generation 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.
model RecommendationFeedback {
id String @id @default(cuid())
userId String @map("user_id")
recommendationId String @map("recommendation_id")
recommendationText String @map("recommendation_text")
recommendationSeverity String @map("recommendation_severity")
metricSourceType String @map("metric_source_type")
metricSourceTimeRange String @map("metric_source_time_range")
helpful Boolean
providerType String @map("provider_type")
promptVersion String @map("prompt_version")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, recommendationId, recommendationText], map: "rec_feedback_user_rec_text_key")
@@index([userId, createdAt])
@@index([recommendationSeverity, helpful, createdAt])
@@map("recommendation_feedback")
}
+19
View File
@@ -97,6 +97,22 @@ describe("user.delete cascades to all personal-data tables", () => {
},
});
// v1.4.16 phase B5e: rec-feedback rows must follow the user on
// erasure (provider attribution makes them personal data).
await prisma.recommendationFeedback.create({
data: {
userId: user.id,
recommendationId: "rec-cascade-1",
recommendationText: "Discuss home BP log with your physician.",
recommendationSeverity: "important",
metricSourceType: "bloodPressure",
metricSourceTimeRange: "last7days",
helpful: true,
providerType: "codex",
promptVersion: "4.16.0",
},
});
await prisma.idempotencyKey.create({
data: {
userId: user.id,
@@ -168,6 +184,9 @@ describe("user.delete cascades to all personal-data tables", () => {
expect(
await prisma.userAchievement.count({ where: { userId: user.id } }),
).toBe(0);
expect(
await prisma.recommendationFeedback.count({ where: { userId: user.id } }),
).toBe(0);
expect(
await prisma.idempotencyKey.count({ where: { userId: user.id } }),
).toBe(0);