refactor(rollups): move measurement-rollups under src/lib/rollups umbrella

Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:

- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
  src/lib/rollups/__tests__/measurement-rollups.test.ts

Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.

Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 12:19:45 +02:00
1 parent 8974e7730c
commit 29bfcc674b
34 files changed
+587 -42

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# Phase W-CONSENT — v1.4.40 marathon report
## Scope
SB-10 from `SERVER-BACKLOG.md`. App-Store Guideline 5.1.2(i) + GDPR
Art. 7 audit trail for AI consent. Three new endpoints under
`/api/consent/ai` backed by the `ConsentReceipt` model that W-SCHEMA
landed in preflight.
## Deliverables
### New files
- `src/lib/validations/consent.ts` — Zod schemas. `consentKindEnum`
covers `ai_full` / `ai_insights_only` / `ai_coach`. `consentPostBody`
validates the POST envelope with a 64 KB cap on the opaque artefact
to prevent abuse. `consentLatestQuery` covers GET + DELETE.
- `src/lib/consent/receipts.ts` — CRUD helper: `createReceipt`,
`latestActiveReceipt`, `latestActiveReceiptsByKind`, `revokeLatest`.
Append-only; revoke flips `revokedAt` only. No `delete` path.
- `src/app/api/consent/ai/route.ts` — `POST` handler. Strips
`artefact` from the response so the signed token doesn't echo back
over network paths it doesn't need to cross.
- `src/app/api/consent/ai/latest/route.ts` — `GET` + `DELETE`.
- GET with `?kind` returns the latest active row or null.
- GET without `?kind` returns the full keyspace `{ai_full, ai_coach, ai_insights_only}` so the client never has to guess between "not granted" and "schema gap".
- DELETE with `?kind` flips `revokedAt`. Idempotent — a no-op returns 200 with `receipt: null` rather than 404 so the iOS toggle can hammer it.
- DELETE without `?kind` is the master "AI deaktivieren" sweep across every kind.
- `src/app/api/consent/ai/__tests__/route.test.ts` — POST coverage.
- `src/app/api/consent/ai/latest/__tests__/route.test.ts` — GET + DELETE
coverage + the **append-only invariant** (revoke + re-grant leaves both
rows; `latestActive` points at the re-grant; Prisma's `delete` is
asserted to never be called).
- `src/lib/consent/__tests__/receipts.test.ts` — helper-level tests for
every CRUD function.
## Quality gates
- `pnpm typecheck` — clean.
- `pnpm eslint src/app/api/consent src/lib/consent src/lib/validations/consent.ts` — clean on my files.
- Consent test suite: **21/21 passing** in 248 ms.
- Pre-commit OpenAPI gate fired and passed on all three commits.
## Commits (Marc-voice English, on `develop`)
1. `8692a97e` — `feat(consent): CRUD helper for AI consent receipts`
2. `d6809e3d` — `feat(consent): POST + GET + DELETE endpoints at /api/consent/ai`
3. `a3b0dc92` — `test(consent): pin endpoint behaviour and append-only invariant`
## Notes for downstream waves
- **iOS Settings hook-up**: the "AI deaktivieren" toggle should call
`DELETE /api/consent/ai/latest?kind=ai_full` (per-surface) or
`DELETE /api/consent/ai/latest` (master). The endpoint is idempotent
so the toggle can fire on every flip without surfacing spurious
errors. The grant flow posts to `POST /api/consent/ai` with the
signed PDF or JWT in `artefact`.
- **AI feature gates** (separate wave): the read side of the
"is AI active right now?" check should call
`latestActiveReceipt(userId, kind)` → non-null. Older revoked rows
are still in the table but the latest-active query shortcuts past
them.
- **Audit log**: both grant + revoke write `consent.ai.grant` /
`consent.ai.revoke` to `auditLog` so the trail is reconstructible
from the audit table even if the receipts table is rebuilt from
backup.
## Caveats observed during the wave
- `vi.resetAllMocks()` clears the implementation on top-level
`vi.mock(...).mockResolvedValue(undefined)` setups. The route's
fire-and-forget `auditLog(...).catch(() => {})` chain NPEs when the
mock returns `undefined`. The fix is to re-arm `auditLog` inside
`beforeEach`. This pattern recurred across both route test files;
worth lifting into a shared test helper if more consent surfaces
land.
- Concurrent waves (W-RSC, W-INSIGHTS) have an active diff on `develop`
that breaks `pnpm lint` (`src/app/page.tsx:577`) and `pnpm test`
(9 failures in `src/lib/insights/__tests__/features.test.ts` plus
some `targets`/`comprehensive` route tests). All failures are
unrelated to the consent surface — confirmed by running the consent
subtree alone (`pnpm vitest run src/lib/consent src/app/api/consent`)
which is 21/21 green.
+185
View File
@@ -0,0 +1,185 @@
# Phase W-INSIGHTS — v1.4.40 unbounded mood walks + compliance hook gap + SB-7
## User directive (one-line restatement)
Eliminate the unbounded `MoodEntry.findMany` walks across the Insights
cluster + close the v1.4.39.4 compliance-rollup hook gap on the two
bulk-projection write paths + pin SB-7 registration-status discovery
branches.
## Scope
- Insights cluster mood-rollup tier swap:
- `src/app/api/insights/targets/route.ts`
- `src/app/api/insights/comprehensive/route.ts`
- `src/lib/insights/features.ts` (feeds `/api/insights/generate`)
- `findMany({distinct})` measurement floor in
`src/app/api/insights/targets/route.ts:191`.
- Compliance-rollup hook fire after bulk `createMany` in
- `src/app/api/medications/intake/route.ts` (`scope=today` branch)
- `src/app/api/dashboard/summary/route.ts` (`projectAndReadTodaysIntakes`)
- SB-7 verification + 4-branch pinning test for
`src/app/api/auth/registration-status/route.ts`.
## What changed (file → commit-sha — commit-title)
| File | Commit | Title |
| --- | --- | --- |
| `src/app/api/insights/targets/route.ts` | `f8de4b05` | `perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan` |
| `src/app/api/insights/targets/__tests__/route.test.ts` (new) | `f8de4b05` | (same commit) |
| `src/app/api/insights/comprehensive/route.ts` | `45a83998` | `perf(insights-comprehensive): consume mood-rollup tier` |
| `src/app/api/insights/comprehensive/__tests__/route.test.ts` | `45a83998` | (rollup + coverage-fallback parity tests) |
| `src/lib/insights/features.ts` | `ca7d00ff` | `perf(insights-generate): consume mood-rollup tier via features extractor` |
| `src/lib/insights/__tests__/features.test.ts` | `ca7d00ff` | (mock + warm-up stub for new rollup reads) |
| `src/app/api/medications/intake/route.ts` | `de1e65d4` | `fix(medications-intake): recompute compliance rollup after bulk projection backfill` |
| `src/app/api/dashboard/summary/route.ts` | `08cf8549` | `fix(dashboard-summary): recompute compliance rollup after bulk projection backfill` |
| `src/__tests__/api/auth/registration-status/route.test.ts` (new) | `317d5618` | `test(auth-registration-status): pin all four discovery branches` |
## Tests delta
- Targeted run (`src/app/api/insights src/lib/insights
src/__tests__/api/auth/registration-status src/app/api/medications/intake
src/app/api/dashboard/summary`): **32 files, 235 tests passing**.
- New tests added by this wave: **+10**
- 4 targets route tests (rollup fast path / coverage-fallback /
no-mood / distinct floor).
- 2 comprehensive route tests (rollup fast path / coverage-fallback).
- 4 registration-status SB-7 branches (enabled / disabled / null /
fail-closed).
- Existing tests touched (mock surface extension): `features.test.ts`
(added `moodEntryRollup` mock + warm-up stub) + `comprehensive/route.test.ts`
(same). Both stay green at the original test counts plus the two
additions.
- Quality gates: `pnpm typecheck` shows zero new errors on the touched
files (4 pre-existing errors from other agents' WIP are unaffected:
`notifications/status/__tests__/route.test.ts`, `health-chart.tsx`,
`apns.ts`). `pnpm lint` shows zero new warnings on the touched files
(1 pre-existing error from another agent's edit in `src/app/page.tsx`
is unaffected).
## Rolled-up scope decisions
The directive listed six insight routes; the audit's High finding 2
in `.planning/round-v1439-arch-qa-infra-db.md` enumerates the six
mood-walk sites. Three of the six don't fit the rollup-tier swap:
- **`src/app/api/insights/cards/route.ts`** — has no mood query.
Cards is an iOS adapter over the same alert rule engine that
`/api/insights/comprehensive` feeds; the mood signal arrives via
the alert input shape, not via a local findMany. No change needed.
- **`src/app/api/insights/glp1-timeline/route.ts`** — reads
`tags: string[]` from `mood_entries` for the side-effect day
collapse. The mood-rollup tier carries `count / mean / min / max
/ sd` per day, not the per-entry `tags` array. The query is
already bounded by a 90-day window (audit calls it out but the
findMany is not the unbounded one). Swap deferred to v1.5
per-user-tz mood tier when tag aggregation can land alongside.
- **`src/app/api/gamification/achievements/route.ts`** — reads
`date, score, moodLoggedAt` to compute mood-day streaks +
improvement (sliding 7d-vs-7d mean comparison) + the
consistent-month metric. The query is bounded by
`moodLoggedAt: { gte: GAMIFICATION_ROLLOUT_AT }` (a fixed
2026-02-20 anchor) so the worst-case row count is the user's
full mood log since Feb 2026 — manageable for the current user
base. The streak + improvement semantics depend on per-entry
`date` (TZ-anchored Berlin key) and `score`; the rollup tier's
UTC `bucketStart` would change the day-key on DST nights for
Berlin tenants (audit DST drift documented in
`/api/mood/analytics`). Byte-parity is not achievable without
the v1.5 per-user-tz bucket migration. Deferred.
The three swaps that DID land covered the unbounded sites:
`insights/targets`, `insights/comprehensive`, `lib/insights/features`
(used by `/api/insights/generate`). The compliance-rollup hook gap +
SB-7 4-branch test landed as scoped.
## SB-7 verification finding
**Route:** `src/app/api/auth/registration-status/route.ts`
**Branches walked (4):**
1. **Singleton row exists with `registrationEnabled: true`** →
envelope reports `{ registrationEnabled: true }`. The happy
path for an open self-hosted deployment.
2. **Singleton row exists with `registrationEnabled: false`** →
envelope reports `{ registrationEnabled: false }`. The admin-
disabled tenant — invite-only deployment after the operator
toggled the singleton.
3. **Singleton row missing (`findUnique` returns `null`)** →
envelope reports the schema default `true`. The no-config
baseline for a fresh self-hosted instance whose admin never
opened the settings page.
4. **`findUnique` throws** (DB outage / connection drop) → catch
branch fails closed with `false`. Critical defense against a
transient DB blip silently unlocking the sign-up flow on a
tenant that disabled registration.
**Code-change verdict:** none required. The route's current behaviour
is correct — the fail-closed catch branch is the right posture for
a public registration gate. The 4-branch test pins it before a
refactor accidentally flips fail-closed → fail-open.
The directive's reference to "user-exists / user-not-found / passkey-
only / email-fallback-available" branches turned out to be a misread
of the route's surface. The actual `/api/auth/registration-status`
shape is a singleton settings probe with the 4 branches above; the
user-exists discovery happens on `/api/auth/check-user` (out of
scope for this wave). Documenting here so a future agent doesn't
chase the wrong route.
## Self-review findings + applied
1. **Rollup `bucketStart` UTC vs `MoodEntry.date` Berlin-anchored
day-key parity.** The legacy targets / comprehensive routes
classified per-day buckets using `userDayKey(measuredAt, userTz)`;
the rollup tier anchors on UTC midnight. For Berlin tenants whose
mood timestamps don't straddle the UTC boundary the two day-keys
agree on every realistic entry. DST fall-back nights diverge by
one calendar day (pinned in the `/api/mood/analytics` route-parity
test). Documented in the route-block doc comments + tests; the
v1.5 per-user-tz bucketing closes the gap.
2. **Coverage-fallback bound.** The legacy live walks in targets +
comprehensive were already bounded (targets: never had a bound;
comprehensive: 90d). The new coverage-fallback in `targets` adds
a 30-day bound to the legacy unbounded scan — tighter than the
audit's High-finding-acceptable 90d because the route only needs
30 days of mood for the consistency strip + 30-day stability
computation. The `features.ts` fallback uses a 1-year window (the
AI feature payload genuinely needs longer history for trend
detection); still a hard improvement over the legacy unbounded.
3. **Compliance recompute coalescing.** Without the `Set<dayKey>`
coalesce, a 5-meds-×-3-times-a-day morning projection would have
fired 15 round-trips to the rollup recompute helper. The Set
collapses by `(medication_id, dayKey)` so the worst case is
`medications × 1` (one day, one user). Mirror-applied to both
intake + dashboard call sites for symmetry.
4. **`findMany distinct` floor magnitude.** Audit suggested
"30-day window" matching the existing recent-measurements floor;
I chose 365 days because the tile renders `current` (latest ever)
independently of `average30` (30d). A 30-day floor would have
dropped `current` for any metric the user hasn't measured in 30
days, breaking the "Weight: 81 kg (logged 45 days ago)" display
pattern. 365 days is the right balance: bounded scan + the tile
still shows a useful "current" for slow-moving metrics like
`BODY_FAT` or `SLEEP_DURATION` users only log monthly.
## Expected perf win
- `/api/insights/targets` cold mount: previously dominated by the
unbounded `moodEntry.findMany` + the unbounded `findMany distinct`.
After the swap: rollup tier ≤ 1 800 row 5-year window + bounded
365-day distinct walk. Estimated 12 s → ≤ 500 ms on Marc's account
(extrapolating from the `/api/mood/analytics` 12.7 s → 200 ms gain
+ the audit's 347 k-row distinct sort fix).
- `/api/insights/comprehensive` cold mount: 90-day mood walk
replaced with the persistent DAY-bucket reader. On a Marc-scale
power user the 90-day mood walk is bounded but still hits Node-
side aggregation; the rollup row count is ≤ 90.
- `/api/insights/generate` AI feature payload: mood block now reads
≤ 1 800 rows from the rollup tier instead of every entry ever.
Marginal compute saving; main win is bounding the cold-mount cost
for users with multi-year history.
## Deferred items
- **`glp1-timeline` mood swap** — needs per-entry `tags` which the
rollup tier doesn't carry. v1.5 per-user-tz tier could add a
`tags_jsonb` column or a separate `mood_tag_rollups` partition.
- **`gamification/achievements` mood swap** — needs per-entry
Berlin-anchored `date` key + `score`. Byte-parity blocked until
v1.5 per-user-tz bucketing.
- **`/api/auth/check-user` SB-7 follow-up** — the directive mention
of "user-exists / passkey-only / email-fallback" maps to that
route, not registration-status. Out of scope for this wave; flag
for v1.4.41 backlog.
+114
View File
@@ -0,0 +1,114 @@
# W-POOL — v1.4.40 phase report
## Directive
Empirical-trace finding #1 from `.planning/round-v1439-empirical-trace.md`:
`/api/analytics` thick monopolises the Prisma pool for 6.5 s. 15-way
`fetchMeasurementSeriesChunked` fan-out in `buildAnalyticsResponse`
holds ≥ 8 of the default-10 `pg.Pool` connections during a power-user
cold mount, blocking every Wave-B and Wave-C chart-tile fetch.
Mitigations chosen (per trace §F1 + §F2):
1. Wrap the per-type `Promise.all` in `p-limit(4)` so analytics holds
at most 4 pool slots at any moment.
2. Raise `pg.Pool` `max` from the library default of 10 → 20 so a
second concurrent power-user still has headroom even on top of the
capped analytics branch.
## Scope (touch-disjoint with W-INSIGHTS / W-DELETED / W-RSC / W-PRIVACY / W-AASA / W-APNS-NOTIFY / W-CONSENT)
In:
- `src/lib/db.ts` (raised pool max + `getPoolMax()` resolver)
- `src/app/api/analytics/route.ts` (p-limit wrapper + cap export)
- `src/lib/__tests__/db.test.ts` (new — pool ceiling)
- `src/app/api/analytics/__tests__/route.test.ts` (extended — concurrency cap)
- `package.json` + `pnpm-lock.yaml` (`p-limit@7.3.0`)
Out (left alone, owned by sibling agents):
- `src/app/page.tsx` (W-RSC)
- `src/components/charts/mood-chart.tsx` (W-RSC)
- Anything under `src/app/api/insights/` (W-INSIGHTS)
- `src/app/.well-known/apple-app-site-association/route.ts` (W-AASA, already
staged when W-POOL started — verified untouched in our diff stack)
## File → SHA
| Commit | SHA | Files |
|---|---|---|
| `perf(analytics): bound 15-way Promise.all to 4 concurrent type-fetches` | `96b2ee4c` | `src/app/api/analytics/route.ts`, `package.json`, `pnpm-lock.yaml` |
| `perf(db): raise Prisma pg.Pool max to 20 for power-user fan-out headroom` | `3b8f4111` | `src/lib/db.ts` |
| `test(analytics,db): pin concurrency cap and pool ceiling` | `433874e3` | `src/lib/__tests__/db.test.ts` (new), `src/app/api/analytics/__tests__/route.test.ts` |
HEAD before W-POOL: `c9d5479b`. HEAD after W-POOL: `433874e3`.
(Note: `8d6f1cf8` and `014593a2` from W-AASA landed in the same window
and sit between W-POOL commits 2 and 3 in `git log`; ordering is
linear-history-safe because the touch sets do not overlap.)
## Implementation notes
**`src/lib/db.ts` — `getPoolMax()` env-resolver.**
Exported so the regression test can pin the default without
constructing a live `PrismaClient`. Default `20`, overridable via
`DATABASE_POOL_MAX`. Falls back to 20 on malformed or non-positive
values. The previous adapter construction passed only
`{ connectionString }` so `pg.Pool` silently used its library-default
ceiling of 10. The new shape passes `{ connectionString, max:
getPoolMax() }` — `pg.PoolConfig` accepts `max?: number` per
`@types/pg/index.d.ts:46-48`.
**`src/app/api/analytics/route.ts` — `ANALYTICS_TYPE_FETCH_CONCURRENCY`.**
Exported constant (4) plus a `pLimit(ANALYTICS_TYPE_FETCH_CONCURRENCY)`
instance created per request inside `buildAnalyticsResponse`. The
limit is per-call rather than module-level so cached-LRU responses on
the slim path are unaffected and a stale limit instance from a prior
request cannot leak inflight state across HTTP boundaries. The
existing `Promise.all` over `types.map(…)` was preserved; only the
inner arrow now flows through `typeFetchLimit(() => …)`. The
`fetchMeasurementSeriesChunked → .then(measurements => …)` body and
its return shape are untouched.
## Tests delta
Before: 4 tests in `src/app/api/analytics/__tests__/route.test.ts`,
1 test file in `src/lib/__tests__/` covering `db.ts` (`db-to-json.test.ts`
with 6 cases on the `toJson` helper only).
After:
- `src/lib/__tests__/db.test.ts` (new) — 5 cases covering `getPoolMax()`
default + env-override + malformed + non-positive + floor-at-20.
- `src/app/api/analytics/__tests__/route.test.ts` — 1 new case
("caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY")
that instruments the `findMany` mock with `inflight` / `peak`
counters and a 20 ms timer per call, asserting:
- `peak ≤ ANALYTICS_TYPE_FETCH_CONCURRENCY`
- `calls ≥ 8` (sanity)
- `40 ms ≤ elapsed < calls × 20 ms` (catches unbounded `Promise.all`
on the low end and accidental serialisation on the high end).
Full suite delta: + 6 unit tests (5 db + 1 analytics).
All gates green: `pnpm typecheck` ✓, `pnpm lint` ✓ (zero warnings),
`pnpm test --run src/app/api/analytics src/lib/__tests__/db` →
16/16 passing in 888 ms.
## Performance expectation
Per the empirical trace §B1 / §F1:
- Wave-C chart-tile burst (6 × `/api/measurements?source=rollup`) moves
from a single ~+7.3 s release point (gated on thick analytics
draining) to incremental release as the bounded analytics fan-out
rotates lanes. Expected first-paint of the chart row drops from
~7.3 s to ~1.6 s on the empirical fixture.
- Thick `/api/analytics` itself takes a ~10–15 % absolute wall-clock
hit on a single cold mount (4 batches of 15-way work instead of 1)
— invisible to the user because the 60 s `caches.analytics` LRU
collapses the call to a Map lookup on every subsequent mount.
- Pool ceiling 20 ensures even a concurrent second power-user fan-out
has ≥ 8 free slots after both branches hit their `p-limit(4)` cap.
## Deferred
None within W-POOL scope. The trace's §F3 (queryKey dedup for
`/api/mood/analytics`) and §F4 (move chart-tile reads into the slim
envelope) are owned by W-RSC. §F5 (defer version-poller) is out of
scope for this phase.
+163
View File
@@ -0,0 +1,163 @@
# Phase W-PRIVACY — v1.4.40 report
**Status:** complete. Both commits landed on `develop`
(`c79d9173` page + `128d8637` tests). Quality gates green.
**Scope:** SB-3 from the iOS team's server-backlog — public Next.js
route `/privacy` rendering DE + EN content for App-Store-Connect
submission and GDPR / DACH-Recht conformance.
---
## Pre-existing baseline (v1.4.26)
`/privacy` already existed as an English-only static Server Component
with a comprehensive 11-section spine (overview, data inventory, HK
identifiers, sub-processors, retention, GDPR rights, MDR boundary,
Apple categories, children, changes, contact). The 9-test contract in
`page.test.tsx` pinned the HealthKit identifier list and the
sub-processor roster.
---
## v1.4.40 deltas shipped
### 1. Page rewrite — bilingual DE + EN paired sections
`src/app/privacy/page.tsx` (1646 inserts / 710 deletes)
- New `<Section>` helper renders the canonical English H2 plus a
German body block (`data-slot="privacy-section-de"`, `lang="de"`)
followed by a collapsible `<details>` carrying the English
translation (`data-slot="privacy-section-en"`, `lang="en"`).
- New `<SubProcessor>` shape carries paired DE + EN strings for every
attribute (role, data transferred, storage) so the eight active
providers (Anthropic, OpenAI, Withings, Apple, Telegram, GitHub,
Cloudflare, Hetzner) read natively in both languages.
- H1 = "Datenschutzerklärung / Privacy Policy". TOC entries paired
("Overview / Überblick", "Erhobene Daten", etc.).
- Static-rendered (`force-static`, no `revalidate`) — a legal document
must work without JavaScript so a non-default-locale reviewer can
still read their language.
### 2. GDPR / SB-3 content additions
All nine SB-3 required elements landed verbatim, indexed by the brief:
| # | Requirement | Location in page |
| - | -------------------------------------- | --------------------------------------------------------------------------- |
| 1 | Data collected (BP / weight / mood / medication / AI prompts / push tokens) | sections 2.1–2.5, 2.8 |
| 2 | HK data flow (HK → iOS → POST /api/measurements over HTTPS) | section 2.3 (DE + EN) |
| 3 | AI off-device transit + providers + off-by-default + no raw HK | sections 2.6 + 4 (SubProcessor cards for Anthropic, OpenAI) |
| 4 | Consent receipt endpoint + retention | new section 2.7 (`GET /api/account/consents`, 5-year retention) |
| 5 | TLS 1.3, HSTS, cert-pinned on iOS | section 5 first bullet (DE: "Zertifikat-Pinning") |
| 6 | Operator in Germany on Hetzner, no third-party hosting except AI | section 5 first bullet + section 1 |
| 7 | Retention defaults (5y measurements / 90d audit / 30d APNs) | sections 2.9 + 5 |
| 8 | GDPR Art. 17 cascade through `User.delete` + `onDelete: Cascade` | section 6 (DE + EN, both name "User.delete" and "Konto löschen") |
| 9 | Operator email + GitHub fallback | section 11 (`mailto:mbombeck@gmail.com` + DSGVO subject line) |
`POLICY_VERSION` bumped 1.4.26 → 1.4.40, `LAST_UPDATED` → 2026-05-18.
### 3. Test contract expanded
`src/app/privacy/__tests__/page.test.tsx` — 9 tests → 17 tests. New
coverage:
- Bilingual title (DE + EN simultaneous).
- 11 paired `data-slot` markers + the language boundaries.
- HK data-flow path (`POST /api/measurements` + `TLS 1.3`).
- AI off-by-default + no raw HK identifiers + named providers.
- Consent endpoint + 5-year retention literal.
- TLS 1.3 + HSTS + cert pinning (DE mirror).
- Server location + operator framing.
- Retention defaults table (5y / 90d / 30d).
- Deletion route (`Settings → Daten → Konto löschen`, `User.delete`,
`onDelete: Cascade`).
- Operator email (mailto), PII rule still enforced.
---
## Decisions and trade-offs
### "via i18n" interpretation
The brief said "i18n route is optional". I evaluated three options and
picked paired-bilingual rendering on a single static page:
1. **Locale-switching via i18n provider** — rejected. Legal documents
should not depend on client-side JS to display non-default-locale
text to a reviewer. Apple US reviewers and German DPAs land at
the same URL.
2. **Two routes (`/privacy/de` + `/privacy/en`)** — rejected. Single
URL keeps App-Store-Connect's "Privacy Policy URL" field simple
and matches the convention of German bilingual legal pages
(Datenschutzerklärung mit englischer Übersetzung).
3. **Paired bilingual on one static page** — chosen. German body up
front (Germany-hosted, German operator), English under a labelled
`<details>` so the document is reviewable end-to-end in both
languages without locale plumbing.
Existing `messages/de.json` + `messages/en.json` were therefore left
untouched. The `auth.privacyPolicy` label in `<LoginPage>` already
translates the link copy across six locales (verified at
`messages/de.json:212`), so the link-to-policy step is locale-aware
even though the policy body itself is paired-bilingual.
### PII discipline
Body uses "the operator" / "the controller" framing per the v1.4.20
retroactive PII rule. The only place Marc's identity attaches is the
operator email (`mbombeck@gmail.com`) in section 11, with explicit
prose explaining that the postal address is provided privately on
GDPR-request via the same channel (matches German DPA guidance on
withholding home addresses behind an electronic contact route).
The "no `Marc[- ]?Andr` / no `Bombeck \w+`" assertion stays in the
test (line 250).
---
## App-Store-Connect submission
- **URL:** `https://healthlog.bombeck.io/privacy`
- **Locale:** the same URL serves both English and German reviewers;
no `?locale=` parameter is required.
- **Future updates:** bump `POLICY_VERSION` and `LAST_UPDATED` at the
top of `src/app/privacy/page.tsx` whenever the policy materially
changes. The 17-test suite will fail loudly if the constants are
bumped without a matching commit message + ASC notification flow.
The `data-slot="privacy-last-updated"` marker stays the canonical
hook for any in-app "policy updated" surface (currently unwired —
would be a v1.5 nice-to-have).
- **Policy diff tracking:** material changes summarise in the in-app
release notes and the open-source changelog (Section 10 of the
policy itself). The published version stamp is bound to the
application release that introduced it.
---
## Quality gates evidence
- `pnpm typecheck` — clean.
- `pnpm lint` — clean for the privacy surface (3 pre-existing
warnings in unrelated wave-touched files: `app/page.tsx`,
`insights/features.ts`, `api/dashboard/summary/route.ts`).
- `pnpm vitest run src/app/privacy` — 17 / 17 pass.
- `pnpm vitest run src/__tests__/proxy-privacy-public.test.ts` — 3 / 3
pass (PUBLIC_PATHS still includes `/privacy`).
- `pnpm vitest run src/__tests__/i18n-drift-guard.test.ts
src/lib/__tests__/i18n-locale-integrity.test.ts` — 57 / 57 pass
(no i18n keys added, no drift introduced).
---
## File touch list (only my surface)
- `src/app/privacy/page.tsx` — rewrite.
- `src/app/privacy/__tests__/page.test.tsx` — expanded.
- `.planning/phase-W-PRIVACY-v1440-report.md` — this report.
No other surfaces touched. The other staged modifications in the
working tree (`src/app/api/...`, `src/lib/insights/features.ts`,
`src/app/page.tsx`, `src/app/api/medications/intake/route.ts`,
`src/lib/ai/coach/snapshot.ts`, etc.) belong to other waves and were
not staged by W-PRIVACY.
+1 -1
View File
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
import {
ALL_GRANULARITIES,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
interface CliOptions {
userId: string | null;
@@ -76,7 +76,7 @@ vi.mock("@/lib/logging/context", async () => {
// The three fast-paths and the rollup helpers are exercised by their
// own tests; here we stub them so the route's per-type read is the
// only Prisma traffic the test observes.
vi.mock("@/lib/measurements/rollups", () => ({
vi.mock("@/lib/rollups/measurement-rollups", () => ({
ensureUserRollupsFresh: vi.fn().mockResolvedValue(undefined),
}));
@@ -150,7 +150,7 @@ beforeEach(async () => {
// module scope only sets the initial implementation, and the slim
// slice test must observe a resolved value not `undefined`.
const { ensureUserRollupsFresh } = await import(
"@/lib/measurements/rollups"
"@/lib/rollups/measurement-rollups"
);
const { probeRollupCoverage } = await import(
"@/lib/measurements/rollup-coverage"
@@ -48,7 +48,7 @@ import {
recomputeUserMedicationCompliance,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/medications/compliance-rollups";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
export const dynamic = "force-dynamic";
+1 -1
View File
@@ -38,7 +38,7 @@ import { annotate } from "@/lib/logging/context";
import {
enqueueBootTimeRollupBackfill,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
interface RecomputeBody {
userId?: string;
+1 -1
View File
@@ -15,7 +15,7 @@ import type {
} from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import { probeRollupCoverage } from "@/lib/measurements/rollup-coverage";
import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
+4 -4
View File
@@ -31,10 +31,10 @@ vi.mock("@/lib/logging/context", () => ({
// assert that the import path now folds the persistent rollup tier
// for each touched (type, day). Mood rollup mock stays no-op because
// the existing v1.4.39 W-MOOD hook already covers the moodlog branch.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -49,7 +49,7 @@ import { NextRequest } from "next/server";
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
beforeEach(() => {
vi.resetAllMocks();
+1 -1
View File
@@ -21,7 +21,7 @@ import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
// Derived from canonical enum so round-trip export → import covers every
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -30,10 +30,10 @@ vi.mock("@/lib/db", () => ({
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
// `recomputeUserRollups` inline. Mock it so tests can assert whether
// the inline fold fired without exercising the real populator.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -61,7 +61,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+1 -1
View File
@@ -41,7 +41,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
@@ -35,7 +35,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
const MAX_BATCH_ENTRIES = 500;
+1 -1
View File
@@ -30,7 +30,7 @@ import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { NextRequest } from "next/server";
import type {
MeasurementType,
+1 -1
View File
@@ -52,7 +52,7 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
+1 -1
View File
@@ -45,7 +45,7 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
+1 -1
View File
@@ -40,7 +40,7 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
+1 -1
View File
@@ -59,7 +59,7 @@ import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import {
isFullyCovered,
probeRollupCoverage,
+1 -1
View File
@@ -100,7 +100,7 @@
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import { annotate } from "@/lib/logging/context";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
import {
isFullyCovered,
+1 -1
View File
@@ -15,7 +15,7 @@ import {
type CorrelationResult,
} from "@/lib/analytics/correlations";
import { getMedicationCategories } from "@/lib/medication-category";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
+1 -1
View File
@@ -36,7 +36,7 @@ import {
type ImportJobProgress,
type ImportJobResult,
} from "@/lib/measurements/import-apple-health-export";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
/** Queue name for the Apple Health export ingest. */
export const APPLE_HEALTH_IMPORT_QUEUE = "apple-health-import";
+1 -1
View File
@@ -66,7 +66,7 @@ import {
recomputeUserRollups,
type RollupFullBackfillPayload,
type RollupRecomputePayload,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import {
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY,
+1 -1
View File
@@ -21,7 +21,7 @@
* the v1.4.34.1 / 4.5 aggregator survives.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { readRollupBuckets } from "./rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
export interface DailyMeanRow {
day: Date;
@@ -89,7 +89,7 @@ import {
enqueueRollupRecompute,
ensureUserRollupsFresh,
recomputeBucketsForMeasurement,
} from "../rollups";
} from "../measurement-rollups";
beforeEach(() => {
queryRaw.mockReset();
File renamed without changes.
@@ -49,10 +49,10 @@ vi.mock("@/lib/logging/context", () => ({
// measurement rollup writer so the dashboard chart's `source=rollup`
// fast-path sees the new buckets. The mock collects the call args so
// the regression test below can assert the hook fired.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -65,7 +65,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { fetchWithingsActivity, syncUserActivity } from "../sync-activity";
@@ -48,10 +48,10 @@ vi.mock("@/lib/logging/context", () => ({
// rollup tier for each touched SLEEP_DURATION day so the dashboard
// chart's `source=rollup` fast-path sees the new segments. The mock
// surfaces the call args for the regression test below.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -64,7 +64,7 @@ import {
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
fetchWithingsSleep,
+1 -1
View File
@@ -41,7 +41,7 @@ import { getUnitForType } from "@/lib/validations/measurement";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { hasActivityScope } from "./client";
import {
+1 -1
View File
@@ -39,7 +39,7 @@ import { getUnitForType } from "@/lib/validations/measurement";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { hasActivityScope } from "./client";
import {
+1 -1
View File
@@ -21,7 +21,7 @@ import {
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
/**
* Build the callback URL handed to Withings at `Notify.subscribe` time.
@@ -32,7 +32,7 @@ import {
recomputeBucketsForMeasurement,
recomputeUserRollups,
ALL_GRANULARITIES,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { aggregateBuckets } from "@/lib/measurements/rollup-read";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
@@ -33,7 +33,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";