Commit Graph
8 Commits
Author SHA1 Message Date
Marc-André Bombeck 7bbcc872f8 perf(api): wire 6 remaining hot reads through the server cache
The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.

Reads wrapped (60 s TTL unless noted):
  - GET /api/mood/analytics                 → caches.moodAnalytics, key userId
  - GET /api/workouts                       → caches.workouts, key includes
                                              all params (limit/offset/since/
                                              until/sportType)
  - GET /api/bugreport/status               → caches.bugreportStatus, key
                                              "singleton" (10 min TTL);
                                              isAdmin layered per request
  - GET /api/medications                    → caches.medications, key userId
  - GET /api/dashboard/widgets              → caches.dashboardWidgets, key
                                              userId (5 min TTL)

Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.

The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.

The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
2026-05-16 22:24:56 +02:00
Marc-André Bombeck 757387ca83 feat(cache): wire 13 write endpoints to per-user invalidation helpers
Every endpoint that mutates a measurement / mood / medication /
intake / workout / dashboard-widget row now calls the matching
invalidateUser* helper after its database commit so the next cached
read paints fresh data instead of waiting out the TTL. The admin
app-settings PUT also flushes the bug-report-status singleton cache.

Endpoints wired (per blueprint §6 matrix):

  POST/DELETE /api/measurements
  PUT/DELETE /api/measurements/[id]
  POST /api/measurements/batch
  DELETE /api/measurements/by-external-ids
  POST/PUT/DELETE /api/mood-entries[/id]
  POST /api/mood-entries/bulk
  POST/PUT/DELETE /api/medications[/id]
  POST /api/medications/[id]/intake
  PUT/DELETE /api/medications/[id]/intake/[eventId]
  POST /api/medications/intake/bulk
  POST /api/workouts/batch
  PUT/DELETE /api/dashboard/widgets
  PUT /api/admin/settings (bug-report toggle path)

Batch endpoints gate invalidation on insertedCount > 0 so a
duplicates-only ingest stays a no-op. Route-level tests reset every
cache in beforeEach so re-using a fixed userId doesn't carry response
state across cases.
2026-05-16 21:05:41 +02:00
Marc-André Bombeck 8d514ed127 feat(api): expose workouts list + detail endpoints
Swap the v1.4.27 cluster picker on `GET /api/workouts` for the
source-priority-aware `pickCanonicalWorkoutRows()` (v1.4.30) so the
per-user ladder governs the canonical pick, fix the latent
Prisma-field-name bug (`distanceMeters` / `energyKcal` → the actual
`totalDistanceM` / `totalEnergyKcal` columns), and align the wire
shape with the iOS handoff contract (`distanceM`,
`activeEnergyKcal`, `avgHr`, `maxHr`).

Add `GET /api/workouts/{id}` for the workout-detail surface — owns
the optional `WorkoutRoute` GeoJSON geometry, exposes `canonicalId`
so deep-link redirects can land on the cluster winner, and seals
the existence channel with a cross-user 404 guard.

Register both routes in the OpenAPI registry so the pre-commit
drift hook catches the new shape going forward.
2026-05-16 15:29:33 +02:00
Marc-André Bombeck 7dc51a2177 fix(workouts): correct pagination under canonical dedup
Reading rows with skip/take and then deduping inside the window
double-counted cluster boundaries: a cluster split across two pages
would be dropped on page N-1 and surface again on page N, while
meta.total reflected the per-window canonical count rather than the
user's true canonical total.

Pull the full filtered set, dedupe once, then slice. Workout volume
for a single user stays well within budget for an unbounded read; if
the workload ever grows past that, switch to a (startedAt, id) cursor
with a deterministic over-fetch window. A new test case exercises a
window with eight twin clusters split across two pages to pin the
contract.
2026-05-15 20:57:13 +02:00
Marc-André Bombeck 988bbf1a2f feat(workouts): wire pickCanonicalWorkout into the list read path and collapse the attach route N+1
Adds `GET /api/workouts` as a paginated read surface that runs every
fetch through `pickCanonicalWorkout()` so a duplicated workout that
Apple Health and Withings both publish collapses to the canonical
row before reaching the client. The picker stays a pure function;
the route owns the query window, the fetch multiplier that keeps the
page full after dedup, and the meta envelope that surfaces the
dedup ratio.

Collapses the batch-route route-attach lookup from `Promise.all` of
`tx.workout.findFirst` per manual workout to a single batched
`findMany` keyed by the deterministic `(source, startedAt, sportType)`
tuple. For a 100-row batch this cuts the per-batch round-trip count
from 1 + N to 2 while preserving the most-recent createdAt tie-break
through in-memory grouping.

Adds `__tests__/canonical-dedup.test.ts` covering twin collapse,
proximity-window separation, sport-type isolation, and the limit
clamp.
2026-05-15 17:31:37 +02:00
Marc-André Bombeck 7b7a896c44 feat(api): batch routes enqueue pr-detection (silent on >50 batch)
Measurement and workout batch ingest now fire a pr-detection job
after each successful insert. Batches above 50 entries propagate
silent=true so a multi-year Apple Health or HKWorkout backfill
writes the records without spamming hundreds of pushes. Both
enqueue calls are wrapped in audit-log entries and a swallow-and-
annotate failure path so a flaky queue cannot 500 an ingest call —
the 30-minute fallback cron picks the user up in any case.
2026-05-14 18:47:22 +02:00
Marc-André Bombeck caf2371c77 test(api): batch-workout integration + race + size-cap coverage
Unit + integration coverage for POST /api/workouts/batch.

Unit (database-free, mocks the Prisma layer):
- 401 on unauthenticated calls
- 413 when Content-Length exceeds the 5 MB ceiling, with the
  documented workout.batch.payload_too_large error code
- 429 when the rate-limit gate blocks the call
- 400 when the workouts array exceeds the 100-entry cap
- 400 when the Zod schema rejects the payload
- 400 when a route exceeds the 20 000-point cap
- Fresh entries marked inserted
- Pre-existing entries flagged duplicate without re-inserting
- Race-reconciliation downgrades inserted statuses to duplicate
  when createMany.count is below the attempted-row count
- WorkoutRoute rows attached by FK after the workout createMany

Integration (real Postgres in a testcontainer):
- Happy-path single workout with nested route lands as a Workout
  row + WorkoutRoute row + 1:1 FK + computed durationSec
- 100-workout batch with mixed routes lands all 100 workouts and
  exactly the expected number of route rows
- Over-cap batch (101 workouts) returns 400 with
  workout.batch.too_large
- Over-cap route (20 001 points) returns 400 with
  workout.batch.invalid
- Re-posted batch surfaces as duplicates without inserting twice
- Idempotency-Key replays the cached envelope and sets the
  X-Idempotent-Replay header
- Pre-seeded rate-limit row triggers 429 and writes nothing
- Narrow-scope Bearer token admitted on a route that declares no
  required permission (the W10 fix-C correctness fix the iOS
  narrow-scope sync depends on)
2026-05-14 18:15:37 +02:00
Marc-André Bombeck 839fa6bd62 feat(api): POST /api/workouts/batch — typed workout ingest with nested route
First write endpoint for the W8d Workout + WorkoutRoute tables.
Mirrors the v1.4.25 W10-hardened /api/measurements/batch in every
cross-cutting concern so the iOS sync engine and the deferred
Withings activity sync (W17b) can re-use the same retry / cursor
plumbing:

- requireAuth() — cookie + Bearer; narrow-scope-token safe per
  the W10 fix-C contract (a route that declares no scope admits
  any authenticated token, wildcard or otherwise).
- withIdempotency — Idempotency-Key replays the cached envelope
  on retry (24h window).
- checkRateLimit at 60 batches/min/user — 100 workouts/batch
  ceiling × 60/min = 6 000 workouts/min headroom, orders of
  magnitude past any healthy sync.
- Content-Length pre-flight at 5 MB — anything larger returns
  413 with a structured error code so the client falls back to
  one-workout-per-call.
- Per-entry status (inserted | duplicate | skipped) so the iOS
  sync cursor can checkpoint accurately.

Idempotency contract:
- HTTP-level via Idempotency-Key.
- Per-entry via @@unique([userId, source, externalId]) — Prisma
  createMany skipDuplicates absorbs duplicate-key conflicts.

Race reconciliation mirrors the v1.4.25 W10 fix for measurements
batch: under contention, createMany.count is the source of truth
for how many rows THIS request wrote, and we downgrade enough
per-entry inserted statuses to duplicate so the envelope sums
stay consistent with the aggregate counts. The iOS sync cursor
advances past both statuses identically.

Two-step write: createMany the workouts, then look up the freshly
written rows by composite key and createMany the routes by FK.
The 1:1 WorkoutRoute.workoutId unique index means route inserts
are also idempotent on re-submit.
2026-05-14 18:08:54 +02:00