diff --git a/AGENTS.md b/AGENTS.md index ceaec3adc..4c2b55b87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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[]` 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 `` 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index addeeb34e..d0fda05cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index de5399768..def1180f5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 6cca5848c..5115fa8d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 8d6d19c22..e97634d40 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index a241e5b8b..ffa1d745c 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -404,7 +404,6 @@ export async function buildFullBackupPayload( unit: n.unit, source: n.source, })), - manifest: records.manifest, }; return { diff --git a/tests/integration/backup-custom-cycle-symptom-restore.test.ts b/tests/integration/backup-custom-cycle-symptom-restore.test.ts new file mode 100644 index 000000000..be4709966 --- /dev/null +++ b/tests/integration/backup-custom-cycle-symptom-restore.test.ts @@ -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[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 }]); + }); +});