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

+19
View File
@@ -496,9 +496,20 @@ export function calculateCompliance(
* `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by
* filtering on `row.at`.
*/
/**
* The report windows a client may ask the batched compliance read for
* (`GET /api/medications/compliance?days=N`), beyond the 7 and 30 days every
* payload carries. Each is a suffix of the 365-day ledger window, so all of
* them are tallies over the rows the bundle already minted.
*/
export const COMPLIANCE_REPORT_DAYS = [30, 60, 90, 180, 365] as const;
export type ComplianceReportDays = (typeof COMPLIANCE_REPORT_DAYS)[number];
export interface MedicationComplianceBundle {
compliance7: ComplianceResult;
compliance30: ComplianceResult;
/** One result per report window, keyed by its length in days. */
reportWindows: Record<ComplianceReportDays, ComplianceResult>;
complianceDisplay: ComplianceDisplay;
/** Unified ledger rows over `[ledgerFrom, now]`, chronological. */
ledgerRows: DoseHistoryRow[];
@@ -704,9 +715,17 @@ export function buildMedicationComplianceBundle(
currentDose,
};
const reportWindows = Object.fromEntries(
COMPLIANCE_REPORT_DAYS.map((days) => [
days,
days === 30 ? compliance30 : resultForWindow(days),
]),
) as Record<ComplianceReportDays, ComplianceResult>;
return {
compliance7,
compliance30,
reportWindows,
complianceDisplay,
ledgerRows,
ledgerFrom,
+14
View File
@@ -6,6 +6,20 @@ export function apiSuccess<T>(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
}
/**
* A success envelope that also carries `meta`: figures ABOUT the payload
* (an aggregate across its rows, the window it was cut to) that do not belong
* inside any one row. `data` keeps the shape the route always had, so a
* client that never reads `meta` decodes the response unchanged.
*/
export function apiSuccessWithMeta<T>(
data: T,
meta: Record<string, unknown>,
status = 200,
) {
return NextResponse.json({ data, error: null, meta }, { status });
}
/**
* Sanitised view of a single Zod issue. We surface `path`, `code`,
* `message` and — for `unrecognized_keys` only — the bounded `keys`
+63 -16
View File
@@ -304,6 +304,33 @@ export async function createAuthenticationOptions(userId?: string) {
return { options, challengeId: challenge.id };
}
/**
* Why a passkey assertion could not be checked at all — as opposed to an
* assertion that was checked and did not verify. Each kind is a case a
* client can name to the person (the passkey was removed here, the sign-in
* took too long), so the sign-in route answers it with its own status and
* `meta.errorCode` instead of letting it surface as a server fault.
*
* - `challenge_expired`: the challenge is gone (expired, already used, never
* issued) or was begun for a different account.
* - `unknown_passkey`: no passkey with this credential id is registered here.
* - `malformed`: the assertion body is not an authentication response.
* - `verification_failed`: the WebAuthn library refused the assertion
* (origin, RP id, challenge or signature mismatch).
*/
export type PasskeyAuthErrorKind =
"challenge_expired" | "unknown_passkey" | "malformed" | "verification_failed";
export class PasskeyAuthError extends Error {
constructor(
readonly kind: PasskeyAuthErrorKind,
message: string,
) {
super(message);
this.name = "PasskeyAuthError";
}
}
export async function verifyAuthentication(
challengeId: string,
response: unknown,
@@ -318,7 +345,10 @@ export async function verifyAuthentication(
type: "authentication",
});
if (!challenge) {
throw new Error("Challenge expired or not found");
throw new PasskeyAuthError(
"challenge_expired",
"Challenge expired or not found",
);
}
// v1.4.43 W13 L-3 — explicit Zod narrowing instead of the previous
@@ -329,7 +359,10 @@ export async function verifyAuthentication(
// type-narrowing gap a future refactor could trip on.
const parsed = authenticationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Malformed passkey authentication response");
throw new PasskeyAuthError(
"malformed",
"Malformed passkey authentication response",
);
}
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
@@ -340,27 +373,41 @@ export async function verifyAuthentication(
});
if (!passkey) {
throw new Error("Passkey not found");
throw new PasskeyAuthError("unknown_passkey", "Passkey not found");
}
// A challenge begun for one account (the re-proof arm names the account)
// is answered only by that account's passkey.
if (challenge.userId !== null && challenge.userId !== passkey.userId) {
throw new Error("Challenge expired or not found");
throw new PasskeyAuthError(
"challenge_expired",
"Challenge expired or not found",
);
}
const verification = await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
},
});
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
},
});
} catch (err) {
// The library throws, rather than answering `verified: false`, for an
// origin, RP id or challenge mismatch and for a bad signature. Those are
// a refused assertion, not a server fault.
throw new PasskeyAuthError(
"verification_failed",
err instanceof Error ? err.message : "Passkey verification failed",
);
}
if (verification.verified) {
// Update counter + stamp last-used so the management UI can surface
+12
View File
@@ -95,6 +95,18 @@ export function glucoseBandParams(
};
}
/**
* The stored glucose-unit choice as the closed pair, or `null` when none was
* made. The column is free text (a restored backup writes it unchecked), so a
* value that is neither unit reads as no choice rather than passing through to
* a client that decodes the pair.
*/
export function storedGlucoseUnit(
value: string | null | undefined,
): GlucoseUnit | null {
return value === "mg/dL" || value === "mmol/L" ? value : null;
}
export function resolveGlucoseUnit(
userPreference: string | null | undefined,
): GlucoseUnit {
@@ -410,6 +410,23 @@ describe("computeMoodAggregates", () => {
]);
expect(agg.stability).toBeNull();
});
it("scores stability per trailing window, so a steady month after a turbulent year reads steady", () => {
// Days 0..29 sit at 4; days 30..119 swing between 1 and 5.
const entries: MoodAggregateEntry[] = [];
for (let d = 0; d < 30; d++) entries.push(entry(d, 4));
for (let d = 30; d < 120; d++) entries.push(entry(d, d % 2 ? 1 : 5));
const agg = computeMoodAggregates({ entries, measurements: [], now: NOW });
expect(agg.stabilityByWindow[30]?.score).toBe(100);
expect(agg.stabilityByWindow[30]?.days).toBe(30);
expect(agg.stabilityByWindow[90]?.score).toBeLessThan(
agg.stabilityByWindow[30]!.score,
);
// The whole-year entry is the score `stability` has always carried.
expect(agg.stabilityByWindow[365]).toEqual(agg.stability);
expect(agg.stability?.days).toBe(120);
});
});
describe("bucketForHour", () => {
+26
View File
@@ -328,6 +328,14 @@ export function pairDailyBuckets(
export type HeatmapWindowDays = 30 | 90 | 365;
/**
* The trailing windows the stability score is offered over. 365 is the
* whole daily series the aggregate keeps, which is what `stability` has
* always covered.
*/
export const MOOD_STABILITY_WINDOWS = [30, 90, 180, 365] as const;
export type MoodStabilityWindow = (typeof MOOD_STABILITY_WINDOWS)[number];
/**
* Adaptive heatmap window so the grid is never mostly-empty.
*
@@ -497,6 +505,13 @@ export interface MoodAggregates {
* descriptive band. `null` for a sparse logger (< STABILITY_MIN_DAYS).
*/
stability: MoodStability | null;
/**
* The same score over the shorter trailing windows a client can pick
* (`GET /api/mood/insights?days=N`). Cached with the rest of the aggregate
* so a period switch costs nothing; the route serves the chosen one as
* `stability` and does not publish this map.
*/
stabilityByWindow: Record<MoodStabilityWindow, MoodStability | null>;
tags: TagSummaryRow[];
/**
* v1.38 — how the day's pleasantness sat on days carrying each context
@@ -608,6 +623,16 @@ export function computeMoodAggregates(args: {
const weekday = computeWeekdayAverages(moodDaily, now, tz);
const timeOfDay = computeTimeOfDayAverages(entries);
const stability = computeMoodStability(moodDaily);
const stabilityByWindow = Object.fromEntries(
MOOD_STABILITY_WINDOWS.map((days) => [
days,
days === 365
? stability
: computeMoodStability(
moodDaily.filter((bucket) => bucket.dayOffset < days),
),
]),
) as Record<MoodStabilityWindow, MoodStability | null>;
const tags = computeTagSummary(entries, now);
const structuredTags = computeStructuredTagSummary(entries, now);
const tagInfluence = computeTagInfluence(entries, now);
@@ -689,6 +714,7 @@ export function computeMoodAggregates(args: {
weekday,
timeOfDay,
stability,
stabilityByWindow,
tags,
structuredTags,
tagInfluence,
@@ -223,6 +223,7 @@ describe("handleReminderCheck per-slot reminder windows", () => {
metadata: expect.objectContaining({
phase: "ORANGE",
timeOfDay: "08:00",
followUp: true,
}),
}),
);
@@ -248,6 +249,34 @@ describe("handleReminderCheck per-slot reminder windows", () => {
metadata: expect.objectContaining({
phase: "YELLOW",
timeOfDay: "08:00",
// GREEN announced this window; YELLOW escalates an open dose.
followUp: true,
}),
}),
);
});
it("marks the phase that announces the dose as the one reminder that is not a follow-up", async () => {
vi.useFakeTimers();
// 55 minutes before the 09:30 end of a 07:30 to 09:30 window: GREEN.
vi.setSystemTime(new Date("2026-07-28T08:35:00.000Z"));
prismaMock.medication.findMany.mockResolvedValue([
medicationWithSchedule({
windowEnd: "18:00",
timesOfDay: ["08:00", "18:00"],
doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:30" }],
}),
] as never);
setIntakeEvents([]);
await handleReminderCheck([]);
expect(dispatchNotification).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
phase: "GREEN",
timeOfDay: "08:00",
followUp: false,
}),
}),
);
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import {
determinePhase,
getPhaseKeyboard,
openingPhase,
getPhaseMessage,
resolvePhaseThresholds,
DEFAULT_PHASE_CONFIG,
@@ -142,3 +143,26 @@ describe("getPhaseKeyboard — localised", () => {
}
});
});
describe("openingPhase", () => {
it("is GREEN for a window longer than the YELLOW lead", () => {
const t = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 120);
expect(openingPhase(t, 120)).toBe("GREEN");
});
it("is YELLOW for a point slot, which never shows GREEN", () => {
const t = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 0);
expect(openingPhase(t, 0)).toBe("YELLOW");
// The same window walked tick by tick reaches YELLOW before anything else.
expect(determinePhase(30, -30, t)).toBe("YELLOW");
expect(determinePhase(45, -45, t)).toBeNull();
});
it("is YELLOW when the GREEN lead is not longer than the YELLOW lead", () => {
const t = resolvePhaseThresholds(
{ ...DEFAULT_PHASE_CONFIG, greenValue: 20, yellowValue: 30 },
120,
);
expect(openingPhase(t, 120)).toBe("YELLOW");
});
});
+25
View File
@@ -84,6 +84,31 @@ export function resolvePhaseThresholds(
};
}
/**
* The first phase a slot's window reaches, which is the reminder that
* announces the dose; every later phase is a follow-up for a dose that is
* still open.
*
* It is not always GREEN. GREEN needs the window to have opened AND to be
* within `greenMinBefore` of its end, while YELLOW needs only the second
* condition with the smaller `yellowMinBefore`. A window no longer than the
* YELLOW lead (a point slot such as 07:00 to 07:00 is zero minutes long)
* reaches YELLOW first and never shows GREEN, and so does any configuration
* whose GREEN lead is not longer than its YELLOW lead.
*/
export function openingPhase(
thresholds: ReturnType<typeof resolvePhaseThresholds>,
windowDurationMin: number,
): ReminderPhase {
if (
windowDurationMin <= thresholds.yellowMinBefore ||
thresholds.greenMinBefore <= thresholds.yellowMinBefore
) {
return "YELLOW";
}
return "GREEN";
}
/**
* Determine the current phase for a schedule based on minutes to window end.
*
@@ -31,6 +31,7 @@ import {
determinePhase,
getPhaseMessage,
getPhaseKeyboard,
openingPhase,
} from "@/lib/jobs/reminder-phases";
import {
claimNotificationEvent,
@@ -555,6 +556,9 @@ export async function handleReminderCheck(
windowStart: phaseWindow.start,
minutesToEnd,
currentPhase,
followUp:
currentPhase !==
openingPhase(thresholds, windowDuration),
}
: null;
})
@@ -570,6 +574,7 @@ export async function handleReminderCheck(
windowStart,
minutesToEnd,
currentPhase,
followUp,
} = phaseCandidate;
const slotInstant = slotScheduledFor.getTime();
if (liveEraStart !== null && slotInstant < liveEraStart) {
@@ -767,6 +772,12 @@ export async function handleReminderCheck(
medicationId: med.id,
scheduleId: schedule.id,
phase: currentPhase,
// False for the reminder that announces the dose (the
// slot's opening phase), true for every escalation after
// it. A client that reminds locally at dose time
// (`medication.clientManaged`) replaces only the first
// one, so only that one is kept off APNs.
followUp,
date: slotScheduledFor.toLocaleDateString("sv-SE", {
timeZone: userTz,
}),
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { aggregateCompliance } from "../compliance-payload";
function result(taken: number, missed: number, skipped = 0, streak = 0) {
return {
totalExpected: taken + missed + skipped,
taken,
skipped,
missed,
rate:
taken + missed > 0 ? Math.round((100 * taken) / (taken + missed)) : 100,
streak,
};
}
describe("aggregateCompliance", () => {
it("weights by expected doses rather than averaging the rates", () => {
// A three-times-daily tablet at 60/90 and a weekly injection at 4/4.
// The mean of the rates would read 83 %; the doses say 64/94 = 68 %.
const out = aggregateCompliance([result(60, 30), result(4, 0)]);
expect(out).toMatchObject({
totalExpected: 94,
taken: 64,
missed: 30,
rate: 68,
});
});
it("keeps skipped doses out of the rate, as a single medication's tally does", () => {
expect(aggregateCompliance([result(9, 1, 5)])?.rate).toBe(90);
expect(aggregateCompliance([result(9, 1, 5)])?.totalExpected).toBe(15);
});
it("takes the shortest streak, because a day counts only when every medication was taken", () => {
expect(
aggregateCompliance([result(5, 0, 0, 12), result(5, 0, 0, 3)])?.streak,
).toBe(3);
});
it("is null when nothing contributes", () => {
expect(aggregateCompliance([])).toBeNull();
});
});
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { assertMedicationOwnership } from "../route-guards";
import {
assertMedicationOwnership,
assertMedicationTakesIntake,
} from "../route-guards";
function buildClient(med: { id: string; userId: string } | null) {
return {
@@ -72,3 +75,34 @@ describe("assertMedicationOwnership", () => {
expect(call.select).toEqual({ id: true, userId: true });
});
});
describe("assertMedicationTakesIntake", () => {
function client(
med: { id: string; userId: string; trackIntake: boolean } | null,
) {
return { medication: { findUnique: vi.fn().mockResolvedValue(med) } };
}
it("admits a tracked medication the caller holds", async () => {
const c = client({ id: "med-1", userId: "user-1", trackIntake: true });
expect(await assertMedicationTakesIntake("med-1", "user-1", c)).toBeNull();
expect(c.medication.findUnique).toHaveBeenCalledWith({
where: { id: "med-1" },
select: { id: true, userId: true, trackIntake: true },
});
});
it("refuses a record-only medication by name", async () => {
const c = client({ id: "med-1", userId: "user-1", trackIntake: false });
const result = await assertMedicationTakesIntake("med-1", "user-1", c);
expect(result?.status).toBe(422);
const body = await result!.json();
expect(body.meta.errorCode).toBe("medication.intake.notTracked");
});
it("answers 404, not 422, for a record-only medication of another account", async () => {
const c = client({ id: "med-1", userId: "owner", trackIntake: false });
const result = await assertMedicationTakesIntake("med-1", "intruder", c);
expect(result?.status).toBe(404);
});
});
+64
View File
@@ -15,7 +15,9 @@ import {
buildMedicationComplianceBundle,
expectsDoses,
lastNonSkippedTakenAt,
COMPLIANCE_REPORT_DAYS,
type ComplianceDisplay,
type ComplianceReportDays,
type ComplianceResult,
type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
@@ -47,8 +49,17 @@ export interface CompliancePayload {
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay | null;
/**
* The report windows the batched read serves on `?days=N`. Cached with the
* rest of the cell so a report read costs no second ledger pass; NOT on the
* per-medication wire, which keeps its published shape.
*/
reportWindows: Record<ComplianceReportDays, ComplianceResult>;
}
/** The cached payload minus the fields only the batched read serves. */
export type CompliancePayloadWire = Omit<CompliancePayload, "reportWindows">;
/**
* Legacy-compatible placeholder for a medication whose adherence percentage
* is explicitly not applicable.
@@ -68,6 +79,13 @@ const NOT_APPLICABLE_LEGACY_COMPLIANCE: ComplianceResult = {
streak: 0,
};
const NOT_APPLICABLE_REPORT_WINDOWS = Object.fromEntries(
COMPLIANCE_REPORT_DAYS.map((days) => [
days,
NOT_APPLICABLE_LEGACY_COMPLIANCE,
]),
) as Record<ComplianceReportDays, ComplianceResult>;
/** The medication slice the payload builder consumes. */
export interface ComplianceMedicationInput {
id: string;
@@ -194,6 +212,7 @@ export async function buildCompliancePayload(
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
dailyCompliance: {},
complianceDisplay: null,
reportWindows: NOT_APPLICABLE_REPORT_WINDOWS,
};
}
@@ -207,6 +226,7 @@ export async function buildCompliancePayload(
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
dailyCompliance: {},
complianceDisplay: null,
reportWindows: NOT_APPLICABLE_REPORT_WINDOWS,
};
}
@@ -314,5 +334,49 @@ export async function buildCompliancePayload(
compliance30: bundle.compliance30,
dailyCompliance,
complianceDisplay: bundle.complianceDisplay,
reportWindows: bundle.reportWindows,
};
}
/**
* One adherence figure for the whole account, in the per-medication
* `ComplianceResult` shape, weighted by expected doses: the counts are summed
* across every applicable medication and the rate is recomputed from the sums
* with the same rule a single medication's tally uses
* (`round(100 · taken / (taken + missed))`, capped at 100, 100 when nothing
* was expected). Averaging the per-medication rates instead would let a
* weekly injection weigh as much as a three-times-daily tablet.
*
* `streak` is the shortest per-medication streak: a day only extends the
* account's streak when every medication's doses were taken that day.
*
* `null` when no medication contributes, so a client shows nothing rather
* than a vacuous 100 %.
*/
export function aggregateCompliance(
results: readonly ComplianceResult[],
): ComplianceResult | null {
if (results.length === 0) return null;
let taken = 0;
let skipped = 0;
let missed = 0;
let streak = Number.POSITIVE_INFINITY;
for (const r of results) {
taken += r.taken;
skipped += r.skipped;
missed += r.missed;
streak = Math.min(streak, r.streak);
}
const denominator = taken + missed;
return {
totalExpected: taken + skipped + missed,
taken,
skipped,
missed,
rate:
denominator > 0
? Math.min(100, Math.round((taken / denominator) * 100))
: 100,
streak,
};
}
+12
View File
@@ -66,3 +66,15 @@ export function scheduleWireFields<T>(
? { schedules: [], recordedSchedules: schedules }
: { schedules };
}
/**
* Error code for a dose written against a record-only medication. Every route
* that records a dose answers it the same way (iOS #116, item 15): the history
* the medication already has stays, but nothing new is counted, so a new
* write is refused by name rather than stored and then counted nowhere.
* The bulk route reports the same decision per entry as `intake_not_tracked`.
*/
export const INTAKE_NOT_TRACKED_ERROR_CODE = "medication.intake.notTracked";
/** Per-entry skip reason for the bulk route, the batch form of the code. */
export const INTAKE_NOT_TRACKED_REASON = "intake_not_tracked";
+46
View File
@@ -22,6 +22,7 @@
*/
import { apiError } from "@/lib/api-response";
import { INTAKE_NOT_TRACKED_ERROR_CODE } from "@/lib/medications/intake-tracking";
interface MedicationOwnershipPrisma {
medication: {
@@ -53,6 +54,51 @@ export async function assertMedicationOwnership(
return null;
}
/** The 422 a dose write against a record-only medication answers with. */
export function refuseUntrackedIntake(): Response {
return apiError(
"This medication is kept as a record; its intake is not tracked",
422,
{ errorCode: INTAKE_NOT_TRACKED_ERROR_CODE },
);
}
interface MedicationIntakeGuardPrisma {
medication: {
findUnique: (args: {
where: { id: string };
select: { id: true; userId: true; trackIntake: true };
}) => Promise<{ id: string; userId: string; trackIntake: boolean } | null>;
};
}
/**
* The ownership guard for a route that RECORDS a dose against the
* medication: the same 404 as {@link assertMedicationOwnership}, and then a
* 422 `medication.intake.notTracked` when the medication is kept as a record
* only (`trackIntake: false`). One read answers both, so the refusal cannot
* be reached for a medication the caller does not hold — an unknown id and a
* foreign one still look identical.
*/
export async function assertMedicationTakesIntake(
medicationId: string,
userId: string,
prismaClient?: MedicationIntakeGuardPrisma,
): Promise<Response | null> {
const client =
prismaClient ??
((await loadPrisma()) as unknown as MedicationIntakeGuardPrisma);
const med = await client.medication.findUnique({
where: { id: medicationId },
select: { id: true, userId: true, trackIntake: true },
});
if (!med || med.userId !== userId) {
return apiError("Medication not found", 404);
}
if (med.trackIntake === false) return refuseUntrackedIntake();
return null;
}
async function loadPrisma(): Promise<MedicationOwnershipPrisma> {
// Lazy import so test files that stub `@/lib/db` at the module level
// see their stub instead of the real Prisma client. Matches the
@@ -146,6 +146,43 @@ describe("dispatchNotification — client-managed APNs suppression", () => {
});
});
it("keeps a follow-up for a dose still open on APNs, because the phone sends none", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
await dispatchNotification({
...MEDICATION_PAYLOAD,
metadata: {
...MEDICATION_PAYLOAD.metadata,
phase: "ORANGE",
followUp: true,
},
});
expect(sendViaApnsMock).toHaveBeenCalledTimes(1);
expect(sendViaTelegramMock).toHaveBeenCalledTimes(1);
expect(recordPushAttemptMock).not.toHaveBeenCalled();
});
it("still keeps the reminder that announces the dose off APNs", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
await dispatchNotification({
...MEDICATION_PAYLOAD,
metadata: {
...MEDICATION_PAYLOAD.metadata,
phase: "YELLOW",
followUp: false,
},
});
expect(sendViaApnsMock).not.toHaveBeenCalled();
expect(sendViaTelegramMock).toHaveBeenCalledTimes(1);
});
it("honours the roaming deliveryDefault mapping", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { deliveryDefault: "client" } },
+23 -4
View File
@@ -37,6 +37,12 @@ interface ClientManagedGate {
userId: string,
metadata: Record<string, unknown> | undefined,
) => Record<string, unknown>;
/**
* Which dispatches of the event the flag covers. Absent: all of them. The
* flag says the phone delivers this reminder itself, so it covers exactly
* what the phone replaces and nothing the phone does not send.
*/
covers?: (metadata: Record<string, unknown> | undefined) => boolean;
}
function str(value: unknown): string {
@@ -62,6 +68,13 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record<string, ClientManagedGate> = {
phase: str(metadata?.phase),
dose_at: str(metadata?.scheduledAt),
}),
// The iOS client reminds once, at dose time, and sends no follow-ups of
// its own (iOS #116, item 21). So the flag keeps only the reminder that
// announces the dose off APNs; the escalations for a dose that is still
// open (`followUp: true`, set by the reminder cron) are pushed as usual.
// A dispatch that does not say is treated as the announcement, the
// behaviour every reminder had before the cron started saying.
covers: (metadata) => metadata?.followUp !== true,
},
MEASUREMENT_REMINDER: {
isClientManaged: isMeasurementReminderClientManaged,
@@ -71,9 +84,15 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record<string, ClientManagedGate> = {
};
/**
* Does this event type have a client-managed opt-out at all? Cheap check
* the dispatcher runs before paying for the preferences read.
* Does a client-managed opt-out apply to this dispatch at all? Cheap check
* the dispatcher runs before paying for the preferences read: the event type
* has to have the opt-out, and the opt-out has to cover this dispatch.
*/
export function hasClientManagedApnsGate(eventType: string): boolean {
return eventType in CLIENT_MANAGED_APNS_EVENTS;
export function hasClientManagedApnsGate(
eventType: string,
metadata?: Record<string, unknown>,
): boolean {
const gate = CLIENT_MANAGED_APNS_EVENTS[eventType];
if (!gate) return false;
return gate.covers ? gate.covers(metadata) : true;
}
+4 -1
View File
@@ -326,7 +326,10 @@ export async function dispatchNotification(
if (
channel.type === "APNS" &&
!delivery.managed &&
hasClientManagedApnsGate(recipientPayload.eventType) &&
hasClientManagedApnsGate(
recipientPayload.eventType,
recipientPayload.metadata,
) &&
(await resolveClientManagedApns())
) {
const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType];
+10 -1
View File
@@ -248,7 +248,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"measurement.restore.invalid",
"measurement.update.server_owned_source",
],
medication: ["medication.intake.import.invalid_format"],
medication: [
"medication.intake.import.invalid_format",
"medication.intake.notTracked",
],
medications: [
"medications.category.invalid",
"medications.category.limitReached",
@@ -306,6 +309,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"onboarding.restart.rateLimited",
"onboarding.tour.invalid",
],
passkey: [
"passkey.challenge.expired",
"passkey.response.invalid",
"passkey.unknown",
"passkey.verification.failed",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
profile: [
+24
View File
@@ -501,6 +501,30 @@ const accountPayload = z
.looseObject({
id: z.string(),
username: z.string(),
// Enumerated because clients cut days and convert glucose by them: the
// day key and the unit must be the server's, not the device's.
timezone: z
.string()
.describe(
"The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the stored one is unusable. Resolved, never the raw column, so bucket day keys by this value.",
),
glucoseUnit: z
.enum(["mg/dL", "mmol/L"])
.nullable()
.describe(
"The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the stored value is neither unit), and the server then shows glucose in mg/dL.",
),
features: z
.object({
trackIntake: z
.literal(true)
.describe(
"Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.",
),
})
.describe(
"What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
),
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
// v1.39 — enumerated because every AI surface decides on it: a client
+13 -8
View File
@@ -47,6 +47,7 @@ import {
errorEnvelope,
loginPasswordSchema,
notImplementedResponse,
validationIssue,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
@@ -633,13 +634,7 @@ const authProfileUpdateResponse = z
"Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence.",
),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.array(validationIssue)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.",
@@ -1446,10 +1441,20 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"404": {
description:
"The assertion resolved to a user row that no longer exists.",
"`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without a code: the assertion resolved to a user row that no longer exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"401": {
description:
"The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion was checked and refused (signature, origin, relying-party id or challenge mismatch).",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) `credential` is not a WebAuthn authentication response.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+1 -1
View File
@@ -87,7 +87,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first.",
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
+1 -1
View File
@@ -1079,7 +1079,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way.",
'The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: every issue caused by one entry has a `path` that starts `entries.<n>` (the entry\'s index in the request, then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` is `""` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about the batch itself, and no split recovers it.',
content: { "application/json": { schema: errorEnvelope } },
},
},
+51 -8
View File
@@ -88,6 +88,7 @@ import {
medicationCadenceResponse,
medicationComplianceResponse,
medicationComplianceSummaryEntry,
medicationComplianceAggregate,
scheduleRevisionResource,
scheduleRevisionListResponse,
medicationExtractRequest,
@@ -212,7 +213,12 @@ const bulkIntakeEntryResult = z
.describe(
"`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.",
),
reason: z.string().optional(),
reason: z
.string()
.optional()
.describe(
"Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same answer.",
),
id: z.string().optional().describe("The landed row id (absent on skips)."),
})
.meta({ id: "BulkMedicationIntakeEntryResult" });
@@ -312,7 +318,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication. The latter two carry no issue list — branch on the code.",
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry no issue list — branch on the code.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": stdResponses["401"],
@@ -1437,6 +1443,11 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...stdResponses,
...recordWriteRateLimitResponse,
"422": {
description:
"Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after ownership, so a foreign medication is still a 404.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
get: {
@@ -1699,17 +1710,49 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Batched adherence read for every medication of the caller",
description:
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.",
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.\n\n" +
"v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, weighted by expected doses across the applicable medications; `null` when none applies), so a client never averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a 422 rather than a rounded window.",
requestParams: {
query: z.object({
days: z
.enum(["30", "60", "90", "180", "365"])
.optional()
.describe(
"Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`.",
),
}),
},
responses: {
...recordRefusal(),
"200": {
description: "One adherence row per medication.",
description:
"One adherence row per medication, and the account aggregate in `meta`.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(medicationComplianceSummaryEntry),
"ListMedicationComplianceResponse",
),
schema: z
.object({
data: z.array(medicationComplianceSummaryEntry),
error: z.null(),
meta: z
.object({
aggregate: medicationComplianceAggregate.nullable(),
days: z
.union([
z.literal(30),
z.literal(60),
z.literal(90),
z.literal(180),
z.literal(365),
])
.optional()
.describe("The `days` the request asked for."),
requestId: z.string().optional(),
})
.describe(
"Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate.",
),
})
.meta({ id: "ListMedicationComplianceResponse" }),
},
},
},
@@ -936,6 +936,18 @@ export const medicationComplianceSummaryEntry = z
compliance30: complianceResult.describe(
"Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
),
compliance60: complianceResult
.optional()
.describe("Present only on `?days=60`: the trailing 60 days."),
compliance90: complianceResult
.optional()
.describe("Present only on `?days=90`: the trailing 90 days."),
compliance180: complianceResult
.optional()
.describe("Present only on `?days=180`: the trailing 180 days."),
compliance365: complianceResult
.optional()
.describe("Present only on `?days=365`: the trailing 365 days."),
complianceDisplay: complianceDisplay.nullable(),
})
.meta({
@@ -1732,3 +1744,27 @@ export const medicationSideEffect = z
description:
"One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.",
});
/**
* v1.42.0 — the account-wide adherence figure the batched read carries in
* `meta.aggregate`, beside the per-medication rows.
*/
export const medicationComplianceAggregate = z
.object({
compliance7: complianceResult,
compliance30: complianceResult,
compliance60: complianceResult.nullable().optional(),
compliance90: complianceResult.nullable().optional(),
compliance180: complianceResult.nullable().optional(),
compliance365: complianceResult.nullable().optional(),
medicationCount: z
.number()
.int()
.positive()
.describe("How many applicable medications the figure is built from."),
})
.meta({
id: "MedicationComplianceAggregate",
description:
"One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a requested `days` is present when `days` was sent and is not 30.",
});
+19 -2
View File
@@ -955,7 +955,14 @@ const moodAggregatesResponse = z
days: z.number().int(),
})
.nullable()
.describe("Null for a sparse logger below the day floor."),
.describe(
"Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not.",
),
stabilityWindowDays: z
.union([z.literal(30), z.literal(90), z.literal(180), z.literal(365)])
.describe(
"The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year.",
),
tags: z
.array(
z.object({
@@ -1277,7 +1284,17 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Mood"],
summary: "Pre-computed mood aggregates (v1.8.5)",
description:
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach.",
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it.",
requestParams: {
query: z.object({
days: z
.enum(["30", "90", "180", "365"])
.optional()
.describe(
"Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422.",
),
}),
},
responses: {
"200": {
description: "The aggregate bundle.",
+4 -9
View File
@@ -56,6 +56,7 @@ import {
recordRefusal,
stdResponses,
updatedAtTokenField,
validationIssue,
} from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
@@ -259,7 +260,7 @@ const notificationPrefsResolved = z
clientManaged: z
.boolean()
.describe(
"True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is suppressed.",
"True when the iOS app delivers medication reminders locally. The server then keeps the reminder that announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was suppressed). Other channels are never affected.",
),
deliveryDefault: z
.enum(["server", "client"])
@@ -587,13 +588,7 @@ const profileUpdateResponse = z
.boolean()
.describe("Whether a KVNR is on file (the value itself is not echoed)."),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.array(validationIssue)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.",
@@ -1638,7 +1633,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Notifications"],
summary: "Update per-user notification preferences",
description:
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
requestBody: {
required: true,
content: {
+1 -1
View File
@@ -32,7 +32,7 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/
* built for could not read it from the contract. `params` never ships: some
* Zod codes embed the offending value in it and that is user content.
*/
const validationIssue = z
export const validationIssue = z
.object({
path: z
.string()