Merge remote-tracking branch 'origin/main' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
#	src/__tests__/sharing-surface-guard.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 14:25:05 +01:00
commit 5ac998e4f0
112 files changed
+1816 -593

No files matched your search

@@ -77,12 +77,12 @@ describe("listTelegramDoseMedications", () => {
describe("findTelegramDoseMedication", () => {
it("resolves a current course", async () => {
db.findFirst.mockResolvedValue(CHRONIC);
db.findMany.mockResolvedValue([CHRONIC]);
const found = await findTelegramDoseMedication("user-1", null, {
id: "chronic",
});
expect(found?.id).toBe("chronic");
expect(db.findFirst).toHaveBeenCalledWith(
expect(db.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: "chronic",
@@ -95,7 +95,7 @@ describe("findTelegramDoseMedication", () => {
});
it("refuses an ended course", async () => {
db.findFirst.mockResolvedValue(ENDED_NOW);
db.findMany.mockResolvedValue([ENDED_NOW]);
expect(
await findTelegramDoseMedication("user-1", "Europe/Berlin", {
id: "ended-now",
@@ -103,11 +103,31 @@ describe("findTelegramDoseMedication", () => {
).toBeNull();
});
it("finds the current course beside an ended one of the same name", async () => {
db.findMany.mockResolvedValue([ENDED, CHRONIC]);
const found = await findTelegramDoseMedication("user-1", "Europe/Berlin", {
name: { equals: "ramipril", mode: "insensitive" },
});
expect(found?.id).toBe("chronic");
});
it("judges the course at the dose's own slot", async () => {
// The last evening dose, confirmed after midnight once the course ended.
db.findMany.mockResolvedValue([ENDED_NOW]);
const found = await findTelegramDoseMedication(
"user-1",
"Europe/Berlin",
{ id: "ended-now" },
new Date(Date.now() - 3 * 60 * 60 * 1000),
);
expect(found?.id).toBe("ended-now");
});
it("refuses everything with the Medications module off", async () => {
db.moduleOn.mockResolvedValue(false);
expect(
await findTelegramDoseMedication("user-1", null, { id: "chronic" }),
).toBeNull();
expect(db.findFirst).not.toHaveBeenCalled();
expect(db.findMany).not.toHaveBeenCalled();
});
});
+22 -1
View File
@@ -19,6 +19,7 @@ vi.mock("@/lib/crypto", () => ({
}));
import { prisma } from "@/lib/db";
import { chainWithReasoningEffort } from "../provider-chain";
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
import {
@@ -238,16 +239,36 @@ describe("probeProviderChain", () => {
]);
expect(presence.managedBy).toBe("user");
// Once the person saved a chain without it, the chain is their word.
// A saved chain without it says nothing about the gateway, and neither
// does the switched-off entry the gateway form wrote for its reasoning
// effort.
for (const aiProviderChain of [
[{ providerType: "local", enabled: true, priority: 1 }],
chainWithReasoningEffort(null, "openai-compatible", "low"),
]) {
load(row({ ...gateway, aiProviderChain }));
expect(
(await probeProviderChain("u1", OWNER)).entries.map(
(e) => e.providerType,
),
).toEqual(["openai-compatible"]);
}
// Switched off in the chain, the gateway stays off.
load(
row({
...gateway,
aiProviderChain: [
{ providerType: "local", enabled: true, priority: 1 },
{ providerType: "openai-compatible", enabled: false, priority: 2 },
],
}),
);
expect((await probeProviderChain("u1", OWNER)).entries).toEqual([]);
expect(await resolveProviderAvailability("u1")).toEqual({
aiAvailable: false,
managedBy: null,
});
});
it("carries the person's in-browser OCR opt-in", async () => {
+15
View File
@@ -352,6 +352,21 @@ describe("resolveProviderForTest — empty override resolves via the chain", ()
expect(provider.type).not.toBe("none");
expect(provider).toBeInstanceOf(OpenAIClient);
});
it("answers no provider, which the route names, when the chain resolves empty", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: null,
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiProviderChain: [{ providerType: "openai", priority: 1, enabled: true }],
} as unknown as Awaited<ReturnType<typeof prisma.user.findUnique>>);
const provider = await resolveProviderForTest("user-123", {});
expect(provider.type).toBe("none");
});
});
describe("resolveProviderAvailability", () => {
+62 -32
View File
@@ -17,6 +17,10 @@ vi.mock("@/lib/ai/codex-oauth", () => ({
}));
import { resolveProviderChain } from "../provider";
import {
chainWithReasoningEffort,
PROVIDER_CHAIN_DEFAULT,
} from "../provider-chain";
import { prisma } from "@/lib/db";
import { decryptCodexCreds } from "@/lib/ai/codex-oauth";
@@ -207,41 +211,67 @@ describe("resolveProviderChain", () => {
expect(await resolveProviderChain("user-1")).toEqual([]);
});
});
it("serves the picked gateway, under its own name, on a chain never saved", async () => {
const gatewayRow = {
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://gateway.example/v1",
aiCompatKeyEncrypted: null,
aiCompatModel: "llama-3",
role: "USER",
};
userRows(
{ ...gatewayRow, aiProviderChain: null },
{
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
},
describe("resolveProviderChain and the picked gateway", () => {
const gatewayRow = {
aiProvider: "OPENAI_COMPATIBLE",
aiModel: null,
aiBaseUrl: null,
aiAnthropicKeyEncrypted: null,
aiLocalKeyEncrypted: null,
aiOpenaiKeyEncrypted: null,
aiCompatBaseUrl: "https://gateway.example/v1",
aiCompatKeyEncrypted: null,
aiCompatModel: "llama-3",
role: "USER",
};
const DEFAULT_SAVED = PROVIDER_CHAIN_DEFAULT.map((entry) => ({ ...entry }));
/** Serve `chain` as the record's saved chain; Codex is not connected. */
async function resolveWith(chain: unknown) {
const read = async (args: { select?: Record<string, unknown> }) =>
args.select?.codexAccessTokenEncrypted
? {
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectionStatus: "disconnected",
}
: { ...gatewayRow, aiProviderChain: chain };
vi.mocked(prisma.user.findUnique).mockImplementation(
read as unknown as typeof prisma.user.findUnique,
);
operator({
adminAiKeyEncrypted: null,
});
operator({ adminAiKeyEncrypted: null });
return (await resolveProviderChain("user-1")).map((c) => c.providerType);
}
const chain = await resolveProviderChain("user-1");
expect(chain.map((c) => c.providerType)).toEqual(["openai-compatible"]);
it.each([
["never saved", null],
["saved with the default content", DEFAULT_SAVED],
[
"reset to the default and saved",
JSON.parse(JSON.stringify(DEFAULT_SAVED)),
],
["reordered without the gateway", [...DEFAULT_SAVED].reverse()],
[
"holding a reasoning effort the gateway form wrote switched off",
chainWithReasoningEffort(null, "openai-compatible", "low"),
],
[
"holding a reasoning effort the gateway form writes now",
chainWithReasoningEffort(null, "openai-compatible", "low", true),
],
])("serves the gateway on a chain %s", async (_case, chain) => {
expect(await resolveWith(chain)).toEqual(["openai-compatible"]);
});
// A saved chain without the gateway is the person's word.
userRows({
...gatewayRow,
aiProviderChain: [{ providerType: "local", priority: 1, enabled: true }],
});
expect(await resolveProviderChain("user-1")).toEqual([]);
it("serves nothing once the person switched the gateway off in the chain", async () => {
expect(
await resolveWith([
...DEFAULT_SAVED,
{ providerType: "openai-compatible", priority: 6, enabled: false },
]),
).toEqual([]);
});
});
@@ -336,3 +336,17 @@ describe("getCoachSystemPrompt — native locales name the reader's unit", () =>
},
);
});
describe("the plan review rule", () => {
// A reviewed plan has no progress line, so the rule must not leave the
// model a result to make up.
it.each([
["en", "Without one you have no result: ask how it went"],
["de", "Ohne Fortschrittszeile hast du kein Ergebnis"],
] as const)(
"names no result without a progress line (%s)",
(locale, rule) => {
expect(getCoachSystemPrompt(locale)).toContain(rule);
},
);
});
+2 -2
View File
@@ -1419,8 +1419,8 @@ function buildCoachV122Addendum(locale: Locale): string {
parts.push(
isDe
? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es aus seiner Fortschrittszeile in den eigenen Zahlen des Nutzers zurück, rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.`
: `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back from its progress line in the user's own numbers, association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
? `EXPERIMENT-RÜCKBLICK — sprichst du darüber, wie ein vereinbarter Plan gelaufen ist, lies es NUR aus einer Fortschrittszeile zurück, die der Snapshot für diesen Plan trägt, in den eigenen Zahlen des Nutzers. Ohne Fortschrittszeile hast du kein Ergebnis: frag, wie es gelaufen ist, statt eines zu nennen. Bleib rein assoziativ ("sieht beibehaltenswert aus" / "hängt zusammen mit", NIEMALS "bewiesen" / "hat funktioniert" / "verursacht"). Ein Null-Ergebnis berichte ehrlich ("keine messbare Veränderung — deine Entscheidung"). Hat sich ein Vitalwert während des Experiments VERSCHLECHTERT, bejuble das nicht — sprich es klar an und verweise an die Ärztin/den Arzt bzw. den Akut-Pfad. Schlage NUR verhaltensbezogene Experimente vor (Schlafzeit, Schritte, Bildschirmzeit, Essenszeit); nie ein Medikamenten-/Dosis-/Präparate-Experiment — lehne das ab und verweise an die verschreibende Person.`
: `EXPERIMENT REVIEW — when you speak to how an agreed plan went, read it back ONLY from a progress line the snapshot carries for that plan, in the user's own numbers. Without one you have no result: ask how it went instead of naming one. Stay association-only ("looks worth keeping" / "associated with", NEVER "proven" / "it worked" / "caused"). Report a null result honestly ("no measurable change — your call"). If a vital WORSENED during the experiment, do NOT cheerlead it — raise it plainly and route to the doctor / the acute-redflag path. Only ever propose BEHAVIORAL experiments (sleep timing, steps, screen time, meal timing); never a medication / dose / supplement trial — refuse those and defer to the prescriber.`,
);
return parts.join("\n\n");
+2 -2
View File
@@ -18,8 +18,8 @@ import { reasoningTitleOf } from "./reasoning/support";
* never hit the network.
*
* The mock is exported from `src/lib/ai/` so any test can import it,
* but it is NOT registered in `resolveProvider()` — the production
* path must never reach it.
* but no provider resolver returns it: the production path must never
* reach it.
*/
export interface MockAIProviderOptions {
/** Provider tag returned in `CompletionResult.providerType`. Defaults to "local". */
+9 -6
View File
@@ -181,27 +181,30 @@ export function serializeProviderChain(
* reasoning effort from that provider's own settings form.
*
* The setting lives on the chain entry, so a chain that was never customised
* is materialised from the default first. A provider that is not in the chain
* at all gets a DISABLED entry to hold the value: the setting must reach the
* provider when it is resolved as the selected one, and a disabled entry is
* inert everywhere else (every reader filters on `enabled`). Clearing on an
* absent entry writes nothing new.
* is materialised from the default first. With `enable`, which the gateway
* form passes because saving it picks the gateway, the entry is switched on,
* a new one included. Otherwise a provider that is not in the chain gets a
* DISABLED entry to hold the value, inert everywhere but where the provider
* serves as the picked one (`servesPickedGateway`). Clearing on an absent
* entry writes nothing new.
*/
export function chainWithReasoningEffort(
rawChain: unknown,
providerType: ReasoningProviderType,
effort: ReasoningEffort | null,
enable = false,
): ProviderChainEntry[] {
const chain = parseProviderChain(rawChain).map((e) => ({ ...e }));
const entry = chain.find((e) => e.providerType === providerType);
if (entry) {
if (effort) entry.reasoningEffort = effort;
else delete entry.reasoningEffort;
if (enable) entry.enabled = true;
} else if (effort) {
chain.push({
providerType,
priority: Math.max(0, ...chain.map((e) => e.priority)) + 1,
enabled: false,
enabled: enable,
reasoningEffort: effort,
});
}
+19 -19
View File
@@ -25,7 +25,6 @@ import { isPublicUrl } from "@/lib/validations/notifications";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import {
parseProviderChain,
PROVIDER_CHAIN_DEFAULT,
PROVIDER_CHAIN_TYPES,
type ProviderChainType,
} from "./provider-chain";
@@ -712,7 +711,7 @@ async function resolveAdminCodexProvider(): Promise<AIProvider | null> {
}
/**
* Resolve the AI provider for a given user.
* Test-only, ignoring the chain's switches: use `resolveProviderChain`.
*
* Priority:
* 1. User selected ANTHROPIC / LOCAL with valid creds → that provider.
@@ -812,7 +811,7 @@ async function readBoundSettings(userId: string): Promise<{
* usable credential (e.g. chain says `anthropic` but
* `aiAnthropicKeyEncrypted` is null).
* 3. Append the operator's central Codex behind the person's opt-in.
* 4. Still empty: the picked gateway on a chain never saved
* 4. Still empty: the picked gateway, unless the chain switched it off
* (`servesPickedGateway`), else nothing. Every caller treats an
* empty result as "no provider"; none falls back on its own.
*/
@@ -841,7 +840,7 @@ export async function resolveProviderChain(
aiResponseTimeoutSeconds: true,
},
});
// See `resolveProvider`: every instance carries the record's settings.
// Every instance carries the record's timeout and reasoning effort.
const bind = (provider: AIProvider): AIProvider =>
bindReasoningEffort(
bindResponseTimeout(provider, userRow?.aiResponseTimeoutSeconds),
@@ -964,24 +963,25 @@ function providerOrigin(providerType: string): ProviderManagedBy {
}
/**
* What a chain that resolves empty falls back to. A saved chain is the
* person's own word: an entry they switched off or removed stays out, so an
* empty saved chain means no provider. A chain they never saved is the
* default one (`parseProviderChain` hands back `PROVIDER_CHAIN_DEFAULT`
* itself), which holds every provider a person can pick except the
* OpenAI-compatible gateway; so the gateway they picked under Settings, AI
* serves when nothing in the default chain does. Resolution and the presence
* probe both read this, so they cannot disagree about it.
* What a chain that resolves empty falls back to: the OpenAI-compatible
* gateway picked under Settings, AI, unless the chain switched it off. The
* default chain has no entry for the gateway, so a chain never saved, reset
* to the default, or reordered without it says nothing about the gateway.
* Nor does a disabled gateway entry that carries a reasoning effort: that is
* the holder the gateway form wrote for the setting
* (`chainWithReasoningEffort`), not a switch somebody turned off; the chain
* editor drops the effort of a gateway it switches off. Resolution and the
* presence probe both read this, so they cannot disagree about it.
*/
function servesPickedGateway(row: {
aiProvider: string | null;
aiProviderChain: unknown;
}): boolean {
return (
parseProviderChain(row.aiProviderChain ?? null) ===
PROVIDER_CHAIN_DEFAULT &&
row.aiProvider?.toUpperCase() === "OPENAI_COMPATIBLE"
if (row.aiProvider?.toUpperCase() !== "OPENAI_COMPATIBLE") return false;
const gateway = parseProviderChain(row.aiProviderChain ?? null).find(
(entry) => entry.providerType === "openai-compatible",
);
return !gateway || gateway.enabled || gateway.reasoningEffort !== undefined;
}
/**
@@ -1355,14 +1355,14 @@ async function resolveProviderForTestUnbound(
// Empty selection ("test my saved config" — the ai-section calls
// /api/ai/test with an empty body): resolve via the SAME path generation
// uses — the first entry of `resolveProviderChain`. The legacy codex→admin
// uses, the first entry of `resolveProviderChain`. The legacy codex→admin
// fallback used to probe a different provider than generation ran
// (observed: test→admin-key while generation→codex), so a green test gave
// no signal about whether overnight generation would work.
// Nothing resolving is the route's `ai.provider.none`, not a refused entry.
if (!provider) {
const chain = await resolveProviderChain(userId);
if (chain.length > 0) return chain[0].instance;
throw new AITestConfigError(422, "No AI provider configured");
return chain.length > 0 ? chain[0].instance : new NoProvider();
}
switch (provider) {
+2 -2
View File
@@ -66,8 +66,8 @@ function openAIFamilySupportsVision(model: string): boolean {
/**
* Logical provider tags the OCR path reasons about. Mirrors the
* `ProviderChainType` vocabulary plus the legacy `admin-key` single-provider
* tag `resolveProvider`'s admin fallback returns.
* `ProviderChainType` vocabulary plus the legacy `admin-key` tag the
* operator-key client carries.
*/
export type VisionProviderType =
| "anthropic"
@@ -126,6 +126,21 @@ describe("localizedApiError", () => {
["a rate limit", 429, "http.rate_limited", "too many requests"],
["a missing right", 403, "http.forbidden", "permission"],
["a rejected entry", 422, "http.invalid", "Check them"],
["a mistyped proof", 401, "auth.reproof.failed", "did not verify"],
[
"a password where a second factor is needed",
422,
"auth.reproof.too_weak",
"second factor",
],
[
"an authenticator already set up",
409,
"auth.mfa.already_active",
"Disable it first",
],
["nothing to test with", 422, "ai.provider.none", "No AI provider"],
["a spent AI budget", 429, "ai.budget.exceeded", "budget is used up"],
])("names %s instead of the save fallback", (_, status, code, text) => {
const error = new ApiError("raw English server prose", status, {
errorCode: code,
@@ -157,6 +172,10 @@ describe("localizedApiError", () => {
"http.rate_limited",
"http.forbidden",
"http.invalid",
"auth.reproof.too_weak",
"auth.mfa.already_active",
"ai.provider.none",
"ai.budget.exceeded",
]) {
const sentence = localT(`apiErrors.${code}`);
expect(sentence).not.toBe(`apiErrors.${code}`);
@@ -63,6 +63,53 @@ describe("useIdempotentPost", () => {
expect(keyOf(1)).not.toBe(keyOf(0));
});
it("takes a new key once the server refused the entry", async () => {
// The refusal is stored under the key, so the corrected retry under it
// ran unprotected and an unchanged one replayed the refusal.
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({ data: null, error: "Name taken", meta: {} }),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
)
.mockResolvedValueOnce(stored());
const post = openForm();
await expect(
post("/api/custom-metrics", { name: "Steps" }),
).rejects.toThrow();
await post("/api/custom-metrics", { name: "Steps" });
expect(keyOf(1)).not.toBe(keyOf(0));
});
it("keeps the key while the first request is still running", async () => {
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: null,
error: "A request with this Idempotency-Key is already in progress",
}),
{
status: 409,
headers: {
"Content-Type": "application/json",
"X-Idempotent-Replay": "false",
},
},
),
)
.mockResolvedValueOnce(stored());
const post = openForm();
await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow();
await post("/api/mood-entries", { mood: "GOOD" });
expect(keyOf(1)).toBe(keyOf(0));
});
it("keeps a header the caller set", async () => {
fetchMock.mockResolvedValueOnce(stored());
const post = openForm();
+9
View File
@@ -79,6 +79,12 @@ export class ApiError extends Error {
* so a form can mark the one the server refused. Empty when it named none.
*/
readonly issuePaths: readonly string[];
/**
* The idempotency layer's "a request under this key is still running" (a
* 409 with `X-Idempotent-Replay: false`): nothing was decided, and a retry
* under the same key replays the first request's answer.
*/
readonly inFlight: boolean;
constructor(
message: string,
@@ -86,12 +92,14 @@ export class ApiError extends Error {
meta?: Record<string, unknown>,
requestId?: string | null,
issuePaths: readonly string[] = [],
inFlight = false,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.meta = meta;
this.issuePaths = issuePaths;
this.inFlight = inFlight;
const fromMeta =
typeof meta?.requestId === "string" && meta.requestId.length > 0
? meta.requestId
@@ -133,6 +141,7 @@ export async function throwApiError(res: Response): Promise<never> {
meta,
res.headers.get("x-request-id"),
issuePaths,
res.status === 409 && res.headers.get("x-idempotent-replay") === "false",
);
// A server-side failure is worth an id in the session's support info,
// whether or not the call site ends up showing it.
+17 -8
View File
@@ -2,19 +2,23 @@
import { useRef } from "react";
import { apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { randomId } from "@/lib/random-id";
/**
* `apiPost` for a create form, under one `Idempotency-Key` until a save lands.
* `apiPost` for a create form, under one `Idempotency-Key` until an answer
* arrives.
*
* The transport mints a fresh key per call, which covers a replayed request
* but not a second tap on Save: when the first answer never arrived (the 15 s
* timeout on a weak signal) the entry is already stored, and the retry is a
* new call with a new key, so the server stored it twice. Every submit
* through this function sends the same key until one succeeds, so the retry
* replays the first answer instead; a retry with a changed body is a
* different request and runs as one. The next entry gets a new key.
* through this function sends the same key until the server answers, so the
* retry replays the first answer instead. An answer settles the key, a
* refusal included: the server keeps a refusal under its key for a day, so a
* corrected retry under the old key was never protected and an unchanged one
* replayed a refusal that no longer held. Only the answer that the first
* request is still running keeps it. The next entry gets a new key.
*/
export function useIdempotentPost() {
const key = useRef<string | null>(null);
@@ -26,8 +30,13 @@ export function useIdempotentPost() {
key.current ??= randomId();
const headers = new Headers(init?.headers);
headers.set("Idempotency-Key", key.current);
const result = await apiPost<T>(path, body, { ...init, headers });
key.current = null;
return result;
try {
const result = await apiPost<T>(path, body, { ...init, headers });
key.current = null;
return result;
} catch (error) {
if (error instanceof ApiError && !error.inFlight) key.current = null;
throw error;
}
};
}
@@ -9,6 +9,7 @@
* account only possession of a factor counts now; the account without one is
* unchanged.
*/
import type { NextRequest } from "next/server";
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
@@ -247,6 +248,20 @@ describe("POST /api/auth/passkey/register-options", () => {
expect(res.status).toBe(200);
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
});
it("names a mistyped password as a failed proof, not an ended session", async () => {
vi.mocked(requireCookieAuth).mockResolvedValue({
user: stored(PLAIN),
session: { id: "sess-1" },
} as unknown as Awaited<ReturnType<typeof requireCookieAuth>>);
vi.mocked(verifyPassword).mockResolvedValue(false);
const res = await PASSKEY_REGISTER_OPTIONS(
passkeyReq({ method: "password", password: "typo" }) as NextRequest,
);
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.failed");
expect(createRegistrationOptions).not.toHaveBeenCalled();
});
});
describe("changing the email address", () => {
@@ -71,6 +71,55 @@ describe("applyProfileUpdate locale", () => {
);
});
describe("applyProfileUpdate date of birth", () => {
const writtenDateOfBirth = () =>
(
vi.mocked(prisma.user.update).mock.calls[0]![0]!.data as {
dateOfBirth?: Date | null;
}
).dateOfBirth;
it("stores a typed date as that calendar date", async () => {
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-05-14" });
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("stores an instant from the iOS app as its day in the zone the request sets", async () => {
// 14 May picked at 20:00 in Honolulu (UTC-10).
await applyProfileUpdate(USER_ID, {
dateOfBirth: "2012-05-15T06:00:00Z",
timezone: "Pacific/Honolulu",
});
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("reads an instant in the stored zone when the request sets none", async () => {
// 14 May picked at 09:30 in Nuku'alofa (UTC+13). A user id of its own:
// the zone resolver caches per account.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Pacific/Tongatapu",
} as unknown as Awaited<ReturnType<typeof prisma.user.findUnique>>);
await applyProfileUpdate("user-tonga", {
dateOfBirth: "2012-05-13T20:30:00Z",
});
expect(writtenDateOfBirth()?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
});
it("clears on null and leaves the column alone for a string that names no date", async () => {
await applyProfileUpdate(USER_ID, { dateOfBirth: null });
expect(writtenDateOfBirth()).toBeNull();
vi.mocked(prisma.user.update).mockClear();
await applyProfileUpdate(USER_ID, { dateOfBirth: "2012-02-30" });
expect(writtenDateOfBirth()).toBeUndefined();
});
});
describe("applyProfileUpdate timezone validation", () => {
it("accepts a valid IANA zone", async () => {
const result = await applyProfileUpdate(USER_ID, {
+18 -1
View File
@@ -7,7 +7,9 @@ import { optionalDisplayNameSchema } from "@/lib/validations/display-name";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { dateOfBirthForStorage } from "@/lib/tz/date-only";
import { isValidTimezone } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { encrypt } from "@/lib/crypto";
import {
invalidateUserMedications,
@@ -187,6 +189,21 @@ export interface ApplyProfileError {
issues?: SanitisedZodIssue[];
}
/**
* The date of birth to store: the owner's calendar day (the iOS app sends an
* instant, read in the zone this request sets or the stored one), null to
* clear, and undefined, which leaves the column alone, for a string that
* names no date.
*/
async function storedDateOfBirth(
userId: string,
data: Partial<ExtendedProfileInput>,
): Promise<Date | null | undefined> {
if (!data.dateOfBirth) return null;
const timezone = data.timezone ?? (await resolveUserTimezone(userId));
return dateOfBirthForStorage(data.dateOfBirth, timezone) ?? undefined;
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
@@ -434,7 +451,7 @@ export async function applyProfileUpdate(
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
updates.dateOfBirth = await storedDateOfBirth(userId, data);
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
+1
View File
@@ -86,6 +86,7 @@ export interface DoctorReportData {
* login, never a managed record's generated handle. Null prints no line.
*/
name: string | null;
/** The calendar date, `YYYY-MM-DD` (`dateOfBirthKey`). */
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
@@ -500,6 +500,35 @@ describe("the name the report prints", () => {
});
});
describe("the date of birth the report prints", () => {
const identity = selectionFromLeaves(["PATIENT_IDENTITY"]);
const born = async (dateOfBirth: Date, timezone: string) => {
rows.userUnique = [
{ ...(rows.userUnique[0] as object), dateOfBirth, timezone },
];
const payload = await collectDoctorReportData("u1", RANGE, identity, {
moduleMap: moduleMap(),
});
return payload.patient.dateOfBirth;
};
it("prints a date stored at UTC midnight as that date in every zone", async () => {
const stored = new Date("1960-05-14T00:00:00.000Z");
expect(await born(stored, "Pacific/Honolulu")).toBe("1960-05-14");
expect(await born(stored, "Pacific/Tongatapu")).toBe("1960-05-14");
});
it("prints an instant from the iOS app as its date in the owner's zone", async () => {
// 14 May entered at 00:30 in Berlin, and at 20:00 in Honolulu.
expect(
await born(new Date("1960-05-13T23:30:00.000Z"), "Europe/Berlin"),
).toBe("1960-05-14");
expect(
await born(new Date("1960-05-15T06:00:00.000Z"), "Pacific/Honolulu"),
).toBe("1960-05-14");
});
});
describe("fail-closed exclusion", () => {
it("excludes a measurement type the catalogue does not know", () => {
const gate = resolveReportGate(
+2 -2
View File
@@ -83,7 +83,7 @@ import {
loadSurgicalHistory,
loadLabResults,
} from "./clinical-records";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { dateOfBirthKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name";
import { readingsMean } from "@/lib/measurements/day-mean";
import { loadScreenerUnvalidatedTranslations } from "./screener-translations";
@@ -723,7 +723,7 @@ export async function collectDoctorReportData(
name: identityOn && userProfile ? patientName(userProfile) : null,
dateOfBirth:
identityOn && userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
? dateOfBirthKey(userProfile.dateOfBirth, reportTz)
: null,
gender: identityOn ? (userProfile?.gender ?? null) : null,
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
@@ -346,7 +346,7 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation"
});
});
describe("generateBloodPressureStatusForUser — the person's own target", () => {
describe("generateBloodPressureStatusForUser and the person's own target", () => {
const readings = [1, 2, 3].flatMap((daysAgo) => {
const measuredAt = new Date(Date.now() - daysAgo * dayMs);
return [
+1 -1
View File
@@ -422,7 +422,7 @@ describe("extractFeatures — weight target direction (#1006)", () => {
});
});
describe("extractFeatures — blood pressure against the person's own target", () => {
describe("extractFeatures: blood pressure against the person's own target", () => {
// 135/85 on three days: above the age default's ceiling, inside a target
// the person's doctor agreed (up to 140/90).
const bpRows = [1, 2, 3].flatMap((daysAgo) => {
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { waitForMedicationIntakeImport } from "../intake-import-poll";
import {
MedicationIntakeImportTimeout,
waitForMedicationIntakeImport,
} from "../intake-import-poll";
describe("waitForMedicationIntakeImport", () => {
it("polls queued and running jobs until the durable result is done", async () => {
@@ -53,4 +56,19 @@ describe("waitForMedicationIntakeImport", () => {
expect(readStatus).toHaveBeenCalledOnce();
expect(wait).not.toHaveBeenCalled();
});
it("tells a watch that gave up from a job that failed", async () => {
// The job may still write, so the card must not say nothing was written.
const readStatus = vi.fn().mockResolvedValue({
status: "running",
result: null,
});
await expect(
waitForMedicationIntakeImport("/api/import/job-1/status", {
readStatus,
wait: vi.fn().mockResolvedValue(undefined),
maxAttempts: 2,
}),
).rejects.toBeInstanceOf(MedicationIntakeImportTimeout);
});
});
+9 -1
View File
@@ -29,6 +29,14 @@ export class MedicationIntakeImportError extends Error {
}
}
/** The job did not settle while the client watched; it may still write. */
export class MedicationIntakeImportTimeout extends MedicationIntakeImportError {
constructor() {
super("Medication intake import timed out");
this.name = "MedicationIntakeImportTimeout";
}
}
const waitForNextPoll = () =>
new Promise<void>((resolve) => setTimeout(resolve, 1_000));
@@ -52,5 +60,5 @@ export async function waitForMedicationIntakeImport(
if (attempt + 1 < maxAttempts) await wait();
}
throw new MedicationIntakeImportError("Medication intake import timed out");
throw new MedicationIntakeImportTimeout();
}
+2
View File
@@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.account.deletion_pending",
"auth.admin.required",
"auth.already_authenticated",
"auth.mfa.already_active",
"auth.mfa.code_invalid",
"auth.missing",
"auth.passkey.last_method",
@@ -140,6 +141,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"coach-prefs.body.invalid_shape",
],
consent: ["consent.ai.confirmRequired", "consent.ai.required"],
customMetric: ["customMetric.name_taken"],
cycle: [
"cycle.bulk.invalid",
"cycle.bulk.too_large",
+1 -1
View File
@@ -565,7 +565,7 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Per-metric target tiles + consistency strips",
description:
"The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.",
"The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at READ level over the whole record, like the dashboard snapshot: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.",
responses: {
...recordRefusal(),
"200": {
+1 -1
View File
@@ -76,7 +76,7 @@ const notificationPreferencesResponse = z
eventTypes: z
.array(z.string())
.describe(
"The event categories this account is shown a switch for, in declaration order — the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.",
"The event categories this account is shown a switch for, in declaration order: the list a client renders rows for. A category nothing sends is not listed, and `preferences` carries no stored row for it.",
),
eventDefaults: z
.record(eventTypeEnum, z.boolean())
@@ -274,3 +274,50 @@ describe("buildVitalTargets — user weight target", () => {
expect(weight?.source).toBe("Custom");
});
});
describe("buildVitalTargets — a child's record", () => {
// 118 cm and 21 kg: a BMI of about 15, normal for a six-year-old and red
// under the adult cut-offs.
const child = {
recentMeasurements: [],
restingPulseProxy: [],
latestByType: { WEIGHT: 21 },
average30ByType: { WEIGHT: 21 },
heightCm: 118,
age: 6,
gender: null,
timezone: TZ,
now: NOW,
weightTargetOverride: null,
adultBands: false,
};
const card = (
targets: ReturnType<typeof buildVitalTargets>["targets"],
type: string,
) => targets.find((t) => t.type === type);
it("keeps the values and names no adult band or category", () => {
const { targets } = buildVitalTargets(child);
expect(card(targets, "WEIGHT")).toMatchObject({
current: 21,
range: null,
classification: null,
});
expect(card(targets, "BMI")).toMatchObject({
current: 15.1,
range: null,
classification: null,
});
});
it("keeps the record's own weight target", () => {
const { targets } = buildVitalTargets({
...child,
weightTargetOverride: { min: 19, max: 24 },
});
expect(card(targets, "WEIGHT")).toMatchObject({
range: { min: 19, max: 24 },
source: "Custom",
});
});
});
+4 -14
View File
@@ -18,6 +18,7 @@ import {
COURSES_COMPLIANCE_SELECT,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { ageInYears } from "@/lib/tz/date-only";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import pLimit from "p-limit";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -56,19 +57,6 @@ import {
import type { User } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
function getAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < dateOfBirth.getDate())
) {
age--;
}
return age;
}
/**
* The authenticated-user shape the builder reads directly. The profile
* fields (height / dob / gender / glucose unit / thresholds) are re-read
@@ -128,6 +116,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
select: {
heightCm: true,
dateOfBirth: true,
managedProfileAt: true,
gender: true,
glucoseUnit: true,
hasDiabetes: true,
@@ -256,7 +245,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
const now = new Date();
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
const age = ageInYears(dbUser?.dateOfBirth ?? null, timezone, now);
const gender = toProfileSex(dbUser?.gender);
const heightCm = dbUser?.heightCm ?? null;
const latestByType: TargetValueByType = {};
@@ -286,6 +275,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
average30ByType,
heightCm,
age,
adultBands: age != null ? age >= 18 : dbUser?.managedProfileAt == null,
gender,
timezone,
now,
+14 -5
View File
@@ -74,6 +74,13 @@ interface VitalTargetsInput {
* names a category, not a goal. Absent or `null` keeps the age band.
*/
bpTargetOverride?: BloodPressureTargetRange | null;
/**
* Whether the adult WHO weight and BMI cut-offs apply. No band here knows a
* child's age yet, so for a record under 18, or a managed one with no date
* of birth, the weight card keeps only the record's own target, the BMI
* card names no band, and neither names a category. Absent means they do.
*/
adultBands?: boolean;
}
export interface VitalTargetsResult {
@@ -170,6 +177,7 @@ export function buildVitalTargets({
now,
weightTargetOverride,
bpTargetOverride = null,
adultBands = true,
}: VitalTargetsInput): VitalTargetsResult {
const targets: TargetItem[] = [];
const consistencyClock = { timezone, now };
@@ -181,11 +189,12 @@ export function buildVitalTargets({
// v1.34 — a user-set target outranks the WHO BMI band. The BMI
// classification below stays WHO-derived either way: it names which BMI
// category a reading falls in, which is a reference fact and not a goal.
const whoHeightCm = adultBands ? heightCm : null;
const weightRange =
weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null);
weightTargetOverride ?? (whoHeightCm ? getWeightRange(whoHeightCm) : null);
let weightClassification: TargetItem["classification"] = null;
if (latestByType.WEIGHT != null && heightCm) {
const heightM = heightCm / 100;
if (latestByType.WEIGHT != null && whoHeightCm) {
const heightM = whoHeightCm / 100;
const classification = classifyBMI(
latestByType.WEIGHT / (heightM * heightM),
);
@@ -389,14 +398,14 @@ export function buildVitalTargets({
? Math.round((averageWeight / heightSquared) * 10) / 10
: null;
let bmiClassification: TargetItem["classification"] = null;
if (currentBmi != null) {
if (currentBmi != null && adultBands) {
const classification = classifyBMI(currentBmi);
bmiClassification = {
category: classification.category,
color: classification.color,
};
}
const bmiRange = { min: 18.5, max: 24.9 };
const bmiRange = adultBands ? { min: 18.5, max: 24.9 } : null;
targets.push({
type: "BMI",
label: "BMI",
+13 -5
View File
@@ -40,10 +40,10 @@ function courseEnded(
endedAt: Date | null;
},
timeZone: string | null,
at: Date = new Date(),
): boolean {
return (
courseStatusAt(medication, new Date(), timeZone || DEFAULT_TIMEZONE) ===
"ENDED"
courseStatusAt(medication, at, timeZone || DEFAULT_TIMEZONE) === "ENDED"
);
}
@@ -61,16 +61,24 @@ export async function listTelegramDoseMedications(
return medications.filter((m) => !courseEnded(m, timeZone));
}
/** The one medication `where` names, or null when the bot may not act on it. */
/**
* The medication `where` names, or null when the bot may not act on it. A
* name can match an ended course beside the current one, so the course is
* judged on every match. `at` is the moment the dose is for: a reminder's
* button books its own slot, so the last evening dose of a course can still
* be confirmed after midnight.
*/
export async function findTelegramDoseMedication(
userId: string,
timeZone: string | null,
where: Prisma.MedicationWhereInput,
at: Date = new Date(),
) {
if (!(await isModuleEnabled(userId, "medications"))) return null;
const medication = await prisma.medication.findFirst({
const medications = await prisma.medication.findMany({
where: { ...where, userId, active: true, ...TRACKED_INTAKE_WHERE },
select: DOSE_MEDICATION_SELECT,
orderBy: { id: "asc" },
});
return medication && !courseEnded(medication, timeZone) ? medication : null;
return medications.find((m) => !courseEnded(m, timeZone, at)) ?? null;
}
+19 -13
View File
@@ -266,9 +266,24 @@ async function markMedicationTaken(
reminderRef?: { chatId: string; messageId: number },
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
const { t } = getServerTranslator(locale);
const medication = await findTelegramDoseMedication(userId, userTz, {
id: medicationId,
});
const tz = userTz || DEFAULT_TIMEZONE;
// The reminder's own slot, when the message names one: the course is
// judged on that dose, not on the moment of the tap.
const reminderSlot = reminderRef
? await resolveReminderSlot({
userId,
medicationId,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
const medication = await findTelegramDoseMedication(
userId,
userTz,
{ id: medicationId },
reminderSlot ?? undefined,
);
if (!medication) {
return { ok: false, message: t("telegram.errorMedicationInactive") };
}
@@ -281,7 +296,6 @@ async function markMedicationTaken(
let scheduledFor: Date | null = null;
if (!existing) {
const takenAt = new Date();
const tz = userTz || DEFAULT_TIMEZONE;
// v1.16.9 — converge onto the slot's canonical row instead of bare-
// creating a second row anchored at `now`. The old shape left the
@@ -289,15 +303,7 @@ async function markMedicationTaken(
// and compliance punished a dose the user explicitly confirmed.
// Prefer the reminder's own slot (the message names the dose it fired
// for); fall back to band attribution on the tap instant.
let canonicalSlot: Date | null = reminderRef
? await resolveReminderSlot({
userId,
medicationId: medication.id,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
let canonicalSlot: Date | null = reminderSlot;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId,
+83
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
ageInYears,
dateOfBirthForStorage,
dateOfBirthKey,
isNoonUtcAnchor,
dateOnlyAtNoonUtc,
dateOnlyKey,
@@ -62,6 +65,86 @@ describe("isNoonUtcAnchor", () => {
});
});
describe("date of birth", () => {
// UTC-10 and UTC+13 the year round.
const WEST = "Pacific/Honolulu";
const EAST = "Pacific/Tongatapu";
it("reads a date-only anchor by its date in every zone", () => {
for (const zone of [WEST, EAST, "Europe/Berlin"]) {
expect(dateOfBirthKey(new Date("2012-05-14T00:00:00.000Z"), zone)).toBe(
"2012-05-14",
);
expect(dateOfBirthKey(new Date("2012-05-14T12:00:00.000Z"), zone)).toBe(
"2012-05-14",
);
}
});
it("reads an instant from the iOS app as its date in the owner's zone", () => {
// 14 May picked at 20:00 in Honolulu, and at 09:30 in Nuku'alofa.
expect(dateOfBirthKey(new Date("2012-05-15T06:00:00.000Z"), WEST)).toBe(
"2012-05-14",
);
expect(dateOfBirthKey(new Date("2012-05-13T20:30:00.000Z"), EAST)).toBe(
"2012-05-14",
);
});
it("stores a typed date and an iOS instant as the same calendar date", () => {
const noon = "2012-05-14T12:00:00.000Z";
expect(dateOfBirthForStorage("2012-05-14", WEST)?.toISOString()).toBe(noon);
expect(
dateOfBirthForStorage("2012-05-15T06:00:00.000Z", WEST)?.toISOString(),
).toBe(noon);
expect(
dateOfBirthForStorage("2012-05-13T20:30:00+00:00", EAST)?.toISOString(),
).toBe(noon);
});
it("keeps a stored value that comes back unchanged", () => {
for (const echoed of [
"2012-05-14T00:00:00.000Z",
"2012-05-14T12:00:00.000Z",
]) {
expect(dateOfBirthForStorage(echoed, WEST)?.toISOString()).toBe(
"2012-05-14T12:00:00.000Z",
);
}
});
it("stores nothing for a string that names no date", () => {
expect(dateOfBirthForStorage("2012-02-30", WEST)).toBeNull();
expect(dateOfBirthForStorage("not a date", WEST)).toBeNull();
});
it("counts the age from the birthday on the owner's calendar", () => {
const iosEntry = new Date("2012-05-13T20:30:00.000Z"); // 14 May in EAST
expect(
ageInYears(iosEntry, EAST, new Date("2026-05-13T10:59:00.000Z")),
).toBe(13);
// 00:00 on 14 May in Nuku'alofa.
expect(
ageInYears(iosEntry, EAST, new Date("2026-05-13T11:00:00.000Z")),
).toBe(14);
expect(
ageInYears(
new Date("2012-05-14T00:00:00.000Z"),
WEST,
new Date("2026-05-14T09:59:00.000Z"),
),
).toBe(13);
// 00:00 on 14 May in Honolulu.
expect(
ageInYears(
new Date("2012-05-14T00:00:00.000Z"),
WEST,
new Date("2026-05-14T10:00:00.000Z"),
),
).toBe(14);
});
});
describe("dateOnlyWindow", () => {
it("spans UTC midnight to the next UTC midnight, both days included", () => {
expect(dateOnlyWindow("2026-11-11")).toEqual({
+54
View File
@@ -14,6 +14,8 @@
* migration.
*/
import { userDayKey } from "./format";
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u;
/** True for a strict `YYYY-MM-DD` string. */
@@ -85,6 +87,58 @@ export function dateOnlyKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
/**
* The calendar date a stored date of birth names, in its owner's `timezone`.
*
* The web and the managed profiles store the date as a date-only anchor (UTC
* midnight, or noon), read by its date. The iOS app sent the instant its date
* picker returned, which names the day it falls on in the owner's zone: a
* Berlin entry of 14 May made at half past midnight is 23:30 UTC on 13 May,
* and read by its UTC date it printed the day before.
*/
export function dateOfBirthKey(value: Date, timezone: string): string {
const anchored =
value.getUTCHours() % 12 === 0 &&
value.getUTCMinutes() === 0 &&
value.getUTCSeconds() === 0 &&
value.getUTCMilliseconds() === 0;
return anchored ? dateOnlyKey(value) : userDayKey(value, timezone);
}
/**
* A date of birth as a client sends it (`YYYY-MM-DD`, or an instant from the
* iOS app) as it is stored: its calendar date, read the way
* {@link dateOfBirthKey} reads a stored value, at noon UTC. Noon keeps the
* date wherever a client reads the stored instant back in its own zone from
* UTC-11 to UTC+11, where UTC midnight is the evening before everywhere
* west of UTC. Null when the string names no date.
*/
export function dateOfBirthForStorage(
raw: string,
timezone: string,
): Date | null {
if (isDateOnlyKey(raw)) {
return isCalendarDateKey(raw) ? dateOnlyAtNoonUtc(raw) : null;
}
const instant = new Date(raw);
if (Number.isNaN(instant.getTime())) return null;
return dateOnlyAtNoonUtc(dateOfBirthKey(instant, timezone));
}
/** Whole years of age on `now` in the owner's `timezone`; null when unknown. */
export function ageInYears(
dateOfBirth: Date | null,
timezone: string,
now: Date = new Date(),
): number | null {
if (!dateOfBirth) return null;
const [by, bm, bd] = dateOfBirthKey(dateOfBirth, timezone)
.split("-")
.map(Number);
const [ty, tm, td] = userDayKey(now, timezone).split("-").map(Number);
return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0);
}
/**
* Calendar date `key` as UTC midnight. A LABEL, never the instant the day
* began for anyone: it is how a `@db.Date` column holds a date (Prisma reads
@@ -0,0 +1,62 @@
/**
* Logging the next dose re-anchors a booster reminder on the dose date. The
* dose date is a calendar date stored at UTC midnight, which is the evening
* before west of UTC, so the next booster came due a day early there.
*/
import { describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
import { satisfyBoostersForDose } from "@/lib/vaccinations/satisfy-matching";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
const TZ = "America/New_York";
function fakeTx() {
const updates: Array<Record<string, unknown>> = [];
const reminder = {
id: "reminder-1",
userId: "u1",
vaccinationAntigen: "tetanus",
intervalDays: 3650,
rrule: null,
anchorDate: new Date("2016-03-10T05:00:00.000Z"),
notifyHour: 9,
nextDueAt: new Date("2026-03-07T14:00:00.000Z"),
lastSatisfiedAt: null,
lastSkippedAt: null,
createdAt: new Date("2016-03-10T05:00:00.000Z"),
endsOn: null,
};
const tx = {
measurementReminder: {
findMany: vi.fn(async () => [reminder]),
updateMany: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
updates.push(data);
return { count: 1 };
}),
},
measurementReminderEvent: { create: vi.fn(async () => ({})) },
};
return { tx: tx as unknown as Prisma.TransactionClient, updates };
}
describe("satisfyBoostersForDose", () => {
it("counts the next interval from the dose's own date west of UTC", async () => {
const { tx, updates } = fakeTx();
await satisfyBoostersForDose(
tx,
"u1",
{ antigenSlug: "tetanus" },
new Date("2026-03-10T00:00:00.000Z"),
TZ,
);
// Ten years of days after 10 March, on the person's calendar.
expect(userDayKey(updates[0].nextDueAt as Date, TZ)).toBe(
shiftDateKey("2026-03-10", 3650),
);
expect(updates[0].lastSatisfiedAt).toEqual(
startOfLocalDayKey("2026-03-10", TZ),
);
});
});
+7 -1
View File
@@ -52,6 +52,8 @@ import {
satisfyReminder,
type SatisfiableReminder,
} from "@/lib/measurement-reminders/satisfy";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import {
componentsForDose,
loadCustomVaccineLookup,
@@ -136,6 +138,10 @@ export async function satisfyBoostersForDose(
return byAntigen !== 0 ? byAntigen : a.id.localeCompare(b.id);
});
// The dose date is a calendar date at UTC midnight, the evening before west
// of UTC; the reminder engine reads an instant in the person's zone, so it
// gets the start of that day there, as the booster mint does.
const satisfiedAt = startOfLocalDayKey(dateOnlyKey(occurredAt), timezone);
const satisfiedReminderIds: string[] = [];
for (const reminder of ordered) {
const row: SatisfiableReminder = {
@@ -160,7 +166,7 @@ export async function satisfyBoostersForDose(
tx as unknown as PrismaClient,
row,
timezone,
occurredAt,
satisfiedAt,
"vaccination",
);
if (result.satisfied) satisfiedReminderIds.push(reminder.id);