Answer the native client's open server asks

- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 10:32:11 +02:00
1 parent 7bcb6f71d9
commit bb563b6506
50 files changed
+1789 -142

No files matched your search

@@ -119,6 +119,14 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
count: 1,
reason: "read filter over whole searchParams",
},
"medications/compliance/route.ts": {
count: 1,
reason: "read filter over whole searchParams",
},
"mood/insights/route.ts": {
count: 1,
reason: "read filter over whole searchParams",
},
// ---------------------------------------------------------------
// 3. Native-client ingest and device registration. This is where the
+16 -1
View File
@@ -74,9 +74,17 @@ import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { storedGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
/**
* Capability flags for clients that are not shipped with the server. Constant
* by design: a feature the server supports is supported for every account,
* and a per-account answer belongs in `modules` or `ai`, not here.
*/
const SERVER_FEATURES = { trackIntake: true } as const;
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -331,7 +339,8 @@ export const GET = apiHandler(async () => {
avatarUrl: user.avatarUpdatedAt
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
glucoseUnit: user.glucoseUnit ?? null,
// The stored choice, published as the closed pair the contract names.
glucoseUnit: storedGlucoseUnit(user.glucoseUnit),
// v1.7.0 — global metric/imperial display preference. Canonical
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
@@ -442,5 +451,11 @@ export const GET = apiHandler(async () => {
// behind it have one reader by design, and a route that projected them
// itself would be a second statement of what a record context is.
recordSession: recordSessionForPayload(auth),
// v1.42 — what this server understands, for a client released on its own
// schedule (iOS #116, item 18). `trackIntake: true` says medications take
// the record-only switch, so an add form can offer it before the account
// has a medication to learn it from. A flag only ever appears here once
// the server supports it, and stays `true`; absence means an older server.
features: SERVER_FEATURES,
});
});
@@ -0,0 +1,164 @@
/**
* A passkey sign-in that cannot be checked answers with its own status and
* `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here
* against a mocked database, so the test pins the whole path from the
* library's refusal to the wire: an expired or already-used challenge, a
* credential this server does not know, a body that is not an assertion, and
* an assertion the WebAuthn library refuses.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
passkey: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@simplewebauthn/server", async (importOriginal) => ({
...(await importOriginal<typeof import("@simplewebauthn/server")>()),
verifyAuthenticationResponse: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const ASSERTION = {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: {
clientDataJSON: "e30",
authenticatorData: "AA",
signature: "AA",
},
};
function request(credential: unknown = ASSERTION): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ challengeId: "ch-1", credential }),
});
}
async function refusal(res: Response) {
const body = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
return { status: res.status, code: body.meta?.errorCode, data: body.data };
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: null },
] as never);
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
id: "pk-1",
userId: "user-1",
credentialId: "cred-1",
credentialPublicKey: new Uint8Array([1]),
counter: BigInt(0),
transports: [],
} as never);
});
describe("POST /api/auth/passkey/login-verify — named refusals", () => {
it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
expect(await refusal(await POST(request()))).toEqual({
status: 401,
code: "passkey.challenge.expired",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({
details: { reason: "passkey_challenge_expired" },
}),
);
});
it("answers a challenge begun for another account the same way", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: "someone-else" },
] as never);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.challenge.expired");
});
it("answers a passkey that is not registered here with 404 passkey.unknown", async () => {
vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never);
expect(await refusal(await POST(request()))).toEqual({
status: 404,
code: "passkey.unknown",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({ details: { reason: "passkey_unknown" } }),
);
});
it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => {
const out = await refusal(await POST(request({ id: "cred-1" })));
expect(out.status).toBe(422);
expect(out.code).toBe("passkey.response.invalid");
});
it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => {
vi.mocked(verifyAuthenticationResponse).mockRejectedValue(
new Error("Unexpected authentication response origin"),
);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.verification.failed");
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("still lets a real fault surface as one", async () => {
vi.mocked(prisma.passkey.findUnique).mockRejectedValue(
new Error("connection reset"),
);
const res = await POST(request());
expect(res.status).toBe(500);
});
});
+69 -6
View File
@@ -1,4 +1,8 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import {
PasskeyAuthError,
verifyAuthentication,
type PasskeyAuthErrorKind,
} from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { recordSignInDevice } from "@/lib/auth/login-alert";
@@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed";
/**
* How each passkey refusal answers. The challenge and verification cases are
* 401 (the sign-in did not prove anything), the unknown credential is 404 (the
* passkey is not registered on this server), and a malformed body is 422.
*/
const PASSKEY_REFUSALS: Record<
PasskeyAuthErrorKind,
{ status: number; errorCode: string; message: string; auditReason: string }
> = {
challenge_expired: {
status: 401,
errorCode: "passkey.challenge.expired",
message: "The sign-in took too long or was already used. Try again.",
auditReason: "passkey_challenge_expired",
},
unknown_passkey: {
status: 404,
errorCode: "passkey.unknown",
message: "This passkey is not registered here.",
auditReason: "passkey_unknown",
},
malformed: {
status: 422,
errorCode: "passkey.response.invalid",
message: "The passkey response could not be read.",
auditReason: "passkey_response_invalid",
},
verification_failed: {
status: 401,
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
message: "Passkey verification failed",
auditReason: "passkey_verification_failed",
},
};
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
// credential minted before SSO was mandated must not stay a live bypass.
@@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("challengeId and credential required", 422);
}
const { verification, passkey } = await verifyAuthentication(
challengeId,
credential,
);
let outcome: Awaited<ReturnType<typeof verifyAuthentication>>;
try {
outcome = await verifyAuthentication(challengeId, credential);
} catch (err) {
// A known client-side case — an expired challenge, a passkey that is no
// longer registered here, a malformed or refused assertion — answers
// with its own status and code (iOS #116, item 20) so the app can tell
// the person what to do instead of reporting a server error. Anything
// else is a real fault and keeps propagating.
if (!(err instanceof PasskeyAuthError)) throw err;
const refusal = PASSKEY_REFUSALS[err.kind];
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: refusal.auditReason },
});
annotate({
action: { name: "auth.login.passkey.refused" },
meta: { reason: err.kind },
});
return apiError(refusal.message, refusal.status, {
errorCode: refusal.errorCode,
});
}
const { verification, passkey } = outcome;
if (!verification.verified) {
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: "passkey_verification_failed" },
});
return apiError("Passkey verification failed", 401);
return apiError("Passkey verification failed", 401, {
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
});
}
const user = await prisma.user.findUnique({
@@ -577,4 +577,22 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => {
);
expect(res.status).toBe(429);
});
it("refuses a dose for a record-only medication with medication.intake.notTracked", async () => {
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "med-1",
userId: "user-1",
name: "Metformin",
trackIntake: false,
} as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-record-001" }),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("medication.intake.notTracked");
});
});
+7
View File
@@ -31,6 +31,8 @@ import {
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
const RATE_LIMIT = 60;
@@ -224,6 +226,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API endpoint for this medication is disabled", 403);
}
// A record-only medication takes no new dose — the same refusal every
// dose-writing route gives (iOS #116, item 15). Checked after the scope
// gate so a token without this medication's scope learns nothing about it.
if (isRecordOnly(medication)) return refuseUntrackedIntake();
// The external ingest path runs without a User record in scope;
// resolve the timezone once — band attribution and the compliance
// recompute below both anchor on the user's local day.
@@ -0,0 +1,183 @@
/**
* `POST /api/measurements/batch` — which issue belongs to which entry.
*
* A whole-batch 422 (`measurement.batch.invalid`) lists every problem in
* `details.issues`. The native client splits such a batch: it drops the
* entries the issues name and resends the rest, so one bad reading does not
* cost the valid ones. That only works if every problem caused by one entry
* carries a path that starts `entries.<n>`, and if the problems that are not
* about an entry (the wrapper itself) never do. Both halves are pinned here,
* and the first case proves the split by resending what is left.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: { findMany: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: vi.fn(
async (_tx: unknown, input: Record<string, unknown>) => ({
status: "inserted",
row: { id: "row-1", ...input },
}),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function post(body: unknown) {
return POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
const AT = new Date(Date.now() - 3_600_000).toISOString();
function entry(i: number, overrides: Record<string, unknown> = {}) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 64,
unit: "count/min",
startDate: AT,
endDate: AT,
externalId: `uuid-${i}`,
...overrides,
};
}
type Issue = { path: string; code: string };
async function issuesFor(body: unknown): Promise<Issue[]> {
const res = await post(body);
expect(res.status).toBe(422);
const json = (await res.json()) as {
details: { issues: Issue[] };
meta?: { errorCode?: string };
};
expect(json.meta?.errorCode).toBe("measurement.batch.invalid");
return json.details.issues;
}
/** The entry index an issue names, or null when it names the batch itself. */
function entryIndex(path: string): number | null {
const m = /^entries\.(\d+)(?:\.|$)/.exec(path);
return m ? Number(m[1]) : null;
}
/** The paths that are about the batch wrapper and no single entry. */
const BATCH_LEVEL_PATHS = new Set(["", "entries", "syncTrigger"]);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
});
describe("POST /api/measurements/batch — every entry problem names its entry", () => {
it("names each bad entry as entries.<n>, so the rest of the batch can be resent", async () => {
const entries = [
entry(0),
entry(1, { startDate: "yesterday" }),
entry(2),
entry(3, { value: "64" }),
"not an entry",
entry(5, { hkIdentifier: "", unit: "" }),
entry(6),
];
const issues = await issuesFor({ entries });
expect(issues.length).toBeGreaterThan(0);
for (const issue of issues) {
expect(entryIndex(issue.path), issue.path).not.toBeNull();
}
const bad = new Set(issues.map((i) => entryIndex(i.path)));
expect([...bad].sort()).toEqual([1, 3, 4, 5]);
// The split the client performs: drop the named entries, resend the rest.
const kept = entries.filter((_, i) => !bad.has(i));
const res = await post({ entries: kept });
expect(res.status).toBe(200);
});
it("names the batch itself, never an entry, when the wrapper is wrong", async () => {
const cases: unknown[] = [
[],
{ entries: [] },
{ entries: "x" },
{ entries: [entry(0)], syncTrigger: "sometimes" },
];
let seen = 0;
for (const body of cases) {
for (const issue of await issuesFor(body)) {
expect(BATCH_LEVEL_PATHS.has(issue.path), issue.path).toBe(true);
expect(entryIndex(issue.path)).toBeNull();
seen += 1;
}
}
expect(seen).toBeGreaterThanOrEqual(cases.length);
});
});
@@ -7,6 +7,7 @@ import {
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
type CompliancePayloadWire,
} from "@/lib/medications/compliance-payload";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -113,6 +114,16 @@ export const GET = apiHandler(
},
});
return apiSuccess(payload);
// The report windows ride the cached cell for the batched read only; this
// route's published shape does not carry them.
const wire: CompliancePayloadWire = {
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
dailyCompliance: payload.dailyCompliance,
complianceDisplay: payload.complianceDisplay,
};
return apiSuccess(wire);
},
);
@@ -71,6 +71,7 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
assertMedicationTakesIntake: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
@@ -79,6 +79,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
assertMedicationTakesIntake: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
+9 -3
View File
@@ -27,7 +27,10 @@ import {
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
assertMedicationOwnership,
assertMedicationTakesIntake,
} from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
@@ -111,8 +114,11 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. This is a
// dose write, so the guard also refuses a record-only medication with
// `medication.intake.notTracked` (iOS #116, item 15): its history stays,
// but a new dose would be stored and then counted nowhere.
const guard = await assertMedicationTakesIntake(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
@@ -274,4 +274,70 @@ describe("GET /api/medications/compliance", () => {
);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
it("carries one account-wide figure in meta.aggregate, from applicable medications only", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
medication("med-2"),
{
...medication("mirror-1"),
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
},
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
const body = await res.json();
const items = body.data as Array<{
compliance7: { totalExpected: number; missed: number };
compliance30: { totalExpected: number };
}>;
expect(body.meta.aggregate.medicationCount).toBe(2);
// Summed over the two scheduled medications; the mirror adds nothing.
expect(body.meta.aggregate.compliance7.totalExpected).toBe(
items[0].compliance7.totalExpected + items[1].compliance7.totalExpected,
);
expect(body.meta.aggregate.compliance30.totalExpected).toBe(
items[0].compliance30.totalExpected + items[1].compliance30.totalExpected,
);
expect(body.meta.aggregate.compliance7.totalExpected).toBeGreaterThan(0);
expect(body.meta).not.toHaveProperty("days");
});
it("answers meta.aggregate null when no medication's adherence applies", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
const body = await res.json();
expect(body.meta).toEqual({ aggregate: null });
});
it("adds a complianceN block per medication and to the aggregate on ?days=N", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/medications/compliance?days=90"),
);
expect(res.status).toBe(200);
const body = await res.json();
const item = body.data[0] as Record<string, { totalExpected: number }>;
expect(item.compliance90.totalExpected).toBeGreaterThan(
item.compliance30.totalExpected,
);
expect(body.meta.days).toBe(90);
expect(body.meta.aggregate.compliance90).toEqual(item.compliance90);
});
it("refuses a window the ledger does not serve", async () => {
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/medications/compliance?days=45"),
);
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
+87 -13
View File
@@ -1,15 +1,22 @@
import pLimit from "p-limit";
import { z } from "zod/v4";
import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccessWithMeta,
returnAllZodIssues,
} from "@/lib/api-response";
import {
aggregateCompliance,
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import { COMPLIANCE_REPORT_DAYS } from "@/lib/analytics/compliance";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
@@ -28,9 +35,29 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
* wire shape — the cards render rates / streak / display block only; the
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
/**
* `?days=N` adds a `complianceN` block per medication and to the account
* aggregate, for a report that covers its own window rather than the fixed 7
* and 30 days. Only the windows the ledger serves are accepted; anything else
* is a 422, never a silently rounded window.
*/
const querySchema = z.object({
days: z
.enum(COMPLIANCE_REPORT_DAYS.map(String) as [string, ...string[]])
.transform(Number)
.optional(),
});
export const GET = apiHandler(async (request: Request) => {
const { user, actor } = await requireRecordAuth("read", "medications");
const query = querySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
);
if (!query.success) return returnAllZodIssues(query.error, 422);
const days = query.data.days as
(typeof COMPLIANCE_REPORT_DAYS)[number] | undefined;
// v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to
// the record. Two reasons, and both are the same reason from opposite ends:
// a delegate hammering this route must burn their own allowance rather than
@@ -74,7 +101,7 @@ export const GET = apiHandler(async () => {
// on a cold cache. Warm / stale cells return without touching the
// database at all; `Promise.all` keeps the response in list order.
const limit = pLimit(3);
const items = await Promise.all(
const results = await Promise.all(
medications.map((medication) =>
limit(async () => {
const payload = await cachedSwr(
@@ -83,22 +110,69 @@ export const GET = apiHandler(async () => {
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
return {
medicationId: medication.id,
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
};
return { payload, medicationId: medication.id };
}),
),
);
// A cell cached before the report windows existed carries none; it reads
// as the not-applicable placeholder until its next rebuild rather than
// failing the whole read.
const windowFor = (payload: CompliancePayload, n: number) =>
payload.reportWindows?.[n as keyof CompliancePayload["reportWindows"]];
const items = results.map(({ payload, medicationId }) => ({
medicationId,
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
...(days !== undefined && days !== 30
? { [`compliance${days}`]: windowFor(payload, days) ?? null }
: {}),
complianceDisplay: payload.complianceDisplay,
}));
// The account-wide figure the widget ring and the report show, built only
// from medications whose adherence means something (`applicable`).
const applicable = results
.map((r) => r.payload)
.filter((payload) => payload.applicable);
const reportWindow =
days !== undefined && days !== 30
? applicable.map((payload) => windowFor(payload, days))
: [];
const aggregate =
applicable.length === 0
? null
: {
compliance7: aggregateCompliance(
applicable.map((p) => p.compliance7),
),
compliance30: aggregateCompliance(
applicable.map((p) => p.compliance30),
),
...(days !== undefined && days !== 30
? {
[`compliance${days}`]: reportWindow.every(Boolean)
? aggregateCompliance(
reportWindow as NonNullable<
(typeof reportWindow)[number]
>[],
)
: null,
}
: {}),
medicationCount: applicable.length,
};
annotate({
action: { name: "medication.compliance_summary.read" },
meta: { count: items.length },
meta: { count: items.length, days: days ?? null },
});
return apiSuccess(items);
return apiSuccessWithMeta(items, {
aggregate,
...(days !== undefined ? { days } : {}),
});
});
@@ -381,6 +381,45 @@ describe("POST /api/medications/intake", () => {
expect(res.status).toBe(422);
});
it("refuses to resolve an open dose of a record-only medication with medication.intake.notTracked", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
takenAt: null,
skipped: false,
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
medication: { trackIntake: false },
} as never);
const res = await POST(req({ intakeId: "e1", status: "taken" }));
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("medication.intake.notTracked");
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(consumeForIntake).not.toHaveBeenCalled();
});
it("still lets the owner correct a dose a record-only medication already has", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
takenAt: new Date("2026-05-18T10:05:00.000Z"),
skipped: false,
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
medication: { trackIntake: false },
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
skipped: true,
takenAt: null,
} as never);
const res = await POST(req({ intakeId: "e1", status: "skipped" }));
expect(res.status).toBe(200);
});
it("marks event as skipped", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
@@ -244,6 +244,29 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
vi.useRealTimers();
});
it("skips an entry for a record-only medication as intake_not_tracked and keeps the rest", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-1", trackIntake: false },
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:00.000Z",
takenAt: "2026-06-15T05:01:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.entries).toEqual([
{ index: 0, status: "skipped", reason: "intake_not_tracked" },
]);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
// echo (no takenAt, skipped false) — must NOT clear takenAt.
@@ -86,6 +86,10 @@ import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
INTAKE_NOT_TRACKED_REASON,
isRecordOnly,
} from "@/lib/medications/intake-tracking";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -256,6 +260,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
trackInjectionSites: true,
allowedInjectionSites: true,
externalSource: true,
trackIntake: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
@@ -366,6 +371,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// A record-only medication takes no new dose (iOS #116, item 15). The
// single-dose routes answer 422 `medication.intake.notTracked`; here the
// same decision is one entry's outcome, so the rest of the batch lands.
if (isRecordOnly(medById.get(entry.medicationId) ?? {})) {
const reason = INTAKE_NOT_TRACKED_REASON;
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
// Both dedup keys on this route are only idempotent while the value
// is STABLE across client launches: `(userId, externalId)` for the
// Apple dose event, and the `@unique` `idempotency_key` column for
+16 -1
View File
@@ -54,7 +54,11 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import {
TRACKED_INTAKE_EVENT_WHERE,
isRecordOnly,
} from "@/lib/medications/intake-tracking";
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
// The query + body schemas moved to `@/lib/validations/medication` when this
// endpoint joined the published contract: the OpenAPI registry generates from
@@ -265,6 +269,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
trackIntake: true,
},
},
},
@@ -273,6 +278,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Intake event not found", 404);
}
// A record-only medication keeps the doses it already has, and an owner may
// still correct one of those (that is an edit of history). What it no longer
// takes is a NEW dose: resolving or snoozing an event that is still open
// would record one, and it would then be counted nowhere (iOS #116, item
// 15). Refused by name, the same code the per-medication route answers with.
const stillOpen = existing.takenAt === null && !existing.skipped;
if (stillOpen && isRecordOnly(existing.medication)) {
return refuseUntrackedIntake();
}
// v1.36.1 — this route is admitted for delegates because marking a dose is
// the thing a person standing next to the patient needs to do. It is an
// UPDATE, though, and two of the transitions it can express are not that.
@@ -0,0 +1,86 @@
/**
* `GET /api/mood/insights?days=N` serves the stability score for the period
* a client shows. The aggregate is cached once per user with every window in
* it; the route picks one, names it, and keeps the per-window map off the
* wire.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/cache/server-cache", () => ({
cachedSwr: vi.fn(),
caches: { moodInsights: {} },
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { requireModuleEnabled } from "@/lib/modules/gate";
const year = { score: 40, stdDev: 0.9, band: "variable", days: 300 };
const month = { score: 90, stdDev: 0.2, band: "verySteady", days: 28 };
const AGGREGATE = {
summary: { totalEntries: 300 },
heatmap: { windowDays: 365, cells: [] },
tags: [],
stability: year,
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
};
const call = (query = "") =>
(GET as (req: Request) => Promise<Response>)(
new Request(`http://localhost/api/mood/insights${query}`),
);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue({
session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER", timezone: "UTC" },
} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(cachedSwr).mockResolvedValue(AGGREGATE as never);
});
describe("GET /api/mood/insights — stability per period", () => {
it("serves the whole year by default and names the window", async () => {
const body = await (await call()).json();
expect(body.data.stability).toEqual(year);
expect(body.data.stabilityWindowDays).toBe(365);
expect(body.data).not.toHaveProperty("stabilityByWindow");
});
it("serves the chosen window as stability on ?days=30", async () => {
const body = await (await call("?days=30")).json();
expect(body.data.stability).toEqual(month);
expect(body.data.stabilityWindowDays).toBe(30);
});
it("answers null, not the year's score, when the window has too few days", async () => {
const body = await (await call("?days=90")).json();
expect(body.data.stability).toBeNull();
expect(body.data.stabilityWindowDays).toBe(90);
});
it("refuses a window it does not offer", async () => {
const res = await call("?days=45");
expect(res.status).toBe(422);
expect(cachedSwr).not.toHaveBeenCalled();
});
});
+38 -3
View File
@@ -1,10 +1,14 @@
import { apiSuccess } from "@/lib/api-response";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
MOOD_STABILITY_WINDOWS,
type MoodAggregates,
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -26,9 +30,28 @@ export const dynamic = "force-dynamic";
* aggregate is served immediately and a single background recompute
* warms a fresh one, so an active logger never re-pays the cold compute.
*/
export const GET = apiHandler(async () => {
/**
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
* the steadiness of the period it shows. Without it the score covers the
* whole year, as it always has; `stabilityWindowDays` names the window the
* served score covers either way.
*/
const querySchema = z.object({
days: z
.enum(MOOD_STABILITY_WINDOWS.map(String) as [string, ...string[]])
.transform((v) => Number(v) as MoodStabilityWindow)
.optional(),
});
export const GET = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const query = querySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
);
if (!query.success) return returnAllZodIssues(query.error, 422);
const stabilityWindowDays: MoodStabilityWindow = query.data.days ?? 365;
// Per-domain gate: the mood-insights aggregate surface only serves the
// mood module's analysis page, so it must be gated on the mood module
// (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled.
@@ -51,5 +74,17 @@ export const GET = apiHandler(async () => {
},
});
return apiSuccess(result);
// The per-window map is the cache's, not the wire's: the chosen window is
// served as `stability`. An aggregate cached before the map existed has
// only the year, so a shorter window reads as not yet computed (`null`)
// until the next rebuild rather than as the year's score under a new name.
const { stabilityByWindow, ...wire } = result;
return apiSuccess({
...wire,
stability:
stabilityWindowDays === 365
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
});
});