mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Integration syncs (Fitbit, Oura, Polar, WHOOP, Strava, Nightscout) now mark the per-user analytics cells stale and sweep the workouts cache at their sync tails — imported rows used to stay invisible to the cached dashboard, analytics and workout readers until the TTL lapsed. The WHOOP per-resource webhook/cron driver and the Polar workouts leg carry the same call, matching the posture Google Health and the workout batch route already had. Illness, cycle and encounter write routes hard-evict the analytics bucket through the new invalidateUserHealthContext helper: Rest Mode, the cycle context and the Today-rail appointment feed all read from cached cells under that bucket, and an interactive write must be true on the very next read.
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
/**
|
|
* v1.32.15 — Polar AccessLink exercise (workout) sync.
|
|
*
|
|
* Pulls the user's exercises from the stateless `GET /v3/exercises` list (last
|
|
* 30 days of uploads, no transaction / no commit / no userId in the path) and
|
|
* upserts each into the `Workout` table as `source = POLAR`, keyed on
|
|
* `(userId, source, externalId)` so a re-fetch / Polar-side re-analysis
|
|
* overwrites in place. Polar exercises are WORKOUT rows, not `Measurement`
|
|
* rows, so this file is deliberately SEPARATE from the vitals sync
|
|
* (`./sync.ts`) — isolation for a sensitive cohort, mirroring WHOOP's split
|
|
* `sync.ts` / `sync-workout.ts`.
|
|
*
|
|
* Cross-source dedup is NOT done here: a Polar run and the same run via Apple
|
|
* Health remain two distinct `Workout` rows (different `source`); the read-time
|
|
* `pickCanonicalWorkoutRows` picker collapses the cross-source twin via the
|
|
* user's source-priority ladder. One engine, no parallel path.
|
|
*
|
|
* Idempotency: every tick re-reads the whole 30-day window; the unique index
|
|
* makes a re-ingest a no-op-shaped overwrite. There is no commit / acknowledge
|
|
* step to fail (the deprecated transaction model had one), so ingest is the
|
|
* only write and it is idempotent — no cursor, no backfill queue (the API caps
|
|
* history at 30 days, so first-connect backfill IS the first regular tick).
|
|
*
|
|
* Ledger: workouts and vitals share the single `polar` integration ledger. This
|
|
* leg records only its OWN FAILURE (and rethrows so the cohort handler can
|
|
* attribute it); the single success write stays the vitals leg's job so a fully
|
|
* successful pass records success exactly once. A workouts-leg failure records a
|
|
* transient/reauth failure on the shared ledger but never touches the vitals
|
|
* success timestamp (`recordSyncFailure` leaves `lastSuccessAt` intact).
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
|
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import {
|
|
markSyncFailureRecorded,
|
|
recordSyncFailure,
|
|
toFailureKind,
|
|
} from "@/lib/integrations/status";
|
|
import { fetchExercises, mapExercise, type PolarWorkoutRow } from "./client";
|
|
import { getPolarConnection } from "./credentials";
|
|
import { PolarApiError, classifyPolarError } from "./response-classifier";
|
|
|
|
const POLAR_LEG_WORKOUTS = "workouts";
|
|
|
|
async function recordPolarWorkoutFailure(
|
|
userId: string,
|
|
err: unknown,
|
|
): Promise<void> {
|
|
await recordSyncFailure({
|
|
userId,
|
|
integration: "polar",
|
|
// The workouts leg rides the same hourly tick as vitals but owns its own
|
|
// errors: the vitals success stamp must not clear a failure from here.
|
|
leg: POLAR_LEG_WORKOUTS,
|
|
// Same classification the vitals leg uses (`classifyPolarFailure` in
|
|
// `./sync`); resolved directly off the shared classifier so this leg does
|
|
// not have to import the heavier vitals-sync module.
|
|
kind: toFailureKind(classifyPolarError(err)),
|
|
message: err instanceof Error ? err.message : String(err),
|
|
errorCode:
|
|
err instanceof PolarApiError && err.httpStatus != null
|
|
? String(err.httpStatus)
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync one user's Polar exercises into `Workout`. Returns the count of rows
|
|
* written. A user with no Polar connection is a clean no-op (returns 0, touches
|
|
* no status row).
|
|
*
|
|
* A 403 on the exercises read (a scope gap under `accesslink.read_all`) is
|
|
* classified through the shared response classifier as `reauth_required` — a
|
|
* SOFT/recoverable integration error surfaced on the status ledger, NOT a hard
|
|
* crash: it is recorded and rethrown so the composite cohort pass isolates this
|
|
* user and the vitals leg's own result is untouched.
|
|
*/
|
|
export async function syncUserPolarWorkouts(userId: string): Promise<number> {
|
|
const conn = await getPolarConnection(userId);
|
|
if (!conn) return 0;
|
|
|
|
let rows: PolarWorkoutRow[];
|
|
try {
|
|
const exercises = await fetchExercises(conn.accessToken);
|
|
rows = [];
|
|
for (const ex of exercises) {
|
|
const row = mapExercise(ex);
|
|
// Skip an unmappable exercise (no id / unparseable start); never throw
|
|
// per-row — one bad record must not fail the whole leg.
|
|
if (row) rows.push(row);
|
|
}
|
|
} catch (err) {
|
|
await recordPolarWorkoutFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
let imported: number;
|
|
try {
|
|
imported = await upsertPolarWorkouts(userId, rows);
|
|
} catch (err) {
|
|
await recordPolarWorkoutFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
// Sweep the workouts cache (and mark the analytics cells stale — workouts
|
|
// feed achievements + analytics) so the upserted rows reach the cached
|
|
// readers, matching the `/api/workouts/batch` and Google Health writers.
|
|
if (imported > 0) {
|
|
invalidateUserMeasurements(userId);
|
|
}
|
|
|
|
return imported;
|
|
}
|
|
|
|
/**
|
|
* Upsert a batch of mapped Polar exercises into `Workout`, keyed on
|
|
* `(userId, source = POLAR, externalId)` so a re-fetch overwrites in place. A
|
|
* line-for-line mirror of `upsertStravaWorkouts`: `createManyAndReturn` +
|
|
* `skipDuplicates`, update-in-place on conflict, arrival emit on fresh inserts
|
|
* only. Failed rows are logged while the rest of the batch is attempted, then
|
|
* the FIRST error is rethrown so the caller cannot treat a partially persisted
|
|
* batch as a clean pass.
|
|
*/
|
|
export async function upsertPolarWorkouts(
|
|
userId: string,
|
|
rows: readonly PolarWorkoutRow[],
|
|
): Promise<number> {
|
|
if (rows.length === 0) return 0;
|
|
|
|
let imported = 0;
|
|
let firstError: unknown;
|
|
let hadError = false;
|
|
for (const r of rows) {
|
|
const data = {
|
|
sportType: r.sportType,
|
|
startedAt: r.startedAt,
|
|
endedAt: r.endedAt,
|
|
durationSec: r.durationSec,
|
|
totalEnergyKcal: r.totalEnergyKcal,
|
|
totalDistanceM: r.totalDistanceM,
|
|
avgHeartRate: r.avgHeartRate,
|
|
maxHeartRate: r.maxHeartRate,
|
|
elevationM: r.elevationM,
|
|
metadata: r.metadata,
|
|
};
|
|
try {
|
|
const [inserted] = await prisma.workout.createManyAndReturn({
|
|
data: {
|
|
userId,
|
|
source: "POLAR",
|
|
externalId: r.externalId,
|
|
...data,
|
|
},
|
|
skipDuplicates: true,
|
|
select: { id: true, startedAt: true },
|
|
});
|
|
if (inserted) {
|
|
void emitInsertedWorkoutArrival(userId, inserted, "polar").catch(
|
|
() => {},
|
|
);
|
|
} else {
|
|
await prisma.workout.update({
|
|
where: {
|
|
userId_source_externalId: {
|
|
userId,
|
|
source: "POLAR",
|
|
externalId: r.externalId,
|
|
},
|
|
},
|
|
data,
|
|
});
|
|
}
|
|
imported += 1;
|
|
} catch (err) {
|
|
getEvent()?.addWarning(`polar: failed to upsert workout: ${err}`);
|
|
if (!hadError) {
|
|
firstError = err;
|
|
hadError = true;
|
|
}
|
|
}
|
|
}
|
|
if (hadError) throw firstError;
|
|
|
|
annotate({
|
|
action: { name: "polar.workout.ingest", details: { imported } },
|
|
});
|
|
return imported;
|
|
}
|