mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user