Commit Graph
582 Commits
Author SHA1 Message Date
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 f2a1e8cec8 test(dashboard): update Suspense fallback pin to the placeholder-div contract
v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the tile-strip Suspense
fallback from `null` to an `aria-hidden` placeholder div that mirrors
the trend-card chrome, preventing CLS when a future RSC hoist makes
the tile body suspend. The literal regex pin in the boundary test was
not updated alongside, leaving a red on develop tip.

Updates the regex to match the new structural contract and refreshes
the surrounding comment so future contributors see why the fallback is
no longer `null`.
2026-05-21 17:55:11 +02:00
Marc-André Bombeck 70ff3eef73 chore(cleanup): trim 13 dead exports surfaced by knip
Narrow each helper's visibility to its actual reach instead of leaving
it exported "just in case":

  - Drop the `export` keyword on file-local helpers / constants:
    `getTrendSentiment`, `isPRWithin30Days`, `buildCoachMetricSourceType`,
    `countPassedSchedules`, `getGithubConfig`, `isComparisonBaseline`,
    `DASHBOARD_LAYOUT_VERSION`, `INTL_LOCALE_MAP`, `SIDE_EFFECT_NOTES_MAX`,
    `MEDICATION_CATEGORIES`, `openApiBase`, `ALL_MOOD_GRANULARITIES`.
  - Remove never-used dead code:
    `CodexOAuthNotConfiguredError` (declared as a backwards-compat shim
    no caller ever consumed), `editMessageReplyMarkup` (Telegram method
    nothing on the bot dispatch tree calls), and the stale
    `DISPLAY_TIMEZONE` re-export from `src/lib/format.ts`.

Knip drops from 48 -> 35 unused exports. Pure narrowing / deletion;
no behaviour change.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 1b0bcadb50 refactor(rollups): drop unused optional tx params from recompute helpers
`recomputeMoodBucketsForEntry`, `recomputeMedicationComplianceForDay`,
and `recomputeMedicationComplianceForEvent` all declared an optional
`tx?` Prisma transaction client but no call site has ever passed one —
every caller (routes, jobs, worker, tests) invokes them outside a
`$transaction` block. The dead surface area kept dragging a
`PrismaLike` / `PrismaTxOrClient` alias plus a top-level `Prisma`
import along with it. With the param gone the helpers simply close
over the singleton `prisma` client; the alias and the
type-only import follow.

No call-site changes needed — the param was optional everywhere.
2026-05-21 17:52:01 +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 3c73ef5c6f chore(cleanup): drop five unused imports flagged by eslint
The insights page kept `CorrelationResult` + `DataSummary` type imports
left over from an earlier refactor, and the slim summaries slice still
referenced `RollupGranularity`, `aggregateWmyBuckets`, and
`RollupBucketRow` after the v1.4.37.2 GROUP BY rewrite folded that work
into the narrow aggregate. Pure deletions — both helpers stay exported
for the test suite that still exercises them.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 27f3bec1f4 perf(insights): persist timeout stub for blood-pressure and weight status
Mirror the bmi-status (v1.4.37) timeout-stub persist into the two
sibling endpoints. On a 20 s provider stall the route now writes a
sentinel auditLog row keyed to today's Berlin day so the next mount
short-circuits at the cache lookup instead of re-racing the same
provider call for the rest of the day.

This eliminates the ~14 s warm response on every iOS dashboard open
when the provider has stalled once that day. Response envelope
unchanged; iOS v0.5.4 contract preserved.

Tests cover both arms: the persist-on-timeout path (one create with
timeout:true, model:"timeout-stub", today's dateKey) and the
short-circuit-on-stub path (subsequent mount returns cached text,
provider never invoked).
2026-05-21 17:38:20 +02:00
Marc-André Bombeck 9d2901aaf9 refactor(rollups): retire v1.4.39 W-SUM legacy-NULL UNION discovery arm
The `sum_value IS NULL` UNION arm in `enqueueBootTimeRollupBackfill`
was added in v1.4.39 W-SUM to converge DAY rollup rows that pre-dated
the writer change. Production data has since converged, so the arm is
a permanent no-op seq scan over `measurement_rollups`. Per-day missing
coverage remains the sole discovery anchor.

Drops the legacy text-anchor unit test. The read-side `mean * count`
fallback in `/api/dashboard/summary` retains legacy-row readability
and stays covered by its dedicated route test.
2026-05-21 17:35:10 +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 0bf07abd9d refactor(query-keys): expand factory and migrate auth, notifications, about
Routes the registration-status, auth invalidation, notifications
preferences, and Settings about-section api-version reads through
the centralised queryKeys factory. Adds factory entries for the
remaining settings/admin surfaces a follow-up will migrate, and
extends the walker test to fail on any future bare-literal
queryKey in src/app/auth, src/app/notifications, and the
about-section. Also swaps the dashboard tile Suspense fallback
from null to a layout-stable card placeholder so a future RSC
hoist of any tile slot can't trigger CLS.
2026-05-21 17:13:57 +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 dc66dfcfdc refactor(types): consolidate AnalyticsData into src/types/analytics.ts
Three structurally-distinct `interface AnalyticsData {…}` declarations
shared a name across `src/app/page.tsx` (dashboard), `src/app/insights/
page.tsx` (insights mother), and `src/components/onboarding/
getting-started-checklist.tsx` (checklist). The collision made the
shapes un-discoverable from any one site and forced every iOS-contract
addition to land in three places.

Hoist all three into `src/types/analytics.ts` as named exports:
`DashboardAnalyticsData`, `InsightsAnalyticsData`, `ChecklistAnalytics
Data`. Each call site re-imports its shape under the original alias to
keep downstream code readable; the slim `SubPageAnalyticsData` that was
already there stays put. We deliberately keep the four shapes distinct
(rather than collapsing to one with everything optional) so call sites
can't cross-read fields they shouldn't reach for — closes v1.4.39 org-
audit recommendation #2.
2026-05-21 17:12:38 +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 2c3eeee39b fix(consent): enforce artefact 64 KB cap via Buffer.byteLength (not char-count)
`z.string().max()` counts JavaScript string units (UTF-16 code
units), not bytes. The audit-table guarantee for SB-10 consent
artefacts is byte-bounded — a UTF-8 artefact full of multi-byte code
points (CJK, emoji) would otherwise slip past the 64 KB row budget.
Replace the `.max()` with a `.refine()` that uses
`Buffer.byteLength(value, "utf8")`.

Closes security M1 from the v1.4.40 QA round.
2026-05-21 12:43:28 +02:00
Marc-André Bombeck 4d4a034efc refactor(compare-toggle): migrate to queryKeys.dashboardWidgets factory
The compare-toggle component still declared three bare-literal
`["user", "dashboardWidgets"]` queryKeys for read, onMutate
cancel/set, onError rollback and onSettled invalidation. Migrate every
call site to the central `queryKeys.dashboardWidgets()` factory so a
future rename or shape evolution only touches one file. Extend the
W-RSC factory-bypass walker to cover `src/components/comparison/`
so any future regression in this directory gets caught by CI.

Closes UX H1 from the v1.4.40 QA round.
2026-05-21 12:42:17 +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 99b4fde002 fix(dashboard-suspense-test): align timezone regex with post-W-INFRA local-lift
The audit-H4 regex still pinned the pre-W-INFRA Thread 2 form
`useMemo(..., [user?.timezone])` while the production code lifts
`user?.timezone` to a `userTimezone` local one line above the
`useMemo` so the dep array stays stable. Update the test fixture to
match the new dependency form.

Closes senior C1 from the v1.4.40 QA round.
2026-05-21 12:40:26 +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 8cfb1715b4 refactor(rollups): move WMY and cumulative readers
- src/lib/measurements/rollup-read-wmy.ts → src/lib/rollups/measurement-read-wmy.ts
- src/lib/measurements/rollup-read-cumulative.ts → src/lib/rollups/measurement-read-cumulative.ts
- co-located tests follow the source files
- cumulative module's sibling import of apple-health-mapping promoted to
  the @/lib alias since that mapping stays in src/lib/measurements/

Importers updated: 2 sites for WMY (health-score + summaries-slice),
zero external sites for cumulative (only co-located test reads it).

Pure relocation.
2026-05-21 12:21:40 +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 8974e7730c fix(app-page): clear lint regression from per-tile Suspense scaffolding
The useMemo introduced for the timezone-aware greeting in the W-RSC
per-tile Suspense pass listed [user?.timezone] as its dep array. React
Compiler's preserve-manual-memoization rule rejected the literal
optional-chain expression because the inferred dep is the wider 'user'
object — a less-specific source than the declared property access — so
the compiler refused to optimize the surrounding 1 400-line component
body.

Lift userTimezone into a local before the memo so the inferred dep and
the declared dep agree. No behaviour change; the memo still recomputes
only when the user's timezone string changes.
2026-05-21 12:17:08 +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 cd8277d367 refactor(tz): consolidate wallClockInTz into src/lib/tz/wall-clock.ts
The compliance-rollups module and the cadence scheduling module each
carried a private wallClockInTz definition. The cadence variant
returned the full {year,month,day,hour,minute,second,weekday} tuple;
the compliance variant returned only {year,month,day}. Promote one
canonical helper that returns every part — the compliance caller
destructures the three fields it needs — so the cross-tz ±3 h guard
stays in lock-step instead of drifting between sibling modules.
2026-05-21 12:12:40 +02:00
Marc-André Bombeck 79c86a0652 refactor(tz): consolidate startOfUtcDay into src/lib/tz/start-of-utc-day.ts
The mood-rollup and measurement-rollup writers carried byte-identical
copies of the helper that could drift independently. Promote one
canonical export under src/lib/tz/ so both writers — and any future
rollup table — import the same UTC-anchored truncation.
2026-05-21 12:10:42 +02:00
Marc-André Bombeck e26e27b5ff chore(i18n): drop dead format.dateShort timeShort dateTime keys across six locales
The runtime never reads the format namespace — every locale-aware
formatter routes through Intl.DateTimeFormat directly via
src/lib/format-locale.ts. The keys were only verified by their own
ordering smoke test, which is also removed.
2026-05-21 12:09:31 +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 b1469c4626 test(rollup-read-wmy): parity and granularity-routing across consumers
Pins the cross-consumer routing contract the v1.4.40 wire-in relies
on. Both computeSummariesSlice (year-ago baseline) and
computeUserHealthScoreFastPath (weight long-window) now call
readBestGranularityRollups so the floor table is shared state. The
new tests prove:

- 90 d -> DAY (every coarser floor too high),
- 365 d -> MONTH (clears 181 d floor, short of 731 d YEAR floor),
- 1095 d -> YEAR (clears the 731 d YEAR floor).

Plus a parity assertion: a MONTH bucket consolidating 30 measurements
with mean 80 returns the same count-weighted mean as the two DAY
buckets (count 10 / mean 82 + count 20 / mean 79) that compose it.
Locks the linearly-composable contract count / mean carries across
granularities — any future floor adjustment forces both consumers to
re-pin in lock-step rather than silently diverging.
2026-05-21 12:06:15 +02:00
Marc-André Bombeck 75607e4c09 chore(measurements): drop unreferenced typesMissingCoverage and readCumulativeDaySumsBatch exports
The bucket-fresh read paths probe coverage per-type via
coverage.get(type) or rely on isFullyCovered for the all-or-nothing
fast path; the typesMissingCoverage helper had no production caller.
The cumulative-batch reader was only exercised by its own test —
the analytics A2 path took the chunked per-type findMany route in
production. Strip both and their tests.
2026-05-21 12:05:43 +02:00
Marc-André Bombeck 9009f1bb33 perf(health-score-fast-path): consume monthly rollup buckets for slope90
Wires readBestGranularityRollups into the WEIGHT-covered branch of
computeUserHealthScoreFastPath so the trailing-year weight baseline is
served from the WEEK / MONTH / YEAR rollup tier instead of falling
through to a live measurements walk. The 365-day window routes through
MONTH by default (~12 buckets) and falls back to WEEK -> DAY on per-
tier coverage miss. count / mean compose linearly across MONTH buckets
so the derived mean is mathematically equivalent to the per-row
average over the same window.

The read fires in parallel with the canonical DAY-bucket read for the
30-day weight series so wall-clock cost stays flat. Score shape is
unchanged; the weightLongWindow annotate carries the resolved
granularity + bucket count + mean so operators can verify the rollup
tier serves production traffic. The live-fallback branch deliberately
omits the long-window probe because the raw weight findMany already
does the work — guards against double-reading on cold mounts.
2026-05-21 12:05:02 +02:00
Marc-André Bombeck 9df23c3c87 chore(analytics): drop unreferenced movingAverage and weeklyAverages exports
Neither function had any production caller. The chart components in
src/components/charts/* implement a local movingAverageByPoints rather
than reaching for the lib helper, and weeklyAverages was never imported
outside its own test file. detectAnomalies stays — it is reached
transitively through summarize() which the analytics, insights, and
measurements-series routes all consume.
2026-05-21 12:04:07 +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 24568c805b perf(summaries-slice): consume monthly rollup buckets for long-window slice
Wires readBestGranularityRollups into computeSummariesSlice to populate
the year-ago 30-day baseline (avg30LastYear) per type. The field was
hardcoded null up to v1.4.39 because the 90-day windowed aggregate
cannot reach back a year and the live measurements walk over a 395-day
per-type window is exactly the cold-path cost the rollup tier was meant
to absorb.

Coverage-fallback mirrors the W-MOOD posture: per-type calls route
through YEAR (skipped for the 395-day window — floor 731 d) -> MONTH
-> WEEK -> DAY and return null when no granularity carries any bucket
overlapping the [now-395d, now-365d) slice. count / mean compose
linearly across any granularity so the derived baseline is
mathematically equivalent to the per-row average over the same window.

Adds year_over_year_types annotate count alongside the existing
slim_summaries path tag so operators can verify the wire-up in
production wide-events without redeploying instrumentation.
2026-05-21 12:00:56 +02:00
Marc-André Bombeck 0ec4a99322 chore(jobs): remove dead TELEGRAM_CLEANUP_QUEUE handler and registration
The queue had a handler, registration, and worker binding but no
producer anywhere in the codebase — zero boss.send calls and no cron
schedule. The doc comment claimed the Telegram sender schedules
cleanup but the sender never called boss.send. Strip the constant, the
interface, the handler, the allQueues entry, and the worker bind.
2026-05-21 11:58:36 +02:00
Marc-André Bombeck 317d56181b test(auth-registration-status): pin all four discovery branches
The route is read-only and its envelope shape (`registrationEnabled`
boolean) gates iOS sign-in + web onboarding. Four branches walked:
  1. singleton row exists with registrationEnabled: true → reports true
  2. singleton row exists with registrationEnabled: false → reports
     false (admin-disabled tenant, invite-only)
  3. singleton row missing → reports schema default true (fresh self-
     hosted instance, no-config baseline)
  4. findUnique throws → reports false (fail-closed; critical defense
     against a DB blip silently unlocking sign-up on a tenant that
     disabled registration)

No code change; the route's current behaviour is correct. Pin it
before someone refactors fail-closed → fail-open by accident.
2026-05-21 11:53:04 +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 ca7d00ffbf perf(insights-generate): consume mood-rollup tier via features extractor
Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The mood
block inside `extractFeatures` ran an unbounded
`prisma.moodEntry.findMany({ where: { userId } })` and folded the
entire history into the AI feature payload for /api/insights/generate.
On power-user accounts the walk dominated the cold-mount budget.

Swap to `readMoodDayRollups(userId, fiveYearsAgo)` so the feature
payload reads one row per calendar day. The downstream consumers
(`avg7` / `avg30` / `trend30` / `coverage` / cross-metric
correlations) all operate at per-day resolution, matching the rollup
shape. `latest` score reads a single `findFirst` (bounded). Coverage-
fallback runs a bounded one-year live walk + fires
`ensureUserMoodRollupsFresh` so the next request lands on the rollup
tier (same posture as /api/mood/analytics post-v1.4.39).

Existing test fixture updated to mock `moodEntryRollup` + the
warm-up; six features tests stay green.
2026-05-21 11:52:32 +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é Bombeck 691443e264 test(query-keys): pin factory enforcement and dedup behaviour
Three test suites land together:

1. queryKeys factory contracts for the four new v1.4.40 entries
   (`authMe`, `userThresholds`, `chartData`, `dashboardMedicationCompliance`)
   — pin the byte-stable shapes so a future rename of any literal
   lands a matching test failure rather than silently breaking cache
   layout.

2. `measurementDependentKeys` / `medicationDependentKeys` bundle
   membership — `["chart-data"]` and
   `["dashboard-medication-compliance"]` prefixes now ride the
   bundles so a mutation evicts every per-chart and per-compliance
   cache in one pass (audit-C2 + audit-L4).

3. Factory-bypass guard — walks `src/components/charts`,
   `src/app/page.tsx` and `src/hooks/use-auth.ts` and fails if a
   literal `queryKey: [...]` slipped past the factory. Acts as a
   lint-style enforcement check in the absence of a custom ESLint
   rule. The guarded directory list is intentionally scoped tight to
   the files this wave migrated; a follow-up wave can extend it as
   it routes the remaining admin/settings/medications surfaces
   through the factory.

Plus a dashboard Suspense-boundary structural test that pins the
per-tile `<Suspense fallback={...}>` wrappers, the module-scope
`DASHBOARD_QUERY_OPTS` hoist, and the `useMemo` around the hour-of-day
derivation. A refactor that drops any of those three structural
guarantees lands red CI rather than silently regressing the
streaming-composition contract.

All 16 + 5 new assertions green; tests 4565 → 4586.
2026-05-21 11:50:37 +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