Merge branch 'v1422/autotaken' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 14:41:28 +02:00
commit 2f780df0b0
52 files changed
+2552 -19

No files matched your search

@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -182,7 +183,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const isOverdue = dayMatch && minutesPastEnd > 0;
const hasEvent = eventCount > schedulesProcessed;
if (isOverdue && !hasEvent && med.notificationsEnabled) {
// v1.42.2 — a medication whose doses are recorded automatically
// is never reminded, here or in the worker.
if (isOverdue && !hasEvent && sendsMedicationReminders(med)) {
const doseInfo = schedule.dose ?? med.dose;
const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`;
@@ -92,6 +92,8 @@ interface SerializedDoseHistoryRow {
takenAt: string | null;
skipped: boolean;
autoMissed: boolean;
/** v1.42.2 — recorded automatically (`autoMarkTaken`). */
autoRecorded: boolean;
/** v1.16.4 — per-intake dose override; null = configured dose. */
doseTaken: string | null;
/** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */
@@ -179,6 +181,8 @@ export const GET = apiHandler(
// v1.32.8 (iOS #64) — write provenance so the client can label how
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
source: true,
// v1.42.2 — the history labels an automatically recorded dose.
autoRecorded: true,
},
});
@@ -191,6 +195,7 @@ export const GET = apiHandler(
pinned: e.attributionSource === "USER_PIN",
doseTaken: e.doseTaken,
source: e.source,
autoRecorded: e.autoRecorded,
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
@@ -296,6 +301,7 @@ export const GET = apiHandler(
pinned: e.pinned,
doseTaken: e.doseTaken,
source: e.source,
autoRecorded: e.autoRecorded,
}));
const rows = reconstructDoseHistory(
@@ -327,6 +333,7 @@ export const GET = apiHandler(
autoMissed: row.intake.autoMissed ?? false,
doseTaken: row.intake.doseTaken ?? null,
source: row.intake.source ?? null,
autoRecorded: row.intake.autoRecorded ?? false,
}
: null,
}));
@@ -252,6 +252,10 @@ export const PUT = apiHandler(
// onto the slot the row already sits on, or an unpin whose band
// re-attribution lands on the same anchor).
...(attributionSource !== undefined && { attributionSource }),
// v1.42.2 — an edit of the outcome is the person's own record.
...((data.takenAt !== undefined || data.skipped !== undefined) && {
autoRecorded: false,
}),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
+15
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -264,6 +265,8 @@ export const GET = apiHandler(
categoryLabel,
...courseFields,
nextDueAt: display ? display.at.toISOString() : null,
// v1.42.2 — the server's own reminder decision, published resolved.
remindersActive: sendsMedicationReminders(medication),
nextDueOverdue: display?.overdue ?? false,
// v1.39.4 (#1040) — see `resolveIntakeActionability`.
...resolveIntakeActionability(
@@ -310,6 +313,8 @@ export const PUT = apiHandler(
asNeeded: true,
// v1.39.1 (#1033) — the tracking transition below needs the before.
trackIntake: true,
// v1.42.2 — `autoMarkTakenSince` is stamped only on an off→on flip.
autoMarkTaken: true,
// v1.37.0 — the pre-image the audit row carries (C4).
name: true,
dose: true,
@@ -407,6 +412,7 @@ export const PUT = apiHandler(
oneShot,
asNeeded,
trackIntake,
autoMarkTaken,
reminderGraceMinutes: topLevelGraceMinutes,
} = input;
@@ -962,6 +968,15 @@ export const PUT = apiHandler(
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field.
...(trackIntake !== undefined && { trackIntake }),
// v1.42.2 — auto-mark-taken, field-by-field. Only an off→on flip
// stamps `autoMarkTakenSince`, so re-saving the form with the
// option already on never moves the floor; switching it off
// clears the stamp.
...(autoMarkTaken !== undefined &&
autoMarkTaken !== existing.autoMarkTaken && {
autoMarkTaken,
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
}),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
@@ -438,7 +438,12 @@ describe("POST /api/medications/intake", () => {
// v1.7.0 sync — the skip toggle bumps syncVersion.
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith({
where: { id: "e1" },
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
data: {
takenAt: null,
skipped: true,
autoRecorded: false,
syncVersion: { increment: 1 },
},
});
// v1.16.10 — a row toggled out of taken refunds its consumption
// stamp (no-op for a never-consumed row) and never consumes.
+9 -1
View File
@@ -438,6 +438,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: {
takenAt: resolvedTakenAt,
skipped: false,
// v1.42.2 — the person confirmed it; no longer automatic.
autoRecorded: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
@@ -525,7 +527,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// v1.7.0 sync — bump the reconciliation counter on the skip toggle.
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
// v1.42.2 — a skip the person records clears the automatic marker.
data: {
takenAt: null,
skipped: true,
autoRecorded: false,
syncVersion: { increment: 1 },
},
});
// v1.16.10 — the row stopped being taken; refund whatever its
// consumption stamp recorded (no-op for a never-consumed row).
+7
View File
@@ -189,6 +189,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
oneShot,
asNeeded,
trackIntake,
autoMarkTaken,
} = parsed.data;
// v1.40 (#1041) — a custom category must be one of this record's own.
@@ -378,6 +379,12 @@ async function postMedication(request: NextRequest): Promise<Response> {
// the schedule is stored as a record from the start; the column
// default (true) covers every caller that does not send it.
...(trackIntake !== undefined && { trackIntake }),
// v1.42.2 — auto-mark-taken, field-by-field. Created on, the
// auto-take job fills slots from this moment only.
...(autoMarkTaken !== undefined && {
autoMarkTaken,
autoMarkTakenSince: autoMarkTaken ? new Date() : null,
}),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
+4
View File
@@ -163,6 +163,8 @@ interface IntakeUpsert {
takenAt: string | null;
skipped: boolean;
source: string;
/** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */
autoRecorded: boolean;
syncVersion: number;
updatedAt: string;
}
@@ -389,6 +391,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
takenAt: true,
skipped: true,
source: true,
autoRecorded: true,
syncVersion: true,
deletedAt: true,
updatedAt: true,
@@ -543,6 +546,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
takenAt: row.takenAt?.toISOString() ?? null,
skipped: row.skipped,
source: row.source,
autoRecorded: row.autoRecorded,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
});