Merge remote-tracking branch 'origin/main' into feat/pulse-hourly-mean-complete

This commit is contained in:
Marc-André Bombeck committed 2026-10-04 14:54:53 +02:00
commit 13b8920dd5
88 files changed
+5627 -772

No files matched your search

@@ -58,10 +58,13 @@ vi.mock("@/lib/profile/health-facts", () => ({
import { NextRequest } from "next/server";
import { POST } from "../route";
import { requestFingerprint } from "@/lib/idempotency";
/** The pending sentinel `findCached` recognises as "another call is running". */
const PENDING_STATUS = 0;
const BODY = JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" });
function request(): NextRequest {
return new NextRequest("http://localhost/api/anamnesis/facts", {
method: "POST",
@@ -69,7 +72,7 @@ function request(): NextRequest {
"content-type": "application/json",
"idempotency-key": "key-in-flight",
},
body: JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }),
body: BODY,
});
}
@@ -99,6 +102,12 @@ describe("idempotency 409 envelope, over the real route", () => {
it("serves `error` as a string when a request is already in flight", async () => {
mocks.findUnique.mockResolvedValue({
id: "idem-1",
// The same request in flight: a different body would run instead.
requestFingerprint: requestFingerprint(
"POST",
"/api/anamnesis/facts",
new TextEncoder().encode(BODY),
),
responseStatus: PENDING_STATUS,
responseBody: "",
expiresAt: new Date(Date.now() + 60_000),
@@ -53,6 +53,8 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: {
update: vi.fn(),
// #1126 — the stored chain, read for the reasoning settings it carries.
findUnique: vi.fn(async () => null),
},
},
}));
@@ -172,4 +174,93 @@ describe("PUT /api/insights/provider-chain", () => {
{ providerType: "codex", priority: 2, enabled: true },
]);
});
// #1126 — the reasoning setting rides on the local / openai-compatible
// entries. The chain editor only reorders and toggles, so saving the order
// must not wipe a value it never sent.
describe("reasoning setting", () => {
function storedChain(chain: unknown) {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
aiProviderChain: chain,
} as never);
}
async function put(chain: unknown[]) {
vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never);
return (PUT as (req: Request) => Promise<Response>)(
jsonRequest({ chain }),
);
}
function persisted() {
return vi.mocked(prisma.user.update).mock.calls[0][0].data
.aiProviderChain;
}
it("persists a value sent on a local entry", async () => {
storedChain(null);
const res = await put([
{ providerType: "local", enabled: true, reasoningEffort: "none" },
{ providerType: "openai", enabled: true },
]);
expect(res.status).toBe(200);
expect(persisted()).toEqual([
{
providerType: "local",
priority: 1,
enabled: true,
reasoningEffort: "none",
},
{ providerType: "openai", priority: 2, enabled: true },
]);
});
it("keeps the stored value when the body leaves it out", async () => {
storedChain([
{ providerType: "openai", priority: 1, enabled: true },
{
providerType: "openai-compatible",
priority: 2,
enabled: false,
reasoningEffort: "high",
},
]);
await put([
{ providerType: "openai-compatible", enabled: true },
{ providerType: "openai", enabled: true },
]);
expect(persisted()).toEqual([
{
providerType: "openai-compatible",
priority: 1,
enabled: true,
reasoningEffort: "high",
},
{ providerType: "openai", priority: 2, enabled: true },
]);
});
it("clears it on null", async () => {
storedChain([
{
providerType: "local",
priority: 1,
enabled: true,
reasoningEffort: "low",
},
]);
await put([
{ providerType: "local", enabled: true, reasoningEffort: null },
]);
expect(persisted()).toEqual([
{ providerType: "local", priority: 1, enabled: true },
]);
});
it("refuses it on an entry that cannot carry it", async () => {
const res = await put([
{ providerType: "anthropic", enabled: true, reasoningEffort: "none" },
]);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
});
+33 -35
View File
@@ -1,6 +1,4 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -15,10 +13,9 @@ import {
getLastWorkingProvider,
} from "@/lib/ai/provider-runner";
import { prisma } from "@/lib/db";
import {
parseProviderChain,
PROVIDER_CHAIN_TYPES,
} from "@/lib/ai/provider-chain";
import { parseProviderChain } from "@/lib/ai/provider-chain";
import { isReasoningProviderType } from "@/lib/ai/reasoning-effort";
import { providerChainPutSchema } from "@/lib/validations/ai-provider";
/**
* v1.4.16 phase B5b — read-only chain summary for the Settings → AI
@@ -85,29 +82,6 @@ export const GET = apiHandler(async () => {
});
});
const chainEntrySchema = z
.object({
providerType: z.enum(
PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]],
),
// Priority is recomputed server-side from insertion order so a stale
// client cannot persist a chain whose displayed order disagrees with
// its priority field. The number is accepted (and may be present) but
// ignored on the wire.
priority: z.number().int().optional(),
enabled: z.boolean(),
})
.strict();
const chainBodySchema = z
.object({
chain: z
.array(chainEntrySchema)
.min(1, "Chain must contain at least one provider")
.max(PROVIDER_CHAIN_TYPES.length, "Too many providers"),
})
.strict();
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.provider_chain.update" } });
@@ -117,7 +91,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (error) return error;
const parsed = chainBodySchema.safeParse(body);
const parsed = providerChainPutSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid provider chain payload",
@@ -142,11 +116,35 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Normalise priority to insertion order (1-based). UI hands us the
// visual order it wants persisted; we make priority match so a later
// GET reflects the same order without depending on the client's math.
const normalised = parsed.data.chain.map((entry, idx) => ({
providerType: entry.providerType,
priority: idx + 1,
enabled: entry.enabled,
}));
//
// #1126 — a reasoning setting the body leaves out is carried over from the
// stored entry of the same type, so the chain editor (which only reorders
// and toggles) and an older client cannot wipe it by saving the order.
// `null` clears it; the schema has already refused it on any type other
// than `local` / `openai-compatible`.
const stored = await prisma.user.findUnique({
where: { id: user.id },
select: { aiProviderChain: true },
});
const storedEffort = new Map(
parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [
e.providerType,
e.reasoningEffort ?? null,
]),
);
const normalised = parsed.data.chain.map((entry, idx) => {
const reasoningEffort = isReasoningProviderType(entry.providerType)
? entry.reasoningEffort === undefined
? (storedEffort.get(entry.providerType) ?? null)
: entry.reasoningEffort
: null;
return {
providerType: entry.providerType,
priority: idx + 1,
enabled: entry.enabled,
...(reasoningEffort ? { reasoningEffort } : {}),
};
});
await prisma.user.update({
where: { id: user.id },
@@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// The PUT writes inside one interactive transaction; run its callback
// against the same mocked client.
vi.mocked(prisma.$transaction).mockImplementation((async (
fn: (tx: typeof prisma) => unknown,
) => fn(prisma)) as never);
// v1.40 — the category and course helpers' defaults, which
// `resetAllMocks` clears.
vi.mocked(resolveCourseFields).mockResolvedValue(new Map());
@@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
vi.mocked(auditLog).mockResolvedValue(undefined);
const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX);
expect(res.status).toBe(200);
// v1.40 (#1024) — the window goes through the course writer, which
// writes the row and the latest course in one transaction; the plain
// row update no longer carries it.
// v1.40 (#1024) — the window goes through the course writer, inside the
// same transaction as the row update; the plain row update no longer
// carries it.
const call = lastUpdateCall();
expect(call.data).not.toHaveProperty("endsOn");
expect(setCurrentWindow).toHaveBeenCalledWith(
@@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
medicationId: "m1",
endsOn: new Date("2026-12-31"),
}),
prisma,
);
});
+327 -339
View File
@@ -667,7 +667,12 @@ 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).
const tombstoneOpenSlots = async (): Promise<void> => {
// The rollup days the tombstoned slots sat on, recomputed once the
// transaction below has committed the tombstones.
const staleRollupDays = new Set<string>();
const tombstoneOpenSlots = async (
db: Prisma.TransactionClient,
): Promise<void> => {
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
// medication that now doses at 20:00 would linger as a phantom slot,
@@ -689,12 +694,12 @@ export const PUT = apiHandler(
scheduledFor: { gte: todayStart },
} as const;
// Capture the affected slots BEFORE the tombstone so the compliance
// rollups for those days can be recomputed immediately below —
// rollups for those days can be recomputed after the commit —
// `updateMany` does not return the touched rows. Without the
// recompute a tombstoned pending kept counting as `scheduled` in
// the rollup row until the next event-driven write for that day,
// depressing the rate the cards read in the meantime.
const tombstoned = await prisma.medicationIntakeEvent.findMany({
const tombstoned = await db.medicationIntakeEvent.findMany({
where: tombstoneWhere,
select: { scheduledFor: true },
});
@@ -708,331 +713,337 @@ export const PUT = apiHandler(
tombstonedTo = row.scheduledFor;
}
}
await prisma.medicationIntakeEvent.updateMany({
await db.medicationIntakeEvent.updateMany({
where: tombstoneWhere,
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// Recompute each affected rollup day (deduped via the user-tz day
// key). Best-effort like every other rollup write-hook: a failure
// is annotated, never blocks the schedule replace.
const staleDayKeys = new Set(
tombstoned.map((row) =>
dayKeyForScheduledFor(row.scheduledFor, userTz),
),
);
for (const dayKey of staleDayKeys) {
try {
await recomputeMedicationComplianceForDay(
user.id,
id,
dayKey,
userTz,
);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_failed: true,
medication_compliance_rollup_error:
err instanceof Error ? err.message : String(err),
},
});
}
// Deduped via the user-tz day key.
for (const row of tombstoned) {
staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz));
}
};
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
// v1.39.1 (#1033) — a stretch with intake tracking off expected
// nothing, so it archives with an empty payload whatever the rows
// said; and a tracking transition is itself an era boundary even when
// the rows stay the same.
const archivedEntries = wasTracked ? previousEntries : [];
if (
previousRows.length > 0 &&
(trackingChanged ||
!wasTracked ||
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
))
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: archivedEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
await tombstoneOpenSlots();
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
} else if (trackingChanged) {
// v1.39.1 (#1033) — intake tracking switched without a schedule
// replace. Close the current era at this instant: switching OFF
// archives the schedule that was in force (the tracked stretch keeps
// its expected doses); switching back ON archives the untracked
// stretch with an empty payload, so compliance never counts it as
// missed and the live schedule, the projector and the reminder worker
// all resume from now.
const liveRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
if (liveRows.length > 0) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
const archivedEntries = wasTracked
? liveRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
)
: [];
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: {
schedule_revision_rows: archivedEntries.length,
track_intake: willTrack,
},
});
}
}
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
// the open placeholders of today and later go the way a schedule
// replace sends them (a replace in the same request already did it).
if (trackingChanged && !willTrack && !schedules) {
await tombstoneOpenSlots();
}
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
// already-taken intake events freezes their recorded consumption.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
// valid explicit value (clears the override → user-level default).
...(reorderLeadDays !== undefined && { reorderLeadDays }),
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites. Field-by-field; `false` / `[]` are valid explicit values
// (deactivate tracking / clear the per-med restriction).
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.7.0 — iOS reminder flags, field-by-field.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Field-by-field; `null` is a valid explicit value (clears the code).
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.5 scheduling primitives — pass-through when supplied.
// `startsOn` / `endsOn` are `Date | null | undefined` (the
// schema lets the user clear them explicitly with null).
// v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the
// course writer below writes them together with the course they
// project, in one transaction, so the row and its courses cannot
// disagree if one of the two writes fails.
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field (the invariants above
// already guaranteed the medication ends schedule-consistent).
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field.
...(trackIntake !== undefined && { trackIntake }),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
},
}),
};
const withoutNotifications = { ...baseUpdateData } as Record<
string,
unknown
>;
delete withoutNotifications.notificationsEnabled;
const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0;
const hasNotificationsPatch = notificationsEnabled !== undefined;
const updateCandidates: Array<Record<string, unknown>> = [
{ ...baseUpdateData, ...pausedAtPatch },
];
if (hasPausedAtPatch) {
updateCandidates.push(baseUpdateData);
}
if (hasNotificationsPatch) {
updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch });
if (hasPausedAtPatch) {
updateCandidates.push(withoutNotifications);
}
}
// v1.40 — everything the PUT writes to the medication, its schedule and
// its course commits as one transaction. The course write can still be
// refused after the check above (another request wrote a course in
// between); with the row update committed first, the client got the
// refusal while the name, the schedule and `oneShot` were already
// changed. Now a refusal rolls all of it back. The rollup recompute and
// the grace bridge stay outside: both read or write what this commit
// settles, and neither may undo it.
let medication;
let lastUpdateErr: unknown;
for (const candidate of updateCandidates) {
try {
medication = await prisma.medication.update({
try {
medication = await prisma.$transaction(async (tx) => {
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await tx.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
// v1.39.1 (#1033) — a stretch with intake tracking off expected
// nothing, so it archives with an empty payload whatever the rows
// said; and a tracking transition is itself an era boundary even when
// the rows stay the same.
const archivedEntries = wasTracked ? previousEntries : [];
if (
previousRows.length > 0 &&
(trackingChanged ||
!wasTracked ||
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
))
) {
const lastRevision = await tx.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await tx.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: archivedEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await tx.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await tx.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
await tombstoneOpenSlots(tx);
await tx.medicationSchedule.deleteMany({
where: { medicationId: id },
});
} else if (trackingChanged) {
// v1.39.1 (#1033) — intake tracking switched without a schedule
// replace. Close the current era at this instant: switching OFF
// archives the schedule that was in force (the tracked stretch keeps
// its expected doses); switching back ON archives the untracked
// stretch with an empty payload, so compliance never counts it as
// missed and the live schedule, the projector and the reminder worker
// all resume from now.
const liveRows = await tx.medicationSchedule.findMany({
where: { medicationId: id },
});
if (liveRows.length > 0) {
const lastRevision = await tx.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
const archivedEntries = wasTracked
? liveRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
)
: [];
await tx.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: {
schedule_revision_rows: archivedEntries.length,
track_intake: willTrack,
},
});
}
}
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
// the open placeholders of today and later go the way a schedule
// replace sends them (a replace in the same request already did it).
if (trackingChanged && !willTrack && !schedules) {
await tombstoneOpenSlots(tx);
}
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
// already-taken intake events freezes their recorded consumption.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
// valid explicit value (clears the override → user-level default).
...(reorderLeadDays !== undefined && { reorderLeadDays }),
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites. Field-by-field; `false` / `[]` are valid explicit values
// (deactivate tracking / clear the per-med restriction).
...(trackInjectionSites !== undefined && { trackInjectionSites }),
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.7.0 — iOS reminder flags, field-by-field.
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
// Field-by-field; `null` is a valid explicit value (clears the code).
...(atcCode !== undefined && { atcCode }),
...(rxNormCode !== undefined && { rxNormCode }),
// v1.5 scheduling primitives — pass-through when supplied.
// `startsOn` / `endsOn` are `Date | null | undefined` (the
// schema lets the user clear them explicitly with null).
// v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the
// course writer below writes them together with the course they
// project, in one transaction, so the row and its courses cannot
// disagree if one of the two writes fails.
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field (the invariants above
// already guaranteed the medication ends schedule-consistent).
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field.
...(trackIntake !== undefined && { trackIntake }),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
},
}),
};
const updated = await tx.medication.update({
where: { id },
data: candidate,
data: { ...baseUpdateData, ...pausedAtPatch },
include: { schedules: true },
});
break;
} catch (updateErr) {
lastUpdateErr = updateErr;
}
}
if (!medication) throw lastUpdateErr;
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
// single live marker that resume clears, so the paused window is
// irrecoverable once resumed and every paused day's slot collapses to
// "missed" in the compliance denominator. Mirror the live transition
// onto an additive era record the rate paths read: a pause opens an era
// (resumedAt null), a resume closes the latest open era. `userId` comes
// from the authenticated session; the data object is built field-by-field.
if (active === false && existing.active) {
await prisma.medicationPauseEra.create({
data: {
medicationId: id,
userId: user.id,
pausedAt: new Date(),
resumedAt: null,
},
});
} else if (active === true && !existing.active) {
await prisma.medicationPauseEra.updateMany({
where: { medicationId: id, userId: user.id, resumedAt: null },
data: { resumedAt: new Date() },
// v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a
// single live marker that resume clears, so the paused window is
// irrecoverable once resumed and every paused day's slot collapses to
// "missed" in the compliance denominator. Mirror the live transition
// onto an additive era record the rate paths read: a pause opens an era
// (resumedAt null), a resume closes the latest open era. `userId` comes
// from the authenticated session; the data object is built field-by-field.
if (active === false && existing.active) {
await tx.medicationPauseEra.create({
data: {
medicationId: id,
userId: user.id,
pausedAt: new Date(),
resumedAt: null,
},
});
} else if (active === true && !existing.active) {
await tx.medicationPauseEra.updateMany({
where: { medicationId: id, userId: user.id, resumedAt: null },
data: { resumedAt: new Date() },
});
}
// v1.40 (#1024) — write the window onto the latest course and
// project it back, then serve the projected row.
if (touchesWindow) {
const projected = await setCurrentWindow(
{
userId: user.id,
medicationId: id,
timeZone: courseTz,
...nextWindow,
},
tx,
);
return { ...updated, ...projected };
}
return updated;
});
} catch (err) {
// A concurrent course write landed between the check above and the
// course write. The transaction rolled back: nothing was written.
if (err instanceof CourseWriteError) {
const r = courseRefusalResponse(err.refusal);
return apiError(r.message, r.status, { errorCode: r.errorCode });
}
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),
},
});
}
}
// Apply the v1.5.5 primary-schedule grace bridge after the
@@ -1055,29 +1066,6 @@ export const PUT = apiHandler(
}
}
// v1.40 (#1024) — write the window onto the latest course and project it
// back, then serve the projected row.
if (touchesWindow) {
let projected;
try {
projected = await setCurrentWindow({
userId: user.id,
medicationId: id,
timeZone: courseTz,
...nextWindow,
});
} catch (err) {
// A concurrent course write landed between the check above and this
// transaction. Nothing of the window was written.
if (err instanceof CourseWriteError) {
const r = courseRefusalResponse(err.refusal);
return apiError(r.message, r.status, { errorCode: r.errorCode });
}
throw err;
}
medication = { ...medication, ...projected };
}
// v1.40 (#1041) — an iPhone app build that predates custom categories
// decodes an unknown category as OTHER and sends it back on every edit,
// which would overwrite the person's own category with OTHER. A native
@@ -623,3 +623,96 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
}
});
});
// #1126 — the Local and gateway entries' reasoning setting, written from the
// provider forms onto the chain entry and read back from it.
describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => {
const patch = PATCH as (req: Request) => Promise<Response>;
function storedChain(chain: unknown) {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProviderChain: chain,
} as never);
}
function written() {
return vi.mocked(prisma.user.update).mock.calls[0]?.[0].data as
Record<string, unknown> | undefined;
}
it("stores Off on the local entry of a chain that was never customised", async () => {
storedChain(null);
const res = await patch(
patchRequest({ provider: "LOCAL", localReasoningEffort: "none" }),
);
expect(res.status).toBe(200);
const chain = written()?.aiProviderChain as Array<{
providerType: string;
reasoningEffort?: string;
}>;
expect(chain.find((e) => e.providerType === "local")).toMatchObject({
enabled: true,
reasoningEffort: "none",
});
expect(chain.filter((e) => e.reasoningEffort)).toHaveLength(1);
});
it("holds the gateway's value on a disabled entry when the chain lacks it", async () => {
storedChain([{ providerType: "local", priority: 1, enabled: true }]);
await patch(patchRequest({ compatReasoningEffort: "high" }));
expect(written()?.aiProviderChain).toEqual([
{ providerType: "local", priority: 1, enabled: true },
{
providerType: "openai-compatible",
priority: 2,
enabled: false,
reasoningEffort: "high",
},
]);
});
it("does not touch the chain when Default is restated on a default chain", async () => {
storedChain(null);
await patch(
patchRequest({ provider: "LOCAL", localReasoningEffort: null }),
);
expect(written()).toEqual({ aiProvider: "LOCAL" });
});
it("answers 200 for a body that only restates Default", async () => {
storedChain(null);
const res = await patch(patchRequest({ localReasoningEffort: null }));
expect(res.status).toBe(200);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a value outside the five", async () => {
const res = await patch(patchRequest({ localReasoningEffort: "max" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("GET /api/user/ai-provider — reasoning setting (#1126)", () => {
it("reads both values from the chain, null for Default", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
aiProvider: "LOCAL",
aiProviderChain: [
{
providerType: "local",
priority: 1,
enabled: true,
reasoningEffort: "none",
},
],
} as never);
vi.mocked(resolveProviderAvailability).mockResolvedValue({
aiAvailable: true,
managedBy: "local",
});
const res = await (GET as () => Promise<Response>)();
const body = (await res.json()) as {
data: { localReasoningEffort: unknown; compatReasoningEffort: unknown };
};
expect(body.data.localReasoningEffort).toBe("none");
expect(body.data.compatReasoningEffort).toBeNull();
});
});
+57 -2
View File
@@ -20,6 +20,12 @@ import {
} from "@/lib/sharing/provider-work-authority";
import { annotate } from "@/lib/logging/context";
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
import {
chainWithReasoningEffort,
parseProviderChain,
serializeProviderChain,
} from "@/lib/ai/provider-chain";
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
export const dynamic = "force-dynamic";
@@ -42,6 +48,8 @@ export const GET = apiHandler(async () => {
aiCompatModel: true,
// v1.22 (#89)
aiResponseTimeoutSeconds: true,
// #1126 — the reasoning settings live on the chain entries.
aiProviderChain: true,
// v1.38.19 — a managed profile is never the party that gives
// consent for itself; the offer below has to know.
managedProfileAt: true,
@@ -141,6 +149,13 @@ export const GET = apiHandler(async () => {
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
// #1126 — the Local and gateway entries' reasoning settings (null =
// Default), read from the chain where they are stored.
localReasoningEffort: reasoningEffortFor(u?.aiProviderChain, "local"),
compatReasoningEffort: reasoningEffortFor(
u?.aiProviderChain,
"openai-compatible",
),
// v1.38.19 — see the block above. A tri-state and two booleans;
// no count, no timestamp and no other account is inferable from them.
serverProviderHealth,
@@ -297,7 +312,45 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds;
}
if (Object.keys(updates).length === 0) {
// ── #1126 — reasoning settings of the Local and gateway entries ──
// Stored on the entry in `aiProviderChain` rather than in a column of their
// own, so the setting is per entry and a chain can mix a thinking model with
// one that does not know the field. See `chainWithReasoningEffort` for how a
// default chain, or a provider absent from the chain, holds the value.
const reasoningTouched =
body.localReasoningEffort !== undefined ||
body.compatReasoningEffort !== undefined;
if (reasoningTouched) {
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { aiProviderChain: true },
});
let chain: unknown = current?.aiProviderChain ?? null;
if (body.localReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(
chain,
"local",
body.localReasoningEffort,
);
}
if (body.compatReasoningEffort !== undefined) {
chain = chainWithReasoningEffort(
chain,
"openai-compatible",
body.compatReasoningEffort,
);
}
// Written only when it changed: restating Default on a chain that was
// never customised must not freeze today's default chain into the row.
const before = serializeProviderChain(
parseProviderChain(current?.aiProviderChain ?? null),
);
if (serializeProviderChain(parseProviderChain(chain)) !== before) {
updates.aiProviderChain = chain;
}
}
if (Object.keys(updates).length === 0 && !reasoningTouched) {
return apiError("No valid fields", 422, {
errorCode: "ai_provider.no_fields",
});
@@ -317,7 +370,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
updates.aiBaseUrl = null;
}
await prisma.user.update({ where: { id: user.id }, data: updates });
if (Object.keys(updates).length > 0) {
await prisma.user.update({ where: { id: user.id }, data: updates });
}
return apiSuccess({ updated: true });
});