From badc89385310b0e02002f73fdbc2df7134d1c172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:25:57 +0200 Subject: [PATCH] feat(db): RecommendationFeedback model with GDPR cascade + provider attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migration.sql | 56 ++++++++++++++++++ prisma/schema.prisma | 57 +++++++++++++++++++ tests/integration/cascade-delete.test.ts | 19 +++++++ 3 files changed, 132 insertions(+) create mode 100644 prisma/migrations/0034_recommendation_feedback/migration.sql diff --git a/prisma/migrations/0034_recommendation_feedback/migration.sql b/prisma/migrations/0034_recommendation_feedback/migration.sql new file mode 100644 index 000000000..eb31d5833 --- /dev/null +++ b/prisma/migrations/0034_recommendation_feedback/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be161393d..39731bdba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/tests/integration/cascade-delete.test.ts b/tests/integration/cascade-delete.test.ts index 456c5bc38..33f713413 100644 --- a/tests/integration/cascade-delete.test.ts +++ b/tests/integration/cascade-delete.test.ts @@ -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);