mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge: forward-port v1.33.2 into release/v1.34.0
This commit is contained in:
commit
81825ce23b
7 files changed
+298
-236
No files matched your search
@@ -1,244 +1,59 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) working on this repository.
|
||||
Entry point for coding agents working on this repository, whatever harness they run in.
|
||||
|
||||
## Project
|
||||
## Read CLAUDE.md first. It is the operating manual.
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first PWA design.
|
||||
`CLAUDE.md` carries the architecture walk, the code conventions, the security patterns, the
|
||||
branch and release model, the self-hosting gotchas and the DO-NOTs. It is maintained. This file
|
||||
is not a summary of it and must never become one.
|
||||
|
||||
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
|
||||
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
|
||||
consolidation, the per-route `/settings/[section]` split, the
|
||||
status-first admin grid + aggregator endpoint, five new "Test
|
||||
connection" buttons, AI insights with inline charts via allowlisted
|
||||
metric tokens, off-host encrypted S3 backup, encryption-key
|
||||
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
|
||||
web split, and short-lived 24h access tokens with refresh-token
|
||||
rotation for native API clients. v1.4.1 follows up with per-section
|
||||
admin component extraction (the inner monolith into one file per
|
||||
panel), a Postgres testcontainers integration test suite, and a
|
||||
Playwright + axe-core E2E foundation. See GitHub Releases +
|
||||
CHANGELOG.md for the full feature timeline.
|
||||
That is deliberate, and the reason is written in scar tissue. The previous version of this file
|
||||
duplicated the project description, the version, the stack table, the command list and a tree of
|
||||
the source layout. It then sat untouched for roughly thirty releases. It announced "v1.4.0
|
||||
released, v1.4.1 in progress" long after the version had passed 1.33, and described the UI as
|
||||
Dracula-themed long after the palette had been replaced by tokens with a light theme beside it.
|
||||
Anything that duplicates a moving fact will eventually assert a false one, and a confidently
|
||||
wrong instruction file is worse than none. So this one points rather than copies.
|
||||
|
||||
## Tech Stack
|
||||
## What is genuinely specific to working here as an agent
|
||||
|
||||
| Layer | Technology | Version | Notes |
|
||||
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
|
||||
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
|
||||
| ORM | Prisma | 7.8 | 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.mts` |
|
||||
| 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 |
|
||||
- **Never read anything under `src/generated/`.** It is the generated Prisma client, roughly
|
||||
9 MB across 83 files including a 224 KB inline schema. Reading or grepping it exhausts a
|
||||
context window and stalls the session. Regenerate it with `pnpm prisma generate`; never open
|
||||
it. Treat `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json` and `docs/api/openapi.yaml` the
|
||||
same way: grep them narrowly, do not read them whole.
|
||||
- **The gate before anything is called finished:**
|
||||
`pnpm typecheck && pnpm lint && pnpm format:check && TZ=UTC pnpm test && pnpm build`.
|
||||
`TZ=UTC` is load-bearing, because CI runs in UTC and a timezone-fragile date test passes
|
||||
locally and fails there. `pnpm test:integration` needs Docker. `pnpm e2e` is heavy and worth
|
||||
running locally before pushing a large branch; skipping it has cost five CI rounds before.
|
||||
- **Read the output, not the exit code.** `tsc` colours its output, so counting matches for an
|
||||
error string can return zero while errors are open. A shell chain of two commands can report
|
||||
success for the second while the first failed.
|
||||
- **Working notes live in `.planning/`**, which is gitignored. Build plans, audits, design
|
||||
documents and reconcile notes are there. Read them before planning anything, because they
|
||||
usually already contain the measurement about to be redone.
|
||||
- **Parallel work happens in git worktrees off `origin/main`**, one stream per worktree,
|
||||
file-disjoint. Commit early: a worktree can break and uncommitted work is lost work. Each
|
||||
worktree carries its own Prisma client, so regenerate in the main tree after merging.
|
||||
|
||||
## Commands
|
||||
## Hard rules for every committed artefact
|
||||
|
||||
```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
|
||||
pnpm openapi:generate # Regenerate docs/api/openapi.yaml from Zod schemas
|
||||
pnpm openapi:check # Verify the spec is in sync (CI gate)
|
||||
Stated in full in `CLAUDE.md` under "Voice and privacy". Repeated here because they are the ones
|
||||
most often broken by an agent that skipped the manual.
|
||||
|
||||
# Git hooks (one-time per clone)
|
||||
bash scripts/install-hooks.sh # Activates .githooks/, including the OpenAPI pre-commit check
|
||||
- No tooling or assistant vocabulary in anything that reaches GitHub: not in commit messages,
|
||||
branch names, PR bodies, code comments or release notes. No `Co-Authored-By` trailer.
|
||||
- No personal names, no live health figures, no account identifiers.
|
||||
- English, in the maintainer's voice: present-tense imperative for commits, factual for prose.
|
||||
- Never `--no-verify`. Never force-push `main` or a release branch. Never `--no-gpg-sign` unless
|
||||
asked.
|
||||
|
||||
# Tests
|
||||
pnpm test # Run all tests (vitest)
|
||||
pnpm test:watch # Watch mode
|
||||
## When a claim and the code disagree
|
||||
|
||||
# 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 shell — 77 LOC, mounts the per-section components from src/components/admin/
|
||||
│ ├── 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 # 308-redirects /settings → /settings/account
|
||||
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
|
||||
│ ├── mood/page.tsx # Mood tracking
|
||||
│ ├── targets/page.tsx # Target values dashboard
|
||||
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
|
||||
│ ├── mood-entries/ # Mood CRUD
|
||||
│ ├── import/ # JSON data import
|
||||
│ ├── doctor-report/ # Doctor report data aggregation
|
||||
│ ├── gamification/ # Achievements (persistent)
|
||||
│ └── ...
|
||||
├── 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 cards (status, advisor)
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
|
||||
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
|
||||
│ └── 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)
|
||||
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat)
|
||||
│ ├── feedback/ # In-app feedback + optional GitHub escalation
|
||||
│ ├── gamification/ # Achievement definitions + progress
|
||||
│ ├── insights/ # Insight pipeline + prompts
|
||||
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
|
||||
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
|
||||
│ └── 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 (26 models)
|
||||
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
|
||||
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
|
||||
|
||||
- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations.
|
||||
- **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("Not authenticated", 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 & Admin (per-route layout, v1.4)
|
||||
|
||||
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
|
||||
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
|
||||
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
|
||||
|
||||
### Insights Page
|
||||
|
||||
- Large file (~1750 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`, `meds`, `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` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
|
||||
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
|
||||
- **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)
|
||||
|
||||
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
|
||||
|
||||
## 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.
|
||||
Fix the claim. A comment, a docblock or a release note asserting behaviour the code does not
|
||||
have is treated as a defect here, not as documentation drift. More than one real failure in this
|
||||
project began as prose that nothing enforced, including one that shipped a fix which did
|
||||
nothing while its notes said otherwise. If it cannot be fixed inside the change being made, say
|
||||
so plainly in the same file rather than leaving the false sentence standing.
|
||||
@@ -2,6 +2,40 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.33.2] — 2026-07-28
|
||||
|
||||
### The cycle-symptom fix in v1.33.1 did not work
|
||||
|
||||
v1.33.1 said it had fixed custom cycle symptoms in backups. It had not. The
|
||||
fix was real in three places and missing from the one that decides what ends
|
||||
up in the file. The code that reads your cycle data returned the symptoms you
|
||||
created yourself. The file format declared a field to hold them. The restore
|
||||
knew how to put them back. But the step that assembles the file copies its
|
||||
contents across one field at a time, and that list was never extended, so
|
||||
every backup written since carried an empty list of custom symptoms.
|
||||
|
||||
The same release also replaced a silent drop with an error. Before it, a
|
||||
backup referencing a symptom the restoring instance did not have lost that
|
||||
one link and finished. After it, the restore stops and reports a failure.
|
||||
Together with the empty list, that took an account with a symptom of its own
|
||||
from losing a single link to being unable to restore at all. That is the
|
||||
group the fix was written for.
|
||||
|
||||
Nothing in your stored data was damaged. But a backup written by v1.33.1 or
|
||||
earlier does not hold the symptom definitions you created, and restoring one
|
||||
of those files on this version will fail at the first day-log referencing
|
||||
one. Take a fresh backup after updating and keep it alongside the older
|
||||
files.
|
||||
|
||||
The assembly step no longer lists fields by hand. It copies each section
|
||||
whole, so a field added to a section reaches the file because of the way the
|
||||
code is written rather than because somebody remembered. Both ends of this
|
||||
were covered by tests that passed for the whole time it was broken, and
|
||||
nothing covered the join between them, so there is now a test that writes a
|
||||
real backup for an account with a symptom of its own, removes that symptom
|
||||
from the database, restores the file, and checks the row and its day-log link
|
||||
came back.
|
||||
|
||||
## [1.33.1] — 2026-07-27
|
||||
|
||||
A patch release about the same thing as the one before it: places where the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.33.1
|
||||
version: 1.33.2
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.33.1",
|
||||
"version": "1.33.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.33.1";
|
||||
/* @sw-version-fallback */ "v1.33.2";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -404,7 +404,6 @@ export async function buildFullBackupPayload(
|
||||
unit: n.unit,
|
||||
source: n.source,
|
||||
})),
|
||||
manifest: records.manifest,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* The seam between the backup writer and the backup reader.
|
||||
*
|
||||
* Custom cycle symptoms had a green test on every end and shipped broken
|
||||
* anyway. `buildCycleBackupSection` returned them, `backupPayloadSchema`
|
||||
* declared them, `restoreCycleData` read them, and each of those three was
|
||||
* covered. What nothing covered was the assembly in between: the payload was
|
||||
* built as an object literal that copied section keys out one at a time, and
|
||||
* `customSymptoms` was never on that list. Every backup written carried an
|
||||
* empty array, so an account whose day-log referenced a symptom it had created
|
||||
* itself could not be restored at all — the restore threw on a key nothing had
|
||||
* written back and the route answered 500.
|
||||
*
|
||||
* So this test asserts on ROWS, never on the payload object. Asserting the
|
||||
* payload carries `customSymptoms` would re-test the writer, which was already
|
||||
* passing while production was broken. The account is populated, the REAL
|
||||
* `buildFullBackupPayload` produces the file, the file is serialised and parsed
|
||||
* back through the REAL `parseBackupPayload`, the account's own symptom row is
|
||||
* removed so the file is the only place it survives, and the REAL admin restore
|
||||
* has to put the row and its day-log link back.
|
||||
*
|
||||
* Removing the symptom row before restoring is the point, not a contrivance:
|
||||
* `restoreCycleData` wipes day-logs, cycles, and the profile, but never the
|
||||
* symptom catalogue. Restore onto the same instance and the row is still
|
||||
* sitting there to resolve against, which is exactly why the older round-trip
|
||||
* test stayed green. The file has to carry the symptom for the case the
|
||||
* `customSymptoms` field exists for: a restore onto an instance that never had
|
||||
* it.
|
||||
*
|
||||
* Mutation check: hand-pick the cycle section's keys in
|
||||
* `buildFullBackupPayload` instead of spreading it, and this test reproduces
|
||||
* the production failure — restore answers 500 on
|
||||
* `Unknown cycle symptom keys: custom:sacroiliac-ache`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
/** The user's own words for the symptom, as they are held at rest. */
|
||||
const SYMPTOM_LABEL = "Sacroiliac ache";
|
||||
const SYMPTOM_KEY = "custom:sacroiliac-ache";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
vi.mocked(invalidateUserData).mockClear();
|
||||
});
|
||||
|
||||
function makeRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("a backup of an account with its own cycle symptom", () => {
|
||||
it("restores the symptom and its day-log link onto an instance without it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// The restore writes into the backup's owner, and the route refuses a
|
||||
// payload whose declared owner differs from the backup row. Owner and
|
||||
// operator are the same account here so the fixture stays about the
|
||||
// symptom rather than about the ownership guard.
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "cycle-symptom-owner",
|
||||
email: "cycle-symptom-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const category = await prisma.cycleSymptomCategory.findFirstOrThrow();
|
||||
const symptom = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
key: SYMPTOM_KEY,
|
||||
labelKey: "cycle.symptom.custom",
|
||||
labelEncrypted: encrypt(SYMPTOM_LABEL),
|
||||
sortOrder: 3,
|
||||
},
|
||||
});
|
||||
await prisma.cycleProfile.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
typicalCycleLength: 29,
|
||||
},
|
||||
});
|
||||
const cycle = await prisma.menstrualCycle.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
startDate: "2026-07-01",
|
||||
periodEndDate: "2026-07-05",
|
||||
tz: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const dayLog = await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
cycleId: cycle.id,
|
||||
date: "2026-07-03",
|
||||
flow: "MEDIUM",
|
||||
source: "MANUAL",
|
||||
tz: "Europe/Berlin",
|
||||
symptomLinks: { create: { symptomId: symptom.id } },
|
||||
},
|
||||
});
|
||||
|
||||
// The real writer, the real wire format, the real reader. Nothing is
|
||||
// asserted about `built` — the writer was green through the whole defect.
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const parsed = parseBackupPayload(JSON.stringify(built));
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "CUSTOM_CYCLE_SYMPTOM_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(parsed)),
|
||||
},
|
||||
});
|
||||
|
||||
// Model the instance the `customSymptoms` field exists for: one that never
|
||||
// had this account's own symptom. The restore clears day-logs, cycles, and
|
||||
// the profile itself, but never the symptom catalogue, so the row has to go
|
||||
// before the restore runs or the file is not what resolves the link.
|
||||
await prisma.cycleDayLog.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.menstrualCycle.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.cycleProfile.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.cycleSymptom.deleteMany({ where: { userId: owner.id } });
|
||||
expect(
|
||||
await prisma.cycleSymptom.count({ where: { key: SYMPTOM_KEY } }),
|
||||
).toBe(0);
|
||||
|
||||
const response = await POST(
|
||||
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Rows, not payload keys.
|
||||
const restoredSymptom = await prisma.cycleSymptom.findUniqueOrThrow({
|
||||
where: { key: SYMPTOM_KEY },
|
||||
});
|
||||
expect(restoredSymptom).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: owner.id,
|
||||
categoryId: category.id,
|
||||
labelKey: "cycle.symptom.custom",
|
||||
sortOrder: 3,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
// The label is ciphertext on the wire and stays readable after the trip:
|
||||
// a restored symptom whose label came back empty is the same lost data in
|
||||
// a quieter form.
|
||||
expect(decrypt(restoredSymptom.labelEncrypted!)).toBe(SYMPTOM_LABEL);
|
||||
|
||||
const restoredDayLog = await prisma.cycleDayLog.findUniqueOrThrow({
|
||||
where: { id: dayLog.id },
|
||||
});
|
||||
expect(restoredDayLog.date).toBe("2026-07-03");
|
||||
expect(
|
||||
await prisma.cycleSymptomLink.findMany({
|
||||
where: { dayLogId: restoredDayLog.id },
|
||||
select: { symptomId: true },
|
||||
}),
|
||||
).toEqual([{ symptomId: restoredSymptom.id }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user