diff --git a/prisma/migrations/0116_v1111_coach_facts/migration.sql b/prisma/migrations/0116_v1111_coach_facts/migration.sql new file mode 100644 index 000000000..4d56933b1 --- /dev/null +++ b/prisma/migrations/0116_v1111_coach_facts/migration.sql @@ -0,0 +1,27 @@ +-- v1.11.1 — durable Coach personal facts (Epic B, B-W7). +-- +-- Additive, multi-tenant-safe: one new table + index + FK, no column on an +-- existing hot table, no backfill. The fact text is encrypted at rest +-- (AES-256-GCM, same codec as coach_messages.encrypted_content); category / +-- confidence / source stay plain so the injection picker can rank without a +-- per-row decrypt. Soft-delete via deleted_at keeps "forget this" auditable. +CREATE TABLE "coach_facts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "fact_encrypted" BYTEA NOT NULL, + "category" TEXT NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 50, + "source_conversation_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "coach_facts_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "coach_facts_user_id_deleted_at_confidence_idx" + ON "coach_facts" ("user_id", "deleted_at", "confidence"); + +ALTER TABLE "coach_facts" + ADD CONSTRAINT "coach_facts_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 8cb4c38e8..358f0c0ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -351,6 +351,7 @@ model User { devices Device[] integrationStatuses IntegrationStatus[] coachConversations CoachConversation[] + coachFacts CoachFact[] coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS @@ -2687,6 +2688,37 @@ model CoachMessage { @@map("coach_messages") } +// v1.11.1 (Epic B, B-W7) — durable, user-scoped personal facts the Coach +// extracts from conversations: stable preferences, conditions the user states +// about themselves, goals, constraints, durable life context. The fact TEXT is +// encrypted at rest (AES-256-GCM, the same codec as CoachMessage). `category`, +// `confidence`, and `sourceConversationId` stay plain so the injection picker +// can rank without paying a per-row decrypt. Descriptive, never diagnostic. +// Soft-delete via `deletedAt` so a user "forget this" action is reversible / +// auditable and re-extraction can avoid resurrecting it. +model CoachFact { + id String @id @default(cuid()) + userId String @map("user_id") + factEncrypted Bytes @map("fact_encrypted") + // App-side closed enum (not a DB enum, to keep the migration purely additive + // and avoid an enum-migration per new category): preference | condition | + // goal | constraint | context. + category String + // 0..100 server-assigned extraction confidence. + confidence Int @default(50) + // Provenance — nullable so a future manual-entry surface can leave it null. + sourceConversationId String? @map("source_conversation_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + // Soft-delete: hides the fact from injection but keeps the row for audit. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, deletedAt, confidence]) + @@map("coach_facts") +} + // 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 diff --git a/src/lib/ai/coach/bytes-codec.ts b/src/lib/ai/coach/bytes-codec.ts new file mode 100644 index 000000000..5154a3202 --- /dev/null +++ b/src/lib/ai/coach/bytes-codec.ts @@ -0,0 +1,32 @@ +/** + * Shared AES-256-GCM ↔ `Bytes` codec for the Coach's encrypted columns. + * + * `coach_messages.encrypted_content`, `coach_conversations.summary_encrypted`, + * and `coach_facts.fact_encrypted` all store the same payload shape: the + * `encrypt()` string format from `@/lib/crypto` (`"."`) encoded + * as UTF-8 bytes. Centralised here so there is one ArrayBuffer-backed + * implementation every Coach persistence path shares. + * + * Prisma's `Bytes` type maps to `Uint8Array`, not Node's + * `Buffer`, so we allocate a fresh ArrayBuffer-backed + * `Uint8Array` for writes to keep the structural type stable across Node + * versions. + */ +import { Buffer } from "node:buffer"; + +import { decrypt, encrypt } from "@/lib/crypto"; + +/** Encrypt a UTF-8 string into the `Bytes` payload the schema stores. */ +export function encryptToBytes(plaintext: string): Uint8Array { + const ciphertext = encrypt(plaintext); + const encoded = Buffer.from(ciphertext, "utf8"); + const out = new Uint8Array(new ArrayBuffer(encoded.byteLength)); + out.set(encoded); + return out; +} + +/** Decrypt a `Bytes` payload back to its plaintext. Throws on a bad key id. */ +export function decryptFromBytes(buf: Uint8Array): string { + const text = Buffer.from(buf).toString("utf8"); + return decrypt(text); +} diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 22a6141e5..0dc79bc21 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -10,10 +10,8 @@ * provenance (window names, metric tags, sample counts) and never raw * values, so it can be queried without decryption for analytics. */ -import { Buffer } from "node:buffer"; - import { prisma } from "@/lib/db"; -import { encrypt, decrypt } from "@/lib/crypto"; +import { decryptFromBytes, encryptToBytes } from "./bytes-codec"; import type { CoachConversationDTO, @@ -49,27 +47,6 @@ export function summariseTitle(input: string): string { return `${cut.trimEnd()}…`; } -/** - * Encode a UTF-8 string as the AES-256-GCM payload format the schema - * stores in `coach_messages.encrypted_content`. - * - * Prisma's `Bytes` type maps to `Uint8Array`, not Node's - * `Buffer`. We allocate a fresh ArrayBuffer-backed - * Uint8Array so the structural type matches across Node versions. - */ -function encryptToBytes(plaintext: string): Uint8Array { - const ciphertext = encrypt(plaintext); - const encoded = Buffer.from(ciphertext, "utf8"); - const out = new Uint8Array(new ArrayBuffer(encoded.byteLength)); - out.set(encoded); - return out; -} - -function decryptFromBytes(buf: Uint8Array): string { - const text = Buffer.from(buf).toString("utf8"); - return decrypt(text); -} - function provenanceToJson(provenance: CoachProvenance | null): string | null { if (!provenance) return null; return JSON.stringify(provenance);