Merge pull request #797 from MBombeck/release/v1.37.21

release/v1.37.21
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-14 19:17:28 +02:00
commit d2491d5589
8 files changed
+150 -3

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.37.21] — 2026-08-14
### Fixed
- A dose that reaches the server through the batch route, the path a phone's offline queue drains through, now quiets an active snooze the same way a directly logged dose always has. Before, a take synced in later kept counting as snoozed and the reminder kept ringing.
- Marking a dose through the single-medication intake route now also closes the still-pending dose-due reminder in the web app and refreshes its badge. The other intake paths have done this since v1.18.4; this one, which is exactly the route a phone's offline replay uses, left the web reminder standing.
## [1.37.20] — 2026-08-14
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.37.20
version: 1.37.21
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.20",
"version": "1.37.21",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.37.20";
/* @sw-version-fallback */ "v1.37.21";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -82,6 +82,15 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
// The route dispatches the PWA clear fire-and-forget after a recorded dose;
// both halves are mocked so the test asserts the dispatch, not web-push.
vi.mock("@/lib/notifications/web-push-clear", () => ({
dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/outstanding-doses", () => ({
countOutstandingDosesToday: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -110,6 +119,8 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveBearerToken } from "@/lib/auth/bearer";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
const USER = {
id: "user-1",
@@ -191,6 +202,32 @@ describe("POST intake — transport-derived source (iOS #64)", () => {
expect(createdSource()).toBe("API");
});
it("closes the pending dose-due web reminder after a recorded intake", async () => {
// Before this route dispatched the clear, an iOS offline drain (which
// replays free intakes through exactly this endpoint) left the web
// dose-due reminder and the app badge standing after the dose landed.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(countOutstandingDosesToday).mockResolvedValue(3);
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
// Fire-and-forget: the dispatch runs detached from the response.
await expect
.poll(
() => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length,
{ timeout: 2_000, interval: 25 },
)
.toBe(1);
expect(
vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0],
).toMatchObject({
userId: "user-1",
medicationId: "med-1",
badgeCount: 3,
});
});
it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -29,6 +29,8 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
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 {
@@ -616,6 +618,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
originDeviceToken: request.headers.get("x-device-id"),
});
// PWA counterpart of the sync wake above: a dose resolved here (taken or
// skipped) closes the still-pending dose-due Web Push reminder for the
// slot and refreshes the app badge. The other intake routes have carried
// this since v1.18.4; this route is the replay target of the iOS offline
// queue, so a drained dose must clear the web reminder the same way.
// Best-effort, fire-and-forget — the canonical row is already persisted.
void (async () => {
const badgeCount = await countOutstandingDosesToday(user.id, user.timezone);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor.toISOString(),
badgeCount,
});
})();
// v1.36.x — "somebody else marked your dose". This route has no snooze arm,
// so the state is whichever of the two markings the payload carried. The
// helper refuses on self, so a person marking their own dose is unaffected.
@@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
// v1.8.2 — the slot resolver loads the med via findFirst.
findFirst: vi.fn(),
// A landed non-skipped dose clears the medication's snooze.
updateMany: vi.fn(),
},
medicationIntakeEvent: {
create: vi.fn(),
@@ -324,6 +326,74 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
});
});
it("a landed taken write clears the medication's active snooze", async () => {
// The single-intake routes null `snoozedUntil` on every recorded take;
// before this parity fix a dose drained through the bulk route (the iOS
// offline-queue path) left the snooze standing and the reminder ringing.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-pending",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
takenAt: "2026-06-15T05:02:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
expect(prisma.medication.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["med-1"] }, userId: "user-1" },
data: { snoozedUntil: null },
});
});
it("a landed skip leaves the snooze untouched", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-pending",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
skipped: true,
},
],
}),
);
expect(res.status).toBe(200);
expect(prisma.medication.updateMany).not.toHaveBeenCalled();
});
it("queues exactly ONE intake-sync fan-out for a multi-entry batch", async () => {
// Two pending rows at the day's two slots; the batch resolves both.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
@@ -351,6 +351,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
string,
{ medicationId: string; scheduledFor: string }
>();
// A landed non-skipped dose quiets an active snooze, exactly like the
// single-intake routes (they null `snoozedUntil` on every recorded take).
// Collected per medication here, cleared once after the loop.
const snoozeClearIds = new Set<string>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -786,6 +790,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
medicationId: entry.medicationId,
scheduledFor: scheduledForIso,
});
if (!entry.skipped) snoozeClearIds.add(entry.medicationId);
}
} catch (err: unknown) {
// P2002 = unique-constraint violation. Two shapes reach here:
@@ -909,6 +914,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
// device (`X-Device-Id` = registered `Device.token`). APNs-only,
// best-effort, fire-and-forget: the canonical rows are already
// persisted, so a sync-push miss never affects the batch response.
// The single-intake routes reset a medication's snooze on every recorded
// take; a drained offline batch must quiet the snooze the same way, or a
// dose synced in later keeps counting as snoozed although it was taken.
if (snoozeClearIds.size > 0) {
await prisma.medication.updateMany({
where: { id: { in: [...snoozeClearIds] }, userId: user.id },
data: { snoozedUntil: null },
});
}
if (syncSlots.size > 0) {
queueMedicationIntakeSync({
userId: user.id,