diff --git a/prisma/migrations/0035_coach_conversations_v1420/migration.sql b/prisma/migrations/0035_coach_conversations_v1420/migration.sql new file mode 100644 index 000000000..7e9ed457e --- /dev/null +++ b/prisma/migrations/0035_coach_conversations_v1420/migration.sql @@ -0,0 +1,79 @@ +-- v1.4.20 — AI Coach persistence + per-day token-spend ledger. +-- +-- Three new tables: +-- coach_conversations — one row per chat thread, owned by a user. +-- coach_messages — encrypted message text + provenance metadata. +-- coach_usage — per-(user, day) token ledger for budget gating. +-- +-- Encryption: `coach_messages.encrypted_content` is AES-256-GCM +-- ciphertext written via `src/lib/crypto.ts` under the active +-- `ENCRYPTION_KEYS` entry. Key rotation works the same way as for the +-- existing encrypted columns (Withings tokens, Telegram bot tokens, +-- moodLog API keys). `metric_source_json` is intentionally plain text +-- — it stores window + metric labels only ("last30days", "n=12"), +-- never raw values, so analytics queries can slice it without a key. +-- +-- GDPR: every table cascades on user delete via FK chains, covered +-- end-to-end in tests/integration/cascade-delete.test.ts. + +CREATE TABLE "coach_conversations" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "coach_conversations_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "coach_conversations_user_id_updated_at_idx" + ON "coach_conversations" ("user_id", "updated_at"); + +ALTER TABLE "coach_conversations" + ADD CONSTRAINT "coach_conversations_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "coach_messages" ( + "id" TEXT NOT NULL, + "conversation_id" TEXT NOT NULL, + "role" TEXT NOT NULL, + "encrypted_content" BYTEA NOT NULL, + "metric_source_json" TEXT, + "provider_type" TEXT, + "prompt_version" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "coach_messages_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "coach_messages_conversation_id_created_at_idx" + ON "coach_messages" ("conversation_id", "created_at"); + +ALTER TABLE "coach_messages" + ADD CONSTRAINT "coach_messages_conversation_id_fkey" + FOREIGN KEY ("conversation_id") REFERENCES "coach_conversations"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "coach_usage" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date_key" TEXT NOT NULL, + "total_tokens" INTEGER NOT NULL DEFAULT 0, + "message_count" INTEGER NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "coach_usage_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "coach_usage_user_id_date_key_key" + ON "coach_usage" ("user_id", "date_key"); + +CREATE INDEX "coach_usage_user_id_date_key_idx" + ON "coach_usage" ("user_id", "date_key"); + +ALTER TABLE "coach_usage" + ADD CONSTRAINT "coach_usage_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39731bdba..10c6a0e0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,6 +133,8 @@ model User { idempotencyKeys IdempotencyKey[] devices Device[] integrationStatuses IntegrationStatus[] + coachConversations CoachConversation[] + coachUsage CoachUsage[] @@map("users") } @@ -849,3 +851,86 @@ model RecommendationFeedback { @@index([recommendationSeverity, helpful, createdAt]) @@map("recommendation_feedback") } + +// ─── AI Coach (v1.4.20) ──────────────────────────────────── +// +// Conversation thread between a user and the AI Coach. Messages are +// stored encrypted at rest via AES-256-GCM (`src/lib/crypto.ts`) under +// the active `ENCRYPTION_KEYS` entry — the Coach inherits the same +// key-rotation tooling as the rest of the app. GDPR cascade through +// User keeps the model deletion-clean. +// +// `title` is a short snippet of the first user message (≤80 chars, +// non-encrypted) so the history rail can render without paying the +// per-message decrypt cost. The snippet is plain text; do not put PII +// the user explicitly opted out of into it. The summarisation helper +// in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis. +model CoachConversation { + id String @id @default(cuid()) + userId String @map("user_id") + title String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages CoachMessage[] + + @@index([userId, updatedAt]) + @@map("coach_conversations") +} + +// One message in a Coach conversation. `role` is "user" or "assistant" +// — same shape as the OpenAI/Anthropic chat-completion APIs. +// +// `encryptedContent` holds the AES-256-GCM ciphertext of the message +// body. The encrypt/decrypt helpers stamp the active key id into the +// ciphertext so rotation works without a backfill. +// +// `metricSourceJson` is the provenance envelope ("based on 14d HRV + +// 6 BP readings") attached to assistant messages — labels only, NEVER +// raw values. The frontend renders source-chip rows from this field. +// It is intentionally NOT encrypted: the labels are window+metric +// names ("last30days", "bloodPressure", "n=12") and carry no PII; the +// stable shape lets future analytics queries slice without a key. +// +// `providerType` and `promptVersion` mirror the recommendation- +// feedback attribution columns so the cross-feature aggregator can +// slice helpful-rate per (provider × prompt) for Coach replies too. +model CoachMessage { + id String @id @default(cuid()) + conversationId String @map("conversation_id") + role String // "user" | "assistant" + encryptedContent Bytes @map("encrypted_content") + metricSourceJson String? @map("metric_source_json") + providerType String? @map("provider_type") + promptVersion String? @map("prompt_version") + createdAt DateTime @default(now()) @map("created_at") + + conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@index([conversationId, createdAt]) + @@map("coach_messages") +} + +// Per-user, per-day token-spend ledger for the AI Coach. The dispatcher +// reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before +// hitting any provider, so a runaway client cannot drain the operator's +// LLM budget. `dateKey` is the UTC `YYYY-MM-DD` bucket; the unique +// constraint enforces one row per (user, day). `messageCount` is a +// secondary signal used by the frontend to render a "≈ N replies left" +// hint without exposing raw token figures. +model CoachUsage { + id String @id @default(cuid()) + userId String @map("user_id") + dateKey String @map("date_key") + totalTokens Int @default(0) @map("total_tokens") + messageCount Int @default(0) @map("message_count") + updatedAt DateTime @updatedAt @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, dateKey]) + @@index([userId, dateKey]) + @@map("coach_usage") +} diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 5a5c0b1ba..e61704681 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -40,6 +40,9 @@ export async function truncateAllTables(client: PrismaClient): Promise { "app_settings", "audit_logs", "auth_challenges", + "coach_conversations", + "coach_messages", + "coach_usage", "data_backups", "devices", "feedback",