mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge: measurement-reminder completion route
Add an idempotent, owner-scoped completion endpoint for measurement reminders that records satisfaction without firing a notification.
This commit is contained in:
commit
39505a5d35
5 files changed
+321
No files matched your search
@@ -5866,6 +5866,33 @@ paths:
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurement-reminders/{id}/complete:
|
||||
post:
|
||||
tags:
|
||||
- MeasurementReminders
|
||||
summary: Complete a Vorsorge reminder (v1.18.6)
|
||||
description: "Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only
|
||||
dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps
|
||||
lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed
|
||||
/ auto-satisfied reminder returns 200 with completed=false. Owner-scoped."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the
|
||||
canonical post-completion DTO.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderCompleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs:
|
||||
get:
|
||||
tags:
|
||||
@@ -18498,6 +18525,37 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementReminderCompleteEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementReminderCompletion"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementReminderCompletion:
|
||||
type: object
|
||||
properties:
|
||||
completed:
|
||||
type: boolean
|
||||
reminder:
|
||||
$ref: "#/components/schemas/MeasurementReminderDTO"
|
||||
required:
|
||||
- completed
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
description: Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt;
|
||||
completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle
|
||||
(idempotent no-op). reminder is the canonical post-completion DTO.
|
||||
ListLabResultsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v1.18.6 — explicit-completion route (iOS #23 follow-up).
|
||||
*
|
||||
* Covers: marks done via the shared primitive, idempotent no-op surfaces
|
||||
* completed=false, owner-scoped 404 on a cross-user / tombstoned id, and the
|
||||
* structural no-double-notify guarantee (the route delegates to
|
||||
* `satisfyReminder` and never reaches the notification dispatcher).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const satisfyReminderMock = vi.fn();
|
||||
vi.mock("@/lib/measurement-reminders/satisfy", () => ({
|
||||
satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args),
|
||||
}));
|
||||
|
||||
const findFirstMock = vi.fn();
|
||||
const findUserMock = vi.fn();
|
||||
const findUniqueOrThrowMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementReminder: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
|
||||
},
|
||||
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const ROW = {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
label: "Blutdruck messen",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-25T07:00:00Z"),
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-18T00:00:00Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
const params = { params: Promise.resolve({ id: "r1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
});
|
||||
|
||||
describe("POST /api/measurement-reminders/[id]/complete", () => {
|
||||
it("marks the reminder done via the shared primitive and returns completed=true", async () => {
|
||||
findFirstMock.mockResolvedValue(ROW);
|
||||
satisfyReminderMock.mockResolvedValue({
|
||||
satisfied: true,
|
||||
nextDueAt: new Date("2026-06-25T07:00:00Z"),
|
||||
});
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.completed).toBe(true);
|
||||
expect(body.data.reminder.id).toBe("r1");
|
||||
expect(body.data.reminder.lastSatisfiedAt).toBe("2026-06-18T08:00:00.000Z");
|
||||
// The route delegates to the ONE shared satisfaction primitive.
|
||||
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is idempotent: an already-satisfied reminder is a 200 no-op with completed=false", async () => {
|
||||
findFirstMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
// Forward-only guard inside the primitive returns satisfied=false.
|
||||
satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null });
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.completed).toBe(false);
|
||||
expect(body.data.reminder.id).toBe("r1");
|
||||
});
|
||||
|
||||
it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => {
|
||||
findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" });
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s a tombstoned / missing reminder", async () => {
|
||||
findFirstMock.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* v1.18.6 — explicit "complete" for a Vorsorge reminder (iOS #23 follow-up).
|
||||
*
|
||||
* The user-action equivalent of the cron auto-satisfy: the iOS app (and web)
|
||||
* call this when the user actively marks a measurement reminder done, rather
|
||||
* than only dismissing the notification locally. It routes through the ONE
|
||||
* shared `satisfyReminder` primitive — the same code the cron auto-resolve
|
||||
* and the eventful ingest worker use — so it:
|
||||
*
|
||||
* - stamps `lastSatisfiedAt = now` and re-anchors the server-authoritative
|
||||
* `nextDueAt` past now (one engine, no duplicated reschedule logic);
|
||||
* - fires NO notification of its own (the primitive only writes the row;
|
||||
* the dispatcher is never invoked on this path);
|
||||
* - is idempotent via the primitive's forward-only guard — completing an
|
||||
* already-completed / auto-satisfied reminder is a no-op that still
|
||||
* returns 200, with `completed: false` so the client can tell the second
|
||||
* tap apart from the first.
|
||||
*
|
||||
* Distinct from `satisfy` only in that the response surfaces the `completed`
|
||||
* flag the explicit-action flow benefits from; the underlying effect is the
|
||||
* shared satisfaction primitive either way.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Measurement reminder not found", 404);
|
||||
}
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
|
||||
|
||||
const now = new Date();
|
||||
// Forward-only: a completion strictly after any prior satisfy advances
|
||||
// the row; a completion at/behind the last satisfy is a no-op. Either
|
||||
// way no notification is emitted — the primitive only stamps + reschedules.
|
||||
const result = await satisfyReminder(prisma, existing, timezone, now);
|
||||
|
||||
const updated = await prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.complete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: id, completed: result.satisfied },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.complete" },
|
||||
meta: { reminderId: id, completed: result.satisfied },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
completed: result.satisfied,
|
||||
reminder: toMeasurementReminderDto(updated),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createMeasurementReminderSchema,
|
||||
updateMeasurementReminderSchema,
|
||||
measurementReminderDto,
|
||||
measurementReminderCompletionDto,
|
||||
} from "@/lib/validations/measurement-reminders";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
@@ -169,4 +170,29 @@ export const measurementReminderPaths: NonNullable<
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurement-reminders/{id}/complete": {
|
||||
post: {
|
||||
tags: ["MeasurementReminders"],
|
||||
summary: "Complete a Vorsorge reminder (v1.18.6)",
|
||||
description:
|
||||
"Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed / auto-satisfied reminder returns 200 with completed=false. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the canonical post-completion DTO.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
measurementReminderCompletionDto,
|
||||
"MeasurementReminderCompleteEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...reminderNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -184,3 +184,26 @@ export const measurementReminderDto = z
|
||||
description:
|
||||
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.18.6 — explicit-completion result. The `complete` route is the
|
||||
* user-action equivalent of the cron auto-satisfy: it stamps
|
||||
* `lastSatisfiedAt` and re-anchors `nextDueAt` through the SAME shared
|
||||
* primitive, so it fires no notification of its own and is idempotent.
|
||||
*
|
||||
* `completed` reports whether THIS call advanced the reminder (true) or was
|
||||
* a forward-only no-op because a prior satisfy / matching reading already
|
||||
* fulfilled the current cycle (false). Either way the response is a 200 so
|
||||
* a client that completes an already-completed reminder need not special-case
|
||||
* the second tap. `reminder` carries the canonical post-completion DTO.
|
||||
*/
|
||||
export const measurementReminderCompletionDto = z
|
||||
.object({
|
||||
completed: z.boolean(),
|
||||
reminder: measurementReminderDto,
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderCompletion",
|
||||
description:
|
||||
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
|
||||
});
|
||||
Reference in new issue
Block a user