mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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.
349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
/**
|
|
* Nightscout CGM sync (v1.17.0).
|
|
*
|
|
* Pulls the most recent SGV entries from the user's self-hosted Nightscout
|
|
* instance and writes each as a `BLOOD_GLUCOSE` mg/dL Measurement tagged
|
|
* `source = NIGHTSCOUT`. This is the CGM density that makes the clinical
|
|
* glucose panel (TIR / GMI) real — a continuous stream rather than the manual
|
|
* spot readings.
|
|
*
|
|
* Idempotency: each row's `externalId` is derived from Nightscout's `_id` (or
|
|
* the reading's epoch ms), and the write is FIRST-WRITE-WINS — a CGM sample is
|
|
* an immutable canonical reading, so the `(userId, type, source, externalId)`
|
|
* unique collapses a re-sync onto the existing row and the `update` branch is a
|
|
* no-op (no value overwrite). This mirrors the Apple Health immutable-sample
|
|
* contract, not the WHOOP re-score overwrite.
|
|
*
|
|
* Status bookkeeping rides the shared integration ledger (`nightscout` key): a
|
|
* clean pass stamps `recordSyncSuccess`; an unreachable instance / wrong token
|
|
* records a classified `recordSyncFailure` and rethrows so the cohort runner
|
|
* can warn-and-continue.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
|
|
import {
|
|
markSyncFailureRecorded,
|
|
recordSyncFailure,
|
|
recordSyncSuccess,
|
|
toFailureKind,
|
|
type FailureKind,
|
|
} from "@/lib/integrations/status";
|
|
import type { IntegrationClassification } from "@/lib/integrations/http-status-classifier";
|
|
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
|
|
import {
|
|
collapseToTypeDayKeys,
|
|
recomputeBucketsForMeasurement,
|
|
} from "@/lib/rollups/measurement-rollups";
|
|
import {
|
|
emitInsertedMeasurementArrivals,
|
|
type InsertedMeasurementArrivalRow,
|
|
} from "@/lib/arrivals/measurement-emit";
|
|
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
|
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
|
import {
|
|
fetchSgvEntries,
|
|
mapSgvEntryToMeasurement,
|
|
NightscoutApiError,
|
|
NightscoutPolicyError,
|
|
type ParsedSgvEntry,
|
|
} from "./client";
|
|
import { getUserNightscoutCredentials } from "./credentials";
|
|
|
|
/**
|
|
* How many recent SGV entries to pull per sync tick. A CGM emits one reading
|
|
* every ~5 min (≈288/day); 576 covers ~48 h of catch-up so a missed hourly
|
|
* tick (or a brief outage) still backfills on the next run without paging the
|
|
* full instance.
|
|
*
|
|
* This ~48 h window is the ONLY history Nightscout imports: there is no
|
|
* far-history backfill queue, so connecting Nightscout brings in about two
|
|
* days of readings and every tick thereafter walks the same recent set. A
|
|
* deeper walk would need its own windowed-pagination queue; it is not built.
|
|
*/
|
|
export const NIGHTSCOUT_SYNC_COUNT = 576;
|
|
|
|
/**
|
|
* Redact the Nightscout auth secret from an error message before it reaches the
|
|
* integration ledger / the `/api/nightscout/status` response.
|
|
*
|
|
* The entries URL carries the access token as a `?token=<secret>` query param
|
|
* (and a classic instance may carry `api-secret`). A `SafeFetchError` embeds the
|
|
* full target URL verbatim in its message — a private-host refusal, a timeout,
|
|
* or a network failure would otherwise persist the token into `lastError` and
|
|
* echo it back through the status endpoint. Rewrite any URL in the message so
|
|
* the secret-bearing params read `=REDACTED`; everything else is preserved so
|
|
* the operator still sees a useful diagnostic.
|
|
*/
|
|
export function redactNightscoutSecret(message: string): string {
|
|
// Match `token=...` / `api-secret=...` up to the next param / whitespace /
|
|
// quote boundary. Case-insensitive on the key; the value is whatever a URL
|
|
// query value can hold short of a delimiter.
|
|
return message.replace(/\b(token|api-secret)=[^\s&"']+/gi, "$1=REDACTED");
|
|
}
|
|
|
|
/**
|
|
* Record a Nightscout sync failure on the shared `nightscout` ledger with the
|
|
* correct classification + HTTP code + the token STRIPPED from the message.
|
|
* Extracted so the poll-cohort boundary can record a future escape with the
|
|
* same provider-owned classification AND redaction the inline fetch catch
|
|
* applies — a boundary-blind recorder would persist the token-bearing URL a
|
|
* `SafeFetchError` embeds.
|
|
*/
|
|
export async function recordNightscoutSyncFailure(
|
|
userId: string,
|
|
err: unknown,
|
|
): Promise<void> {
|
|
const policyDenied =
|
|
err instanceof NightscoutPolicyError ||
|
|
(typeof err === "object" &&
|
|
err !== null &&
|
|
"reasonCode" in err &&
|
|
err.reasonCode === "private_origin_not_approved");
|
|
await recordSyncFailure({
|
|
userId,
|
|
integration: "nightscout",
|
|
kind: classifyNightscoutFailure(err),
|
|
message: policyDenied
|
|
? "private_origin_not_approved"
|
|
: redactNightscoutSecret(
|
|
err instanceof Error ? err.message : String(err),
|
|
),
|
|
errorCode: policyDenied
|
|
? "private_origin_not_approved"
|
|
: err instanceof NightscoutApiError && err.status != null
|
|
? String(err.status)
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
/** Map a Nightscout HTTP status / network error onto the shared ledger kind. */
|
|
export function classifyNightscoutFailure(err: unknown): FailureKind {
|
|
let classification: IntegrationClassification = "transient";
|
|
if (
|
|
err instanceof NightscoutPolicyError ||
|
|
(typeof err === "object" &&
|
|
err !== null &&
|
|
"reasonCode" in err &&
|
|
err.reasonCode === "private_origin_not_approved")
|
|
) {
|
|
classification = "persistent";
|
|
} else if (err instanceof NightscoutApiError && err.status != null) {
|
|
// Wrong / missing token, or a token whose role can't read SGV.
|
|
if (err.status === 401 || err.status === 403) {
|
|
classification = "reauth_required";
|
|
} else if (err.status >= 400 && err.status < 500) {
|
|
// 4xx other than auth = a contract / config problem worth surfacing.
|
|
classification = "persistent";
|
|
}
|
|
}
|
|
// 5xx, timeout, DNS, connection refused stay `transient` — retry next tick.
|
|
return toFailureKind(classification);
|
|
}
|
|
|
|
/**
|
|
* Sync one user's recent SGV entries.
|
|
*
|
|
* `imported` counts the rows FRESHLY INSERTED this tick (re-confirmed rows the
|
|
* upsert merely touched are not counted — the old insert+reconfirm total
|
|
* inflated the cohort's `measurements_imported` and fired the reminder-satisfy
|
|
* trigger every tick regardless of whether new glucose actually arrived).
|
|
* `failed` is true when any SGV row did not write.
|
|
*
|
|
* The count used to be the whole return value, which narrowed the failures
|
|
* away: a tick where every row was refused answered the same `0` as a tick
|
|
* that found nothing new, and the settings card rendered both as a green tick
|
|
* with a count. The caller now gets both halves and decides.
|
|
*
|
|
* A user with no configured instance is a clean no-op (nothing imported,
|
|
* nothing failed, no status row touched).
|
|
*/
|
|
export async function syncUserNightscout(
|
|
userId: string,
|
|
opts: { count?: number } = {},
|
|
): Promise<SyncWriteResult> {
|
|
const creds = await getUserNightscoutCredentials(userId);
|
|
if (!creds) return { imported: 0, failed: false };
|
|
|
|
let entries: ParsedSgvEntry[];
|
|
try {
|
|
entries = await fetchSgvEntries({
|
|
baseUrl: creds.baseUrl,
|
|
token: creds.token,
|
|
count: opts.count ?? NIGHTSCOUT_SYNC_COUNT,
|
|
});
|
|
} catch (err) {
|
|
// Recorded here (token-redacted), then marked so the poll-cohort boundary
|
|
// does not double-record it — and never re-persists the token-bearing URL
|
|
// a boundary-blind recorder would.
|
|
await recordNightscoutSyncFailure(userId, err);
|
|
throw markSyncFailureRecorded(err);
|
|
}
|
|
|
|
const { inserted, failedRows } = await upsertNightscoutEntries(
|
|
userId,
|
|
entries,
|
|
);
|
|
|
|
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
|
// from a poll) so the inserted rows reach the cached readers. Fires on a
|
|
// partial failure too — rows that DID land must not stay invisible for the
|
|
// rest of the TTL window.
|
|
if (inserted > 0) {
|
|
invalidateUserMeasurements(userId);
|
|
}
|
|
|
|
if (failedRows > 0) {
|
|
// Partial write failure: keep the tick honest. `transient` because the
|
|
// ~48 h re-fetch window self-heals a transient DB hiccup, while a
|
|
// persistent malformed-row failure now accrues visible strikes instead of
|
|
// being invisibly reset by a success stamp. Do NOT stamp success.
|
|
await recordSyncFailure({
|
|
userId,
|
|
integration: "nightscout",
|
|
kind: "transient",
|
|
message: `${failedRows} of ${entries.length} SGV rows failed to write`,
|
|
});
|
|
return { imported: inserted, failed: true };
|
|
}
|
|
|
|
await recordSyncSuccess(userId, "nightscout");
|
|
return { imported: inserted, failed: false };
|
|
}
|
|
|
|
/**
|
|
* Insert each SGV entry as an immutable BLOOD_GLUCOSE mg/dL row, then fold the
|
|
* rollup tier + invalidate status-insight caches once at the end (mirrors the
|
|
* Withings / WHOOP sync tail). First-write-wins: the `update` branch is empty
|
|
* so a re-sync of the same reading is a no-op rather than a rewrite. Best-effort
|
|
* on the per-row write + the rollup fold — one bad row never aborts the pass,
|
|
* and the count of failed rows is returned so the caller can surface a partial
|
|
* write failure instead of stamping success over it.
|
|
*
|
|
* Returns `inserted` (rows whose key was absent before this pass — the honest
|
|
* "new readings" count that also drives the arrival emit) and `failedRows`.
|
|
*/
|
|
export async function upsertNightscoutEntries(
|
|
userId: string,
|
|
entries: ParsedSgvEntry[],
|
|
): Promise<{ inserted: number; failedRows: number }> {
|
|
if (entries.length === 0) return { inserted: 0, failedRows: 0 };
|
|
|
|
const type = "BLOOD_GLUCOSE" as MeasurementType;
|
|
// A CGM that reports a glucose the app's own band calls impossible is
|
|
// reporting a sensor fault or a unit slip, not a reading. Drop it: the row
|
|
// would otherwise be immutable (first-write-wins), so an impossible sample
|
|
// that lands here never corrects itself on a later pass.
|
|
const mapped = dropImplausibleMeasurements(
|
|
"nightscout",
|
|
entries.map((entry) => mapSgvEntryToMeasurement(entry)),
|
|
(m) => ({ type, value: m.value }),
|
|
);
|
|
if (mapped.length === 0) return { inserted: 0, failedRows: 0 };
|
|
|
|
// Probe which keys already exist so a re-confirmed reading (the upsert's
|
|
// no-op update branch) is not miscounted as a fresh import and the arrival
|
|
// spine emits ONLY the genuinely-new rows. Cohort passes are sequential per
|
|
// user, so no concurrent insert races this probe. A probe failure degrades
|
|
// to "treat every row as fresh" — the rollup fold + count self-correct on
|
|
// the next tick.
|
|
const existingKeys = new Set<string>();
|
|
try {
|
|
const existing = await prisma.measurement.findMany({
|
|
where: {
|
|
userId,
|
|
type,
|
|
source: "NIGHTSCOUT",
|
|
externalId: { in: mapped.map((m) => m.externalId) },
|
|
},
|
|
select: { externalId: true },
|
|
});
|
|
for (const row of existing) {
|
|
if (row.externalId) existingKeys.add(row.externalId);
|
|
}
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`nightscout: insert-probe failed for ${userId}: ${err}`,
|
|
);
|
|
}
|
|
|
|
let failedRows = 0;
|
|
const insertedRows: InsertedMeasurementArrivalRow[] = [];
|
|
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
|
|
|
for (const m of mapped) {
|
|
try {
|
|
const row = await prisma.measurement.upsert({
|
|
where: {
|
|
userId_type_source_externalId: {
|
|
userId,
|
|
type,
|
|
source: "NIGHTSCOUT",
|
|
externalId: m.externalId,
|
|
},
|
|
},
|
|
create: {
|
|
userId,
|
|
type,
|
|
source: "NIGHTSCOUT",
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
externalId: m.externalId,
|
|
},
|
|
// First-write-wins: a CGM sample is immutable, so a re-sync of the
|
|
// same reading must not rewrite the stored value or measuredAt.
|
|
// `deletedAt: null` is a no-op on a live row, a deliberate
|
|
// RESURRECTION on a tombstoned one — Nightscout is the source of
|
|
// truth for its own rows, so a re-synced reading brings the row
|
|
// back (mirrors Google / Fitbit).
|
|
update: { deletedAt: null },
|
|
select: { id: true },
|
|
});
|
|
touched.push({ type, measuredAt: m.measuredAt });
|
|
if (!existingKeys.has(m.externalId)) {
|
|
insertedRows.push({ type, measuredAt: m.measuredAt, id: row.id });
|
|
}
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`nightscout: failed to upsert measurement: ${err}`,
|
|
);
|
|
failedRows++;
|
|
}
|
|
}
|
|
|
|
// Spine parity with every other measurement writer: emit at most one arrival
|
|
// per supported kind for the rows freshly inserted. BLOOD_GLUCOSE is not yet
|
|
// an arrival kind (`ARRIVAL_MEASUREMENT_KIND`), so this is behaviourally
|
|
// inert for glucose today — it removes Nightscout as the spine's odd one out
|
|
// and lights up automatically once a `glucose` arrival kind + its consuming
|
|
// surface land in a later release.
|
|
void emitInsertedMeasurementArrivals(
|
|
userId,
|
|
insertedRows,
|
|
"nightscout",
|
|
).catch(() => {});
|
|
|
|
try {
|
|
const keys = collapseToTypeDayKeys(touched);
|
|
for (const k of keys) {
|
|
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
|
|
}
|
|
invalidateStatusInsightsForTypes(
|
|
userId,
|
|
keys.map((k) => k.type),
|
|
).catch((err) => {
|
|
getEvent()?.addWarning(
|
|
`nightscout: status-insight invalidate failed for ${userId}: ${err}`,
|
|
);
|
|
});
|
|
} catch (err) {
|
|
getEvent()?.addWarning(
|
|
`nightscout: rollup fold failed for ${userId}: ${err}`,
|
|
);
|
|
}
|
|
|
|
return { inserted: insertedRows.length, failedRows };
|
|
}
|