mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Initial release v1.0.0
HealthLog — personal health-tracking PWA with weight, blood pressure, pulse, mood tracking, medication compliance, Withings integration, doctor report PDF export, and Dracula-themed UI.
This commit is contained in:
commit
38309932f3
301 files changed
+60041
No files matched your search
@@ -0,0 +1,37 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
APP_URL="http://localhost:3000"
|
||||
NODE_ENV="development"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# Encryption (for tokens, sensitive notes)
|
||||
ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# HMAC key for API token hashing
|
||||
API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# Withings OAuth (M5)
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# WITHINGS_REDIRECT_URI=""
|
||||
# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
|
||||
|
||||
# Telegram webhook hardening (optional but recommended)
|
||||
# TELEGRAM_WEBHOOK_SECRET=""
|
||||
|
||||
# OpenAI (optional, user-provided keys stored encrypted)
|
||||
# No server-side key needed - users bring their own
|
||||
|
||||
# Structured Logging (Wide Events)
|
||||
# LOG_LEVEL="info" # debug | info | warn | error
|
||||
# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
|
||||
# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
|
||||
# LOG_INCLUDE_STACK="true" # Include stack traces in error events
|
||||
# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
|
||||
# LOKI_USERNAME="" # Optional: Loki basic auth username
|
||||
# LOKI_PASSWORD="" # Optional: Loki basic auth password
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
security-patches:
|
||||
applies-to: security-updates
|
||||
update-types:
|
||||
- "patch"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Auto-merge Dependabot
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
- run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Security & Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Lint, Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
dependency-audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Audit for known vulnerabilities
|
||||
run: pnpm audit --prod --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
secret-scan:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gitleaks secret scan
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker-security:
|
||||
name: Container Security
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t healthlog:scan .
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
sudo apt-get install -y wget apt-transport-https gnupg
|
||||
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
|
||||
sudo apt-get update && sudo apt-get install -y trivy
|
||||
|
||||
- name: Trivy container scan
|
||||
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
|
||||
continue-on-error: true
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# prisma generated
|
||||
/src/generated/prisma
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
src/generated
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) working on this repository.
|
||||
|
||||
## Project
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: Feature-complete (v1.0.0). See GitHub Issues for open tasks.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version | Notes |
|
||||
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
|
||||
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
|
||||
| ORM | Prisma | 7.4 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
|
||||
| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` |
|
||||
| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` |
|
||||
| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens |
|
||||
| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) |
|
||||
| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` |
|
||||
| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) |
|
||||
| Testing | Vitest | latest | Config in `vitest.config.ts` |
|
||||
| Package manager | pnpm | latest | **Not** npm or yarn |
|
||||
| Node | 20.x | via nvm | |
|
||||
| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas |
|
||||
| Auth | SimpleWebAuthn | 13 | Passkeys primary, password fallback |
|
||||
| i18n | Custom context-based | — | `useTranslations()` hook, `messages/de.json` + `messages/en.json` |
|
||||
| PDF | jsPDF + autotable | latest | Client-side doctor report generation |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier format
|
||||
pnpm format:check # Prettier check
|
||||
pnpm typecheck # TypeScript strict check
|
||||
|
||||
# Tests
|
||||
pnpm test # Run all tests (vitest)
|
||||
pnpm test:watch # Watch mode
|
||||
|
||||
# Database
|
||||
pnpm db:generate # Generate Prisma client
|
||||
pnpm db:migrate # Create & apply migration (dev)
|
||||
pnpm db:migrate:deploy # Apply migrations (production)
|
||||
pnpm db:push # Push schema without migration (prototyping)
|
||||
pnpm db:studio # Prisma Studio GUI
|
||||
|
||||
# Docker
|
||||
docker compose up -d # Start app + postgres
|
||||
docker compose logs -f app # Tail app logs
|
||||
|
||||
# Verification (run before completing tasks)
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
```
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Pages and API routes (App Router)
|
||||
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
|
||||
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
|
||||
│ ├── globals.css # Dracula theme CSS variables
|
||||
│ ├── admin/page.tsx # Admin panel
|
||||
│ ├── auth/login/page.tsx # Login
|
||||
│ ├── auth/register/page.tsx # Registration
|
||||
│ ├── achievements/page.tsx # Gamification achievements
|
||||
│ ├── bugreport/page.tsx # Bug report → GitHub issue
|
||||
│ ├── insights/page.tsx # Health insights + correlations + AI (7 sections with sticky nav)
|
||||
│ ├── measurements/page.tsx # Measurements CRUD table
|
||||
│ ├── medications/page.tsx # Medications management
|
||||
│ ├── notifications/page.tsx # Notification preferences matrix
|
||||
│ ├── onboarding/page.tsx # 4-step guided onboarding
|
||||
│ ├── settings/page.tsx # All settings (13 sections, ~2100 lines)
|
||||
│ ├── zielwerte/page.tsx # Target values dashboard
|
||||
│ └── api/ # ~60 API route files (see docs/api.md)
|
||||
│ ├── mood-entries/ # Mood CRUD
|
||||
│ ├── import/ # JSON data import
|
||||
│ ├── doctor-report/ # Doctor report data aggregation
|
||||
│ ├── gamification/ # Achievements (persistent)
|
||||
│ ├── integrations/moodlog/ # moodLog.app webhook + sync + status
|
||||
│ └── ...
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Shell: sidebar-nav, topbar, mobile-nav, bottom-nav
|
||||
│ ├── medications/ # Medication form, card, timeline
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ ├── charts/ # Recharts wrappers, compliance charts
|
||||
│ ├── insights/ # AI insights card
|
||||
│ └── monitoring/ # Umami, GlitchTip bootstrap
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
│ ├── crypto.ts # AES-256-GCM encryption
|
||||
│ ├── gravatar.ts # Gravatar URL from email (SHA-256)
|
||||
│ ├── api-response.ts # { data, error, meta } envelope helpers
|
||||
│ ├── rate-limit.ts # In-memory sliding window rate limiter
|
||||
│ ├── export.ts # CSV/JSON export formatting (incl. mood)
|
||||
│ ├── doctor-report-pdf.ts # Client-side PDF generation (jsPDF)
|
||||
│ ├── auth/session.ts # getSession() → { session, user } | null
|
||||
│ ├── analytics/ # Trend calculations, compliance, correlations
|
||||
│ ├── i18n/ # I18n context + config
|
||||
│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push)
|
||||
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
|
||||
│ ├── validations/ # Zod schemas (shared between API + client)
|
||||
│ └── withings/ # Withings OAuth client + sync service
|
||||
├── hooks/
|
||||
│ └── use-auth.ts # useAuth(), useLogout() hooks
|
||||
├── generated/prisma/client/ # Generated Prisma client (DO NOT EDIT)
|
||||
messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (22 models)
|
||||
└── migrations/ # Migration files (0001–0018)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
├── sw.js # Service worker (Web Push + offline caching)
|
||||
├── manifest.json # PWA manifest
|
||||
└── ... # Static assets
|
||||
docs.healthlog.dev # External documentation site
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **UI language is German**. All user-facing text in German. Code, comments, docs in English.
|
||||
- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`.
|
||||
- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`.
|
||||
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Nicht angemeldet", 401);`
|
||||
- **Timezone**: `Europe/Berlin` for display, UTC in database.
|
||||
- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`.
|
||||
- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements.
|
||||
- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie on all non-public paths. Unauthenticated page requests → redirect `/auth/login`. API routes keep their own `getSession()` auth. Public paths defined in `PUBLIC_PATHS` array.
|
||||
- **PWA safe area**: Root layout uses `viewportFit: "cover"`. Bottom-nav has `pb-[env(safe-area-inset-bottom)]`. Auth-shell uses `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]`.
|
||||
- **PDF generation**: Client-side via `src/lib/doctor-report-pdf.ts` using jsPDF. Data fetched from `POST /api/doctor-report`. European medical format with German terminology.
|
||||
- **Data backup**: pg-boss weekly job (`data-backup` queue, Sundays 03:00), stores compressed JSON in `DataBackup` model.
|
||||
- **Achievements**: Persistent in `UserAchievement` table. API at `/api/gamification/achievements` computes current state, compares with DB, persists new unlocks. `unlockedAt` timestamp is stable once set.
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
### Prisma 7
|
||||
|
||||
- **No `url` in schema.prisma** — The database URL is configured in `prisma.config.ts`, not in the `datasource` block.
|
||||
- **Import path**: `import { ... } from "@/generated/prisma/client"` (with `/client` suffix, not `@/generated/prisma`).
|
||||
- **Adapter required**: PrismaClient needs `PrismaPg` adapter from `@prisma/adapter-pg`. See `src/lib/db.ts`.
|
||||
- **No `earlyAccess`** in defineConfig — causes TypeScript errors.
|
||||
|
||||
### Next.js 16
|
||||
|
||||
- **Proxy, not middleware**: Next.js 16 renamed middleware to proxy. The file is `src/proxy.ts` (not `middleware.ts`). Having both causes a build error: "Both middleware file and proxy file detected".
|
||||
|
||||
### Libraries
|
||||
|
||||
- **SimpleWebAuthn v13**: No `@simplewebauthn/server/script/deps` — define Transport type inline.
|
||||
- **zxcvbn-typescript**: Default export only. `zxcvbnAsync` and `zxcvbnOptions` do not exist as named exports.
|
||||
- **pg-boss v12**: `{ PgBoss }` named import (not default), use `localConcurrency` (not `teamSize`), handler receives `Job<T>[]` array.
|
||||
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
|
||||
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
|
||||
|
||||
### Settings Page
|
||||
|
||||
- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts.
|
||||
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
|
||||
- Section IDs: `profil`, `sprache`, `sicherheit`, `telegram`, `ntfy`, `web-push`, `insights`, `withings`, `api`, `export`, `protokoll`, `daten`.
|
||||
|
||||
### Insights Page
|
||||
|
||||
- Large file (~1800+ lines), 7 content sections with AI summaries.
|
||||
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
|
||||
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `medications`, `bmi`.
|
||||
- Each section has `scroll-mt-28` for header offset on scroll.
|
||||
|
||||
### Sidebar
|
||||
|
||||
- Collapsible (icons-only mode, `w-16`). State persisted in localStorage (`healthlog-sidebar-collapsed`).
|
||||
- User section at bottom: Avatar (Gravatar if email, initials fallback) + username + three-dot MoreVertical menu (right side).
|
||||
- Three-dot menu: Admin link, Notifications, Theme picker, Logout.
|
||||
- ESLint enforces `react-hooks/set-state-in-effect` — use lazy `useState(() => ...)` for localStorage reads, NOT `useEffect` + `setState`.
|
||||
|
||||
### Gravatar
|
||||
|
||||
- `src/lib/gravatar.ts` generates Gravatar URL server-side (SHA-256 hash of email, `?d=404` fallback).
|
||||
- Returned in `/api/auth/me` response as `gravatarUrl` field. Used by `AvatarImage` component with `AvatarFallback` for initials.
|
||||
|
||||
### Notification System
|
||||
|
||||
- **Channels**: `TELEGRAM`, `NTFY`, `WEB_PUSH` (DB: `NotificationChannel`).
|
||||
- **Event types**: `MEDICATION_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`.
|
||||
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
|
||||
- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type.
|
||||
- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h.
|
||||
|
||||
### Onboarding
|
||||
|
||||
- 4-step guided flow: Profile → Medications → Notifications teaser → Target values.
|
||||
- Optional steps 2-4 have "Skip" buttons.
|
||||
- Progress indicator bar at top.
|
||||
- Located at `/onboarding`, linked from proxy as public path.
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
1. **Read before modifying** — understand the existing code patterns.
|
||||
2. **Run verification** after changes: `pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`
|
||||
3. **German UI text** — all user-facing strings go through `t("key")` with translations in both `messages/de.json` and `messages/en.json`.
|
||||
4. **Don't over-engineer** — keep changes focused. No speculative abstractions.
|
||||
5. **Update `docs/STATUS.md`** when completing tasks.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Contributing to HealthLog
|
||||
|
||||
Contributions are welcome. This guide covers how to set up the project and submit changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- pnpm
|
||||
- PostgreSQL 16+ (or Docker)
|
||||
- Git
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and configure at minimum:
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog"
|
||||
SESSION_SECRET=<64-char hex>
|
||||
ENCRYPTION_KEY=<64-char hex>
|
||||
API_TOKEN_HMAC_KEY=<64-char hex>
|
||||
```
|
||||
|
||||
Generate secrets with `openssl rand -hex 32`.
|
||||
|
||||
### With Docker (database only)
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
pnpm install
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Full Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
pnpm typecheck # TypeScript strict mode
|
||||
pnpm test # Vitest
|
||||
```
|
||||
|
||||
All four checks must pass before submitting a PR.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **TypeScript strict mode** -- no `any` types, no type assertions unless absolutely necessary
|
||||
- **UI text in German**, code/comments/docs in English
|
||||
- **API responses** follow `{ data, error, meta }` envelope pattern
|
||||
- **Zod v4** for input validation (API routes and forms)
|
||||
- **shadcn/ui** components (new-york style) for UI primitives
|
||||
- **Dracula theme** -- dark mode only, use `--dracula-*` CSS tokens
|
||||
|
||||
See `CLAUDE.md` for detailed architecture and patterns.
|
||||
|
||||
## Database Changes
|
||||
|
||||
```bash
|
||||
# After modifying prisma/schema.prisma:
|
||||
pnpm db:migrate # Creates a new migration
|
||||
pnpm db:generate # Regenerates Prisma client
|
||||
```
|
||||
|
||||
Always include the migration file in your PR.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||
3. Make your changes
|
||||
4. Ensure all checks pass (`pnpm lint && pnpm typecheck && pnpm test`)
|
||||
5. Submit a PR with a clear description of the changes
|
||||
|
||||
## Security
|
||||
|
||||
If you find a security vulnerability, **do not open a public issue**. See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](LICENSE) license.
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# ── Stage 1: Dependencies ──────────────────────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# ── Stage 2: Build ─────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm db:generate
|
||||
|
||||
# Build Next.js
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copy Prisma for migrations (schema, migration SQL, config, engines)
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder /app/src/generated ./src/generated
|
||||
|
||||
# Install all worker runtime dependencies in a shared prefix.
|
||||
# NODE_PATH makes them available to Node.js module resolution.
|
||||
# These packages are listed in next.config.ts serverExternalPackages
|
||||
# and are NOT bundled by Next.js — they must exist at runtime.
|
||||
RUN mkdir -p /opt/pg-boss && \
|
||||
cd /opt/pg-boss && \
|
||||
npm init -y && \
|
||||
npm install --omit=dev pg-boss@12 @prisma/adapter-pg@7 pg@8
|
||||
|
||||
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
|
||||
RUN mkdir -p /opt/prisma-cli && \
|
||||
cd /opt/prisma-cli && \
|
||||
npm init -y && \
|
||||
npm install --omit=dev prisma@7.4.0 @prisma/engines@7.4.0
|
||||
|
||||
# Entrypoint script (runs migrations, then starts app)
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,335 @@
|
||||
<p align="center">
|
||||
<img src="public/logo-readme.svg" alt="HealthLog Logo" width="120" height="120" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">HealthLog</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your health data belongs to you. Track it on your own terms.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||||
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted, privacy-first health tracking PWA.<br/>
|
||||
Weight, blood pressure, mood, medications, AI insights -- all under your control.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://healthlog.dev">Website</a> ·
|
||||
<a href="https://demo.healthlog.dev">Live Demo</a> ·
|
||||
<a href="https://docs.healthlog.dev">Documentation</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Why HealthLog?
|
||||
|
||||
Most health apps lock your data behind proprietary clouds, push subscriptions, and sell your metrics to advertisers. HealthLog takes a different approach: your data stays on your server, encrypted at rest, accessible only to you.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, and steps with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 guidelines.
|
||||
|
||||
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
|
||||
|
||||
**Medication Compliance** -- Flexible scheduling with time windows, recurrence patterns, intake logging (take/skip/snooze), and compliance heatmaps. External API for iOS Shortcuts integration.
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**AI-Powered Insights** -- Bring your own OpenAI API key. Personalized analysis across blood pressure, weight, pulse, mood, BMI, and medication adherence. Cached daily to minimize API usage.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. European format with vital sign summaries, BP/BMI classification, compliance rates, and optional AI analysis.
|
||||
|
||||
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
|
||||
|
||||
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation.
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- Full German and English UI with 1000+ translation keys. Browser-based locale detection with per-user override.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the required secrets:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # SESSION_SECRET
|
||||
openssl rand -hex 32 # ENCRYPTION_KEY
|
||||
openssl rand -hex 32 # API_TOKEN_HMAC_KEY
|
||||
```
|
||||
|
||||
Add them to `.env`, then:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (22 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
|
||||
---
|
||||
|
||||
## Security and Privacy
|
||||
|
||||
HealthLog is designed for people who take data ownership seriously.
|
||||
|
||||
- **Self-hosted** -- Your data never leaves your server. No telemetry, no third-party tracking.
|
||||
- **AES-256-GCM encryption** -- All stored secrets (OAuth tokens, API keys, VAPID keys) are encrypted at rest.
|
||||
- **Passkey authentication** -- WebAuthn as primary auth with password fallback (Argon2id + zxcvbn strength validation).
|
||||
- **Server-side sessions** -- PostgreSQL-backed with 30-day sliding expiry, HttpOnly/SameSite=Strict cookies.
|
||||
- **Security headers** -- CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy, Referrer-Policy.
|
||||
- **Rate limiting** -- Sliding window on auth and API endpoints.
|
||||
- **HMAC-SHA256 API tokens** -- Bearer tokens are hashed before storage.
|
||||
- **Audit logging** -- All sensitive operations tracked with IP addresses.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
|
||||
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages & API routes
|
||||
│ ├── api/ # REST API endpoints (~60 route files)
|
||||
│ ├── admin/ # Admin panel
|
||||
│ ├── medications/ # Medication management
|
||||
│ ├── measurements/ # Health metric entry
|
||||
│ ├── insights/ # AI-powered analytics
|
||||
│ ├── achievements/ # Gamification page
|
||||
│ ├── onboarding/ # 4-step guided setup
|
||||
│ └── settings/ # User preferences
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
|
||||
│ ├── medications/ # Medication cards, forms, timeline
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ └── charts/ # Recharts wrappers
|
||||
├── lib/
|
||||
│ ├── auth/ # Session, audit, passkey logic
|
||||
│ ├── notifications/ # Dispatcher + channel senders
|
||||
│ ├── jobs/ # pg-boss worker
|
||||
│ ├── analytics/ # Trend calculations, compliance
|
||||
│ ├── withings/ # OAuth client, sync service
|
||||
│ ├── i18n/ # Translations context & config
|
||||
│ ├── validations/ # Shared Zod schemas
|
||||
│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
|
||||
│ ├── db.ts # Prisma singleton
|
||||
│ └── doctor-report-pdf.ts # Client-side PDF generation
|
||||
├── hooks/ # React hooks
|
||||
└── generated/prisma/ # Generated Prisma client
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **RSC by default** -- `"use client"` only for interactive components
|
||||
- **API envelope** -- All responses follow `{ data, error, meta }` shape
|
||||
- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
|
||||
- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
|
||||
- **Route protection** -- `proxy.ts` checks session cookie, redirects unauthenticated requests
|
||||
- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All mutations require authentication via session cookie. External ingest uses Bearer tokens.
|
||||
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Mood</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Medications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Integrations
|
||||
|
||||
| Integration | Setup | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Prerequisites: Node.js 20+, pnpm, PostgreSQL
|
||||
|
||||
cp .env.example .env
|
||||
pnpm install
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
```bash
|
||||
pnpm dev # Development server
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
pnpm typecheck # TypeScript strict check
|
||||
pnpm test # Vitest
|
||||
pnpm format # Prettier
|
||||
|
||||
pnpm db:generate # Generate Prisma client
|
||||
pnpm db:migrate # Create & apply migration
|
||||
pnpm db:migrate:deploy # Apply migrations (production)
|
||||
pnpm db:studio # Prisma Studio GUI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint automatically waits for the database, runs pending migrations, and starts the server.
|
||||
|
||||
The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
- Code style: `pnpm format && pnpm lint`
|
||||
- Type safety: `pnpm typecheck` must pass
|
||||
- Tests: `pnpm test`
|
||||
- UI language: German. Code, comments, and commits: English.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://healthlog.dev">healthlog.dev</a> ·
|
||||
<a href="https://demo.healthlog.dev">Live Demo</a> ·
|
||||
<a href="https://docs.healthlog.dev">Docs</a>
|
||||
</p>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest release on the `main` branch is actively supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly.
|
||||
|
||||
**Do not open a public GitHub issue for security vulnerabilities.**
|
||||
|
||||
Instead, send an email to **security@bombeck.io** with:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
## Response Timeline
|
||||
|
||||
- **Acknowledgement**: Within 72 hours
|
||||
- **Initial assessment**: Within 1 week
|
||||
- **Fix and disclosure**: Coordinated with reporter, typically within 30 days
|
||||
|
||||
## Scope
|
||||
|
||||
The following are in scope:
|
||||
|
||||
- Authentication and session management
|
||||
- Data encryption and secret handling
|
||||
- API authorization and access control
|
||||
- Cross-site scripting (XSS), CSRF, and injection vulnerabilities
|
||||
- Information disclosure
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Denial of service attacks
|
||||
- Social engineering
|
||||
- Vulnerabilities in third-party dependencies (report upstream)
|
||||
- Issues requiring physical access to the server
|
||||
|
||||
## Security Architecture
|
||||
|
||||
HealthLog is designed with security as a core principle:
|
||||
|
||||
- **Passkey-first authentication** (WebAuthn/FIDO2) with password fallback
|
||||
- **AES-256-GCM encryption** for all stored secrets (OAuth tokens, API keys, VAPID keys)
|
||||
- **Argon2id** password hashing with zxcvbn strength validation
|
||||
- **HMAC-SHA256** hashed API tokens
|
||||
- **Server-side sessions** in PostgreSQL (HttpOnly, SameSite=Strict, 30-day sliding expiry)
|
||||
- **Rate limiting** on authentication and external-facing endpoints
|
||||
- **Security headers**: CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy
|
||||
- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths)
|
||||
|
||||
For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/).
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: healthlog-app
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U healthlog"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
|
||||
echo "HealthLog: Waiting for database..."
|
||||
until node -e "
|
||||
const { createConnection } = require('net');
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
const sock = createConnection(Number(url.port) || 5432, url.hostname);
|
||||
sock.on('connect', () => { sock.destroy(); process.exit(0); });
|
||||
sock.on('error', () => process.exit(1));
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "HealthLog: Database is reachable."
|
||||
|
||||
# ── Run Prisma migrations ────────────────────────────────────
|
||||
echo "HealthLog: Running database migrations..."
|
||||
NODE_PATH=/opt/prisma-cli/node_modules \
|
||||
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
|
||||
echo "HealthLog: Migrations complete."
|
||||
|
||||
# ── Start application ────────────────────────────────────────
|
||||
echo "HealthLog: Starting application..."
|
||||
exec "$@"
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
+1212
File diff suppressed because it is too large.
Load diff
+1212
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: [
|
||||
"@prisma/client",
|
||||
"pg-boss",
|
||||
"@prisma/adapter-pg",
|
||||
"pg",
|
||||
],
|
||||
experimental: {
|
||||
optimizePackageImports: ["recharts", "lucide-react"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:studio": "prisma studio",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.3",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"jspdf": "^4.2.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg-boss": "^12.13.0",
|
||||
"prisma": "^7.4.2",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"prisma",
|
||||
"@prisma/engines"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+10485
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, ".env") });
|
||||
dotenv.config({ path: path.join(__dirname, ".env.local") });
|
||||
|
||||
const inContainer = fs.existsSync("/.dockerenv");
|
||||
const defaultDatabaseUrl = inContainer
|
||||
? "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
|
||||
: "postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public";
|
||||
|
||||
function resolveDatabaseUrl(): string {
|
||||
const configured = process.env.DATABASE_URL?.trim() || defaultDatabaseUrl;
|
||||
|
||||
// Host-local Prisma CLI calls cannot resolve Docker-internal hostname "db".
|
||||
// Keep container behavior unchanged (Coolify / Docker runtime).
|
||||
if (inContainer) return configured;
|
||||
|
||||
try {
|
||||
const parsed = new URL(configured);
|
||||
if (parsed.hostname === "db") {
|
||||
parsed.hostname = "localhost";
|
||||
return parsed.toString();
|
||||
}
|
||||
return configured;
|
||||
} catch {
|
||||
return configured;
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = resolveDatabaseUrl();
|
||||
|
||||
export default defineConfig({
|
||||
schema: path.join(__dirname, "prisma", "schema.prisma"),
|
||||
datasource: {
|
||||
url: databaseUrl,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "measurement_type" AS ENUM ('WEIGHT', 'BLOOD_PRESSURE_SYS', 'BLOOD_PRESSURE_DIA', 'PULSE', 'BODY_FAT', 'SLEEP_DURATION', 'ACTIVITY_STEPS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "measurement_source" AS ENUM ('MANUAL', 'WITHINGS', 'IMPORT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "intake_source" AS ENUM ('WEB', 'API', 'REMINDER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"password_hash" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"height_cm" DOUBLE PRECISION,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||
"bp_sys_target_low" INTEGER,
|
||||
"bp_sys_target_high" INTEGER,
|
||||
"bp_dia_target_low" INTEGER,
|
||||
"bp_dia_target_high" INTEGER,
|
||||
"openai_key_encrypted" TEXT,
|
||||
"insights_privacy_mode" TEXT NOT NULL DEFAULT 'aggregated',
|
||||
"insights_cached_at" TIMESTAMP(3),
|
||||
"insights_cached_text" TEXT,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "passkeys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT 'Passkey',
|
||||
"credential_id" TEXT NOT NULL,
|
||||
"credential_public_key" BYTEA NOT NULL,
|
||||
"counter" BIGINT NOT NULL DEFAULT 0,
|
||||
"credential_device_type" TEXT NOT NULL,
|
||||
"credential_backed_up" BOOLEAN NOT NULL DEFAULT false,
|
||||
"transports" TEXT[],
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "passkeys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "auth_challenges" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"challenge" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "auth_challenges_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "measurements" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" "measurement_type" NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"unit" TEXT NOT NULL,
|
||||
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
|
||||
"measured_at" TIMESTAMP(3) NOT NULL,
|
||||
"notes" TEXT,
|
||||
"external_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "measurements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "medications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"dose" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "medications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "medication_schedules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"windowStart" TEXT NOT NULL,
|
||||
"windowEnd" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
|
||||
CONSTRAINT "medication_schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "medication_intake_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"scheduled_for" TIMESTAMP(3) NOT NULL,
|
||||
"taken_at" TIMESTAMP(3),
|
||||
"skipped" BOOLEAN NOT NULL DEFAULT false,
|
||||
"source" "intake_source" NOT NULL DEFAULT 'WEB',
|
||||
"idempotency_key" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_intake_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"permissions" TEXT[] DEFAULT ARRAY['medication:ingest']::TEXT[],
|
||||
"last_used_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "api_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "withings_connections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"withings_user_id" TEXT NOT NULL,
|
||||
"access_token" TEXT NOT NULL,
|
||||
"refresh_token" TEXT NOT NULL,
|
||||
"token_expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_synced_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "withings_connections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT,
|
||||
"ip_address" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "passkeys_credential_id_key" ON "passkeys"("credential_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "auth_challenges_user_id_idx" ON "auth_challenges"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "measurements_user_id_type_measured_at_idx" ON "measurements"("user_id", "type", "measured_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "measurements_external_id_idx" ON "measurements"("external_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "measurements_user_id_type_measured_at_source_key" ON "measurements"("user_id", "type", "measured_at", "source");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medications_user_id_idx" ON "medications"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "medication_intake_events_idempotency_key_key" ON "medication_intake_events"("idempotency_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_medication_id_scheduled_fo_idx" ON "medication_intake_events"("user_id", "medication_id", "scheduled_for");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_tokens_token_hash_key" ON "api_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "withings_connections_user_id_key" ON "withings_connections"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_user_id_created_at_idx" ON "audit_logs"("user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_action_created_at_idx" ON "audit_logs"("action", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "auth_challenges" ADD CONSTRAINT "auth_challenges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "measurements" ADD CONSTRAINT "measurements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medications" ADD CONSTRAINT "medications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_schedules" ADD CONSTRAINT "medication_schedules_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_intake_events" ADD CONSTRAINT "medication_intake_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_intake_events" ADD CONSTRAINT "medication_intake_events_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "withings_connections" ADD CONSTRAINT "withings_connections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable: Add role and date_of_birth to users
|
||||
ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL DEFAULT 'USER';
|
||||
ALTER TABLE "users" ADD COLUMN "date_of_birth" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: Remove BP target columns from users
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_sys_target_low";
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_sys_target_high";
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_dia_target_low";
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "bp_dia_target_high";
|
||||
|
||||
-- CreateTable: app_settings (singleton)
|
||||
CREATE TABLE "app_settings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"registration_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "app_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Insert default settings row
|
||||
INSERT INTO "app_settings" ("id", "registration_enabled") VALUES ('singleton', true)
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
|
||||
-- Set existing users as ADMIN (first user gets admin role)
|
||||
UPDATE "users" SET "role" = 'ADMIN'
|
||||
WHERE "id" = (SELECT "id" FROM "users" ORDER BY "created_at" ASC LIMIT 1);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AlterTable: Add missing columns to users
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "gender" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "telegram_bot_token" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "telegram_chat_id" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "telegram_enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "withings_client_id_encrypted" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "withings_client_secret_encrypted" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "onboarding_completed_at" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: Add missing dose column to medication_schedules
|
||||
ALTER TABLE "medication_schedules" ADD COLUMN IF NOT EXISTS "dose" TEXT;
|
||||
|
||||
-- AlterEnum: Add IMPORT to intake_source
|
||||
ALTER TYPE "intake_source" ADD VALUE IF NOT EXISTS 'IMPORT';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "audit_logs" ADD COLUMN "location" TEXT;
|
||||
|
||||
-- DropTable (orphaned, not in schema)
|
||||
DROP TABLE IF EXISTS "medication_categories";
|
||||
@@ -0,0 +1,55 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "notification_channels" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"config" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notification_channels_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notification_preferences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channel_id" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "push_subscriptions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"user_agent" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notification_channels_user_id_idx" ON "notification_channels"("user_id");
|
||||
CREATE UNIQUE INDEX "notification_channels_user_id_type_key" ON "notification_channels"("user_id", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notification_preferences_channel_id_event_type_key" ON "notification_preferences"("channel_id", "event_type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "push_subscriptions_user_id_idx" ON "push_subscriptions"("user_id");
|
||||
CREATE UNIQUE INDEX "push_subscriptions_user_id_endpoint_key" ON "push_subscriptions"("user_id", "endpoint");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notification_channels" ADD CONSTRAINT "notification_channels_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_channel_id_fkey" FOREIGN KEY ("channel_id") REFERENCES "notification_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: per-medication notification toggle
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "notifications_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: track when medication was paused
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "paused_at" TIMESTAMP(3);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: add bug report GitHub configuration
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "github_issue_token_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "github_issue_repo" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "api_global" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "umami_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "umami_script_url" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "umami_website_id" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "glitchtip_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "glitchtip_dsn" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "glitchtip_environment" TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable: add Web Push VAPID settings to app_settings
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "web_push_vapid_public_key" TEXT;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "web_push_vapid_private_key_encrypted" TEXT;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "web_push_vapid_subject" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable: add medication reminder thresholds to app_settings
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "reminder_late_minutes" INTEGER NOT NULL DEFAULT 120;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "reminder_missed_minutes" INTEGER NOT NULL DEFAULT 240;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "medications" ADD COLUMN "snoozed_until" TIMESTAMP(3);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "mood_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"mood" TEXT NOT NULL,
|
||||
"score" INTEGER NOT NULL,
|
||||
"tags" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'MOODLOG',
|
||||
"mood_logged_at" TIMESTAMP(3) NOT NULL,
|
||||
"synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "mood_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mood_entries_user_id_date_idx" ON "mood_entries"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "mood_entries_user_id_date_mood_logged_at_key" ON "mood_entries"("user_id", "date", "mood_logged_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mood_entries" ADD CONSTRAINT "mood_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: Add moodLog fields to users
|
||||
ALTER TABLE "users" ADD COLUMN "mood_log_url_encrypted" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "mood_log_api_key_encrypted" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "mood_log_enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "users" ADD COLUMN "mood_log_last_synced_at" TIMESTAMP(3);
|
||||
ALTER TABLE "users" ADD COLUMN "mood_log_webhook_secret" TEXT;
|
||||
|
||||
-- AlterTable: Add moodLogGlobal to app_settings
|
||||
ALTER TABLE "app_settings" ADD COLUMN "mood_log_global" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "reminder_phase" AS ENUM ('GREEN', 'YELLOW', 'ORANGE', 'RED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "phase_mode" AS ENUM ('MINUTES', 'PERCENT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reminder_phase_configs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"green_value" INTEGER NOT NULL DEFAULT 60,
|
||||
"green_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES',
|
||||
"yellow_value" INTEGER NOT NULL DEFAULT 30,
|
||||
"yellow_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES',
|
||||
"orange_value" INTEGER NOT NULL DEFAULT 0,
|
||||
"orange_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES',
|
||||
"red_value" INTEGER NOT NULL DEFAULT 240,
|
||||
"red_mode" "phase_mode" NOT NULL DEFAULT 'MINUTES',
|
||||
|
||||
CONSTRAINT "reminder_phase_configs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "telegram_reminder_messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"schedule_id" TEXT NOT NULL,
|
||||
"chat_id" TEXT NOT NULL,
|
||||
"message_id" INTEGER NOT NULL,
|
||||
"phase" "reminder_phase" NOT NULL,
|
||||
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"date" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "telegram_reminder_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "reminder_phase_configs_medication_id_key" ON "reminder_phase_configs"("medication_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "telegram_reminder_messages_medication_id_schedule_id_date_phase_key" ON "telegram_reminder_messages"("medication_id", "schedule_id", "date", "phase");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "telegram_reminder_messages_medication_id_date_idx" ON "telegram_reminder_messages"("medication_id", "date");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reminder_phase_configs" ADD CONSTRAINT "reminder_phase_configs_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "telegram_reminder_messages" ADD CONSTRAINT "telegram_reminder_messages_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "telegram_reminder_messages" ADD CONSTRAINT "telegram_reminder_messages_schedule_id_fkey" FOREIGN KEY ("schedule_id") REFERENCES "medication_schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "telegram_scheduled_deletions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"chat_id" TEXT NOT NULL,
|
||||
"message_id" INTEGER NOT NULL,
|
||||
"delete_after" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "telegram_scheduled_deletions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "telegram_scheduled_deletions_delete_after_idx" ON "telegram_scheduled_deletions"("delete_after");
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "data_backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL DEFAULT 'WEEKLY_AUTO',
|
||||
"data" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "data_backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "data_backups_user_id_idx" ON "data_backups"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "data_backups_user_id_type_key" ON "data_backups"("user_id", "type");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "data_backups" ADD CONSTRAINT "data_backups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_achievements" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"achievement_id" TEXT NOT NULL,
|
||||
"unlocked_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_achievements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_achievements_user_id_idx" ON "user_achievements"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_achievements_user_id_achievement_id_key" ON "user_achievements"("user_id", "achievement_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "rate_limits" (
|
||||
"key" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL DEFAULT 1,
|
||||
"reset_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rate_limits_reset_at_idx" ON "rate_limits"("reset_at");
|
||||
@@ -0,0 +1,486 @@
|
||||
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")
|
||||
|
||||
// OpenAI Insights (optional)
|
||||
openaiKeyEncrypted String? @map("openai_key_encrypted") // AES-256-GCM encrypted
|
||||
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
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
challenges AuthChallenge[]
|
||||
measurements Measurement[]
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
apiTokens ApiToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
moodEntries MoodEntry[]
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
|
||||
@@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
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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")
|
||||
}
|
||||
|
||||
// ─── 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")
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
// ─── 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")
|
||||
}
|
||||
|
||||
// ─── 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")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
|
||||
<defs>
|
||||
<clipPath id="heart-clip">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#bd93f9" fill-opacity="0.2" stroke="#bd93f9" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="3,12.5 8,12.5 9.5,9 11,16 12.5,7 14,12.5 15.5,12.5 21,12.5" fill="none" stroke="#bd93f9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" clip-path="url(#heart-clip)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 874 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#282a36"/>
|
||||
<stop offset="100%" stop-color="#1e1f29"/>
|
||||
</linearGradient>
|
||||
<clipPath id="heart-clip">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="60" cy="60" r="58" fill="url(#bg)" stroke="#6272a4" stroke-width="2"/>
|
||||
<g transform="translate(24, 25) scale(3)">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#bd93f9" fill-opacity="0.25" stroke="#bd93f9" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="3,12.5 8,12.5 9.5,9 11,16 12.5,7 14,12.5 15.5,12.5 21,12.5" fill="none" stroke="#bd93f9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" clip-path="url(#heart-clip)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "HealthLog",
|
||||
"short_name": "HealthLog",
|
||||
"description": "Personal health tracking — weight, blood pressure, medications",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#282a36",
|
||||
"background_color": "#282a36",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Service Worker for HealthLog PWA.
|
||||
* Handles Web Push notifications and offline caching.
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = "v1";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
const MAX_PAGE_ENTRIES = 30;
|
||||
|
||||
// App shell files to precache on install
|
||||
const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"];
|
||||
|
||||
// ── Install: precache app shell ──────────────────────────────────────────────
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(STATIC_CACHE)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Activate: clean up old caches ────────────────────────────────────────────
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Fetch: strategy per resource type ────────────────────────────────────────
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle same-origin GET requests
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
|
||||
// API calls — network only (never serve stale data)
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
// Next.js static assets — cache-first (immutable hashed filenames)
|
||||
if (url.pathname.startsWith("/_next/static/")) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Static files (fonts, icons, images) — cache-first with long TTL
|
||||
if (
|
||||
/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)
|
||||
) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages — network-first with cache fallback
|
||||
if (request.headers.get("accept")?.includes("text/html")) {
|
||||
event.respondWith(networkFirst(request, PAGE_CACHE));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache-first: serve from cache, fall back to network and cache the response.
|
||||
*/
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
trimCache(cacheName, MAX_STATIC_ENTRIES);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first: try network, fall back to cache.
|
||||
*/
|
||||
async function networkFirst(request, cacheName) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
trimCache(cacheName, MAX_PAGE_ENTRIES);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
// Offline fallback
|
||||
return new Response(
|
||||
'<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HealthLog — Offline</title><style>body{font-family:system-ui,sans-serif;background:#282a36;color:#f8f8f2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}div{text-align:center;padding:2rem}h1{color:#bd93f9;margin-bottom:0.5rem}p{color:#6272a4}</style></head><body><div><h1>Offline</h1><p>Keine Internetverbindung. Bitte versuche es später erneut.</p></div></body></html>',
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim cache to maxEntries by removing oldest entries.
|
||||
*/
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
let keys = await cache.keys();
|
||||
while (keys.length > maxEntries) {
|
||||
await cache.delete(keys[0]);
|
||||
keys = await cache.keys();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push Notifications ───────────────────────────────────────────────────────
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: "HealthLog", body: event.data.text() };
|
||||
}
|
||||
|
||||
const {
|
||||
title = "HealthLog",
|
||||
body = "",
|
||||
tag = "default",
|
||||
url = "/",
|
||||
} = payload;
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
icon: "/logo-192.png",
|
||||
badge: "/logo-192.png",
|
||||
data: { url },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || "/";
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && "focus" in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Demo Data Seed Script for HealthLog
|
||||
*
|
||||
* Creates realistic demo data:
|
||||
* - 1 admin user (demo/demo123demo123)
|
||||
* - 90 days of measurements (weight, BP, pulse, body fat, sleep, steps)
|
||||
* - 3 medications with schedules and intake history
|
||||
* - 90 days of mood entries
|
||||
* - App settings (registration disabled, English locale)
|
||||
*
|
||||
* Usage: npx tsx scripts/seed-demo.ts
|
||||
* Requires DATABASE_URL env var.
|
||||
*/
|
||||
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
// ── Helpers ──────────────────────────────────────
|
||||
|
||||
function cuid(): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id = "c";
|
||||
for (let i = 0; i < 24; i++) {
|
||||
id += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function daysAgo(n: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
d.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// Smooth random walk with mean reversion
|
||||
function randomWalk(start: number, target: number, days: number, volatility: number): number[] {
|
||||
const values: number[] = [start];
|
||||
for (let i = 1; i < days; i++) {
|
||||
const prev = values[i - 1];
|
||||
const drift = (target - prev) * 0.03; // mean reversion
|
||||
const noise = (Math.random() - 0.5) * volatility;
|
||||
values.push(Math.round((prev + drift + noise) * 10) / 10);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────
|
||||
|
||||
async function seed() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
console.log("Cleaning existing data...");
|
||||
await client.query("DELETE FROM mood_entries");
|
||||
await client.query("DELETE FROM medication_intake_events");
|
||||
await client.query("DELETE FROM medication_schedules");
|
||||
await client.query("DELETE FROM reminder_phase_configs");
|
||||
await client.query("DELETE FROM medications");
|
||||
await client.query("DELETE FROM measurements");
|
||||
await client.query("DELETE FROM user_achievements");
|
||||
await client.query("DELETE FROM audit_logs");
|
||||
await client.query("DELETE FROM sessions");
|
||||
await client.query("DELETE FROM passkeys");
|
||||
await client.query("DELETE FROM auth_challenges");
|
||||
await client.query("DELETE FROM notification_channels");
|
||||
await client.query("DELETE FROM push_subscriptions");
|
||||
await client.query("DELETE FROM api_tokens");
|
||||
await client.query("DELETE FROM data_backups");
|
||||
await client.query("DELETE FROM users");
|
||||
|
||||
// ── User ──────────────────────────────────
|
||||
console.log("Creating demo user...");
|
||||
const userId = cuid();
|
||||
// argon2id hash of "demo123demo123" — pre-computed
|
||||
// We'll use a bcrypt-compatible approach: register via API later
|
||||
// For now, insert with a placeholder and we'll set it via the app
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
|
||||
[userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en"]
|
||||
);
|
||||
|
||||
// ── Measurements (90 days) ────────────────
|
||||
console.log("Creating 90 days of measurements...");
|
||||
const days = 90;
|
||||
|
||||
// Weight: 86.5 → trending down to ~82.5
|
||||
const weights = randomWalk(86.5, 82.0, days, 0.6);
|
||||
// Systolic BP: 132 → improving to ~124
|
||||
const sysBP = randomWalk(132, 124, days, 4);
|
||||
// Diastolic BP: 85 → improving to ~78
|
||||
const diaBP = randomWalk(85, 78, days, 3);
|
||||
// Pulse: 72 → slight improvement to ~68
|
||||
const pulse = randomWalk(72, 68, days, 3);
|
||||
// Body fat: 24.5% → trending to ~22%
|
||||
const bodyFat = randomWalk(24.5, 22.0, days, 0.4);
|
||||
// Sleep: 6.5h → improving to ~7.5h
|
||||
const sleep = randomWalk(6.5, 7.5, days, 0.8);
|
||||
// Steps: 5000 → improving to ~8000
|
||||
const steps = randomWalk(5000, 8500, days, 1500);
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
|
||||
// Weight (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, weights[i], date]
|
||||
);
|
||||
|
||||
// Blood pressure (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(sysBP[i]), date]
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(diaBP[i]), date]
|
||||
);
|
||||
|
||||
// Pulse (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(pulse[i]), date]
|
||||
);
|
||||
|
||||
// Body fat (every 2-3 days)
|
||||
if (i % 2 === 0 || Math.random() > 0.5) {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, bodyFat[i], date]
|
||||
);
|
||||
}
|
||||
|
||||
// Sleep (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date]
|
||||
);
|
||||
|
||||
// Steps (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(steps[i]), date]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Medications ───────────────────────────
|
||||
console.log("Creating medications...");
|
||||
|
||||
// Medication 1: Ramipril (blood pressure)
|
||||
const med1Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`,
|
||||
[med1Id, userId, daysAgo(120)]
|
||||
);
|
||||
const sched1Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
|
||||
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
|
||||
[sched1Id, med1Id]
|
||||
);
|
||||
|
||||
// Medication 2: Vitamin D3
|
||||
const med2Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
|
||||
[med2Id, userId, daysAgo(90)]
|
||||
);
|
||||
const sched2Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
|
||||
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
|
||||
[sched2Id, med2Id]
|
||||
);
|
||||
|
||||
// Medication 3: Magnesium (evening)
|
||||
const med3Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`,
|
||||
[med3Id, userId, daysAgo(60)]
|
||||
);
|
||||
const sched3Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
|
||||
VALUES ($1, $2, '20:00', '22:00', 'Evening')`,
|
||||
[sched3Id, med3Id]
|
||||
);
|
||||
|
||||
// ── Medication Intake Events ──────────────
|
||||
console.log("Creating intake events (90 days)...");
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
|
||||
// Ramipril: 96% compliance (miss ~1 every 25 days)
|
||||
const takeMed1 = Math.random() > 0.04;
|
||||
if (takeMed1) {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
[cuid(), userId, med1Id, date, takenTime]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
[cuid(), userId, med1Id, date]
|
||||
);
|
||||
}
|
||||
|
||||
// Vitamin D3: 92% compliance
|
||||
const takeMed2 = Math.random() > 0.08;
|
||||
if (takeMed2) {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
[cuid(), userId, med2Id, date, takenTime]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
[cuid(), userId, med2Id, date]
|
||||
);
|
||||
}
|
||||
|
||||
// Magnesium: only last 60 days, 88% compliance
|
||||
if (i >= 30) {
|
||||
const takeMed3 = Math.random() > 0.12;
|
||||
if (takeMed3) {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
[cuid(), userId, med3Id, date, takenTime]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
[cuid(), userId, med3Id, date]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mood Entries ─────────────────────────
|
||||
console.log("Creating mood entries (90 days)...");
|
||||
|
||||
const moodOptions = ["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"];
|
||||
const tagOptions = [
|
||||
["productive", "exercise", "well-rested"],
|
||||
["focused", "social", "creative"],
|
||||
["tired", "stressed"],
|
||||
["anxious", "low-energy"],
|
||||
["exhausted", "pain"],
|
||||
];
|
||||
|
||||
// Mood trend: starts around 3, improves to ~4 over time
|
||||
const moodTrend = randomWalk(3.0, 4.2, days, 0.8);
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
// Skip ~5% of days (forgot to log)
|
||||
if (Math.random() < 0.05) continue;
|
||||
|
||||
const date = daysAgo(days - i);
|
||||
const rawScore = Math.round(Math.min(5, Math.max(1, moodTrend[i])));
|
||||
const mood = moodOptions[5 - rawScore]; // invert: score 5 = SUPER_GUT
|
||||
const tags = tagOptions[5 - rawScore];
|
||||
const loggedAt = new Date(date);
|
||||
loggedAt.setHours(21, Math.floor(Math.random() * 59), 0);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`,
|
||||
[cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Achievements ─────────────────────────
|
||||
console.log("Creating achievements...");
|
||||
|
||||
const achievements = [
|
||||
{ id: "first-measurement", daysAgo: 89 },
|
||||
{ id: "first-medication", daysAgo: 89 },
|
||||
{ id: "first-mood", daysAgo: 89 },
|
||||
{ id: "week-streak-measurements", daysAgo: 82 },
|
||||
{ id: "week-streak-mood", daysAgo: 82 },
|
||||
{ id: "month-streak-measurements", daysAgo: 59 },
|
||||
{ id: "compliance-90", daysAgo: 45 },
|
||||
{ id: "weight-loss-5", daysAgo: 30 },
|
||||
{ id: "bp-normal", daysAgo: 20 },
|
||||
{ id: "steps-10k", daysAgo: 15 },
|
||||
{ id: "month-streak-mood", daysAgo: 10 },
|
||||
{ id: "three-month-streak", daysAgo: 2 },
|
||||
];
|
||||
|
||||
for (const ach of achievements) {
|
||||
const unlocked = daysAgo(ach.daysAgo);
|
||||
await client.query(
|
||||
`INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $4)`,
|
||||
[cuid(), userId, ach.id, unlocked]
|
||||
);
|
||||
}
|
||||
|
||||
// ── App Settings ─────────────────────────
|
||||
console.log("Creating app settings...");
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale)
|
||||
VALUES ('singleton', false, 'en')
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`
|
||||
);
|
||||
|
||||
// ── Audit Log (some login entries) ──────
|
||||
console.log("Creating audit log entries...");
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const loginDate = daysAgo(Math.floor(Math.random() * 30));
|
||||
await client.query(
|
||||
`INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at)
|
||||
VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`,
|
||||
[cuid(), userId, loginDate]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log("\nDemo data seeded successfully!");
|
||||
console.log(` User: demo / demo123demo123`);
|
||||
console.log(` Measurements: ${days * 6} entries (7 types, 90 days)`);
|
||||
console.log(` Medications: 3 (with ${days * 2 + 60} intake events)`);
|
||||
console.log(` Mood: ~${Math.round(days * 0.95)} entries`);
|
||||
console.log(` Achievements: ${achievements.length}`);
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("Seed failed:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Test script for notification delivery and reminder check.
|
||||
*
|
||||
* Usage (inside Docker container):
|
||||
* npx tsx scripts/test-notifications.ts
|
||||
*
|
||||
* Or from host:
|
||||
* docker compose exec app npx tsx scripts/test-notifications.ts
|
||||
*
|
||||
* What it does:
|
||||
* 1. Lists all configured notification channels
|
||||
* 2. Sends a test notification through each enabled channel
|
||||
* 3. Runs the reminder check logic once with verbose logging
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL!;
|
||||
|
||||
function createPrisma() {
|
||||
const adapter = new PrismaPg({ connectionString: DATABASE_URL });
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone
|
||||
|
||||
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
|
||||
async function main() {
|
||||
const prisma = createPrisma();
|
||||
|
||||
try {
|
||||
console.log("=== HealthLog Notification Test ===\n");
|
||||
|
||||
// ── Step 1: List channels ──
|
||||
console.log("── 1. Notification Channels ──\n");
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
});
|
||||
|
||||
if (channels.length === 0) {
|
||||
console.log("❌ Keine Notification-Channels konfiguriert!");
|
||||
console.log(
|
||||
" → Gehe zu Einstellungen > Benachrichtigungen und richte einen Kanal ein.\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ch of channels) {
|
||||
const status = ch.enabled ? "✅ aktiv" : "⏸️ deaktiviert";
|
||||
let configInfo = "";
|
||||
try {
|
||||
const config = JSON.parse(decrypt(ch.config));
|
||||
if (ch.type === "TELEGRAM") {
|
||||
configInfo = `Chat-ID: ${config.chatId}`;
|
||||
} else if (ch.type === "NTFY") {
|
||||
configInfo = `Topic: ${config.topic}`;
|
||||
} else {
|
||||
configInfo = "Web Push";
|
||||
}
|
||||
} catch {
|
||||
configInfo = "(Config nicht lesbar)";
|
||||
}
|
||||
console.log(
|
||||
` ${ch.type} [${status}] — User: ${ch.user.username} — ${configInfo}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: Send test notification ──
|
||||
console.log("\n── 2. Test-Notification senden ──\n");
|
||||
const enabledChannels = channels.filter((ch) => ch.enabled);
|
||||
|
||||
if (enabledChannels.length === 0) {
|
||||
console.log("❌ Keine aktivierten Channels — überspringe Test.\n");
|
||||
} else {
|
||||
const userIds = [...new Set(enabledChannels.map((ch) => ch.userId))];
|
||||
for (const userId of userIds) {
|
||||
console.log(` Sende Test-Notification an User ${userId}...`);
|
||||
try {
|
||||
await dispatchNotification({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId,
|
||||
title: "🔔 Test-Notification",
|
||||
message:
|
||||
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
|
||||
});
|
||||
console.log(" ✅ Dispatched (best-effort, check deine Kanäle)\n");
|
||||
} catch (err) {
|
||||
console.error(" ❌ Fehler:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Reminder check dry-run ──
|
||||
console.log("── 3. Reminder-Check (Dry-Run) ──\n");
|
||||
|
||||
const now = new Date();
|
||||
console.log(` Aktuelle Zeit (UTC): ${now.toISOString()}`);
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { reminderMissedMinutes: true },
|
||||
});
|
||||
const missedMinutes = appSettings?.reminderMissedMinutes ?? 240;
|
||||
console.log(` Verpasst-Schwellenwert: ${missedMinutes} Minuten\n`);
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { active: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
user: { select: { id: true, username: true, timezone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (medications.length === 0) {
|
||||
console.log(" Keine aktiven Medikamente gefunden.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const userTz = med.user.timezone || "Europe/Berlin";
|
||||
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const todayDow = getDayOfWeekInTz(now, userTz);
|
||||
|
||||
console.log(
|
||||
` 📋 ${med.name} (${med.dose}) — User: ${med.user.username}`,
|
||||
);
|
||||
console.log(
|
||||
` Timezone: ${userTz}, Lokale Zeit: ${currentTime}, Tag: ${dayLabels[todayDow]}`,
|
||||
);
|
||||
console.log(
|
||||
` Notifications: ${med.notificationsEnabled ? "✅ an" : "❌ aus"}`,
|
||||
);
|
||||
console.log(` Schedules: ${med.schedules.length}`);
|
||||
|
||||
for (const schedule of med.schedules) {
|
||||
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
|
||||
const endMins = parseTimeToMinutes(schedule.windowEnd);
|
||||
const currentMins = parseTimeToMinutes(currentTime);
|
||||
const minutesPastEnd = currentMins - endMins;
|
||||
|
||||
const dayMatch =
|
||||
recurrence.daysOfWeek.length === 0 ||
|
||||
recurrence.daysOfWeek.includes(todayDow);
|
||||
const windowPassed = currentMins > endMins;
|
||||
const thresholdPassed = minutesPastEnd > missedMinutes;
|
||||
|
||||
let status: string;
|
||||
if (!dayMatch) {
|
||||
status = "⏭️ Heute kein geplanter Tag";
|
||||
} else if (!windowPassed) {
|
||||
status = `🟢 Fenster noch offen (endet um ${schedule.windowEnd})`;
|
||||
} else if (!thresholdPassed) {
|
||||
status = `🟡 Fenster vorbei seit ${minutesPastEnd} Min (Threshold: ${missedMinutes} Min)`;
|
||||
} else {
|
||||
status = `🔴 Missed-Threshold erreicht (${minutesPastEnd} Min > ${missedMinutes} Min)`;
|
||||
}
|
||||
|
||||
const daysInfo =
|
||||
recurrence.daysOfWeek.length > 0
|
||||
? ` [${recurrence.daysOfWeek.map((d) => dayLabels[d]).join(", ")}]`
|
||||
: " [Täglich]";
|
||||
|
||||
console.log(
|
||||
` ${schedule.windowStart}–${schedule.windowEnd}${daysInfo}: ${status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Count existing events today
|
||||
const eventCount = await prisma.medicationIntakeEvent.count({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId: med.user.id,
|
||||
scheduledFor: { gte: todayStart, lte: todayEnd },
|
||||
},
|
||||
});
|
||||
console.log(` Heutige Events: ${eventCount}\n`);
|
||||
}
|
||||
|
||||
console.log("=== Test abgeschlossen ===");
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Test failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
CalendarCheck,
|
||||
Fingerprint,
|
||||
Flame,
|
||||
Heart,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Pill,
|
||||
ShieldCheck,
|
||||
SkipForward,
|
||||
Star,
|
||||
Target,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type {
|
||||
AchievementMetrics,
|
||||
AchievementProgress,
|
||||
AchievementSummary,
|
||||
} from "@/lib/gamification/achievements";
|
||||
|
||||
interface AchievementsData {
|
||||
summary: AchievementSummary;
|
||||
achievements: AchievementProgress[];
|
||||
metrics: AchievementMetrics;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
Flame,
|
||||
CalendarCheck,
|
||||
Pill,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
Trophy,
|
||||
Heart,
|
||||
KeyRound,
|
||||
Fingerprint,
|
||||
LogIn,
|
||||
Bug,
|
||||
AlertTriangle,
|
||||
SkipForward,
|
||||
};
|
||||
|
||||
function formatMetric(
|
||||
format: AchievementProgress["format"],
|
||||
value: number,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
) {
|
||||
if (format === "percent")
|
||||
return t("achievements.metricPercent", { count: value });
|
||||
if (format === "days") return t("achievements.metricDays", { count: value });
|
||||
return t("achievements.metricCount", { count: value });
|
||||
}
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["gamification", "achievements"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/gamification/achievements");
|
||||
if (!res.ok) throw new Error("Failed to load achievements");
|
||||
const json = await res.json();
|
||||
return json.data as AchievementsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("achievements.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.loginRequired")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const achievements = data?.achievements ?? [];
|
||||
const unlockedAchievements = achievements.filter((item) => item.unlocked);
|
||||
const remainingUnlocks = Math.max(
|
||||
0,
|
||||
(summary?.totalCount ?? 0) - (summary?.unlockedCount ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("achievements.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.points")}
|
||||
</p>
|
||||
<p className="text-4xl leading-none font-bold md:text-5xl">
|
||||
{summary?.earnedPoints ?? 0}
|
||||
<span className="text-muted-foreground ml-2 text-base font-medium">
|
||||
/ {summary?.totalPoints ?? 0}
|
||||
</span>
|
||||
</p>
|
||||
<Progress
|
||||
value={
|
||||
summary?.totalPoints
|
||||
? Math.round((summary.earnedPoints / summary.totalPoints) * 100)
|
||||
: 0
|
||||
}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.unlocked")}
|
||||
</p>
|
||||
<p className="text-4xl leading-none font-bold md:text-5xl">
|
||||
{summary?.unlockedCount ?? 0}
|
||||
<span className="text-muted-foreground ml-2 text-base font-medium">
|
||||
/ {summary?.totalCount ?? 0}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border min-h-34 rounded-xl border p-5 sm:col-span-2 lg:col-span-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.nextGoal")}
|
||||
</p>
|
||||
{summary?.nextAchievement ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-base font-semibold">
|
||||
{t(summary.nextAchievement.titleKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{t(summary.nextAchievement.descriptionKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t("achievements.nextProgressLabel")}:{" "}
|
||||
{formatMetric(
|
||||
summary.nextAchievement.format,
|
||||
summary.nextAchievement.current,
|
||||
t,
|
||||
)}{" "}
|
||||
/{" "}
|
||||
{formatMetric(
|
||||
summary.nextAchievement.format,
|
||||
summary.nextAchievement.target,
|
||||
t,
|
||||
)}{" "}
|
||||
({summary.nextAchievement.progressPercent}%)
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("achievements.pointsValue", {
|
||||
points: summary.nextAchievement.points,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
{t("achievements.allCompleted")}
|
||||
</p>
|
||||
)}
|
||||
<Progress
|
||||
value={summary?.nextAchievement?.progressPercent ?? 100}
|
||||
className="mt-3 h-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{unlockedAchievements.map((achievement) => {
|
||||
const Icon = iconMap[achievement.icon] ?? Star;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/15 text-primary rounded-lg p-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t(achievement.titleKey)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{t(achievement.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{t("achievements.completed")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
<span className="font-medium">
|
||||
{t("achievements.pointsValue", {
|
||||
points: achievement.points,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{achievement.completedAt
|
||||
? t("achievements.completedOn", {
|
||||
date: formatDate(achievement.completedAt),
|
||||
})
|
||||
: t("achievements.goalReached")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{unlockedAchievements.length === 0 && (
|
||||
<div className="bg-card border-border rounded-xl border p-6 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.noneUnlockedYet")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const paginationSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
filter: z.enum(["auth", "all"]).default("auth"),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Admin endpoint: list all auth-related audit log entries across all users.
|
||||
* Supports filtering by action prefix and pagination.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.audit-log.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const { limit, offset, filter } = paginationSchema.parse({
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
offset: searchParams.get("offset") ?? undefined,
|
||||
filter: searchParams.get("filter") ?? undefined,
|
||||
});
|
||||
|
||||
const where =
|
||||
filter === "auth" ? { action: { startsWith: "auth." } } : undefined;
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
ipAddress: true,
|
||||
location: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, username: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ entries, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Admin-only global wipe of all health/integration data.
|
||||
* Keeps users/passkeys so access to the app remains possible.
|
||||
*/
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`admin-data-delete:${ip}`, 5, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.data.delete" } });
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
const body = await request.json();
|
||||
confirm = typeof body?.confirm === "string" ? body.confirm : "";
|
||||
} catch {
|
||||
return apiError("Ungültige Anfrage", 422);
|
||||
}
|
||||
|
||||
if (confirm !== "DELETE ALL") {
|
||||
return apiError("Bestätigung fehlt", 422);
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const measurements = await tx.measurement.deleteMany({});
|
||||
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
|
||||
const medications = await tx.medication.deleteMany({});
|
||||
const apiTokens = await tx.apiToken.deleteMany({});
|
||||
const withingsConnections = await tx.withingsConnection.deleteMany({});
|
||||
const auditLogs = await tx.auditLog.deleteMany({});
|
||||
const authChallenges = await tx.authChallenge.deleteMany({});
|
||||
|
||||
await tx.user.updateMany({
|
||||
data: {
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
openaiKeyEncrypted: null,
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
telegramBotToken: null,
|
||||
telegramChatId: null,
|
||||
telegramEnabled: false,
|
||||
withingsClientIdEncrypted: null,
|
||||
withingsClientSecretEncrypted: null,
|
||||
onboardingCompletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
measurements: measurements.count,
|
||||
intakeEvents: intakeEvents.count,
|
||||
medications: medications.count,
|
||||
apiTokens: apiTokens.count,
|
||||
withingsConnections: withingsConnections.count,
|
||||
auditLogs: auditLogs.count,
|
||||
authChallenges: authChallenges.count,
|
||||
};
|
||||
});
|
||||
|
||||
await auditLog("admin.data.clear", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: result,
|
||||
});
|
||||
|
||||
return apiSuccess({ cleared: true, ...result });
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.monitoring.glitchtip-test" } });
|
||||
|
||||
const settings = await getGlitchtipSettings();
|
||||
if (!settings.glitchtipEnabled) {
|
||||
return apiError("Glitchtip ist deaktiviert", 422);
|
||||
}
|
||||
if (!settings.glitchtipDsn) {
|
||||
return apiError("Glitchtip DSN fehlt", 422);
|
||||
}
|
||||
|
||||
const delivery = await sendGlitchtipEvent({
|
||||
dsn: settings.glitchtipDsn,
|
||||
input: {
|
||||
environment: settings.glitchtipEnvironment || "production",
|
||||
message: "HealthLog Monitoring Test",
|
||||
level: "error",
|
||||
type: "HealthLogMonitoringTest",
|
||||
sourceTag: "healthlog-admin-test",
|
||||
},
|
||||
});
|
||||
|
||||
if (!delivery.ok) {
|
||||
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
|
||||
return apiError(
|
||||
`Glitchtip-Testevent wurde abgelehnt (HTTP ${delivery.status ?? 502})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
sent: true,
|
||||
message: "Glitchtip-Testevent wurde gesendet",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
|
||||
if (!scriptUrl) return [];
|
||||
try {
|
||||
const parsed = new URL(scriptUrl);
|
||||
const origin = parsed.origin;
|
||||
const pathSegments = parsed.pathname.split("/").filter(Boolean);
|
||||
const segments = [...pathSegments];
|
||||
|
||||
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
|
||||
segments.pop();
|
||||
}
|
||||
|
||||
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
|
||||
const candidates = [
|
||||
`${origin}${prefix}/api/send`,
|
||||
`${origin}/api/send`,
|
||||
`${origin}/umami/api/send`,
|
||||
];
|
||||
return Array.from(new Set(candidates));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAppUrl(request: NextRequest): URL {
|
||||
const configured =
|
||||
process.env.APP_URL ??
|
||||
process.env.NEXT_PUBLIC_APP_URL ??
|
||||
`https://${request.headers.get("host") ?? "localhost:3000"}`;
|
||||
|
||||
try {
|
||||
return new URL(configured);
|
||||
} catch {
|
||||
return new URL(
|
||||
`https://${request.headers.get("host") ?? "localhost:3000"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.monitoring.umami-test" } });
|
||||
|
||||
const settings = await getPublicMonitoringSettings();
|
||||
if (!settings.umamiEnabled) {
|
||||
return apiError("Umami ist deaktiviert", 422);
|
||||
}
|
||||
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
|
||||
return apiError(
|
||||
"Umami Script-URL und Website-ID müssen konfiguriert sein",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
|
||||
if (targetUrls.length === 0) {
|
||||
return apiError("Umami Script-URL ist ungültig", 422);
|
||||
}
|
||||
|
||||
const appUrl = resolveAppUrl(request);
|
||||
const payload = {
|
||||
type: "event",
|
||||
payload: {
|
||||
website: settings.umamiWebsiteId,
|
||||
hostname: appUrl.hostname,
|
||||
screen: "1920x1080",
|
||||
language: "de-DE",
|
||||
title: "HealthLog Monitoring Test",
|
||||
url: "/admin",
|
||||
referrer: "",
|
||||
name: "healthlog_monitoring_test",
|
||||
data: {
|
||||
source: "admin",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let lastStatus = 404;
|
||||
let lastDetails = "";
|
||||
|
||||
for (const targetUrl of targetUrls) {
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "healthlog-admin-monitoring-test",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
sent: true,
|
||||
message: "Umami-Testevent wurde gesendet",
|
||||
});
|
||||
}
|
||||
|
||||
lastStatus = upstream.status;
|
||||
lastDetails = await upstream.text().catch(() => "");
|
||||
|
||||
// Try next candidate only for path misses.
|
||||
if (upstream.status === 404) {
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
|
||||
return apiError(
|
||||
`Umami-Testevent wurde abgelehnt (HTTP ${lastStatus})`,
|
||||
502,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
|
||||
interface ScheduleStatus {
|
||||
window: string;
|
||||
days: string;
|
||||
status: "open" | "late" | "threshold" | "missed" | "skipped";
|
||||
label: string;
|
||||
minutesPastEnd: number | null;
|
||||
notificationSent: boolean;
|
||||
}
|
||||
|
||||
interface MedicationResult {
|
||||
name: string;
|
||||
dose: string;
|
||||
user: string;
|
||||
timezone: string;
|
||||
localTime: string;
|
||||
dayOfWeek: string;
|
||||
notificationsEnabled: boolean;
|
||||
schedules: ScheduleStatus[];
|
||||
eventsToday: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: Execute the reminder check — analyzes all medications AND sends
|
||||
* notifications for overdue schedules (late + missed). Returns detailed
|
||||
* results for display in the admin panel.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.notifications.reminder-check" } });
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { reminderMissedMinutes: true },
|
||||
});
|
||||
const missedMinutes = appSettings?.reminderMissedMinutes ?? 240;
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { active: true },
|
||||
include: {
|
||||
schedules: true,
|
||||
user: { select: { id: true, username: true, timezone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const results: MedicationResult[] = [];
|
||||
let notificationsSent = 0;
|
||||
|
||||
for (const med of medications) {
|
||||
const userTz = med.user.timezone || "Europe/Berlin";
|
||||
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const todayDow = getDayOfWeekInTz(now, userTz);
|
||||
|
||||
const eventCount = await prisma.medicationIntakeEvent.count({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId: med.user.id,
|
||||
scheduledFor: { gte: todayStart, lte: todayEnd },
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleStatuses: ScheduleStatus[] = [];
|
||||
let schedulesProcessed = 0;
|
||||
|
||||
const sortedSchedules = [...med.schedules].sort((a, b) =>
|
||||
a.windowStart.localeCompare(b.windowStart),
|
||||
);
|
||||
|
||||
for (const schedule of sortedSchedules) {
|
||||
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
|
||||
const endMins = parseTimeToMinutes(schedule.windowEnd);
|
||||
const currentMins = parseTimeToMinutes(currentTime);
|
||||
const minutesPastEnd = currentMins - endMins;
|
||||
|
||||
const dayMatch =
|
||||
recurrence.daysOfWeek.length === 0 ||
|
||||
recurrence.daysOfWeek.includes(todayDow);
|
||||
|
||||
const daysInfo =
|
||||
recurrence.daysOfWeek.length > 0
|
||||
? recurrence.daysOfWeek.map((d) => dayLabels[d]).join(", ")
|
||||
: "Täglich";
|
||||
|
||||
let status: ScheduleStatus["status"];
|
||||
let label: string;
|
||||
let notificationSent = false;
|
||||
|
||||
if (!dayMatch) {
|
||||
status = "skipped";
|
||||
label = "Heute kein geplanter Tag";
|
||||
} else if (currentMins <= endMins) {
|
||||
status = "open";
|
||||
label = `Fenster noch offen (endet um ${schedule.windowEnd})`;
|
||||
} else if (minutesPastEnd <= missedMinutes) {
|
||||
status = "threshold";
|
||||
label = `Fenster vorbei seit ${minutesPastEnd} Min (Threshold: ${missedMinutes} Min)`;
|
||||
} else {
|
||||
status = "missed";
|
||||
label = `Missed-Threshold erreicht (${minutesPastEnd} Min > ${missedMinutes} Min)`;
|
||||
}
|
||||
|
||||
// Send notification for overdue schedules that haven't been taken
|
||||
const isOverdue = dayMatch && minutesPastEnd > 0;
|
||||
const hasEvent = eventCount > schedulesProcessed;
|
||||
|
||||
if (isOverdue && !hasEvent && med.notificationsEnabled) {
|
||||
const doseInfo = schedule.dose ?? med.dose;
|
||||
const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`;
|
||||
|
||||
try {
|
||||
if (status === "missed") {
|
||||
await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: med.user.id,
|
||||
title: `Verpasst: ${med.name}`,
|
||||
message: `<b>${med.name}</b> (${doseInfo}, ${timeWindow}) wurde als verpasst markiert.`,
|
||||
metadata: { medicationId: med.id },
|
||||
});
|
||||
} else {
|
||||
await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: med.user.id,
|
||||
title: `Erinnerung: ${med.name}`,
|
||||
message: `Erinnerung: <b>${med.name}</b> (${doseInfo}, ${timeWindow}) wurde noch nicht eingenommen. Seit ${minutesPastEnd} Min überfällig.`,
|
||||
metadata: { medicationId: med.id },
|
||||
});
|
||||
}
|
||||
notificationSent = true;
|
||||
notificationsSent++;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning("Notification failed for " + med.name + ": " + err);
|
||||
}
|
||||
}
|
||||
|
||||
if (dayMatch && minutesPastEnd > 0) {
|
||||
schedulesProcessed++;
|
||||
}
|
||||
|
||||
scheduleStatuses.push({
|
||||
window: `${schedule.windowStart}–${schedule.windowEnd}`,
|
||||
days: daysInfo,
|
||||
status,
|
||||
label,
|
||||
minutesPastEnd:
|
||||
dayMatch && currentMins > endMins ? minutesPastEnd : null,
|
||||
notificationSent,
|
||||
});
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
user: med.user.username,
|
||||
timezone: userTz,
|
||||
localTime: currentTime,
|
||||
dayOfWeek: dayLabels[todayDow],
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
schedules: scheduleStatuses,
|
||||
eventsToday: eventCount,
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
timestamp: now.toISOString(),
|
||||
missedThresholdMinutes: missedMinutes,
|
||||
medications: results,
|
||||
notificationsSent,
|
||||
message:
|
||||
results.length > 0
|
||||
? `${results.length} Medikamente geprüft, ${notificationsSent} Erinnerungen gesendet`
|
||||
: "Keine aktiven Medikamente gefunden",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
|
||||
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
|
||||
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
|
||||
import type {
|
||||
TelegramChannelConfig,
|
||||
NtfyChannelConfig,
|
||||
ChannelType,
|
||||
NotificationPayload,
|
||||
} from "@/lib/notifications/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Ensure legacy Telegram config (stored on User model) has a matching
|
||||
* NotificationChannel record. Returns true if a channel was created.
|
||||
*/
|
||||
async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId, type: "TELEGRAM" } },
|
||||
});
|
||||
if (existing) return false;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
telegramBotToken: true,
|
||||
telegramChatId: true,
|
||||
telegramEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!user?.telegramEnabled ||
|
||||
!user.telegramBotToken ||
|
||||
!user.telegramChatId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const botToken = decrypt(user.telegramBotToken);
|
||||
const config = encrypt(
|
||||
JSON.stringify({ botToken, chatId: user.telegramChatId }),
|
||||
);
|
||||
|
||||
await prisma.notificationChannel.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "TELEGRAM",
|
||||
enabled: true,
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.notifications.test" } });
|
||||
|
||||
// Auto-migrate legacy Telegram config before querying channels
|
||||
const migrated = await ensureTelegramChannel(user.id);
|
||||
if (migrated) {
|
||||
getEvent()?.addMeta("telegram_migrated", true);
|
||||
}
|
||||
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
where: { userId: user.id, enabled: true },
|
||||
include: {
|
||||
preferences: { where: { eventType: "SYSTEM_ALERT" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (channels.length === 0) {
|
||||
return apiSuccess({
|
||||
sent: false,
|
||||
message: "Keine aktivierten Benachrichtigungskanäle gefunden",
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: user.id,
|
||||
title: "Test-Notification",
|
||||
message:
|
||||
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const pref = channel.preferences[0];
|
||||
if (pref && !pref.enabled) {
|
||||
results.push({
|
||||
channel: channel.type,
|
||||
success: false,
|
||||
error: "SYSTEM_ALERT deaktiviert in Einstellungen",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const type = channel.type as ChannelType;
|
||||
let success = false;
|
||||
|
||||
switch (type) {
|
||||
case "TELEGRAM": {
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as TelegramChannelConfig;
|
||||
const telegramResult = await sendViaTelegram(config, payload);
|
||||
success = telegramResult.ok;
|
||||
if (!success) {
|
||||
results.push({
|
||||
channel: "TELEGRAM",
|
||||
success: false,
|
||||
error: `Telegram API hat Fehler gemeldet (chatId: ${config.chatId})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "NTFY": {
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as NtfyChannelConfig;
|
||||
success = await sendViaNtfy(config, payload);
|
||||
if (!success) {
|
||||
results.push({
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
error: `Senden fehlgeschlagen (topic: ${config.topic})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "WEB_PUSH": {
|
||||
success = await sendViaWebPush(user.id, payload);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
results.push({
|
||||
channel: type,
|
||||
success: false,
|
||||
error: "Unbekannter Kanaltyp",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({ channel: type, success });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
channel: channel.type,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Unbekannter Fehler",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failCount = results.filter((r) => !r.success).length;
|
||||
|
||||
let message: string;
|
||||
if (failCount === 0) {
|
||||
message = `Alle ${successCount} Kanäle erfolgreich`;
|
||||
} else if (successCount === 0) {
|
||||
message = `Alle ${failCount} Kanäle fehlgeschlagen`;
|
||||
} else {
|
||||
message = `${successCount} erfolgreich, ${failCount} fehlgeschlagen`;
|
||||
}
|
||||
|
||||
return apiSuccess({ sent: successCount > 0, message, results });
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { adminSettingsSchema } from "@/lib/validations/admin";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.settings.get" } });
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||
defaultLocale: settings?.defaultLocale ?? "de",
|
||||
telegramGlobal: settings?.telegramGlobal ?? true,
|
||||
ntfyGlobal: settings?.ntfyGlobal ?? true,
|
||||
webPushGlobal: settings?.webPushGlobal ?? true,
|
||||
webPushVapidPublicKey: settings?.webPushVapidPublicKey ?? null,
|
||||
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
|
||||
webPushVapidConfigured: Boolean(
|
||||
settings?.webPushVapidPublicKey &&
|
||||
settings?.webPushVapidPrivateKeyEncrypted &&
|
||||
settings?.webPushVapidSubject,
|
||||
),
|
||||
apiGlobal: settings?.apiGlobal ?? true,
|
||||
umamiEnabled: settings?.umamiEnabled ?? false,
|
||||
umamiScriptUrl: settings?.umamiScriptUrl ?? null,
|
||||
umamiWebsiteId: settings?.umamiWebsiteId ?? null,
|
||||
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
|
||||
glitchtipDsn: settings?.glitchtipDsn ?? null,
|
||||
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings?.githubIssueRepo ?? null,
|
||||
bugReportConfigured: Boolean(
|
||||
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
|
||||
),
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
});
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.settings.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = adminSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const updates: Record<string, unknown> = {};
|
||||
const auditDetails: Record<string, unknown> = {};
|
||||
|
||||
// Boolean fields — direct mapping
|
||||
const booleanFields = [
|
||||
"registrationEnabled",
|
||||
"telegramGlobal",
|
||||
"ntfyGlobal",
|
||||
"webPushGlobal",
|
||||
"apiGlobal",
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
updates[field] = data[field];
|
||||
auditDetails[field] = data[field];
|
||||
}
|
||||
}
|
||||
|
||||
if (data.defaultLocale !== undefined) {
|
||||
updates.defaultLocale = data.defaultLocale;
|
||||
auditDetails.defaultLocale = data.defaultLocale;
|
||||
}
|
||||
|
||||
// String fields that map directly (with empty → null)
|
||||
if (data.webPushVapidPublicKey !== undefined) {
|
||||
const value = data.webPushVapidPublicKey.trim();
|
||||
updates.webPushVapidPublicKey = value || null;
|
||||
auditDetails.webPushVapidPublicKey = value ? "configured" : null;
|
||||
}
|
||||
|
||||
if (data.webPushVapidSubject !== undefined) {
|
||||
const value = data.webPushVapidSubject.trim();
|
||||
updates.webPushVapidSubject = value || null;
|
||||
auditDetails.webPushVapidSubject = value || null;
|
||||
}
|
||||
|
||||
// Encrypted fields
|
||||
if (data.webPushVapidPrivateKey !== undefined) {
|
||||
const value = data.webPushVapidPrivateKey.trim();
|
||||
if (value) {
|
||||
updates.webPushVapidPrivateKeyEncrypted = encrypt(value);
|
||||
auditDetails.webPushVapidPrivateKeyUpdated = true;
|
||||
}
|
||||
}
|
||||
if (data.clearWebPushVapidPrivateKey === true) {
|
||||
updates.webPushVapidPrivateKeyEncrypted = null;
|
||||
auditDetails.webPushVapidPrivateKeyUpdated = false;
|
||||
}
|
||||
|
||||
// URL fields with normalization
|
||||
if (data.umamiScriptUrl !== undefined) {
|
||||
const value = data.umamiScriptUrl.trim();
|
||||
if (!value) {
|
||||
updates.umamiScriptUrl = null;
|
||||
auditDetails.umamiScriptUrl = null;
|
||||
} else {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.pathname === "/" || parsed.pathname === "") {
|
||||
parsed.pathname = "/script.js";
|
||||
}
|
||||
updates.umamiScriptUrl = parsed.toString();
|
||||
auditDetails.umamiScriptUrl = parsed.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (data.umamiWebsiteId !== undefined) {
|
||||
const value = data.umamiWebsiteId.trim();
|
||||
updates.umamiWebsiteId = value || null;
|
||||
auditDetails.umamiWebsiteId = value || null;
|
||||
}
|
||||
|
||||
if (data.glitchtipDsn !== undefined) {
|
||||
const value = data.glitchtipDsn.trim();
|
||||
if (!value) {
|
||||
updates.glitchtipDsn = null;
|
||||
auditDetails.glitchtipDsn = null;
|
||||
} else {
|
||||
updates.glitchtipDsn = new URL(value).toString();
|
||||
auditDetails.glitchtipDsn = "configured";
|
||||
}
|
||||
}
|
||||
|
||||
if (data.glitchtipEnvironment !== undefined) {
|
||||
const value = data.glitchtipEnvironment.trim();
|
||||
updates.glitchtipEnvironment = value || null;
|
||||
auditDetails.glitchtipEnvironment = value || null;
|
||||
}
|
||||
|
||||
// Bug report
|
||||
if (data.bugReportRepo !== undefined) {
|
||||
const repo = data.bugReportRepo.trim();
|
||||
updates.githubIssueRepo = repo || null;
|
||||
auditDetails.bugReportRepo = repo || null;
|
||||
}
|
||||
if (data.bugReportToken !== undefined) {
|
||||
const token = data.bugReportToken.trim();
|
||||
if (token) {
|
||||
updates.githubIssueTokenEncrypted = encrypt(token);
|
||||
auditDetails.bugReportTokenUpdated = true;
|
||||
}
|
||||
}
|
||||
if (data.clearBugReportToken === true) {
|
||||
updates.githubIssueTokenEncrypted = null;
|
||||
auditDetails.bugReportTokenUpdated = false;
|
||||
}
|
||||
|
||||
// Numeric thresholds
|
||||
if (data.reminderLateMinutes !== undefined) {
|
||||
updates.reminderLateMinutes = data.reminderLateMinutes;
|
||||
auditDetails.reminderLateMinutes = data.reminderLateMinutes;
|
||||
}
|
||||
if (data.reminderMissedMinutes !== undefined) {
|
||||
updates.reminderMissedMinutes = data.reminderMissedMinutes;
|
||||
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("Keine gültigen Felder", 422);
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: updates,
|
||||
create: { id: "singleton", ...updates },
|
||||
});
|
||||
|
||||
await auditLog("admin.settings.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: auditDetails,
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings.registrationEnabled,
|
||||
defaultLocale: settings.defaultLocale,
|
||||
telegramGlobal: settings.telegramGlobal,
|
||||
ntfyGlobal: settings.ntfyGlobal,
|
||||
webPushGlobal: settings.webPushGlobal,
|
||||
webPushVapidPublicKey: settings.webPushVapidPublicKey,
|
||||
webPushVapidSubject: settings.webPushVapidSubject,
|
||||
webPushVapidConfigured: Boolean(
|
||||
settings.webPushVapidPublicKey &&
|
||||
settings.webPushVapidPrivateKeyEncrypted &&
|
||||
settings.webPushVapidSubject,
|
||||
),
|
||||
apiGlobal: settings.apiGlobal,
|
||||
umamiEnabled: settings.umamiEnabled,
|
||||
umamiScriptUrl: settings.umamiScriptUrl,
|
||||
umamiWebsiteId: settings.umamiWebsiteId,
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment:
|
||||
settings.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings.githubIssueRepo,
|
||||
bugReportConfigured: Boolean(
|
||||
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
|
||||
),
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
|
||||
const START_TIME = new Date().toISOString();
|
||||
|
||||
// Try to read git commit info
|
||||
let gitCommit = process.env.GIT_COMMIT ?? "unknown";
|
||||
try {
|
||||
const head = readFileSync(".git/HEAD", "utf-8").trim();
|
||||
if (head.startsWith("ref:")) {
|
||||
const ref = head.replace("ref: ", "");
|
||||
gitCommit = readFileSync(`.git/${ref}`, "utf-8").trim().slice(0, 8);
|
||||
} else {
|
||||
gitCommit = head.slice(0, 8);
|
||||
}
|
||||
} catch {
|
||||
// In Docker or production, .git may not exist
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status" } });
|
||||
|
||||
const [
|
||||
userCount,
|
||||
measurementCount,
|
||||
medicationCount,
|
||||
intakeEventCount,
|
||||
tokenCount,
|
||||
sessionCount,
|
||||
appSettings,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.measurement.count(),
|
||||
prisma.medication.count(),
|
||||
prisma.medicationIntakeEvent.count(),
|
||||
prisma.apiToken.count({ where: { revoked: false } }),
|
||||
prisma.session.count(),
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
]);
|
||||
|
||||
const umamiConfigured = Boolean(
|
||||
appSettings?.umamiScriptUrl && appSettings?.umamiWebsiteId,
|
||||
);
|
||||
const glitchtipConfigured = Boolean(appSettings?.glitchtipDsn);
|
||||
const webPushConfigured = Boolean(
|
||||
appSettings?.webPushVapidPublicKey &&
|
||||
appSettings?.webPushVapidPrivateKeyEncrypted &&
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const bugReportConfigured = Boolean(
|
||||
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
|
||||
);
|
||||
|
||||
const workerStatus = getWorkerStatus();
|
||||
|
||||
return apiSuccess({
|
||||
version: process.env.npm_package_version ?? "0.1.0",
|
||||
nodeVersion: process.version,
|
||||
gitCommit,
|
||||
buildTime: BUILD_TIME,
|
||||
startTime: START_TIME,
|
||||
database: "connected",
|
||||
worker: workerStatus,
|
||||
counts: {
|
||||
users: userCount,
|
||||
measurements: measurementCount,
|
||||
medications: medicationCount,
|
||||
intakeEvents: intakeEventCount,
|
||||
activeTokens: tokenCount,
|
||||
activeSessions: sessionCount,
|
||||
},
|
||||
integrations: {
|
||||
umami: umamiConfigured
|
||||
? { configured: true, enabled: appSettings?.umamiEnabled ?? false }
|
||||
: null,
|
||||
glitchtip: glitchtipConfigured
|
||||
? {
|
||||
configured: true,
|
||||
enabled: appSettings?.glitchtipEnabled ?? false,
|
||||
}
|
||||
: null,
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.tokens.list" } });
|
||||
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
permissions: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revoked: true,
|
||||
user: {
|
||||
select: { id: true, username: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return apiSuccess(tokens);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const { password } = body as { password?: string };
|
||||
|
||||
if (!password || typeof password !== "string") {
|
||||
return apiError("Passwort erforderlich", 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("Benutzer nicht gefunden", 404);
|
||||
|
||||
const strength = checkPasswordStrength(password, [target.username]);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
// Invalidate all sessions of the target user
|
||||
await destroyAllSessions(id);
|
||||
|
||||
await auditLog("admin.user.reset-password", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ success: true });
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
username: z.string().min(3).max(30).optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
role: z.enum(["ADMIN", "USER"]).optional(),
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("Benutzer nicht gefunden", 404);
|
||||
|
||||
// Prevent removing the last admin
|
||||
if (parsed.data.role === "USER" && target.role === "ADMIN") {
|
||||
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
|
||||
if (adminCount <= 1) {
|
||||
return apiError("Der letzte Admin kann nicht degradiert werden", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.username !== undefined && {
|
||||
username: parsed.data.username,
|
||||
}),
|
||||
...(parsed.data.email !== undefined && { email: parsed.data.email }),
|
||||
...(parsed.data.role !== undefined && { role: parsed.data.role }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.user.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id, changes: parsed.data },
|
||||
});
|
||||
|
||||
return apiSuccess(updatedUser);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.users.list" } });
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
createdAt: u.createdAt,
|
||||
passkeyCount: u._count.passkeys,
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
const types: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
|
||||
const measurementsByType = await Promise.all(
|
||||
types.map((type) =>
|
||||
prisma.measurement
|
||||
.findMany({
|
||||
where: { userId: user.id, type },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true },
|
||||
})
|
||||
.then((measurements) => ({
|
||||
type,
|
||||
summary: summarize(
|
||||
measurements.map(
|
||||
(m): DataPoint => ({
|
||||
date: m.measuredAt,
|
||||
value: m.value,
|
||||
}),
|
||||
),
|
||||
),
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
const results: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const { type, summary } of measurementsByType) {
|
||||
results[type] = summary;
|
||||
}
|
||||
|
||||
// BMI calculation
|
||||
let bmi: number | null = null;
|
||||
if (user.heightCm && results.WEIGHT?.latest) {
|
||||
const heightM = user.heightCm / 100;
|
||||
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
// BP in-target percentage (auto-calculated from date of birth)
|
||||
let bpInTargetPct: number | null = null;
|
||||
const bpTargets = getBpTargets(user.dateOfBirth);
|
||||
if (bpTargets) {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const [sysData, diaData] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (sysData.length > 0 && diaData.length > 0) {
|
||||
let inTarget = 0;
|
||||
for (const sys of sysData) {
|
||||
const closestDia = diaData.reduce((closest, dia) =>
|
||||
Math.abs(dia.measuredAt.getTime() - sys.measuredAt.getTime()) <
|
||||
Math.abs(closest.measuredAt.getTime() - sys.measuredAt.getTime())
|
||||
? dia
|
||||
: closest,
|
||||
);
|
||||
const timeDiff = Math.abs(
|
||||
closestDia.measuredAt.getTime() - sys.measuredAt.getTime(),
|
||||
);
|
||||
if (timeDiff < 5 * 60 * 1000) {
|
||||
if (
|
||||
sys.value >= bpTargets.sysLow &&
|
||||
sys.value <= bpTargets.sysHigh &&
|
||||
closestDia.value >= bpTargets.diaLow &&
|
||||
closestDia.value <= bpTargets.diaHigh
|
||||
) {
|
||||
inTarget++;
|
||||
}
|
||||
}
|
||||
}
|
||||
bpInTargetPct =
|
||||
sysData.length > 0
|
||||
? Math.round((inTarget / sysData.length) * 100)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Get audit log entries for the current user.
|
||||
* Query params: limit (default 50, max 200), offset (default 0)
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "audit-log.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
Math.max(1, parseInt(searchParams.get("limit") ?? "50", 10) || 50),
|
||||
200,
|
||||
);
|
||||
const offset = Math.max(
|
||||
0,
|
||||
parseInt(searchParams.get("offset") ?? "0", 10) || 0,
|
||||
);
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
action: true,
|
||||
ipAddress: true,
|
||||
location: true,
|
||||
details: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({
|
||||
where: { userId: user.id },
|
||||
}),
|
||||
]);
|
||||
|
||||
return apiSuccess({ entries, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:login:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Zu viele Anmeldeversuche. Bitte später erneut versuchen.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError("Ungültige Anmeldedaten", 422);
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const identifier = email.trim();
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { equals: identifier, mode: "insensitive" } },
|
||||
{ username: { equals: identifier, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
await auditLog("auth.login.failed", {
|
||||
ipAddress: ip,
|
||||
details: { identifier, reason: "user_not_found_or_no_password" },
|
||||
});
|
||||
return apiError("Ungültige Anmeldedaten", 401);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(user.passwordHash, password);
|
||||
if (!valid) {
|
||||
await auditLog("auth.login.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { reason: "invalid_password" },
|
||||
});
|
||||
return apiError("Ungültige Anmeldedaten", 401);
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, ip, ua);
|
||||
|
||||
await auditLog("auth.login.password", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { destroySession } from "@/lib/auth/session";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await destroySession();
|
||||
|
||||
annotate({ action: { name: "auth.logout" } });
|
||||
|
||||
return apiSuccess({ loggedOut: true });
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getGravatarUrl } from "@/lib/gravatar";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
annotate({ action: { name: "auth.me" } });
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role ?? "USER",
|
||||
heightCm: user.heightCm,
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender: user.gender,
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-login-options:${ip}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Zu viele Passkey-Anfragen. Bitte später erneut versuchen.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions();
|
||||
|
||||
annotate({ action: { name: "auth.passkey.login-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Zu viele Versuche. Bitte 15 Minuten warten.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const challengeId = body.challengeId as string | undefined;
|
||||
const credential = body.credential;
|
||||
|
||||
if (!challengeId || !credential) {
|
||||
return apiError("challengeId und credential erforderlich", 422);
|
||||
}
|
||||
|
||||
const { verification, passkey } = await verifyAuthentication(
|
||||
challengeId,
|
||||
credential,
|
||||
);
|
||||
|
||||
if (!verification.verified) {
|
||||
await auditLog("auth.login.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: "passkey_verification_failed" },
|
||||
});
|
||||
return apiError("Passkey-Verifizierung fehlgeschlagen", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: passkey.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiError("Benutzer nicht gefunden", 404);
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, ip, ua);
|
||||
|
||||
await auditLog("auth.login.passkey", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRegistrationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { options, challengeId } = await createRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.passkey.register-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyRegistration } from "@/lib/auth/passkey";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const challengeId = body.challengeId as string | undefined;
|
||||
const credential = body.credential as Record<string, unknown> | undefined;
|
||||
|
||||
if (!challengeId || !credential) {
|
||||
return apiError("challengeId und credential erforderlich", 422);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistration(challengeId, credential);
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return apiError("Passkey-Verifizierung fehlgeschlagen", 400);
|
||||
}
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
credentialId: registrationInfo.credential.id,
|
||||
credentialPublicKey: Buffer.from(registrationInfo.credential.publicKey),
|
||||
counter: BigInt(registrationInfo.credential.counter),
|
||||
credentialDeviceType: registrationInfo.credentialDeviceType,
|
||||
credentialBackedUp: registrationInfo.credentialBackedUp,
|
||||
transports: ((credential.response as Record<string, unknown> | undefined)?.transports as string[]) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("auth.passkey.register", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.register" } });
|
||||
|
||||
return apiSuccess({ verified: true });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const DELETE = apiHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!passkey || passkey.userId !== user.id) {
|
||||
return apiError("Passkey nicht gefunden", 404);
|
||||
}
|
||||
|
||||
// Check: at least 1 auth method must remain
|
||||
const passkeyCount = await prisma.passkey.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasPassword = !!user.passwordHash;
|
||||
|
||||
if (passkeyCount <= 1 && !hasPassword) {
|
||||
return apiError(
|
||||
"Kann nicht gelöscht werden — es muss mindestens eine Anmeldemethode bestehen bleiben",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.passkey.delete({ where: { id } });
|
||||
|
||||
await auditLog("auth.passkey.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { passkeyId: id, passkeyName: passkey.name },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.delete" } });
|
||||
|
||||
return apiSuccess({ success: true });
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const passkeys = await prisma.passkey.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkeys.list" } });
|
||||
|
||||
return apiSuccess(passkeys);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
verifyPassword,
|
||||
hashPassword,
|
||||
checkPasswordStrength,
|
||||
} from "@/lib/auth/password";
|
||||
import { changePasswordSchema } from "@/lib/validations/auth";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`auth:password:${user.id}`,
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Zu viele Versuche. Bitte 15 Minuten warten.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = changePasswordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
if (!user.passwordHash) {
|
||||
return apiError("Für dieses Konto ist kein Passwort gesetzt", 400);
|
||||
}
|
||||
|
||||
const currentValid = await verifyPassword(
|
||||
user.passwordHash,
|
||||
currentPassword,
|
||||
);
|
||||
if (!currentValid) {
|
||||
return apiError("Aktuelles Passwort ist falsch", 401);
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
return apiError(
|
||||
"Neues Passwort muss sich vom aktuellen unterscheiden",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const strength = checkPasswordStrength(newPassword, [
|
||||
user.username,
|
||||
user.email ?? "",
|
||||
]);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: newHash },
|
||||
});
|
||||
|
||||
// Invalidate all existing sessions and create a fresh one
|
||||
await destroyAllSessions(user.id);
|
||||
await createSession(
|
||||
user.id,
|
||||
getClientIp(request),
|
||||
request.headers.get("user-agent"),
|
||||
);
|
||||
|
||||
await auditLog("auth.password.change", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.password.change" } });
|
||||
|
||||
return apiSuccess({ changed: true });
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = profileSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== user.id) {
|
||||
return apiError("E-Mail-Adresse wird bereits verwendet", 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
...(data.email !== undefined ? { email: normalizedEmail } : {}),
|
||||
heightCm: data.heightCm ?? null,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
...(data.gender !== undefined ? { gender: data.gender } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("profile.update", {
|
||||
userId: updatedUser.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.profile.update" } });
|
||||
|
||||
return apiSuccess({
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
role: updatedUser.role,
|
||||
heightCm: updatedUser.heightCm,
|
||||
dateOfBirth: updatedUser.dateOfBirth,
|
||||
gender: updatedUser.gender,
|
||||
timezone: updatedUser.timezone,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:register:${ip}`, 5, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"Zu viele Registrierungsversuche. Bitte später erneut versuchen.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const userCount = await prisma.user.count();
|
||||
|
||||
// Check if registration is enabled.
|
||||
// Safety valve: when there are zero users, always allow bootstrap registration.
|
||||
let registrationEnabled = true;
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
if (settings && !settings.registrationEnabled && userCount > 0) {
|
||||
registrationEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist yet; allow registration
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
return apiError("Registrierung ist deaktiviert", 403);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { email, username, password } = parsed.data;
|
||||
|
||||
// Check if email or username already taken (unified message to prevent enumeration)
|
||||
const [existingEmail, existingUsername] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { email } }),
|
||||
prisma.user.findUnique({ where: { username } }),
|
||||
]);
|
||||
if (existingEmail || existingUsername) {
|
||||
return apiError("Benutzername oder E-Mail-Adresse bereits vergeben", 409);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const strength = checkPasswordStrength(password, [username, email]);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// First user becomes admin
|
||||
const role = userCount === 0 ? "ADMIN" : "USER";
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
// Create session immediately
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, ip, ua);
|
||||
|
||||
await auditLog("auth.register", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: "password" },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.register" } });
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
user: { id: user.id, username: user.username, email: user.email },
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.registration-status" } });
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||
});
|
||||
} catch {
|
||||
// Fail closed on backend errors.
|
||||
annotate({ action: { name: "auth.registration-status" } });
|
||||
return apiSuccess({ registrationEnabled: false });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Beschreibung zu kurz").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot zu groß (max. 5 MB)")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximal 3 Bugreports pro Stunde", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "bugreport.submit" } });
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
},
|
||||
});
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
return apiError(
|
||||
"Bugreport ist nicht konfiguriert (GitHub-Issue-Token/Repository in Admin-Einstellungen fehlt)",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bugReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
const dateStr = new Date().toLocaleString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Build issue body
|
||||
let issueBody = `**Gemeldet von:** ${safeUsername}\n`;
|
||||
issueBody += `**Datum:** ${dateStr}\n\n`;
|
||||
issueBody += `## Beschreibung\n\n${safeDescription}\n`;
|
||||
issueBody += `\n---\n*Erstellt über die HealthLog Bugreport-Funktion*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
|
||||
if (screenshot) {
|
||||
issueBody += `\n\n**Screenshot:** Ein Screenshot wurde angehängt (siehe Kommentar).`;
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
const issueRes = await fetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Bug] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["bug", "user-reported"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!issueRes.ok) {
|
||||
const errBody = await issueRes.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
|
||||
return apiError("Issue konnte nicht erstellt werden", 500);
|
||||
}
|
||||
|
||||
const issue = (await issueRes.json()) as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
// If screenshot, upload as a comment
|
||||
if (screenshot) {
|
||||
// Extract just the base64 part
|
||||
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
|
||||
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
|
||||
|
||||
// Upload screenshot as issue comment with embedded image
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Hinweis: Der vollständige Screenshot wurde aus Größengründen gekürzt. Bitte den Nutzer direkt kontaktieren.*`;
|
||||
|
||||
await fetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({ body: commentBody }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("bugreport.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
issueNumber: issue.number,
|
||||
hasScreenshot: Boolean(screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: { issue_number: issue.number } });
|
||||
|
||||
return apiSuccess({
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.html_url,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
* Returns aggregated health data for the specified time range.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.generate" } });
|
||||
|
||||
const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximal 10 Reports pro Stunde", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const rawDays = (body as Record<string, unknown>)?.days;
|
||||
const days =
|
||||
typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365
|
||||
? rawDays
|
||||
: 90;
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, scheduledFor: { gte: since } },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
username: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Group measurements by type
|
||||
const byType: Record<
|
||||
string,
|
||||
Array<{ value: number; measuredAt: string }>
|
||||
> = {};
|
||||
for (const m of measurements) {
|
||||
if (!byType[m.type]) byType[m.type] = [];
|
||||
byType[m.type].push({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate stats per type
|
||||
const stats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
> = {};
|
||||
for (const [type, entries] of Object.entries(byType)) {
|
||||
const values = entries.map((e) => e.value);
|
||||
stats[type] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
// Medication compliance
|
||||
const complianceByMed: Record<
|
||||
string,
|
||||
{ total: number; taken: number; skipped: number; missed: number }
|
||||
> = {};
|
||||
for (const event of intakeEvents) {
|
||||
const name = event.medication.name;
|
||||
if (!complianceByMed[name]) {
|
||||
complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
|
||||
}
|
||||
complianceByMed[name].total++;
|
||||
if (event.takenAt) {
|
||||
complianceByMed[name].taken++;
|
||||
} else if (event.skipped) {
|
||||
complianceByMed[name].skipped++;
|
||||
} else {
|
||||
complianceByMed[name].missed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Mood summary
|
||||
const moodScores = moodEntries.map((e) => e.score);
|
||||
const moodSummary =
|
||||
moodScores.length > 0
|
||||
? {
|
||||
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
|
||||
min: Math.min(...moodScores),
|
||||
max: Math.max(...moodScores),
|
||||
count: moodScores.length,
|
||||
distribution: {
|
||||
1: moodScores.filter((s) => s === 1).length,
|
||||
2: moodScores.filter((s) => s === 2).length,
|
||||
3: moodScores.filter((s) => s === 3).length,
|
||||
4: moodScores.filter((s) => s === 4).length,
|
||||
5: moodScores.filter((s) => s === 5).length,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
// BMI
|
||||
const weightStats = stats.WEIGHT;
|
||||
const bmi =
|
||||
weightStats && userProfile?.heightCm
|
||||
? weightStats.latest / (userProfile.heightCm / 100) ** 2
|
||||
: null;
|
||||
|
||||
await auditLog("doctor-report.generate", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { days },
|
||||
});
|
||||
|
||||
annotate({ meta: { report_days: days } });
|
||||
|
||||
return apiSuccess({
|
||||
period: { days, since: since.toISOString() },
|
||||
patient: {
|
||||
username: userProfile?.username ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
},
|
||||
measurements: byType,
|
||||
stats,
|
||||
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
|
||||
compliance: complianceByMed,
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
})),
|
||||
})),
|
||||
mood: moodSummary,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
toCSV,
|
||||
formatMeasurementsForExport,
|
||||
formatMedicationsForExport,
|
||||
formatIntakeEventsForExport,
|
||||
formatMoodEntriesForExport,
|
||||
} from "@/lib/export";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Export user data.
|
||||
* Query params:
|
||||
* format: "csv" | "json" (default: json)
|
||||
* type: "measurements" | "medications" | "intake" | "all" (default: all)
|
||||
*
|
||||
* Re-authentication is required via session (user must be logged in).
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximal 10 Exports pro Stunde", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "export.download" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const format = searchParams.get("format") ?? "json";
|
||||
const type = searchParams.get("type") ?? "all";
|
||||
|
||||
if (!["csv", "json"].includes(format)) {
|
||||
return apiError("Format muss csv oder json sein", 422);
|
||||
}
|
||||
if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) {
|
||||
return apiError("Typ muss measurements, medications, intake, mood oder all sein", 422);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (type === "measurements" || type === "all") {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: { userId },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
data.measurements =
|
||||
format === "csv"
|
||||
? toCSV(formatMeasurementsForExport(measurements))
|
||||
: formatMeasurementsForExport(measurements);
|
||||
}
|
||||
|
||||
if (type === "medications" || type === "all") {
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId },
|
||||
include: { schedules: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
data.medications =
|
||||
format === "csv"
|
||||
? toCSV(formatMedicationsForExport(medications))
|
||||
: formatMedicationsForExport(medications);
|
||||
}
|
||||
|
||||
if (type === "intake" || type === "all") {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
data.intakeEvents =
|
||||
format === "csv"
|
||||
? toCSV(formatIntakeEventsForExport(events))
|
||||
: formatIntakeEventsForExport(events);
|
||||
}
|
||||
|
||||
if (type === "mood" || type === "all") {
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
data.moodEntries =
|
||||
format === "csv"
|
||||
? toCSV(formatMoodEntriesForExport(moodEntries))
|
||||
: formatMoodEntriesForExport(moodEntries);
|
||||
}
|
||||
|
||||
await auditLog("export.download", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { format, type },
|
||||
});
|
||||
|
||||
annotate({ meta: { export_format: format, export_type: type } });
|
||||
|
||||
if (format === "csv") {
|
||||
// For CSV with all types, combine into separate sections
|
||||
const csvParts: string[] = [];
|
||||
if (data.measurements) {
|
||||
csvParts.push("# Measurements\n" + data.measurements);
|
||||
}
|
||||
if (data.medications) {
|
||||
csvParts.push("# Medications\n" + data.medications);
|
||||
}
|
||||
if (data.intakeEvents) {
|
||||
csvParts.push("# Intake Events\n" + data.intakeEvents);
|
||||
}
|
||||
if (data.moodEntries) {
|
||||
csvParts.push("# Mood Entries\n" + data.moodEntries);
|
||||
}
|
||||
const csvContent = csvParts.join("\n\n");
|
||||
|
||||
return new NextResponse(csvContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ data },
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.json"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
ACHIEVEMENT_DEFINITIONS,
|
||||
GAMIFICATION_ROLLOUT_AT,
|
||||
calculateLongestStreak,
|
||||
evaluateAchievementsWithCompletionDates,
|
||||
toBerlinDayKey,
|
||||
} from "@/lib/gamification/achievements";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
classifyPulse,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const ROLLING_WINDOW_DAYS = 30;
|
||||
|
||||
type IntakeEventRecord = {
|
||||
medicationId: string;
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
};
|
||||
|
||||
type MedicationScheduleRecord = {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
daysOfWeek: string | null;
|
||||
};
|
||||
|
||||
type MeasurementRecord = {
|
||||
type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE";
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
};
|
||||
|
||||
function maxDate(a: Date, b: Date): Date {
|
||||
return a > b ? a : b;
|
||||
}
|
||||
|
||||
function dayKeyToSerial(dayKey: string): number {
|
||||
const [year, month, day] = dayKey.split("-").map(Number);
|
||||
return Date.UTC(year, month - 1, day) / DAY_MS;
|
||||
}
|
||||
|
||||
function serialToDayKey(serial: number): string {
|
||||
const date = new Date(serial * DAY_MS);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function dayKeyToDate(dayKey: string): Date {
|
||||
const [year, month, day] = dayKey.split("-").map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
||||
}
|
||||
|
||||
function mean(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((total, value) => total + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function findCountCompletionDate(dates: Date[], target: number): Date | null {
|
||||
if (dates.length < target) return null;
|
||||
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
|
||||
return sorted[target - 1] ?? null;
|
||||
}
|
||||
|
||||
function findStreakCompletionDate(
|
||||
dayKeys: string[],
|
||||
target: number,
|
||||
firstDateByDay: Map<string, Date>,
|
||||
): Date | null {
|
||||
if (dayKeys.length === 0) return null;
|
||||
|
||||
let streak = 1;
|
||||
if (target === 1) {
|
||||
return firstDateByDay.get(dayKeys[0]) ?? null;
|
||||
}
|
||||
|
||||
for (let i = 1; i < dayKeys.length; i++) {
|
||||
const previous = dayKeyToSerial(dayKeys[i - 1]);
|
||||
const current = dayKeyToSerial(dayKeys[i]);
|
||||
streak = current - previous === 1 ? streak + 1 : 1;
|
||||
|
||||
if (streak >= target) {
|
||||
return firstDateByDay.get(dayKeys[i]) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toDaySeries(dayKeys: string[]): {
|
||||
dayKeys: string[];
|
||||
firstDateByDay: Map<string, Date>;
|
||||
} {
|
||||
const sortedKeys = [...new Set(dayKeys)].sort();
|
||||
const firstDateByDay = new Map<string, Date>(
|
||||
sortedKeys.map((key) => [key, dayKeyToDate(key)]),
|
||||
);
|
||||
|
||||
return {
|
||||
dayKeys: sortedKeys,
|
||||
firstDateByDay,
|
||||
};
|
||||
}
|
||||
|
||||
function getEventDaySeries(dates: Date[]): {
|
||||
dayKeys: string[];
|
||||
firstDateByDay: Map<string, Date>;
|
||||
} {
|
||||
const firstDateByDay = new Map<string, Date>();
|
||||
|
||||
for (const date of dates) {
|
||||
const dayKey = toBerlinDayKey(date);
|
||||
const existing = firstDateByDay.get(dayKey);
|
||||
|
||||
if (!existing || date < existing) {
|
||||
firstDateByDay.set(dayKey, date);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dayKeys: Array.from(firstDateByDay.keys()).sort(),
|
||||
firstDateByDay,
|
||||
};
|
||||
}
|
||||
|
||||
function getHealthGreenDaySeries(
|
||||
measurements: MeasurementRecord[],
|
||||
heightCm: number | null,
|
||||
) {
|
||||
const dayStats = new Map<
|
||||
string,
|
||||
{
|
||||
weight: number[];
|
||||
bpSys: number[];
|
||||
bpDia: number[];
|
||||
pulse: number[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const measurement of measurements) {
|
||||
const dayKey = toBerlinDayKey(measurement.measuredAt);
|
||||
const bucket = dayStats.get(dayKey) ?? {
|
||||
weight: [],
|
||||
bpSys: [],
|
||||
bpDia: [],
|
||||
pulse: [],
|
||||
};
|
||||
|
||||
if (measurement.type === "WEIGHT") bucket.weight.push(measurement.value);
|
||||
if (measurement.type === "BLOOD_PRESSURE_SYS")
|
||||
bucket.bpSys.push(measurement.value);
|
||||
if (measurement.type === "BLOOD_PRESSURE_DIA")
|
||||
bucket.bpDia.push(measurement.value);
|
||||
if (measurement.type === "PULSE") bucket.pulse.push(measurement.value);
|
||||
|
||||
dayStats.set(dayKey, bucket);
|
||||
}
|
||||
|
||||
const bmiGreenDays: string[] = [];
|
||||
const bpGreenDays: string[] = [];
|
||||
const pulseGreenDays: string[] = [];
|
||||
|
||||
for (const [dayKey, bucket] of dayStats.entries()) {
|
||||
if (heightCm && bucket.weight.length > 0) {
|
||||
const bmi = mean(bucket.weight) / (heightCm / 100) ** 2;
|
||||
if (classifyBMI(bmi).severity === "normal") {
|
||||
bmiGreenDays.push(dayKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (bucket.bpSys.length > 0 && bucket.bpDia.length > 0) {
|
||||
const bpClass = classifyBP(mean(bucket.bpSys), mean(bucket.bpDia));
|
||||
if (bpClass.severity === "normal") {
|
||||
bpGreenDays.push(dayKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (bucket.pulse.length > 0) {
|
||||
const pulseClass = classifyPulse(mean(bucket.pulse));
|
||||
if (pulseClass.severity === "normal") {
|
||||
pulseGreenDays.push(dayKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bmi: toDaySeries(bmiGreenDays),
|
||||
bp: toDaySeries(bpGreenDays),
|
||||
pulse: toDaySeries(pulseGreenDays),
|
||||
};
|
||||
}
|
||||
|
||||
function getOnTimePerfectDaySeries(
|
||||
intakeEvents: IntakeEventRecord[],
|
||||
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
|
||||
) {
|
||||
const eventsByDay = new Map<string, IntakeEventRecord[]>();
|
||||
|
||||
for (const event of intakeEvents) {
|
||||
const dayKey = toBerlinDayKey(event.scheduledFor);
|
||||
const list = eventsByDay.get(dayKey) ?? [];
|
||||
list.push(event);
|
||||
eventsByDay.set(dayKey, list);
|
||||
}
|
||||
|
||||
const perfectDays: string[] = [];
|
||||
|
||||
for (const [dayKey, events] of eventsByDay.entries()) {
|
||||
if (events.length === 0) continue;
|
||||
|
||||
const scheduledDate = dayKeyToDate(dayKey);
|
||||
let isPerfectDay = true;
|
||||
|
||||
for (const event of events) {
|
||||
if (event.skipped || event.takenAt === null) {
|
||||
isPerfectDay = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const schedules = schedulesByMedicationId.get(event.medicationId) ?? [];
|
||||
if (schedules.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventMinutes =
|
||||
event.scheduledFor.getUTCHours() * 60 +
|
||||
event.scheduledFor.getUTCMinutes();
|
||||
|
||||
let bestSchedule = schedules[0];
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const [h, m] = schedule.windowStart.split(":").map(Number);
|
||||
const scheduleMinutes = h * 60 + m;
|
||||
const distance = Math.abs(eventMinutes - scheduleMinutes);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSchedule = schedule;
|
||||
}
|
||||
}
|
||||
|
||||
const timing = classifyIntakeTiming(
|
||||
event.takenAt,
|
||||
bestSchedule.windowStart,
|
||||
bestSchedule.windowEnd,
|
||||
scheduledDate,
|
||||
);
|
||||
|
||||
if (timing !== "on_time") {
|
||||
isPerfectDay = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPerfectDay) {
|
||||
perfectDays.push(dayKey);
|
||||
}
|
||||
}
|
||||
|
||||
return toDaySeries(perfectDays);
|
||||
}
|
||||
|
||||
function parseDaysOfWeek(daysOfWeek: string | null): Set<number> | null {
|
||||
if (!daysOfWeek) return null;
|
||||
|
||||
const values = daysOfWeek
|
||||
.split(",")
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
|
||||
|
||||
return values.length > 0 ? new Set(values) : null;
|
||||
}
|
||||
|
||||
function getExpectedIntakesForDay(
|
||||
schedules: MedicationScheduleRecord[],
|
||||
dayKey: string,
|
||||
): number {
|
||||
if (schedules.length === 0) return 0;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0;
|
||||
|
||||
const day = dayKeyToDate(dayKey).getUTCDay();
|
||||
let expected = 0;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const allowedDays = parseDaysOfWeek(schedule.daysOfWeek);
|
||||
if (!allowedDays || allowedDays.has(day)) {
|
||||
expected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return expected;
|
||||
}
|
||||
|
||||
function getIntakeIssueMetrics(
|
||||
intakeEvents: IntakeEventRecord[],
|
||||
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
|
||||
) {
|
||||
const skippedIntakeDates = intakeEvents
|
||||
.filter((event) => event.skipped)
|
||||
.map((event) => event.scheduledFor)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const eventsByMedicationDay = new Map<
|
||||
string,
|
||||
Map<string, IntakeEventRecord[]>
|
||||
>();
|
||||
|
||||
for (const event of intakeEvents) {
|
||||
if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dayKey = toBerlinDayKey(event.scheduledFor);
|
||||
const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map();
|
||||
const list = byDay.get(dayKey) ?? [];
|
||||
list.push(event);
|
||||
byDay.set(dayKey, list);
|
||||
eventsByMedicationDay.set(event.medicationId, byDay);
|
||||
}
|
||||
|
||||
let overIntakeCount = 0;
|
||||
const overIntakeDates: Date[] = [];
|
||||
|
||||
for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) {
|
||||
const schedules = schedulesByMedicationId.get(medicationId) ?? [];
|
||||
|
||||
for (const [dayKey, events] of dayMap.entries()) {
|
||||
const expectedCount = getExpectedIntakesForDay(schedules, dayKey);
|
||||
|
||||
if (expectedCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenEvents = events
|
||||
.filter((event) => !event.skipped && event.takenAt !== null)
|
||||
.sort((a, b) => {
|
||||
const aTime = (a.takenAt ?? a.scheduledFor).getTime();
|
||||
const bTime = (b.takenAt ?? b.scheduledFor).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
const excessCount = Math.max(0, takenEvents.length - expectedCount);
|
||||
if (excessCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
overIntakeCount += excessCount;
|
||||
|
||||
for (
|
||||
let index = takenEvents.length - excessCount;
|
||||
index < takenEvents.length;
|
||||
index++
|
||||
) {
|
||||
const event = takenEvents[index];
|
||||
if (!event) continue;
|
||||
overIntakeDates.push(event.takenAt ?? event.scheduledFor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overIntakeDates.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
return {
|
||||
skippedIntakeCount: skippedIntakeDates.length,
|
||||
skippedIntakeDates,
|
||||
overIntakeCount,
|
||||
overIntakeDates,
|
||||
};
|
||||
}
|
||||
|
||||
function getCompliance80DaySeries(
|
||||
intakeEvents: IntakeEventRecord[],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
) {
|
||||
const perDayCounts = new Map<number, { logged: number; taken: number }>();
|
||||
|
||||
for (const event of intakeEvents) {
|
||||
const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor));
|
||||
const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 };
|
||||
|
||||
const logged = event.skipped || event.takenAt !== null;
|
||||
const taken = !event.skipped && event.takenAt !== null;
|
||||
|
||||
if (logged) current.logged += 1;
|
||||
if (taken) current.taken += 1;
|
||||
|
||||
perDayCounts.set(serial, current);
|
||||
}
|
||||
|
||||
const startSerial = dayKeyToSerial(toBerlinDayKey(startDate));
|
||||
const endSerial = dayKeyToSerial(toBerlinDayKey(endDate));
|
||||
|
||||
let rollingLogged = 0;
|
||||
let rollingTaken = 0;
|
||||
const greenDays: string[] = [];
|
||||
|
||||
for (let serial = startSerial; serial <= endSerial; serial++) {
|
||||
const todayCounts = perDayCounts.get(serial);
|
||||
if (todayCounts) {
|
||||
rollingLogged += todayCounts.logged;
|
||||
rollingTaken += todayCounts.taken;
|
||||
}
|
||||
|
||||
const outOfWindowCounts = perDayCounts.get(serial - ROLLING_WINDOW_DAYS);
|
||||
if (outOfWindowCounts) {
|
||||
rollingLogged -= outOfWindowCounts.logged;
|
||||
rollingTaken -= outOfWindowCounts.taken;
|
||||
}
|
||||
|
||||
const hasFullWindow = serial >= startSerial + (ROLLING_WINDOW_DAYS - 1);
|
||||
if (!hasFullWindow || rollingLogged <= 0) continue;
|
||||
|
||||
const compliance = Math.round((rollingTaken / rollingLogged) * 100);
|
||||
if (compliance >= 80) {
|
||||
greenDays.push(serialToDayKey(serial));
|
||||
}
|
||||
}
|
||||
|
||||
return toDaySeries(greenDays);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "gamification.achievements" } });
|
||||
|
||||
const now = new Date();
|
||||
const userId = user.id;
|
||||
const startDate = maxDate(
|
||||
user.createdAt,
|
||||
GAMIFICATION_ROLLOUT_AT,
|
||||
);
|
||||
|
||||
const [measurements, intakeEvents, medications, passkeys, auditEvents] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
measuredAt: { gte: startDate, lte: now },
|
||||
source: { not: "IMPORT" },
|
||||
type: {
|
||||
in: [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: { not: "IMPORT" },
|
||||
scheduledFor: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
medicationId: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
},
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.passkey.findMany({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startDate, lte: now },
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startDate, lte: now },
|
||||
action: {
|
||||
in: [
|
||||
"auth.login.passkey",
|
||||
"auth.login.password",
|
||||
"bugreport.submit",
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const schedulesByMedicationId = new Map(
|
||||
medications.map((med) => [med.id, med.schedules]),
|
||||
);
|
||||
const intakeIssueMetrics = getIntakeIssueMetrics(
|
||||
intakeEvents,
|
||||
schedulesByMedicationId,
|
||||
);
|
||||
|
||||
const takenIntakeDates = intakeEvents
|
||||
.filter((event) => !event.skipped && event.takenAt !== null)
|
||||
.map((event) => event.takenAt as Date);
|
||||
const passkeyCreatedDates = passkeys.map((passkey) => passkey.createdAt);
|
||||
const passkeyLoginDates = auditEvents
|
||||
.filter((event) => event.action === "auth.login.passkey")
|
||||
.map((event) => event.createdAt);
|
||||
const passwordLoginDates = auditEvents
|
||||
.filter((event) => event.action === "auth.login.password")
|
||||
.map((event) => event.createdAt);
|
||||
const bugReportDates = auditEvents
|
||||
.filter((event) => event.action === "bugreport.submit")
|
||||
.map((event) => event.createdAt);
|
||||
const loginDaySeries = getEventDaySeries([
|
||||
...passkeyLoginDates,
|
||||
...passwordLoginDates,
|
||||
]);
|
||||
|
||||
const healthSeries = getHealthGreenDaySeries(
|
||||
measurements as MeasurementRecord[],
|
||||
user.heightCm,
|
||||
);
|
||||
const onTimeSeries = getOnTimePerfectDaySeries(
|
||||
intakeEvents,
|
||||
schedulesByMedicationId,
|
||||
);
|
||||
const complianceSeries = getCompliance80DaySeries(
|
||||
intakeEvents,
|
||||
startDate,
|
||||
now,
|
||||
);
|
||||
|
||||
const metrics = {
|
||||
totalTakenIntakes: takenIntakeDates.length,
|
||||
overIntakeCount: intakeIssueMetrics.overIntakeCount,
|
||||
skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount,
|
||||
bmiGreenStreak: calculateLongestStreak(healthSeries.bmi.dayKeys),
|
||||
bpGreenStreak: calculateLongestStreak(healthSeries.bp.dayKeys),
|
||||
pulseGreenStreak: calculateLongestStreak(healthSeries.pulse.dayKeys),
|
||||
onTimePerfectDayStreak: calculateLongestStreak(onTimeSeries.dayKeys),
|
||||
compliance80DayStreak: calculateLongestStreak(complianceSeries.dayKeys),
|
||||
passkeyCreatedCount: passkeyCreatedDates.length,
|
||||
passkeyLoginCount: passkeyLoginDates.length,
|
||||
passwordLoginCount: passwordLoginDates.length,
|
||||
loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
|
||||
bugReportCount: bugReportDates.length,
|
||||
};
|
||||
|
||||
const completionDates: Partial<Record<string, Date>> = {};
|
||||
|
||||
for (const definition of ACHIEVEMENT_DEFINITIONS) {
|
||||
if (definition.metric === "totalTakenIntakes") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
takenIntakeDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "passkeyCreatedCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
passkeyCreatedDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "passkeyLoginCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
passkeyLoginDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "passwordLoginCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
passwordLoginDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "bugReportCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
bugReportDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "overIntakeCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
intakeIssueMetrics.overIntakeDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "skippedIntakeCount") {
|
||||
const completedAt = findCountCompletionDate(
|
||||
intakeIssueMetrics.skippedIntakeDates,
|
||||
definition.target,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "loginDayStreak") {
|
||||
const completedAt = findStreakCompletionDate(
|
||||
loginDaySeries.dayKeys,
|
||||
definition.target,
|
||||
loginDaySeries.firstDateByDay,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "bmiGreenStreak") {
|
||||
const completedAt = findStreakCompletionDate(
|
||||
healthSeries.bmi.dayKeys,
|
||||
definition.target,
|
||||
healthSeries.bmi.firstDateByDay,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "bpGreenStreak") {
|
||||
const completedAt = findStreakCompletionDate(
|
||||
healthSeries.bp.dayKeys,
|
||||
definition.target,
|
||||
healthSeries.bp.firstDateByDay,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "pulseGreenStreak") {
|
||||
const completedAt = findStreakCompletionDate(
|
||||
healthSeries.pulse.dayKeys,
|
||||
definition.target,
|
||||
healthSeries.pulse.firstDateByDay,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "onTimePerfectDayStreak") {
|
||||
const completedAt = findStreakCompletionDate(
|
||||
onTimeSeries.dayKeys,
|
||||
definition.target,
|
||||
onTimeSeries.firstDateByDay,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (definition.metric === "compliance80DayStreak") {
|
||||
const completedAt = findStreakCompletionDate(
|
||||
complianceSeries.dayKeys,
|
||||
definition.target,
|
||||
complianceSeries.firstDateByDay,
|
||||
);
|
||||
if (completedAt) completionDates[definition.id] = completedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Load persisted achievements
|
||||
const persisted = await prisma.userAchievement.findMany({
|
||||
where: { userId },
|
||||
select: { achievementId: true, unlockedAt: true },
|
||||
});
|
||||
const persistedMap = new Map(
|
||||
persisted.map((p) => [p.achievementId, p.unlockedAt]),
|
||||
);
|
||||
|
||||
// Merge: persisted dates take priority, then calculated dates
|
||||
for (const [id, date] of Object.entries(completionDates)) {
|
||||
if (!persistedMap.has(id) && date) {
|
||||
persistedMap.set(id, date);
|
||||
}
|
||||
}
|
||||
|
||||
// Use persisted dates as the source of truth
|
||||
const mergedDates: Partial<Record<string, Date>> = {};
|
||||
for (const [id, date] of persistedMap) {
|
||||
mergedDates[id] = date;
|
||||
}
|
||||
|
||||
const result = evaluateAchievementsWithCompletionDates(
|
||||
metrics,
|
||||
mergedDates,
|
||||
);
|
||||
|
||||
// Persist newly unlocked achievements
|
||||
const newUnlocks = result.achievements.filter(
|
||||
(a) => a.unlocked && a.completedAt && !persisted.some((p) => p.achievementId === a.id),
|
||||
);
|
||||
if (newUnlocks.length > 0) {
|
||||
await prisma.userAchievement.createMany({
|
||||
data: newUnlocks.map((a) => ({
|
||||
userId,
|
||||
achievementId: a.id,
|
||||
unlockedAt: new Date(a.completedAt!),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
annotate({ action: { name: "health.check" } });
|
||||
|
||||
let dbOk = true;
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
dbOk = false;
|
||||
}
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const status = dbOk && worker.running ? "ok" : "degraded";
|
||||
const statusCode = status === "ok" ? 200 : 503;
|
||||
|
||||
const cacheHeaders = {
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
};
|
||||
|
||||
// Only expose detailed info to authenticated admins
|
||||
const session = await getSession().catch(() => null);
|
||||
if (session?.user.role === "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
database: dbOk ? "connected" : "disconnected",
|
||||
worker: worker.running ? "running" : "stopped",
|
||||
...(worker.lastHeartbeat
|
||||
? { workerLastHeartbeat: worker.lastHeartbeat }
|
||||
: {}),
|
||||
},
|
||||
{ status: statusCode, headers: cacheHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ status },
|
||||
{ status: statusCode, headers: cacheHeaders },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
|
||||
const measurementSchema = z
|
||||
.object({
|
||||
type: z.enum([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
]),
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
measuredAt: z.string().datetime(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => validateMeasurementRange(data.type, data.value) === null,
|
||||
"Wert ausserhalb des plausiblen Bereichs",
|
||||
);
|
||||
|
||||
const moodEntrySchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
|
||||
score: z.number().int().min(1).max(5),
|
||||
tags: z.string().optional(),
|
||||
loggedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const importSchema = z.object({
|
||||
measurements: z.array(measurementSchema).max(10000).optional(),
|
||||
moodEntries: z.array(moodEntrySchema).max(10000).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Import user data (JSON format matching export structure).
|
||||
* Deduplicates via unique constraints — existing records are skipped.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "import.upload" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = importSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiSuccess({ error: parsed.error.issues[0].message }, 422);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const data = parsed.data;
|
||||
const stats = { measurements: 0, moodEntries: 0, skipped: 0 };
|
||||
|
||||
// Import measurements
|
||||
if (data.measurements?.length) {
|
||||
for (const m of data.measurements) {
|
||||
try {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt: new Date(m.measuredAt),
|
||||
notes: m.notes || null,
|
||||
},
|
||||
});
|
||||
stats.measurements++;
|
||||
} catch {
|
||||
// Unique constraint violation — skip duplicate
|
||||
stats.skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import mood entries
|
||||
if (data.moodEntries?.length) {
|
||||
for (const e of data.moodEntries) {
|
||||
try {
|
||||
const loggedAt = e.loggedAt ? new Date(e.loggedAt) : new Date();
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
});
|
||||
stats.moodEntries++;
|
||||
} catch {
|
||||
// Unique constraint violation — skip duplicate
|
||||
stats.skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("import.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: stats,
|
||||
});
|
||||
|
||||
annotate({ meta: { import_stats: stats } });
|
||||
|
||||
return apiSuccess(stats);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { externalIntakeSchema } from "@/lib/validations/medication";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* External medication ingest endpoint.
|
||||
* Auth: Bearer token (hashed and looked up in api_tokens table).
|
||||
* Idempotent via idempotencyKey.
|
||||
* Rate limit: 60 requests per minute per IP.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "ingest.medication" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API ist global deaktiviert", 403);
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
// Rate limiting: 60 requests per minute per IP
|
||||
const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
// Extract bearer token
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return apiError("Authorization header required", 401);
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const tokenHashValue = hashToken(token);
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { tokenHash: tokenHashValue },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
permissions: true,
|
||||
revoked: true,
|
||||
expiresAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken || apiToken.revoked) {
|
||||
return apiError("Invalid or revoked token", 401);
|
||||
}
|
||||
|
||||
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
||||
return apiError("Token expired", 401);
|
||||
}
|
||||
|
||||
if (!apiToken.permissions.includes("medication:ingest")) {
|
||||
return apiError("Insufficient permissions", 403);
|
||||
}
|
||||
|
||||
// Annotate auth after verification
|
||||
getEvent()?.setAuth({
|
||||
user_id: apiToken.userId,
|
||||
auth_method: "api_key",
|
||||
});
|
||||
|
||||
// Update lastUsedAt
|
||||
await prisma.apiToken.update({
|
||||
where: { id: apiToken.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = externalIntakeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { medicationName, takenAt, idempotencyKey } = parsed.data;
|
||||
|
||||
// Idempotency check (scoped to token owner to prevent cross-user lookups)
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
idempotencyKey,
|
||||
userId: apiToken.userId,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return apiSuccess(existing);
|
||||
}
|
||||
|
||||
// Find medication by name for this user
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: {
|
||||
userId: apiToken.userId,
|
||||
name: { equals: medicationName, mode: "insensitive" },
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
}
|
||||
|
||||
const medicationScope = `medication:${medication.id}:ingest`;
|
||||
if (!apiToken.permissions.includes(medicationScope)) {
|
||||
return apiError("API-Endpoint fuer dieses Medikament ist deaktiviert", 403);
|
||||
}
|
||||
|
||||
const event = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: apiToken.userId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: takenAt ?? new Date(),
|
||||
takenAt: takenAt ?? new Date(),
|
||||
skipped: false,
|
||||
source: "API",
|
||||
idempotencyKey,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("medication.ingest.external", {
|
||||
userId: apiToken.userId,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: medication.id,
|
||||
eventId: event.id,
|
||||
tokenId: apiToken.id,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: { medication_id: medication.id, event_id: event.id } });
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateBloodPressureStatusForUser,
|
||||
resolveBloodPressureStatusLocale,
|
||||
} from "@/lib/insights/blood-pressure-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveBloodPressureStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
|
||||
const result = await generateBloodPressureStatusForUser(
|
||||
user.id,
|
||||
{
|
||||
locale,
|
||||
force: false,
|
||||
},
|
||||
);
|
||||
|
||||
annotate({ action: { name: "insights.blood-pressure-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateBmiStatusForUser,
|
||||
resolveBmiStatusLocale,
|
||||
} from "@/lib/insights/bmi-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveBmiStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
|
||||
const result = await generateBmiStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.bmi-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
generateAlerts,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import {
|
||||
pairByTimestamp,
|
||||
pearsonCorrelation,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userId = user.id;
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch user profile
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
openaiKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all measurements (90 days)
|
||||
const types: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
];
|
||||
|
||||
const allMeasurements = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Fetch mood entries (90 days)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { date: true, score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Aggregate mood to daily averages
|
||||
const moodByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const entry of moodEntries) {
|
||||
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
current.sum += entry.score;
|
||||
current.count += 1;
|
||||
moodByDay.set(entry.date, current);
|
||||
}
|
||||
const dailyMoodEntries = Array.from(moodByDay.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, stats]) => ({
|
||||
day,
|
||||
value: Math.round((stats.sum / stats.count) * 100) / 100,
|
||||
}));
|
||||
|
||||
// Build DataPoint array for mood summary
|
||||
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const byType = (t: MeasurementType): DataPoint[] =>
|
||||
allMeasurements
|
||||
.filter((m) => m.type === t)
|
||||
.map((m) => ({ date: m.measuredAt, value: m.value }));
|
||||
|
||||
// Summaries
|
||||
const summaries: Record<string, ReturnType<typeof summarize>> = {};
|
||||
for (const t of types) {
|
||||
const data = byType(t);
|
||||
if (data.length > 0) {
|
||||
summaries[t] = summarize(data);
|
||||
}
|
||||
}
|
||||
|
||||
// BMI
|
||||
let bmi: number | null = null;
|
||||
let bmiClassification = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
bmiClassification = classifyBMI(bmi);
|
||||
}
|
||||
|
||||
// BP classification (30-day average)
|
||||
let bpClassification = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (
|
||||
summaries.BLOOD_PRESSURE_SYS?.avg30 &&
|
||||
summaries.BLOOD_PRESSURE_DIA?.avg30
|
||||
) {
|
||||
bpClassification = classifyBP(
|
||||
summaries.BLOOD_PRESSURE_SYS.avg30,
|
||||
summaries.BLOOD_PRESSURE_DIA.avg30,
|
||||
);
|
||||
}
|
||||
|
||||
// BP target adherence
|
||||
let bpPctInTarget: number | null = null;
|
||||
if (bpTargets) {
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const diaData = byType("BLOOD_PRESSURE_DIA");
|
||||
const sysPairs = pairByTimestamp(sysData, diaData, 5 * 60 * 1000);
|
||||
|
||||
if (sysPairs.length > 0) {
|
||||
const inTarget = sysPairs.filter(
|
||||
(p) =>
|
||||
p.a >= bpTargets.sysLow &&
|
||||
p.a <= bpTargets.sysHigh &&
|
||||
p.b >= bpTargets.diaLow &&
|
||||
p.b <= bpTargets.diaHigh,
|
||||
).length;
|
||||
bpPctInTarget = Math.round((inTarget / sysPairs.length) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Correlations: Weight vs Sys BP
|
||||
const weightData = byType("WEIGHT");
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const weightBpPairs = pairByTimestamp(weightData, sysData);
|
||||
const weightBpCorrelation = pearsonCorrelation(weightBpPairs);
|
||||
const scatterData = weightBpPairs.map((p) => ({
|
||||
weight: p.a,
|
||||
sysBP: p.b,
|
||||
}));
|
||||
|
||||
// Correlations: Mood vs metrics (using daily averages matched by date)
|
||||
function buildMoodMetricPairs(
|
||||
dailyMood: Array<{ day: string; value: number }>,
|
||||
measurements: Array<{ measuredAt: Date; value: number }>,
|
||||
): PairedPoint[] {
|
||||
// Group measurements by day
|
||||
const metricByDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of measurements) {
|
||||
const dayKey = m.measuredAt.toISOString().slice(0, 10);
|
||||
const current = metricByDay.get(dayKey) ?? { sum: 0, count: 0 };
|
||||
current.sum += m.value;
|
||||
current.count += 1;
|
||||
metricByDay.set(dayKey, current);
|
||||
}
|
||||
|
||||
const pairs: PairedPoint[] = [];
|
||||
for (const mood of dailyMood) {
|
||||
const metric = metricByDay.get(mood.day);
|
||||
if (metric) {
|
||||
pairs.push({
|
||||
a: mood.value,
|
||||
b: Math.round((metric.sum / metric.count) * 100) / 100,
|
||||
date: new Date(`${mood.day}T12:00:00.000Z`),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// Mood vs Systolic BP
|
||||
const moodBpPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements.filter((m) => m.type === "BLOOD_PRESSURE_SYS").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
|
||||
// Mood vs Weight
|
||||
const moodWeightPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements.filter((m) => m.type === "WEIGHT").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({ mood: p.a, weight: p.b }));
|
||||
|
||||
// Mood vs Pulse
|
||||
const moodPulsePairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements.filter((m) => m.type === "PULSE").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, pulse: p.b }));
|
||||
|
||||
// Medication compliance
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
});
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
|
||||
const medCompliance = [];
|
||||
const bpMedicationEvents: Array<{
|
||||
scheduledFor: Date;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
}> = [];
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
for (const med of medications) {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
}));
|
||||
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt);
|
||||
const c30 = calculateCompliance(mapped, med.schedules, 30, med.createdAt);
|
||||
|
||||
medCompliance.push({
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: categoryMap[med.id] ?? "OTHER",
|
||||
compliance7: c7.rate,
|
||||
compliance30: c30.rate,
|
||||
streak: c7.streak,
|
||||
taken7: c7.taken,
|
||||
skipped7: c7.skipped,
|
||||
missed7: c7.missed,
|
||||
});
|
||||
|
||||
if ((categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE") {
|
||||
bpMedicationEvents.push(
|
||||
...events.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Correlation: continuity of BP medications vs systolic BP
|
||||
let bpMedicationCorrelation: {
|
||||
r: number;
|
||||
strength: string;
|
||||
n: number;
|
||||
medicationCount: number;
|
||||
} | null = null;
|
||||
const bpMedicationScatterData: Array<{
|
||||
continuityPct: number;
|
||||
sysBP: number;
|
||||
}> = [];
|
||||
|
||||
const expectedBpIntakesPerDay = bpMedications.reduce(
|
||||
(sum, med) => sum + med.schedules.length,
|
||||
0,
|
||||
);
|
||||
|
||||
if (expectedBpIntakesPerDay > 0) {
|
||||
const sysByDay = new Map<string, number[]>();
|
||||
for (const m of allMeasurements) {
|
||||
if (m.type !== "BLOOD_PRESSURE_SYS") continue;
|
||||
const dayKey = m.measuredAt.toISOString().slice(0, 10);
|
||||
const list = sysByDay.get(dayKey) ?? [];
|
||||
list.push(m.value);
|
||||
sysByDay.set(dayKey, list);
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, sysValues] of sysByDay.entries()) {
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
const avgSys =
|
||||
sysValues.reduce((sum, value) => sum + value, 0) / sysValues.length;
|
||||
pairs.push({
|
||||
a: continuity,
|
||||
b: avgSys,
|
||||
date: new Date(`${dayKey}T00:00:00.000Z`),
|
||||
});
|
||||
bpMedicationScatterData.push({
|
||||
continuityPct: Math.round(continuity * 100),
|
||||
sysBP: Math.round(avgSys * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
const corr = pearsonCorrelation(pairs);
|
||||
if (corr) {
|
||||
bpMedicationCorrelation = {
|
||||
...corr,
|
||||
medicationCount: bpMedications.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Generate alerts
|
||||
const alerts = generateAlerts({
|
||||
bmi,
|
||||
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
|
||||
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount,
|
||||
medications: medCompliance.map((m) => ({
|
||||
name: m.name,
|
||||
compliance7: m.compliance7,
|
||||
compliance30: m.compliance30,
|
||||
})),
|
||||
});
|
||||
|
||||
// Data span
|
||||
const firstMeasurement =
|
||||
allMeasurements.length > 0 ? allMeasurements[0].measuredAt : null;
|
||||
const dataSpanDays = firstMeasurement
|
||||
? Math.ceil(
|
||||
(Date.now() - firstMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
||||
)
|
||||
: 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.comprehensive" },
|
||||
meta: {
|
||||
totalMeasurements: allMeasurements.length,
|
||||
moodEntries: moodEntries.length,
|
||||
medications: medications.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
summaries,
|
||||
bmi,
|
||||
bmiClassification,
|
||||
bpClassification,
|
||||
bpPctInTarget,
|
||||
bpTargets,
|
||||
weightBpCorrelation,
|
||||
scatterData,
|
||||
bpMedicationCorrelation,
|
||||
bpMedicationScatterData,
|
||||
moodSummary,
|
||||
moodBpCorrelation,
|
||||
moodBpScatterData,
|
||||
moodWeightCorrelation,
|
||||
moodWeightScatterData,
|
||||
moodPulseCorrelation,
|
||||
moodPulseScatterData,
|
||||
medications: medCompliance,
|
||||
alerts,
|
||||
hasOpenAiKey: !!dbUser?.openaiKeyEncrypted,
|
||||
dataSpanDays,
|
||||
totalMeasurements: allMeasurements.length,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateGeneralStatusForUser,
|
||||
resolveGeneralStatusLocale,
|
||||
} from "@/lib/insights/general-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveGeneralStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
|
||||
const result = await generateGeneralStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.general-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import {
|
||||
INSIGHTS_SYSTEM_PROMPT,
|
||||
buildUserPrompt,
|
||||
type InsightsOutput,
|
||||
} from "@/lib/insights/prompt";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Generate AI-powered health insights.
|
||||
* Rate limit: 2 per hour per user.
|
||||
* Caches result daily (or until new data arrives).
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
// Rate limit: 2 per hour per user
|
||||
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Maximal 2 Insights-Generierungen pro Stunde. Bitte spaeter erneut versuchen.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
openaiKeyEncrypted: true,
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser?.openaiKeyEncrypted) {
|
||||
return apiError(
|
||||
"Kein OpenAI API-Key hinterlegt. Bitte in den Einstellungen konfigurieren.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if force refresh is requested
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const forceRefresh = body.force === true;
|
||||
|
||||
// Return cached result if available and less than 24h old
|
||||
if (
|
||||
!forceRefresh &&
|
||||
dbUser.insightsCachedAt &&
|
||||
dbUser.insightsCachedText &&
|
||||
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
|
||||
) {
|
||||
try {
|
||||
const cached = JSON.parse(dbUser.insightsCachedText) as InsightsOutput;
|
||||
annotate({ action: { name: "insights.generate" }, meta: { cached: true } });
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache, regenerate
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = decrypt(dbUser.openaiKeyEncrypted);
|
||||
const includeRaw = dbUser.insightsPrivacyMode === "raw";
|
||||
const features = await extractFeatures(userId, includeRaw);
|
||||
const featuresJson = JSON.stringify(features, null, 2);
|
||||
const userPrompt = buildUserPrompt(featuresJson, dbUser.insightsPrivacyMode);
|
||||
|
||||
// Call OpenAI
|
||||
const openaiRes = await fetch(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: INSIGHTS_SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 1000,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!openaiRes.ok) {
|
||||
const errBody = await openaiRes.text();
|
||||
annotate({ meta: { openai_status: openaiRes.status, openai_error: errBody } });
|
||||
if (openaiRes.status === 401) {
|
||||
return apiError("Ungueltiger OpenAI API-Key", 422);
|
||||
}
|
||||
return apiError("OpenAI-Anfrage fehlgeschlagen", 502);
|
||||
}
|
||||
|
||||
const openaiJson = await openaiRes.json();
|
||||
const content = openaiJson.choices?.[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
return apiError("Keine Antwort von OpenAI erhalten", 502);
|
||||
}
|
||||
|
||||
let insights: InsightsOutput;
|
||||
try {
|
||||
insights = JSON.parse(content);
|
||||
} catch {
|
||||
return apiError("OpenAI-Antwort konnte nicht verarbeitet werden", 502);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log (no sensitive content)
|
||||
await auditLog("insights.generate", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
privacyMode: dbUser.insightsPrivacyMode,
|
||||
tokensUsed: openaiJson.usage?.total_tokens ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: {
|
||||
cached: false,
|
||||
privacyMode: dbUser.insightsPrivacyMode,
|
||||
tokensUsed: openaiJson.usage?.total_tokens ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ insights, cached: false });
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateMedicationComplianceStatusForUser,
|
||||
resolveMedicationComplianceStatusLocale,
|
||||
} from "@/lib/insights/medication-compliance-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveMedicationComplianceStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser(
|
||||
user.id,
|
||||
{
|
||||
locale,
|
||||
force: false,
|
||||
},
|
||||
);
|
||||
|
||||
annotate({ action: { name: "insights.medication-compliance-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateMoodStatusForUser,
|
||||
resolveMoodStatusLocale,
|
||||
} from "@/lib/insights/mood-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveMoodStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
|
||||
const result = await generateMoodStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.mood-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generatePulseStatusForUser,
|
||||
resolvePulseStatusLocale,
|
||||
} from "@/lib/insights/pulse-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolvePulseStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
|
||||
const result = await generatePulseStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.pulse-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Get insights configuration status.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
openaiKeyEncrypted: true,
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.settings.get" } });
|
||||
|
||||
return apiSuccess({
|
||||
hasKey: !!dbUser?.openaiKeyEncrypted,
|
||||
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
lastInsightAt: dbUser?.insightsCachedAt ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update insights settings (API key and/or privacy mode).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
// Update API key if provided
|
||||
if (typeof body.apiKey === "string") {
|
||||
const key = (body.apiKey as string).trim();
|
||||
if (key === "") {
|
||||
// Remove key
|
||||
data.openaiKeyEncrypted = null;
|
||||
data.insightsCachedAt = null;
|
||||
data.insightsCachedText = null;
|
||||
} else {
|
||||
if (!key.startsWith("sk-")) {
|
||||
return apiError(
|
||||
"Ungueltiges API-Key-Format (muss mit sk- beginnen)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
data.openaiKeyEncrypted = encrypt(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update privacy mode if provided
|
||||
if (typeof body.privacyMode === "string") {
|
||||
const mode = body.privacyMode as string;
|
||||
if (!["aggregated", "raw"].includes(mode)) {
|
||||
return apiError("Ungueltiger Privacy-Modus", 422);
|
||||
}
|
||||
data.insightsPrivacyMode = mode;
|
||||
// Clear cache when privacy mode changes
|
||||
data.insightsCachedAt = null;
|
||||
data.insightsCachedText = null;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return apiError("Keine Aenderungen", 422);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.settings.update" } });
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
classifyBMI,
|
||||
classifyBP,
|
||||
classifySleepDuration,
|
||||
classifyBodyFat,
|
||||
classifySteps,
|
||||
getWeightRange,
|
||||
getSleepDurationRange,
|
||||
getStepsRange,
|
||||
getBpTargetsByAge,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
|
||||
if (
|
||||
monthDiff < 0 ||
|
||||
(monthDiff === 0 && today.getDate() < dateOfBirth.getDate())
|
||||
) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
interface TargetItem {
|
||||
type: string;
|
||||
label: string;
|
||||
current: number | null;
|
||||
average30: number | null;
|
||||
trend: "up" | "down" | "stable" | null;
|
||||
unit: string;
|
||||
range: { min: number; max: number } | null;
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
// Fetch user profile
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
},
|
||||
});
|
||||
|
||||
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
|
||||
const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
|
||||
const heightCm = dbUser?.heightCm ?? null;
|
||||
|
||||
// Fetch latest measurements for each type
|
||||
const types: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"SLEEP_DURATION",
|
||||
"BODY_FAT",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all measurements in the last 30 days + the latest for each type
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days)
|
||||
const latestByType: Record<string, number | null> = {};
|
||||
const avg30ByType: Record<string, number | null> = {};
|
||||
|
||||
for (const t of types) {
|
||||
// Latest value
|
||||
const latest = await prisma.measurement.findFirst({
|
||||
where: { userId, type: t },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true },
|
||||
});
|
||||
latestByType[t] = latest?.value ?? null;
|
||||
|
||||
// 30-day average
|
||||
const recentOfType = recentMeasurements.filter((m) => m.type === t);
|
||||
if (recentOfType.length > 0) {
|
||||
const sum = recentOfType.reduce((acc, m) => acc + m.value, 0);
|
||||
avg30ByType[t] = Math.round((sum / recentOfType.length) * 10) / 10;
|
||||
} else {
|
||||
avg30ByType[t] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute trend (compare first half of 30-day data to second half)
|
||||
function computeTrend(
|
||||
type: MeasurementType,
|
||||
): "up" | "down" | "stable" | null {
|
||||
const data = recentMeasurements
|
||||
.filter((m) => m.type === type)
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
if (data.length < 4) return null;
|
||||
|
||||
const mid = Math.floor(data.length / 2);
|
||||
const firstHalf = data.slice(0, mid);
|
||||
const secondHalf = data.slice(mid);
|
||||
|
||||
const avgFirst =
|
||||
firstHalf.reduce((s, m) => s + m.value, 0) / firstHalf.length;
|
||||
const avgSecond =
|
||||
secondHalf.reduce((s, m) => s + m.value, 0) / secondHalf.length;
|
||||
|
||||
const diff = avgSecond - avgFirst;
|
||||
const threshold = avgFirst * 0.02; // 2% change threshold
|
||||
|
||||
if (diff > threshold) return "up";
|
||||
if (diff < -threshold) return "down";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
// Build target items
|
||||
const targets: TargetItem[] = [];
|
||||
|
||||
// 1. Weight
|
||||
const weightRange = heightCm ? getWeightRange(heightCm) : null;
|
||||
let weightClassification: { category: string; color: string } | null = null;
|
||||
if (latestByType.WEIGHT != null && heightCm) {
|
||||
const heightM = heightCm / 100;
|
||||
const bmi = latestByType.WEIGHT / (heightM * heightM);
|
||||
const cls = classifyBMI(bmi);
|
||||
weightClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "WEIGHT",
|
||||
label: "Gewicht",
|
||||
current: latestByType.WEIGHT ?? null,
|
||||
average30: avg30ByType.WEIGHT ?? null,
|
||||
trend: computeTrend("WEIGHT"),
|
||||
unit: "kg",
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
});
|
||||
|
||||
// 2. Blood Pressure (sys/dia combined)
|
||||
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
|
||||
let bpClassification: { category: string; color: string } | null = null;
|
||||
if (
|
||||
latestByType.BLOOD_PRESSURE_SYS != null &&
|
||||
latestByType.BLOOD_PRESSURE_DIA != null
|
||||
) {
|
||||
const cls = classifyBP(
|
||||
latestByType.BLOOD_PRESSURE_SYS,
|
||||
latestByType.BLOOD_PRESSURE_DIA,
|
||||
);
|
||||
bpClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE",
|
||||
label: "Blutdruck",
|
||||
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
trend: computeTrend("BLOOD_PRESSURE_SYS"),
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESC/ESH 2018",
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
|
||||
// 2b. Blood pressure target-hit rate over 30 days
|
||||
if (bpRange) {
|
||||
const sysPoints = recentMeasurements
|
||||
.filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((measurement) => ({
|
||||
date: measurement.measuredAt,
|
||||
value: measurement.value,
|
||||
}));
|
||||
const diaPoints = recentMeasurements
|
||||
.filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA")
|
||||
.map((measurement) => ({
|
||||
date: measurement.measuredAt,
|
||||
value: measurement.value,
|
||||
}));
|
||||
|
||||
const bpPairs = pairByTimestamp(sysPoints, diaPoints, 5 * 60 * 1000);
|
||||
const bpInTargetCount = bpPairs.filter(
|
||||
(pair) =>
|
||||
pair.a >= bpRange.sysLow &&
|
||||
pair.a <= bpRange.sysHigh &&
|
||||
pair.b >= bpRange.diaLow &&
|
||||
pair.b <= bpRange.diaHigh,
|
||||
).length;
|
||||
const bpInTargetRate =
|
||||
bpPairs.length > 0
|
||||
? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10
|
||||
: null;
|
||||
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE_IN_TARGET",
|
||||
label: "Blutdruck im Zielbereich",
|
||||
current: bpInTargetRate,
|
||||
average30: bpInTargetRate,
|
||||
trend: null,
|
||||
unit: "%",
|
||||
range: { min: 70, max: 100 },
|
||||
classification:
|
||||
bpInTargetRate != null
|
||||
? bpInTargetRate >= 70
|
||||
? { category: "Gut", color: "#50fa7b" }
|
||||
: bpInTargetRate >= 40
|
||||
? { category: "Moderat", color: "#f1fa8c" }
|
||||
: { category: "Niedrig", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESC/ESH 2018",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Pulse
|
||||
const pulseTarget = getPersonalizedPulseTarget(age, gender);
|
||||
let pulseClassification: { category: string; color: string } | null = null;
|
||||
if (latestByType.PULSE != null) {
|
||||
const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget);
|
||||
pulseClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Ruhepuls",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
unit: "bpm",
|
||||
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
});
|
||||
|
||||
// 4. Sleep Duration
|
||||
const sleepRange = getSleepDurationRange();
|
||||
let sleepClassification: { category: string; color: string } | null = null;
|
||||
if (latestByType.SLEEP_DURATION != null) {
|
||||
const cls = classifySleepDuration(latestByType.SLEEP_DURATION);
|
||||
sleepClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "SLEEP_DURATION",
|
||||
label: "Schlafdauer",
|
||||
current: latestByType.SLEEP_DURATION ?? null,
|
||||
average30: avg30ByType.SLEEP_DURATION ?? null,
|
||||
trend: computeTrend("SLEEP_DURATION"),
|
||||
unit: "h",
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
});
|
||||
|
||||
// 5. BMI (derived from weight + height)
|
||||
if (heightCm) {
|
||||
const heightM = heightCm / 100;
|
||||
const heightSq = heightM * heightM;
|
||||
const latestWeight = latestByType.WEIGHT ?? null;
|
||||
const currentBmi =
|
||||
latestWeight != null
|
||||
? Math.round((latestWeight / heightSq) * 10) / 10
|
||||
: null;
|
||||
|
||||
// Compute 30-day average BMI from average weight
|
||||
const avgWeight = avg30ByType.WEIGHT ?? null;
|
||||
const avgBmi =
|
||||
avgWeight != null ? Math.round((avgWeight / heightSq) * 10) / 10 : null;
|
||||
|
||||
let bmiClassification: { category: string; color: string } | null = null;
|
||||
if (currentBmi != null) {
|
||||
const cls = classifyBMI(currentBmi);
|
||||
bmiClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
|
||||
targets.push({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
current: currentBmi,
|
||||
average30: avgBmi,
|
||||
trend: computeTrend("WEIGHT"), // BMI trend follows weight trend
|
||||
unit: "kg/m\u00B2",
|
||||
range: { min: 18.5, max: 24.9 },
|
||||
classification: bmiClassification,
|
||||
source: "WHO",
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Body Fat
|
||||
let bodyFatClassification: { category: string; color: string } | null = null;
|
||||
if (latestByType.BODY_FAT != null && age != null) {
|
||||
const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age);
|
||||
bodyFatClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
// Body fat ranges depend on gender/age; provide simplified ranges
|
||||
let bodyFatRange: { min: number; max: number } | null = null;
|
||||
if (gender === "MALE") {
|
||||
bodyFatRange = { min: 14, max: 24 };
|
||||
} else if (gender === "FEMALE") {
|
||||
bodyFatRange = { min: 21, max: 31 };
|
||||
} else if (age != null) {
|
||||
// Average of male/female fitness+average ranges
|
||||
bodyFatRange = { min: 17.5, max: 27.5 };
|
||||
}
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Koerperfett",
|
||||
current: latestByType.BODY_FAT ?? null,
|
||||
average30: avg30ByType.BODY_FAT ?? null,
|
||||
trend: computeTrend("BODY_FAT"),
|
||||
unit: "%",
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
});
|
||||
|
||||
// 7. Activity Steps
|
||||
const stepsRange = getStepsRange();
|
||||
let stepsClassification: { category: string; color: string } | null = null;
|
||||
if (avg30ByType.ACTIVITY_STEPS != null) {
|
||||
const cls = classifySteps(avg30ByType.ACTIVITY_STEPS);
|
||||
stepsClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
label: "Schritte/Tag",
|
||||
current: latestByType.ACTIVITY_STEPS ?? null,
|
||||
average30: avg30ByType.ACTIVITY_STEPS ?? null,
|
||||
trend: computeTrend("ACTIVITY_STEPS"),
|
||||
unit: "Schritte",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
source: "WHO",
|
||||
});
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
if (activeMedications.length > 0) {
|
||||
const thirtyDaysAgoIntake = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
medicationId: {
|
||||
in: activeMedications.map((medication) => medication.id),
|
||||
},
|
||||
scheduledFor: { gte: thirtyDaysAgoIntake },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
const medicationStats = activeMedications.map((medication) => {
|
||||
const medicationEvents = intakeEvents.filter(
|
||||
(event) => event.medicationId === medication.id,
|
||||
);
|
||||
const compliance7 = calculateCompliance(
|
||||
medicationEvents,
|
||||
medication.schedules,
|
||||
7,
|
||||
medication.createdAt,
|
||||
);
|
||||
const compliance30 = calculateCompliance(
|
||||
medicationEvents,
|
||||
medication.schedules,
|
||||
30,
|
||||
medication.createdAt,
|
||||
);
|
||||
|
||||
return {
|
||||
name: medication.name,
|
||||
compliance7: compliance7.rate,
|
||||
compliance30: compliance30.rate,
|
||||
totalExpected7: compliance7.totalExpected,
|
||||
taken7: compliance7.taken,
|
||||
totalExpected30: compliance30.totalExpected,
|
||||
taken30: compliance30.taken,
|
||||
};
|
||||
});
|
||||
|
||||
const totalExpected7 = medicationStats.reduce(
|
||||
(sum, medication) => sum + medication.totalExpected7,
|
||||
0,
|
||||
);
|
||||
const totalTaken7 = medicationStats.reduce(
|
||||
(sum, medication) => sum + medication.taken7,
|
||||
0,
|
||||
);
|
||||
const totalExpected30 = medicationStats.reduce(
|
||||
(sum, medication) => sum + medication.totalExpected30,
|
||||
0,
|
||||
);
|
||||
const totalTaken30 = medicationStats.reduce(
|
||||
(sum, medication) => sum + medication.taken30,
|
||||
0,
|
||||
);
|
||||
|
||||
const complianceRate7 =
|
||||
totalExpected7 > 0
|
||||
? Math.round(
|
||||
(Math.min(1, totalTaken7 / totalExpected7) * 100 + Number.EPSILON) *
|
||||
10,
|
||||
) / 10
|
||||
: null;
|
||||
const complianceRate30 =
|
||||
totalExpected30 > 0
|
||||
? Math.round(
|
||||
(Math.min(1, totalTaken30 / totalExpected30) * 100 +
|
||||
Number.EPSILON) *
|
||||
10,
|
||||
) / 10
|
||||
: complianceRate7;
|
||||
|
||||
targets.push({
|
||||
type: "MEDICATION_COMPLIANCE",
|
||||
label: "Einnahmetreue",
|
||||
current: complianceRate7,
|
||||
average30: complianceRate30,
|
||||
trend: null,
|
||||
unit: "%",
|
||||
range: { min: 90, max: 100 },
|
||||
classification:
|
||||
complianceRate7 != null
|
||||
? complianceRate7 >= 90
|
||||
? { category: "Sehr gut", color: "#50fa7b" }
|
||||
: complianceRate7 >= 70
|
||||
? { category: "Gut", color: "#f1fa8c" }
|
||||
: { category: "Niedrig", color: "#ff5555" }
|
||||
: null,
|
||||
source: "7-Tage",
|
||||
details: {
|
||||
medications: medicationStats.map((medication) => ({
|
||||
name: medication.name,
|
||||
compliance7: medication.compliance7,
|
||||
compliance30: medication.compliance30,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 9. Mood targets (if mood data exists)
|
||||
const moodEntries = await prisma.moodEntry.findMany({
|
||||
where: { userId },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
if (moodEntries.length >= 3) {
|
||||
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const recentMood = moodEntries.filter(
|
||||
(entry) => entry.moodLoggedAt >= thirtyDaysAgoMood,
|
||||
);
|
||||
const latestMoodScore = moodEntries[0]?.score ?? null;
|
||||
|
||||
const moodAvg30 =
|
||||
recentMood.length > 0
|
||||
? Math.round(
|
||||
(recentMood.reduce((sum, e) => sum + e.score, 0) /
|
||||
recentMood.length) *
|
||||
10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
// Mood trend: compare first half to second half of last 30 days
|
||||
let moodTrend: "up" | "down" | "stable" | null = null;
|
||||
if (recentMood.length >= 4) {
|
||||
const sorted = [...recentMood].sort(
|
||||
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
|
||||
);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const firstHalf = sorted.slice(0, mid);
|
||||
const secondHalf = sorted.slice(mid);
|
||||
const avgFirst =
|
||||
firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length;
|
||||
const avgSecond =
|
||||
secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length;
|
||||
const diff = avgSecond - avgFirst;
|
||||
if (diff > 0.2) moodTrend = "up";
|
||||
else if (diff < -0.2) moodTrend = "down";
|
||||
else moodTrend = "stable";
|
||||
}
|
||||
|
||||
const moodClassification =
|
||||
latestMoodScore != null
|
||||
? latestMoodScore >= 3.5
|
||||
? { category: "Gut", color: "#50fa7b" }
|
||||
: latestMoodScore >= 2
|
||||
? { category: "Moderat", color: "#f1fa8c" }
|
||||
: { category: "Niedrig", color: "#ff5555" }
|
||||
: null;
|
||||
|
||||
targets.push({
|
||||
type: "MOOD_SCORE",
|
||||
label: "Stimmung",
|
||||
current: latestMoodScore,
|
||||
average30: moodAvg30,
|
||||
trend: moodTrend,
|
||||
unit: "/ 5",
|
||||
range: { min: 3.5, max: 5 },
|
||||
classification: moodClassification,
|
||||
source: "moodLog",
|
||||
});
|
||||
|
||||
// Mood stability: standard deviation of recent scores (lower = more stable)
|
||||
if (recentMood.length >= 5) {
|
||||
const mean = moodAvg30 ?? latestMoodScore ?? 3;
|
||||
const variance =
|
||||
recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) /
|
||||
recentMood.length;
|
||||
const stdDev = Math.round(Math.sqrt(variance) * 100) / 100;
|
||||
|
||||
const stabilityClassification =
|
||||
stdDev <= 0.5
|
||||
? { category: "Sehr stabil", color: "#50fa7b" }
|
||||
: stdDev <= 1.0
|
||||
? { category: "Stabil", color: "#f1fa8c" }
|
||||
: { category: "Schwankend", color: "#ff5555" };
|
||||
|
||||
targets.push({
|
||||
type: "MOOD_STABILITY",
|
||||
label: "Stimmungsstabilitaet",
|
||||
current: stdDev,
|
||||
average30: stdDev,
|
||||
trend: null,
|
||||
unit: "\u03C3",
|
||||
range: { min: 0, max: 0.5 },
|
||||
classification: stabilityClassification,
|
||||
source: "moodLog",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
|
||||
|
||||
return apiSuccess({
|
||||
targets,
|
||||
// Extra diastolic data for BP display
|
||||
bpDiastolic: {
|
||||
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_DIA ?? null,
|
||||
range: bpRange ? { min: bpRange.diaLow, max: bpRange.diaHigh } : null,
|
||||
},
|
||||
profile: {
|
||||
heightCm,
|
||||
age,
|
||||
gender,
|
||||
},
|
||||
});
|
||||
});
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user