mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #797 from MBombeck/release/v1.37.21
release/v1.37.21
This commit is contained in:
8 files changed
+150
-3
No files matched your search
@@ -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,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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user