Files
HealthLog/src/app/api/workouts/route.ts
T
Marc-André Bombeck 6eef3ab21c fix(workouts): gate the workout read APIs behind the workouts module
GET /api/workouts and GET /api/workouts/{id} returned a disabled-
module account's workout list and detail to any valid Bearer or
cookie session. Both now require the workouts module and return the
403 module.disabled envelope when it is off.

The batch INGEST route (POST /api/workouts/batch) stays ungated by
design: synced workouts must keep landing in storage even while the
surface is hidden, so re-enabling the module reveals a complete
history rather than a gap.
2026-06-16 01:57:54 +02:00

211 lines
6.6 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. Workout
* volume for HealthLog is bounded (single user, tens of workouts per
* week — single-digit MB at the table's busiest), so reading the
* full filtered set per request is well within budget.
*
* 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 { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { requireModuleEnabled } from "@/lib/modules/gate";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
interface WorkoutsParams {
readonly limit: number;
readonly offset: number;
readonly since: string | null;
readonly until: string | null;
readonly sportType: string | null;
}
interface WorkoutsResult {
workouts: Array<{
id: string;
sportType: string | null;
startedAt: Date | null;
endedAt: Date | null;
durationSec: number | null;
distanceM: number | null;
activeEnergyKcal: number | null;
avgHr: number | null;
maxHr: number | null;
source: string;
externalId: string | null;
}>;
meta: {
total: number;
limit: number;
offset: number;
droppedDuplicates: number;
};
}
async function buildWorkoutsResponse(
userId: string,
params: WorkoutsParams,
): Promise<WorkoutsResult> {
const { limit, offset, since, until, sportType } = params;
const where: Record<string, unknown> = { userId };
if (since || until) {
const range: Record<string, Date> = {};
if (since) {
const d = new Date(since);
if (!Number.isNaN(d.getTime())) range.gte = d;
}
if (until) {
const d = new Date(until);
if (!Number.isNaN(d.getTime())) range.lte = d;
}
if (Object.keys(range).length > 0) {
where.startedAt = range;
}
}
if (sportType) {
where.sportType = sportType;
}
const rows = await prisma.workout.findMany({
where,
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
select: {
id: true,
source: true,
externalId: true,
sportType: true,
startedAt: true,
endedAt: true,
durationSec: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
},
});
const userRow = await prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
});
const canonical = pickCanonicalWorkoutRows(
rows,
userRow?.sourcePriorityJson ?? null,
);
const page = canonical.slice(offset, offset + limit).map((row) => ({
id: row.id,
sportType: row.sportType,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
distanceM: row.totalDistanceM,
activeEnergyKcal: row.totalEnergyKcal,
avgHr: row.avgHeartRate,
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
}));
return {
workouts: page,
meta: {
total: canonical.length,
limit,
offset,
droppedDuplicates: rows.length - canonical.length,
},
};
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
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 since = searchParams.get("since");
const until = searchParams.get("until");
const sportType = searchParams.get("sportType");
const params: WorkoutsParams = { limit, offset, since, until, sportType };
const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`;
const result = await cached(
caches.workouts as ServerCache<WorkoutsResult>,
cacheKey,
() => buildWorkoutsResponse(user.id, params),
annotate,
);
return apiSuccess(result);
});