chore(release): v1.4.27

v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-15 22:59:10 +02:00
1 parent d98e85cd03
commit 4f09c3f862
314 files changed
+26801 -4419

No files matched your search

+6 -6
View File
@@ -141,13 +141,13 @@ describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
expect(ids).toContain("achievements");
});
it("includes the insightsPreview widget (v1.4.16 phase D reconcile C2)", () => {
// CRITICAL C2: dashboard preview of the polished AI recommendations
// surface. Same Zod-enum-drift trap as A5 — if the widget id isn't
// listed here, the API PUT silently 422s and the layout toggle
// never persists. Pin the contract to catch a regression early.
it("does NOT advertise a retired insightsPreview widget (v1.4.27 B1)", () => {
// v1.4.27 B1 retired the standalone dashboard preview because it
// duplicated the much-richer `/insights` advisor surface. The
// widget id must stay out of the default layout so a stale client
// cannot reintroduce it via a layout PUT.
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
expect(ids).toContain("insightsPreview");
expect(ids).not.toContain("insightsPreview");
});
it("survives a round-trip through serialize → resolve unchanged", () => {
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
/**
* v1.4.27 B3 — ASN + carrier lookup tests.
*
* The MaxMind GeoLite2-ASN MMDB is too large for the test fixtures
* directory (~10 MB) and shipping it would bloat git history. These
* tests mock `mmdb-lib` directly so the assertion focuses on the
* resolver contract:
*
* - Private / loopback IPs short-circuit to `null` without touching
* the reader.
* - A missing MMDB file (no offline DB on the host) yields `null`.
* - A row from the DB with both `autonomous_system_number` and
* `autonomous_system_organization` is folded into `{ asn, carrier }`.
* - Reader throws are swallowed (the helper is called from a
* fire-and-forget audit-log path; a thrown error would surface as
* an unhandled rejection).
* - The offline-first `lookupIpLocation` path picks the German city
* name first, English second, with the country ISO code from
* `country` or `registered_country`.
*
* The MMDB Reader is mocked once at the top of the file; per-test
* shape control happens through the `setReaderRow` + `setReaderThrows`
* helpers below. The `fs` stub is also installed module-wide so the
* lazy loader believes the DB exists.
*/
interface AsnRow {
autonomous_system_number?: number;
autonomous_system_organization?: string;
}
interface CityRow {
city?: { names?: Record<string, string> };
country?: { iso_code?: string };
registered_country?: { iso_code?: string };
}
// Test-time spy state — captured by the mocked Reader. The Reader
// constructor inspects the buffer header to decide whether it's
// serving City or ASN rows.
const readerState = {
city: null as ((ip: string) => CityRow | null) | null,
asn: null as ((ip: string) => AsnRow | null) | null,
cityThrows: false,
asnThrows: false,
cityExists: false,
asnExists: false,
};
vi.mock("mmdb-lib", () => ({
Reader: class {
private kind: "city" | "asn";
constructor(buf: Buffer) {
const head = buf.toString("utf-8", 0, 4);
this.kind = head === "city" ? "city" : "asn";
}
get(ip: string): AsnRow | CityRow | null {
if (this.kind === "asn") {
if (readerState.asnThrows) throw new Error("mmdb-asn boom");
return readerState.asn ? readerState.asn(ip) : null;
}
if (readerState.cityThrows) throw new Error("mmdb-city boom");
return readerState.city ? readerState.city(ip) : null;
}
},
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
const existsSync = (p: string) => {
if (p.endsWith("GeoLite2-City.mmdb")) return readerState.cityExists;
if (p.endsWith("GeoLite2-ASN.mmdb")) return readerState.asnExists;
return false;
};
const readFileSync = (p: string) => {
if (p.endsWith("GeoLite2-City.mmdb"))
return Buffer.from("city-mmdb-stub");
if (p.endsWith("GeoLite2-ASN.mmdb"))
return Buffer.from("asn-mmdb-stub");
throw new Error(`unexpected read ${p}`);
};
return {
...actual,
existsSync,
readFileSync,
default: { ...actual, existsSync, readFileSync },
};
});
const ORIGINAL_ENV = { ...process.env };
beforeEach(async () => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_DISABLED;
delete process.env.GEOLITE2_DIR;
readerState.city = null;
readerState.asn = null;
readerState.cityThrows = false;
readerState.asnThrows = false;
readerState.cityExists = false;
readerState.asnExists = false;
vi.resetModules();
// Reset the lazy cache so each test gets a fresh Reader load.
const mod = await import("../geo");
mod.__resetGeoLite2CacheForTests();
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => {
it("returns null for private + loopback addresses without touching the reader", async () => {
const get = vi.fn();
readerState.asn = get;
readerState.asnExists = true;
const { lookupIpAsn } = await import("../geo");
expect(lookupIpAsn("10.0.0.5")).toBeNull();
expect(lookupIpAsn("127.0.0.1")).toBeNull();
expect(lookupIpAsn("::1")).toBeNull();
expect(lookupIpAsn(null)).toBeNull();
expect(get).not.toHaveBeenCalled();
});
it("returns null when the GeoLite2-ASN MMDB is missing", async () => {
readerState.asnExists = false;
const { lookupIpAsn } = await import("../geo");
expect(lookupIpAsn("8.8.8.8")).toBeNull();
});
it("returns { asn, carrier } when the reader resolves a public IP", async () => {
readerState.asnExists = true;
readerState.asn = () => ({
autonomous_system_number: 3320,
autonomous_system_organization: "Deutsche Telekom AG",
});
const { lookupIpAsn } = await import("../geo");
expect(lookupIpAsn("84.131.0.1")).toEqual({
asn: 3320,
carrier: "Deutsche Telekom AG",
});
});
it("returns null when the reader has no row for the IP", async () => {
readerState.asnExists = true;
readerState.asn = () => null;
const { lookupIpAsn } = await import("../geo");
expect(lookupIpAsn("8.8.8.8")).toBeNull();
});
it("returns the asn with carrier=null when the org field is missing", async () => {
readerState.asnExists = true;
readerState.asn = () => ({ autonomous_system_number: 64500 });
const { lookupIpAsn } = await import("../geo");
expect(lookupIpAsn("8.8.8.8")).toEqual({ asn: 64500, carrier: null });
});
it("swallows reader throws and returns null (caller is fire-and-forget)", async () => {
readerState.asnExists = true;
readerState.asnThrows = true;
const { lookupIpAsn } = await import("../geo");
expect(lookupIpAsn("8.8.8.8")).toBeNull();
});
});
describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => {
it("prefers the German exonym from the MMDB Names record", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
country: { iso_code: "DE" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
// Offline hit — no online fallback was needed.
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls back to the English city name when the German exonym is absent", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { en: "Birmingham" } },
country: { iso_code: "GB" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB");
});
it("accepts the registered_country ISO when country is absent", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { en: "Geneva" } },
registered_country: { iso_code: "CH" },
});
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH");
});
it("falls back to the online provider when the offline DB has no city/country", async () => {
readerState.cityExists = true;
readerState.city = () => ({ city: { names: {} } });
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
new TextEncoder().encode(
JSON.stringify({ success: true, city: "Hamburg", country_code: "DE" }),
).buffer as ArrayBuffer,
{
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
},
),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("falls back to the online provider when the offline DB is missing entirely", async () => {
readerState.cityExists = false;
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
new TextEncoder().encode(
JSON.stringify({ success: true, city: "Berlin", country_code: "DE" }),
).buffer as ArrayBuffer,
{
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
},
),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("skips the online fallback when IP_GEO_LOOKUP_DISABLED=1 and offline misses", async () => {
process.env.IP_GEO_LOOKUP_DISABLED = "1";
readerState.cityExists = false;
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,186 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* v1.4.27 R5 — runtime detection + one-shot admin notification for the
* offline GeoLite2 tier.
*
* The CI workflow drops an `.empty` marker into `assets/geolite2/`
* whenever `MAXMIND_LICENSE_KEY` is unset, so the Docker COPY still
* lands a non-empty directory in `/opt/geolite2/`. The runtime resolver
* detects the marker on first fallback, fires a single localised
* notification to every admin, and stays silent for the rest of the
* process lifetime.
*
* The tests cover the three states the design pins:
*
* 1. `.empty` marker present, City MMDB absent → notification fires
* on first public-IP lookup.
* 2. No marker, City MMDB present → no notification fires.
* 3. Marker present, repeat lookups → notification fires once and
* never again.
*
* `mmdb-lib` is stubbed via the same shape the existing
* `geo-asn.test.ts` uses; `fs` is left real because `offlineGeoReady`
* reads real files at the temp-dir path picked per test, and the
* `dispatchLocalisedNotification` + `prisma.user.findMany` are mocked
* so the test never reaches a database or a sender.
*/
vi.mock("mmdb-lib", () => ({
Reader: class {
constructor(_buf: Buffer) {
void _buf;
}
get(ip: string): unknown {
void ip;
return null;
}
},
}));
const dispatchSpy = vi.fn(async () => undefined);
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: dispatchSpy,
}));
const findManySpy = vi.fn(async () => [{ id: "admin-1" }]);
vi.mock("@/lib/db", () => ({
prisma: {
user: { findMany: findManySpy },
},
}));
const ORIGINAL_ENV = { ...process.env };
let tmpRoot: string;
async function flushMicrotasks(): Promise<void> {
// The notification path is fire-and-forget — `void notify…()` —
// and pulls in `@/lib/db` + `@/lib/notifications/dispatch-localised`
// through dynamic `import()` calls, so each dispatch needs several
// microtask + macrotask flushes before the spy registers the call.
for (let i = 0; i < 20; i++) {
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
}
}
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_DISABLED;
tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-r5-"));
process.env.GEOLITE2_DIR = tmpRoot;
dispatchSpy.mockClear();
findManySpy.mockClear();
findManySpy.mockResolvedValue([{ id: "admin-1" }]);
vi.resetModules();
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("offline-geo runtime detection (v1.4.27 R5)", () => {
it("offlineGeoReady() is false when the .empty marker is present", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
const { offlineGeoReady } = await import("../geo");
expect(offlineGeoReady()).toBe(false);
});
it("offlineGeoReady() is false when the City MMDB is absent", async () => {
const { offlineGeoReady } = await import("../geo");
expect(offlineGeoReady()).toBe(false);
});
it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => {
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
const { offlineGeoReady } = await import("../geo");
expect(offlineGeoReady()).toBe(true);
});
it("fires the one-shot admin notification when the marker is present and lookupIpLocation falls back", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
// Disable the online fallback so the test does not hit the network.
process.env.IP_GEO_LOOKUP_DISABLED = "1";
const { lookupIpLocation } = await import("../geo");
await lookupIpLocation("8.8.8.8");
await flushMicrotasks();
expect(findManySpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
userId: "admin-1",
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
messageKey: "notifications.admin.offlineGeoUnavailableBody",
params: expect.objectContaining({
secretsUrl: expect.stringContaining(
"github.com/MBombeck/HealthLog/settings/secrets/actions",
),
}),
metadata: expect.objectContaining({ source: "geo-offline-detection" }),
}),
);
});
it("does NOT fire the notification when the City MMDB is present", async () => {
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
// The stubbed reader returns null for every IP, which is fine — the
// lookup will still go through the online path, but the offline
// check sees a healthy directory so the notification stays silent.
process.env.IP_GEO_LOOKUP_DISABLED = "1";
const { lookupIpLocation } = await import("../geo");
await lookupIpLocation("8.8.8.8");
await flushMicrotasks();
expect(dispatchSpy).not.toHaveBeenCalled();
expect(findManySpy).not.toHaveBeenCalled();
});
it("fires the notification exactly once across repeated fallbacks (process-level latch)", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
process.env.IP_GEO_LOOKUP_DISABLED = "1";
const { lookupIpLocation, lookupIpAsn } = await import("../geo");
await lookupIpLocation("8.8.8.8");
await flushMicrotasks();
await lookupIpLocation("1.1.1.1");
await flushMicrotasks();
lookupIpAsn("9.9.9.9");
await flushMicrotasks();
// Three fallbacks, exactly one dispatch.
expect(dispatchSpy).toHaveBeenCalledTimes(1);
});
it("falls back silently when no admin user is configured", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
process.env.IP_GEO_LOOKUP_DISABLED = "1";
findManySpy.mockResolvedValueOnce([]);
const { lookupIpLocation } = await import("../geo");
await lookupIpLocation("8.8.8.8");
await flushMicrotasks();
expect(findManySpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled();
});
it("lookupIpAsn fires the notification when the ASN reader is missing and the marker is set", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
const { lookupIpAsn } = await import("../geo");
const result = lookupIpAsn("8.8.8.8");
await flushMicrotasks();
expect(result).toBeNull();
expect(dispatchSpy).toHaveBeenCalledTimes(1);
});
});
@@ -132,6 +132,24 @@ describe("buildCoachSnapshot", () => {
expect(parsed.pulse.timeline.recent.length).toBe(1);
});
it("respects the scope.window — lastYear flags the year-in-review window", async () => {
featuresMock.mockResolvedValue({
bloodPressure: { avg30Sys: 124, coverage: { count: 50 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(120, 124, "BLOOD_PRESSURE_SYS"),
daysAgo(120, 81, "BLOOD_PRESSURE_DIA"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["bp"],
window: "lastYear",
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.scope.window).toBe("lastYear");
expect(out.provenance.windows).toContain("lastYear");
});
it("defaults to all-source last30days when no scope is provided", async () => {
const out = await buildCoachSnapshot("user-1");
const parsed = JSON.parse(out.snapshotJson);
-9
View File
@@ -70,7 +70,6 @@ function deriveDrugNames(raw: string): { display: string; generic: string } {
return { display, generic };
}
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
function parseDaysOfWeek(raw: string | null): number[] {
if (!raw) return [];
@@ -411,11 +410,3 @@ export async function buildGlp1SnapshotBlock(
};
}
/** Exported for tests. */
export const __testables = {
deriveDrugNames,
parseTagList,
parseDaysOfWeek,
predictNextInjection,
WEEKDAY_KEYS,
};
-7
View File
@@ -317,10 +317,3 @@ export async function deleteConversation(
return true;
}
/**
* Count assistant + user turns in one conversation. Used by the
* 20-turn cap before a summarise-and-restart pass.
*/
export async function countMessages(conversationId: string): Promise<number> {
return prisma.coachMessage.count({ where: { conversationId } });
}
+15
View File
@@ -69,6 +69,13 @@ function windowToDays(window: CoachScopeWindow): number {
return 30;
case "last90days":
return 90;
case "lastYear":
// v1.4.27 B7 / BL-P6-4 — year-in-review window. Same 365-day
// ceiling as the historical `allTime` cap so the prompt budget
// stays bounded, but with explicit semantics — the Coach knows
// the user is asking about a long-horizon view rather than the
// unbounded "all time" backfill.
return 365;
case "allTime":
// Cap "allTime" at one year for the timeline. The aggregate
// section already cites multi-year ranges via the features
@@ -331,6 +338,14 @@ export async function buildCoachSnapshot(
// budget for free-tier accounts.
const snapshot: Record<string, unknown> = {};
const windows = new Set<CoachProvenance["windows"][number]>();
// v1.4.27 B7 / BL-P6-4 — seed the provenance window set with the
// user's resolved scope so the year-in-review window surfaces in the
// provenance envelope even when the per-metric branches below only
// emit `last30days` / `last90days` chips. Older windows are added
// by the metric branches as before.
if (window === "lastYear" || window === "allTime") {
windows.add(window);
}
const metrics = new Set<CoachProvenance["metrics"][number]>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
+8 -1
View File
@@ -41,6 +41,11 @@ export const coachScopeWindowSchema = z.enum([
"last7days",
"last30days",
"last90days",
// v1.4.27 B7 / BL-P6-4 — long-horizon window for the year-in-review
// surfaces. Sits between `last90days` and `allTime` so the snapshot
// builder can sample a denser timeline (one row per week) than the
// unbounded fallback while still surfacing seasonal patterns.
"lastYear",
"allTime",
]);
@@ -145,7 +150,9 @@ export interface CoachProvenance {
* Time windows the assistant drew on. Same enum the strict insight
* schema uses elsewhere so the UI can pin a mini-chart to the chip.
*/
windows: ReadonlyArray<"last7days" | "last30days" | "last90days" | "allTime">;
windows: ReadonlyArray<
"last7days" | "last30days" | "last90days" | "lastYear" | "allTime"
>;
/**
* Metric topics referenced. Stable contract keys — translated by the
* UI, never by the server.
-38
View File
@@ -72,41 +72,3 @@ Fokussiere auf das Zusammenspiel der verschiedenen Parameter und identifiziere d
${snapshotJson}`;
}
// Backward-compatible export for insights/generate route (Task 8 uses this)
export function buildGeneratePrompts(
featuresJson: string,
privacyMode: string,
locale: Locale,
): { systemPrompt: string; userPrompt: string } {
if (locale === "en") {
const modeLabel =
privacyMode === "raw"
? "Aggregated data + raw values (entire available period, anonymised)"
: "Aggregated data only (no exact timestamps or raw values)";
return {
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: `Analyse the following health data.
Data mode: ${modeLabel}
Use each metric's coverage object for measurement frequency and time spans.
Use correlations for cross-metric analysis and historicalComparison for temporal context.
${featuresJson}`,
};
}
const modeLabel =
privacyMode === "raw"
? "Aggregierte Daten + Rohdaten (gesamter verfügbarer Zeitraum, anonymisiert)"
: "Nur aggregierte Daten (keine exakten Zeitstempel oder Rohwerte)";
return {
systemPrompt: getGeneralStatusSystemPrompt(locale),
userPrompt: `Analysiere die folgenden Gesundheitsdaten.
Datenmodus: ${modeLabel}
Beachte die coverage-Objekte jeder Metrik für Informationen zu Messhäufigkeit und Zeiträumen.
Nutze die correlations-Daten für Kreuzanalysen und historicalComparison für temporale Einordnung.
${featuresJson}`,
};
}
+1 -2
View File
@@ -714,8 +714,7 @@ const INSIGHTS_LOCALE_REPLY_FOOTER_FALLBACK: Record<
/**
* Returns the active scope-hardened system prompt for a given locale.
* Use this in place of the legacy `getInsightsSystemPrompt` once the
* route migrates to `generateInsight()` (planned v1.4.16).
* The route consumes this via `generateInsight()`.
*/
export function getStrictInsightsSystemPrompt(locale: Locale): string {
if (locale === "de") return SYSTEM_PROMPT_DE;
-20
View File
@@ -187,11 +187,6 @@ export function loadSafetyContracts(locale: Locale): SafetyContractMatrix {
return data;
}
/** Clear the in-process cache. Used in tests that swap matrices. */
export function _resetSafetyContractsCacheForTests(): void {
cache = {};
}
/**
* Return the locale-specific body of a ground rule. For EN the matrix
* stores it under `.en`; for every other locale it lives under
@@ -213,20 +208,5 @@ export function getGroundRuleBody(
return body;
}
/**
* Iterate every (locale, ground-rule-key) pair. Used by the parity +
* refusal-probe tests so they cover the full 14×6 = 84 grid without
* hard-coding the locale list.
*/
export function forEachLocaleAndRule(
fn: (locale: Locale, key: GroundRuleKey) => void,
): void {
for (const locale of ALL_LOCALES) {
for (const key of GROUND_RULE_KEYS) {
fn(locale, key);
}
}
}
/** Ordered locale list — exported so tests can iterate identically. */
export const SAFETY_CONTRACT_LOCALES = ALL_LOCALES;
-7
View File
@@ -305,13 +305,6 @@ export function getWeightRange(heightCm: number): { min: number; max: number } {
};
}
/**
* Normal resting pulse range (AHA).
*/
export function getPulseRange(): { min: number; max: number } {
return { min: 60, max: 100 };
}
/**
* Recommended nightly sleep duration for adults (AASM/SRS).
*/
-51
View File
@@ -104,57 +104,6 @@ export function trendSlope(
};
}
// ── Trend Line Points ────────────────────────────────────
/**
* Generate two data points representing the linear trend line endpoints.
* Used by chart components to overlay a regression line.
*/
export function trendLinePoints(
data: DataPoint[],
windowDays: number,
): { start: DataPoint; end: DataPoint } | null {
if (data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const cutoff =
sorted[sorted.length - 1].date.getTime() - windowDays * 24 * 60 * 60 * 1000;
const window = sorted.filter((p) => p.date.getTime() >= cutoff);
if (window.length < 2) return null;
const startTime = window[0].date.getTime();
const points = window.map((p) => ({
x: (p.date.getTime() - startTime) / (24 * 60 * 60 * 1000),
y: p.value,
}));
const n = points.length;
const sumX = points.reduce((s, p) => s + p.x, 0);
const sumY = points.reduce((s, p) => s + p.y, 0);
const sumXY = points.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = points.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
const endDays = points[points.length - 1].x;
return {
start: {
date: window[0].date,
value: Math.round(intercept * 100) / 100,
},
end: {
date: window[window.length - 1].date,
value: Math.round((intercept + slope * endDays) * 100) / 100,
},
};
}
// ── Anomaly Detection ────────────────────────────────────
export interface Anomaly {
+14 -1
View File
@@ -78,7 +78,20 @@ function safeRequestProp<R>(
try {
return read(request as NextRequest);
} catch (err) {
if (isTolerableRequestProbeError(err)) return fallback;
if (isTolerableRequestProbeError(err)) {
// v1.4.27 B7 / BL-P1-3 — surface every fallback so a real read
// regression cannot hide behind the tolerated-error narrowing.
// Vitest direct-invoke and the force-static placeholder path are
// the two known-quiet shapes; anything else worth a look should
// show up in the dev console + the run log.
if (process.env.NODE_ENV !== "test") {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[api-handler] safeRequestProp fallback — tolerable error: ${msg}`,
);
}
return fallback;
}
throw err;
}
}
+98 -1
View File
@@ -11,11 +11,12 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
lookupIpAsn: vi.fn(),
}));
import { auditLog } from "../audit";
import { prisma } from "@/lib/db";
import { lookupIpLocation } from "@/lib/geo";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
const ENTRY = { id: "audit-1" };
@@ -23,6 +24,9 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue(ENTRY as never);
vi.mocked(prisma.auditLog.update).mockResolvedValue(ENTRY as never);
// Default: no ASN row resolves. Tests that exercise carrier
// resolution override this with `mockReturnValueOnce`.
vi.mocked(lookupIpAsn).mockReturnValue(null);
});
afterEach(() => {
@@ -183,4 +187,97 @@ describe("auditLog", () => {
expect(prisma.auditLog.update).toHaveBeenCalledOnce();
});
// ── v1.4.27 B3 — ASN + carrier resolution ────────────────────────
it("writes asn + carrier alongside location when both resolvers fire", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3320,
carrier: "Deutsche Telekom AG",
});
await auditLog("auth.login.password", {
userId: "user-14",
ipAddress: "84.131.0.1",
});
await flush();
expect(lookupIpAsn).toHaveBeenCalledWith("84.131.0.1");
expect(prisma.auditLog.update).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: {
location: "Berlin, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
},
});
});
it("writes asn + carrier even when the location lookup returns null", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3209,
carrier: "Vodafone GmbH",
});
await auditLog("auth.login.passkey", {
userId: "user-15",
ipAddress: "139.7.0.1",
});
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: {
asn: 3209,
carrier: "Vodafone GmbH",
},
});
});
it("writes location only when the ASN lookup misses", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
await auditLog("auth.login.password", {
userId: "user-16",
ipAddress: "192.0.2.1",
});
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: { location: "Berlin, DE" },
});
});
it("does NOT update when both resolvers miss", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
await auditLog("auth.login.password", {
userId: "user-17",
ipAddress: "192.0.2.2",
});
await flush();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("carries a null carrier through when the ASN row has no organisation", async () => {
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce({ asn: 64500, carrier: null });
await auditLog("auth.login.password", {
userId: "user-18",
ipAddress: "192.0.2.3",
});
await flush();
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: { asn: 64500, carrier: null },
});
});
});
+31 -9
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { lookupIpLocation } from "@/lib/geo";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
export async function auditLog(
action: string,
@@ -18,20 +18,42 @@ export async function auditLog(
},
});
// Fire-and-forget: resolve IP location and update the entry (3s timeout)
// Fire-and-forget: resolve IP location + carrier and update the entry
// with whichever fields the resolver could fill. v1.4.27 B3 — carrier
// resolution is synchronous offline MMDB read so it cannot outrun the
// location-lookup 3 s race; we run both inside the same race so a
// hung online provider still bounds the await window.
if (opts.ipAddress && action.startsWith("auth.")) {
const ipAddress = opts.ipAddress;
const timeout = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 3000),
);
Promise.race([lookupIpLocation(opts.ipAddress), timeout])
.then((location) => {
if (location) {
return prisma.auditLog.update({
where: { id: entry.id },
data: { location },
});
Promise.race([
Promise.all([
lookupIpLocation(ipAddress),
Promise.resolve(lookupIpAsn(ipAddress)),
]),
timeout,
])
.then((result) => {
if (!result) return;
const [location, asnRow] = result;
const data: {
location?: string;
asn?: number;
carrier?: string | null;
} = {};
if (location) data.location = location;
if (asnRow) {
data.asn = asnRow.asn;
data.carrier = asnRow.carrier;
}
if (Object.keys(data).length === 0) return;
return prisma.auditLog.update({
where: { id: entry.id },
data,
});
})
.catch(() => {
// Silently ignore geo lookup failures
+35
View File
@@ -0,0 +1,35 @@
/**
* Shared chart layout constants for the dashboard trend strip.
*
* Why a single module: prior to v1.4.27 the three chart files
* (`health-chart.tsx`, `mood-chart.tsx`, `medication-compliance-chart.tsx`)
* carried their own hard-coded heights, which drifted apart over time —
* MoodChart landed on 280 px while every other sibling rendered at 240 px,
* which broke the vertical rhythm of the trend strip. The constants
* below give every chart card one source of truth so a future maintainer
* can retune from one place.
*
* Heights are in CSS pixels. Tailwind arbitrary-value classes elsewhere
* read them as `h-[${CHART_HEIGHT_PX}px]`.
*/
/** Full-size trend-card body height (non-mini chart cards on the dashboard). */
export const CHART_HEIGHT_PX = 240;
/**
* Range-preset options for charts that expose a 7d / 30d / 90d / All
* range selector. Each entry carries the i18n keys for the visible
* label + sr-only title, plus a `points` count used by the underlying
* `HealthChart` query window. `points: 0` denotes "all data".
*
* Re-used by the GLP-1 dashboard tile so its range strip stays in
* lockstep with the rest of the trend strip.
*/
export const CHART_RANGE_PRESETS = [
{ labelKey: "charts.points7Label", titleKey: "charts.points7Title", points: 7 },
{ labelKey: "charts.points30Label", titleKey: "charts.points30Title", points: 30 },
{ labelKey: "charts.points90Label", titleKey: "charts.points90Title", points: 90 },
{ labelKey: "charts.pointsAllLabel", titleKey: "charts.pointsAllTitle", points: 0 },
] as const;
export type ChartRangePreset = (typeof CHART_RANGE_PRESETS)[number];
+1 -17
View File
@@ -29,11 +29,6 @@ export const DASHBOARD_WIDGET_IDS = [
"bpInTarget",
"oxygenSaturation",
"achievements",
// v1.4.16 phase D reconcile (CRITICAL C2) — dashboard preview of the
// top severity-ordered AI recommendations + "View all" CTA. Has only
// a `visible` (chart-row) surface; tileVisible is forced false because
// there's no tile-strip representation for it.
"insightsPreview",
// v1.4.25 W8d — VO2 max trend tile (opt-in, secondary-metric pattern).
// Self-gates on having any VO2_MAX sample; default-invisible so
// existing dashboards stay unchanged until the user enables it in
@@ -216,22 +211,11 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
// `tileVisible` is forced false because there is no tile surface
// for this widget; only the chart-row card.
{ id: "achievements", visible: true, tileVisible: false, order: 13 },
// v1.4.16 phase D reconcile (CRITICAL C2) — AI insights preview
// anchored at the top of the chart row. Default visible so users
// discover the feature without hunting through Settings →
// Dashboard; self-gates on having a generated payload AND at least
// one recommendation (`<InsightsCardPreview>` returns null
// otherwise). `tileVisible` forced false because the preview lives
// in the chart row, not the tile strip. The dashboard page
// renders this entry independently of the sorted `charts[]` array
// so it stays pinned above charts even if the user reorders other
// widgets; only the boolean visibility flag is consumed.
{ id: "insightsPreview", visible: true, tileVisible: false, order: 14 },
// v1.4.25 W8d — VO2 max trend tile. Default-invisible on both
// surfaces; opt-in via Settings → Dashboard. Only the tile-strip
// representation today (`visible` stays false so the chart-row
// gate never fires; the chart card lands in v1.5).
{ id: "vo2Max", visible: false, tileVisible: false, order: 15 },
{ id: "vo2Max", visible: false, tileVisible: false, order: 14 },
],
};
-4
View File
@@ -61,7 +61,3 @@ export function formatTime(date: Date | string): string {
return formatters().time(date);
}
/** Locale-aware short weekday + date. */
export function formatDateWithWeekday(date: Date | string): string {
return formatters().dateWithWeekday(date);
}
+280 -18
View File
@@ -1,23 +1,70 @@
/**
* IP geolocation lookup for audit-log enrichment.
*
* Default provider is ipwho.is (HTTPS, free, no key). Both the response
* shape from ipwho.is and the fallback ip-api.com pro endpoint are
* accepted, so swapping providers via IP_GEO_LOOKUP_URL only requires
* matching one of those response shapes.
* v1.4.27 B3: offline-first resolver. The hot path is now a microsecond-
* scale local read against the bundled MaxMind GeoLite2-City MMDB at
* `/opt/geolite2/GeoLite2-City.mmdb` (and the matching GeoLite2-ASN
* database for carrier lookups). The previous `ipwho.is` HTTPS path is
* kept as the second-tier fallback for IPs the offline DB cannot
* resolve (freshly-allocated ranges that lag the monthly GeoLite2
* release roll).
*
* Setting IP_GEO_LOOKUP_DISABLED=1 disables lookup entirely — used by
* deployments that do not want any IP egress to a third-party service
* (V3 audit: GDPR Art. 32 + Art. 44, plaintext HTTP IP egress).
* 1. Private / loopback / opt-out → null (no lookup).
* 2. Offline MMDB hit → return immediately.
* 3. Offline miss → fall back to the existing `ipwho.is` path.
*
* v1.4.16 A8a: the body is decoded as UTF-8 explicitly via
* `TextDecoder('utf-8')` instead of `Response.json()`, and an
* The legacy contract still holds: `lookupIpLocation` returns a
* `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the
* new sibling that resolves the autonomous-system number + the carrier
* organisation string (so the admin login overview can render a
* carrier chip next to the auth provider).
*
* Both helpers are safe to call from any request context. The MMDB
* Reader is loaded lazily on first call and held in a module-level
* cache; the file load is synchronous and not cheap, but it only
* happens once per worker process.
*
* The base path is overridable via `GEOLITE2_DIR` (default
* `/opt/geolite2`). When the DB files are missing the helpers silently
* skip the offline tier — local dev without the MMDBs still works and
* falls straight back to the online provider.
*
* Default provider for the online fallback is ipwho.is (HTTPS, free,
* no key). Both the response shape from ipwho.is and the fallback
* ip-api.com pro endpoint are accepted, so swapping providers via
* `IP_GEO_LOOKUP_URL` only requires matching one of those response
* shapes.
*
* Setting `IP_GEO_LOOKUP_DISABLED=1` disables the online fallback
* entirely — used by deployments that do not want any IP egress to a
* third-party service (V3 audit: GDPR Art. 32 + Art. 44, plaintext
* HTTP IP egress). The offline tier still runs because no egress is
* involved.
*
* v1.4.16 A8a: the online-fallback body is decoded as UTF-8 explicitly
* via `TextDecoder('utf-8')` instead of `Response.json()`, and an
* `Accept-Language: de, en;q=0.5` hint is sent so providers return
* native city names ("Nürnberg") rather than ASCII folds
* ("Nuremberg"). The previous path lost umlauts in production for at
* least one maintainer-flagged login row that rendered as "Nrnberg" in
* /admin/login-overview — see `docs/audit/v1416-summary.md` once cut.
* native city names ("Nürnberg") rather than ASCII folds ("Nuremberg").
* The previous path lost umlauts in production for at least one
* maintainer-flagged login row that rendered as "Nrnberg" in
* /admin/login-overview — see `docs/audit/v1416-summary.md`.
*
* v1.4.27 R5: the build no longer hard-fails on a missing
* `MAXMIND_LICENSE_KEY` secret — the CI workflow drops an `.empty`
* marker into the geo asset directory when the key is unset.
* `offlineGeoReady()` is the canonical check used by `/api/version`
* and the admin status surface, and the lookup paths fire a one-shot
* admin notification on the first fallback so the maintainer hears
* about the gap from the running app.
*/
import fs from "node:fs";
import path from "node:path";
import { Reader as MmdbReader } from "mmdb-lib";
import type {
AsnResponse,
CityResponse,
} from "mmdb-lib/lib/reader/response";
import { getEvent } from "@/lib/logging/context";
interface IpwhoIsResponse {
success?: boolean;
@@ -38,6 +85,170 @@ const PRIVATE_IP =
const DEFAULT_GEO_URL = "https://ipwho.is";
function geoLiteDir(): string {
return process.env.GEOLITE2_DIR ?? "/opt/geolite2";
}
// ── Offline tier (MaxMind GeoLite2) ──────────────────────────────────
//
// Both readers are held in a process-local cache. We use `undefined`
// for "never tried", and `null` for "tried, file missing" so we don't
// pay the `existsSync + readFileSync + parse` cost on every lookup.
interface MmdbCache {
city?: MmdbReader<CityResponse> | null;
asn?: MmdbReader<AsnResponse> | null;
}
const cache: MmdbCache = {};
function loadMmdbReader<T extends import("mmdb-lib/lib/reader/response").Response>(
file: string,
): MmdbReader<T> | null {
try {
const full = path.join(geoLiteDir(), file);
if (!fs.existsSync(full)) return null;
const buf = fs.readFileSync(full);
return new MmdbReader<T>(buf);
} catch {
// Corrupt MMDB or unreadable file → fall back to online tier.
// Never throw from the geo lookup path; the auth-audit caller is
// fire-and-forget and a thrown error would propagate into an
// unhandled rejection.
return null;
}
}
function getCityReader(): MmdbReader<CityResponse> | null {
if (cache.city === undefined) {
cache.city = loadMmdbReader<CityResponse>("GeoLite2-City.mmdb");
}
return cache.city;
}
function getAsnReader(): MmdbReader<AsnResponse> | null {
if (cache.asn === undefined) {
cache.asn = loadMmdbReader<AsnResponse>("GeoLite2-ASN.mmdb");
}
return cache.asn;
}
/**
* Test-only — reset the lazy reader cache so a test can swap the
* `GEOLITE2_DIR` between cases without leaking the previous Reader.
* Not exported from the public surface; the tests import via the
* module-level alias.
*/
export function __resetGeoLite2CacheForTests(): void {
cache.city = undefined;
cache.asn = undefined;
notifiedThisProcess = false;
}
// ── Offline readiness + one-shot admin notification ─────────────────
//
// The CI workflow drops an `.empty` marker into the geo asset directory
// when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in
// which case the City + ASN MMDBs are absent and every lookup hits the
// online fallback. `offlineGeoReady()` is the canonical truth used by
// `/api/version` and the admin status surface so the two paths cannot
// disagree.
//
// `notifiedThisProcess` is a module-level latch so the admin only
// receives the "offline geo is disabled" notification once per worker
// boot — every subsequent fallback hit is silent. Test-only reset is
// folded into the cache reset above.
let notifiedThisProcess = false;
export function offlineGeoReady(): boolean {
try {
const dir = geoLiteDir();
if (fs.existsSync(path.join(dir, ".empty"))) return false;
return fs.existsSync(path.join(dir, "GeoLite2-City.mmdb"));
} catch {
return false;
}
}
async function notifyOfflineGeoUnavailable(): Promise<void> {
if (notifiedThisProcess) return;
notifiedThisProcess = true;
// Defer the resolve so we don't pay the cost of pulling the
// Prisma client into the import graph until the first fallback —
// most calls are cache hits or private-IP short-circuits.
try {
const [{ prisma }, { dispatchLocalisedNotification }] = await Promise.all([
import("@/lib/db"),
import("@/lib/notifications/dispatch-localised"),
]);
const admins = await prisma.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
if (admins.length === 0) {
getEvent()?.addWarning(
"geo: offline databases unavailable, no admin user configured to notify",
);
return;
}
getEvent()?.addWarning(
"geo: offline databases unavailable, falling back to ipwho.is — notifying admins",
);
for (const admin of admins) {
await dispatchLocalisedNotification({
userId: admin.id,
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
messageKey: "notifications.admin.offlineGeoUnavailableBody",
params: {
secretsUrl:
"https://github.com/MBombeck/HealthLog/settings/secrets/actions",
},
metadata: { source: "geo-offline-detection" },
});
}
} catch (err) {
// Never let a notification failure propagate — the auth-audit
// caller is fire-and-forget.
getEvent()?.addWarning(
`geo: offline-geo notification failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* Pick the best-available localised city name from a GeoLite2
* record. German first because the user-base is DACH-skewed; English
* as a graceful fallback for cities that don't have a German exonym.
* The Names map is incomplete in MaxMind for some small German
* towns, so we accept any of the locales the MMDB layout exposes.
*/
function pickCityName(city: CityResponse["city"]): string | null {
if (!city?.names) return null;
return (
city.names.de ??
city.names.en ??
city.names.fr ??
city.names.es ??
null
);
}
function lookupIpLocationOffline(ip: string): string | null {
const reader = getCityReader();
if (!reader) return null;
try {
const row = reader.get(ip);
if (!row) return null;
const city = pickCityName(row.city);
const cc = row.country?.iso_code ?? row.registered_country?.iso_code;
if (!city || !cc) return null;
return `${city}, ${cc}`;
} catch {
return null;
}
}
function buildLookupUrl(ip: string): string {
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
/\/+$/,
@@ -70,12 +281,8 @@ async function readUtf8Json(res: Response): Promise<unknown> {
return JSON.parse(text);
}
export async function lookupIpLocation(
ip: string | null,
): Promise<string | null> {
if (!ip || PRIVATE_IP.test(ip)) return null;
async function lookupIpLocationOnline(ip: string): Promise<string | null> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
try {
const res = await fetch(buildLookupUrl(ip), {
signal: AbortSignal.timeout(3000),
@@ -109,3 +316,58 @@ export async function lookupIpLocation(
return null;
}
}
export async function lookupIpLocation(
ip: string | null,
): Promise<string | null> {
if (!ip || PRIVATE_IP.test(ip)) return null;
const offline = lookupIpLocationOffline(ip);
if (offline) return offline;
// First public-IP lookup that has to leave the offline tier — if the
// offline DBs are missing entirely, send the one-shot admin alert so
// the maintainer can wire `MAXMIND_LICENSE_KEY` when convenient. The
// notification is fire-and-forget so the audit-log path stays fast.
if (!offlineGeoReady()) {
void notifyOfflineGeoUnavailable();
}
return lookupIpLocationOnline(ip);
}
/**
* Resolve the autonomous-system number + carrier organisation for an
* IP. Offline-only against the bundled GeoLite2-ASN MMDB — the public
* `ipwho.is` endpoint does not expose an ASN field in its free tier,
* so a miss returns `null` rather than falling back to an online
* lookup.
*
* Private / loopback IPs return `null` without touching the reader.
* Same gate the location helper applies.
*/
export function lookupIpAsn(
ip: string | null,
): { asn: number; carrier: string | null } | null {
if (!ip || PRIVATE_IP.test(ip)) return null;
const reader = getAsnReader();
if (!reader) {
// No offline ASN data — alert once per process. There is no online
// fallback for ASN, but the maintainer still wants to know the
// carrier chip is going to stay empty until the secret is set.
if (!offlineGeoReady()) {
void notifyOfflineGeoUnavailable();
}
return null;
}
try {
const row = reader.get(ip);
if (!row) return null;
const asn = row.autonomous_system_number;
if (typeof asn !== "number") return null;
const carrier = row.autonomous_system_organization || null;
return { asn, carrier };
} catch {
return null;
}
}
@@ -0,0 +1,69 @@
/**
* v1.4.27 B6 — `format.dateShort` / `timeShort` / `dateTime` ordering.
*
* Each shipped locale carries a `format.*` namespace that documents
* its native date-pattern ordering as i18n strings. The actual
* formatting still routes through `Intl.*` via
* `src/lib/format-locale.ts`; the format keys exist so downstream
* surfaces (PDF export, email templates, CSV docstrings) can render
* the date hint without a JS context.
*
* This smoke test pins the ordering per locale so a copy-paste
* regression that flips DD/MM to MM/DD on the French bundle would
* fail fast.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MESSAGES = join(__dirname, "../../../../messages");
function loadFormat(locale: string): {
dateShort: string;
timeShort: string;
dateTime: string;
} {
const bundle = JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
);
const fmt = bundle.format as
| { dateShort?: string; timeShort?: string; dateTime?: string }
| undefined;
if (!fmt || !fmt.dateShort || !fmt.timeShort || !fmt.dateTime) {
throw new Error(`Locale ${locale} is missing the format namespace`);
}
return fmt as { dateShort: string; timeShort: string; dateTime: string };
}
describe("locale-native format ordering", () => {
it("DE renders day-month-year with dot separators", () => {
const fmt = loadFormat("de");
expect(fmt.dateShort).toBe("{day}.{month}.{year}");
expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
it("EN renders month-day-year with slash separators", () => {
const fmt = loadFormat("en");
expect(fmt.dateShort).toBe("{month}/{day}/{year}");
expect(fmt.dateTime).toBe("{month}/{day}/{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
it.each([["fr"], ["es"], ["it"]])(
"%s renders day-month-year with slash separators (native ordering)",
(locale) => {
const fmt = loadFormat(locale);
expect(fmt.dateShort).toBe("{day}/{month}/{year}");
expect(fmt.dateTime).toBe("{day}/{month}/{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
},
);
it("PL renders day-month-year with dot separators (native ordering)", () => {
const fmt = loadFormat("pl");
expect(fmt.dateShort).toBe("{day}.{month}.{year}");
expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}");
expect(fmt.timeShort).toBe("{hour}:{minute}");
});
});
+1 -35
View File
@@ -11,41 +11,7 @@ import {
} from "react";
import { locales, defaultLocale, type Locale } from "./config";
import { makeFormatters, type Formatters } from "../format-locale";
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
const allMessages: Record<Locale, Record<string, unknown>> = {
de: deMessages,
en: enMessages,
fr: frMessages,
es: esMessages,
it: itMessages,
pl: plMessages,
};
// Resolve nested key like "nav.dashboard" from a message object
function resolveKey(
messages: Record<string, unknown>,
key: string,
): string | undefined {
const parts = key.split(".");
let current: unknown = messages;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === "string" ? current : undefined;
}
import { allMessages, resolveKey } from "./shared-resolve";
interface I18nContextValue {
locale: Locale;
+1 -31
View File
@@ -1,35 +1,5 @@
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import { defaultLocale, type Locale } from "./config";
const allMessages: Record<Locale, Record<string, unknown>> = {
de: deMessages,
en: enMessages,
fr: frMessages,
es: esMessages,
it: itMessages,
pl: plMessages,
};
function resolveKey(
messages: Record<string, unknown>,
key: string,
): string | undefined {
const parts = key.split(".");
let current: unknown = messages;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === "string" ? current : undefined;
}
import { allMessages, resolveKey } from "./shared-resolve";
export interface ServerTranslator {
locale: Locale;
+59
View File
@@ -0,0 +1,59 @@
/**
* Shared message-bundle map and nested-key resolver used by both the
* client-side `I18nProvider` in `context.tsx` and the server-side
* `getServerTranslator()` in `server-translator.ts`.
*
* v1.4.27 B7 / BL-P4-11-S10 — the two surfaces previously each carried
* their own private copy of `allMessages` plus a near-identical
* `resolveKey` helper. The maps drifted twice during v1.4.18 / v1.4.25
* polishing passes when a new locale was added; pinning both call-sites
* to the same import keeps client and server fallback semantics in
* lockstep and shrinks the surface a future locale addition has to
* touch from two to one.
*/
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import { type Locale } from "./config";
/**
* Single source of truth for the per-locale message bundle.
*
* Both the client provider and the server translator import this map.
* The bundles ship as static JSON imports so they tree-shake into the
* client chunk that needs them and into the server chunk that needs
* them without duplicating.
*/
export const allMessages: Record<Locale, Record<string, unknown>> = {
de: deMessages,
en: enMessages,
fr: frMessages,
es: esMessages,
it: itMessages,
pl: plMessages,
};
/**
* Resolve a dotted key (`nav.dashboard`) inside a flat-or-nested
* message object. Returns `undefined` when the key path doesn't land
* on a string leaf so the caller can chain a fallback bundle (English,
* raw key) without ambiguity.
*/
export function resolveKey(
messages: Record<string, unknown>,
key: string,
): string | undefined {
const parts = key.split(".");
let current: unknown = messages;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return typeof current === "string" ? current : undefined;
}
@@ -108,3 +108,47 @@ describe("generateBloodPressureStatusForUser — v1.4.6 bucketed payload", () =>
expect(dia.monthly.length).toBeGreaterThan(0);
});
});
describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() },
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() },
] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateBloodPressureStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -78,3 +78,39 @@ describe("generateBmiStatusForUser — v1.4.6 bucketed payload", () => {
expect(bmi.monthly[0]).toHaveProperty("n");
});
});
describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: 175,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 80, measuredAt: new Date() },
] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateBmiStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
CoachLaunchProvider,
useCoachLaunch,
} from "../coach-launch-context";
/**
* v1.4.27 R3d MB4 — Coach launch context smoke contract.
*
* The project's test convention is `renderToStaticMarkup` (no
* `@testing-library/react` dependency). We can't simulate interaction
* inside SSR, but we can verify:
* 1. The provider mounts and renders its children.
* 2. The hook returns a value-shape (open, prefill, askCoach,
* setOpen) when called beneath the provider.
* 3. The hook returns `null` when called outside the provider —
* consumers degrade gracefully (e.g. the launch button renders
* nothing rather than crashing).
*/
function Probe({ output }: { output: string[] }) {
const launch = useCoachLaunch();
if (!launch) {
output.push("null");
return null;
}
output.push(
JSON.stringify({
hasAskCoach: typeof launch.askCoach,
hasSetOpen: typeof launch.setOpen,
open: launch.open,
prefill: launch.prefill,
}),
);
return <span data-slot="probe-output">{output.join(",")}</span>;
}
describe("CoachLaunchProvider", () => {
it("returns null when consumed outside the provider", () => {
const output: string[] = [];
renderToStaticMarkup(<Probe output={output} />);
expect(output).toEqual(["null"]);
});
it("exposes the full context shape when consumed beneath the provider", () => {
const output: string[] = [];
renderToStaticMarkup(
<CoachLaunchProvider>
<Probe output={output} />
</CoachLaunchProvider>,
);
expect(output).toHaveLength(1);
const parsed = JSON.parse(output[0]);
expect(parsed.hasAskCoach).toBe("function");
expect(parsed.hasSetOpen).toBe("function");
expect(parsed.open).toBe(false);
expect(parsed.prefill).toBeNull();
});
it("renders the children inside the provider mount", () => {
const html = renderToStaticMarkup(
<CoachLaunchProvider>
<div data-slot="child-mount">child</div>
</CoachLaunchProvider>,
);
expect(html).toContain('data-slot="child-mount"');
expect(html).toContain("child");
});
});
@@ -99,3 +99,45 @@ describe("generateGeneralStatusForUser — v1.4.6 bucketed payload", () => {
expect(weight.monthly[0]).toHaveProperty("n");
});
});
describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateGeneralStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -107,3 +107,65 @@ describe("generateMedicationComplianceStatusForUser — v1.4.6 bucketed payload"
expect(dailySeries.monthly[0]).toHaveProperty("n");
});
});
describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached summary + per-medication text", async () => {
const now = new Date();
const medication = {
id: "med-1",
name: "Ramipril",
dose: "5mg",
active: true,
createdAt: new Date(now.getTime() - 60 * dayMs),
schedules: [{ id: "s1", time: "08:00" }],
};
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication,
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: JSON.stringify({
summary:
"Compliance held steady at 96%. metric:BLOOD_PRESSURE_SYS over the last 30 days.",
medications: [
{
medicationId: "med-1",
summary:
"Ramipril taken on schedule. metric:PULSE no missed doses in 30 days.",
},
],
}),
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateMedicationComplianceStatusForUser("user-1", {
locale: "en",
});
expect(result.summary).toBeTruthy();
expect(result.summary).not.toContain("metric:");
expect(result.medications[0].text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as {
summary: string;
medications: Array<{ text: string }>;
};
expect(parsed.summary).not.toContain("metric:");
expect(parsed.medications[0].text).not.toContain("metric:");
});
});
@@ -0,0 +1,171 @@
import { describe, it, expect } from "vitest";
import { hasMetricData } from "../metric-availability";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.4.27 F17/F18/F19 — gating helper unit cover.
*
* The matrix covers each metric × {has data, no data, missing
* summary entry, undefined summaries}. The `MOOD` and `MEDICATION`
* paths read the boolean flags directly; the rest go through the
* `summaries[METRIC].count` lookup.
*/
function fakeSummary(count: number): DataSummary {
return {
count,
latest: count > 0 ? 1 : null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
};
}
const emptyInputs = {
summaries: {},
hasMood: false,
hasMedication: false,
};
describe("hasMetricData", () => {
it("returns false for PULSE when summaries are undefined", () => {
expect(
hasMetricData("PULSE", {
summaries: undefined,
hasMood: false,
hasMedication: false,
}),
).toBe(false);
});
it("returns false for PULSE when the summary entry is missing", () => {
expect(hasMetricData("PULSE", emptyInputs)).toBe(false);
});
it("returns false for PULSE when the count is zero", () => {
expect(
hasMetricData("PULSE", {
...emptyInputs,
summaries: { PULSE: fakeSummary(0) },
}),
).toBe(false);
});
it("returns true for PULSE when the count is one", () => {
expect(
hasMetricData("PULSE", {
...emptyInputs,
summaries: { PULSE: fakeSummary(1) },
}),
).toBe(true);
});
it("returns true for WEIGHT when the count is positive", () => {
expect(
hasMetricData("WEIGHT", {
...emptyInputs,
summaries: { WEIGHT: fakeSummary(42) },
}),
).toBe(true);
});
it("derives BMI from WEIGHT count — true when WEIGHT > 0", () => {
expect(
hasMetricData("BMI", {
...emptyInputs,
summaries: { WEIGHT: fakeSummary(3) },
}),
).toBe(true);
});
it("derives BMI from WEIGHT count — false when WEIGHT is missing", () => {
expect(hasMetricData("BMI", emptyInputs)).toBe(false);
});
it("returns true for BLOOD_PRESSURE_SYS independently of DIA", () => {
expect(
hasMetricData("BLOOD_PRESSURE_SYS", {
...emptyInputs,
summaries: { BLOOD_PRESSURE_SYS: fakeSummary(5) },
}),
).toBe(true);
});
it("returns false for BLOOD_PRESSURE_DIA when only SYS has data", () => {
expect(
hasMetricData("BLOOD_PRESSURE_DIA", {
...emptyInputs,
summaries: { BLOOD_PRESSURE_SYS: fakeSummary(5) },
}),
).toBe(false);
});
it("returns true for MOOD when hasMood is true", () => {
expect(
hasMetricData("MOOD", {
summaries: {},
hasMood: true,
hasMedication: false,
}),
).toBe(true);
});
it("returns false for MOOD when hasMood is false even with a summary", () => {
expect(
hasMetricData("MOOD", {
summaries: { MOOD: fakeSummary(99) },
hasMood: false,
hasMedication: false,
}),
).toBe(false);
});
it("returns true for MEDICATION when hasMedication is true", () => {
expect(
hasMetricData("MEDICATION", {
summaries: {},
hasMood: false,
hasMedication: true,
}),
).toBe(true);
});
it("returns false for MEDICATION when hasMedication is false", () => {
expect(hasMetricData("MEDICATION", emptyInputs)).toBe(false);
});
it("returns true for SLEEP_DURATION when the summary count is positive", () => {
expect(
hasMetricData("SLEEP_DURATION", {
...emptyInputs,
summaries: { SLEEP_DURATION: fakeSummary(8) },
}),
).toBe(true);
});
it("returns true for VO2_MAX when the summary count is positive", () => {
expect(
hasMetricData("VO2_MAX", {
...emptyInputs,
summaries: { VO2_MAX: fakeSummary(2) },
}),
).toBe(true);
});
it("returns false for VO2_MAX when summaries are undefined", () => {
expect(
hasMetricData("VO2_MAX", {
summaries: undefined,
hasMood: false,
hasMedication: false,
}),
).toBe(false);
});
});
@@ -87,3 +87,37 @@ describe("generateMoodStatusForUser — v1.4.6 bucketed payload", () => {
expect(mood.monthly[0]).toHaveProperty("n");
});
});
describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
const t = new Date();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t },
] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"summary":"Mood stayed positive. metric:MOOD"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateMoodStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
@@ -81,3 +81,42 @@ describe("generatePulseStatusForUser — v1.4.6 bucketed payload", () => {
expect(pulse.monthly[0]).toHaveProperty("n");
});
});
describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 72, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generatePulseStatusForUser("user-1", { locale: "en" });
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
expect(parsed.text).toContain("Your pulse is stable.");
});
});
@@ -81,3 +81,39 @@ describe("generateWeightStatusForUser — v1.4.6 bucketed payload", () => {
expect(weight.monthly[0]).toHaveProperty("n");
});
});
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(resolveProvider).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content:
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
model: "x",
tokensUsed: 1,
})),
} as never);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});
+2 -1
View File
@@ -23,6 +23,7 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -58,7 +59,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+2 -1
View File
@@ -8,6 +8,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -43,7 +44,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+103
View File
@@ -0,0 +1,103 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
/**
* v1.4.27 R3d MB4 — Coach launch context.
*
* Until v1.4.27 the Coach drawer was mounted inline on
* `/insights/page.tsx` body, which meant navigating to a routed
* sub-page (`/insights/blutdruck`, etc.) unmounted the drawer and
* removed every entry point to the Coach. The MA3 audit + Decision F
* promoted the drawer up to `/insights/layout.tsx` so every routed
* Insights surface can launch it.
*
* Shape:
* - `<CoachLaunchProvider>` owns the drawer's open / prefill state
* and renders the children beneath the launch surface.
* - `useCoachLaunch()` returns `{ open, askCoach, setOpen, prefill }`
* so any descendant can open the drawer with an optional prefill
* string (the hero-strip suggested-prompt chips feed prefills;
* the sticky `<CoachLaunchButton>` opens without one).
*
* The provider is intentionally tiny — the actual drawer mount sits
* next to it in the layout, reading the same state via the hook.
*/
export interface CoachLaunchScope {
/**
* Optional metric the user is looking at when they open the Coach.
* Reserved for v1.4.28 so the drawer can pre-narrow the source rail
* to the active metric; ignored in v1.4.27.
*/
metric?: string;
}
interface CoachLaunchValue {
/** Whether the Coach drawer is currently open. */
open: boolean;
/** Current prefill string (or null when the next open should start blank). */
prefill: string | null;
/** Open the drawer with an optional prefill + scope hint. */
askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
/** Direct setter for the open state — the drawer consumes this. */
setOpen: (next: boolean) => void;
}
const CoachLaunchContext = createContext<CoachLaunchValue | null>(null);
export interface CoachLaunchProviderProps {
children: ReactNode;
}
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const [open, setOpen] = useState<boolean>(false);
const [prefill, setPrefill] = useState<string | null>(null);
const askCoach = useCallback(
(nextPrefill?: string | null, scope?: CoachLaunchScope) => {
// `scope` is currently reserved for v1.4.28 (metric-narrow on the
// sources rail). Accept the parameter today so the call sites
// don't need to change shape when the rail starts honouring it.
void scope;
setPrefill(nextPrefill ?? null);
setOpen(true);
},
[],
);
const handleSetOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
// Drop the prefill on close so the next open starts blank.
setPrefill(null);
}
}, []);
const value = useMemo<CoachLaunchValue>(
() => ({ open, prefill, askCoach, setOpen: handleSetOpen }),
[open, prefill, askCoach, handleSetOpen],
);
return (
<CoachLaunchContext.Provider value={value}>
{children}
</CoachLaunchContext.Provider>
);
}
/**
* Read the Coach launch context. Returns `null` when called outside a
* `<CoachLaunchProvider>` so consumer components can degrade gracefully
* (e.g. the hero strip's "Ask the coach" action stays disabled until
* the provider mounts).
*/
export function useCoachLaunch(): CoachLaunchValue | null {
return useContext(CoachLaunchContext);
}
+2 -6
View File
@@ -13,6 +13,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -51,7 +52,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function summarizeSeries(series: Array<{ value: number }>) {
@@ -402,8 +403,3 @@ export async function generateGeneralStatusForUser(
};
}
export function resolveGeneralStatusLocale(
locale: string | null | undefined,
): SupportedLocale {
return normalizeLocale(locale);
}
@@ -13,6 +13,7 @@ import {
getPreviousInsightContext,
} from "@/lib/insights/memory";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
// 360 daily days + 24 monthly windows ≈ 1080 days of intake history.
@@ -52,7 +53,7 @@ function round(value: number, digits = 1): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
+89
View File
@@ -0,0 +1,89 @@
/**
* v1.4.27 F17/F18/F19 — single decision point for "does the user have
* any observations on this metric?".
*
* Three render gates consume this helper:
*
* 1. The routed Insights sub-pages (`/insights/{slug}/page.tsx`)
* early-return an empty-state with a CTA when the gate is false.
* 2. The Insights tab strip (`<InsightsTabStrip>`) filters pills so
* a metric with zero observations doesn't surface a navigation
* target the user can't act on.
* 3. The dashboard tile registry (`src/app/page.tsx`) — wired in
* bucket B1 — applies the same gate per tile.
*
* Source signals:
*
* - Sensor metrics (`PULSE`, `WEIGHT`, `BMI`, `BLOOD_PRESSURE_*`,
* `SLEEP_DURATION`, `VO2_MAX`, `STEPS`, `ACTIVE_ENERGY`) read
* `summaries[METRIC].count` from the analytics endpoint.
* - `BMI` is derived from `WEIGHT` so it inherits the weight count.
* - Event-driven metrics (`MOOD`, `MEDICATION`) read the boolean
* `hasMood` / `hasMedication` flags the caller threads in from
* `/api/insights/comprehensive` (mood summary count > 0,
* medications array length > 0).
*
* Auto-light-up behaviour: once the iOS native client uploads its
* first Apple-Health measurement, `summaries[METRIC].count` flips
* from 0 to ≥1 and the next React-Query refetch reveals the matching
* pill, sub-page, and dashboard tile — no separate feature flag
* needed. (F18.)
*/
import type { DataSummary } from "@/lib/analytics/trends";
/**
* Metric keys the gating helper distinguishes. Mirrors the
* `MeasurementType` enum for sensor metrics and adds synthetic keys
* for `MOOD`, `MEDICATION`, and `BMI` (derived from `WEIGHT`).
*
* Adding a new metric is a one-place change: list it here, decide
* which branch of `hasMetricData` handles it, and the sub-page /
* tab strip / dashboard tile gates pick it up automatically.
*/
export type InsightMetric =
| "BLOOD_PRESSURE_SYS"
| "BLOOD_PRESSURE_DIA"
| "PULSE"
| "WEIGHT"
| "BMI"
| "MOOD"
| "MEDICATION"
| "SLEEP_DURATION"
| "VO2_MAX"
| "STEPS"
| "ACTIVE_ENERGY";
/**
* Inputs the gating helper consumes. The `summaries` shape mirrors
* the `/api/analytics` response (`Record<MeasurementType, DataSummary>`)
* — undefined means the caller hasn't loaded analytics yet, which
* the helper treats as "not available" so loading shells aren't
* accidentally lit up.
*/
export interface InsightInputs {
summaries: Record<string, DataSummary> | undefined;
/** Whether the user has logged at least one mood entry. */
hasMood: boolean;
/** Whether the user has at least one active medication. */
hasMedication: boolean;
}
/**
* Decide whether a metric has enough observations to surface its
* sub-page, tab pill, or dashboard tile. Returns `false` for any
* unknown branch so a future metric added to `InsightMetric` without
* a handler stays hidden until the gate is wired explicitly.
*/
export function hasMetricData(
metric: InsightMetric,
inputs: InsightInputs,
): boolean {
if (metric === "MOOD") return inputs.hasMood;
if (metric === "MEDICATION") return inputs.hasMedication;
if (metric === "BMI") {
// BMI is derived from WEIGHT + the profile height. The chart
// mounts even at one weight reading; the gate matches.
return (inputs.summaries?.WEIGHT?.count ?? 0) > 0;
}
return (inputs.summaries?.[metric]?.count ?? 0) > 0;
}
+2 -1
View File
@@ -15,6 +15,7 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -50,7 +51,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
-71
View File
@@ -40,77 +40,6 @@ export interface ComparisonSnapshot {
}>;
}
const INSIGHTS_SYSTEM_PROMPT_DE = `Du bist ein Gesundheitsdaten-Insights-Assistent. Du analysierst aggregierte Gesundheitsdaten und gibst klare, datenbasierte Hinweise.
WICHTIGE REGELN:
- Erkläre immer, welche Datenpunkte zu deinen Schlussfolgerungen geführt haben.
- Gib ein Konfidenzniveau an (niedrig/mittel/hoch) basierend auf der Datenmenge UND Datendichte.
- Antworte auf Deutsch. Das Feld "confidence" muss exakt einer der englischen Enum-Schlüssel ("niedrig"|"mittel"|"hoch") sein — nicht übersetzen.
DATENABDECKUNG UND MESSZEITRÄUME:
- Jede Metrik enthält ein "coverage"-Objekt mit: count (Anzahl Messungen), spanDays (Zeitspanne in Tagen), avgDaysBetween (durchschnittlicher Abstand zwischen Messungen), oldestDaysAgo und newestDaysAgo.
- Berücksichtige diese Informationen bei deiner Analyse:
* Weniger als 5 Messpunkte pro Metrik: Sage explizit, dass noch nicht genügend Daten für eine fundierte Aussage vorliegen.
* Große Lücken (avgDaysBetween > 7): Weise darauf hin, dass die Daten spärlich sind und Trends nur eingeschränkt belastbar sind.
* Daten über einen langen Zeitraum (spanDays > 60) aber wenig Messpunkte: Versuche trotzdem eine grobe Entwicklung abzuleiten, weise aber auf die eingeschränkte Aussagekraft hin.
* Wenn die neueste Messung länger als 7 Tage zurückliegt (newestDaysAgo > 7): Erwähne, dass die Daten nicht aktuell sind.
- context.dataSpanDays zeigt den Gesamtzeitraum aller Messungen, context.oldestMeasurementDaysAgo und context.newestMeasurementDaysAgo den ältesten und neuesten Messpunkt.
AUSGABEFORMAT (JSON):
{
"changed": "Was hat sich verändert? (Bezug auf Zeiträume und Datenbasis)",
"stable": "Was ist stabil geblieben?",
"drivers": "Mögliche Zusammenhänge und Hypothesen (mit Vorsicht formuliert)",
"nextSteps": "Nächste kleine Schritte",
"confidence": "niedrig|mittel|hoch",
"limitations": "Einschränkungen dieser Analyse (inkl. Datenlücken und Messhäufigkeit)"
}
Antworte NUR mit validem JSON im obigen Format. Alle natürlichsprachigen Felder auf Deutsch.`;
const INSIGHTS_SYSTEM_PROMPT_EN = `You are a health-data insights assistant. You analyse aggregated health data and provide clear, data-backed observations.
KEY RULES:
- Always explain which data points drove each conclusion.
- Provide a confidence level (niedrig/mittel/hoch) based on data volume AND density.
- Reply in English. The "confidence" field must contain exactly one of the stable enum keys ("niedrig"|"mittel"|"hoch") — do not translate.
DATA COVERAGE AND MEASUREMENT WINDOWS:
- Each metric carries a "coverage" object with: count (number of measurements), spanDays (timespan in days), avgDaysBetween (mean spacing between measurements), oldestDaysAgo and newestDaysAgo.
- Factor these into the analysis:
* Fewer than 5 points per metric: state explicitly that there is not yet enough data for a reliable statement.
* Large gaps (avgDaysBetween > 7): note that the data is sparse and trends are only weakly supported.
* Long span (spanDays > 60) but few points: still try to derive a coarse direction, but flag the limited reliability.
* If the newest measurement is more than 7 days old (newestDaysAgo > 7): mention that the data is not current.
- context.dataSpanDays shows the overall span of all measurements; context.oldestMeasurementDaysAgo and context.newestMeasurementDaysAgo show the oldest and newest points.
OUTPUT FORMAT (JSON):
{
"changed": "What changed? (Reference time spans and data basis.)",
"stable": "What stayed stable?",
"drivers": "Possible associations and hypotheses (worded cautiously).",
"nextSteps": "Next small steps.",
"confidence": "niedrig|mittel|hoch",
"limitations": "Limitations of this analysis (incl. data gaps and measurement frequency)."
}
Reply with VALID JSON ONLY in the format above. All natural-language fields in English.`;
export function getInsightsSystemPrompt(locale: Locale): string {
return locale === "en"
? INSIGHTS_SYSTEM_PROMPT_EN
: INSIGHTS_SYSTEM_PROMPT_DE;
}
export interface InsightsOutput {
changed: string;
stable: string;
drivers: string;
nextSteps: string;
confidence: "niedrig" | "mittel" | "hoch";
limitations: string;
}
export function buildUserPrompt(
featuresJson: string,
privacyMode: string,
+2 -1
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/analytics/pulse-targets";
import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -49,7 +50,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
-3
View File
@@ -46,6 +46,3 @@ export const SUB_PAGE_SLUGS = Object.keys(SUB_PAGE_METRIC) as SubPageSlug[];
*/
export const INSIGHTS_OVERVIEW_PATH = "/insights" as const;
export function isSubPageSlug(value: string): value is SubPageSlug {
return (SUB_PAGE_SLUGS as readonly string[]).includes(value);
}
+2 -1
View File
@@ -18,6 +18,7 @@ import {
dayOffsetToBerlinDayKey,
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { annotate } from "@/lib/logging/context";
const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
@@ -53,7 +54,7 @@ function average(values: number[]): number {
}
function normalizeSummaryText(value: string): string {
return value.replace(/\s+/g, " ").trim();
return stripChartTokens(value).replace(/\s+/g, " ").trim();
}
function normalizeLocale(value: string | null | undefined): SupportedLocale {
@@ -8,10 +8,51 @@
*
* The formatter is a pure function so we can test deterministically
* without touching Prisma or the dispatcher.
*
* v1.4.27 — additionally asserts `parkIntegrationAtReauth` semantics:
* the helper writes the row but neither increments the failure counter
* nor pages admins through the dispatcher. The two scope-skip
* call-sites in `withings/sync-{activity,sleep}.ts` must use this
* helper rather than `recordSyncFailure`.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { formatAdminAlertPayload } from "../status";
vi.mock("@/lib/db", () => ({
prisma: {
integrationStatus: {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc(${s})`,
decrypt: (s: string) =>
s.startsWith("enc(") && s.endsWith(")") ? s.slice(4, -1) : s,
}));
import { formatAdminAlertPayload, parkIntegrationAtReauth } from "../status";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
beforeEach(() => {
vi.resetAllMocks();
});
describe("formatAdminAlertPayload — Withings re-auth", () => {
it("uses the integration display name (Withings, not 'withings')", () => {
@@ -148,3 +189,109 @@ describe("formatAdminAlertPayload — omits errorCode parens when undefined", ()
expect(out.message).toContain("transient error — network reset");
});
});
describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", () => {
it("sets state=error_reauth without incrementing consecutiveFailures and without paging admins", async () => {
// Pre-existing row at counter=87 — the maintainer's reported
// surviving-deploy state. The first scope-skip after the v1.4.27
// deploy must NOT push the row to 88 and must NOT trigger the
// 3-strike alert ladder.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_transient",
lastError: "enc(old)",
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await parkIntegrationAtReauth({
userId: "u1",
integration: "withings",
message: "Withings connection is missing the user.activity scope.",
errorCode: "scope_missing",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.update).toMatchObject({
state: "error_reauth",
lastError: "enc(Withings connection is missing the user.activity scope.)",
});
// The whole point of the helper: counter is not in the update set.
expect(upsertArgs.update).not.toHaveProperty("consecutiveFailures");
// No admin alert fired.
expect(dispatchNotification).not.toHaveBeenCalled();
// A standalone reauth audit row IS written so the ops trail still
// shows the park event — but it lands on
// `integrations.reauth_required`, not `integrations.sync.failed`.
expect(auditLog).toHaveBeenCalledWith(
"integrations.reauth_required",
expect.objectContaining({
userId: "u1",
details: expect.objectContaining({
integration: "withings",
errorCode: "scope_missing",
source: "scope_skip",
}),
}),
);
expect(auditLog).not.toHaveBeenCalledWith(
"integrations.sync.failed",
expect.anything(),
);
});
it("is idempotent on the audit log — a second call for the same scope-skip writes no extra audit row", async () => {
// First call: row already parked with the same lastError. The
// helper detects "no fresh park" and skips the audit write.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
state: "error_reauth",
lastError: "enc(Withings connection is missing the user.activity scope.)",
} as never);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await parkIntegrationAtReauth({
userId: "u1",
integration: "withings",
message: "Withings connection is missing the user.activity scope.",
errorCode: "scope_missing",
});
// Upsert still runs (it's the "set lastAttemptAt" touch), but the
// audit log is unchanged.
expect(prisma.integrationStatus.upsert).toHaveBeenCalledOnce();
expect(auditLog).not.toHaveBeenCalled();
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("creates a fresh row at counter=0 when no row exists yet", async () => {
// First-ever sync attempt on a legacy connection: no row in the
// table. Helper must create the row at counter=0 so a later
// genuine transient burst still has the full 3-strike runway.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
);
await parkIntegrationAtReauth({
userId: "u-new",
integration: "withings",
message: "scope missing",
errorCode: "scope_missing",
});
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
.calls[0][0];
expect(upsertArgs.create).toMatchObject({
userId: "u-new",
integration: "withings",
state: "error_reauth",
consecutiveFailures: 0,
});
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
+74
View File
@@ -270,6 +270,80 @@ export async function recordSyncFailure(
}
}
/**
* Park a connection at `error_reauth` from a deliberate scope-skip
* short-circuit. Unlike `recordSyncFailure`, this helper:
*
* 1. does NOT increment `consecutiveFailures`.
* 2. does NOT write an `integrations.sync.failed` audit row through
* `recordSyncFailure`. A standalone `integrations.reauth_required`
* row is written instead so the operations trail still shows the
* park event.
* 3. does NOT enter the 3-strike alert ladder — no admin Telegram
* page fires.
*
* Idempotent: a second call for the same scope-skip leaves the row at
* `error_reauth` with the same encrypted message and the same counter
* value (no increment). Use it from sync routines that have detected a
* deliberate, structural scope gap (e.g. legacy Withings connection
* missing `user.activity`). The defence-in-depth catch-block path
* stays on `recordSyncFailure` because a 403 reaching the catch is
* genuinely unexpected once the scope-skip lands.
*
* The audit-row-once semantics mean a row is only written if the row
* is not already parked at `error_reauth` with the same `lastError`.
* Re-parking the same scope-skip is a no-op for the audit log.
*/
export async function parkIntegrationAtReauth(opts: {
userId: string;
integration: IntegrationKey;
message: string;
errorCode: string;
}): Promise<void> {
const { userId, integration, message, errorCode } = opts;
const now = new Date();
const encryptedError = safeEncryptError(message);
// Idempotency probe: re-parking the same scope-skip should NOT emit
// another audit row. We read the current row first; only write the
// audit log when the state or error changes.
const existing = await prisma.integrationStatus.findUnique({
where: { userId_integration: { userId, integration } },
select: { state: true, lastError: true },
});
const isFreshPark =
existing?.state !== "error_reauth" || existing.lastError !== encryptedError;
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
userId,
integration,
state: "error_reauth",
lastError: encryptedError,
lastAttemptAt: now,
// First-ever row for this (user, integration) — counter stays at
// 0 so a later genuine transient burst still has the full
// 3-strike runway before paging.
consecutiveFailures: 0,
},
update: {
state: "error_reauth",
lastError: encryptedError,
lastAttemptAt: now,
// Deliberately omit `consecutiveFailures` — the existing value
// is preserved exactly. This is the whole point of the helper.
},
});
if (isFreshPark) {
await auditLog("integrations.reauth_required", {
userId,
details: { integration, message, errorCode, source: "scope_skip" },
});
}
}
/**
* Mark a connection as needing re-auth without recording a fresh
* "attempt". Used by the OAuth/refresh-token flows when they detect
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
lookupIpAsn: vi.fn(),
}));
import {
GEO_BACKFILL_BATCH_CAP,
GEO_BACKFILL_WINDOW_DAYS,
runGeoBackfill,
} from "../geo-backfill";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
interface FakeRow {
id: string;
ipAddress: string | null;
}
function makePrismaMock(rows: FakeRow[]) {
return {
auditLog: {
findMany: vi.fn().mockResolvedValue(rows),
update: vi.fn().mockResolvedValue({}),
},
} as unknown as PrismaClient;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(lookupIpAsn).mockReturnValue(null);
vi.mocked(lookupIpLocation).mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("runGeoBackfill", () => {
it("returns an empty summary when no rows match the filter", async () => {
const prisma = makePrismaMock([]);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 0,
located: 0,
carrierResolved: 0,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("queries rows where location is null + ipAddress is not null + createdAt > now()-30d", async () => {
const prisma = makePrismaMock([]);
const now = new Date("2026-05-15T12:00:00Z");
await runGeoBackfill(prisma, now);
const args = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0];
expect(args?.where).toEqual({
location: null,
ipAddress: { not: null },
createdAt: {
gt: new Date(
now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000,
),
},
});
expect(args?.take).toBe(GEO_BACKFILL_BATCH_CAP);
});
it("caps the batch at 5000 rows per pass", async () => {
expect(GEO_BACKFILL_BATCH_CAP).toBe(5000);
});
it("updates a row with location only when ASN resolver misses", async () => {
const prisma = makePrismaMock([{ id: "a1", ipAddress: "203.0.113.7" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 1,
carrierResolved: 0,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a1" },
data: { location: "Berlin, DE" },
});
});
it("updates a row with asn + carrier alongside location", async () => {
const prisma = makePrismaMock([{ id: "a2", ipAddress: "84.131.0.1" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce("München, DE");
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3320,
carrier: "Deutsche Telekom AG",
});
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 1,
carrierResolved: 1,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a2" },
data: {
location: "München, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
},
});
});
it("updates a row with ASN only when location resolver misses but ASN hits", async () => {
const prisma = makePrismaMock([{ id: "a3", ipAddress: "139.7.0.1" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce({
asn: 3209,
carrier: "Vodafone GmbH",
});
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 0,
carrierResolved: 1,
stillUnresolved: 0,
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a3" },
data: { asn: 3209, carrier: "Vodafone GmbH" },
});
});
it("skips the update when both resolvers miss, counts as still-unresolved", async () => {
const prisma = makePrismaMock([{ id: "a4", ipAddress: "192.0.2.1" }]);
vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 0,
carrierResolved: 0,
stillUnresolved: 1,
});
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("handles a mixed batch of hits and misses", async () => {
const prisma = makePrismaMock([
{ id: "a", ipAddress: "84.131.0.1" },
{ id: "b", ipAddress: "192.0.2.99" },
{ id: "c", ipAddress: "139.7.0.1" },
]);
vi.mocked(lookupIpLocation)
.mockResolvedValueOnce("Berlin, DE") // a
.mockResolvedValueOnce(null) // b
.mockResolvedValueOnce("Hamburg, DE"); // c
vi.mocked(lookupIpAsn)
.mockReturnValueOnce({ asn: 3320, carrier: "Deutsche Telekom AG" }) // a
.mockReturnValueOnce(null) // b
.mockReturnValueOnce({ asn: 3209, carrier: "Vodafone GmbH" }); // c
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 3,
located: 2,
carrierResolved: 2,
stillUnresolved: 1,
});
expect(prisma.auditLog.update).toHaveBeenCalledTimes(2);
});
it("treats a Prisma update throw as still-unresolved without aborting the pass", async () => {
const prisma = makePrismaMock([
{ id: "x", ipAddress: "84.131.0.1" },
{ id: "y", ipAddress: "139.7.0.1" },
]);
vi.mocked(lookupIpLocation)
.mockResolvedValueOnce("Berlin, DE")
.mockResolvedValueOnce("Hamburg, DE");
vi.mocked(prisma.auditLog.update)
.mockRejectedValueOnce(new Error("row gone"))
.mockResolvedValueOnce({} as never);
const summary = await runGeoBackfill(prisma);
expect(summary.scanned).toBe(2);
expect(summary.located).toBe(2);
expect(summary.stillUnresolved).toBe(1);
expect(prisma.auditLog.update).toHaveBeenCalledTimes(2);
});
it("skips rows whose ipAddress is null (defensive — findMany filter already excludes)", async () => {
const prisma = makePrismaMock([{ id: "z", ipAddress: null }]);
const summary = await runGeoBackfill(prisma);
expect(summary).toEqual({
scanned: 1,
located: 0,
carrierResolved: 0,
stillUnresolved: 1,
});
expect(lookupIpLocation).not.toHaveBeenCalled();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* v1.4.27 B3 — Backfill `location` + `asn` + `carrier` on audit-log
* rows that landed with the offline DB unavailable or with `ipwho.is`
* timing out.
*
* The fire-and-forget lookup in `src/lib/auth/audit.ts` runs once at
* audit-creation time. If the offline MMDB was missing on a host
* (operator skipped the GeoLite2 fetch step) or if the online
* provider was down, the row stays at `location IS NULL` forever
* with no retry path. This helper walks those rows and re-resolves
* them through the now-bundled offline databases.
*
* The scope is intentionally narrow:
*
* - Only rows where `location IS NULL` and `ipAddress IS NOT NULL`.
* A null IP has no signal to backfill against.
* - Only rows from the last 30 days. Rows beyond that retention
* window typically belong to deleted users or have aged out of
* the operational triage window where the `location` chip
* matters.
* - Capped at 5 000 rows per pass. Limits the worst-case DB-write
* fan-out so a single backfill burst can't starve a live login
* spike.
*
* Idempotent: a row that the resolver still cannot match (private IP
* sneaking through CF egress, freshly-allocated range the GeoLite2
* release hasn't picked up yet) stays NULL and the next pass tries
* again. The cap is per-call, not per-row, so the helper can be
* scheduled at any cadence without doubling up.
*
* No notifications, no event ladder — the maintainer can read the
* counts off the returned summary and decide whether to keep
* scheduling.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
export const GEO_BACKFILL_BATCH_CAP = 5000;
export const GEO_BACKFILL_WINDOW_DAYS = 30;
export interface GeoBackfillSummary {
scanned: number;
located: number;
carrierResolved: number;
stillUnresolved: number;
}
export async function runGeoBackfill(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<GeoBackfillSummary> {
const cutoff = new Date(
now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000,
);
const rows = await prisma.auditLog.findMany({
where: {
location: null,
ipAddress: { not: null },
createdAt: { gt: cutoff },
},
select: { id: true, ipAddress: true },
orderBy: { createdAt: "desc" },
take: GEO_BACKFILL_BATCH_CAP,
});
const summary: GeoBackfillSummary = {
scanned: rows.length,
located: 0,
carrierResolved: 0,
stillUnresolved: 0,
};
for (const row of rows) {
const ip = row.ipAddress;
if (!ip) {
summary.stillUnresolved += 1;
continue;
}
// Run both resolvers; the offline path is microsecond-scale and
// the online fallback inside `lookupIpLocation` already bounds
// itself with `AbortSignal.timeout(3000)`.
const [location, asnRow] = await Promise.all([
lookupIpLocation(ip),
Promise.resolve(lookupIpAsn(ip)),
]);
const data: { location?: string; asn?: number; carrier?: string | null } =
{};
if (location) {
data.location = location;
summary.located += 1;
}
if (asnRow) {
data.asn = asnRow.asn;
data.carrier = asnRow.carrier;
summary.carrierResolved += 1;
}
if (Object.keys(data).length === 0) {
summary.stillUnresolved += 1;
continue;
}
try {
await prisma.auditLog.update({
where: { id: row.id },
data,
});
} catch {
// The row may have been deleted by the retention sweeper between
// findMany and update. Treat as unresolved; the next pass will
// skip it because the row no longer exists.
summary.stillUnresolved += 1;
}
}
return summary;
}
-5
View File
@@ -44,11 +44,6 @@ export function markWorkerStarted() {
status.lastHeartbeat = new Date().toISOString();
}
export function recordHeartbeat() {
const status = getStatus();
status.lastHeartbeat = new Date().toISOString();
}
export function recordReminderCheck() {
const status = getStatus();
status.lastReminderCheck = new Date().toISOString();
+14 -11
View File
@@ -45,16 +45,16 @@ export type Glp1DrugId =
* record AND a per-brand override map so a single drug-id can carry
* mixed routes (semaglutide → Ozempic/Wegovy sc, Rybelsus oral).
*/
export type Glp1Route = "subcutaneous" | "oral";
type Glp1Route = "subcutaneous" | "oral";
/**
* Drug class descriptor — Tirzepatide is the only dual GIP/GLP-1
* agonist among EMA-approved agents (2026-05); the rest are pure
* GLP-1 receptor agonists.
*/
export type Glp1DrugClass = "GIP-GLP1 dual agonist" | "GLP-1 receptor agonist";
type Glp1DrugClass = "GIP-GLP1 dual agonist" | "GLP-1 receptor agonist";
export type Glp1Pharmacology = {
type Glp1Pharmacology = {
/** Terminal elimination half-life. Days for the long-acting agents,
* hours for liraglutide / short-acting exenatide. */
halfLifeDays: number;
@@ -83,7 +83,7 @@ export type Glp1Pharmacology = {
compartmentModel: "one-compartment" | "two-compartment";
};
export type Glp1Storage = {
type Glp1Storage = {
/** Refrigerated stability — every GLP-1 product is shipped 2–8 °C
* per EMA. */
unopened: {
@@ -97,7 +97,7 @@ export type Glp1Storage = {
};
};
export type Glp1Pen = {
type Glp1Pen = {
/** Pen device name as printed on the carton. */
type: string;
/** Doses per pen / per cartridge — drives W19b inventory
@@ -105,7 +105,7 @@ export type Glp1Pen = {
dosesPerPen: number | null;
};
export type Glp1AdverseReactions = {
type Glp1AdverseReactions = {
/** ≥ 1/10 frequency per EMA §4.8. */
veryCommon: readonly string[];
/** 1/100 to <1/10 per EMA §4.8. */
@@ -116,12 +116,12 @@ export type Glp1AdverseReactions = {
rare: readonly string[];
};
export type Glp1Indications = {
type Glp1Indications = {
t2dm: { firstLine: boolean };
weightManagement: { bmiThreshold: number | null };
};
export type Glp1SourceCitations = {
type Glp1SourceCitations = {
/** Direct link to the EMA EPAR product-information PDF. */
sourceEMA: string;
/** Peer-reviewed pharmacometric citation where one exists. */
@@ -548,7 +548,8 @@ export const GLP1_DRUGS: Readonly<Record<Glp1DrugId, Glp1DrugRecord>> = {
/**
* Resolve the route for a given drug + brand. Falls back to the
* record default when no per-brand override is registered.
* record default when no per-brand override is registered. Read by
* the glp1-knowledge test suite.
*/
export function routeForBrand(
drugId: Glp1DrugId,
@@ -597,8 +598,9 @@ export function findDrugIdByBrand(brand: string): Glp1DrugId | null {
}
/**
* The catalog's drug-ids in stable order. Useful for iteration in
* tests and UI surfaces that need a deterministic display order.
* The catalog's drug-ids in stable order. Read by the GLP-1 PK +
* ladder test suites so a future drug addition flows through the
* verification matrix without a hand-rolled list.
*/
export const GLP1_DRUG_IDS: readonly Glp1DrugId[] = [
"tirzepatide",
@@ -607,3 +609,4 @@ export const GLP1_DRUG_IDS: readonly Glp1DrugId[] = [
"dulaglutide",
"exenatide",
] as const;
+11 -10
View File
@@ -45,7 +45,7 @@ export interface IntakeEventLike {
}
/** One slot the schedule expected the user to dose. */
export interface ExpectedDose {
interface ExpectedDose {
/** Midnight of the local day this dose was expected. */
day: Date;
/** Start of the dose window for this day (Date with HH:mm applied). */
@@ -308,16 +308,18 @@ export function pairDoses(
now: Date,
): PairedDose[] {
const claimed = new Set<number>();
const result: PairedDose[] = [];
// Sort slots by window centre so the earlier slot picks its match
// first; same shape as the reminder-worker's "process schedules in
// window order" loop.
// v1.4.27 B7 / simp-M6 — sort once by `windowStart` for the match
// pass and return the result in the same order. Slots that touch
// pairDoses are always strictly chronologically separable (the
// window radius is far smaller than the day grid step), so sorting
// by `windowStart` and sorting by the centre `(start+end)/2`
// produce the same order. Dropping the second `.sort()` at the
// end keeps the contract intact and removes the O(n log n) tail.
const sorted = [...slots].sort(
(a, b) =>
(a.windowStart.getTime() + a.windowEnd.getTime()) / 2 -
(b.windowStart.getTime() + b.windowEnd.getTime()) / 2,
(a, b) => a.windowStart.getTime() - b.windowStart.getTime(),
);
const result: PairedDose[] = [];
for (const slot of sorted) {
const centre = (slot.windowStart.getTime() + slot.windowEnd.getTime()) / 2;
@@ -352,8 +354,7 @@ export function pairDoses(
result.push({ ...slot, match, status });
}
// Restore the chronological order callers expect.
return result.sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime());
return result;
}
/**
+15 -3
View File
@@ -183,7 +183,19 @@ export function weeksOnCurrentStep(
/**
* Display flag — true when the EMA-reference minimum dwell time has
* elapsed AND a next step exists.
* elapsed AND a next ladder step exists.
*
* Parameters:
* - `drugIdForCeiling`: identifies which ladder to walk so the
* "next step exists" guard knows when the user has hit the
* ceiling. The dwell-time check itself reads `typicalWeeks`
* straight off `currentStep`; the drug id is only needed to
* answer "is there a step above this one?".
* - `currentStep`: the step the user is sitting on today. Null
* when titration is paused or the user is at the bottom of the
* ladder with no recorded dose history.
* - `weeksOnStep`: how many complete weeks have elapsed since the
* user landed on `currentStep`. Computed by `weeksOnCurrentStep`.
*
* IMPORTANT: this is a *reference* signal, not advice. The UI copy
* tied to this boolean is strictly observational ("ladder typically
@@ -191,11 +203,11 @@ export function weeksOnCurrentStep(
* the W19c safety ground-rules.
*/
export function escalationDue(
drugId: Glp1DrugId,
drugIdForCeiling: Glp1DrugId,
currentStep: TitrationStep | null,
weeksOnStep: number,
): boolean {
if (!currentStep) return false;
if (!nextStep(drugId, currentStep)) return false;
if (!nextStep(drugIdForCeiling, currentStep)) return false;
return weeksOnStep >= currentStep.typicalWeeks;
}
+70
View File
@@ -0,0 +1,70 @@
/**
* v1.4.27 B6 / BL-P6-11 — single source of truth for mood verbal labels.
*
* Previously `mood-list.tsx` and `charts/mood-chart.tsx` each carried
* their own label set:
* - List used `mood.levelSuperGut` / `levelGut` / `levelOkay` /
* `levelSchlecht` / `levelLausig` (rendering "Amazing / Good /
* Okay / Bad / Terrible").
* - Chart used `charts.moodLabel1..5` (rendering "Awful / Bad /
* Okay / Good / Great").
*
* Two distinct copy sets for the same five mood states drifted twice
* during v1.4.18 / v1.4.25 polishing passes. Pinning both call-sites
* to the same key map keeps the chart axis and the list copy in
* lockstep.
*/
export const MOOD_ENUM_VALUES = [
"LAUSIG",
"SCHLECHT",
"OKAY",
"GUT",
"SUPER_GUT",
] as const;
export type MoodEnum = (typeof MOOD_ENUM_VALUES)[number];
/** Mood enum → numeric score (1..5). Mirrors `getScoreForMood()`. */
export const MOOD_SCORE_BY_ENUM: Record<MoodEnum, number> = {
LAUSIG: 1,
SCHLECHT: 2,
OKAY: 3,
GUT: 4,
SUPER_GUT: 5,
};
/** Numeric score (1..5) → mood enum. Inverse of `MOOD_SCORE_BY_ENUM`. */
export const MOOD_ENUM_BY_SCORE: Record<number, MoodEnum> = {
1: "LAUSIG",
2: "SCHLECHT",
3: "OKAY",
4: "GUT",
5: "SUPER_GUT",
};
/**
* Mood enum → i18n key for the verbal label.
*
* Both `components/mood/mood-list.tsx` and
* `components/charts/mood-chart.tsx` import from this map. Adding a
* new mood state means updating the enum + key map here once. The
* indexer is `string` rather than `MoodEnum` because list rows arrive
* from the API typed as `string` — the validation layer already
* checked the enum bound via Zod, and a missing key returns
* `undefined` rather than ever rendering a wrong label.
*/
export const MOOD_LABEL_KEYS: Record<string, string> = {
SUPER_GUT: "mood.levelSuperGut",
GUT: "mood.levelGut",
OKAY: "mood.levelOkay",
SCHLECHT: "mood.levelSchlecht",
LAUSIG: "mood.levelLausig",
};
/** Numeric score (1..5) → i18n key for the verbal label. */
export function moodLabelKeyForScore(score: number): string | undefined {
const moodEnum = MOOD_ENUM_BY_SCORE[score];
if (!moodEnum) return undefined;
return MOOD_LABEL_KEYS[moodEnum];
}
@@ -0,0 +1,247 @@
/**
* v1.4.27 F21 — translator-aware dispatch helper.
*
* Drives `dispatchLocalisedNotification` through the three locale
* resolution paths plus the missing-key fallback. The dispatcher and
* translator are mocked so the test is hermetic.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
const dispatchNotificationMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: (...args: unknown[]) =>
dispatchNotificationMock(...args),
}));
const addWarningMock = vi.fn();
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: addWarningMock,
addMeta: vi.fn(),
}),
}));
// Translator mock that mimics the real `getServerTranslator` shape:
// returns the registered string for the locale + key, otherwise falls
// back to the raw key (the real translator does the same after trying
// the default locale).
const fakeStrings: Record<string, Record<string, string>> = {
de: {
"notifications.admin.testTitle": "Test-Hinweis",
"notifications.admin.testBody": "Hallo {name}, der Test läuft.",
},
en: {
"notifications.admin.testTitle": "Test notification",
"notifications.admin.testBody": "Hello {name}, the test is running.",
},
fr: {
"notifications.admin.testTitle": "Notification de test",
"notifications.admin.testBody": "Bonjour {name}, le test est en cours.",
},
};
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: (locale: string) => ({
locale,
t: (key: string, params?: Record<string, string | number>) => {
const bundle = fakeStrings[locale] ?? fakeStrings.en;
let value = bundle[key] ?? fakeStrings.en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
}
}
return value;
},
}),
}));
import { dispatchLocalisedNotification } from "../dispatch-localised";
import { prisma } from "@/lib/db";
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("dispatchLocalisedNotification — locale resolution", () => {
it("uses User.locale=de → German title and body", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: "de",
} as never);
await dispatchLocalisedNotification({
userId: "u-de",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
});
expect(dispatchNotificationMock).toHaveBeenCalledTimes(1);
const payload = dispatchNotificationMock.mock.calls[0][0];
expect(payload.title).toBe("Test-Hinweis");
expect(payload.message).toBe("Hallo Test, der Test läuft.");
expect(payload.userId).toBe("u-de");
expect(payload.eventType).toBe("SYSTEM_ALERT");
});
it("uses User.locale=fr → French title and body", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: "fr",
} as never);
await dispatchLocalisedNotification({
userId: "u-fr",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
});
const payload = dispatchNotificationMock.mock.calls[0][0];
expect(payload.title).toBe("Notification de test");
expect(payload.message).toBe("Bonjour Test, le test est en cours.");
});
it("falls back to the project default locale (en) when User.locale is null", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: null,
} as never);
await dispatchLocalisedNotification({
userId: "u-no-locale",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
});
const payload = dispatchNotificationMock.mock.calls[0][0];
expect(payload.title).toBe("Test notification");
expect(payload.message).toBe("Hello Test, the test is running.");
});
it("falls back to the project default locale when User.locale is an unsupported string", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: "klingon",
} as never);
await dispatchLocalisedNotification({
userId: "u-bogus",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
});
const payload = dispatchNotificationMock.mock.calls[0][0];
expect(payload.title).toBe("Test notification");
});
it("falls back to the default locale when the user row is missing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null);
await dispatchLocalisedNotification({
userId: "u-missing",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
});
const payload = dispatchNotificationMock.mock.calls[0][0];
expect(payload.title).toBe("Test notification");
});
});
describe("dispatchLocalisedNotification — missing translation key", () => {
it("falls back to the raw key string and logs a warning", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: "en",
} as never);
await dispatchLocalisedNotification({
userId: "u-en",
titleKey: "notifications.admin.unknownTitleKey",
messageKey: "notifications.admin.unknownBodyKey",
});
const payload = dispatchNotificationMock.mock.calls[0][0];
// The translator falls back to the raw key — we still send
// *something* rather than dropping the notification.
expect(payload.title).toBe("notifications.admin.unknownTitleKey");
expect(payload.message).toBe("notifications.admin.unknownBodyKey");
// Warning so ops can spot the missing-bundle drift.
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("missing translation"),
);
});
});
describe("dispatchLocalisedNotification — pass-through plumbing", () => {
it("forwards a custom event type when supplied", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: "en",
} as never);
await dispatchLocalisedNotification({
userId: "u-en",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
eventType: "MEDICATION_REMINDER",
});
expect(dispatchNotificationMock.mock.calls[0][0].eventType).toBe(
"MEDICATION_REMINDER",
);
});
it("annotates metadata with preferredChannel when channel is supplied", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
locale: "en",
} as never);
await dispatchLocalisedNotification({
userId: "u-en",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
channel: "telegram",
metadata: { source: "test" },
});
expect(dispatchNotificationMock.mock.calls[0][0].metadata).toEqual({
source: "test",
preferredChannel: "telegram",
});
});
it("does not crash when the locale lookup throws (logs warning, falls back to default)", async () => {
vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(
new Error("db down"),
);
await expect(
dispatchLocalisedNotification({
userId: "u-bad",
titleKey: "notifications.admin.testTitle",
messageKey: "notifications.admin.testBody",
params: { name: "Test" },
}),
).resolves.toBeUndefined();
const payload = dispatchNotificationMock.mock.calls[0][0];
expect(payload.title).toBe("Test notification");
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("locale lookup failed"),
);
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* v1.4.27 F21 — translator-aware notification dispatch.
*
* The base `dispatchNotification` (in `./dispatcher`) is deliberately
* locale-agnostic: it forwards `title` and `message` verbatim to every
* sender. The contract is "the caller composes the localised body".
*
* Several call-sites (admin alerts, deploy-webhook, Telegram test
* routes, reminder-check) compose bodies in English without consulting
* `User.locale`. This helper closes that gap:
*
* 1. resolves the recipient's `User.locale` (falls back to the
* project default when missing).
* 2. calls `getServerTranslator(locale).t(titleKey, params)` and
* `.t(messageKey, params)`.
* 3. delegates to `dispatchNotification` with the resolved strings.
*
* The base dispatcher stays untouched so already-localised callers
* (e.g. `jobs/reminder-phases.ts`) don't churn. Use this helper from
* every new admin- or user-facing surface that emits notifications.
*
* The default `eventType` is `SYSTEM_ALERT` — the only event type that
* predates F21 outside of the medication-reminder lane. Callers that
* need a different event type pass it explicitly; the `channel` option
* is reserved for a future per-channel routing pass and is currently
* a no-op (the dispatcher fans the message across every enabled
* channel as before).
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import type { EventType } from "@/lib/notifications/types";
interface DispatchLocalisedOptions {
userId: string;
titleKey: string;
messageKey: string;
params?: Record<string, string | number>;
/**
* Per-channel routing is forward-compat scaffolding. Today the
* dispatcher fans out across every enabled channel for the user; the
* option is accepted so call-sites can declare intent without
* needing a second helper later.
*/
channel?: "telegram" | "email";
/** Event type for preference gating. Defaults to SYSTEM_ALERT. */
eventType?: EventType;
/** Channel-specific extras forwarded to the dispatcher metadata. */
metadata?: Record<string, unknown>;
}
function isLocale(value: string | null | undefined): value is Locale {
return value === "de" || value === "en" || value === "fr" ||
value === "es" || value === "it" || value === "pl";
}
/**
* Resolve a user's persisted locale, falling back to the project
* default when the column is null/empty. We deliberately do NOT chain
* through cookies or Accept-Language here — this helper runs from
* background jobs and webhook handlers where the request context may
* not belong to the recipient (an admin alert resolves the admin's
* locale, not the affected user's), so the only authoritative source
* is the persisted column.
*/
async function resolveRecipientLocale(userId: string): Promise<Locale> {
try {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
if (isLocale(row?.locale)) return row.locale;
} catch (err) {
getEvent()?.addWarning(
`dispatchLocalisedNotification — locale lookup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
return defaultLocale;
}
export async function dispatchLocalisedNotification(
opts: DispatchLocalisedOptions,
): Promise<void> {
const locale = await resolveRecipientLocale(opts.userId);
const t = getServerTranslator(locale).t;
const title = t(opts.titleKey, opts.params);
const message = t(opts.messageKey, opts.params);
// Warn when a translation key falls back to its own raw string —
// that signals a missing entry in `messages/{locale}.json` (or a
// typo in the call-site). The dispatcher still fires so the user
// gets *something*, but ops should see the gap.
if (title === opts.titleKey || message === opts.messageKey) {
getEvent()?.addWarning(
`dispatchLocalisedNotification — missing translation for ${opts.titleKey} / ${opts.messageKey} in locale ${locale}`,
);
}
// `channel` is accepted for future per-channel routing; today it
// is silently passed through. Suppress unused-var lint by referencing
// it on the metadata if present.
const metadata = opts.channel
? { ...(opts.metadata ?? {}), preferredChannel: opts.channel }
: opts.metadata;
await dispatchNotification({
eventType: opts.eventType ?? "SYSTEM_ALERT",
userId: opts.userId,
title,
message,
metadata,
});
}
-9
View File
@@ -20,15 +20,6 @@ export const EVENT_TYPES = [
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export const EVENT_TYPE_LABELS: Record<EventType, string> = {
MEDICATION_REMINDER: "Medication reminder",
MEASUREMENT_ANOMALY: "Anomalous measurements",
COMPLIANCE_LOW: "Low compliance",
WITHINGS_SYNC_FAILED: "Withings sync failed",
SYSTEM_ALERT: "System alerts",
PERSONAL_RECORD: "Personal records",
};
/**
* Per-event default policy when no `NotificationPreference` row
* exists. The dispatcher reads this to decide whether to fire on a
+5 -5
View File
@@ -23,11 +23,11 @@ export const queryKeys = {
insightsComprehensive: () => ["insights", "comprehensive"] as const,
insightsTargets: () => ["insights", "targets"] as const,
/**
* v1.4.16 phase D reconcile (CRITICAL C1 + C2) — shared cache key for
* the rich `/api/insights/generate` advisor payload. Both `/insights`
* (full `<InsightAdvisorCard>`) and `/` (`<InsightsCardPreview>`) read
* this key so a regenerate on one surface refreshes the other without
* a second LLM round-trip.
* Shared cache key for the rich `/api/insights/generate` advisor
* payload. `/insights` (full `<InsightAdvisorCard>`) and any other
* surface that subscribes under this key share the same cache so a
* regenerate on one surface refreshes the others without a second
* LLM round-trip.
*/
insightsAdvisor: () => ["insights", "advisor"] as const,
insightsBpStatus: (locale: string) =>
-10
View File
@@ -51,16 +51,6 @@ export async function checkRateLimit(
};
}
/**
* Delete expired rate limit entries. Called periodically by pg-boss.
*/
export async function cleanupExpiredRateLimits(): Promise<number> {
const result = await prisma.$executeRaw`
DELETE FROM rate_limits WHERE reset_at < NOW()
`;
return result;
}
/**
* Rate limit response headers.
*/
-9
View File
@@ -41,15 +41,6 @@ export const glucoseContextEnum = z.enum([
"BEDTIME",
]);
export const sleepStageEnum = z.enum([
"IN_BED",
"AWAKE",
"ASLEEP",
"REM",
"CORE",
"DEEP",
]);
export const measurementSourceEnum = z.enum([
"MANUAL",
"WITHINGS",
+39 -63
View File
@@ -101,29 +101,42 @@ export type DeviceType = z.infer<typeof deviceTypeEnum>;
*/
const metricSourceLadder = z.array(measurementSourceEnum).max(8);
const metricPriorityObjectSchema = z
.object({
// ── Cumulative metrics — sum-per-day; pick ONE source per day. ──
steps: metricSourceLadder,
activeEnergy: metricSourceLadder,
walkingRunningDistance: metricSourceLadder,
flightsClimbed: metricSourceLadder,
// ── Sleep — pick best-resolution source per night. ──
sleep: metricSourceLadder,
// ── Point measurements — all sources kept; this controls
// "display preference" when multiple sources happen to capture
// the same metric on the same day.
weight: metricSourceLadder,
bloodPressure: metricSourceLadder,
pulse: metricSourceLadder,
bodyFat: metricSourceLadder,
bodyTemperature: metricSourceLadder,
spo2: metricSourceLadder,
hrv: metricSourceLadder,
restingHeartRate: metricSourceLadder,
vo2Max: metricSourceLadder,
})
.partial();
/**
* Metric-class keys carried by `SourcePriority`. Listed once here so
* the Settings UI, the aggregator helper, and the tests all read from
* the same place — a future addition (Apple Health workouts in v1.5)
* shows up everywhere by extending one constant.
*/
export const SOURCE_PRIORITY_METRIC_KEYS = [
"steps",
"activeEnergy",
"walkingRunningDistance",
"flightsClimbed",
"sleep",
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"bodyTemperature",
"spo2",
"hrv",
"restingHeartRate",
"vo2Max",
] as const;
export type SourcePriorityMetricKey =
(typeof SOURCE_PRIORITY_METRIC_KEYS)[number];
// v1.4.27 B7 / BL-P4-11-S1 — derive the metric-priority schema shape
// from `SOURCE_PRIORITY_METRIC_KEYS` so the schema and the enum cannot
// drift. Adding a new metric class is now a single-line constant edit
// instead of a two-place change that the lint chain doesn't enforce.
const metricPriorityShape: Record<SourcePriorityMetricKey, typeof metricSourceLadder> =
Object.fromEntries(
SOURCE_PRIORITY_METRIC_KEYS.map((key) => [key, metricSourceLadder]),
) as Record<SourcePriorityMetricKey, typeof metricSourceLadder>;
const metricPriorityObjectSchema = z.object(metricPriorityShape).partial();
/**
* v1.4.25 W8c — device-type ladder per metric class. Walked by
@@ -161,20 +174,9 @@ const deviceTypePrioritySchema = z
export const sourcePrioritySchema = z
.object({
// ── v1.4.25 W5e flat shape (backward-compat) ──
steps: metricSourceLadder,
activeEnergy: metricSourceLadder,
walkingRunningDistance: metricSourceLadder,
flightsClimbed: metricSourceLadder,
sleep: metricSourceLadder,
weight: metricSourceLadder,
bloodPressure: metricSourceLadder,
pulse: metricSourceLadder,
bodyFat: metricSourceLadder,
bodyTemperature: metricSourceLadder,
spo2: metricSourceLadder,
hrv: metricSourceLadder,
restingHeartRate: metricSourceLadder,
vo2Max: metricSourceLadder,
// v1.4.27 B7 — derived from the shared metric-priority shape so the
// flat keys cannot drift from the nested `metricPriority` schema.
...metricPriorityShape,
// ── v1.4.25 W8c additions ──
metricPriority: metricPriorityObjectSchema,
deviceTypePriority: deviceTypePrioritySchema,
@@ -185,32 +187,6 @@ export type SourcePriority = z.infer<typeof sourcePrioritySchema>;
export type MetricPriority = z.infer<typeof metricPriorityObjectSchema>;
export type DeviceTypePriority = z.infer<typeof deviceTypePrioritySchema>;
/**
* Metric-class keys carried by `SourcePriority`. Listed once here so
* the Settings UI, the aggregator helper, and the tests all read from
* the same place — a future addition (Apple Health workouts in v1.5)
* shows up everywhere by extending one constant.
*/
export const SOURCE_PRIORITY_METRIC_KEYS = [
"steps",
"activeEnergy",
"walkingRunningDistance",
"flightsClimbed",
"sleep",
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"bodyTemperature",
"spo2",
"hrv",
"restingHeartRate",
"vo2Max",
] as const;
export type SourcePriorityMetricKey =
(typeof SOURCE_PRIORITY_METRIC_KEYS)[number];
/**
* Marc-directive 2026-05-14 defaults:
* - Cumulative metrics (steps, activeEnergy, walkingRunningDistance,
@@ -22,6 +22,7 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn().mockResolvedValue(false),
parkIntegrationAtReauth: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
@@ -45,7 +46,11 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { prisma } from "@/lib/db";
import { recordSyncSuccess } from "@/lib/integrations/status";
import {
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import { fetchWithingsActivity, syncUserActivity } from "../sync-activity";
@@ -74,6 +79,14 @@ function installFetchMock(entries: FakeActivity[]) {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.26 — every syncUserActivity call now reads the connection's
// scope to short-circuit legacy `user.metrics`-only connections.
// Default-mock to "scope is fine" so the existing field-mapping +
// idempotency tests stay focused on the write path. The scope-skip
// case has its own dedicated tests below.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
});
afterEach(() => {
@@ -255,3 +268,98 @@ describe("syncUserActivity — field mapping + idempotency", () => {
);
});
});
describe("syncUserActivity — scope-skip guard (v1.4.26)", () => {
it("returns 0 without firing the Withings call when scope lacks user.activity", async () => {
// Legacy v1.4.24- connection — scope holds `user.metrics` only.
// The sync must short-circuit BEFORE the upstream fetch so a
// guaranteed 403 doesn't park as `transient` and spam pg-boss
// retries.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("parks the connection via parkIntegrationAtReauth (NOT recordSyncFailure) — v1.4.27 F20", async () => {
// The deliberate scope-skip is a no-op park, not a failure burst.
// Calling `recordSyncFailure` here would increment the per-user
// counter and trip the 3-strike admin alert ladder. The v1.4.27
// fix swaps to `parkIntegrationAtReauth`, which leaves the counter
// untouched and never pages admins.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
vi.stubGlobal("fetch", vi.fn());
await syncUserActivity("user-1");
expect(parkIntegrationAtReauth).toHaveBeenCalledTimes(1);
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "withings",
errorCode: "scope_missing",
}),
);
// CRITICAL: the failure path is NOT touched on a scope-skip — that
// is what silences the false 3-strike Telegram alert.
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("treats a null scope (pre-v1.4.25 connection) as missing user.activity and parks silently", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: null,
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserActivity("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({ errorCode: "scope_missing" }),
);
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("classifies a Withings 403 in the catch-block as reauth_required and STILL pages (defence-in-depth)", async () => {
// BL-P3-2 — the defence-in-depth 403 catch-block stays on
// `recordSyncFailure` so a genuinely unexpected 403 (Withings
// revokes scope between OAuth and the next sync without
// invalidating the refresh token) still trips the 3-strike alert.
// This is the asymmetric pair to the scope-skip park above: the
// park is silent, the catch is loud.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({ status: 403, error: "insufficient scope" }),
})),
);
await expect(syncUserActivity("user-1")).rejects.toThrow(
/Withings activity error: 403/,
);
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
kind: "reauth_required",
errorCode: "403",
}),
);
// The park helper is NOT used for the catch — only the scope-skip.
expect(parkIntegrationAtReauth).not.toHaveBeenCalled();
});
});
+99 -1
View File
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn().mockResolvedValue(false),
parkIntegrationAtReauth: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
@@ -44,7 +45,11 @@ vi.mock("@/lib/logging/context", () => ({
}));
import { prisma } from "@/lib/db";
import { recordSyncSuccess } from "@/lib/integrations/status";
import {
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import {
fetchWithingsSleep,
@@ -73,6 +78,14 @@ function installFetchMock(segments: FakeSegment[]) {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.26 — every syncUserSleep call now reads the connection's
// scope to short-circuit legacy `user.metrics`-only connections.
// Default-mock to "scope is fine" so the existing segment-mapping
// tests stay focused on the write path. The scope-skip case has
// its own dedicated tests below.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
});
afterEach(() => {
@@ -254,3 +267,88 @@ describe("syncUserSleep — segment writes + idempotency", () => {
expect(stages).not.toContain("REM");
});
});
describe("syncUserSleep — scope-skip guard (v1.4.26)", () => {
it("returns 0 without firing the Withings call when scope lacks user.activity", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
it("parks the connection via parkIntegrationAtReauth (NOT recordSyncFailure) — v1.4.27 F20 + BL-P3-2 parity", async () => {
// BL-P3-2 — sleep mirrors activity. The deliberate scope-skip is a
// no-op park, not a failure burst; calling `recordSyncFailure`
// here would increment the counter and could trip the 3-strike
// admin alert ladder. Swap to `parkIntegrationAtReauth`.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics",
} as never);
vi.stubGlobal("fetch", vi.fn());
await syncUserSleep("user-1");
expect(parkIntegrationAtReauth).toHaveBeenCalledTimes(1);
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "withings",
errorCode: "scope_missing",
}),
);
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("treats a null scope (pre-v1.4.25 connection) as missing user.activity and parks silently", async () => {
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: null,
} as never);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const imported = await syncUserSleep("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
expect(parkIntegrationAtReauth).toHaveBeenCalledWith(
expect.objectContaining({ errorCode: "scope_missing" }),
);
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("classifies a Withings 403 in the catch-block as reauth_required and STILL pages (defence-in-depth)", async () => {
// BL-P3-2 — symmetric to sync-activity. The catch-block stays on
// `recordSyncFailure` because a 403 reaching the catch IS
// genuinely unexpected after the scope-skip lands above. The
// 3-strike alert ladder fires here.
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
scope: "user.metrics,user.activity",
} as never);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 200,
json: async () => ({ status: 403, error: "insufficient scope" }),
})),
);
await expect(syncUserSleep("user-1")).rejects.toThrow(
/Withings sleep error: 403/,
);
expect(recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
kind: "reauth_required",
errorCode: "403",
}),
);
expect(parkIntegrationAtReauth).not.toHaveBeenCalled();
});
});
+54 -4
View File
@@ -39,6 +39,7 @@ import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { getUnitForType } from "@/lib/validations/measurement";
import { hasActivityScope } from "./client";
import {
extractWithingsStatus,
isWithingsRefreshReauthFailure,
@@ -46,6 +47,7 @@ import {
import { getValidToken } from "./sync";
import {
isReauthRequired,
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
@@ -202,6 +204,46 @@ export async function syncUserActivity(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// v1.4.26 — scope-skip guard. The W17b activity endpoint is gated on
// `user.activity`; legacy v1.4.24-and-earlier connections sit on
// `user.metrics` only and Withings returns HTTP 200 / status 403 for
// every request. Without this short-circuit the call fires, the
// catch-block classifies the 403 as `"transient"` (the refresh-token
// worked; only the resource call failed), pg-boss retries, and the
// 3-strike alert fires for every legacy user that hasn't reconnected
// since W5d. The Settings reconnect-banner is already in place — the
// sync just has to stop hammering the endpoint until the user acts.
//
// Park behaviour: mark the connection `error_reauth` so the next
// hourly cron tick also short-circuits via `isReauthRequired` above
// (cheap query, no upstream call). `getValidToken` already fetched
// the connection row to refresh the access token; we re-read the
// `scope` column with a `select` so the call is one indexed lookup.
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
select: { scope: true },
});
if (!hasActivityScope(connection?.scope ?? null)) {
getEvent()?.addWarning(
`withings activity sync skipped for ${userId}: missing user.activity scope (legacy connection — reconnect required)`,
);
// v1.4.27 — silent park. The scope-skip is a deliberate no-op, not
// a failure burst, so the 3-strike admin alert must NOT fire from
// this branch. `parkIntegrationAtReauth` sets the row to
// `error_reauth` without incrementing the failure counter and
// without entering the threshold ladder. The defence-in-depth 403
// catch-block below stays on `recordSyncFailure` — a 403 reaching
// the catch IS unexpected once the scope-skip lands.
await parkIntegrationAtReauth({
userId,
integration: "withings",
message:
"Withings connection is missing the user.activity scope. Reconnect Withings in Settings to enable activity sync.",
errorCode: "scope_missing",
});
return 0;
}
// 30-day rolling window keeps the call cheap (≤ one page) and
// self-heals stale rows from a clock skew or a backfill replay.
// The webhook-driven path narrows naturally to today/yesterday;
@@ -222,14 +264,22 @@ export async function syncUserActivity(
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = extractWithingsStatus(message);
// v1.4.26 defence-in-depth — a 403 from a scope-gated endpoint
// always means scope-missing or token-revoked. Park at
// `error_reauth` rather than `transient` so pg-boss stops
// retrying and the 3-strike alert doesn't fire. The early
// scope-skip above covers the common case; this catches the
// race where Withings revokes scope between OAuth and the next
// sync without invalidating the refresh token.
const isReauth =
isWithingsRefreshReauthFailure(message) || status === "403";
await recordSyncFailure({
userId,
integration: "withings",
kind: isWithingsRefreshReauthFailure(message)
? "reauth_required"
: "transient",
kind: isReauth ? "reauth_required" : "transient",
message,
errorCode: extractWithingsStatus(message),
errorCode: status,
});
throw err;
}
+38 -4
View File
@@ -37,6 +37,7 @@ import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { getUnitForType } from "@/lib/validations/measurement";
import { hasActivityScope } from "./client";
import {
extractWithingsStatus,
isWithingsRefreshReauthFailure,
@@ -44,6 +45,7 @@ import {
import { getValidToken } from "./sync";
import {
isReauthRequired,
parkIntegrationAtReauth,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
@@ -158,6 +160,35 @@ export async function syncUserSleep(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// v1.4.26 — scope-skip guard. Sleep v2 shares the `user.activity`
// scope gate with the activity endpoints; legacy v1.4.24- connections
// 403 on every call. Park at `error_reauth` rather than retrying so
// pg-boss stops queuing and the 3-strike alert doesn't fire for users
// who haven't reconnected since W5d. See `sync-activity.ts` for the
// full rationale.
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
select: { scope: true },
});
if (!hasActivityScope(connection?.scope ?? null)) {
getEvent()?.addWarning(
`withings sleep sync skipped for ${userId}: missing user.activity scope (legacy connection — reconnect required)`,
);
// v1.4.27 — silent park (BL-P3-2 parity with activity sync). The
// scope-skip is a deliberate no-op; the 3-strike admin alert must
// NOT fire from this branch. The defence-in-depth 403 catch-block
// below stays on `recordSyncFailure` so a genuinely unexpected 403
// after the scope-skip lands still pages admins.
await parkIntegrationAtReauth({
userId,
integration: "withings",
message:
"Withings connection is missing the user.activity scope. Reconnect Withings in Settings to enable sleep sync.",
errorCode: "scope_missing",
});
return 0;
}
void opts.fullSync;
const now = new Date();
const start = new Date(
@@ -175,14 +206,17 @@ export async function syncUserSleep(
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = extractWithingsStatus(message);
// v1.4.26 defence-in-depth — see sync-activity.ts. A 403 on a
// scope-gated endpoint always means scope-missing or token-revoked.
const isReauth =
isWithingsRefreshReauthFailure(message) || status === "403";
await recordSyncFailure({
userId,
integration: "withings",
kind: isWithingsRefreshReauthFailure(message)
? "reauth_required"
: "transient",
kind: isReauth ? "reauth_required" : "transient",
message,
errorCode: extractWithingsStatus(message),
errorCode: status,
});
throw err;
}