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:
Marc-André Bombeck committed 2026-03-10 20:14:33 +01:00
commit 38309932f3
301 files changed
+60041

No files matched your search

+37
View File
@@ -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
+12
View File
@@ -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 }}
+96
View File
@@ -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
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
out
build
src/generated
pnpm-lock.yaml
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"]
}
+217
View File
@@ -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.
+94
View File
@@ -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
View File
@@ -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"]
+661
View File
@@ -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/>.
+335
View File
@@ -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> &middot;
<a href="https://demo.healthlog.dev">Live Demo</a> &middot;
<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> &middot;
<a href="https://demo.healthlog.dev">Live Demo</a> &middot;
<a href="https://docs.healthlog.dev">Docs</a>
</p>
+61
View File
@@ -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/).
+23
View File
@@ -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": {}
}
+39
View File
@@ -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:
+33
View File
@@ -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 "$@"
+18
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1212
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -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;
+74
View File
@@ -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"
]
}
}
+10485
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+40
View File
@@ -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,
},
});
+252
View File
@@ -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");
+486
View File
@@ -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")
}
+9
View File
@@ -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

+16
View File
@@ -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

+21
View File
@@ -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
View File
@@ -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);
}),
);
});
+375
View File
@@ -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();
+209
View File
@@ -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);
});
+258
View File
@@ -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
+56
View File
@@ -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 } });
});
+85
View File
@@ -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 });
});
+227
View File
@@ -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,
});
});
+93
View File
@@ -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,
},
});
});
+29
View File
@@ -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 });
});
+68
View File
@@ -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);
});
+34
View File
@@ -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,
})),
);
});
+118
View File
@@ -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,
});
});
+46
View File
@@ -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 } });
});
+80
View File
@@ -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 },
});
});
+12
View File
@@ -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 });
});
+25
View File
@@ -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 });
});
+48
View File
@@ -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 });
});
+26
View File
@@ -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);
});
+92
View File
@@ -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 });
});
+62
View File
@@ -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,
});
});
+109
View File
@@ -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 });
}
});
+157
View File
@@ -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![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\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,
});
});
+171
View File
@@ -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,
});
});
+138
View File
@@ -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);
});
+50
View File
@@ -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 },
);
});
+122
View File
@@ -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);
});
+145
View File
@@ -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);
});
+28
View File
@@ -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);
});
+378
View File
@@ -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);
});
+156
View File
@@ -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);
});
+28
View File
@@ -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);
});
+86
View File
@@ -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 });
});
+589
View File
@@ -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