feat(db): MedicationSideEffect table + 21-entry × 5-category taxonomy

W19d sub-wave — structured GLP-1 side-effect logbook derived from
EMA EPAR §4.8 ("Undesirable effects"). 21 entries rolled up into 5
surface categories (GI / METABOLIC / INJECTION_SITE / COGNITIVE /
GLP1_SPECIFIC) with 1-5 Likert severity per row.

Composite index on (user_id, medication_id, occurred_at) for the
detail-page timeline read; a second (user_id, occurred_at) index
serves the Coach snapshot aggregation. Severity is bounded with a
DB-level CHECK constraint, defence in depth against client drift.
Notes column is unbounded TEXT, application-layer capped to 280
chars in the Zod schema introduced in the follow-up commit.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:52:39 +02:00
1 parent 575e951c50
commit 7d56f6f51f
2 files changed
+437 -243

No files matched your search

@@ -0,0 +1,79 @@
-- v1.4.25 W19d — MedicationSideEffect logbook.
--
-- 21-entry × 5-category structured side-effect taxonomy derived from
-- EMA EPAR §4.8 ("Undesirable effects") clustered into the five
-- surfaces the HealthLog UI renders. 1-5 Likert severity (finer
-- granularity than the EMA 3-level reporting convention; the UI maps
-- each integer to a translated semantic label).
--
-- Daily capture, not per-shot — delayed-onset symptoms (nausea on
-- day 4 post-tirzepatide is common) keep their actual onset time.
-- The Coach reads these against MedicationDoseChange +
-- MedicationIntakeEvent to surface patterns without making a clinical
-- claim.
--
-- Composite index (user_id, medication_id, occurred_at) is the
-- dominant read path: "last N entries for this medication on the
-- detail page". A second per-user (user_id, occurred_at) index serves
-- the Coach snapshot aggregation, which correlates across medications.
--
-- Severity is bounded with a CHECK constraint at the DB level —
-- defence in depth against client-side Zod drift. Notes are unbounded
-- TEXT, application-layer capped to 280 chars in the Zod schema.
CREATE TYPE "medication_side_effect_category" AS ENUM (
'GI',
'METABOLIC',
'INJECTION_SITE',
'COGNITIVE',
'GLP1_SPECIFIC'
);
CREATE TYPE "medication_side_effect_entry" AS ENUM (
-- GI (5)
'NAUSEA',
'VOMITING',
'DIARRHEA',
'CONSTIPATION',
'ABDOMINAL_PAIN',
-- Metabolic (4)
'HYPOGLYCEMIA_SYMPTOMS',
'DEHYDRATION',
'ANOREXIA',
'ELECTROLYTE_FATIGUE',
-- Injection-site (4)
'INJECTION_REDNESS',
'INJECTION_SWELLING',
'INJECTION_BRUISING',
'INJECTION_INDURATION',
-- Cognitive (4)
'BRAIN_FOG',
'DIZZINESS',
'LOW_MOOD',
'LOW_ENERGY',
-- GLP-1 specific (4)
'EARLY_SATIETY',
'GASTROPARESIS_LIKE',
'DYSGEUSIA',
'GALLBLADDER_DISCOMFORT'
);
CREATE TABLE "medication_side_effects" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"medication_id" TEXT NOT NULL REFERENCES "medications"("id") ON DELETE CASCADE,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"category" "medication_side_effect_category" NOT NULL,
"entry" "medication_side_effect_entry" NOT NULL,
"severity" INTEGER NOT NULL,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_side_effects_severity_range_chk"
CHECK ("severity" >= 1 AND "severity" <= 5)
);
CREATE INDEX "medication_side_effects_user_med_occurred_idx"
ON "medication_side_effects" ("user_id", "medication_id", "occurred_at");
CREATE INDEX "medication_side_effects_user_occurred_idx"
ON "medication_side_effects" ("user_id", "occurred_at");
+358 -243
View File
@@ -10,11 +10,11 @@ datasource db {
// ─── User & Auth ───────────────────────────────────────────
model User {
id String @id @default(cuid())
username String @unique
email String? @unique
id String @id @default(cuid())
username String @unique
email String? @unique
passwordHash String? @map("password_hash") // argon2id, optional fallback
role String @default("USER") // "ADMIN" | "USER"
role String @default("USER") // "ADMIN" | "USER"
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -25,56 +25,56 @@ model User {
timezone String @default("Europe/Berlin")
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
// Telegram notifications (per-user)
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
telegramChatId String? @map("telegram_chat_id")
telegramEnabled Boolean @default(false) @map("telegram_enabled")
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
telegramChatId String? @map("telegram_chat_id")
telegramEnabled Boolean @default(false) @map("telegram_enabled")
// Withings OAuth credentials (per-user, encrypted)
withingsClientIdEncrypted String? @map("withings_client_id_encrypted")
withingsClientSecretEncrypted String? @map("withings_client_secret_encrypted")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
moodLogEnabled Boolean @default(false) @map("mood_log_enabled")
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
moodLogEnabled Boolean @default(false) @map("mood_log_enabled")
moodLogLastSyncedAt DateTime? @map("mood_log_last_synced_at")
moodLogWebhookSecret String? @map("mood_log_webhook_secret")
moodLogWebhookSecret String? @map("mood_log_webhook_secret")
// Locale (null = use system/admin default)
locale String? @map("locale") // "de" | "en" | null
locale String? @map("locale") // "de" | "en" | null
// Personalization
// Per-user overrides for computed default thresholds. Shape:
// { "WEIGHT": { "min": 70, "max": 85 },
// "BLOOD_PRESSURE_SYS": { "min": 110, "max": 130 }, ... }
// Null or missing metric → use computed default.
thresholdsJson Json? @map("thresholds_json")
thresholdsJson Json? @map("thresholds_json")
// Per-user dashboard layout — widget visibility + ordering.
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
aiBaseUrl String? @map("ai_base_url")
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
aiBaseUrl String? @map("ai_base_url")
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
// v1.4.3: per-user OpenAI API key for the OPENAI provider — previously
// the schema only stored Anthropic and Local keys, leaving OPENAI without
// a credential surface. Encrypted with the active ENCRYPTION_KEYS entry
// before storage.
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
// v1.4.16 phase B5b — ordered fallback chain for multi-provider AI
// redundancy. Each entry is `{ providerType, priority, enabled }`,
@@ -86,7 +86,7 @@ model User {
aiProviderChain Json? @map("ai_provider_chain")
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
// v1.4.25 W14b — last persisted onboarding wizard step.
// Encodes resume-from-where-you-left-off semantics for the rebuilt
// multi-step wizard at `/onboarding/[step]`. Values mirror the
@@ -98,7 +98,7 @@ model User {
// 4 = done (triggers `onboardingCompletedAt` flip on the same
// round-trip — the column stays at 4 for completed users)
// Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
onboardingStep Int? @default(0) @map("onboarding_step")
onboardingStep Int? @default(0) @map("onboarding_step")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
@@ -108,11 +108,11 @@ model User {
// integrations, and achievements. Default false; flipped to true
// on tour completion or dismiss; users can replay the tour from
// Settings → Account.
onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed")
onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
// Display name (optional override of username for greetings).
@@ -177,9 +177,9 @@ model User {
// user who turns Research Mode off must re-acknowledge the
// disclaimer if they ever turn it back on, even if the version
// hasn't changed in the interim.
researchModeEnabled Boolean @default(false) @map("research_mode_enabled")
researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at")
researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version")
researchModeEnabled Boolean @default(false) @map("research_mode_enabled")
researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at")
researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version")
// v1.4.25 W6c — Doctor-report section toggles.
// Shape:
@@ -196,58 +196,63 @@ model User {
doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
// Relations
passkeys Passkey[]
sessions Session[]
challenges AuthChallenge[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
apiTokens ApiToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
moodEntries MoodEntry[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
passkeys Passkey[]
sessions Session[]
challenges AuthChallenge[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
apiTokens ApiToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
moodEntries MoodEntry[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachUsage CoachUsage[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
// session can build against a stable DTO. Routes hang off Workout
// (1:1), not User — the User → WorkoutRoute path is two hops by
// design.
workouts Workout[]
workouts Workout[]
// v1.4.25 W8d — personal records (schema only; detection worker
// lands in a later release). The GET /api/personal-records route
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
personalRecords PersonalRecord[]
personalRecords PersonalRecord[]
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
// companion to the running-sum MedicationInventoryEvent ledger.
// Carries the EMA §6.3 30-day in-use clock per opened pen.
inventoryItems MedicationInventoryItem[]
inventoryItems MedicationInventoryItem[]
// v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category
// EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily
// capture (not per-shot) so delayed-onset symptoms map to their
// actual occurrence time.
medicationSideEffects MedicationSideEffect[]
@@map("users")
}
model Passkey {
id String @id @default(cuid())
userId String @map("user_id")
name String @default("Passkey")
credentialId String @unique @map("credential_id")
credentialPublicKey Bytes @map("credential_public_key")
counter BigInt @default(0)
credentialDeviceType String @map("credential_device_type")
credentialBackedUp Boolean @default(false) @map("credential_backed_up")
transports String[] // e.g. ["usb", "ble", "nfc", "internal"]
createdAt DateTime @default(now()) @map("created_at")
id String @id @default(cuid())
userId String @map("user_id")
name String @default("Passkey")
credentialId String @unique @map("credential_id")
credentialPublicKey Bytes @map("credential_public_key")
counter BigInt @default(0)
credentialDeviceType String @map("credential_device_type")
credentialBackedUp Boolean @default(false) @map("credential_backed_up")
transports String[] // e.g. ["usb", "ble", "nfc", "internal"]
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -272,7 +277,7 @@ model AuthChallenge {
id String @id @default(cuid())
userId String? @map("user_id") // null for registration
challenge String
type String // "registration" | "authentication"
type String // "registration" | "authentication"
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@ -323,10 +328,10 @@ enum MeasurementType {
/// Context for glucose measurements — affects which target range applies.
/// NULL for non-glucose measurements.
enum GlucoseContext {
FASTING // ≥8h without food, typically morning
FASTING // ≥8h without food, typically morning
POSTPRANDIAL // 2h after meal
RANDOM // any time, non-fasting, non-post-meal
BEDTIME // before sleep
RANDOM // any time, non-fasting, non-post-meal
BEDTIME // before sleep
@@map("glucose_context")
}
@@ -356,30 +361,30 @@ enum SleepStage {
}
model Measurement {
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
value Float
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
notes String? // encrypted at app level if sensitive
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
value Float
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
notes String? // encrypted at app level if sensitive
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
// manual entries — Postgres treats NULLs as distinct in unique
// indexes, so two manual rows with the same (user, type, source) are
// still allowed.
externalId String? @map("external_id")
externalId String? @map("external_id")
// v1.4.23 — opaque marker for the source's schema version (HealthKit
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
externalSourceVersion String? @map("external_source_version")
externalSourceVersion String? @map("external_source_version")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
sleepStage SleepStage? @map("sleep_stage")
sleepStage SleepStage? @map("sleep_stage")
// v1.4.25 W8c — device-type tag for the cross-source canonical
// picker. Stored as a free-text string (kept simple — Prisma enum
// would require coordinated client + server bumps every time we
@@ -390,9 +395,9 @@ model Measurement {
// NULL as `unknown`. Unique constraints intentionally do not
// include `deviceType` so two ingest paths from the same source
// still dedup on (type, source, externalId).
deviceType String? @map("device_type")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deviceType String? @map("device_type")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -487,19 +492,19 @@ model Workout {
}
model WorkoutRoute {
id String @id @default(cuid())
workoutId String @unique @map("workout_id")
id String @id @default(cuid())
workoutId String @unique @map("workout_id")
/// GeoJSON LineString — `{ "type": "LineString", coordinates:
/// [[lon, lat, alt?], ...] }`. JSONB rather than PostGIS so the
/// Coolify Postgres template stays extension-free (same decision
/// v1.4.20 made for correlations); GPX import + Leaflet / MapLibre
/// consume GeoJSON natively.
geometry Json
geometry Json
/// Optional per-sample timestamps (ISO strings) and speeds —
/// parallel array to `geometry.coordinates`. NULL when the source
/// only ships static GPX (e.g. Withings).
sampleTimestamps Json? @map("sample_timestamps")
createdAt DateTime @default(now()) @map("created_at")
sampleTimestamps Json? @map("sample_timestamps")
createdAt DateTime @default(now()) @map("created_at")
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
@@ -521,24 +526,24 @@ enum PersonalRecordDirection {
}
model PersonalRecord {
id String @id @default(cuid())
userId String @map("user_id")
metricType MeasurementType @map("metric_type")
id String @id @default(cuid())
userId String @map("user_id")
metricType MeasurementType @map("metric_type")
/// Per-sport-type bucket for workout-driven PRs (e.g.
/// "running_5km_time"). NULL for plain measurement-driven PRs
/// where the metric type alone is the dimension.
metricSlot String? @map("metric_slot")
direction PersonalRecordDirection
value Float
unit String
achievedAt DateTime @map("achieved_at")
metricSlot String? @map("metric_slot")
direction PersonalRecordDirection
value Float
unit String
achievedAt DateTime @map("achieved_at")
/// FK to the Measurement row that achieved the record. SET NULL
/// on delete so the historical fact survives even if the
/// underlying measurement is later removed.
sourceMeasurementId String? @map("source_measurement_id")
source MeasurementSource @default(MANUAL)
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at")
sourceMeasurementId String? @map("source_measurement_id")
source MeasurementSource @default(MANUAL)
externalId String? @map("external_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -569,10 +574,10 @@ enum MedicationCategory {
}
model Medication {
id String @id @default(cuid())
userId String @map("user_id")
name String
dose String // e.g. "10mg"
id String @id @default(cuid())
userId String @map("user_id")
name String
dose String // e.g. "10mg"
/// v1.4.25 W4d — treatment-class discriminator. GENERIC for the
/// vast majority of meds; GLP1 unlocks injection-site rotation,
/// dose-titration history, pen inventory, plateau detection, and
@@ -580,17 +585,17 @@ model Medication {
/// not `category` to avoid clashing with the existing
/// `medication_categories` side-table (clinical taxonomy) that the
/// UI continues to read.
treatmentClass MedicationCategory @default(GENERIC) @map("treatment_class")
treatmentClass MedicationCategory @default(GENERIC) @map("treatment_class")
/// Doses per pen/vial — only meaningful for inventory-tracked meds
/// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
/// pen = 1). NULL = inventory tracking off.
dosesPerUnit Int? @map("doses_per_unit")
active Boolean @default(true)
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
pausedAt DateTime? @map("paused_at")
snoozedUntil DateTime? @map("snoozed_until")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
dosesPerUnit Int? @map("doses_per_unit")
active Boolean @default(true)
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
pausedAt DateTime? @map("paused_at")
snoozedUntil DateTime? @map("snoozed_until")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
@@ -602,6 +607,12 @@ model Medication {
/// 30-day in-use clock). Complements the running-sum
/// MedicationInventoryEvent ledger.
inventoryItems MedicationInventoryItem[]
/// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on
/// the medication detail page for GLP-1 treatmentClass only; the
/// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category
/// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE /
/// GLP1_SPECIFIC) and 1-5 Likert severity per entry.
sideEffects MedicationSideEffect[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -621,12 +632,12 @@ model MedicationSchedule {
/// match the snake_case convention every other column on this model
/// already follows.
windowStart String @map("window_start") // HH:mm format, e.g. "08:00"
windowEnd String @map("window_end") // HH:mm format, e.g. "10:00"
windowEnd String @map("window_end") // HH:mm format, e.g. "10:00"
label String? // e.g. "Morning", "Evening"
dose String? // Per-schedule dose override; null = use Medication.dose
daysOfWeek String? @map("days_of_week") // Comma-separated day numbers (0=Sun..6=Sat), null = daily
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
telegramMessages TelegramReminderMessage[]
@@map("medication_schedules")
@@ -678,15 +689,15 @@ enum InjectionSite {
}
model MedicationIntakeEvent {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
scheduledFor DateTime @map("scheduled_for")
takenAt DateTime? @map("taken_at")
skipped Boolean @default(false)
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
scheduledFor DateTime @map("scheduled_for")
takenAt DateTime? @map("taken_at")
skipped Boolean @default(false)
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
/// v1.4.25 W4d — only populated for GLP-1 intake events (the picker
/// is the only surface that writes this column). Existing rows stay
/// NULL forever and the read paths treat NULL as "no site recorded".
@@ -736,8 +747,8 @@ model MedicationDoseChange {
model MedicationInventoryEvent {
id String @id @default(cuid())
medicationId String @map("medication_id")
delta Int // positive = added, negative = consumed/lost
reason String // "purchased" / "consumed" / "expired" / "damaged"
delta Int // positive = added, negative = consumed/lost
reason String // "purchased" / "consumed" / "expired" / "damaged"
occurredAt DateTime @default(now()) @map("occurred_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@ -765,29 +776,29 @@ enum MedicationInventoryState {
}
model MedicationInventoryItem {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
state MedicationInventoryState @default(ACTIVE)
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
state MedicationInventoryState @default(ACTIVE)
/// Doses the pen / vial shipped with. Mounjaro KwikPen = 4,
/// Trulicity single-dose = 1, Byetta = 60.
dosesTotal Int @map("doses_total")
dosesTotal Int @map("doses_total")
/// Remaining doses; decremented by the intake hook.
dosesRemaining Int @map("doses_remaining")
dosesRemaining Int @map("doses_remaining")
/// NULL until the pen is first opened. Sets the 30-day in-use
/// clock per EMA EPAR §6.3.
firstUseAt DateTime? @map("first_use_at")
firstUseAt DateTime? @map("first_use_at")
/// Persisted = MIN(firstUseAt + 30 days, printedExpiry). Persisting
/// rather than deriving keeps the daily "expire-stale" cron a
/// single indexed scan.
expiresAt DateTime? @map("expires_at")
expiresAt DateTime? @map("expires_at")
/// The carton-printed expiry date the user enters at creation.
printedExpiry DateTime? @map("printed_expiry")
purchasedAt DateTime? @map("purchased_at")
printedExpiry DateTime? @map("printed_expiry")
purchasedAt DateTime? @map("purchased_at")
/// Free-text note, application-layer max 200 chars.
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@ -797,6 +808,110 @@ model MedicationInventoryItem {
@@map("medication_inventory_items")
}
/// v1.4.25 W19d — structured GLP-1 side-effect log.
///
/// Categorical 21-entry × 5-category taxonomy derived from EMA EPAR
/// §4.8 ("Undesirable effects") clustered into the five surfaces the
/// HealthLog UI uses (GI / METABOLIC / INJECTION_SITE / COGNITIVE /
/// GLP1_SPECIFIC). Severity is a 1-5 Likert — chosen over the EMA
/// 3-level (mild / moderate / severe) convention to give the user
/// finer-grained signal for trend charting; the UI maps each integer
/// to a translated semantic label (mild → moderate → significant →
/// severe → very-severe).
///
/// Daily capture, not per-shot. Delayed-onset symptoms (nausea on
/// day 4 post-tirzepatide is common) are captured at their actual
/// onset time, not back-filled onto the dose record. The Coach reads
/// these timestamps against MedicationDoseChange + MedicationIntakeEvent
/// to surface patterns ("nausea has clustered around day 2-3 post-shot
/// for the last 4 weeks") without making a clinical claim.
///
/// Surface gating: the medication detail page mounts the logbook only
/// for `Medication.treatmentClass = GLP1`. The schema deliberately
/// allows non-GLP-1 medications to carry side-effect rows for future
/// drug classes (e.g. insulin hypoglycaemia symptoms) without a
/// migration; UI gating keeps the v1.4.25 release scope tight.
///
/// Indexes:
/// - (userId, medicationId, occurredAt DESC) — drug-detail timeline
/// read path; the dominant query is "last N entries for this med".
/// - (userId, occurredAt DESC) — Coach snapshot aggregation read
/// path; correlation across medications is per-user, not per-drug.
model MedicationSideEffect {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
occurredAt DateTime @default(now()) @map("occurred_at")
category MedicationSideEffectCategory
entry MedicationSideEffectEntry
/// 1 = mild, 2 = moderate, 3 = significant, 4 = severe, 5 = very-severe.
/// Constrained to [1, 5] at the application layer (Zod) and the DB
/// (CHECK constraint via migration). Not an enum to keep cardinality
/// trivially numeric for chart aggregation.
severity Int
/// Optional free-text note, hard-capped to 280 chars at the
/// application layer. The DB column is unbounded TEXT for
/// consistency with sibling "notes" columns on this schema.
notes String?
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([userId, medicationId, occurredAt])
@@index([userId, occurredAt])
@@map("medication_side_effects")
}
/// Five rolled-up surface categories. The pure-mapping helper at
/// src/lib/medications/side-effects/taxonomy.ts is the single source
/// of truth for which `MedicationSideEffectEntry` belongs to which
/// `MedicationSideEffectCategory`; the DB stores the category
/// denormalised so range / category-filter queries don't need a JOIN.
enum MedicationSideEffectCategory {
GI
METABOLIC
INJECTION_SITE
COGNITIVE
GLP1_SPECIFIC
@@map("medication_side_effect_category")
}
/// 21 EMA-EPAR-§4.8-derived entries grouped by surface category.
/// Order in this enum matches the picker UI order; rename / reorder
/// is a breaking change for serialised rows. New entries append.
enum MedicationSideEffectEntry {
// GI (5)
NAUSEA
VOMITING
DIARRHEA
CONSTIPATION
ABDOMINAL_PAIN
// Metabolic (4)
HYPOGLYCEMIA_SYMPTOMS
DEHYDRATION
ANOREXIA
ELECTROLYTE_FATIGUE
// Injection-site (4)
INJECTION_REDNESS
INJECTION_SWELLING
INJECTION_BRUISING
INJECTION_INDURATION
// Cognitive (4)
BRAIN_FOG
DIZZINESS
LOW_MOOD
LOW_ENERGY
// GLP-1 specific (4)
EARLY_SATIETY
GASTROPARESIS_LIKE
DYSGEUSIA
GALLBLADDER_DISCOMFORT
@@map("medication_side_effect_entry")
}
model ReminderPhaseConfig {
id String @id @default(cuid())
medicationId String @unique @map("medication_id")
@@ -822,7 +937,7 @@ model TelegramReminderMessage {
messageId Int @map("message_id")
phase ReminderPhase
sentAt DateTime @default(now()) @map("sent_at")
date String // YYYY-MM-DD for day grouping
date String // YYYY-MM-DD for day grouping
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@ -846,15 +961,15 @@ model TelegramScheduledDeletion {
// ─── API Tokens (for external ingest) ──────────────────────
model ApiToken {
id String @id @default(cuid())
userId String @map("user_id")
id String @id @default(cuid())
userId String @map("user_id")
name String
tokenHash String @unique @map("token_hash") // SHA-256 of the token
permissions String[] @default(["medication:ingest"])
tokenHash String @unique @map("token_hash") // SHA-256 of the token
permissions String[] @default(["medication:ingest"])
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
revoked Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -865,30 +980,30 @@ model ApiToken {
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
model RefreshToken {
id String @id @default(cuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
id String @id @default(cuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
/// Optional device fingerprint — links the refresh token to a specific
/// `Device` row when the caller advertised one (X-Device-Id header). Used
/// to revoke all tokens issued for a stolen/lost device.
deviceId String? @map("device_id")
deviceId String? @map("device_id")
/// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by
/// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response.
/// When the refresh token is rotated we revoke the matching access token
/// via `ApiToken.tokenHash`. Null if the access token was issued
/// out-of-band.
accessTokenHash String? @map("access_token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
accessTokenHash String? @map("access_token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
/// When this token was used (one-time-use). Set during rotation; a second
/// use of the same token is a hard fail and triggers reuse-detection.
usedAt DateTime? @map("used_at")
usedAt DateTime? @map("used_at")
/// Pointer to the row that replaced this one during rotation. Helps audit
/// trail and reuse-detection ("this token was already exchanged into X").
replacedById String? @map("replaced_by_id")
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
replacedById String? @map("replaced_by_id")
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -906,12 +1021,12 @@ model RefreshToken {
// ─── Withings ──────────────────────────────────────────────
model WithingsConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
withingsUserId String @map("withings_user_id")
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
id String @id @default(cuid())
userId String @unique @map("user_id")
withingsUserId String @map("withings_user_id")
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
lastSyncedAt DateTime? @map("last_synced_at")
/// v1.4.25 W5d — comma-separated OAuth scope string returned by the
/// Withings authorisation flow. Legacy v1.4.24- connections sit on
@@ -920,9 +1035,9 @@ model WithingsConnection {
/// activity / sleep endpoints unlock. Null = legacy connection that
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
scope String? @map("scope")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
scope String? @map("scope")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -934,10 +1049,10 @@ model WithingsConnection {
model MoodEntry {
id String @id @default(cuid())
userId String @map("user_id")
date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
score Int // 1-5
tags String? // JSON array of tag keys
date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
score Int // 1-5
tags String? // JSON array of tag keys
source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO
moodLoggedAt DateTime @map("mood_logged_at")
/// v1.4.25 W7b — per-row IANA timezone that the `date` string is
@@ -977,17 +1092,17 @@ model MoodEntry {
// `integration` values are bare strings ("withings", "moodlog") rather
// than an enum so future integrations don't require a migration.
model IntegrationStatus {
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected"
lastSuccessAt DateTime? @map("last_success_at")
lastAttemptAt DateTime? @map("last_attempt_at")
lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata)
consecutiveFailures Int @default(0) @map("consecutive_failures")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected"
lastSuccessAt DateTime? @map("last_success_at")
lastAttemptAt DateTime? @map("last_attempt_at")
lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata)
consecutiveFailures Int @default(0) @map("consecutive_failures")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -1000,39 +1115,39 @@ model IntegrationStatus {
// ─── App Settings ─────────────────────────────────────────
model AppSettings {
id String @id @default("singleton")
registrationEnabled Boolean @default(true) @map("registration_enabled")
defaultLocale String @default("de") @map("default_locale") // "de" | "en"
telegramGlobal Boolean @default(true) @map("telegram_global") // global toggle
ntfyGlobal Boolean @default(true) @map("ntfy_global") // global toggle
webPushGlobal Boolean @default(true) @map("web_push_global") // global toggle
webPushVapidPublicKey String? @map("web_push_vapid_public_key")
id String @id @default("singleton")
registrationEnabled Boolean @default(true) @map("registration_enabled")
defaultLocale String @default("de") @map("default_locale") // "de" | "en"
telegramGlobal Boolean @default(true) @map("telegram_global") // global toggle
ntfyGlobal Boolean @default(true) @map("ntfy_global") // global toggle
webPushGlobal Boolean @default(true) @map("web_push_global") // global toggle
webPushVapidPublicKey String? @map("web_push_vapid_public_key")
webPushVapidPrivateKeyEncrypted String? @map("web_push_vapid_private_key_encrypted")
webPushVapidSubject String? @map("web_push_vapid_subject")
apiGlobal Boolean @default(true) @map("api_global") // global toggle
moodLogGlobal Boolean @default(true) @map("mood_log_global")
umamiEnabled Boolean @default(false) @map("umami_enabled")
umamiScriptUrl String? @map("umami_script_url")
umamiWebsiteId String? @map("umami_website_id")
glitchtipEnabled Boolean @default(false) @map("glitchtip_enabled")
glitchtipDsn String? @map("glitchtip_dsn")
glitchtipEnvironment String? @map("glitchtip_environment")
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
githubIssueRepo String? @map("github_issue_repo")
webPushVapidSubject String? @map("web_push_vapid_subject")
apiGlobal Boolean @default(true) @map("api_global") // global toggle
moodLogGlobal Boolean @default(true) @map("mood_log_global")
umamiEnabled Boolean @default(false) @map("umami_enabled")
umamiScriptUrl String? @map("umami_script_url")
umamiWebsiteId String? @map("umami_website_id")
glitchtipEnabled Boolean @default(false) @map("glitchtip_enabled")
glitchtipDsn String? @map("glitchtip_dsn")
glitchtipEnvironment String? @map("glitchtip_environment")
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
githubIssueRepo String? @map("github_issue_repo")
// v1.4.3: explicit toggle so an admin can hide the bug-report
// surface without nuking the token+repo config. Default true so
// existing instances with credentials don't accidentally lose
// the feature on upgrade.
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes")
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes")
// Admin AI Provider (fallback for users without Codex OAuth)
adminAiKeyEncrypted String? @map("admin_ai_key_encrypted")
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
adminAiKeyEncrypted String? @map("admin_ai_key_encrypted")
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:
@@ -1086,20 +1201,20 @@ enum FeedbackStatus {
/// Admin inbox lives at /admin#feedback; optional escalation publishes to a
/// GitHub issue reusing the existing integration when configured.
model Feedback {
id String @id @default(cuid())
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
email String? // Snapshot at submission time for triage continuity.
category FeedbackCategory @default(BUG)
subject String
description String
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
metadata Json? // URL, userAgent, locale, appVersion, …
status FeedbackStatus @default(OPEN)
adminNote String? @map("admin_note")
gitHubIssueUrl String? @map("github_issue_url")
notifiedEmail Boolean @default(false) @map("notified_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(cuid())
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
email String? // Snapshot at submission time for triage continuity.
category FeedbackCategory @default(BUG)
subject String
description String
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
metadata Json? // URL, userAgent, locale, appVersion, …
status FeedbackStatus @default(OPEN)
adminNote String? @map("admin_note")
gitHubIssueUrl String? @map("github_issue_url")
notifiedEmail Boolean @default(false) @map("notified_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@ -1113,10 +1228,10 @@ model Feedback {
model AuditLog {
id String @id @default(cuid())
userId String? @map("user_id")
action String // e.g. "auth.login", "measurement.create", "export.csv"
details String? // JSON string, no sensitive data
action String // e.g. "auth.login", "measurement.create", "export.csv"
details String? // JSON string, no sensitive data
ipAddress String? @map("ip_address")
location String? // e.g. "Berlin, DE" — resolved from IP at log time
location String? // e.g. "Berlin, DE" — resolved from IP at log time
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@ -1131,9 +1246,9 @@ model AuditLog {
model NotificationChannel {
id String @id @default(cuid())
userId String @map("user_id")
type String // "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS"
type String // "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS"
enabled Boolean @default(true)
config String // AES-256-GCM encrypted JSON (channel-specific config)
config String // AES-256-GCM encrypted JSON (channel-specific config)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1144,15 +1259,15 @@ model NotificationChannel {
// OR was disabled manually (in which case `enabled=false` alone
// is enough — no automated process should treat that as
// re-enable-able).
disabledReason String? @map("disabled_reason")
consecutiveFailures Int @default(0) @map("consecutive_failures")
lastSuccessAt DateTime? @map("last_success_at")
lastFailureAt DateTime? @map("last_failure_at")
lastFailureReason String? @map("last_failure_reason")
disabledReason String? @map("disabled_reason")
consecutiveFailures Int @default(0) @map("consecutive_failures")
lastSuccessAt DateTime? @map("last_success_at")
lastFailureAt DateTime? @map("last_failure_at")
lastFailureReason String? @map("last_failure_reason")
// The next time the dispatcher is allowed to attempt a send for
// this channel after a transient failure. NULL = no cooldown.
// Hard rejects flip `enabled=false` instead of writing this.
nextRetryAt DateTime? @map("next_retry_at")
nextRetryAt DateTime? @map("next_retry_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
preferences NotificationPreference[]
@@ -1178,8 +1293,8 @@ model PushSubscription {
id String @id @default(cuid())
userId String @map("user_id")
endpoint String
p256dh String // encrypted
auth String // encrypted
p256dh String // encrypted
auth String // encrypted
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
@@ -1196,7 +1311,7 @@ model DataBackup {
id String @id @default(cuid())
userId String @map("user_id")
type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL
data String // JSON export blob
data String // JSON export blob
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -1256,7 +1371,7 @@ model IdempotencyKey {
model Device {
id String @id @default(cuid())
userId String @map("user_id")
platform String // "ios"
platform String // "ios"
token String @unique
bundleId String @map("bundle_id")
locale String?
@@ -1366,12 +1481,12 @@ model RecommendationFeedback {
// Coach assistant message. The aggregator slices on targetType to
// bucket each surface independently. Default value lets every
// pre-v1.4.23 row read as "recommendation" without a backfill.
targetType String @default("recommendation") @map("target_type")
targetType String @default("recommendation") @map("target_type")
// Optional free-form reason — populated when the user clicks
// "unhelpful" and selects a category from the small chip strip.
// Capped at 200 chars upstream so the table can't bloat from a
// pasted essay.
reason String? @map("reason")
reason String? @map("reason")
// v1.4.23 reconcile (Sr-H3) — Coach surface references the source
// message instead of snapshotting its prose into
@@ -1437,14 +1552,14 @@ model CoachConversation {
model CoachMessage {
id String @id @default(cuid())
conversationId String @map("conversation_id")
role String // "user" | "assistant"
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)
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
// v1.4.23 reconcile (Sr-H3) — back-relation for the FK that lets
// Coach feedback rows reference their source message instead of
// snapshotting plaintext prose into `recommendation_feedback`.