mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30 seconds, and each request scanned the medication's full intake history and expanded the recurrence calendar five to six times. The endpoint now mints bands once per request, serves the sub-windows from that single ledger, bounds the event read to a year of selected columns and sits behind a write-invalidated 15-minute server cache; the cards poll at five minutes. The dashboard drops two redundant checklist queries by reading counts from the snapshot, the BMI chart derives from the weight series instead of refetching it, the detail page no longer serialises behind the auth probe and seeds its query from the list row. Expensive analytics reads gain a shared per-user rate limit, and the service worker stops serving cross-version shells, enables navigation preload and trims caches in one pass.
This commit is contained in:
1 parent
41ba4c6c17
commit
298e4c34b4
24 files changed
+1483
-319
No files matched your search
@@ -20,8 +20,9 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
ANALYTICS_RANGES,
|
||||
@@ -40,6 +41,12 @@ const rangeQuerySchema = z.object({
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const parsed = rangeQuerySchema.safeParse({
|
||||
type: request.nextUrl.searchParams.get("type"),
|
||||
range: request.nextUrl.searchParams.get("range"),
|
||||
|
||||
@@ -50,6 +50,12 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.15.20 — the route checks the shared analytics-read budget before any
|
||||
// DB work; the real helper would hit the unmocked `$queryRaw`.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -84,6 +90,7 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -104,6 +111,12 @@ function makeReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// Default to assistant-on so the gate doesn't 403 every test.
|
||||
(
|
||||
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -23,6 +23,7 @@ import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
@@ -34,6 +35,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// v1.4.31 — comprehensive feeds the hero strip narration and the
|
||||
// recommendations grid that share the Coach gate.
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
@@ -17,6 +17,12 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.15.20 — the route checks the shared analytics-read budget before any
|
||||
// DB work; the real helper would hit the unmocked `$queryRaw`.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -35,6 +41,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -43,6 +50,12 @@ const SESSION_OK = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// v1.4.31 — default to an all-on flag set so existing assertions
|
||||
// ride through unchanged. Tests that need the gate-off path set
|
||||
// their own findUnique resolution.
|
||||
@@ -118,4 +131,20 @@ describe("GET /api/insights/correlations", () => {
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 429 when the shared analytics-read budget is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkAnalyticsReadRateLimit)).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
// The limited request never reaches the series reads.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@
|
||||
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
@@ -65,6 +66,13 @@ function toDailyMeans(
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Operator can hide the correlation surface entirely.
|
||||
await requireAssistantSurface("correlations");
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// v1.15.20 — the route checks the shared analytics-read budget before any
|
||||
// DB work; the real helper would hit the unmocked `$queryRaw`. This file
|
||||
// uses `clearAllMocks` (implementations survive), so the factory default
|
||||
// is enough.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: 0,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -50,6 +51,13 @@ const derivedQuerySchema = z.object({
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = derivedQuerySchema.safeParse({
|
||||
|
||||
@@ -33,6 +33,26 @@ 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.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: 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: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -51,6 +71,8 @@ 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 { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
@@ -106,6 +128,17 @@ function medication(schedules: unknown[]) {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
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(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
async function callRoute(): Promise<Record<string, unknown>> {
|
||||
@@ -353,3 +386,84 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
expect(display.longDays).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.15.20 — server cache + bounded event read + per-user rate limit.
|
||||
describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
function mockDailyMed() {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
}
|
||||
|
||||
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(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
|
||||
"medication-compliance:user-1",
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
// The limited request never touches the medication tables.
|
||||
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads through the per-user compliance cache key", async () => {
|
||||
mockDailyMed();
|
||||
|
||||
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];
|
||||
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
|
||||
expect(key).toBe("user-1|med-1|compliance");
|
||||
});
|
||||
|
||||
it("bounds the intake-event read to a trailing window and the consumed columns", async () => {
|
||||
mockDailyMed();
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const call = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
|
||||
.calls[0][0] as {
|
||||
where: { scheduledFor?: { gte?: Date } };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
// Lower-bounded: the widest served window is 365 days (+1 day margin).
|
||||
const gte = call.where.scheduledFor?.gte;
|
||||
expect(gte).toBeInstanceOf(Date);
|
||||
const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000;
|
||||
expect((gte as Date).getTime()).toBeGreaterThan(minExpected);
|
||||
// Column-bounded: only the four fields the math consumes.
|
||||
expect(call.select).toEqual({
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
autoMissed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,81 +3,37 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildComplianceDisplay,
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
buildMedicationComplianceBundle,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceDisplay,
|
||||
type ComplianceResult,
|
||||
type DailyComplianceEntry,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
type HistoryIntake,
|
||||
} from "@/lib/medications/scheduling/dose-history";
|
||||
import {
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
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";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
type MedicationScheduleRow = {
|
||||
id: string;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
timesOfDay: string[];
|
||||
daysOfWeek: string | null;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
reminderGraceMinutes: number | null;
|
||||
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
/** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */
|
||||
doseWindows: unknown;
|
||||
};
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the
|
||||
* band minter consumes. A legacy daily row carrying only `windowStart`
|
||||
* surfaces it as the single `timeOfDay` so the minter mints its daily band.
|
||||
* 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.
|
||||
*/
|
||||
function toCanonicalForBands(
|
||||
s: MedicationScheduleRow,
|
||||
oneShot: boolean,
|
||||
): CanonicalSchedule {
|
||||
const base: CanonicalSchedule = {
|
||||
id: s.id,
|
||||
rrule: s.rrule,
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
timesOfDay: s.timesOfDay,
|
||||
daysOfWeek: s.daysOfWeek,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
scheduleType: s.scheduleType,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
doseWindows: normaliseDoseWindows(s.doseWindows),
|
||||
};
|
||||
if (
|
||||
base.timesOfDay.length === 0 &&
|
||||
base.rrule === null &&
|
||||
base.rollingIntervalDays === null &&
|
||||
base.scheduleType !== "PRN" &&
|
||||
!oneShot
|
||||
) {
|
||||
return { ...base, timesOfDay: [base.windowStart] };
|
||||
}
|
||||
return base;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,11 +90,153 @@ function bucketLedgerRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.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.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-compliance:${user.id}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
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;
|
||||
@@ -152,167 +250,19 @@ export const GET = apiHandler(
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
|
||||
where: { medicationId: id, userId: user.id, deletedAt: null },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
|
||||
// miss, not a neutral skip.
|
||||
autoMissed: e.autoMissed,
|
||||
}));
|
||||
|
||||
const createdAt = medication.createdAt;
|
||||
|
||||
// v1.7.0 SB-SCHED-2 — thread the medication context so the
|
||||
// denominator routes through the canonical engine (RRULE / rolling /
|
||||
// one-shot / PRN / cyclic) instead of the legacy daysOfWeek walker.
|
||||
// `lastIntakeAt` is the latest non-skipped takenAt (rolling cadences
|
||||
// re-anchor on it); the events list is already ordered scheduledFor
|
||||
// desc, so scan for the max takenAt.
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
medication,
|
||||
lastIntakeAt,
|
||||
userTz,
|
||||
|
||||
// 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.
|
||||
const payload = await cached(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
`${user.id}|${id}|compliance`,
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation. The two `calculateCompliance` calls + the display windows
|
||||
// can otherwise straddle a day boundary on a slow request, so a dose
|
||||
// would count in one window and not the next within the same response.
|
||||
const now = new Date();
|
||||
|
||||
const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
|
||||
now,
|
||||
medicationContext,
|
||||
});
|
||||
const compliance30 = calculateCompliance(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
30,
|
||||
createdAt,
|
||||
{ now, medicationContext },
|
||||
);
|
||||
|
||||
// v1.8.6 — the two-row compliance display. The card always shows two
|
||||
// percentage rows; the server scales the two windows to the dosing
|
||||
// cadence (dense meds keep 7 / 30 days, sparse meds step both windows
|
||||
// up) and computes each row's rate over the chosen span. `compliance7`
|
||||
// / `compliance30` above are untouched — iOS + the Health Score read
|
||||
// them verbatim.
|
||||
const complianceDisplay = buildComplianceDisplay(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
{ now },
|
||||
);
|
||||
|
||||
// v1.15.18 — the heatmap / line-chart daily map is now 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 legacy
|
||||
// per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount
|
||||
// ForDay` walk are retired here in favour of band membership: a take inside
|
||||
// a slot's on-time band reads on-time, inside its late tail reads late,
|
||||
// outside every band reads ad-hoc; an unfilled slot past its miss cutoff
|
||||
// reads missed; a future slot is upcoming (not yet due).
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
|
||||
// Build the bands across the whole 90-day heatmap window once. The window
|
||||
// floor clamps to the medication's creation so pre-existence days never
|
||||
// mint phantom slots.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const windowFrom = new Date(
|
||||
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
|
||||
);
|
||||
const bandMedication: BandMinterMedication = {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
};
|
||||
const bandCtx: RecurrenceContext = {
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
timeZone: userTz,
|
||||
lastIntakeAt,
|
||||
};
|
||||
const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map(
|
||||
(s) => toCanonicalForBands(s, medication.oneShot),
|
||||
);
|
||||
const intakeInstants = mapped
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
const groups = buildBandsForSchedules({
|
||||
medication: bandMedication,
|
||||
schedules: canonicalSchedules,
|
||||
ctx: bandCtx,
|
||||
userTz,
|
||||
range: { from: windowFrom, to: now },
|
||||
now,
|
||||
intakeInstants,
|
||||
});
|
||||
const bands: SlotBand[] = [];
|
||||
for (const g of groups) {
|
||||
if (g.hasExpectedSlots) bands.push(...g.bands);
|
||||
}
|
||||
const historyIntakes: HistoryIntake[] = mapped
|
||||
.filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed,
|
||||
}));
|
||||
const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
|
||||
// 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.
|
||||
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;
|
||||
};
|
||||
|
||||
for (const row of ledgerRows) {
|
||||
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;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.compliance",
|
||||
@@ -320,18 +270,13 @@ export const GET = apiHandler(
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
compliance7: compliance7.rate,
|
||||
compliance30: compliance30.rate,
|
||||
complianceShortDays: complianceDisplay.shortDays,
|
||||
complianceLongDays: complianceDisplay.longDays,
|
||||
compliance7: payload.compliance7.rate,
|
||||
compliance30: payload.compliance30.rate,
|
||||
complianceShortDays: payload.complianceDisplay.shortDays,
|
||||
complianceLongDays: payload.complianceDisplay.longDays,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
compliance7,
|
||||
compliance30,
|
||||
dailyCompliance,
|
||||
complianceDisplay,
|
||||
});
|
||||
return apiSuccess(payload);
|
||||
},
|
||||
);
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
@@ -158,6 +159,13 @@ export const POST = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.15.20 — imported intakes change every cached medication read
|
||||
// (list, intake caches, compliance payload). This write path was the
|
||||
// one intake surface missing the flush.
|
||||
if (imported > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.import", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
Reference in new issue
Block a user