mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge branch 'fix/v1438-db' into release/v1.43.8
This commit is contained in:
commit
1d9758e3e8
91 files changed
+2107
-2058
No files matched your search
@@ -23,14 +23,6 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.37.19 — the zone change enqueues a compliance re-fold.
|
||||
const refoldMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ enqueued: true, error: null })),
|
||||
);
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
enqueueUserMedicationComplianceBackfill: refoldMock,
|
||||
}));
|
||||
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
|
||||
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
|
||||
@@ -110,11 +102,9 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
// Watched red: with the re-fold enqueue removed from the route (the
|
||||
// pre-v1.37.19 write), the changed-zone case fails — the compliance
|
||||
// rollup's day keys were minted under the old zone and nothing ever
|
||||
// re-bucketed them (the coverage probe compares counts, not keys).
|
||||
it("enqueues a compliance re-fold when the zone actually changes", async () => {
|
||||
// The supplement nutrient rows' day keys were minted under the old zone;
|
||||
// without the enqueue nothing re-buckets them.
|
||||
it("enqueues the nutrient recompute when the zone actually changes", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -123,11 +113,10 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "America/New_York" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).toHaveBeenCalledWith("user-1");
|
||||
expect(nutrientMock).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not re-fold when the zone is unchanged", async () => {
|
||||
it("does not recompute when the zone is unchanged", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -136,7 +125,6 @@ describe("PUT /api/auth/me/timezone", () => {
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(refoldMock).not.toHaveBeenCalled();
|
||||
expect(nutrientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -62,24 +61,13 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
invalidateUserTimezone(user.id);
|
||||
|
||||
// v1.37.19 — the medication-compliance rollup keys its rows by
|
||||
// the LOCAL day the dose fell on, minted under the timezone in force at
|
||||
// write time. A zone change re-buckets those days, so the stored keys go
|
||||
// stale (and the coverage probe compares counts, not keys — it cannot
|
||||
// notice). Re-fold the trailing window through the ordinary backfill
|
||||
// job; best-effort, the singleton key collapses rapid re-picks.
|
||||
// The supplement nutrient rows are keyed by the LOCAL day the dose fell
|
||||
// on, minted under the timezone in force at write time. A zone change
|
||||
// re-buckets those days, so the account's rows are recomputed through
|
||||
// the ordinary job; best-effort, the singleton key collapses rapid
|
||||
// re-picks.
|
||||
if ((before?.timezone ?? null) !== tz) {
|
||||
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
// The supplement nutrient rows are keyed by the same local day.
|
||||
await enqueueSupplementNutrientRecompute(user.id);
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_compliance_refold_enqueued: refold.enqueued,
|
||||
...(refold.error
|
||||
? { timezone_compliance_refold_error: refold.error }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("user.timezone.update", {
|
||||
|
||||
@@ -38,8 +38,8 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
@@ -337,7 +337,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.4.39 — refresh the compliance rollup for the ingested
|
||||
// event's user-day.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: apiToken.userId,
|
||||
medicationId: medication.id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
|
||||
@@ -51,9 +51,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
// v1.16.1 — the schedule-replace branch recomputes the compliance
|
||||
// rollups of the tombstoned slots; stub the rollup module so the route
|
||||
// test never reaches `$executeRaw`.
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// v1.40 (#1024) — the course writer and reader run against their own
|
||||
// integration suite; here they answer "no course, nothing refused".
|
||||
@@ -99,8 +99,8 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -297,7 +297,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
PREVIOUS_ROW,
|
||||
] as never);
|
||||
@@ -447,19 +447,19 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"m1",
|
||||
"2026-06-10",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"m1",
|
||||
"2026-06-11",
|
||||
"Europe/Berlin",
|
||||
);
|
||||
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
|
||||
expect(refreshDoseDay).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
dayKey: "2026-06-10",
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(refreshDoseDay).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
dayKey: "2026-06-11",
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT touch intake rows when the body carries no schedules array", async () => {
|
||||
@@ -865,7 +865,7 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
LIVE_ROW,
|
||||
] as never);
|
||||
@@ -1055,7 +1055,7 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(refreshDoseDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
STORED_ROW,
|
||||
] as never);
|
||||
|
||||
@@ -37,8 +37,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
@@ -392,14 +392,14 @@ export const PUT = apiHandler(
|
||||
// scheduledFor day-keys when the dose moved across a day boundary
|
||||
// (band re-attribution can move it as well as an explicit edit); if the
|
||||
// slot is unchanged the second call collapses to a no-op upsert.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
tz: user.timezone,
|
||||
});
|
||||
if (slotMoved) {
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: targetScheduledFor,
|
||||
@@ -488,7 +488,7 @@ export const DELETE = apiHandler(
|
||||
// v1.4.39 — refresh the rollup row for the day the deleted
|
||||
// event sat on. When that day now holds zero events the helper
|
||||
// drops the rollup row entirely.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
|
||||
@@ -78,8 +78,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -86,8 +86,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
||||
|
||||
@@ -46,12 +46,12 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
dayKeyForScheduledFor: vi.fn((d: Date) => {
|
||||
const iso = d.toISOString();
|
||||
return iso.slice(0, 10);
|
||||
}),
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
@@ -98,8 +98,8 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -174,7 +174,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
|
||||
const json = await res.json();
|
||||
expect(json.data).toEqual({ deleted: 0 });
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled();
|
||||
expect(refreshDoseDay).not.toHaveBeenCalled();
|
||||
// (#22) — a no-op delete queues no cross-device sync fan-out.
|
||||
expect(queueMedicationIntakeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -201,7 +201,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => {
|
||||
|
||||
expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3);
|
||||
// Three rows, two unique dayKeys → two recompute calls.
|
||||
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
|
||||
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
|
||||
|
||||
// (#22) — the whole bulk delete queues exactly ONE cross-device
|
||||
// sync fan-out, not one per removed row.
|
||||
|
||||
@@ -15,8 +15,8 @@ import { restoreForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -159,26 +159,11 @@ export const POST = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort rollup refresh — one trip per unique dayKey so a
|
||||
// 14-row delete spanning two days closes in two SQL trips. The
|
||||
// helper is idempotent and the catch swallows transient
|
||||
// populator failures (same contract as the single-event delete).
|
||||
// Best-effort dose-day refresh, one per unique dayKey, so a 14-row
|
||||
// delete spanning two days refreshes two days (same contract as the
|
||||
// single-event delete).
|
||||
for (const dayKey of dayKeysToRefresh) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "medications.intake.bulk-delete.recompute-failed",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
day_key: dayKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
await refreshDoseDay({ userId: user.id, medicationId: id, dayKey, tz });
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.bulk_delete", {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* v1.5.5 — purge route fires the medication cache bundle.
|
||||
*
|
||||
* Pins the contract the detail-page Verlauf-löschen cascade depends
|
||||
* on: dropping the rollup rows is necessary but not sufficient. The
|
||||
* on: tombstoning the rows is necessary but not sufficient. The
|
||||
* analytics + iOS today-tally + dashboard tiles all read off cached
|
||||
* shapes that survive the rollup delete for their TTL; the bundle
|
||||
* shapes that survive the tombstoning for their TTL; the bundle
|
||||
* invalidation forces every downstream reader to converge on the
|
||||
* post-purge counts in the same tick the user sees the toast.
|
||||
*/
|
||||
@@ -16,7 +16,6 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { updateMany: vi.fn() },
|
||||
medicationComplianceRollup: { deleteMany: vi.fn() },
|
||||
// v1.43 — no composition, so no supplement recompute is queued.
|
||||
medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
@@ -87,9 +86,6 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 7,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({
|
||||
count: 5,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/[id]/intake/purge", () => {
|
||||
|
||||
@@ -17,8 +17,8 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
// v1.37.0 — MANAGE. It tombstones rather than deletes (since the
|
||||
// delegated-write release) and drops only rollups that recompute, so the
|
||||
// history it clears is reconstructable from the rows themselves.
|
||||
// delegated-write release), so the history it clears is reconstructable
|
||||
// from the rows themselves.
|
||||
const { user } = await requireRecordAuth("manage", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
@@ -60,13 +60,6 @@ export const DELETE = apiHandler(
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
// v1.4.39 — drop every rollup row for this medication so the
|
||||
// next compliance read returns zero-filled buckets rather than the
|
||||
// last-known scheduled / taken totals for now-deleted events.
|
||||
await prisma.medicationComplianceRollup.deleteMany({
|
||||
where: { medicationId: id, userId: user.id },
|
||||
});
|
||||
|
||||
// v1.43 — the purged doses no longer count toward the micronutrients.
|
||||
if (await medicationHasComposition(user.id, id)) {
|
||||
await enqueueSupplementNutrientRecompute(user.id);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
findPinConflict,
|
||||
@@ -608,7 +608,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// v1.4.39 — refresh the persistent compliance rollup for the
|
||||
// affected day. The hook is best-effort; failures annotate but never
|
||||
// block the user's POST response.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: event.scheduledFor,
|
||||
|
||||
@@ -48,8 +48,8 @@ import {
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
recomputeMedicationComplianceForDay,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
effectiveUnitsPerDose,
|
||||
@@ -716,9 +716,9 @@ export const PUT = apiHandler(
|
||||
// Tombstone today's and future open pending rows of this medication.
|
||||
// Runs on a schedule replace (the old anchors are stale) and when intake
|
||||
// tracking is switched off (nothing is due any more).
|
||||
// The rollup days the tombstoned slots sat on, recomputed once the
|
||||
// The dose days the tombstoned slots sat on, refreshed once the
|
||||
// transaction below has committed the tombstones.
|
||||
const staleRollupDays = new Set<string>();
|
||||
const staleDoseDays = new Set<string>();
|
||||
const tombstoneOpenSlots = async (
|
||||
db: Prisma.TransactionClient,
|
||||
): Promise<void> => {
|
||||
@@ -768,7 +768,7 @@ export const PUT = apiHandler(
|
||||
});
|
||||
// Deduped via the user-tz day key.
|
||||
for (const row of tombstoned) {
|
||||
staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
|
||||
staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1085,26 +1085,16 @@ export const PUT = apiHandler(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Recompute each rollup day the tombstoned slots sat on, now that the
|
||||
// tombstones are committed. Best-effort like every other rollup
|
||||
// write-hook: a failure is annotated, never fails the edit.
|
||||
for (const dayKey of staleRollupDays) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
user.id,
|
||||
id,
|
||||
dayKey,
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
);
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_failed: true,
|
||||
medication_compliance_rollup_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Refresh each dose day the tombstoned slots sat on, now that the
|
||||
// tombstones are committed. Best-effort like every dose write-hook: a
|
||||
// failure is reported, never fails the edit.
|
||||
for (const dayKey of staleDoseDays) {
|
||||
await refreshDoseDay({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
dayKey,
|
||||
tz: user.timezone || DEFAULT_TIMEZONE,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the v1.5.5 primary-schedule grace bridge after the
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
toRevisionPayloadEntry,
|
||||
type ScheduleRevisionEntry,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -286,13 +285,10 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// The corrected era re-segments history; refresh the pre-aggregated
|
||||
// compliance rollups asynchronously (best-effort).
|
||||
// v1.16.9 — an era write re-segments the bands every cached payload
|
||||
// (list next-due, compliance cells, dashboard tally) was built on;
|
||||
// hard-evict so the next read reflects the new history immediately.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
id: outcome.revision.id,
|
||||
@@ -404,13 +400,10 @@ export const DELETE = apiHandler(
|
||||
meta: { revision_id: revisionId },
|
||||
});
|
||||
|
||||
// History re-segments without the era; refresh the pre-aggregated
|
||||
// compliance rollups asynchronously (best-effort).
|
||||
// v1.16.9 — an era write re-segments the bands every cached payload
|
||||
// (list next-due, compliance cells, dashboard tally) was built on;
|
||||
// hard-evict so the next read reflects the new history immediately.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
|
||||
@@ -42,11 +42,6 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
enqueueUserMedicationComplianceBackfill: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ enqueued: true, error: null }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -64,7 +59,6 @@ import { GET, POST } from "../route";
|
||||
import { DELETE, PATCH } from "../[revisionId]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -349,11 +343,6 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
|
||||
expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
|
||||
expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED");
|
||||
|
||||
// History re-segmented — the rollup backfill must be enqueued.
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
|
||||
// Check-then-insert ran inside the transaction with the
|
||||
// per-medication `FOR UPDATE` lock taken first.
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
@@ -432,9 +421,6 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
where: { medicationId: "m1", supersededByRevisionId: "rev-m" },
|
||||
data: { supersededByRevisionId: null },
|
||||
});
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -533,9 +519,6 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
|
||||
// In-place — no superseding row is minted.
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
// Check-then-write ran inside the locked transaction.
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
@@ -573,8 +556,5 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
|
||||
where: { id: "rev-t" },
|
||||
data: { supersededByRevisionId: "rev-fix" },
|
||||
});
|
||||
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
toRevisionPayloadEntry,
|
||||
type ScheduleRevisionEntry,
|
||||
} from "@/lib/medications/scheduling/schedule-eras";
|
||||
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -282,14 +281,10 @@ export const POST = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// The new era re-segments history the compliance rollups already
|
||||
// pre-aggregated; refresh them asynchronously. Best-effort like
|
||||
// every other rollup write-hook.
|
||||
// v1.16.9 — an era write re-segments the bands every cached payload
|
||||
// (list next-due, compliance cells, dashboard tally) was built on;
|
||||
// hard-evict so the next read reflects the new history immediately.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
await enqueueUserMedicationComplianceBackfill(user.id);
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
|
||||
@@ -34,12 +34,6 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.15.18 — the band resolver loads the medication via findFirst.
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
medicationComplianceRollup: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
// v1.4.43 — audit-ledger breadcrumb for validation-failed paths.
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
@@ -119,15 +113,6 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
// v1.4.39 — default the coverage probe to "uncovered" so the
|
||||
// legacy compliance test still exercises the live-fallback branch
|
||||
// and finds the legacy mocked intake events.
|
||||
vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
// v1.4.39 — the coverage probe is now a single `$queryRaw`
|
||||
// aggregate returning `{ rolled_days, event_days }`. Default to
|
||||
// "zero rollups, zero events" (covered/trivial-empty) so tests that
|
||||
|
||||
@@ -51,8 +51,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
|
||||
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
@@ -65,9 +65,9 @@ import {
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
refreshDoseDay,
|
||||
} from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
findPinConflict,
|
||||
@@ -365,10 +365,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
let extraDosesUnscheduled = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
// v1.4.39 — collect distinct `(medicationId, dayKey)` pairs
|
||||
// touched by the batch so one rollup recompute fires per pair after
|
||||
// all inserts complete. Per-row recompute would balloon a 500-entry
|
||||
// batch into 500 sequential rollup hits.
|
||||
// Distinct `(medicationId, dayKey)` pairs touched by the batch, so one
|
||||
// dose-day refresh fires per pair after all inserts complete. A per-row
|
||||
// refresh would balloon a 500-entry batch into 500 sequential refreshes.
|
||||
const touchedDays = new Map<
|
||||
string,
|
||||
{ medicationId: string; dayKey: string }
|
||||
@@ -949,30 +948,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
// v1.4.39 — refresh one rollup row per distinct
|
||||
// `(medicationId, dayKey)` touched by the batch. Best-effort: failures
|
||||
// are swallowed by the helper's `recomputeMedicationComplianceForEvent`
|
||||
// wrapper, but bulk callers wrap recompute directly to keep the
|
||||
// tight ingest loop unaffected by populator issues.
|
||||
// Refresh the derived rows of each distinct `(medicationId, dayKey)` the
|
||||
// batch touched. Best-effort: the refresh reports its own failures and
|
||||
// never fails the ingest.
|
||||
for (const { medicationId, dayKey } of touchedDays.values()) {
|
||||
try {
|
||||
await recomputeMedicationComplianceForDay(
|
||||
user.id,
|
||||
medicationId,
|
||||
dayKey,
|
||||
user.timezone,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
annotate({
|
||||
meta: {
|
||||
medication_compliance_rollup_bulk_failed: true,
|
||||
medication_compliance_rollup_bulk_error: message,
|
||||
medication_compliance_rollup_medication: medicationId,
|
||||
medication_compliance_rollup_day: dayKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
await refreshDoseDay({
|
||||
userId: user.id,
|
||||
medicationId,
|
||||
dayKey,
|
||||
tz: user.timezone,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out
|
||||
|
||||
@@ -28,7 +28,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh";
|
||||
import {
|
||||
buildScheduleAnchoredComplianceBuckets,
|
||||
type ScheduleAnchoredComplianceBucket,
|
||||
@@ -573,7 +573,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// the affected day so the next read after the cache miss returns
|
||||
// the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort:
|
||||
// a populator failure annotates ops without blocking the response.
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: existing.scheduledFor,
|
||||
@@ -584,7 +584,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// source day self-corrects while the destination keeps its stale tuple.
|
||||
const movedTo = updated?.scheduledFor;
|
||||
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
|
||||
await recomputeMedicationComplianceForEvent({
|
||||
await refreshDoseSlot({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: movedTo,
|
||||
|
||||
@@ -93,8 +93,8 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("@/lib/medications/dose-day-refresh", () => ({
|
||||
refreshDoseSlot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
|
||||
Reference in new issue
Block a user