Commit Graph
224 Commits
Author SHA1 Message Date
Marc-André Bombeck 938d840670 fix(docker): bake CI tag into image, prefer it at runtime
The v1.4.42 docker-publish workflow shipped a stale package.json version
baked into the bundle because BuildKit reused the pnpm build layer
across the release tag bump -- the COPY . . layer key was content-stable
relative to the prior build, the layer was warm, and the runtime
/api/version read the cached package.json content instead of the new
tag.

Thread github.ref_name through as a NEXT_PUBLIC_APP_VERSION build-arg
from docker-publish.yml. The Dockerfile picks it up as an ARG/ENV in
both the builder and runner stages and bakes it into the build context.
src/app/api/version/route.ts now reads the env var first and falls
through to package.json only when unset (local pnpm dev). The build-arg
becomes part of the layer cache key, so every release tag invalidates
every downstream layer and the runtime version can never drift from
the tag the workflow ran on.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck f9cf396f2e feat(widgets): dedup validation-failed audit rows per user 60s
A misbehaving iOS client retrying a 422 in a tight loop could write
thousands of dashboard.widgets.validation-failed audit rows per minute.
Gate the audit-ledger write behind an in-process (userId, action) memo
with a 60 s window so the breadcrumb stays useful for grep traffic
without flooding the ledger. The 422 response itself is unchanged --
every retry still receives the full multi-issue envelope. Cluster
deployments cap at one row per process per minute; a process restart
resets the map (one extra row worst case).
2026-05-21 21:46:12 +02:00
Marc-André Bombeck b70cdcae48 fix(audit): drop identifier from auth.login.failed audit details
The user_not_found_or_no_password branch wrote the typed email/username
into AuditLog.details, leaking PII into an operator artefact. The reason
field already tells the operator everything they need; the identifier
adds no diagnostic value over hashing or dropping it. Per the v1.4.20
no-PII-in-operator-artefacts directive, drop the field entirely.

A regression test pins the absence of both the literal identifier value
and any "identifier" key in the audit-row JSON.
2026-05-21 21:44:30 +02:00
Marc-André Bombeck a5176f4be9 refactor(workouts): rename write-time picker to dedupeWorkoutBatch
The v1.4.42 W5 helper shipped under the same identifier
`pickCanonicalWorkoutRows` as the v1.4.30 read-time picker in
`src/lib/measurements/`. Same name in two modules with different
signatures (write-time takes no user-priority, read-time does) sets
up the next contributor to auto-complete the name in a new route
and silently invoke the wrong picker — no user-priority resolution
on a read, missing the 90-second window on a write.

Rename to `dedupeWorkoutBatch` so the write-time contract is
self-describing. The read-time picker keeps its established name.
One file's export + 12-case test suite + the batch-route caller
update — no algorithm change.
2026-05-21 19:26:35 +02:00
Marc-André Bombeck 6e119ed47b feat(workouts): write-time canonical-row dedup for cross-source batches
iOS HealthKit observer queue routinely carries the same logical workout
from two paired sensors (Apple Watch + Withings ScanWatch) in a single
ingest batch. Without write-time dedup both rows persist (their
externalIds differ, so the (userId, source, externalId) unique index
never fires) and the read-time picker has to carry the duplicate
forward on every dashboard render.

Add pickCanonicalWorkoutRows() at src/lib/workouts/canonical-rows.ts as
a pure pre-createMany pass invoked by POST /api/workouts/batch. Groups
by (userId, activityType, startedAt +-90 s); prefers the canonical
source ladder (APPLE_HEALTH > WITHINGS > MANUAL > IMPORT) shared with
the read-time helper; breaks ties on calories > earliest createdAt >
input order. Losers surface to the iOS client as duplicate so the sync
cursor advances past them identically to the externalId case.
2026-05-21 18:55:53 +02:00
Marc-André Bombeck 876a545d9c feat(api/dashboard): return every Zod issue on widgets PUT 422
The PUT /api/dashboard/widgets route surfaced only the first Zod issue
on 422, which forced iOS clients debugging the widget-layout contract
into one round-trip per wrong field. The route now wires the shared
returnAllZodIssues helper so every issue lands in details.issues at
once.

Adds an append-only audit-ledger breadcrumb keyed
dashboard.widgets.validation-failed so the operator can grep
/api/admin/audit for the same trail outside the iOS dev console. The
write is fire-and-forget — the validation response is the contract,
the audit row is best-effort.

Tests cover 2 + 3 simultaneous issues, audit-row shape, params-leak
guard, and audit-write-rejection survival on both the unit and
integration tiers.
2026-05-21 18:52:41 +02:00
Marc-André Bombeck 9d8e0d7a07 test(soft-delete): pin gamification/achievements and doctor-report/availability
The W-DELETED-2 integration-test file header promises coverage for five
reader tiers; v1.4.41 shipped pin assertions for three (`/api/export`,
`/api/export/full-backup`, `/api/export/measurements`) but never asserted
the two routes that the W-DELETED-2 fix actually touched:

- `/api/gamification/achievements` — reads recent measurements via
  `findMany` to compute streak / badge progress. A tombstoned row must
  never count toward a milestone.
- `/api/doctor-report/availability` — four parallel `measurement.count`
  probes drive the section-availability switch. A soft-deleted history
  must not light up a section the user has since wiped.

Adds one `it()` block per route asserting that every measurement
read scopes to `deletedAt: null`. Mocks already in place; no
test-infra work needed.
2026-05-21 18:15:50 +02:00
Marc-André Bombeck 2f52f66879 fix(auth-check-user): match identifier verbatim and gate behind per-IP rate-limit
Two correctness gaps on the v1.4.41 onboarding-discovery endpoint,
caught in the v1.4.41 QA round:

1. The route called `identifier.toLowerCase()` before the OR-match on
   `username` / `email`, but `registerSchema` applies no transform on
   write — both columns are stored exactly as typed. A user registered
   as `MixedCase@Example.com` would never resolve through this route,
   and iOS onboarding would route them to the sign-up branch despite
   an existing account.

2. The route header claimed an edge-level limit on `/api/auth/*`
   covered brute-force enumeration; no such middleware exists. Sibling
   `/api/auth/passkey/login-options` carries a per-route throttle. We
   mirror that shape — `checkRateLimit("auth:check-user:<ip>", 30,
   15 * 60 * 1000)` — and rewrite the header comment so the rationale
   matches reality.

Tests pin both invariants: a `MixedCase@Example.com` probe hits
`findFirst` with the case preserved, and a 429 short-circuits the
`findFirst` call entirely.
2026-05-21 18:09:35 +02:00
Marc-André Bombeck 54ad5cdb69 refactor(medications): extract shared today-intake projection helper
Both `/api/medications/intake?scope=today` and `/api/dashboard/summary`
carried their own copy of the v1.4.39 projection + idempotent
`createMany` + per-`(med, day)` compliance recompute. The duplicate
implementations had already drifted twice; fold them into one tested
location at `src/lib/medications/scheduling/project-today-intakes.ts`
so future changes (new IntakeSource literal, new schedule cadence,
audit metadata) only land once.

The helper stays read-shape-neutral — the two routes return different
projections of the resulting events, so the caller still owns its
typed re-read. Returns `{ projected, backfilled }` for the existing
`annotate` telemetry.

Net: ~200 lines collapsed into 145 (helper) + two short call sites.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 8a56f48259 refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per-
metric system prompts + safety contracts) and `src/lib/insights/prompt
*.ts` (user-prompt builder + comparison snapshot + the `compactSections`
helper). A new reader could not tell from filenames which file to
extend; both directories looked equally authoritative. Audit rec #3
flagged the split as the only undiscoverable seam in the AI layer.

Move both files into `src/lib/ai/prompts/`:
  - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts`
  - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts`
  - co-located tests follow

Three importers updated: `app/api/insights/generate/route.ts`,
`lib/ai/coach/snapshot.ts`, the generate route test (the
`vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have
ONE home and ONE mental model.
2026-05-21 17:14:01 +02:00
Marc-André Bombeck d0bdc4b8d1 test(integration): pin soft-delete invisibility in remaining reader tiers
v1.4.41 W-DELETED-2 — assert that /api/export, /api/export/full-backup,
and /api/export/measurements all scope the measurement read to
deletedAt: null. Shape-level guard so a future refactor cannot silently
drop the filter and resurrect deleted rows in user-downloaded files.
2026-05-21 17:14:00 +02:00
Marc-André Bombeck 99a801f993 feat(auth-check-user): four-branch discovery endpoint for iOS onboarding
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:

- not_found       no account
- passkey_only    passkey present, no password
- email_fallback  password present (passkey optional)
- exists          neither credential (recovery path)

The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.

Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
2026-05-21 17:13:34 +02:00
Marc-André Bombeck cb8f74e4fa fix(gamification): exclude soft-deleted measurements in achievement queries
v1.4.41 W-DELETED-2 — achievement progress now scopes the measurement
read to deletedAt: null so deleting a row immediately rolls back any
streak or count badge it had earned, matching the analytics + dashboard
sweeps from v1.4.40.
2026-05-21 17:13:07 +02:00
Marc-André Bombeck 5296a6122f fix(doctor-report): exclude soft-deleted measurements in PDF aggregator
v1.4.41 W-DELETED-2 — the doctor-report-data aggregator (shared by
/api/doctor-report JSON + /api/doctor-report/pdf server render) and the
section-availability probe now filter deletedAt: null so deleted rows
never appear in the rendered PDF or trigger an empty section toggle.
2026-05-21 17:12:53 +02:00
Marc-André Bombeck a62b94983b fix(export): exclude soft-deleted measurements in export bundle reads
v1.4.41 W-DELETED-2 — full-backup JSON, per-type CSV, and the legacy
/api/export endpoint now scope reads to deletedAt: null so iOS undo
or pending-sync rows never reach the user's downloaded file or the
admin restore round-trip.
2026-05-21 17:12:31 +02:00
Marc-André Bombeck bba38b8c66 refactor(types): move BackupRow and BackupsList out of route handler into src/types/backups.ts
Resolves the single component → route-handler import in the codebase
(`components/admin/backups-section.tsx ← app/api/admin/backups/route.ts`)
flagged by the v1.4.39 org audit (recommendation #2). The DTOs now live
in `src/types/backups.ts`; the route handler keeps owning the HTTP
contract and re-imports the shared types from there.
2026-05-21 17:10:38 +02:00
Marc-André Bombeck 853792ca06 fix(intake,dashboard): use Promise.allSettled around bulk-projection compliance recompute
The recompute helper swallows errors internally today, but the contract
is fragile — any future refactor that lets a throw escape would turn
the intake POST or dashboard summary GET into a 5xx for a strictly
best-effort hook. Wrap both call sites in `Promise.allSettled` so the
fail-soft contract is explicit at the call site, not buried in the
helper.

Closes specialist H1 from the v1.4.40 QA round.
2026-05-21 12:41:13 +02:00
Marc-André Bombeck 926ac36dea fix(insights): exclude soft-deleted measurements in remaining measurement.findMany sites
W-DELETED closed the dashboard, analytics, summaries and rollup tiers
but the Insights cluster (tile-strip, AI Coach snapshot, GLP-1 plateau
detector, pulse-status helper) still walked `prisma.measurement.findMany`
without filtering tombstoned rows. Once iOS sync starts emitting
deletions any soft-deleted reading would still contribute to:

- /api/insights/targets 30-day average + 1-year latest-ever tile values
  (recentMeasurements, latestEverByType, glucoseRows)
- /api/insights/cards 90-day weight/BP/pulse window the iOS-adapter card
  stream consumes
- /api/insights/generate per-type aggregate payload feeding the AI prompt
- Coach prompt feature aggregator (lib/insights/features.ts)
- GLP-1 plateau detection window (lib/insights/glp1-plateau.ts)
- Pulse-status snapshot input (lib/insights/pulse-status.ts)

Add `deletedAt: null` to every remaining production `where` clause.
`comprehensive-aggregator.ts` already filtered; `ai/coach/snapshot.ts`
already filtered. Closes senior H1 from the v1.4.40 QA round.
2026-05-21 12:40:37 +02:00
Marc-André Bombeck cc08a43e37 refactor(rollups): move medication-compliance-rollups under the umbrella
- src/lib/medications/compliance-rollups.ts → src/lib/rollups/medication-compliance-rollups.ts
- co-located test follows the source file

10 importers updated covering the dashboard summary, every intake CRUD
route (single + bulk + import + Telegram webhook), the ingest endpoint,
the admin backups restore path, and the reminder worker job.

Closes the rollup umbrella restructure — every persistent-rollup
producer + reader now lives under src/lib/rollups/. Pure relocation
across all five batches: zero behaviour or exported-surface change.
2026-05-21 12:22:48 +02:00
Marc-André Bombeck 63e56f7a2d refactor(rollups): move mood-rollups under the umbrella
- src/lib/mood/rollups.ts → src/lib/rollups/mood-rollups.ts
- co-located test follows the source file

17 importers updated from @/lib/mood/rollups to @/lib/rollups/mood-rollups,
covering the mood-entries CRUD routes, the moodlog webhook, the import
restore path, the insights comprehensive + targets routes, the analytics
+ features paths, and the reminder worker + moodlog sync job consumers.

Pure relocation.
2026-05-21 12:22:12 +02:00
Marc-André Bombeck b7da526cbb refactor(rollups): move measurement-read and rollup-coverage helpers
- src/lib/measurements/rollup-read.ts → src/lib/rollups/measurement-read.ts
- src/lib/measurements/rollup-coverage.ts → src/lib/rollups/measurement-coverage.ts
- co-located tests follow the source files

Importers updated:
- @/lib/measurements/rollup-read → @/lib/rollups/measurement-read (2 sites)
- @/lib/measurements/rollup-coverage → @/lib/rollups/measurement-coverage (10 sites)

Pure relocation.
2026-05-21 12:20:34 +02:00
Marc-André Bombeck 29bfcc674b 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.
2026-05-21 12:19:45 +02:00
Marc-André Bombeck fa36817040 fix(analytics-route): exclude soft-deleted measurements in chunked series fetch
The three Measurement reads in the analytics route — glucose-by-context,
sleep-stage breakdown, and the fetchMeasurementSeriesChunked paged
helper that backs the per-type summarize loop and the BP-Zielbereich
windowing — were still pulling soft-deleted rows. W-DELETED skipped this
file assuming the W-POOL pool / p-limit pass covered it, but that pass
only addressed concurrency, not the deletedAt predicate.

Adds deletedAt: null to all three findMany clauses so the route shares
the same soft-delete contract as every other analytics read path. iOS
soft-delete unblocked.
2026-05-21 12:16:13 +02:00
Marc-André Bombeck f3a4da50f6 chore(glucose,berlin-day): drop unreferenced mmolToMgdl and berlinDayKey exports
The glucose UI only converts canonical mg/dL into mmol/L for display
via convertGlucose; the reverse helper had no caller outside its own
test. The Berlin-only day-key helper was already superseded by
userDayKey(date, tz) in v1.4.38 for every read path that consumed it;
its remaining presence as a tested module was dead weight.
2026-05-21 12:07:16 +02:00
Marc-André Bombeck 2bff80aaf9 chore(routes): remove duplicate /api/monitoring/{umami,glitchtip}/test endpoints
Both routes were superseded by their /api/admin/monitoring/{umami,glitchtip}-test
counterparts which are the variants the admin UI actually fetches.
The duplicated routes had no callers anywhere in the source tree.
2026-05-21 12:02:00 +02:00
Marc-André Bombeck 2b4e2177be chore(routes): remove orphan /api/medications/intake-summary endpoint
The GET handler had no fetch caller and the queryKeys.medicationIntakeSummary
entry was never consumed in any useQuery. Strip the route file, the
factory entry, the medicationDependentKeys reference, and an outdated
quick-add doc comment.
2026-05-21 12:01:11 +02:00
Marc-André Bombeck 08cf854960 fix(dashboard-summary): recompute compliance rollup after bulk projection backfill
Mirrors the intake-route call site (v1.4.40 close of v1.4.39.4 hook
gap). `projectAndReadTodaysIntakes` runs the same idempotent
projection helper the intake route uses; without this hook the
compliance rollup stayed pre-projection on the second call site too.

Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` after the bulk `createMany`, best-effort
inside the helper's try/catch so a populator hiccup doesn't surface
as a 5xx on the dashboard summary.
2026-05-21 11:52:54 +02:00
Marc-André Bombeck de1e65d4a1 fix(medications-intake): recompute compliance rollup after bulk projection backfill
Closes the v1.4.39.4 hook gap surfaced by the QA reconcile pass. The
`scope=today` branch's bulk `createMany` mints fresh
`(medicationId, scheduledFor)` PENDING rows when the morning
projection lands; without this hook the compliance rollup for the
affected `(user, medication, day)` tuples stays at its pre-projection
`scheduled` count, inflating the apparent compliance % until the user
actually logs against the new row.

Fires `recomputeMedicationComplianceForEvent` once per distinct
`(medication_id, dayKey)` (coalesced by Set) so a 5-meds-3-times-a-
day projection drops 15 createMany rows down to ≤3 recompute round-
trips. Best-effort: errors stay caught inside the helper so a
populator hiccup doesn't surface as a 5xx on the intake request.
2026-05-21 11:52:42 +02:00
Marc-André Bombeck 45a8399822 perf(insights-comprehensive): consume mood-rollup tier
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day
`prisma.moodEntry.findMany` block fed two downstream consumers:
`dailyMoodEntries` for mood × metric correlations, and `moodSummary`
for the dashboard mood block. Both read at a per-day resolution.

Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads
one row per calendar day instead of one per entry. Coverage-fallback
runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day
mean DataPoints into `summarize()` on both branches, matching the
rollup-tier convention shipped in v1.4.39 (QA UX-H1).

Two parity tests added to `__tests__/route.test.ts`: rollup fast path
skips the raw findMany, and the coverage-fallback runs the live walk
exactly once when rollups are empty.
2026-05-21 11:52:21 +02:00
Marc-André Bombeck f8de4b05fd perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan
Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db).
The mood block at the bottom of the route used to run an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` on every mount —
pulling every mood the user had ever logged into Node only to bucket-
average them in JS. The `findMany({distinct:['type']})` for 'absolute
latest measurement per type' carried no `measuredAt >=` floor either,
so on a 347 k-row tenant Postgres still had to sort the full set
before the driver applied DISTINCT ON (Prisma's `distinct` doesn't
compile to PG's `DISTINCT ON`).

This commit:
  - swaps the mood findMany for `readMoodDayRollups(userId, since)`
    plus a single `findFirst` for the latest score; coverage-fallback
    runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh`
    so the next request lands on the rollup tier (same posture as
    /api/mood/analytics post-v1.4.39)
  - adds a 365-day floor to the latest-ever-per-type read so the
    driver-side distinct walk stays bounded
  - pins all three behaviours in `__tests__/route.test.ts` (4 new
    tests: rollup fast path, coverage-fallback bounded walk, no-mood
    branch, distinct floor present)
2026-05-21 11:52:09 +02:00
Marc-André BombeckandClaude Opus 4.7 a3b0dc920b test(consent): pin endpoint behaviour and append-only invariant
Cases covered:

- POST 401 unauth, 400 invalid kind, 400 non-ISO signedAt, 400 oversize
  artefact (>64 KB), 200 inserts row + strips artefact from response.
- GET 401, 400 invalid kind, null when nothing granted, latest row by
  kind, full keyspace projection when ?kind is omitted.
- DELETE 401, flips revokedAt on the latest active row, idempotent
  no-op when nothing to revoke, master toggle revokes per-kind.

The headline test is the append-only invariant: a revoke + re-grant
cycle leaves the original row in place (with revokedAt set), mints a
fresh row, and a subsequent GET points at the re-grant — never at the
revoked predecessor. Prisma's `delete` is asserted to never be called
on a receipt, which is the legal-team guarantee the audit trail relies
on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:49:22 +02:00
Marc-André BombeckandClaude Opus 4.7 d6809e3d54 feat(consent): POST + GET + DELETE endpoints at /api/consent/ai
Wires the iOS onboarding + Settings "AI deaktivieren" flow to the
persistence layer landed in the previous commit:

- POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists
  a fresh receipt, returns id + serialised row (artefact stripped from
  the response so the signed token doesn't echo over network paths it
  doesn't need to cross).

- GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for
  the given kind. Omit ?kind to get the full three-kind keyspace
  keyed by kind, with null slots for kinds the user hasn't granted.

- DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the
  latest active row. Idempotent: a no-op returns 200 with null receipt
  rather than 404 so the iOS toggle can hammer the endpoint without
  spurious error states. Omit ?kind for the master "deaktivieren alle
  KI-Funktionen" sweep across every kind.

Both grant + revoke write to auditLog so the trail is reconstructible
even if the receipts table is rebuilt from a backup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:49:11 +02:00
Marc-André Bombeck 97920f0f59 fix(measurements-route,series,timeline): exclude soft-deleted in list reads
`GET /api/measurements` has five distinct read shapes (per-day
drill-down, group-by-day, rollup-source coverage-fallback probe + fold
re-read, live `date_trunc` aggregate, default findMany + count). Each
relied on a shared `where` builder or a local `findMany` clause; none
filtered `deletedAt`. The `GET /api/measurements/series` adapter for
iOS read BP-SYS / BP-DIA / single-type series without the filter too.

Pin `deletedAt: null` on the shared `where` so the count + raw findMany
+ group-by-day mode all inherit it; add the filter to the dayKey
drill-down findMany, the coverage-fallback day-count probe, and the
live `date_trunc` aggregate; thread the filter through the three series
findMany calls. The `source=rollup` branch reads from
`measurement_rollups` so the writer-side filter covers it. No
`measurements/timeline` route exists yet — the file-set name was
prospective.
2026-05-21 11:45:26 +02:00
Marc-André Bombeck 1bcaae4764 fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries
`latestIn7d` and `streakDays` are raw SQL against `measurements`; the
`allTime` aggregate is `prisma.measurement.groupBy`. All three folded
tombstoned rows into the tile-strip's latest value, the all-time count
+ last-seen-at metadata, and the measurement-streak day count. The
`sparkBuckets` query reads from `measurement_rollups` so the writer-side
filter covers it.

Add `m."deleted_at" IS NULL` to the two raw queries and `deletedAt: null`
to the Prisma groupBy.
2026-05-21 11:44:00 +02:00
Marc-André Bombeck 433874e390 test(analytics,db): pin concurrency cap and pool ceiling
Two regression nets for the W-POOL changes shipped in the prior two
commits:

* `src/lib/__tests__/db.test.ts` — assert `getPoolMax()` returns 20 by
  default and honours `DATABASE_POOL_MAX`, with a final assertion that
  pins the floor at 20 so a future refactor that drops the explicit
  `max:` config can't silently revert to the library default of 10.
* `src/app/api/analytics/__tests__/route.test.ts` — instrument the
  per-type `findMany` mock with inflight counters and assert peak
  concurrency never exceeds `ANALYTICS_TYPE_FETCH_CONCURRENCY`. The
  wall-clock window also catches both an unbounded `Promise.all`
  regression (would finish too fast) and an accidental serialisation
  (would finish too slow).
2026-05-21 11:39:54 +02:00
Marc-André Bombeck 96b2ee4c1c perf(analytics): bound 15-way Promise.all to 4 concurrent type-fetches
Wrap the per-type fan-out in `buildAnalyticsResponse` with a p-limit(4)
gate. The empirical v1.4.39 cold-mount trace
(`.planning/round-v1439-empirical-trace.md` §B1) showed thick
/api/analytics holding ≥8 of the default-10 pg.Pool slots for 6.5 s on a
power-user account, starving every other dashboard query. The cap keeps
the same total work but holds ≤4 pool slots for this branch at any
moment, leaving the remaining slots free for the parallel chart-tile
reads. Thick analytics is already 60 s cache-wrapped so the small
absolute wall-clock cost on a single cold mount is offset by the
chart-tile burst paying ~5 s less of pool-wait time on every mount
thereafter.
2026-05-21 11:37:40 +02:00
Marc-André Bombeck db2c70a78d test(medications): pin skipDuplicates flag on intake-route backfill
The W-SERVER-FIX-2 race-guard work added `skipDuplicates: true` to the
intake route's projection-backfill createMany alongside the schema
@@unique([userId, medicationId, scheduledFor, source]) constraint. The
existing unit test asserted the row shape but not the flag, so a
regression that drops `skipDuplicates: true` while keeping the unique
constraint would surface as a 5xx in prod (constraint violation
bubbles through) without breaking the suite.

Adds the flag to the `toMatchObject` assertion so the defense-in-depth
contract stays pinned next to the row shape it pairs with. Matches the
equivalent assertion the dashboard summary test already carries.
2026-05-21 11:23:17 +02:00
Marc-André Bombeck 341483de3f fix(medications): swallow concurrent-projection duplicates in intake backfill
PR #191 added a today-window backfill to /api/medications/intake. The
v1.4.39.4 dashboard symmetry fix wires the exact same projection into
/api/dashboard/summary, which means two parallel routes can now race
the same (userId, medicationId, scheduledFor, REMINDER) row between
their existence probes and createMany calls. The @@unique on
(userId, medicationId, scheduledFor, source) is the structural
backstop; without skipDuplicates: true the loser of that race would
surface a constraint violation as a 5xx.

Pair the createMany with skipDuplicates: true so Postgres swallows the
rejected rows and both routes still return 2xx. Matches the same flag
the dashboard route ships with.
2026-05-21 11:19:04 +02:00
Marc-André Bombeck 7c0a722309 fix(medications): backfill today-intakes inside dashboard summary route
Dashboard tile feeding the iOS Compliance ring + Erfassen-sheet preview
pulls from /api/dashboard/summary, not /api/medications/intake. Without
the same projection-then-backfill the intake route received in PR #191,
the dashboard endpoint returned [] for daily meds (daysOfWeek = null),
keeping the iOS Dashboard tile in its "Heute nichts geplant" empty
state even after the intake route fix shipped.

Re-uses expandTodayIntakes(schedules, now, tz) + an idempotent
createMany with skipDuplicates: true so a concurrent intake-route hit
can't race a duplicate row in before the existence probe converges.
Two tests added: backfill-mints-once + idempotent-on-second-pass.
2026-05-21 11:17:48 +02:00
Marc-André Bombeck 10e658f66d fix(medications): project daily-schedule intakes for today's window
Schedule-expansion never minted today's intake rows for medications
with `daysOfWeek: null` until the reminder worker entered the RED
phase at the end of the dose window. The database stores `null` to
mean "every day" (the `MedicationSchedule.daysOfWeek` column carries
that annotation in `prisma/schema.prisma`), so daily meds spent the
whole morning with zero scheduled rows. Endpoint
`/api/medications/intake?scope=today` returned `[]` even when two
active daily meds existed; iOS Dashboard tile + Erfassen sheet both
fell to "Heute nichts geplant" / "Keine offene Einnahme" end-to-end.

Adds `expandTodayIntakes` to `src/lib/medication-schedule.ts` — a
pure projection that re-uses the existing `parseScheduleRecurrence`
(empty array == every day) and respects explicit weekday filters
when present. The `scope=today` route projects active schedules
through the helper, then idempotently backfills any missing rows
into `MedicationIntakeEvent` so the iOS POST update path keeps the
existing intakeId contract.

Tests:
  - `medication-schedule.test.ts` gains eight cases covering
    `daysOfWeek: null`, `""`, `"0,1,2,3,4,5,6"`, explicit-weekday
    excludes-today / includes-today, the operator's Ramipril +
    Metformin fixture, DST-correct `scheduledFor` anchoring at
    `windowStart` in Europe/Berlin, and multi-week cadence skip.
  - `intake/route.test.ts` gains two cases: backfill mints the
    missing row + does-not-double-mint when the row already exists.

Full vitest suite stays green (4672 passed / 1 skipped).
2026-05-21 10:59:25 +02:00
Marc-André Bombeck f8a1319e94 fix(measurements-route): reconcile rollup against live table on coverage miss
v1.4.39.1 wired the rollup write hook into the previously-bypassed
Withings sync, /api/import, and admin-restore paths, and widened the
boot-time backfill discovery to per-(user, type, day). Together those
changes ensure new writes and post-deploy restarts converge the rollup
tier for every measurement type the user has logged. They do NOT,
however, guarantee convergence inside a single request when the boot
backfill has not yet finished folding the user's historic rows: a chart
query lands first, the rollup table still carries 2 rows for a 30-day
BP_SYS window, and the route's pre-fix "rollup wins on length > 0"
short-circuit returns the sparse rollup. The chart trips its `< 3 daily
points` empty-state even though `measurements` holds the full 30 days
(Marc reported the regression from the v1.4.39.1 post-deploy trace).

This commit closes the gap. When the rollup row count is suspiciously
low for the requested window (>= 7 days asked and < 3 rollup rows
present), the route now probes `COUNT(DISTINCT date_trunc('day', ...))`
against `measurements` for the same window. If the live table holds
strictly more days than the rollup, the route folds the
(user, type, DAY, [from, to]) partition inline via
`recomputeUserRollups` and re-reads the rollup so the request returns
the converged rows. The chart paints the full window on the same
request without paying the cost on subsequent requests because the
inline fold leaves the rollup partition converged.

The probe is gated on rollup sparsity so the covered-tenant hot path
stays a single indexed read; the inline fold is bounded by the window
and one measurement type so it stays small (a 30-day BP_SYS window is
one 30-row upsert against the composite primary key). The fallback
surface annotates `meta.coverage_fallback: true` on the response so
ops can spot the convergence path in production wide-events.

Four new tests pin the four shapes:
- under-coverage triggers the inline fold + re-read
- covered window keeps the single-read hot path
- short window (< 7 days) skips the probe entirely
- live + rollup agree (genuine sparsity) returns the sparse rollup
2026-05-21 09:54:59 +02:00
Marc-André Bombeck e73661fef6 test(medications-intake-route): anchor seed row on runtime today
The v1.4.39 W-MED route test hard-coded a `2026-05-18` rollup row and
expected it to land at `body.data[body.data.length - 1]` (today). The
test ran green on the day the marathon shipped, then started failing
on every subsequent day because the trailing-7-day window drifted off
the seed. Deriving the seed key from `new Date()` keeps the assertion
date-stable across the calendar without weakening the parity check.
2026-05-21 09:02:06 +02:00
Marc-André Bombeck a2b0ab19e5 fix(rollups): fold measurement tier on /api/import + admin backup restore
Both ingest paths wrote into `measurements` without firing the rollup
write hook, leaving the dashboard chart's `source=rollup` fast-path
blind to the imported days. The mood / medication-compliance rollup
tiers already had post-batch hooks on these routes; the measurement
tier had been the gap.

- `/api/import`: tracks every touched `(type, measuredAt)` written
  during the batch, collapses to distinct `(type, day)` via
  `collapseToTypeDayKeys`, and folds once at the end. A 10 000-row
  CSV restore now pays at most ~N (type, day) recomputes rather than
  10 000 per-row hooks.
- Admin backup restore: the transaction wipes the user's
  `measurement_rollups` partition alongside the existing
  `mood_entry_rollups` wipe, and the post-transaction step kicks a
  `recomputeUserRollups(ownerId)` so the restored dataset paints
  fresh tiles instead of carrying the previous owner's daily means
  forward.
- Best-effort posture preserved in both paths: a populator hiccup
  surfaces via `annotate` but never undoes the restore / import.

Test coverage extends the existing `/api/import` route suite with a
regression that pins the per-`(type, day)` collapse and a duplicate-
write case (`P2002`) that proves the hook is skipped when nothing
was actually written.
2026-05-21 09:01:46 +02:00
Marc-André Bombeck 96e4cc06f1 test(medications-intake-route): align mocks with v1.4.39 coverage-probe shape
QA F-H-01 (v1.4.39) tightened `hasMedicationComplianceCoverage` to a
`$queryRaw` aggregate; the route test mocks were still calling
`prisma.medicationComplianceRollup.findFirst`. Switch the mocks to the
new probe shape — `{ rolled_days, event_days }` — and add a
`$executeRaw` mock for the atomic upsert path that QA F-H-02 introduced.
The covered-vs-coverage-miss cases pin the rollup-tier and live-fallback
branches under the new probe semantics.
2026-05-18 13:01:23 +02:00
Marc-André Bombeck 4e1f2d4694 refactor(dashboard-summary): flatten nested ternary per CLAUDE.md
QA Simplifier (v1.4.39): the cumulative-vs-spot sparkline branch in
the `sparkByType` loop was a nested ternary
(`useSum ? (row.sum_value !== null ? sum : mean * count) : mean`).
CLAUDE.md bans nested ternaries; the fix hoists the cumulative branch
into a named local so the outer ternary is single-level.
2026-05-18 12:58:45 +02:00
Marc-André Bombeck 6ec2d499fb fix(medication-compliance): scope coverage-miss backfill enqueue to caller user
QA F-SEC-M-01 (v1.4.39): the route's coverage-miss fallback was firing
`enqueueBootTimeMedicationComplianceBackfill` on every authenticated
hit, running a cluster-wide `LEFT JOIN medication_intake_events x
medication_compliance_rollups` discovery scan in the request path. On a
multi-tenant cluster with millions of intake events the planner walked
all of them per request — a soft DoS amplifier. Split into two helpers:
`enqueueUserMedicationComplianceBackfill(userId)` for the request path
(one targeted enqueue, no discovery scan) and the existing cluster-wide
discovery stays for the worker-boot path. Mirrors the mood-rollup
`ensureUserMoodRollupsFresh` posture. Pinned with a unit test that
asserts the user-scoped helper never issues `$queryRaw`.
2026-05-18 12:57:25 +02:00
Marc-André Bombeck aacfbf5b61 perf(analytics): widen live-fallback cap to 425 days to preserve year-ago summaries
QA Specialist-H2 (v1.4.39): the original 90-day cap on the per-type
live-fallback loop in `/api/analytics` silently nulled `summarize().avg30LastYear`
(year-ago tile) on every fall-through path because the window stopped at
day 90 and the year-ago aggregate reads days 365-395. Widened the cap to
425 days (365 + 30 for the year-ago bucket span + 30 buffer) so the
year-over-year comparison tile stays populated on cold mounts. The slim
slice and rollup-backed comprehensive aggregator already operate on
tighter windows so this only affects the legacy live-fallback path the
fast-path coverage gate misses. Adjusted the W-SINCE test that pinned
the 90-day cap and updated the route's `meta.analytics.bp_aggregate.live_since`
annotation rationale.
2026-05-18 12:55:32 +02:00
Marc-André Bombeck ff4e5faf5f test(mood-rollups): pin DST fall-back Berlin midnight slip
QA Specialist-H1 (v1.4.39): the wave-report noon-anchored parity tests
miss the DST-boundary case where the UTC-anchored rollup day-key and the
user's local wall-clock day-key diverge. Pinned with a route-parity test
that logs at `2025-10-25T23:30:00Z` (Berlin local `2025-10-26` 00:30,
post fall-back) and asserts the rollup tier emits `2025-10-25` —
documents the UTC-anchor trade-off the route's header comment now spells
out. v1.5 per-user-tz bucketing closes the gap; until then this is the
contract.
2026-05-18 12:53:43 +02:00
Marc-André Bombeck 3c1a50cbb2 fix(mood-analytics): pre-aggregate live fallback to match rollup-path summarize semantics
QA UX-H1 (v1.4.39): the rollup fast-path feeds `summarize()` one DataPoint
per calendar day (the rollup's `mean`); the live-fallback used to pass every
raw entry, which silently shifted `summary.count / latest / min / max / mean
/ avg7 / avg30 / slope30` on multi-entry days. Pre-aggregating the
live-fallback through `aggregateDailyAverages` before `summarize()` keeps
both branches byte-identical on multi-entry days too. Pinned with a
two-entries-on-the-same-day parity test that compares the live-fallback
and rollup-tier summary fields directly.
2026-05-18 12:53:01 +02:00
Marc-André Bombeck 7657b848f0 test(medication-compliance-rollups): hook, reader, and tz parity
Unit-covers the writer's `(scheduled, taken, skipped)` upsert, the
zero-event delete, idempotency across repeated invocations, the
tz-anchored UTC window math, and the Berlin vs Los Angeles bucket
parity for an instant that lands on different local days. Reader
tests pin the trailing-window zero-fill plus the per-medication
fold-to-day. Boot-backfill discovery tests pin the singleton-keyed
enqueue. Route tests extend the existing harness with the new
coverage probe — rollup-tier read on coverage, live fallback on
miss — without touching the byte-stable response shape.
2026-05-18 12:30:35 +02:00