perf(medications): one compliance round trip and a memoised formatter hot path

Intl.DateTimeFormat instances are memoised per locale, timezone and
options signature — construction dominated the 365-day ledger build and
the hot path drops about fivefold. A batched GET /api/medications/compliance
serves every card from one request through the same per-medication cache
cells the detail page reads, replacing the per-card fan-out; the list
response stays untouched so the due line never waits on a ledger build.
The medications and compliance buckets serve stale-while-revalidate,
interactive writes evict while background bulk sync marks stale, the
per-id route defers ownership and schedule reads until a cache miss,
and the card body reserves constant slots for the status and cycle
lines so late data stops shifting the layout.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:17:17 +02:00
1 parent 23f7c74c04
commit b346e78cc7
36 files changed
+1453 -500

No files matched your search

@@ -29,16 +29,14 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
// v1.15.20 — the route reads through the per-user compliance cache and a
// per-user rate limit. Pass-through mocks keep each test's fixture
// isolated (the real module-scope cache would leak state across tests);
// the rate-limit tests below flip `allowed` explicitly.
// the rate-limit tests below flip `allowed` explicitly. v1.16.8 — the
// read is SWR (`cachedSwr`) and the ownership guard lives INSIDE the
// builder, so the pass-through exercises the guard on every call.
vi.mock("@/lib/cache/server-cache", () => ({
cached: vi.fn(
cachedSwr: vi.fn(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
),
@@ -71,7 +69,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cached } from "@/lib/cache/server-cache";
import { cachedSwr } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
@@ -130,7 +128,7 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// `resetAllMocks` clears the factory implementations — re-prime the
// pass-through cache and the allowing rate limit for every test.
vi.mocked(cached).mockImplementation(
vi.mocked(cachedSwr).mockImplementation(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
);
@@ -436,8 +434,8 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
const [, key] = vi.mocked(cached).mock.calls[0];
expect(vi.mocked(cachedSwr)).toHaveBeenCalledTimes(1);
const [, key] = vi.mocked(cachedSwr).mock.calls[0];
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
// The trailing tz segment (v1.16.1) makes a timezone change miss the
// old entry instead of serving day buckets computed for the prior zone.
@@ -471,3 +469,51 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
});
});
});
// v1.16.8 — the ownership guard + medication read moved INSIDE the cache
// builder: the key is `${userId}|${medicationId}|…`, so a warm hit is by
// construction a payload this caller built for a medication they own, and
// the pre-cache session→rate-limit path no longer pays two row reads per
// card. The guard itself still runs on every cold build.
describe("GET /api/medications/[id]/compliance — ownership inside the builder", () => {
it("returns the sealed 404 when the medication belongs to another user", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
...medication([]),
userId: "user-2",
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(404);
const body = await res.json();
// Same envelope as the absent case — the existence channel stays sealed.
expect(body.error).toBe("Medication not found");
});
it("returns the sealed 404 when the medication does not exist", async () => {
vi.mocked(prisma.medication.findUnique).mockResolvedValue(null as never);
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("Medication not found");
});
it("never touches the medication tables on a warm cache hit", async () => {
// Simulate a warm cell: the SWR read resolves without invoking the
// builder, so neither the medication nor the intake table is read.
vi.mocked(cachedSwr).mockResolvedValue({
compliance7: { rate: 100 },
compliance30: { rate: 100 },
dailyCompliance: {},
complianceDisplay: { shortDays: 7, longDays: 30 },
} as never);
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
expect(res.status).toBe(200);
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
});
+62 -244
View File
@@ -3,234 +3,41 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildComplianceMedicationContext,
buildMedicationComplianceBundle,
lastNonSkippedTakenAt,
type ComplianceDisplay,
type ComplianceResult,
type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import {
cachedSwr,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Lower bound for the intake-event read. The widest window any served
* block needs is the 365-day display rung; one extra day absorbs the
* inclusive-boundary edge so the ledger's own 365-day clamp never sits
* on a half-fetched day.
* Builder-internal sentinel for "no such medication for this caller".
* Thrown from inside the cache builder so a rejected build is never
* persisted; the handler maps it onto the canonical 404 envelope (the
* same "Medication not found" shape `assertMedicationOwnership` mints,
* so the existence channel stays sealed).
*/
const EVENT_FETCH_WINDOW_DAYS = 366;
/** The cached response body (the exact public wire shape). */
interface CompliancePayload {
compliance7: ComplianceResult;
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay;
}
/**
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
*
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
*/
function bucketLedgerRow(
entry: DailyComplianceEntry,
row: DoseHistoryRow,
): void {
switch (row.status) {
case "taken_on_time":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
case "taken_late":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.late++;
break;
case "missed":
entry.expected++;
entry.expectedCount++;
break;
case "skipped":
entry.expected++;
entry.expectedCount++;
entry.skipped++;
break;
case "upcoming":
// A future / still-takeable slot is due but not yet acted on. It counts
// toward the day's expected/due grid but not toward taken or missed.
entry.expected++;
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
class MedicationNotFoundError extends Error {
constructor() {
super("Medication not found");
}
}
/**
* The full compliance computation for one medication. Lifted out of the
* handler so the server cache can wrap it: ONE bounded intake-event read
* (only the four columns the math consumes, floored at the 366-day fetch
* window) feeds ONE shared band-expansion pass
* (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks,
* the cadence-scaled display rows AND the 90-day heatmap — replacing the
* historical five-plus per-request expansions over an unbounded,
* all-columns event read.
*/
async function buildCompliancePayload(
medication: {
id: string;
createdAt: Date;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
},
userId: string,
userTz: string,
): Promise<CompliancePayload> {
// v1.15.9 — pin a single `now` and thread it into every cadence
// computation so no block can straddle a day boundary on a slow request.
const now = new Date();
const createdAt = medication.createdAt;
const fetchFrom = new Date(
Math.max(
createdAt.getTime(),
now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
),
);
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
// Bounded to the 366-day fetch window: every served block is a suffix
// of the 365-day ledger window, so older rows can never change the
// response.
where: {
medicationId: medication.id,
userId,
deletedAt: null,
scheduledFor: { gte: fetchFrom },
},
orderBy: { scheduledFor: "desc" },
select: {
takenAt: true,
skipped: true,
scheduledFor: true,
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
// miss, not a neutral skip.
autoMissed: true,
// v1.15.20 — pinned takes bind by anchor in the unified ledger so the
// % agrees with the history view on a "zugeordnet" dose (taken-late).
attributionSource: true,
},
});
// v1.7.0 SB-SCHED-2 — thread the medication context so the denominator
// routes through the canonical engine (RRULE / rolling / one-shot / PRN /
// cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the
// fetch window (rolling cadences re-anchor on it).
const lastIntakeAt = lastNonSkippedTakenAt(events);
const medicationContext = buildComplianceMedicationContext(
medication,
lastIntakeAt,
userTz,
);
// ONE shared expansion pass for every served block (the v1.15.20
// performance fix): bands + ledger + timeline are minted once over the
// widest window and each sub-window is a tally over the same rows.
const bundle = buildMedicationComplianceBundle(
events,
medication.schedules,
medicationContext,
now,
);
// v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE
// unified dose-history ledger (the same bands the % + the history view
// read), so the per-day timing split (on-time / late) and the per-day
// missed marks can never disagree with the headline rate. The 90-day
// window is carved out of the shared ledger by `row.at`; the window
// floor clamps to the medication's creation so pre-existence days never
// mint phantom slots.
const heatmapFrom = new Date(
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
);
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
const byDay = new Map<string, DailyComplianceEntry>();
const ensureDay = (key: string): DailyComplianceEntry => {
let entry = byDay.get(key);
if (!entry) {
entry = {
expected: 0,
expectedCount: 0,
due: false,
taken: 0,
skipped: 0,
onTime: 0,
late: 0,
veryLate: 0,
early: 0,
};
byDay.set(key, entry);
}
return entry;
};
// Bucket each ledger row into its user-tz day. A slot row buckets on its
// anchor; an ad-hoc row on its real take time.
for (const row of bundle.ledgerRows) {
if (row.at.getTime() < heatmapFrom.getTime()) continue;
const key = userDayKey(row.at, userTz);
const entry = ensureDay(key);
bucketLedgerRow(entry, row);
}
for (const [key, entry] of byDay) {
entry.due = entry.expectedCount > 0;
dailyCompliance[key] = entry;
}
return {
compliance7: bundle.compliance7,
compliance30: bundle.compliance30,
dailyCompliance,
complianceDisplay: bundle.complianceDisplay,
};
}
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// The medications list fans this endpoint out once per card, so the
// per-user budget is generous — it only caps a runaway client loop.
// The per-user budget is generous — it only caps a runaway client
// loop (the cards read the batched `/api/medications/compliance`
// sibling; this endpoint serves the detail page).
const rl = await checkRateLimit(
`medication-compliance:${user.id}`,
60,
@@ -240,39 +47,50 @@ export const GET = apiHandler(
return apiError("Too many compliance requests. Please retry later.", 429);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const medication = await prisma.medication.findUnique({
where: { id },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
if (!medication) {
return apiError("Medication not found", 404);
}
const userTz = user.timezone || "Europe/Berlin";
// Read-through the per-user compliance cache (15 min TTL). Every
// intake / medication write flushes the `${userId}|` prefix via
// `invalidateUserMedications`, so a warm entry can only be stale
// relative to wall-clock drift, never to a user action. The key
// carries the timezone the payload was bucketed in: day keys, the
// heatmap, and the overdue cut all derive from `userTz`, so a
// timezone change must miss the old entry instead of serving day
// buckets computed for the previous zone until the TTL lapses.
const payload = await cached(
caches.medicationCompliance as ServerCache<CompliancePayload>,
`${user.id}|${id}|compliance|${userTz}`,
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
// Read-through the per-user compliance cache (15 min fresh TTL +
// SWR window). The ownership guard and the medication read live
// INSIDE the builder: the cache key is `${userId}|${medicationId}|…`,
// so a warm or stale hit is by construction a payload this caller
// already built for a medication they own — re-running the guard on
// every hit only re-paid two row reads per card. A cross-user id
// misses this user's prefix, runs the builder, and 404s there.
//
// Interactive intake / medication writes EVICT the `${userId}|`
// prefix via `invalidateUserMedications({ evict: true })`, so a warm
// entry can only be stale relative to wall-clock drift, never to the
// user's own action; background sync paths mark the bucket stale and
// the SWR read serves the prior payload while one coalesced rebuild
// warms a fresh one.
let payload: CompliancePayload;
try {
payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, id, userTz),
async () => {
const medication = await prisma.medication.findUnique({
where: { id },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
// Privacy gate — same sealed 404 for "absent" and "not yours".
if (!medication || medication.userId !== user.id) {
throw new MedicationNotFoundError();
}
return buildCompliancePayload(medication, user.id, userTz);
},
annotate,
);
} catch (err) {
if (err instanceof MedicationNotFoundError) {
return apiError("Medication not found", 404);
}
throw err;
}
annotate({
action: {
@@ -285,7 +285,7 @@ export const PUT = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the edited event.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the rollup row for both the old + new
// scheduledFor day-keys when the dose moved across a day boundary
@@ -364,7 +364,7 @@ export const DELETE = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the rollup row for the day the deleted
// event sat on. When that day now holds zero events the helper
@@ -180,7 +180,7 @@ export const POST = apiHandler(
meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size },
});
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ deleted: count });
},
@@ -163,7 +163,7 @@ export const POST = apiHandler(
// (list, intake caches, compliance payload). This write path was the
// one intake surface missing the flush.
if (imported > 0) {
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
}
await auditLog("medication.intake.import", {
@@ -89,7 +89,11 @@ describe("DELETE /api/medications/[id]/intake/purge", () => {
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
await DELETE(deleteReq(), ROUTE_PARAMS);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1");
// v1.16.8 — the purge is an interactive write, so it hard-evicts the
// SWR buckets instead of marking them stale.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns the 404 from the shared ownership helper without invalidating", async () => {
@@ -64,7 +64,7 @@ export const DELETE = apiHandler(
// post-purge counts rather than reading stale values for the
// TTL of each cache. Matches the sibling routes (POST intake,
// PUT medication, bulk-delete) that all fire this bundle.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ purged: true, count });
},
+2 -2
View File
@@ -467,7 +467,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the persistent compliance rollup for the
// affected day. The hook is best-effort; failures annotate but never
@@ -488,7 +488,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// actually receive its dose.
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
if (reconcileAction !== "noop") {
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
}
return apiSuccess(event, 201);
+2 -2
View File
@@ -618,7 +618,7 @@ export const PUT = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the schedule change.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({
...medication,
@@ -675,7 +675,7 @@ export const DELETE = apiHandler(
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ deleted: true });
},
@@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
f(),
),
caches: { medicationsList: {} },
__resetAllCachesForTests: vi.fn(),
}));
@@ -0,0 +1,196 @@
/**
* v1.16.8 — batched card-compliance read (`GET /api/medications/compliance`).
*
* One round trip returns a compact adherence row per medication the
* caller owns, replacing the per-card fan-out over the per-id endpoint.
* The rows build through the SAME per-medication cache cells
* (`${userId}|${medicationId}|compliance|${userTz}`) the per-id route
* reads, so the two endpoints warm each other and invalidate together.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/cache/server-cache", () => ({
cachedSwr: vi.fn(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
),
caches: { medicationCompliance: {} },
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
timezone: TZ,
},
};
function dailySchedule(id: string) {
return {
id,
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
function medication(id: string) {
return {
id,
userId: "user-1",
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [dailySchedule(`${id}-sched`)],
scheduleRevisions: [],
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(cachedSwr).mockImplementation(
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
builder(),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/medications/compliance", () => {
it("returns one compact row per medication, without the heatmap grid", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
medication("med-2"),
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
const body = await res.json();
const items = body.data as Array<Record<string, unknown>>;
expect(items).toHaveLength(2);
expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]);
for (const item of items) {
expect(item.compliance7).toBeDefined();
expect(item.compliance30).toBeDefined();
expect(item.complianceDisplay).toBeDefined();
// The heavy per-day grid stays on the per-id endpoint.
expect(item).not.toHaveProperty("dailyCompliance");
}
});
it("reads each medication through the shared per-medication cache cell", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
medication("med-2"),
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
// Same key shape as the per-id route, so the two endpoints warm each
// other and `invalidateUserMedications` sweeps both with one prefix.
const keys = vi.mocked(cachedSwr).mock.calls.map(([, key]) => key);
expect(keys).toEqual([
"user-1|med-1|compliance|UTC",
"user-1|med-2|compliance|UTC",
]);
});
it("scopes the medication read to the calling user", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual([]);
const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as {
where: { userId: string };
};
expect(call.where.userId).toBe("user-1");
});
it("returns 429 when the per-user rate limit is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
expect(res.status).toBe(429);
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
"medication-compliance-summary:user-1",
30,
60_000,
);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import {
cachedSwr,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
/**
* Batched card-compliance read: one round trip for every medication the
* caller owns, replacing the per-card fan-out over
* `GET /api/medications/{id}/compliance` (one request per card, ~1 s
* cold each). The per-medication payload still builds and caches through
* the SAME `medicationCompliance` cells the per-id route reads, so this
* read warms the detail page (and vice versa) and both invalidate
* together on a write.
*
* The heavy `dailyCompliance` heatmap map is intentionally NOT on this
* wire shape — the cards render rates / streak / display block only; the
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`medication-compliance-summary:${user.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many compliance requests. Please retry later.", 429);
}
const userTz = user.timezone || "Europe/Berlin";
// Same ordering as the medications list so the page's card order and
// this payload walk the same sequence.
const medications = await prisma.medication.findMany({
where: { userId: user.id },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
orderBy: { createdAt: "desc" },
});
// Sequential on purpose: each cold cell costs one bounded intake read +
// one band-expansion pass, and a sequential walk keeps a many-meds
// account from stampeding the pool. Warm / stale cells return without
// touching the database at all.
const items = [];
for (const medication of medications) {
const payload = await cachedSwr(
caches.medicationCompliance as ServerCache<CompliancePayload>,
complianceCacheKey(user.id, medication.id, userTz),
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
items.push({
medicationId: medication.id,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
});
}
annotate({
action: { name: "medication.compliance_summary.read" },
meta: { count: items.length },
});
return apiSuccess(items);
});
+5 -1
View File
@@ -569,7 +569,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
// ingested batch. v1.16.8 — this is the background sync path (iOS bulk
// ingest), so the SWR buckets are marked stale rather than hard-evicted:
// the readers keep serving the prior list / compliance payloads while
// one coalesced recompute warms each cell, instead of a high-frequency
// sync busting every card into an inline cold rebuild.
if (inserted > 0 || updated > 0) {
invalidateUserMedications(user.id);
}
+1 -1
View File
@@ -599,7 +599,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.34 IW-G — bust the medications + compliance + achievement
// caches for this user so the next read reflects the dose change.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
// v1.4.39 W-MED — refresh the persistent compliance rollup row for
// the affected day so the next read after the cache miss returns
+12 -7
View File
@@ -21,7 +21,7 @@ import {
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { NextRequest } from "next/server";
type MedicationsListResult = Array<Record<string, unknown>>;
@@ -187,11 +187,16 @@ export const GET = apiHandler(async () => {
const userTz = user.timezone || "Europe/Berlin";
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
// staleness window for `todayEventCount` (the only time-of-day-derived
// field in the response); writes via POST/PUT/DELETE flush through
// `invalidateUserMedications` before the next read lands.
const result = await cached(
// Cache the list shape on userId. The 60 s fresh TTL bounds the
// cross-midnight staleness window for `todayEventCount` and the
// next-due fields; v1.16.8 reads through `cachedSwr`, so a visit
// minutes after the last one serves the prior list immediately while
// one background recompute warms a fresh entry (the 10-minute stale
// window bounds how old that served list can be). Interactive writes
// via POST/PUT/DELETE hard-evict through
// `invalidateUserMedications({ evict: true })` before the next read
// lands; background sync paths mark the bucket stale instead.
const result = await cachedSwr(
caches.medications as ServerCache<MedicationsListResult>,
user.id,
() => buildMedicationsList(user.id, userTz),
@@ -397,7 +402,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the new schedule.
invalidateUserMedications(user.id);
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(
{