mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
* feat(crypto): G2 — encryption-key versioning + rotation CLI
Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.
scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.
Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens
Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.
New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.
Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.
Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE
Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.
docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.
docs/self-hosting/scaling.md walks through when + how to split.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage
New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.
Wire format documented in docs/ops/backup-restore.md:
magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext
The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.
Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.
scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.
Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates
Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:
Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
(column doesn't exist in the schema; the script crashed on the AppSettings
step). Add the three encrypted columns it was missing —
NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
— so rotating to a new key actually re-encrypts every encrypted row.
Without this, dropping the old key id from ENCRYPTION_KEYS would leave
notification channels and web-push subscriptions permanently
undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
and run scripts/rotate-encryption-key.ts before removing it" so an
operator who pulled the v1 too early notices immediately instead of
hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
troubleshooting section. Spells out that rotation is a forward-only
operation and what to do if the rollback path is needed.
Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
The worker container (which still runs `node server.js` for now) no
longer accepts requests, so a misrouted client gets a clear hint instead
of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
"[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
silently 302-redirect through the auth gate (the prior wget treated the
login page as a 200 success and would never notice an unhealthy backend).
Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
refresh tokens for the user, not only those scoped to the same deviceId.
An attacker who rotated without an X-Device-Id header would otherwise
keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
access token cannot outlive the refresh-token sibling that the caller
just killed (logout-on-device).
Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
Retention is the bucket's lifecycle-policy job; the worker keeps a
PutObject + GetObject grant only. A compromised worker can no longer
wipe the backup history. Per-user errors are now surfaced as Wide
Event warnings + a structured failures[] array on the report so an
operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
writing to disk, plus an optional --user-id=<id> flag that refuses to
proceed unless the dump's userId matches. Prevents silent
cross-tenant restores when an operator points the script at the wrong
S3 key.
Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
(access-token prefix); also block `hlr_` so a future caller wrapping
the new /api/auth/refresh endpoint with withIdempotency() can't
accidentally cache a freshly-issued refresh token.
Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
partial-deploy retry is a no-op instead of "relation already exists";
guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
composite `@@index([userId, deviceId])` (every lookup also filters on
userId; the old index would never have been used). Comment on
accessTokenHash corrected to HMAC-SHA-256.
Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
(HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
comments and the all-or-none semantics for the backup block.
491 tests still green, typecheck clean.
* chore: retrigger CI
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
676 lines
26 KiB
Plaintext
676 lines
26 KiB
Plaintext
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
// ─── User & Auth ───────────────────────────────────────────
|
|
|
|
model User {
|
|
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"
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
// Profile
|
|
heightCm Float? @map("height_cm")
|
|
dateOfBirth DateTime? @map("date_of_birth")
|
|
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
|
|
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
|
|
|
|
// 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")
|
|
|
|
// 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")
|
|
moodLogLastSyncedAt DateTime? @map("mood_log_last_synced_at")
|
|
moodLogWebhookSecret String? @map("mood_log_webhook_secret")
|
|
|
|
// Locale (null = use system/admin default)
|
|
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")
|
|
// Per-user dashboard layout — widget visibility + ordering.
|
|
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
|
|
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"
|
|
// 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")
|
|
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
|
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
|
|
|
// Onboarding
|
|
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
|
|
|
// HealthKit (iOS) integration config — list of per-metric direction toggles.
|
|
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
|
|
healthKitConfigJson Json? @map("healthkit_config_json")
|
|
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
|
|
|
|
// Display name (optional override of username for greetings).
|
|
displayName String? @map("display_name")
|
|
|
|
// 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[]
|
|
idempotencyKeys IdempotencyKey[]
|
|
devices Device[]
|
|
|
|
@@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")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("passkeys")
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
ipAddress String? @map("ip_address")
|
|
userAgent String? @map("user_agent")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("sessions")
|
|
}
|
|
|
|
model AuthChallenge {
|
|
id String @id @default(cuid())
|
|
userId String? @map("user_id") // null for registration
|
|
challenge String
|
|
type String // "registration" | "authentication"
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("auth_challenges")
|
|
}
|
|
|
|
// ─── Measurements ──────────────────────────────────────────
|
|
|
|
enum MeasurementType {
|
|
WEIGHT
|
|
BLOOD_PRESSURE_SYS
|
|
BLOOD_PRESSURE_DIA
|
|
PULSE
|
|
BODY_FAT
|
|
SLEEP_DURATION
|
|
ACTIVITY_STEPS
|
|
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
|
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
|
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
|
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
|
|
|
|
@@map("measurement_type")
|
|
}
|
|
|
|
/// Context for glucose measurements — affects which target range applies.
|
|
/// NULL for non-glucose measurements.
|
|
enum GlucoseContext {
|
|
FASTING // ≥8h without food, typically morning
|
|
POSTPRANDIAL // 2h after meal
|
|
RANDOM // any time, non-fasting, non-post-meal
|
|
BEDTIME // before sleep
|
|
|
|
@@map("glucose_context")
|
|
}
|
|
|
|
enum MeasurementSource {
|
|
MANUAL
|
|
WITHINGS
|
|
IMPORT
|
|
|
|
@@map("measurement_source")
|
|
}
|
|
|
|
model Measurement {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
type MeasurementType
|
|
value Float
|
|
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
|
|
source MeasurementSource @default(MANUAL)
|
|
measuredAt DateTime @map("measured_at")
|
|
notes String? // encrypted at app level if sensitive
|
|
externalId String? @map("external_id") // Withings grpid for dedup
|
|
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
|
|
glucoseContext GlucoseContext? @map("glucose_context")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, type, measuredAt, source])
|
|
@@index([userId, type, measuredAt])
|
|
@@index([externalId])
|
|
@@map("measurements")
|
|
}
|
|
|
|
// ─── Medications ───────────────────────────────────────────
|
|
|
|
model Medication {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
name String
|
|
dose String // e.g. "10mg"
|
|
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[]
|
|
phaseConfig ReminderPhaseConfig?
|
|
telegramMessages TelegramReminderMessage[]
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("medications")
|
|
}
|
|
|
|
model MedicationSchedule {
|
|
id String @id @default(cuid())
|
|
medicationId String @map("medication_id")
|
|
windowStart String // HH:mm format, e.g. "08:00"
|
|
windowEnd String // 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)
|
|
telegramMessages TelegramReminderMessage[]
|
|
|
|
@@map("medication_schedules")
|
|
}
|
|
|
|
enum IntakeSource {
|
|
WEB
|
|
API
|
|
REMINDER
|
|
IMPORT
|
|
|
|
@@map("intake_source")
|
|
}
|
|
|
|
enum ReminderPhase {
|
|
GREEN
|
|
YELLOW
|
|
ORANGE
|
|
RED
|
|
|
|
@@map("reminder_phase")
|
|
}
|
|
|
|
enum PhaseMode {
|
|
MINUTES
|
|
PERCENT
|
|
|
|
@@map("phase_mode")
|
|
}
|
|
|
|
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")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId, medicationId, scheduledFor])
|
|
@@map("medication_intake_events")
|
|
}
|
|
|
|
model ReminderPhaseConfig {
|
|
id String @id @default(cuid())
|
|
medicationId String @unique @map("medication_id")
|
|
greenValue Int @default(60) @map("green_value")
|
|
greenMode PhaseMode @default(MINUTES) @map("green_mode")
|
|
yellowValue Int @default(30) @map("yellow_value")
|
|
yellowMode PhaseMode @default(MINUTES) @map("yellow_mode")
|
|
orangeValue Int @default(0) @map("orange_value")
|
|
orangeMode PhaseMode @default(MINUTES) @map("orange_mode")
|
|
redValue Int @default(240) @map("red_value")
|
|
redMode PhaseMode @default(MINUTES) @map("red_mode")
|
|
|
|
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("reminder_phase_configs")
|
|
}
|
|
|
|
model TelegramReminderMessage {
|
|
id String @id @default(cuid())
|
|
medicationId String @map("medication_id")
|
|
scheduleId String @map("schedule_id")
|
|
chatId String @map("chat_id")
|
|
messageId Int @map("message_id")
|
|
phase ReminderPhase
|
|
sentAt DateTime @default(now()) @map("sent_at")
|
|
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)
|
|
|
|
@@unique([medicationId, scheduleId, date, phase])
|
|
@@index([medicationId, date])
|
|
@@map("telegram_reminder_messages")
|
|
}
|
|
|
|
model TelegramScheduledDeletion {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
chatId String @map("chat_id")
|
|
messageId Int @map("message_id")
|
|
deleteAfter DateTime @map("delete_after")
|
|
|
|
@@index([deleteAfter])
|
|
@@map("telegram_scheduled_deletions")
|
|
}
|
|
|
|
// ─── API Tokens (for external ingest) ──────────────────────
|
|
|
|
model ApiToken {
|
|
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"])
|
|
lastUsedAt DateTime? @map("last_used_at")
|
|
expiresAt DateTime? @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
revoked Boolean @default(false)
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("api_tokens")
|
|
}
|
|
|
|
// ─── 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")
|
|
/// 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")
|
|
/// 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")
|
|
/// 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")
|
|
/// 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")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
/// Composite index — every reuse-detection / device-scoped lookup filters
|
|
/// on (userId, deviceId), so a single-column device_id index would never
|
|
/// be used while costing writes. Multiple live rows per (userId, deviceId)
|
|
/// are intentional: rotated rows linger in `revokedAt != null` state for
|
|
/// audit and reuse-detection.
|
|
@@index([userId, deviceId])
|
|
@@index([expiresAt])
|
|
@@map("refresh_tokens")
|
|
}
|
|
|
|
// ─── 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")
|
|
lastSyncedAt DateTime? @map("last_synced_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("withings_connections")
|
|
}
|
|
|
|
// ─── Mood Entries (moodLog) ───────────────────────────────────
|
|
|
|
model MoodEntry {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
date String // YYYY-MM-DD
|
|
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")
|
|
syncedAt DateTime @default(now()) @map("synced_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, date, moodLoggedAt])
|
|
@@index([userId, date])
|
|
@@map("mood_entries")
|
|
}
|
|
|
|
// ─── 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")
|
|
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")
|
|
|
|
// Medication reminder thresholds (minutes after windowEnd)
|
|
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")
|
|
|
|
@@map("app_settings")
|
|
}
|
|
|
|
// ─── Built-in Feedback ───────────────────────────────────────
|
|
|
|
enum FeedbackCategory {
|
|
BUG
|
|
FEATURE_REQUEST
|
|
QUESTION
|
|
OTHER
|
|
|
|
@@map("feedback_category")
|
|
}
|
|
|
|
enum FeedbackStatus {
|
|
OPEN
|
|
ACKNOWLEDGED
|
|
RESOLVED
|
|
ARCHIVED
|
|
|
|
@@map("feedback_status")
|
|
}
|
|
|
|
/// Stores user-submitted feedback without requiring external GitHub config.
|
|
/// 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")
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([status, createdAt])
|
|
@@index([userId])
|
|
@@map("feedback")
|
|
}
|
|
|
|
// ─── Audit Log ─────────────────────────────────────────────
|
|
|
|
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
|
|
ipAddress String? @map("ip_address")
|
|
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)
|
|
|
|
@@index([userId, createdAt])
|
|
@@index([action, createdAt])
|
|
@@map("audit_logs")
|
|
}
|
|
|
|
// ─── Notification System ──────────────────────────────────────
|
|
|
|
model NotificationChannel {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
type String // "TELEGRAM" | "NTFY" | "WEB_PUSH"
|
|
enabled Boolean @default(true)
|
|
config String // AES-256-GCM encrypted JSON (channel-specific config)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
preferences NotificationPreference[]
|
|
|
|
@@unique([userId, type])
|
|
@@index([userId])
|
|
@@map("notification_channels")
|
|
}
|
|
|
|
model NotificationPreference {
|
|
id String @id @default(cuid())
|
|
channelId String @map("channel_id")
|
|
eventType String @map("event_type") // "MEDICATION_REMINDER" | "MEASUREMENT_ANOMALY" | etc.
|
|
enabled Boolean @default(true)
|
|
|
|
channel NotificationChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([channelId, eventType])
|
|
@@map("notification_preferences")
|
|
}
|
|
|
|
model PushSubscription {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
endpoint String
|
|
p256dh String // encrypted
|
|
auth String // encrypted
|
|
userAgent String? @map("user_agent")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, endpoint])
|
|
@@index([userId])
|
|
@@map("push_subscriptions")
|
|
}
|
|
|
|
// ─── Data Backups ────────────────────────────────────────────
|
|
|
|
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
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, type], map: "data_backups_user_id_type_key")
|
|
@@index([userId])
|
|
@@map("data_backups")
|
|
}
|
|
|
|
// ─── Rate Limiting ──────────────────────────────────────────
|
|
|
|
model RateLimit {
|
|
key String @id
|
|
count Int @default(1)
|
|
resetAt DateTime @map("reset_at")
|
|
|
|
@@index([resetAt])
|
|
@@map("rate_limits")
|
|
}
|
|
|
|
// ─── Idempotency Keys (mobile-client replay protection) ──
|
|
|
|
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
|
|
/// (userId, key, method, path) so retries within the TTL replay the
|
|
/// original outcome instead of producing a duplicate side-effect.
|
|
model IdempotencyKey {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
key String
|
|
method String
|
|
path String
|
|
responseStatus Int @map("response_status")
|
|
responseBody String @map("response_body") // serialized JSON envelope
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, key, method, path])
|
|
@@index([expiresAt])
|
|
@@map("idempotency_keys")
|
|
}
|
|
|
|
// ─── Devices (APNs / mobile push targets) ────────────────
|
|
|
|
/// Registered native-client device for push delivery. Token is unique
|
|
/// across users; re-registering on a different account moves ownership.
|
|
model Device {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
platform String // "ios"
|
|
token String @unique
|
|
bundleId String @map("bundle_id")
|
|
locale String?
|
|
appVersion String? @map("app_version")
|
|
model String?
|
|
lastSeen DateTime @default(now()) @map("last_seen")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("devices")
|
|
}
|
|
|
|
// ─── User Achievements ───────────────────────────────────────
|
|
|
|
model UserAchievement {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
achievementId String @map("achievement_id")
|
|
unlockedAt DateTime @map("unlocked_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, achievementId])
|
|
@@index([userId])
|
|
@@map("user_achievements")
|
|
}
|