mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user