Merge branch 'v1423/fixes' into release/v1.42.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-14 19:05:56 +02:00
commit 9c5fa6d14a
22 files changed
+869 -67

No files matched your search

+3 -2
View File
@@ -75778,8 +75778,9 @@ components:
- day
additionalProperties: false
- type: "null"
description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive
since v1.40."
description: "Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is
active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day`
counts from the earliest onset among the episodes that do. Additive since v1.40."
line:
type: string
description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or
+19 -2
View File
@@ -30,7 +30,20 @@ interface StubbedMedication {
}
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
const capture: { postBody: unknown } = { postBody: null };
// A body that is not JSON must fail the spec where it reads `postBody`,
// with the parse error, not throw inside the un-awaited route callback.
const capture = {
rawPostBody: null as unknown,
parseError: null as string | null,
get postBody(): unknown {
if (this.parseError !== null) {
throw new Error(
`POST /api/medications sent a body that is not JSON: ${this.parseError}`,
);
}
return this.rawPostBody;
},
};
const listEntry = {
id: stubbed.id,
@@ -68,7 +81,11 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
return;
}
if (req.method() === "POST") {
capture.postBody = JSON.parse(req.postData() ?? "null");
try {
capture.rawPostBody = JSON.parse(req.postData() ?? "null");
} catch (err) {
capture.parseError = err instanceof Error ? err.message : String(err);
}
await route.fulfill({
status: 201,
contentType: "application/json",
@@ -0,0 +1,123 @@
/**
* The proxy's side of a notification quick answer (#1203).
*
* ntfy's `http` action POSTs to `/api/notifications/action/<token>` with no
* cookie, no Origin and an empty body. That request must pass the proxy
* untouched. Every request the proxy refuses on this path must leave one log
* line with a reason, because the route's own wide event never runs for it
* and the server log is the operator's only evidence. The token must never be
* in that line.
*/
import { NextRequest } from "next/server";
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from "vitest";
const processType = vi.hoisted(() => ({ web: true }));
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => processType.web,
}));
import { proxy } from "../proxy";
const TOKEN =
"na1.cuserid0000000000000000.mood-good.1792017927608.ACWxZHsEVXyh-Yghadj-2Q.PRmiFi7-C4ZkQ45E9YHl95GjSXoeV3xZMwLJ90Ts4QY";
const URL_ = `https://health.example.test/api/notifications/action/${TOKEN}`;
/** The request ntfy's Android app sends for an `http` action. */
function ntfyRequest(method = "POST"): NextRequest {
return new NextRequest(URL_, {
method,
headers: { "user-agent": "ntfy/1.16.0 (okhttp)", "content-length": "0" },
...(method === "POST" ? { body: "" } : {}),
});
}
let warn: MockInstance<(...args: unknown[]) => void>;
beforeEach(() => {
processType.web = true;
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
vi.unstubAllEnvs();
});
function loggedLines(): Array<Record<string, unknown>> {
return warn.mock.calls.map((args: unknown[]) => JSON.parse(String(args[0])));
}
describe("proxy on /api/notifications/action/<token>", () => {
it("passes ntfy's POST through to the route without a refusal", () => {
const response = proxy(ntfyRequest());
expect(response.status).toBe(200);
expect(response.headers.get("x-middleware-next")).toBe("1");
expect(warn).not.toHaveBeenCalled();
});
it.each(["GET", "HEAD", "PUT", "DELETE"])(
"refuses %s with 405 and logs why, without the token",
(method) => {
const response = proxy(ntfyRequest(method));
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("POST");
const lines = loggedLines();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({
level: "warn",
action: { name: "notification.action.refused" },
meta: { reason: "method_not_allowed" },
http: {
method,
status: 405,
path: "/api/notifications/action/[REDACTED]",
},
});
expect(String(warn.mock.calls[0][0])).not.toContain(TOKEN);
expect(String(warn.mock.calls[0][0])).not.toContain("PRmiFi7");
},
);
it("logs the demo-mode refusal", () => {
vi.stubEnv("DEMO_MODE", "true");
const response = proxy(ntfyRequest());
expect(response.status).toBe(403);
expect(loggedLines()[0]).toMatchObject({
meta: { reason: "demo_mode" },
http: { status: 403 },
});
});
it("logs the worker-only refusal", () => {
processType.web = false;
const response = proxy(ntfyRequest());
expect(response.status).toBe(503);
expect(loggedLines()[0]).toMatchObject({
meta: { reason: "worker_only" },
http: { status: 503 },
});
});
it("logs nothing for a refusal on any other path", () => {
vi.stubEnv("DEMO_MODE", "true");
proxy(
new NextRequest("https://health.example.test/api/measurements", {
method: "POST",
body: "{}",
}),
);
expect(warn).not.toHaveBeenCalled();
});
});
@@ -71,6 +71,10 @@ export const POST = apiHandler(
RATE_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "rate_limited" },
});
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* #1214 — the lab form's draw date is the same full-width, typeable date
* field as the measurement form's timestamp.
*
* It already was the same component (`DateTimeField`), but it sat in the
* second column of a two-column grid, and `DateTimeField` puts its date and
* time halves side by side from `sm:` up, so the date half was squeezed until
* a date could only be set through the calendar. SSR markup (the suite's
* dialect) pins the layout: no two-column grid in the form, the date field is
* the shared primitive, and its date half is an editable text input, not a
* read-only picker trigger.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
}));
import { LabForm } from "../lab-form";
function render(): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<LabForm />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabForm> draw date", () => {
it("does not halve the date field's width with a second column", () => {
expect(render()).not.toMatch(/grid-cols-2/);
});
it("uses the shared date-time field with a typeable date half", () => {
const html = render();
expect(html).toContain('data-slot="date-time-field"');
// The id sits on the date half's hidden mirror; the input a person
// types into is the overlay right after it.
const field = html.slice(html.indexOf('id="lab-takenAt"'));
const overlay = /<input(?![^>]*type="hidden")[^>]*>/.exec(field)?.[0];
expect(overlay).toBeDefined();
expect(overlay).toContain('type="text"');
expect(overlay).not.toMatch(/\s(readonly|disabled)(=|\s|\/?>)/i);
});
});
+6 -1
View File
@@ -448,7 +448,12 @@ export function LabForm({
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{/* One column at every width, as in the measurement form. A second
column halved the draw date's cell, and `DateTimeField` sets its
date and time halves side by side from `sm:` up, so the date
itself was squeezed until it could only be set through the
calendar (#1214). */}
<div className="grid gap-4">
<div className="space-y-1.5">
{resultType === "numeric" ? (
<>
@@ -0,0 +1,65 @@
/**
* #1216 — the Coach illness block (which also answers the MCP
* `get_illness_recovery` tool) keeps every active condition as context, but
* its `restMode` flag follows Rest Mode: a chronic ongoing condition alone
* leaves it false, an acute episode or a flare on top sets it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: {
illnessEpisode: { findMany },
symptomEvent: { groupBy: async () => [] },
},
}));
vi.mock("@/lib/illness/gate", () => ({
isIllnessEnabled: vi.fn(async () => true),
}));
import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot";
const NOW = new Date("2026-06-01T12:00:00.000Z");
function episode(lifecycle: string, label: string, onset: string) {
return {
label,
type: lifecycle === "CHRONIC_ONGOING" ? "CHRONIC" : "INFECTION",
lifecycle,
onsetAt: new Date(onset),
bodySiteEncrypted: null,
laterality: null,
};
}
const CHRONIC = episode("CHRONIC_ONGOING", "Sleep apnoea", "2015-08-01");
beforeEach(() => {
findMany.mockReset();
});
describe("buildIllnessSnapshotBlock — restMode (#1216)", () => {
it("a chronic condition alone is context, not Rest Mode", async () => {
findMany.mockResolvedValueOnce([CHRONIC]).mockResolvedValueOnce([]);
const block = await buildIllnessSnapshotBlock("u1", NOW);
expect(block?.restMode).toBe(false);
expect(block?.active.map((e) => e.label)).toEqual(["Sleep apnoea"]);
});
it("an acute episode on top sets Rest Mode", async () => {
findMany
.mockResolvedValueOnce([CHRONIC, episode("ACUTE", "Cold", "2026-05-29")])
.mockResolvedValueOnce([]);
const block = await buildIllnessSnapshotBlock("u1", NOW);
expect(block?.restMode).toBe(true);
expect(block?.active).toHaveLength(2);
});
it("a flare under the chronic condition sets Rest Mode", async () => {
findMany
.mockResolvedValueOnce([CHRONIC, episode("FLARE", "Flare", "2026-05-30")])
.mockResolvedValueOnce([]);
const block = await buildIllnessSnapshotBlock("u1", NOW);
expect(block?.restMode).toBe(true);
});
});
+8 -3
View File
@@ -12,12 +12,14 @@
* Server-authoritative and module-gated: a non-illness / opted-out account
* gets `null` (no block, no read). Since v1.40 it also names the person's own
* symptoms of the last two weeks (their names, sanitised, and counts), which
* reach the model the way custom mood-tag names reach the mood analysis. The active set drives the `restMode` flag
* reach the model the way custom mood-tag names reach the mood analysis. The active set, chronic conditions
* included, is context; only its non-chronic part drives the `restMode` flag
* the Coach reads; a short resolved-history tail lets it answer "how often do
* I get sick" without re-deriving anything.
*/
import { prisma } from "@/lib/db";
import { isIllnessEnabled } from "@/lib/illness/gate";
import { triggersRestMode } from "@/lib/illness/rest-mode";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import {
BASELINE_WINDOW_DAYS,
@@ -90,7 +92,10 @@ function siteFields(row: {
}
export interface CoachIllnessBlock {
/** True when ≥ 1 episode is active right now (the Rest Mode flag). */
/**
* The Rest Mode flag: true when ≥ 1 active episode is not CHRONIC_ONGOING.
* A chronic condition alone is context (it is in `active`), not Rest Mode.
*/
restMode: boolean;
/** Active (unresolved) episodes — the Coach frames its reply around these. */
active: Array<{
@@ -255,7 +260,7 @@ export async function buildIllnessSnapshotBlock(
}
return {
restMode: activeRows.length > 0,
restMode: activeRows.some(triggersRestMode),
active: activeRows.map((e) => ({
label: sanitizeLabel(e.label),
type: e.type,
+21 -11
View File
@@ -406,11 +406,16 @@ ISO-week means.
figure the block does not carry. Quote progress only as the block gives
it. If a fact seems outdated, gently check it rather than assume.
- The SNAPSHOT MAY carry an "illness" block: { restMode, active[],
recentResolved[] }. When "restMode" is true the user has one or more
ACTIVE conditions right now (each with a label, type, lifecycle, and
onset date, plus "bodySite" and "laterality" when the user named where) — read an off-band vital or a low recovery as plausibly
illness-explained rather than alarming, do not push a heavier
measurement cadence, and lean toward rest-and-recover framing. Treat
recentResolved[] }. "active" lists every condition ongoing right now
(each with a label, type, lifecycle, and onset date, plus "bodySite"
and "laterality" when the user named where), chronic ones included; an
off-band vital may relate to any of them. When "restMode" is true at
least one of them is acute, recurring or a flare — read an off-band
vital or a low recovery as plausibly illness-explained rather than
alarming, do not push a heavier measurement cadence, and lean toward
rest-and-recover framing. A CHRONIC_ONGOING condition alone leaves
"restMode" false: the user lives with it and is not unwell because of
it today, so do not frame them as recovering. Treat
the labels as the user's OWN descriptive context, never a diagnosis;
"recentResolved" is light history for "how often do I get sick"
questions. The block MAY also carry "symptoms": the user's OWN named
@@ -924,12 +929,17 @@ ISO-Wochenmittel zusammen.
Fortschritt nur so, wie der Block ihn angibt. Wirkt ein Fakt veraltet,
frage behutsam nach, statt es anzunehmen.
- Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[],
recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder
mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn,
dazu "bodySite" und "laterality", wenn der Nutzer die Stelle angab) —
lies einen abweichenden Vitalwert oder eine niedrige Erholung eher als
krankheitsbedingt denn als alarmierend, dränge nicht auf eine engere
Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Behandle die
recentResolved[] }. "active" nennt jede gerade laufende Erkrankung (je
mit Label, Typ, Lebenszyklus, Beginn, dazu "bodySite" und "laterality",
wenn der Nutzer die Stelle angab), chronische eingeschlossen; ein
abweichender Vitalwert kann mit jeder davon zusammenhängen. Ist
"restMode" true, ist mindestens eine davon akut, wiederkehrend oder ein
Schub — lies einen abweichenden Vitalwert oder eine niedrige Erholung
eher als krankheitsbedingt denn als alarmierend, dränge nicht auf eine
engere Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Eine
CHRONIC_ONGOING-Erkrankung allein lässt "restMode" false: der Nutzer
lebt damit und ist ihretwegen heute nicht krank, stelle ihn also nicht
als genesend dar. Behandle die
Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose;
"recentResolved" ist leichte Historie für "wie oft bin ich krank".
Der Block KANN auch "symptoms" tragen: die EIGENEN benannten Symptome
@@ -12,6 +12,7 @@
* stays OUT of the cached factory — see v1.18.0 B5 notes on the route).
*/
import { prisma } from "@/lib/db";
import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode";
import { requireAuth } from "@/lib/api-handler";
import type { ModuleKey } from "@/lib/modules/gate";
import {
@@ -614,11 +615,16 @@ export async function buildAchievementsResult(
// never freezes anything. Both ongoing and already-resolved episodes are
// read: a past illness should not retroactively break a historical
// streak, just as an active one should not break the current run.
// A chronic ongoing condition never freezes anything (#1216): it has no
// end date, so it would bridge every gap from its onset on and a streak
// could no longer break. A flare under it carries its own lifecycle and
// still does.
moduleMap.illness !== false
? prisma.illnessEpisode.findMany({
where: {
userId,
deletedAt: null,
...REST_MODE_EPISODE_WHERE,
onsetAt: { lte: now },
OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }],
},
@@ -22,6 +22,8 @@ import {
resolveRestMode,
toRestModeContext,
REST_MODE_INACTIVE,
hasActiveIllnessEpisode,
triggersRestMode,
} from "@/lib/illness/rest-mode";
beforeEach(() => {
@@ -115,3 +117,90 @@ describe("resolveRestMode", () => {
await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE);
});
});
/**
* #1216 — a chronic ongoing condition has no end date by design, so it must
* never put the account into Rest Mode on its own: Day N would count from an
* onset years back, nudges would never resume and streaks could never break.
* Only active episodes that are not CHRONIC_ONGOING trigger it, and Day N
* counts from the earliest of those.
*/
describe("Rest Mode and chronic conditions (#1216)", () => {
const chronic = {
id: "c1",
label: "Sleep apnoea",
type: "CHRONIC" as const,
lifecycle: "CHRONIC_ONGOING" as const,
onsetAt: new Date("2015-08-01T00:00:00.000Z"),
};
const acute = {
id: "a1",
label: "Cold",
type: "INFECTION" as const,
lifecycle: "ACUTE" as const,
onsetAt: new Date("2026-06-12T00:00:00.000Z"),
};
const flare = {
id: "f1",
label: "Flare",
type: "AUTOIMMUNE" as const,
lifecycle: "FLARE" as const,
onsetAt: new Date("2026-06-14T00:00:00.000Z"),
};
it("a chronic condition alone is not Rest Mode", () => {
expect(toRestModeContext([chronic])).toEqual(REST_MODE_INACTIVE);
});
it("chronic + acute is Rest Mode, anchored on the acute onset", () => {
const ctx = toRestModeContext([chronic, acute]);
expect(ctx.active).toBe(true);
expect(ctx.since).toBe("2026-06-12T00:00:00.000Z");
expect(ctx.episodeCount).toBe(1);
expect(ctx.episodes.map((e) => e.id)).toEqual(["a1"]);
});
it("a flare threaded under a chronic condition is Rest Mode", () => {
const ctx = toRestModeContext([chronic, flare]);
expect(ctx.active).toBe(true);
expect(ctx.since).toBe("2026-06-14T00:00:00.000Z");
});
it("anchors on the earliest triggering onset whatever the input order", () => {
expect(toRestModeContext([flare, chronic, acute]).since).toBe(
"2026-06-12T00:00:00.000Z",
);
});
it("resolveRestMode is inactive for a chronic-only account", async () => {
gate.isIllnessEnabled.mockResolvedValue(true);
db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]);
await expect(
resolveRestMode("u1", new Date("2026-06-16T00:00:00.000Z")),
).resolves.toEqual(REST_MODE_INACTIVE);
});
it("hasActiveIllnessEpisode still sees the chronic condition as context", async () => {
gate.isIllnessEnabled.mockResolvedValue(true);
db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]);
await expect(
hasActiveIllnessEpisode("u1", new Date("2026-06-16T00:00:00.000Z")),
).resolves.toBe(true);
});
it("hasActiveIllnessEpisode is fail-soft", async () => {
gate.isIllnessEnabled.mockResolvedValue(true);
db.prisma.illnessEpisode.findMany.mockRejectedValue(new Error("db down"));
await expect(
hasActiveIllnessEpisode("u1", new Date("2026-06-17T00:00:00.000Z")),
).resolves.toBe(false);
});
it("triggersRestMode exempts only CHRONIC_ONGOING", () => {
expect(
["ACUTE", "RECURRING", "FLARE", "CHRONIC_ONGOING"].map((lifecycle) =>
triggersRestMode({ lifecycle }),
),
).toEqual([true, true, true, false]);
});
});
+93 -21
View File
@@ -1,13 +1,13 @@
/**
* Rest Mode — the server-authoritative "an episode is active" context (v1.18.1, P4).
* Rest Mode — the server-authoritative "the account is unwell right now"
* context (v1.18.1, P4).
*
* CORE PRINCIPLE: an active illness/condition episode ANNOTATES, it never
* PENALISES. Rest Mode never changes a measured number — it only frames the
* narrative around it: the health score keeps its value but carries a "you
* were unwell" note, recovery keeps its number, achievement streaks freeze
* (rather than break) across the days you were ill, cadence-nudges pause, and
* a coincident vital deviation reads as illness-explained instead of an
* unexplained anomaly.
* CORE PRINCIPLE: an active illness episode ANNOTATES, it never PENALISES.
* Rest Mode never changes a measured number — it only frames the narrative
* around it: the health score keeps its value but carries a "you were unwell"
* note, recovery keeps its number, achievement streaks freeze (rather than
* break) across the days you were ill, cadence-nudges pause, and the Today
* strip counts the days since onset.
*
* This module is the ONE place that resolves "is the account in Rest Mode
* right now, and which episodes back it" — so every surface annotates from
@@ -16,18 +16,55 @@
* the suppression). The illness module gate is honoured: a disabled / opted-
* out account is never in Rest Mode (the gate short-circuits before any read).
*
* "Active" = an `IllnessEpisode` with `resolvedAt = null` (still ongoing),
* not soft-deleted, whose `onsetAt` is on or before the reference instant.
* CHRONIC_ONGOING episodes are intentionally INCLUDED here — an ongoing
* chronic condition is precisely the case where a measured dip should be
* framed, not penalised. (They are excluded only from the recovery-GAP math,
* which is P3 and lives elsewhere.)
* Two different questions are answered here, and they must not be confused:
*
* - "Which episodes are active?" (`getActiveIllnessEpisodes`): every
* `IllnessEpisode` with `resolvedAt = null`, not soft-deleted, whose
* `onsetAt` is on or before the reference instant. CHRONIC_ONGOING
* conditions are included. This is CONTEXT: a measured dip that coincides
* with a known chronic condition is still framed as explained by it
* (`hasActiveIllnessEpisode`), and the Coach still sees the condition.
*
* - "Is the account in Rest Mode?" (`resolveRestMode`): only the active
* episodes that are NOT CHRONIC_ONGOING (acute, recurring, flare). A
* chronic condition has no end date by design, so letting it trigger Rest
* Mode would put the account into it permanently: the Day N counter would
* count from an onset years back, nudges would never resume and streaks
* could never break. A flare threaded under a chronic condition carries its
* own lifecycle (FLARE), so a chronic condition getting worse still does.
* `since` and Day N count from the earliest onset among the episodes that
* trigger Rest Mode, never from a chronic onset.
*
* Every reader that freezes, pauses or counts on Rest Mode's behalf goes
* through `triggersRestMode` / `REST_MODE_EPISODE_WHERE`, including the
* achievements streak freeze, which runs its own windowed query.
*/
import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { isIllnessEnabled } from "@/lib/illness/gate";
import { memoizePerRequest } from "@/lib/request-cache";
/**
* The lifecycle that never triggers Rest Mode on its own: a condition the
* person lives with. See the module header.
*/
export const REST_MODE_EXEMPT_LIFECYCLE = "CHRONIC_ONGOING" as const;
/**
* Prisma `where` fragment selecting the episodes that can trigger Rest Mode.
* For readers that query episodes themselves (the achievements streak freeze
* reads resolved ones too, over a window) instead of going through
* `resolveRestMode`.
*/
export const REST_MODE_EPISODE_WHERE = {
lifecycle: { not: REST_MODE_EXEMPT_LIFECYCLE },
} as const;
/** Whether an episode, while active, puts the account into Rest Mode. */
export function triggersRestMode(episode: { lifecycle: string }): boolean {
return episode.lifecycle !== REST_MODE_EXEMPT_LIFECYCLE;
}
/** Minimal shape the Rest Mode resolver needs from an episode row. */
export type ActiveIllnessEpisode = Pick<
IllnessEpisode,
@@ -44,13 +81,19 @@ export type ActiveIllnessEpisode = Pick<
* This is the server-authoritative shape iOS mirrors verbatim.
*/
export interface RestModeContext {
/** True when ≥ 1 illness episode is active as of the reference instant. */
/**
* True when ≥ 1 active episode that is not CHRONIC_ONGOING exists as of the
* reference instant.
*/
active: boolean;
/** ISO onset of the earliest active episode, or null when none. */
/** ISO onset of the earliest episode backing Rest Mode, or null when none. */
since: string | null;
/** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */
/** Count of episodes backing Rest Mode (usually 1; ≥ 2 when they overlap). */
episodeCount: number;
/** The active episodes, label + type + onset only — never a decrypted note. */
/**
* The episodes backing Rest Mode, label + type + onset only — never a
* decrypted note. Chronic conditions are not among them.
*/
episodes: Array<{
id: string;
label: string;
@@ -69,7 +112,9 @@ export const REST_MODE_INACTIVE: RestModeContext = {
};
/**
* Read the active illness episodes for an account as of `asOf`. Gated through
* Read the active illness episodes for an account as of `asOf`, chronic
* conditions included — the context set, not the Rest Mode set (that is
* `toRestModeContext` over this). Gated through
* the illness module: a disabled / not-opted-in account returns `[]` without
* touching the episode table. The reference instant is injectable so the
* resolver is deterministic for tests and so a nightly job can resolve "as of
@@ -118,10 +163,18 @@ async function readActiveIllnessEpisodes(
});
}
/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */
/**
* Fold a set of active episodes into the renderable Rest Mode context. Pure.
* Only the episodes that trigger Rest Mode count: a chronic condition alone
* leaves the account out of it, and `since` is the earliest onset among the
* episodes that do (the input is onset-ascending).
*/
export function toRestModeContext(
episodes: ActiveIllnessEpisode[],
activeEpisodes: ActiveIllnessEpisode[],
): RestModeContext {
const episodes = activeEpisodes
.filter(triggersRestMode)
.sort((a, b) => a.onsetAt.getTime() - b.onsetAt.getTime());
if (episodes.length === 0) return REST_MODE_INACTIVE;
return {
active: true,
@@ -155,3 +208,22 @@ export async function resolveRestMode(
return REST_MODE_INACTIVE;
}
}
/**
* Whether any illness episode is active as of `asOf`, chronic conditions
* included. For framing a measured deviation as explained by a known
* condition, which a chronic one does as much as an acute one. Not Rest Mode:
* nothing may freeze, pause or count on this. Fail-soft like
* `resolveRestMode`.
*/
export async function hasActiveIllnessEpisode(
userId: string,
asOf: Date = new Date(),
client: PrismaClient = defaultPrisma,
): Promise<boolean> {
try {
return (await getActiveIllnessEpisodes(userId, asOf, client)).length > 0;
} catch {
return false;
}
}
@@ -85,6 +85,31 @@ describe("buildBriefingIllnessCyclePrompt", () => {
expect(out).toContain("SYSTEM CONTEXT");
});
it("names a chronic-only condition as context, not Rest Mode (#1216)", () => {
const out = buildBriefingIllnessCyclePrompt(
{
illness: {
restMode: false,
active: [
{
label: "Sleep apnoea",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "x",
},
],
recentResolved: [],
},
cycle: null,
},
"en",
);
expect(out).toContain("Sleep apnoea");
expect(out).toContain("chronic condition");
expect(out).not.toContain("The user is in Rest Mode");
expect(out).not.toContain("measure more often");
});
it("emits a cycle day/phase line", () => {
const out = buildBriefingIllnessCyclePrompt(
{
@@ -17,14 +17,11 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
}));
// v1.18.1 P4 — Rest Mode. Default inactive; the reframe test flips it active.
// v1.18.1 P4 — the illness reframe. Default no active episode; the reframe
// test flips it. Since #1216 the reframe reads active episodes directly
// (chronic conditions included), not Rest Mode.
const restModeMock = vi.hoisted(() => ({
resolveRestMode: vi.fn(async () => ({
active: false as boolean,
since: null as string | null,
episodeCount: 0,
episodes: [] as unknown[],
})),
hasActiveIllnessEpisode: vi.fn(async () => false as boolean),
}));
vi.mock("@/lib/illness/rest-mode", () => restModeMock);
@@ -43,12 +40,7 @@ const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
findMany.mockResolvedValue([]);
restModeMock.resolveRestMode.mockResolvedValue({
active: false,
since: null,
episodeCount: 0,
episodes: [],
});
restModeMock.hasActiveIllnessEpisode.mockResolvedValue(false);
});
/** RHR + HRV each flat, then a final day that pushes both outside the band. */
@@ -184,13 +176,8 @@ describe("computeCoincidentDeviation", () => {
}
});
it("reframes a fired flag as illness-explained during Rest Mode (vitals unchanged)", async () => {
restModeMock.resolveRestMode.mockResolvedValue({
active: true,
since: "2026-05-30T00:00:00.000Z",
episodeCount: 1,
episodes: [],
});
it("reframes a fired flag as illness-explained while an episode is active (vitals unchanged)", async () => {
restModeMock.hasActiveIllnessEpisode.mockResolvedValue(true);
findMany.mockImplementation(firingRows);
const result = await computeCoincidentDeviation("u1", PROFILE, {
now: NOW,
@@ -249,7 +236,7 @@ describe("computeCoincidentDeviation", () => {
});
expect(result.status === "ok" && result.value.fired).toBe(false);
expect(result.status === "ok" && result.value.illnessExplained).toBe(false);
expect(restModeMock.resolveRestMode).not.toHaveBeenCalled();
expect(restModeMock.hasActiveIllnessEpisode).not.toHaveBeenCalled();
});
});
@@ -45,7 +45,7 @@ import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { hasActiveIllnessEpisode } from "@/lib/illness/rest-mode";
import {
isPlausibleMetricValue,
plausibleMetricRange,
@@ -463,8 +463,11 @@ export async function computeCoincidentDeviation(
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
// episode is active. Only resolved when the flag fired (no read on the
// common quiet day). Annotation only — the vital deviations are unchanged.
// Any active episode counts, a chronic condition included: it explains a
// coincident dip as much as an acute one, even though it does not put the
// account into Rest Mode (#1216).
const illnessExplained = fired
? (await resolveRestMode(userId, now)).active
? await hasActiveIllnessEpisode(userId, now)
: false;
const { coverage: cov, confidence } = deriveCoverage({
@@ -82,6 +82,17 @@ export function buildBriefingIllnessCyclePrompt(
? `- Aktive Erkrankung(en) gerade: ${labels}. Der Nutzer ist im Rest Mode — lies abweichende Vitalwerte oder eine niedrige Erholung als krankheitsbedingt, nicht alarmierend, und dränge NICHT auf häufigeres Messen.`
: `- Active illness episode(s) right now: ${labels}. The user is in Rest Mode — read off-band vitals or low recovery as illness-explained rather than alarming, and do NOT push a "measure more often" cadence.`,
);
} else if (ctx.illness.active.length > 0) {
// Chronic ongoing conditions only: context for a measured dip, but not
// Rest Mode, so no cadence pause and no "unwell" framing (#1216).
const labels = ctx.illness.active
.map((e) => `${e.label} (${e.type}, ${e.lifecycle})`)
.join("; ");
lines.push(
de
? `- Bekannte chronische Erkrankung(en): ${labels}. Ein abweichender Vitalwert kann damit zusammenhängen; das ist Kontext, kein akutes Kranksein und kein Rest Mode.`
: `- Known chronic condition(s): ${labels}. An off-band vital may relate to them; this is context, not an acute illness and not Rest Mode.`,
);
} else if (ctx.illness.recentResolved.length > 0) {
lines.push(
de
+4 -2
View File
@@ -942,8 +942,10 @@ export async function runCoachNudgeTick(
}
// Gate 7 — Rest Mode pause (v1.18.1 P4). A user with an active
// illness/condition episode should not be told to measure more often;
// the cadence-nudge pauses for the duration of the episode. Module-gated
// illness episode should not be told to measure more often; the
// cadence-nudge pauses for the duration of the episode. A chronic
// ongoing condition alone does not pause it (#1216): it never ends, so
// the nudge would never come back. Module-gated
// (a non-illness account is never in Rest Mode) and only reached past
// every cheaper gate, so the read fires for at most a nudge-eligible
// user per tick.
+1 -1
View File
@@ -210,7 +210,7 @@ export const dailyDigestResponse = z
})
.nullable()
.describe(
"Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive since v1.40.",
"Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day` counts from the earliest onset among the episodes that do. Additive since v1.40.",
),
line: z
.string()
+1 -1
View File
@@ -2204,7 +2204,7 @@ export const healthScoreReport = z
.nullable()
.optional()
.describe(
"Set on every response this route produces (null when no illness episode is active). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.",
"Set on every response this route produces (null when no illness episode is active, or only chronic ongoing ones are: a condition the person lives with frames a dip but never puts the account into Rest Mode). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.",
),
})
.meta({
+65
View File
@@ -310,6 +310,46 @@ function applyBaselineSecurityHeaders(response: NextResponse): NextResponse {
return response;
}
/**
* The notification quick-answer route (`/api/notifications/action/<token>`).
* Its caller is a notification app on a phone, not a browser, so when a tap
* does nothing the operator's only evidence is the server log. Every refusal
* this proxy makes on that path therefore writes one line with a reason,
* because the route's own wide event never runs for them. The token is never
* written: the path is logged as the prefix plus a placeholder.
*/
const NOTIFICATION_ACTION_PREFIX = "/api/notifications/action/";
type NotificationActionRefusal =
"method_not_allowed" | "worker_only" | "demo_mode";
function logNotificationActionRefusal(
request: NextRequest,
reason: NotificationActionRefusal,
status: number,
): void {
if (!request.nextUrl.pathname.startsWith(NOTIFICATION_ACTION_PREFIX)) return;
console.warn(
JSON.stringify({
timestamp: new Date().toISOString(),
level: "warn",
kind: "http",
message:
reason === "method_not_allowed"
? "Notification answer refused: the route takes POST only. A redirect in front of the app (http to https, or a sign-in gateway) turns the POST into a GET."
: "Notification answer refused before the route ran.",
action: { name: "notification.action.refused" },
meta: { reason },
http: {
method: request.method,
path: `${NOTIFICATION_ACTION_PREFIX}[REDACTED]`,
status,
user_agent: request.headers.get("user-agent") ?? undefined,
},
}),
);
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -317,6 +357,7 @@ export function proxy(request: NextRequest) {
// serving requests that would either crash (no DB pool ready for writes from
// the worker) or duplicate work that the dedicated web container is doing.
if (!shouldRunWeb() && pathname !== "/api/health") {
logNotificationActionRefusal(request, "worker_only", 503);
return applyBaselineSecurityHeaders(
NextResponse.json(
{
@@ -364,6 +405,29 @@ export function proxy(request: NextRequest) {
);
}
// A notification quick answer is a POST and nothing else. ntfy's `http`
// action sends one; anything else here is a request a redirect rewrote (an
// HTTP client follows a 301/302 with a GET) or a stray probe. Next would
// answer 405 on its own, but before any wide event, so the refusal is made
// here where it can be logged. `OPTIONS` stays with Next.
if (
pathname.startsWith(NOTIFICATION_ACTION_PREFIX) &&
request.method !== "POST" &&
request.method !== "OPTIONS"
) {
logNotificationActionRefusal(request, "method_not_allowed", 405);
return applyBaselineSecurityHeaders(
NextResponse.json(
{
data: null,
error: "Method not allowed",
meta: { errorCode: "notification.action.method_not_allowed" },
},
{ status: 405, headers: { Allow: "POST" } },
),
);
}
// Demo mode: block all mutations except login
if (process.env.DEMO_MODE === "true") {
const method = request.method.toUpperCase();
@@ -377,6 +441,7 @@ export function proxy(request: NextRequest) {
(entry) => pathname === entry.path && method === entry.method,
)
) {
logNotificationActionRefusal(request, "demo_mode", 403);
return applyBaselineSecurityHeaders(
NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }),
);
@@ -116,3 +116,86 @@ describe("POST /api/notifications/action/{token}", () => {
).toBe(0);
});
});
/**
* #1203 — the request in ntfy's own shape, from the published message to the
* row. The action is taken from the JSON body `buildNtfyMessage` publishes,
* and sent the way ntfy's Android app sends an `http` action: POST, empty
* body, no cookie, no Origin, an okhttp user agent. It goes through the real
* proxy first and then the real route, with the route param taken from the
* URL path the way Next derives it.
*/
describe("a ntfy http action through the proxy and the route", () => {
async function ntfyAction() {
const { buildMoodQuickActions } =
await import("@/lib/notifications/action-token");
const { buildNtfyMessage } =
await import("@/lib/notifications/senders/ntfy");
const quickActions = buildMoodQuickActions({
userId: USER,
origin: "https://health.example.test",
labels: { "mood-good": "Good", "mood-okay": "Okay", "mood-bad": "Bad" },
});
const message = buildNtfyMessage("topic", {
userId: USER,
eventType: "MOOD_REMINDER",
title: "Mood",
message: "How are you?",
quickActions,
} as Parameters<typeof buildNtfyMessage>[1]);
// Round-trip through JSON: what the phone holds is the parsed publish body.
const published = JSON.parse(JSON.stringify(message)) as typeof message;
const okay = published.actions?.find((a) => a.label === "Okay");
if (!okay) throw new Error("no Okay action in the published message");
return okay;
}
async function send(method: string, url: string) {
const { proxy } = await import("@/proxy");
const request = new NextRequest(url, {
method,
headers: { "user-agent": "okhttp/4.12.0", "content-length": "0" },
...(method === "POST" ? { body: "" } : {}),
});
const edge = proxy(request);
if (edge.headers.get("x-middleware-next") !== "1") return edge;
const token = decodeURIComponent(
new URL(url).pathname.split("/").pop() ?? "",
);
const { POST } =
await import("@/app/api/notifications/action/[token]/route");
return POST(request, { params: Promise.resolve({ token }) });
}
it("logs the mood when the phone POSTs the published action", async () => {
const action = await ntfyAction();
expect(action).toMatchObject({ action: "http", method: "POST" });
const res = await send(action.method, action.url);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { logged: true, action: "mood-okay" },
});
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
select: { source: true, score: true },
});
expect(rows).toEqual([{ source: "NOTIFICATION", score: 3 }]);
});
it("refuses a GET (a POST a redirect rewrote) at the proxy and writes nothing", async () => {
const action = await ntfyAction();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = await send("GET", action.url);
expect(res.status).toBe(405);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain("method_not_allowed");
} finally {
warn.mockRestore();
}
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(0);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* #1216 — a chronic ongoing condition alone never puts the account into Rest
* Mode, against real Postgres.
*
* A CHRONIC_ONGOING episode has no end date by design, so counting it would
* put the account into Rest Mode for good: the Today strip read "Rest mode ·
* Day 3948", nudges stayed paused and every streak gap since the onset was
* bridged. Pinned here on the three readers that decide it: the resolver
* (which the score, recovery, nudge and Today surfaces read), the Today
* digest's Day N, and the achievements streak freeze, which runs its own
* episode query.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { loadDailyDigest } from "@/lib/daily/load-digest";
import { buildAchievementsResult } from "@/lib/gamification/achievements-result";
import { REST_MODE_INACTIVE, resolveRestMode } from "@/lib/illness/rest-mode";
import type { ModuleKey } from "@/lib/modules/registry";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const USER = "rest-mode-chronic";
const DAY = 24 * 60 * 60 * 1000;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
__resetAllCachesForTests();
await getPrismaClient().user.create({
data: {
id: USER,
username: USER,
email: `${USER}@example.test`,
timezone: "UTC",
createdAt: new Date("2026-03-01T00:00:00.000Z"),
},
});
});
function episode(data: Partial<Prisma.IllnessEpisodeUncheckedCreateInput>) {
return getPrismaClient().illnessEpisode.create({
data: {
userId: USER,
label: "Condition",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date("2015-08-01T00:00:00.000Z"),
...data,
},
});
}
describe("resolveRestMode on a real record", () => {
const NOW = new Date("2026-10-04T12:00:00.000Z");
it("a chronic condition alone is not Rest Mode", async () => {
await episode({});
expect(await resolveRestMode(USER, NOW, getPrismaClient())).toEqual(
REST_MODE_INACTIVE,
);
});
it("chronic + acute is Rest Mode, anchored on the acute onset", async () => {
await episode({});
const acute = await episode({
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: new Date("2026-10-02T18:00:00.000Z"),
});
const ctx = await resolveRestMode(USER, NOW, getPrismaClient());
expect(ctx.active).toBe(true);
expect(ctx.since).toBe("2026-10-02T18:00:00.000Z");
expect(ctx.episodes.map((e) => e.id)).toEqual([acute.id]);
});
it("a flare threaded under the chronic condition is Rest Mode", async () => {
const parent = await episode({});
await episode({
label: "Flare",
lifecycle: "FLARE",
parentConditionId: parent.id,
onsetAt: new Date("2026-10-03T08:00:00.000Z"),
});
const ctx = await resolveRestMode(USER, NOW, getPrismaClient());
expect(ctx.active).toBe(true);
expect(ctx.since).toBe("2026-10-03T08:00:00.000Z");
});
});
describe("the Today digest's Rest Mode fact", () => {
const NOW = new Date("2026-10-04T06:00:00.000Z");
async function digest() {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: USER },
});
return loadDailyDigest(user, NOW, { locale: "en" });
}
it("shows no Rest Mode for a chronic condition alone", async () => {
await episode({});
const d = await digest();
expect(d.restMode).toBeNull();
expect(d.today.some((f) => f.kind === "rest_mode")).toBe(false);
});
it("counts Day N from the acute onset, not the chronic one", async () => {
await episode({});
await episode({
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: new Date("2026-10-02T18:00:00.000Z"),
});
const d = await digest();
expect(d.restMode).toEqual({ day: 3 });
});
});
describe("the achievements streak freeze", () => {
const MODULES = {} as Record<ModuleKey, boolean>;
/** Logins two days apart: a one-day gap the freeze may or may not bridge. */
async function loginsAroundAGap() {
const today = new Date();
today.setUTCHours(12, 0, 0, 0);
const gapDay = new Date(today.getTime() - 4 * DAY);
await getPrismaClient().auditLog.createMany({
data: [5, 3].map((d) => ({
userId: USER,
action: "auth.login.password",
createdAt: new Date(today.getTime() - d * DAY),
})),
});
return gapDay;
}
async function loginStreak() {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: USER },
});
const result = await buildAchievementsResult(
user as Parameters<typeof buildAchievementsResult>[0],
MODULES,
);
return result.metrics.loginDayStreak;
}
it("does not bridge a gap on a chronic-only day", async () => {
await loginsAroundAGap();
await episode({});
expect(await loginStreak()).toBe(1);
});
it("still bridges a gap covered by an acute episode", async () => {
const gapDay = await loginsAroundAGap();
await episode({});
await episode({
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: new Date(gapDay.getTime() - 2 * 60 * 60 * 1000),
resolvedAt: new Date(gapDay.getTime() + 6 * 60 * 60 * 1000),
});
expect(await loginStreak()).toBe(3);
});
});