mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
7bcb6f71d9
commit
bb563b6506
50 files changed
+1789
-142
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user