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