feat(coach): add CoachConversation, CoachMessage, CoachUsage models

Persist Coach chat threads with AES-256-GCM-encrypted message bodies
and a per-user, per-day token-spend ledger. Encryption reuses the
active ENCRYPTION_KEYS entry so rotation continues to work without a
backfill. metric_source_json carries provenance labels only (window +
metric names, never raw values), keeping it queryable for analytics.

Cascade-on-user-delete is wired through the FK chain so GDPR delete
removes Coach data with the rest of the account. The integration-suite
truncation list picks up the new tables.

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 15:54:28 +02:00
1 parent 3bdb4380ad
commit 5527001a01
3 files changed
+167

No files matched your search

@@ -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;
+85
View File
@@ -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")
}
+3
View File
@@ -40,6 +40,9 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
"app_settings",
"audit_logs",
"auth_challenges",
"coach_conversations",
"coach_messages",
"coach_usage",
"data_backups",
"devices",
"feedback",