Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
Removes the operator step that was implicit in the v1.4.35 foundation
release. Self-hosters no longer need to run scripts/backfill-rollups
after upgrade; the worker boot discovers any account with measurements
but no rollup coverage and enqueues a one-shot full fold via pg-boss.
New rollup-full-backfill queue + handler folds one user per job at
serial concurrency. enqueueBootTimeRollupBackfill runs once at
startReminderWorker boot, idempotent across reboots via the
discovery query (matches only zero-rollup accounts) and a
singleton-key per user so fast restarts coalesce.
Best-effort: discovery errors surface through the helper's return
value and are logged via workerLog; the worker boot never fails
because the backfill missed.
Tests: 4285 unit + 230 integration. tsc + lint clean.
Foundation release for the persistent measurement_rollups cache tier.
Adds the additive table + enum, populates it from every measurement
write hook, lands the Apple-Health-import end-of-job backfill, and
ships the parity-checked read-swap on two reader surfaces.
The comprehensive insights aggregator and the slim analytics
summaries slice now source count / min / max / mean per type from the
DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets. A defensive parity check against the
live aggregate's COUNT(*) falls back to live SQL when divergence is
detected.
Includes the new scripts/backfill-rollups.ts CLI for the one-shot
fold of historical measurements into all four granularities.
Tests: 4280 unit + 228 integration. tsc + lint clean. Migration
0067_v1434_measurement_rollups is idempotent under re-run.
Hotfix on top of v1.4.34. The Insights page mount was paying ~29 s on
every cold load against accounts populated by the v1.4.34 Apple Health
importer. Root cause: `/api/insights/comprehensive` walked the unbounded
90-day measurement set into JS, then ran per-type `summarize()`, BMI,
blood-pressure classification, target adherence, and four Pearson
correlations from the same pile of rows. On a 100 000+ row account the
route consumed one of the 20 pool connections for the whole 29 s, which
cascaded sibling endpoints into Cloudflare "no available server" 503s.
The scatter-correlation card also surfaced a `width(-1)/height(-1)`
Recharts warning on first paint because its dynamic-mount lacked a
non-zero floor before aspect-ratio resolved.
Changed:
- /api/insights/comprehensive now reads through a SQL-side aggregator
(src/lib/insights/comprehensive-aggregator.ts) grouping by metric
type with Postgres-native AVG / MIN / MAX / REGR_SLOPE / REGR_R2 on
the measurements (user_id, type, measured_at) index path. Returns
the same DataSummary-shaped per-type bundle the legacy route
stitched together; the medication-compliance block keeps its
bounded Prisma reads. Wrapped through the server cache keyed on
`${userId}|comprehensive` (60 s TTL). Cold path drops from ~29 s
to a few hundred ms against fixture data matching the production
volume that triggered the bug; warm path resolves in single-digit
milliseconds.
- Cache-wrap the five remaining read routes the v1.4.34 server-cache
blueprint left staged but unwired: /api/mood/analytics,
/api/workouts, /api/bugreport/status, /api/medications,
/api/dashboard/widgets. The Cloudflare 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream
consequence — once the comprehensive route stops monopolising the
pool, the origin accepts new connections fast enough that
Cloudflare's circuit-breaker no longer trips.
- /api/dashboard/chart-overlay-prefs PUT now invalidates the new
dashboard-widgets cache so the partial-update path doesn't leave
stale layout state in the cache for up to TTL.
Fixed:
- Scatter-correlation card no longer logs the Recharts
`width(-1)/height(-1)` warning on first mount. The wrapper now
carries a min-h-[180px] floor that matches the loading skeleton so
ResponsiveContainer always measures a non-zero parent before
aspect-ratio settles.
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.
Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.
Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.
The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).
See CHANGELOG.md for the full per-section delta.
Mostly table column normalisation under docs/audit/ + planning, object-
literal expansion in src/lib/ai/coach/snapshot.ts after the W4 F6 Apple
Health metric block landed, and small wrap/unwrap line adjustments in
the device + APNs routes / tests touched across W3/W4/W5 sessions.
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.
Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.
Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer note: src/app/api/analytics/route.ts fanned out 17+
unbounded prisma.measurement.findMany per request — growth linear
in HealthKit-days. Replace with prisma.groupBy where possible;
chunked-paged with take=5000 where running-window slope or paired
correlations require row-level access. Wide-event annotation
analytics.bp_aggregate.row_count for slow-query attribution.
Fixes: Sr-H1 (Wave-6 v1.4.23 senior-dev review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dispatcher's APNS branch only fires when a NotificationChannel of
type APNS exists for the user, but no production code path created one.
Every iOS install needed a manual settings toggle that doesn't exist,
making the v1.4.23 W3 APNs cascade unreachable in real traffic.
When `POST /api/devices` accepts an apnsToken, eagerly upsert the
channel row with an encrypted empty config — the per-device token and
environment live on the Device row by design, so the channel only
exists to flip the dispatcher's gate. Mirrors the legacy Telegram
on-first-dispatch auto-migration but eager so the very first reminder
after registration routes through APNs.
Adds an integration test that registers a device with apnsToken and
asserts the channel row exists, registers without one and asserts
absence, and re-registers the same apnsToken and asserts idempotency
(channel count stays at one).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.
Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.
Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.
Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.
Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.
UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.
OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.
Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.
Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.
Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.
Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.
Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.
UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.
Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.
Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The W2-of-v1.4.20 BD-Zielbereich fix needed the complete sys + dia
series to compute the all-time + prior-month + prior-year windows,
so it pulled both tables via single-shot `findMany`. For a 5-year
power user (~9 000 rows per side) that's a 50-100 ms Prisma round-
trip and a ~2 MB working-set allocation per /api/analytics request.
Switch to a cursor-paged 5 000-row chunked read via the new
`fetchBpSeriesChunked()` helper. The accumulated `BpReading[]` is the
same shape `computeBpInTargetWindows()` already consumed; the
working-set ceiling is now `BP_CHUNK_SIZE` instead of unbounded. Safety
bound at 1 000 pages = 5 M rows so a cursor-staleness bug can't loop
forever.
Wide-event annotation `analytics.bp_in_target.row_count` (+ per-side
`sys_rows` / `dia_rows`) lets ops attribute slow requests to outlier
users without rerunning the DB queries.
Integration test seeds a 6 000-row dataset (3 000 sys + 3 000 dia)
spanning a chunk boundary and asserts the response shape stays the
same — last30Days, last7Days, allTime windows all carry the
hand-derivable percentages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 unit tests under `senders/__tests__/apns.test.ts` exercise:
* env-var loader — all-or-none guard, inline-PEM `\n` unescape, file
fallback path, cache, APNS_PRODUCTION override
* Provider lazy-init + per-gateway cache (sandbox vs production stay
isolated; APNS_PRODUCTION funnels every send through production)
* permanent-failure detection (`Unregistered`, `BadDeviceToken`,
`DeviceTokenNotForTopic` flip `shouldDisable: true`; transient
reasons stay false)
* `sendViaApns()` dispatcher entry — fan-out, dead-row cleanup,
hard-vs-soft channel-reject classification, HTML stripping
3 integration tests under `tests/integration/apns-dispatch.test.ts`
drive the real dispatcher against a Postgres testcontainer with a
stubbed `@parse/node-apn` Provider:
* Round-trip: notification fans out via APNs and the channel-state
machine records `lastSuccessAt` + clears any cooldown
* Failure path: `Unregistered` deletes the dead Device row,
auto-disables the channel with the captured reason, and writes the
`notification.channel.auto_disabled` audit-log entry
* Cascade: a disabled APNS channel falls through to Telegram on the
next dispatch — the apns send-mock stays untouched
Adds `senders/__mocks__/apns.ts` — a deterministic mock with queued
per-token responses + recorded calls so future test files can swap the
real sender for a fixture-driven double without touching `@parse/node-apn`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The W2 commit shipped with `as never` casts on the request + params
arguments, which let the runtime test pass but failed strict tsc
because `apiHandler<() => Promise<Response>>` narrows the wrapper's
signature down to its inner handler's. Cast through `unknown` so the
wrapper-side `request.url` read works and tsc is satisfied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple Health stores sleep as one Measurement row per stage per night,
so the existing single-value SLEEP_DURATION summary would treat each
stage as its own datapoint and grossly understate the per-night total.
The route now sums per Berlin day before summarising — one summary
datapoint per night, value equal to total minutes asleep.
A new optional `sleepStages` block surfaces the per-stage breakdown
over the trailing 30 days when stage-tagged rows exist (and `null`
otherwise so the existing UI keeps painting the totals-only path).
The block reports total minutes, the night count covered, and the
per-stage minute sums so the upcoming insights surface can render
"averaged across N nights" captions truthfully.
Two integration tests cover both shapes — a four-stage night
aggregating to 880 minutes with a populated stages map, and a
manual-source no-stage row keeping `sleepStages` at null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`POST /api/measurements/batch` accepts up to 500 HealthKit-shaped
entries per call, runs each through the new mapping helper, and writes
them under `source = APPLE_HEALTH` with the inbound sample UUID as the
external id. The composite unique index on
`(user_id, type, source, external_id)` does the per-entry dedup;
`Idempotency-Key` covers the batch-level retry case so the iOS observer
queue can replay in flight without double-writing.
Per-entry status (`inserted` / `duplicate` / `skipped`) lets the
client advance its sync cursor accurately even when individual rows
land on different paths. Skip reasons cover unknown HealthKit
identifiers and out-of-plausible-range values so a single bogus sensor
reading never rejects an otherwise clean batch.
Insertion uses chunked `createMany` with `skipDuplicates: true` so a
race between two concurrent retries reconciles cleanly via the unique
index instead of failing the transaction. Wide Event annotation logs
`processed / inserted / duplicates / skipped` for ops visibility.
Six integration tests against a real Postgres testcontainer cover the
happy path, the per-entry duplicate path, the over-cap 422, mixed
unmappable + out-of-range entries, per-stage sleep rows, and
`Idempotency-Key` replay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `setOnboardingPendingCookie` helper was called from six different
auth routes (login, passkey-verify, register, password, /me,
/onboarding/complete) by convention. A future auth surface (SSO,
magic link, social login) added without remembering the helper would
silently re-introduce the dashboard flash that v1.4.22 C4 was
designed to fix.
Make `onboardingPending` a required parameter on `createSession`
(not optional) so issuing a session without anchoring the cookie is
type-impossible. The five primary auth surfaces now thread the
user's `onboardingCompletedAt == null` value through; the
`/api/auth/me` mirror remains as a fall-back for legacy sessions
that predate the cookie. The `/api/onboarding/complete` clear stays
where it is — it is a value flip after a DB write, not a session
issuance.
Three new integration tests pin: createSession sets the cookie when
pending, clears it when completed, and destroySession clears it
alongside the session cookie.
Closes Sr-H1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.
Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.
Closes Code-H1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to v1.4.19 the BD-Zielbereich tile pinned 50/50/50 because the
route routed the headline through `windows.last30Days?.pct` — making
the headline a literal copy of the `30T` sub-value. v1.4.19 A1
re-anchored the headline to all-time so the three windows could
diverge. That fixed the algorithmic pin but introduced an emotional
problem: the headline became the slowest-moving aggregate possible,
punishing users who had recently dragged their compliance up. A user
seeing 7d 50 % / 30d 50 % alongside a stubborn 11 % all-time read it
as "your work doesn't count yet."
Re-anchor the headline to `windows.last30Days?.pct` and surface
all-time as a third sub-value (`bpInTargetPctAllTime`) so power users
still see the long-arc number without it dominating. The integration
test pins the new contract: with three legitimately divergent windows
the route's `bpInTargetPct` equals `last30Days.pct`, and the new
all-time field carries the historical figure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route now builds the four-component Health Score input from the
already-fetched BP figure plus a fresh weight + mood + medication
intake fetch (last 30 days, plus a 7-day-shifted snapshot for the
delta). The response gains a `healthScore: { score, band,
components, delta } | null` field; null when no component is
computable so the UI hero panel can hide cleanly.
The wide-event annotation carries `healthScore.score` + `band` +
`delta` so admin-app-logs can attribute coverage without DB queries.
The previous-week BP rate reuses the all-time figure (it's a slow-
moving aggregate; rewinding it cheaply isn't possible without a
full historical re-pair). Delta therefore primarily reflects week-
over-week movement in weight / mood / compliance — the components
that actually move on a weekly cadence.
Integration coverage seeds three synthetic-data shapes against the
postgres testcontainer:
- strong-positive → green band (>= 75)
- empty user → null healthScore
- compliance-only → bp/weight/mood null, compliance weight 1.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/insights/chat returns the caller's conversations newest-first
with cursor pagination (default limit 20, hard cap 50). Each row
carries id, title, timestamps, and a message count so the rail can
render without paying the per-message decrypt cost.
GET /api/insights/chat/[id] fetches one conversation with every
message decrypted in memory before serialisation. Provenance metadata
round-trips alongside the assistant turns.
DELETE /api/insights/chat/[id] hard-deletes the thread; the FK chain
cascades the message rows.
Cross-user lookups map to 404, never 403, so the existence channel
never leaks across accounts.
6 integration tests cover the list ordering, owner-only filter,
decryption round-trip, and the 404 boundary on both verbs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route gates on requireAuth (cookie or bearer), enforces the daily
token budget, runs the prompt-injection / off-topic refusal scanner,
and only then walks the user's provider chain.
The conversation is created via withIdempotency() when no
conversationId is supplied, so a retry under the same Idempotency-Key
replays the original assistant reply rather than spinning up a second
thread. Existing-thread turns skip the wrap.
Replies stream as SSE: one "token" frame per word-sized chunk, then a
"provenance" frame describing the windows + metrics the assistant
could see, then a "done" frame carrying the persisted message id.
Provider failure cascades to a single "error" frame and the assistant
message is never persisted. Conversation history is capped at 20 turns
in the prompt window via a synthetic summary placeholder so cost stays
bounded.
`buildCoachSnapshot()` reuses the analytics features pipeline so the
Coach narrates the same numbers the dashboard tiles render.
`getCoachSystemPrompt()` extends the strict-insight ground rules with
conversational framing while keeping the zero-hallucination rule.
5 integration tests pin the round-trip, refusal short-circuit, budget
429, foreign-conversation 404, and validation 422 paths against the
postgres testcontainer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Persist Coach chat threads with AES-256-GCM-encrypted message bodies
and a per-user, per-day token-spend ledger. Encryption reuses the
active ENCRYPTION_KEYS entry so rotation continues to work without a
backfill. metric_source_json carries provenance labels only (window +
metric names, never raw values), keeping it queryable for analytics.
Cascade-on-user-delete is wired through the FK chain so GDPR delete
removes Coach data with the rest of the account. The integration-suite
truncation list picks up the new tables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Marc reported the BD-Zielbereich tile pinned at exactly 50/50/50 for
7T, 30T, and the headline ("total") on his real production data — the
fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 → v1.4.19).
Root cause: the analytics route routed `bpInTargetPct` (the headline)
through `windows.last30Days?.pct` — a literal copy of the `30T`
sub-value. Since `computeBpInTargetWindows` only returned 7d + 30d
windows, the headline could never legitimately differ from `30T`. For
Marc's real data (572 paired readings since 2022, recent 30d = 50 %,
all-time ≈ 11 %) the tile read 50 % / 50 % / 50 % and looked
algorithmically pinned — exactly the symptom he reported.
The previous three passes treated this as either a predicate bug
(v1.4.16 ceiling-vs-narrow-band, real and merged) or a missing
`avg7`/`avg30` wiring (v1.4.18, also real and merged). Both fixes
landed correctly, but neither addressed the headline duplication.
Fix:
- `computeBpInTargetWindows` now returns a third `allTime` window
that aggregates EVERY paired reading in the input series.
- The analytics route fetches all paired BP rows (no 30-day cutoff)
and routes the headline through `windows.allTime?.pct`. The route
only re-pairs once internally so the cost is paid once per request.
- The 7T / 30T sub-values are unchanged (still windowed slices of
the same input).
Failing test reproduces the bug with Marc's data shape (`allTime` MUST
not equal `last30Days` once older history is present); fix turns it
green. Integration test seeds 40 paired readings against real Postgres
and asserts all three windows compute independently.
Hand-counted against Marc's prod data:
- 7T: 50 % (1/2) — matches what he sees, real coincidence
- 30T: 50 % (5/10) — matches what he sees, real coincidence
- Headline: ~11 % (62/572) — was 50 %, now correct
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two trigger fixes so the new achievements actually fire end-to-end.
The doctor-PDF hidden Easter-egg used to watch a non-existent
`doctor-report.export` audit action. The real route emits two:
`doctor-report.generate` (the JSON helper) and
`doctor-report.pdf.generate` (the PDF route). Either now counts.
The locale-flip hidden Easter-egg used to watch
`settings.locale.update`, which no endpoint emits — the locale
field shares the catch-all `profile.update` audit row with every
other profile field. Profile-update now emits a separate
`settings.locale.update` row when (and only when) the locale value
actually changes (compares the new value to the prior persisted
locale, suppresses the row when they match), with the from/to
values in the details payload for traceability.
Already-existing TanStack Query invalidation map covers the new
metrics: `measurementDependentKeys`, `moodDependentKeys`, and
`medicationDependentKeys` already include
`queryKeys.gamificationAchievements()`, so any measurement / mood /
intake mutation triggers a refetch and the route persists the new
unlock to `UserAchievement` (existing v1.4.15 mechanism).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new layers of coverage for the v1.4.18 expansion set:
- Unit (expansion-metrics.test.ts) — drives synthetic data through
every new evaluator (count by type, mood mean improvement, weekend
warrior, hidden Easter-egg triggers including the Berlin TZ
classifier for night-owl / early-bird) and the earnability flag
builder. 13 new assertions.
- Integration (achievements-expansion.test.ts) — round-trips real
prisma rows (MoodEntry's YYYY-MM-DD `date` string, Measurement
type discrimination, audit log row) through
`buildExpansionMetricValues` so a schema drift in any of the row
shapes surfaces here rather than at runtime. We don't drive the
full HTTP route in this spec because of pre-existing
medication_schedules.days_of_week migration drift.
- E2E (achievements.spec.ts) — the existing sidebar smoke gets a
sibling describe-block exercising all four card states (unlocked /
locked-earnable / hidden-locked / hidden-unlocked) and asserting
hidden-locked cards never leak their real strings to the rendered
HTML, which is the entire point of the "hidden" state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.
Wires up:
- GET /api/dashboard/widgets surfaces the resolved prefs in its
response so a fresh dashboard load knows which overlays to paint.
- PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
prefs without round-tripping the whole layout.
- Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
the client doesn't send, so saving widget visibility doesn't wipe
chart prefs.
- useChartOverlayPrefs hook reads + optimistically updates the cache.
- HealthChart, MoodChart, MedicationComplianceChart all consume the
hook, render the controls dropdown next to the range tabs, and gate
their three overlays behind the persisted toggles.
Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The tile rendered the 30-day headline correctly but the `7T:` and
`30T:` sub-values fell back to "—" even when the user had paired BP
readings in both windows. Root cause was that `/api/analytics` only
returned a single `bpInTargetPct`; the tile received `avg7={null}`
and `avg30={null}` and the TrendCard rendered the dash placeholder.
Add `computeBpInTargetWindows()` next to the existing `computeBpInTargetPct()`
helper. It filters the input series by `measuredAt` against the supplied
clock and runs the same pair-and-count logic over the trailing 7-day and
30-day windows. The route fetches the last 30 days of data once, derives
both shares from the result, and surfaces them as `bpInTargetPct7d` and
`bpInTargetPct30d` on the response. The dashboard tile maps them to
`avg7` and `avg30` so the sub-values now show the actual percentages.
Coverage:
- 6 unit tests for the windowed helper (empty input, mixed window data,
null-7d-with-real-30d, default-clock, Marc's production fixture).
- 2 integration tests against testcontainers seeding 30 days of paired
BP measurements; both windows produce the expected hand-counted shares.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.
Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.
The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.
Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.
Tests:
- 7 unit tests for the Zod request schema (valid path, severity
poisoning, time-range poisoning, empty fields, stringified bool,
extra-field stripping)
- 5 unit tests for `pickProviderType()` priority + fallback
- 4 integration tests: 201 happy path with provider attribution,
409 dedup, 422 validation, fallback to unknown when no audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.16 phase B5e: ship the table that backs per-recommendation
thumbs-up / thumbs-down feedback. Each row captures (userId, recId,
recText) with snapshotted severity + metric-source attributes plus the
provider + prompt-version that produced the rec — server-fills the
attribution from the user's most recent cached payload so the client
can't tamper.
Dedup key (userId, recommendationId, recommendationText) lets a
regeneration that rewrites the same id with new text re-rate cleanly
while blocking accidental double-submission of the same (id + text).
Indexes: (userId, createdAt) for the per-user timeline and
(severity, helpful, createdAt) for the daily aggregator's slice. Both
are needed by the v1.4.16 admin AI quality preview.
GDPR: ON DELETE CASCADE through users; cascade-delete integration
test now seeds and asserts a feedback row.
Same migration adds AppSettings.adminAiInsightsFeedbackSummary — the
JSON blob the daily aggregator (next commit) writes a per-(severity x
provider x prompt) helpful-rate to. Null until the aggregator runs
or until there is feedback in the 30-day window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two scenarios pinned end-to-end against the postgres testcontainer:
saving a valid reordered chain persists the JSON column with priority
normalised to 1-indexed insertion order, and saving an empty chain
rejects with 422 + leaves the column untouched. No fetch mocks; the
route runs against the real Prisma client so a future schema drift on
User.aiProviderChain trips the suite immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end integration test for the multi-provider redundancy runner.
Boots the postgres testcontainer, seeds a user with both an OpenAI key
and an Anthropic key plus an explicit chain (openai -> anthropic),
intercepts upstream HTTP calls via fetch-spy, and exercises three
scenarios:
1. Hard-fail-then-success cascade. OpenAI primary returns 401, the
runner walks to Anthropic, the route surfaces a 200 with
`chainProviderType: "anthropic"` and `fallbackHopCount: 1` recorded
in the audit log.
2. All-fail cascade. Every chain entry returns 503, the route surfaces
a 503 with the "temporarily unavailable" envelope.
3. Last-working cache. After scenario 1 the runner remembers Anthropic
was the working provider; the next call (cache bypass forced via
nulled `insightsCachedAt`) reorders the chain so Anthropic is tried
first and only ONE upstream fetch fires (not two — the doomed
OpenAI 401 round-trip is skipped).
Test routing on URL substring (`/chat/completions` for OpenAI,
`/messages` for Anthropic) instead of headers so the assertion is
robust against any future header-shape drift in the clients.
Constrained to user-level providers because the testcontainer
migration history pre-dates several `app_settings` columns added
through schema-only edits — touching admin-openai resolution would
blow up before the chain runner even sees the request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The integration suite that landed in commit 226cac4 still imported
the dotted-segment routes (`@/app/api/export/measurements.csv/route`)
that vitest can't resolve. Realign the import paths and request URLs
to the actually-shipped `/api/export/{kind}` segments. 9 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Host-load chart above /admin/system-status renders the last 2h of
load1, memory %, and disk-IO % from a per-minute pg-boss sampler.
Schema migration 0032_host_metric, /api/admin/host-metrics endpoint,
HostMetricsChart component (Recharts wrapper, next/dynamic from the
consumer), system-status section wired with the chart on top, EN+DE
i18n keys, 13 unit + 6 integration tests.
Three commits landed cleanly under their own titles (db, jobs, api);
the chart + wiring + i18n got absorbed into another agent's commit
due to a parallel-agent staging race during the verification gate's
stash/restore loop. Functionally everything is on origin/main.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backs the new chart above /admin/system-status. Reads HostMetric rows
captured by the per-minute sampler and returns the window the chart
asks for via `?since=` (30m / 1h / 2h / 6h / 12h / 24h, default 2h).
Per-row response shape is what Recharts can plot directly:
- capturedAt ISO-8601
- loadAvg1 number
- memUsedPercent 0..100, rounded to 2 decimals
- diskReadBps / diskWriteBps number | null
Disk fields are derived server-side from the cumulative byte counters
the sampler captured. Computing the per-second delta in the route
keeps the chart component stateless and means a hypothetical CLI / iOS
admin view gets the same units. The first row in any window has no
predecessor, so disk fields are null. A negative delta — counter reset
on host reboot — also surfaces as null instead of a misleading negative
BPS spike.
`meta.memTotalBytes` carries the most recent total so the tooltip can
render an absolute "4.2 GiB / 8 GiB" without a second round-trip.
requireAdmin()-gated: host telemetry is admin-only, never exposed via
Bearer tokens. apiHandler wrapper handles the wide-event annotation
("admin.host-metrics.list") and request-id propagation for free.
6 integration tests against the testcontainer Postgres pin auth
gating, the 2h default window, the diskBps derivation across a
counter-reset, the `?since=30m` filter, and the empty-DB happy path.
Adds the per-minute snapshot table that backs the new admin host-load
chart above /admin/system-status. One row per minute, captured by the
pg-boss sampler in the next commit, with 7-day retention enforced
inside the worker so the table stays under a few thousand rows.
Schema mirrors what the chart needs:
- load_avg_1/5/15 (DOUBLE PRECISION) — os.loadavg() output
- mem_used_bytes / mem_total_bytes — derived from os.totalmem() and
os.freemem(), kept as raw bytes so the API can compute either a
percent or a MiB delta downstream
- disk_read_bytes / disk_write_bytes — nullable cumulative byte
counters from /proc/diskstats. NULL on macOS/Windows dev hosts;
Linux production picks them up automatically. The API endpoint
converts deltas to bytes-per-second between samples
Indexed by captured_at so the "last 2 hours" query stays index-only.
Migration 0032_host_metric. integration setup.ts adds host_metrics to
the truncate list so testcontainers stays clean across runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.
Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".
Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.
Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
exact production fixture (10 paired readings -> 50% under v1.4.16,
was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
testcontainer with Marc's reading shape + null-tolerance + hypotension
edge cases.
Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B2, criterion 4. Every IntegrationStatus failure path
now writes a single AuditLog row with a documented schema, and this
commit lights up the real-Postgres regression guard for it:
action : "integrations.sync.failed"
userId : <subject>
details : {
integration: "withings" | "moodlog",
kind: "transient" | "reauth_required",
errorCode: string | null,
message: string, // plaintext (not encrypted —
// audit table is admin-only)
attemptNumber: number, // 1..N consecutive
state: "error_transient" | "error_reauth"
}
Successes are NOT audited — that path would be far too noisy
(every webhook-triggered sync is a row, ~ten times a day per active
user) and the integration_statuses.lastSuccessAt column already
tracks the same signal at one row per integration per user.
The integration test in tests/integration/integration-status.test.ts
provides 8 cases against a real Postgres testcontainer:
- failure writes both the integration_statuses row AND a matching
AuditLog entry, with the structured meta validated field-by-field
- lastError is encrypted at rest (column never matches plaintext)
- getIntegrationStatus() decrypts cleanly on read
- success clears the streak AND does NOT write an audit row
- admin Telegram alert fires once per burst (24h re-alert window)
- reauth_required parks the integration so isReauthRequired() is true
- markReconnected() clears the parked state without writing a fresh
success — that's the next sync's job
- markDisconnected() preserves the row as a tombstone but wipes
error state
- CASCADE delete on User removes the IntegrationStatus row
Setup file additions: integration_statuses added to the truncate-list
in tests/integration/setup.ts (already shipped under commit 4db72a8;
this commit only adds the test file that consumes it).
Tests: 11 / 39 integration pass. The dispatcher is module-mocked so
the test doesn't actually hit Telegram — we only assert that the
helper INVOKED the dispatcher with the right payload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.
This commit adds:
- New `IntegrationStatus` table (`integration_statuses` migration
`0029`). One row per (user, integration) capturing `state`
(`connected | error_transient | error_reauth | disconnected`),
`lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
`consecutiveFailures` counter, `alertedAt` window-guard.
- `src/lib/integrations/status.ts` — the only writers
(`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
`markReconnected`, `markDisconnected`) plus a read-only
`getIntegrationStatus()` for the API. The error column is
AES-256-GCM encrypted at rest because Withings/moodLog 401s can
echo URL/apiKey fragments — we can't ship that into a future
backup tarball.
- `GET /api/integrations/status` — single roundtrip returns both
Withings and moodLog snapshots plus the global threshold (so the
UI's "{n}/{threshold}" string is server-sourced and rotates with
the env var).
- Settings → Integrations cards both gain an
`IntegrationStatusBanner` row above the credentials form: state
badge ("Connected" / "Reconnect required" / "Error" /
"Disconnected"), `{n}/{threshold} consecutive failures` chip,
last-success / last-attempt times, and a destructive-toned
last-error line. The banner is suppressed entirely when the
integration is fresh-connected with no history (no false alarms).
Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.
Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, `disabled_reason`
captured, audit log `notification.channel.auto_disabled` written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter `consecutive_failures` increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason `give_up_after_5_failures` (criterion 2's "treat as
hard reject after N failures" rule).
- COOLDOWN window (`next_retry_at > now`) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to `notification_channels`:
`disabled_reason`, `consecutive_failures`, `last_success_at`,
`last_failure_at`, `last_failure_reason`, `next_retry_at`. All
nullable / zero-default → no-op for existing rows.
- `src/lib/notifications/retry-policy.ts` exports the classifier
(`classifyTelegramError`, `classifyHttpStatus`) plus
`BACKOFF_SCHEDULE_MS` (frozen) and `nextRetryAt(count, now)` /
`shouldAutoDisableAfterTransient(count)` helpers.
- `src/lib/notifications/channel-state.ts` is the only writer of
the new columns: `recordChannelSuccess`, `recordChannelHardReject`,
`recordChannelTransientFailure`, `reEnableChannel`,
`isChannelInCooldown`. Each transient/hard path writes an audit
entry so the user can trace WHY the channel went silent.
- Senders now return a structured `SendOutcome` (`{ ok, hardReject?,
statusCode?, reason?, message? }`) instead of bare booleans. The
Telegram sender additionally exposes `errorDescription` from the
Bot API so "chat not found" / "bot was blocked" can be classified
without parsing free-form text in the dispatcher.
- Dispatcher: cooldown-skip → success → hard-reject branch →
transient/give-up branch. Sender-throws are absorbed as transient
failures (defence-in-depth).
Tests: +20 unit tests (`retry-policy.test.ts`, `channel-state.test.ts`,
`dispatcher.test.ts`). 883/883 pass. Hard-reject scenarios covered:
web-push 410, Telegram chat-not-found, ntfy 410. Soft-reject
scenarios: 429 schedules backoff without disabling; cooldown skip
prevents repeat call; 5th in-a-row triggers give-up + audit.
Pre-existing bug fix: `src/app/api/settings/telegram/test/route.ts`
had `if (!ok)` against a `SendMessageResult` object — always truthy,
so the route never reported test-send failures. Now reads
`result.ok`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/admin/backups/[id]/restore replaces the target user's
personal data with the contents of the snapshot. This is the riskiest
endpoint in the backup family, hardened with five independent gates:
1. requireAdmin() — cookie-session admins only, never a Bearer token.
2. Body must carry confirm: "RESTORE" (rejected with 422 + audit
denial otherwise).
3. UI gates the destructive button behind a typed-string match —
three independent steps before fire (open dialog, read warning,
type RESTORE).
4. withIdempotency() wraps the handler so duplicate clicks with the
same Idempotency-Key replay the original outcome instead of
re-executing the destructive transaction.
5. Pre-validation of measurement-type / measurement-source /
intake-source enums OUTSIDE the transaction so a malformed
payload can't half-wipe the user before failing the create step.
Restore scope mirrors the v1.4.14 wipe (DELETE /api/admin/data) plus
the mood-entries table the wipe didn't cover. AuditLog rows are
intentionally PRESERVED across the restore — the audit trail is the
last thing that should be touched by a destructive op.
Transaction:
- Delete user's measurements, medications (cascades to schedules +
intake events + reminder configs + telegram messages), mood
entries, notification channels, push subscriptions, telegram
scheduled deletions.
- Re-create from payload: measurements (skipDuplicates), medications
+ their schedules in one create call, intake events keyed by
medication name -> id (orphans dropped silently), mood entries
(skipDuplicates).
Audit:
- admin.backups.restore.start (intent, before transaction)
- admin.backups.restore (success, after transaction)
- admin.backups.restore.denied (5 reasons, no transaction ran)
- admin.backups.restore.failed (decrypt / schema / enum / tx error)
UI:
- New <RestoreRowDialog> subcomponent renders an AlertDialog gated
on a typed-RESTORE Input. Description carries the target username
and the snapshot timestamp so the admin sees exactly which
backup they're about to restore. Confirm button stays disabled
until typed string matches.
- Restore button sits next to Download in each row's action column.
- Idempotency-Key built client-side as restore-<rowId>-<uuid> to
de-duplicate accidental double-clicks.
Tests: 3 new integration tests in
tests/integration/admin-backups-restore.test.ts covering the round-
trip (wipes pre-existing data + recreates from snapshot),
confirmation-token rejection, and 404 on unknown id. 21/21
integration tests pass; 69/69 unit tests on src/lib/validations and
src/components/admin still green.
Note for v1.4.16: the restore test queries `medication_schedules`
with explicit field selection because a sibling branch added
`days_of_week` to the schema without a matching migration; the
default `findMany` would 500 against the deployed test DB. When that
migration lands, the explicit select can be relaxed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>