Commit Graph
2206 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 ef21967d12 refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.

`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.

`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 c97c4c3be3 feat(meds): wizard polish — 560 px shell, intent-aware landing, omit notifications on edit
Six changes the v1.5.5 detail page leans on:

- Desktop shell widens from `sm:max-w-md` (448 px) to `sm:max-w-[560px]`
  so the strip + body stay readable on the wider section icons the v1.5
  shell ships. Mobile bottom-sheet keeps the 40 dvh floor so short
  screens never collapse the wizard to a stub.
- Outer spacing scales to `p-4 pr-12 sm:p-6 sm:pr-14` so the close-X
  has room to breathe and the strip + body share one column. The
  fade-only `animate-in fade-in-0 duration-200 motion-reduce:animate-none`
  on the step body keeps motion to one vocabulary — the slide proposed
  earlier was stripped.
- `<Progress>` gets the explicit `transition-all motion-reduce:transition-none`
  so width-only progress reads as the one source of motion on the
  strip.
- `landingStepForEdit` takes an optional `intent` arg (`"cadence"` /
  `"name"` / `"summary"`); the detail page's cadence-summary row uses
  `"cadence"` so the user lands on Step 5 instead of Step 1. One-shot
  payloads fall back to Step 8 because the cadence picker is not on
  their path.
- The step `<h2>` carries `tabIndex={-1}` and receives focus on
  intent-routed edit-opens so the screen reader announces the step
  the user actually landed on, not the first input on it.
- `buildCreateBody` takes `forMode: "create" | "edit"` and omits
  `notificationsEnabled` on edit. The detail-page notifications
  switch is the single source of truth post-create; the wizard now
  refuses to round-trip its default back to the API on edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:30:35 +02:00
Marc-André BombeckandClaude Opus 4.7 af2249644b fix(meds): route every detail-page sub-route through assertMedicationOwnership
The intake purge endpoint, the parent medication PUT, the parent DELETE
and the CSV-import POST each hand-rolled the same `findUnique +
userId !== id ? 404` block instead of using the shared
`assertMedicationOwnership` helper that every other
`src/app/api/medications/[id]/**` handler already uses. The hand-rolled
checks read slightly different fields and skipped the helper's narrow
`select`, which leaked extra column data into routes that never needed
it. Routing each of the four through the helper keeps the 404 leak
shape on a single line, prepares the purge endpoint for promotion to a
user-discoverable surface on the new detail page, and removes one of
the open audit findings against the medications API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:27:02 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 e93feaf8d1 feat(dashboard): emit i18n keys instead of hardcoded German strings
The /api/dashboard/summary endpoint returned per-metric `title` and
`unit` as translated German strings ("Gewicht", "Schritte", …), which
injected DE into clients with an English UI. The wire now carries i18n
keys (`titleKey` / `unitKey`) under a new `dashboard.metric.*`
namespace; the PWA's `useTranslations()` and the iOS client's bundled
`Localizable.xcstrings` each resolve against their own locale bundle.

Locale parity preserved across de, en, es, fr, it, pl (DE / EN native,
the rest matched against the existing measurement-type labels). The
locale narrowing in `buildContext` is widened to accept every shipped
locale rather than dropping fr / es / it / pl back to the default — the
greeting + streak label now resolve in the user's configured language.

The OpenAPI yaml drift was a pre-existing 1.5.3 → 1.5.4 version-string
gap; regenerating picked it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:46 +02:00
Marc-André BombeckandClaude Opus 4.7 a75b96b2a9 feat(insights): server-persist insights tile order + visibility
Mirrors /api/dashboard/widgets for the insights surface: GET returns
the user's saved layout (defaulting to project order when unset),
PUT writes the layout, the response is the canonical shape the
clients can render directly. Closes the cross-device drift where
insights ordering lived in iOS local state only.

Storage is a new `insights_layout_json` JSONB column on `users`,
parallel to `dashboard_widgets_json` so each surface's resolver owns
its tile-id allowlist. Migration 0083 adds the column idempotently;
default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes
through the shared multi-issue 422 envelope + payload-redaction +
audit-row dedup pattern the dashboard-widgets route established.
A 5-minute per-user cache mirrors the dashboard-widgets cache and
busts on every save / reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:40 +02:00
Marc-André BombeckandClaude Opus 4.7 19cfd69ff1 feat(measurements): accept restingHeartRate, hrv, vo2max in the series enum
The iOS app surfaces these as series-capable, but the kind enum on
/api/measurements/series did not accept them — opening the detail
or trend view returned 422. The underlying MeasurementType enum
already carries the values; this commit extends the route-side
enum so the wire shape matches the data shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 26f13c5064 feat(measurements): raise the series days cap to ten years
The 365-day cap on /api/measurements/series rejected the iOS app's
"Alle"-range request (days = 3650) with a 422, painting an error
banner on every metric tile. Ten years matches the recurrence
engine's hard cap and the medication course-window upper bound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 befac0ec65 fix(measurements): wire the six deferred Apple Health identifiers
Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 b43b57f657 feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding
Closes issue 217. The isPublicUrl input-time guard catches every
RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL
DNS record can flip between accept and dispatch. The new
pinnedPublicDispatcher resolves the hostname literally inside
undici.Agent's connect hook, refuses any address isPublicIp would
reject, and pins the connection to the first valid public address.

The five outbound paths that accept a user-supplied host route
through the dispatcher via safeFetch's requirePublicHost flag —
moodlog sync, moodlog reverse-push, the moodlog credentials probe,
the local AI client (already gated in commit 1), and the ntfy
sender. The flag now buys both the input-time check and the
connect-time pin in one call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 6e204f1a25 fix(insights): raise the trends-row chart-slot wrapper so the annotation no longer overlaps
The 140 px slot matched the mini-chart's internal painting area but
not the mini-card shell's header + padding above it. The roughly
34 px the shell adds overflowed the slot and pushed the chart
envelope down into the TrendAnnotation row below — visible as text
sitting on top of the chart. Raise the wrapper to 180 px so the
full envelope (header + chart band) lives inside the slot and the
three tiles share one annotation baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:42:20 +02:00
Marc-André BombeckandClaude Opus 4.7 dba844235c test(meds): coverage for compose-mode
Five new payload-helper cases pin the multi-schedule encoder, the
hydration that opens on the list view, the add and remove guards,
and the schedule-id preservation on edit. A new Playwright spec
walks a two-schedule create flow end-to-end and verifies both
summary cards land before save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:52:39 +02:00
Marc-André BombeckandClaude Opus 4.7 8578924f14 feat(meds): compose-mode for the modal wizard
A medication can now hold multiple parallel schedules, each with its
own cadence and times. Steps 5-7 of the wizard become per-schedule;
Step 8 lists every configured schedule with edit and remove actions
and surfaces a "Weiteren Zeitplan hinzufügen" card at the bottom.
The hydrator reads every schedule on edit and lands the user
directly on the list view when more than one is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:52:39 +02:00
Marc-André BombeckandClaude Opus 4.7 320970c97d test(meds): coverage for the modal wizard
Component tests pin the path table, the step encoding, the
treatment-class to category mapping, and the create-vs-edit prop
branching. POST /api/medications gains pin-point coverage for the
new DIABETES and ANTIBIOTIC clinical-category values. Six Playwright
specs walk the new dialog end-to-end via the German label surface,
one per cadence shape; helpers consolidate the dialog-open + step
indicator assertions so each cadence spec only carries its own
sub-control selectors. The OpenAPI yaml refreshes to surface the
two new enum values to the iOS client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 105fcee242 refactor(meds): route /medications/new and the edit trigger through the new dialog
The legacy CreationWizard and the flat MedicationForm retire in
favour of MedicationWizardDialog. /medications/new keeps working
through a redirect that opens the dialog from the list page; the
"Bearbeiten" action on a card opens the same dialog with the
hydrated payload. The PhaseConfigDialog drops along with the flat
form — it was only reachable from the kebab menu and stays out of
the wizard's surface; a future release can resurface the per-phase
configuration once the wizard grows compose-mode for multi-schedule
medications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 e686083bae i18n(meds): localise the modal-wizard surface
Populates messages/{de,en}.json with every key the new dialog reads
under medications.wizard.*, plus categoryDiabetes and
categoryAntibiotic on the cards' badge namespace; the other four
locales ship the English fallback per project convention. The
retired medications.create.wizard.* namespace is removed from all
six files so the locale-integrity guard surfaces every dangling key
the moment the old surface stops being reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 b1f9a4bf8f feat(meds): step content for the modal wizard
Each of the eight steps lands with its Lucide icon, title, subline
and a single focused field. The cadence radio rows carry the
plain-language description directly under each label (not in a
tooltip), and the rolling-cadence row gets the literal mental-model
line so the patient understands the counter resets on every log.
Step 2 surfaces ten taxonomy rows that each map onto a single
(treatmentClass, category) pair through a hard-coded table; Step 6
swaps its body on the cadence kind picked in Step 5; Step 7 wraps
the existing times-of-day chips primitive with a sunrise / sun /
sunset / moon preset row above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 0a0af4b772 feat(meds): modal-wizard skeleton + dialog/sheet shell
A new MedicationWizardDialog routes through a Dialog on desktop and
a bottom Sheet on mobile via the existing ResponsiveSheet primitive,
with the wizard's own header inside the body and the footer carrying
Back / Next / Save. The dialog tracks an eight-step state machine
whose visible counter follows the path the user actually walks —
one-shot collapses to 5 steps, daily to 7, every other cadence to 8.
The pure helpers (path table, validation gate, request-body builder,
summary formatter, edit-path hydration) live alongside the dialog
shell so the next commits can plug step content in without touching
the wiring. Step content is empty in this commit; the next commits
fill the eight steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 a868bd1cf2 feat(meds): extend MedicationCategory with diabetes and antibiotic
Adds DIABETES and ANTIBIOTIC to MEDICATION_CATEGORY_VALUES so the
upcoming modal wizard's Step 2 taxonomy can write the two rows as
first-class clinical categories instead of falling back to OTHER.
The clinical category column on medication_categories is plain TEXT
backed by Zod for validation; no Prisma enum exists to ALTER, so the
migration is documentation-only and the category-label drift-guard
gets the matching keys. Both new values feed back into the rendered
badges on the medication card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 9472899736 chore(meds): drop a forwarder and a reserved-code branch the audit flagged
Removes the 402 / 423 spare-code branch in the natural-language
extractor; the route surfaces budget exhaustion through the standard
error envelope, so the inline comment was an unused shim. The
recapCadence single-call wrapper was already retired alongside the
empty day-detail step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 a730ebf07b fix(meds): focus management and full-row hit area on the wizard
Step advance moves focus into the new step's first input so
keyboard and screen-reader users land where they need to type. The
cadence-picker rows now register clicks across the full 44 px row,
not just the radio glyph + label. The "describe it" trigger on
step 1 rises to 44 px so it clears the WCAG 2.5.5 floor the rest of
the codebase honours.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 4ccb70aff8 fix(meds): mount the natural-language overlay on /medications/new
The wizard's "✨ Beschreiben" button now opens the extractor on the
production route. The overlay merges its result onto the wizard
payload; one-shot prefills clear timesOfDay so the user has to pick
the dose time explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 2a9f0c1e06 fix(meds): collapse the medication-level oneShot toggle to a single source
The flat form's oneShot switch now drives the picker's allowed kinds
directly; the picker can no longer encode a contradictory shape.
Multi-schedule medications surface a confirmation when the switch
flips on, since the existing logic drops every schedule but the
first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 22164c6c8b fix(meds): honest step counter and a single source of one-shot truth in the wizard
The progress chip now matches the path the user actually walks (5
steps for one-shot, 6 for recurring after dropping the empty "day
detail" step). The cadence picker no longer offers a one-shot radio
once the mode picker has settled on recurring. The summary line on
the final step interpolates the actual weekdays / day-of-month /
yearly date rather than reading a category label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 17ea23a1cc fix(meds): cadence picker accepts an allowedKinds filter
Lets the wizard and the edit form constrain the radio list so the
one-shot kind never appears alongside a medication-level switch
that already encodes it. The unfiltered shape is preserved for
callers that don't pass the prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 a2de3c7a30 docs(api): document the medication natural-language extract route
Wires the v1.5 Coach-backed extractor route into the OpenAPI
registry with field descriptions covering the citation-guarded
return shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 1cc6f500bb fix(meds): reconcile one-shot active flag on every intake mutation
Extracts the lifecycle hook into reconcileOneShotState and wires it
into POST, PUT, and DELETE on the intake event. A user who logs the
single dose then undoes the log gets the medication back; a user who
flips skipped does too. The hook is idempotent on non-one-shot
medications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 c7e839f97a fix(meds): close the legacy-tier startsOn gap in the recurrence engine
The legacy fallback now honours medication.startsOn the way every
other dispatch tier does, so a legacy-shape schedule with a future
startsOn no longer emits historical slots. Adds a MAX_CHUNKS cap on
the forward walk for defence-in-depth, surfaces RRULE parse errors
through annotate, and stops the engine from compounding the user's
UNTIL/COUNT with its own UNTIL suffix. The header doc-comment is
rewritten to reflect that the read-flip for the legacy walkers is
the v1.5.1 release, not this one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 be7a2f135f fix(meds): tighten the v1.5 schedule schema invariants
Adds three Zod refines that close gaps the audit surfaced: one-shot
medications now require a startsOn, course windows refuse endsOn
before startsOn, and rolling-cadence schedules cap timesOfDay at one
entry. The wizard's reminders toggle is now part of the create
payload so the user's choice actually ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 030e97906c i18n(meds): localise the v1.5 scheduling surface across six locales
Populates messages/{de,en}.json with native copy for every key the
new picker primitives, the wizard, the edit-form refactor, and the
plain-language summary consume. The remaining four locales ship the
English fallback for v1.5.0; native polish lands in a follow-up.

CadencePicker rolling-explainer key flipped to `kind.rollingExplainer`
so the i18n resolver can return both the rolling label and its
explainer (the original `kind.rolling.explainer` path collided with
the `kind.rolling` string leaf — JSON cannot host both shapes on the
same prefix). Adds the dose-unit SI tokens (mg / g / ml) to the
placeholder-allowlist in the locale-integrity guard since those
labels are intentionally identical across every locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:31:15 +02:00
Marc-André BombeckandClaude Opus 4.7 1bdbc2106f feat(meds): natural-language extraction overlay for the create wizard
Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 53695f9434 feat(meds): seven-step creation wizard built on the v1.5 pickers
Adds a step-driven create flow that composes the cadence picker, the
times-of-day chips, and the course-window row. Surfaces a placeholder
slot for the natural-language extraction overlay that lands separately.
Pure helpers (validateStep, buildCreateBody, summariseCadence) carry
the test surface; the interactive flow is covered by Playwright in a
later commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 bb1a587f8b refactor(meds): wire the edit form through the v1.5 picker primitives
Drops the legacy weekday grid in favour of the cadence picker, the
times-of-day chips, and the course-window row. Pre-v1.5 medications
read-fall-back through inferCadenceFromLegacy; writes go to both
shapes so the v1.5.x dual-write window stays intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 a87a48a7c3 docs(api): document the medication + medication-schedule shapes
Annotates the medication Zod schemas with the metadata the OpenAPI
generator consumes and wires the seven medication routes into the
registry. The XOR constraint between rrule and rollingIntervalDays
is documented at the schema description so iOS code-gen surfaces the
mutual exclusion.

Routes covered: list + create on /api/medications; read + replace +
delete on /api/medications/{id}; intake log on
/api/medications/{id}/intake; cadence + compliance read on
/api/medications/{id}/cadence.

Medication is registered as a top-level component so the iOS Swift
codegen produces a shared struct backing the list and detail variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 57ff8776f5 feat(meds): reusable cadence-picker, times-of-day, and course-window components
Three components plus a shared `types.ts` module under
`src/components/medications/scheduling/`. The wizard + the form
refactor consume them; the picker emits a `CadenceValue`
discriminated union that the form maps onto the API request body.

### CadencePicker

A grouped flat radio list with 8 cadence kinds:

- Every day → `FREQ=DAILY`
- Certain weekdays (chip-toggle row) → `FREQ=WEEKLY;BYDAY=...`
- Every N weeks on certain days → `FREQ=WEEKLY;INTERVAL=N;BYDAY=...`
- Monthly on day → `FREQ=MONTHLY;BYMONTHDAY=N`
- Every N months on day → `FREQ=MONTHLY;INTERVAL=N;BYMONTHDAY=M`
- Yearly on date → `FREQ=YEARLY;BYMONTH=M;BYMONTHDAY=D`
- Rolling N days from last intake → `rollingIntervalDays: N`
- One-time dose → `oneShot: true`

The "Every N days flexible" option carries an explainer line so
the GLP-1 patient understands the rolling semantic. Defaults are
chosen so the encoded RRULE is always parseable (weekday set
defaults to Monday, yearly clamps to Jan 1) — the wizard / form
gates "continue" until the user picks the real day.

Sub-controls state is owned by the parent (the picker accepts
`subControls` + emits via the second `onChange` arg) so a wizard
that re-enters step 3 sees the user's previous picks.

### TimesOfDayChips

Multi-chip HH:mm input with morning / noon / evening / night
presets. Dedupes + sorts. `maxChips` prop honours the Zod cap of
8; when the parent passes `maxChips: 1` (one-shot context) the
component renders a single time picker instead of a chip row.

### CourseWindowRow

Two date pickers (`startsOn` + `endsOn`) with a "No end date"
toggle. `lockEndsToStart` prop forces the one-shot case where
both pickers share a date. Validation message rendered below the
row when `endsOn < startsOn`.

### Quality

- "use client" on every component file.
- shadcn/ui-only — no new deps, no shadcn Calendar primitive in
  this repo so date pickers fall back to `<Input type="date">`.
- WCAG 2.5.5 tap targets (44 px) across every interactive
  element; focus rings, aria-labels, aria-pressed on toggles,
  role="group" on chip rows.
- 49 tests across the three components (encoder mapping, sort /
  dedupe / preset toggle, range validation, SSR-only render
  smoke tests). Pure helpers exported so the contract is pinned
  at the unit-test level; user-event paths land in Playwright
  e2e per the synthesis quality bar.

i18n keys listed at the top of each component file under
`medications.scheduling.{cadence,timesOfDay,courseWindow}.*`. The
follow-up i18n agent populates DE + EN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:22:33 +02:00
Marc-André BombeckandClaude Opus 4.7 925138eccb feat(meds): plumb v1.5 scheduling fields through POST + PUT routes
The Zod validators + the engine + the worker accept the new
v1.5 fields. The API route handlers didn't — they silently dropped
`startsOn` / `endsOn` / `oneShot` / `timesOfDay` / `rrule` /
`rollingIntervalDays` / `reminderGraceMinutes` before the Prisma
write. This commit threads them through.

### Route invariants

- One-shot consistency: `oneShot === true` ⇒ at most one schedule
  (422 otherwise), that schedule has neither `rrule` nor
  `rollingIntervalDays`, and `endsOn` is auto-normalised to equal
  `startsOn` on write.
- Recurring default: when `oneShot !== true` and the incoming
  schedule has no `rrule` / `rollingIntervalDays` / legacy
  `daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"` so the
  worker always sees a clean shape.
- Backwards compat: empty / absent `timesOfDay` is server-side
  populated from `windowStart` so the engine always sees a
  non-empty array, and legacy iOS v0.6.x clients that still post
  the `windowStart` / `windowEnd` / `daysOfWeek` shape keep
  working unchanged.

### Tests

9 new cases across both route test files (7 POST + 3 PUT, one
POST as a 7b subcase): one-shot create persistence, RRULE
persistence, rollingIntervalDays persistence, one-shot +
recurrence → 422, one-shot + multiple schedules → 422, PUT
endsOn updates, legacy POST back-fills timesOfDay, legacy POST
with no recurrence hints stamps daily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:22:17 +02:00
Marc-André BombeckandClaude Opus 4.7 39430e16c0 feat(meds): auto-deactivate one-shot medications when the dose is logged
A "one-shot" medication is a single-administration course (flu shot,
vaccine, post-op final dose, one-time injection). The v1.5 schema
adds the `Medication.oneShot` flag; the intake route is the place
the lifecycle bottoms out.

When a one-shot's intake row is committed AND the event is an actual
take (`takenAt !== null && skipped === false`):

1. Flip `medication.active` to `false`.
2. Audit-log `medication.one_shot.completed` with the intake id and
   the medication id.
3. Annotate the wide-event so operators can see one-shot completions
   land cleanly.
4. Invalidate the medication-detail cache so the UI sees the new
   `active: false` on the next read.

A "skipped" event on a one-shot does NOT deactivate the medication.
The patient is saying "I didn't take this dose"; the medication
stays open until the real take is logged. Pinned by a test in the
new integration suite.

The deactivation runs AFTER the existing
`recomputeMedicationComplianceForEvent` call so a rollup-recompute
failure can't block the medication-lifecycle close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:08:09 +02:00
Marc-André BombeckandClaude Opus 4.7 344d1c33b0 feat(meds): route the reminder worker through the canonical recurrence engine
Two changes that ship as one because they share a single rewrite of
the worker's schedule-resolution site:

### The bi-weekly fix

`grep intervalWeeks src/lib/jobs/reminder-worker.ts` used to return
zero hits. The worker iterated `recurrence.daysOfWeek.includes(
todayDow)` and called it good — a bi-weekly Wednesday schedule
fired EVERY Wednesday, regardless of "on" vs "off" week. The
cadence chart respected the interval; the worker did not. This is
the single line at `reminder-worker.ts:514` and it's been wrong
since `intervalWeeks` was introduced.

The fix routes the same predicate through the new canonical engine
(`src/lib/medications/scheduling/recurrence.ts`). The engine
honours INTERVAL natively for RRULE schedules and honours the
legacy `intervalWeeks > 1` arithmetic on the fallback path. Both
paths now agree with the chart.

### New cadence types

The same rewrite picks up monthly / quarterly / yearly / rolling /
one-shot for free because the engine dispatches on the
schedule's shape. The worker doesn't special-case anything — it
just asks the engine.

### Rolling needs a `lastIntakeAt`

Rolling schedules anchor next-due on the latest
`MedicationIntakeEvent.takenAt`. The worker now does a conditional
findFirst on the medication's intake stream when the schedule list
contains a rolling row. The lookup is gated on
`med.schedules.some(s => s.rollingIntervalDays !== null)` so the
calendar-cadence common case keeps its single-query hot path.

### The seam — `worker-helpers.ts`

A small module exports `buildCanonicalSchedule`, `buildRecurrenceContext`,
`scheduleEmitsInWindow`. Keeps the worker file focused on phase math
and dispatch (it's 2347 LOC; a deeper rewrite is for another release)
and gives the integration tests a clean place to exercise the
schedule-resolution path with real Postgres rows.

### Dead code retired

- `todayDow` (line 492) — replaced by the engine's per-instant check.
- `getDayOfWeekInTz` alias (line 355) — last caller was `todayDow`.
- `parseScheduleRecurrence` import — the worker no longer touches the
  legacy decoder directly; the engine handles the fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:07:41 +02:00
Marc-André BombeckandClaude Opus 4.7 65b923ad53 feat(medications): canonical recurrence engine + Zod validation
A single source of truth for "does this schedule fire at this
moment?" — replaces the three duplicate engines that grew up
alongside each other (`expandScheduleSlots` in scheduling/cadence,
`getNextOccurrenceTimestamp` on the medication card,
`getNextRecurrenceDate` in the medication form). The retirement of
the three legacy walkers is a follow-up wire-up step; this commit
ships the engine standalone with no callers so the foundation can
land on its own.

### `src/lib/medications/scheduling/recurrence.ts`

Three public functions:

- `occurrencesBetween(schedule, from, to, ctx)` — emit every slot
  in `[from, to]` inclusive, time-of-day applied, in chronological
  order.
- `nextOccurrenceAfter(schedule, after, ctx)` — strict-after; null
  when the schedule has terminated.
- `matchesInstant(schedule, instant, ctx)` — 1-minute-tolerance
  predicate for the reminder-worker fan-in.

The engine dispatches on the schedule's shape:

- One-shot → emit one slot per `timesOfDay` at the course anchor.
- Rolling → next-due = `lastIntakeAt + rollingIntervalDays * day`,
  fall back to `startsOn` / `createdAt` when never taken; emit only
  the immediately-next slot (past rolling occurrences are historical
  intake events, not predictable future slots).
- RRULE → delegate to the `rrule` library with
  `dtstart = startsOn ?? createdAt` and an optional `until = endsOn`.
- Legacy fallback (rrule + rolling both null, oneShot false) →
  decode the existing `daysOfWeek` string and emit weekly slots,
  **now correctly honouring `intervalWeeks > 1`** — the existing
  `expandTodayIntakes` skipped multi-week schedules; the engine
  emits them anchored at `startsOn` / `createdAt`.

Time-of-day application uses the canonical `wallClockInTz` helper
so a `08:00` schedule fires at Berlin 06:00 / 07:00 UTC depending
on DST, and a Tokyo 08:00 schedule fires at the correct UTC instant
regardless of the host clock's zone. The DST spring-forward case
(02:30 on the transition day in Europe/Berlin) forwards to 03:30
wall-clock — matches macOS / iOS / Google Calendar behaviour.

Grace window is computed from `reminderGraceMinutes` when set,
otherwise from the legacy `windowEnd - windowStart` span, with a
60-minute default for legacy single-time windows.

DoS protection: `nextOccurrenceAfter` walks forward in 90-day
chunks with a 10-year hard cap so a broken / pathological RRULE
can't spin the worker.

### Tests

28 cases pinning the contract for every dispatch branch:

- RRULE DAILY / WEEKLY / MONTHLY / QUARTERLY / YEARLY across
  multi-month windows.
- Bi-weekly Wed anchored — emits weeks 1/3/5, skips weeks 2/4/6.
  Pins the pre-existing `intervalWeeks` worker bug closed for the
  legacy fallback path.
- Rolling: last-intake anchor + null-intake fallback + endsOn cap.
- One-shot: emit + nextOccurrenceAfter returns null past it.
- DST spring-forward (Europe/Berlin) + cross-TZ (Asia/Tokyo).
- endsOn inclusive of full UTC day (matches `@db.Date` semantics).
- Empty `timesOfDay` falls back to `[windowStart]`.
- Grace window: explicit, legacy span, default 60 min.

### Zod validation

`scheduleSchema` extended with `timesOfDay` (HH:mm entries, max 8),
`reminderGraceMinutes` (1..1440 minutes), `rrule` (length-bounded +
regex against the RRULE properties HealthLog mints), and
`rollingIntervalDays` (1..365). A `.refine` enforces the
rrule-xor-rolling invariant at the validation layer (the DB CHECK
constraint is the second line of defence).

`createMedicationSchema` + `updateMedicationSchema` gain the
medication-level `startsOn`, `endsOn`, `oneShot` triplet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:52:38 +02:00
Marc-André BombeckandClaude Opus 4.7 1757b35327 feat(auth): record an identifier HMAC on failed-login audit rows
Failed-login audit rows currently carry `ipAddress` plus `reason`
(`user_not_found_or_no_password` or `invalid_password`). The typed
identifier is deliberately omitted (H-1 contract: PII never lands
in operator artefacts) — but that leaves no anchor a future spray
detector can group on.

Recording `hashToken(identifier)` alongside the reason keeps the
H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one-
way; the raw identifier never serialises into the row) while
giving any future "same identifier hit from many IPs" detector a
correlation key.

Test mock for `hashToken` now returns a deterministic hex string
that does not echo the raw input — mirrors the real HMAC shape
and keeps the existing "audit row must not contain the typed
email" assertion passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:40:05 +02:00
Marc-André BombeckandClaude Opus 4.7 e74eff2979 fix(api): rate-limit POST /api/devices per user
`POST /api/devices` returns a distinct 409 when an `apnsToken` (or
the legacy `token`) is already bound to a different account —
`apns_token_owned_by_other_user` / `token_owned_by_other_user`. The
guard is correct (an attacker who can guess an APNs token shouldn't
be able to hijack another account's pushes), but without a rate
limit the same 409 signal lets an authenticated user enumerate
token ownership across accounts unboundedly.

A `checkRateLimit("devices:register:${userId}", 20, 15 * 60 * 1000)`
landing before the schema parse caps enumeration at 20 mutations
per 15 minutes per user. Real-world clients register a device once
on login plus once on each APNs token rotation; 20 / window is well
outside any legitimate use.

The 429 returns through `apiError()` for envelope parity, and a
`devices.register.rate-limited` wide-event annotation lands so
operators can see the limit fire. Test mock adds the
`@/lib/rate-limit` arm + re-arms the success path inside
`beforeEach` because `vi.resetAllMocks()` strips the top-level
`mockResolvedValue`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:59 +02:00
Marc-André BombeckandClaude Opus 4.7 a680785023 fix(auth): require user verification on every passkey ceremony
`generateRegistrationOptions` and `generateAuthenticationOptions` were
both passing `userVerification: "preferred"`. The WebAuthn spec
treats `preferred` as a soft hint — an authenticator that lacks (or
chooses to skip) a UV factor returns an assertion with
`userVerified: false`, and SimpleWebAuthn accepts it as a successful
ceremony. The registration carries the same `userVerified: false`
flag forever, so a credential created without a biometric / PIN /
fingerprint check cannot reach AAL3 on any later assertion either.

Flipping both call sites to `"required"` makes the UV check
mandatory at the authenticator-policy layer. Apple's platform
authenticators (Touch ID / Face ID / passcode) and every shipped
hardware authenticator support UV, so the user-visible flow is
unchanged for every realistic device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:51 +02:00
Marc-André BombeckandClaude Opus 4.7 53e7389854 fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint
`enforceBudget()` catches the cost dimension — once a user has burned
the day's token allowance the route returns 429 with
`coach.budget.exceeded`. It does not catch the request-rate
dimension: a tight loop or a stolen session can issue 60 requests in
a second, all accepted up to the moment the budget arithmetic
finally rejects, while pinning Prisma connections and provider-side
SSE slots in the meantime.

A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in
front of the budget gate. 20 requests per minute is well outside any
realistic interactive pace — a human doesn't type that fast and the
iOS client paces from user gestures — while keeping the budget logic
as the cost ceiling it was designed to be.

The 429 returns through `apiError()` for envelope parity with the
other rate-limited routes; an `insights.coach.rate-limited`
wide-event lands so operators can see when the limit fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:42 +02:00
Marc-André BombeckandClaude Opus 4.7 a4b53b1852 feat(api): add an opt-in body-size cap to safeJson
`safeJson()` previously called `request.json()` directly, which buffers
the entire body before parsing. Next.js imposes a runtime cap of around
4 MB on App Router handlers, so the worst case is bounded — but
routes that legitimately accept large batches can't tighten the cap
through the helper, and a 3.9 MB payload to a single-record route
still lands in heap before any Zod check fires.

The new `opts.maxBytes` parameter routes the request through
`request.text()`, checks the byte length first, and only then runs
`JSON.parse`. A 413 envelope returns when the cap trips. Existing
callers (every route that doesn't pass `opts`) keep their current
behaviour — the helper falls through to `request.json()` exactly as
before, no regression risk on the broader codebase.

The CSP-report endpoint inlines the same pattern with a 10 KB cap;
this generalises it so single-record routes can pass `{ maxBytes:
16_384 }` and batch routes can pass `{ maxBytes: 262_144 }` (or
whatever fits the schema's max array length × per-row budget).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:27 +02:00
Marc-André BombeckandClaude Opus 4.7 909c572bfd fix(auth): reject moodLog webhook secrets that fail envelope decryption
`readMoodLogSecret()` previously caught every decrypt failure and
returned the stored bytes verbatim, on the theory that legacy
plaintext rows should keep working through the migration grace
period. That fallback is correct for genuinely unprefixed legacy
values, but it also swallowed two failure modes that should never
silently succeed:

- An envelope-shaped row (`<keyId>.<payload>`) whose key id is no
  longer in the live `ENCRYPTION_KEYS` map. Returning the literal
  `v1.<base64-ciphertext>` as if it were the plaintext secret would
  cause every webhook attempt against that row to compare a known-bad
  literal — and worse, lock the user into a state where rotating the
  key out of the env quietly broke their integration.
- A row whose envelope bytes were tampered with directly in the
  column (SQL injection, restored-from-backup-with-tamper, leaked DB
  credentials). The catch-all returned the attacker-chosen value as
  the active secret; the webhook would then accept whatever the
  attacker sent over the wire.

The fix routes the envelope-shape check through `extractKeyId()` and
only catches the failure for the genuinely legacy path. Envelope
rows that fail to decrypt now throw — operators see the rotation gap
or the tampered row instead of running on a quietly-broken secret.

`isLegacyPlaintext()` simplifies to the same shape check: anything
with an envelope prefix is encrypted (even if currently undecryptable),
so it should not be re-rotated as if it were plaintext.

Two new test cases pin the contract for envelope-shape failures
(unknown key id; tampered ciphertext under a known key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:56 +02:00
Marc-André BombeckandClaude Opus 4.7 165a14afda fix(import): refuse zip-bomb expansions on the Apple Health export
The hand-rolled extractor called `inflateRawSync(compressed)` without
a `maxOutputLength` option and never inspected the central directory's
advertised uncompressed size, so a 1 KB ZIP could expand to tens of
gigabytes during inflate and OOM the shared Next.js process. The route
already caps the upload at 1.5 GB and rate-limits to 3 / min per user,
but a single successful inflate would still take the whole container
with it.

Two layers of defence land here:

- Pre-flight refusal on the central directory's `uncompressedSize`
  when it exceeds 8 GiB or when the advertised compression ratio
  exceeds 200×. This is a coarse signal — the metadata is attacker-
  controlled — but it catches honest-but-oversized payloads early.
- `inflateRawSync(compressed, { maxOutputLength: 8 GiB })` instructs
  `node:zlib` to throw rather than expand past the cap, which holds
  even when the metadata lies about the uncompressed size.

8 GiB leaves a wide ceiling for legitimate multi-year heavy-user
exports (the largest real-world ones settle in the low single-digit
GB range), while making the bomb class infeasible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:45 +02:00
Marc-André BombeckandClaude Opus 4.7 e16f4038ed fix(security): require admin Umami and GlitchTip URLs to be publicly reachable
Both fields are admin-supplied and feed into outbound server-side
fetches: the Umami script URL is proxied through
`/api/monitoring/umami-script` (and the script body is re-served from
the app origin under a strict CSP), and the GlitchTip DSN derives the
`envelopeUrl` / `storeUrl` the server posts errors to. The previous
validators only checked the protocol, so an admin (or an attacker
holding the admin account) could point either at a private-IP target
and drive the server into the RFC1918 / loopback / metadata space.

The `isPublicUrl` helper already governs every other admin-supplied
outbound URL the app dials; this aligns Umami + GlitchTip with that
posture. GlitchTip DSNs carry the public key in userinfo
(`https://<key>@host/projectid`) — the WHATWG URL parser strips the
userinfo from the hostname, so the helper validates the host as
expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:32 +02:00
Marc-André BombeckandClaude Opus 4.7 a4e4aaf009 fix(security): replace isAllowedUmamiUrl with the canonical isPublicUrl guard
`/api/send` is the public Umami proxy. The hand-rolled allowlist it
carried predated the central `isPublicUrl` helper in
`src/lib/validations/notifications.ts` and was missing every
notation-bypass class the helper catches:

- 100.64.0.0/10 CGNAT
- 0.0.0.0/8 (only the literal `0.0.0.0` was matched)
- 169.254/16 outside the AWS metadata IP (e.g. azure metadata is
  `169.254.169.254` too, but other addresses in the block are
  routable to other internal endpoints)
- IPv6 ULA (`fc00::/7`)
- IPv4-mapped IPv6 (`::ffff:127.0.0.1`)
- Leading-zero IPv4 octal bypass (`http://010.0.0.1` → 8.0.0.1)
- Hex IPv4 (`http://0x7f.0.0.1`)
- Pure-decimal IPv4 (`http://2130706433`)

The dev-mode escape hatch is also dropped — serving the proxy against
a localhost / RFC1918 Umami target was never a useful configuration
anyway, since the proxy sends the visitor's user-agent + body upstream
which only makes sense against an externally-reachable analytics host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:26 +02:00
Marc-André BombeckandClaude Opus 4.7 fc83d8541f fix(ai): bound every provider fetch with a 60 s AbortSignal timeout
The OpenAI, Anthropic, and local OpenAI-compatible clients all used a
bare `fetch()` with no `signal`. A tar-pit upstream (a misconfigured
local Ollama on a stalled GPU, a flaky proxy in front of a hosted
provider, or a deliberately malicious user-configured LOCAL endpoint)
could accept the POST and then hang indefinitely, pinning the request
handler until the connection died on the socket level.

A 60 s ceiling lands well outside any legitimate completion budget
(production timings live in `getEvent()?.addExternalCall` traces) while
capping the worst case.

The Codex client uses an SSE stream with its own loop and stays as-is
for now; a follow-up can thread the same ceiling through the streaming
read once the contract there is settled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:05:12 +02:00