mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
d98e85cd03
commit
4f09c3f862
314 files changed
+26801
-4419
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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"]> = {};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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];
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user