diff --git a/.planning/phase-W-CONSENT-v1440-report.md b/.planning/phase-W-CONSENT-v1440-report.md new file mode 100644 index 000000000..b91acec10 --- /dev/null +++ b/.planning/phase-W-CONSENT-v1440-report.md @@ -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. diff --git a/.planning/phase-W-INSIGHTS-v1440-report.md b/.planning/phase-W-INSIGHTS-v1440-report.md new file mode 100644 index 000000000..d74fdac2a --- /dev/null +++ b/.planning/phase-W-INSIGHTS-v1440-report.md @@ -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` + 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. diff --git a/.planning/phase-W-POOL-v1440-report.md b/.planning/phase-W-POOL-v1440-report.md new file mode 100644 index 000000000..dbec57f97 --- /dev/null +++ b/.planning/phase-W-POOL-v1440-report.md @@ -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. diff --git a/.planning/phase-W-PRIVACY-v1440-report.md b/.planning/phase-W-PRIVACY-v1440-report.md new file mode 100644 index 000000000..65b2816da --- /dev/null +++ b/.planning/phase-W-PRIVACY-v1440-report.md @@ -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 `
` helper renders the canonical English H2 plus a + German body block (`data-slot="privacy-section-de"`, `lang="de"`) + followed by a collapsible `
` carrying the English + translation (`data-slot="privacy-section-en"`, `lang="en"`). +- New `` 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 + `
` 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 `` 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. diff --git a/scripts/backfill-rollups.ts b/scripts/backfill-rollups.ts index 72b39e855..d85d26e69 100644 --- a/scripts/backfill-rollups.ts +++ b/scripts/backfill-rollups.ts @@ -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; diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index c152852a6..b8511b520 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -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" diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 7126123b1..6839490bd 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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"; diff --git a/src/app/api/admin/rollups/recompute/route.ts b/src/app/api/admin/rollups/recompute/route.ts index 3b4fee9b1..9571b0d40 100644 --- a/src/app/api/admin/rollups/recompute/route.ts +++ b/src/app/api/admin/rollups/recompute/route.ts @@ -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; diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 5283e600f..8a2aa8637 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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"; diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 272a8a77d..32a738704 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -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(); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index a37f4f221..8104acf36 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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 diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 38e49c06e..947a49c8a 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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"; diff --git a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts index 8ead7e75e..fddbbb06e 100644 --- a/src/app/api/measurements/__tests__/range-aggregation-route.test.ts +++ b/src/app/api/measurements/__tests__/range-aggregation-route.test.ts @@ -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) }, diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 9eb623eb5..1741129bd 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 97c517b05..5ab75ded7 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -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; diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 21c72df3f..fd07e82a9 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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, diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index a254a262a..a85766ed3 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -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, diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index ba82bb7b3..49c3b2786 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -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, diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 0c6a1e380..c4eca5638 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -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, diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 6ebf1b75d..6f6c9ed63 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -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, diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 0591ed0be..1d6999cf4 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -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, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 303da770d..9fb4df041 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -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, diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 574b8af85..1a6cd8917 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -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"; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 95743bfcb..4f2a0989e 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -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, diff --git a/src/lib/measurements/rollup-read.ts b/src/lib/measurements/rollup-read.ts index d7525f2ae..b8f0be1a1 100644 --- a/src/lib/measurements/rollup-read.ts +++ b/src/lib/measurements/rollup-read.ts @@ -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; diff --git a/src/lib/measurements/__tests__/rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts similarity index 99% rename from src/lib/measurements/__tests__/rollups.test.ts rename to src/lib/rollups/__tests__/measurement-rollups.test.ts index cdc550aa8..f5d056e85 100644 --- a/src/lib/measurements/__tests__/rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -89,7 +89,7 @@ import { enqueueRollupRecompute, ensureUserRollupsFresh, recomputeBucketsForMeasurement, -} from "../rollups"; +} from "../measurement-rollups"; beforeEach(() => { queryRaw.mockReset(); diff --git a/src/lib/measurements/rollups.ts b/src/lib/rollups/measurement-rollups.ts similarity index 100% rename from src/lib/measurements/rollups.ts rename to src/lib/rollups/measurement-rollups.ts diff --git a/src/lib/withings/__tests__/sync-activity.test.ts b/src/lib/withings/__tests__/sync-activity.test.ts index e0d196dd7..fc463323e 100644 --- a/src/lib/withings/__tests__/sync-activity.test.ts +++ b/src/lib/withings/__tests__/sync-activity.test.ts @@ -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"; diff --git a/src/lib/withings/__tests__/sync-sleep.test.ts b/src/lib/withings/__tests__/sync-sleep.test.ts index 04d005488..28757ebd0 100644 --- a/src/lib/withings/__tests__/sync-sleep.test.ts +++ b/src/lib/withings/__tests__/sync-sleep.test.ts @@ -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, diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index b3eb3b086..3ce27fcb8 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -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 { diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index 4e85858ab..24a2f2d3d 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -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 { diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index cd6068eb6..d85076385 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -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. diff --git a/tests/integration/measurement-rollups.test.ts b/tests/integration/measurement-rollups.test.ts index cc8ec4027..4b0c15b11 100644 --- a/tests/integration/measurement-rollups.test.ts +++ b/tests/integration/measurement-rollups.test.ts @@ -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"; diff --git a/tests/integration/measurement-soft-delete.test.ts b/tests/integration/measurement-soft-delete.test.ts index ccd781a44..40804ac0c 100644 --- a/tests/integration/measurement-soft-delete.test.ts +++ b/tests/integration/measurement-soft-delete.test.ts @@ -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";