mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
A grant has meant one thing since it shipped: the whole record, to read or to add to. This lays the words for the two shapes the rest of the sharing work needs — part of a record, and management of one — while changing what no existing grant does. `ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`, clustered from the delegable read surfaces rather than invented, plus the distinguished `record` for the routes that read across them. It never converts to a `ModuleKey` and never imports one: two masks, two questions, no shared spelling, because the day they share a name is the day somebody makes one imply the other, and a module the owner enabled is not a section the owner shared. `account_grants.scope_json` is NULL on every row that exists, and NULL is the entire record — the answer those rows were consented as, so no backfill and no re-consent. A non-NULL value is read through one fail-closed normaliser: a blob it cannot parse opens nothing, including a section the blob happened to name. That is the deliberate inverse of the module preference normaliser, which fails open on purpose; both do the safe thing with a value nobody can read, and the safe thing differs because one answers an authorization question and the other a presentation question. `MANAGE` joins the level enum, ordered above WRITE so a write grant can never satisfy it, and carries no scope at all: management of part of a record promises a boundary that cannot survive an edit, so the invite path refuses to write one. `requireRecordAuth` takes the section as a second required argument. The requirement is the lever — a delegable route without a classification does not typecheck, so the set of classified routes cannot fall behind the set of delegable ones. All fifty-seven modules carry theirs. The structural guard freezes each against the production vocabulary, fails when a module's own arms disagree, and pins a non-zero count of resolved sections so a matcher that stopped reading the argument fails instead of agreeing with everything. `requireGuardianAuth` is exported and reaches nothing yet. It gates on the managed-profile marker rather than on the grant, so an adult who granted management of their own record cannot arrive at that record's settings by any argument about what MANAGE means: widening MANAGE would widen the record resolver and would not touch this one. Its frozen list lands empty, with the declaration site as the positive control that keeps "nothing reaches it" a measurement rather than a renamed symbol. `users.managed_profile_at` says since when a record is one somebody else runs. A timestamp and not a boolean, for a reason the schema has to keep possible: clearing it is the handover, and the grants and the activity trail span it untouched.
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
/**
|
||
* `GET /api/workouts` — paginated workout list with cross-source
|
||
* canonical-row dedup.
|
||
*
|
||
* Consumers (v1.4.32+):
|
||
* 1. The `/insights/workouts` web surface reads from this endpoint so
|
||
* duplicate twin workouts (Apple Watch writing a workout that
|
||
* Withings also published over its server-to-server feed) collapse
|
||
* to a single canonical row per cluster.
|
||
* 2. The native iOS app drains historical workouts through the same
|
||
* surface — Apple Health is the canonical ladder anchor, so an
|
||
* iOS-originated request typically returns its own rows back.
|
||
*
|
||
* Dedup contract:
|
||
* - The route runs `pickCanonicalWorkoutRows()` from v1.4.30 over the
|
||
* filtered row set. The picker reads the per-user
|
||
* `User.sourcePriorityJson` blob (falling back to
|
||
* `APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT`) and buckets by
|
||
* `(startedAt slot-of-5-min, sportType)`. The highest-priority
|
||
* source's rows win each bucket; the rest are dropped.
|
||
*
|
||
* Query params:
|
||
* - `limit` (default 50, max 200)
|
||
* - `offset` (default 0)
|
||
* - `since` / `until` — ISO timestamps, inclusive. Optional.
|
||
* - `sportType` — narrow to one HKWorkoutActivityType. Optional.
|
||
*
|
||
* Pagination contract:
|
||
* Dedup runs over the full filtered row set, then `offset` / `limit`
|
||
* slice the canonical projection. A naive `skip` + per-window dedup
|
||
* double-counts boundary clusters across pages because a cluster
|
||
* whose first member sits in page N-1 and whose later member sits in
|
||
* page N would be dropped on N-1 and surface again on N.
|
||
*
|
||
* The canonical projection (the deduped, sorted row set) is cached
|
||
* keyed on the FILTER (`userId|since|until|sportType`) — deliberately
|
||
* WITHOUT `offset`/`limit` — so every page of a drain shares one cache
|
||
* entry: the first page's request builds it (one `findMany` + one
|
||
* dedup pass), every later page in the same TTL window slices the
|
||
* already-built array for free. Before this, each page carried its own
|
||
* cache key, so a full historical drain (the iOS app's "drain
|
||
* historical workouts" path, potentially thousands of rows on a
|
||
* 10-year Apple Health import) re-ran the full read + dedup once per
|
||
* page — O(pages × rows) instead of O(rows).
|
||
*
|
||
* Response shape (iOS contract):
|
||
* - `workouts: WorkoutListEntry[]` — each entry exposes
|
||
* `id, sportType, startedAt, endedAt, durationSec, distanceM,
|
||
* activeEnergyKcal, avgHr, maxHr, source, externalId`. Stable across
|
||
* iOS / web consumers.
|
||
* - `meta: { total, limit, offset, droppedDuplicates }`.
|
||
*
|
||
* v1.4.32 — swap the cluster-based dedup helper (v1.4.27 B7) for the
|
||
* source-priority-aware `pickCanonicalWorkoutRows()` helper (v1.4.30)
|
||
* so the per-user source ladder governs the canonical pick. Response
|
||
* field names match the iOS handoff contract.
|
||
*/
|
||
import { NextRequest } from "next/server";
|
||
|
||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||
import { annotate } from "@/lib/logging/context";
|
||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||
import {
|
||
readWorkoutsListCached,
|
||
workoutListFilterSchema,
|
||
} from "@/lib/workouts/list-read";
|
||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||
|
||
const DEFAULT_LIMIT = 50;
|
||
const MAX_LIMIT = 200;
|
||
|
||
export const GET = apiHandler(async (request: NextRequest) => {
|
||
const { user } = await requireRecordAuth("read", "measurements");
|
||
annotate({ action: { name: "workouts.list" } });
|
||
|
||
// v1.18.0 B1 — the workouts surface is a toggleable module. A disabled
|
||
// account must not be able to read the list even with a valid Bearer
|
||
// token. The batch INGEST route stays ungated so synced data still
|
||
// lands while the surface is hidden.
|
||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||
if (!gate.enabled) return gate.response;
|
||
|
||
const { searchParams } = new URL(request.url);
|
||
const limit = Math.min(
|
||
Math.max(
|
||
1,
|
||
parseInt(searchParams.get("limit") ?? String(DEFAULT_LIMIT), 10) ||
|
||
DEFAULT_LIMIT,
|
||
),
|
||
MAX_LIMIT,
|
||
);
|
||
const offset = Math.max(
|
||
0,
|
||
parseInt(searchParams.get("offset") ?? "0", 10) || 0,
|
||
);
|
||
const filterResult = workoutListFilterSchema.safeParse({
|
||
since: searchParams.get("since"),
|
||
until: searchParams.get("until"),
|
||
sportType: searchParams.get("sportType"),
|
||
});
|
||
if (!filterResult.success) {
|
||
return returnAllZodIssues(filterResult.error);
|
||
}
|
||
|
||
const result = await readWorkoutsListCached(user.id, {
|
||
limit,
|
||
offset,
|
||
...filterResult.data,
|
||
});
|
||
|
||
return apiSuccess(result);
|
||
});
|