mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
getClientIp,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
} from "@/lib/api-response";
|
|
import { createMedicationSchema } from "@/lib/validations/medication";
|
|
import {
|
|
getMedicationCategories,
|
|
setMedicationCategory,
|
|
} from "@/lib/medication-category";
|
|
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
|
import {
|
|
computeDisplayDue,
|
|
OVERDUE_LOOKBACK_MS,
|
|
} from "@/lib/medications/scheduling/next-due";
|
|
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
|
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
|
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
|
import { NextRequest } from "next/server";
|
|
|
|
type MedicationsListResult = Array<Record<string, unknown>>;
|
|
|
|
async function buildMedicationsList(
|
|
userId: string,
|
|
userTz: string,
|
|
): Promise<MedicationsListResult> {
|
|
const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds(
|
|
new Date(),
|
|
userTz,
|
|
);
|
|
|
|
// v1.15.10 — the slots the user has already acted on near "now", so the
|
|
// next-due search can skip them. A resolved slot is a taken, deliberately
|
|
// skipped, or cron-auto-missed row. Bound the window to [today-start,
|
|
// today-end + 2d] — the next-due lookahead only needs the slots adjacent to
|
|
// now, and a tight window keeps the read cheap. The 60s list cache covers
|
|
// the rest.
|
|
const resolvedWindowEnd = new Date(
|
|
todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
|
|
);
|
|
// v1.16.4 — the open-overdue search reaches back as far as the widest
|
|
// band tail (weekly on-time + overdue), so the resolved-slot read must
|
|
// cover the same horizon or a long-resolved past slot would resurface
|
|
// as "overdue".
|
|
const resolvedWindowStart = new Date(
|
|
todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS,
|
|
);
|
|
|
|
const [medications, latestIntakes, todayEvents, resolvedEvents, eraFloors] =
|
|
await Promise.all([
|
|
prisma.medication.findMany({
|
|
where: { userId },
|
|
include: { schedules: true },
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
prisma.medicationIntakeEvent.groupBy({
|
|
by: ["medicationId"],
|
|
// v1.7.0 sync — exclude tombstoned rows from the last-taken map.
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
skipped: false,
|
|
takenAt: { not: null },
|
|
},
|
|
_max: { takenAt: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.groupBy({
|
|
by: ["medicationId"],
|
|
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
|
|
},
|
|
_count: { id: true },
|
|
}),
|
|
prisma.medicationIntakeEvent.findMany({
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
|
|
OR: [
|
|
{ takenAt: { not: null } },
|
|
{ skipped: true },
|
|
{ autoMissed: true },
|
|
],
|
|
},
|
|
select: { medicationId: true, scheduledFor: true },
|
|
}),
|
|
// v1.16.4 — current-era floor per medication: the newest revision's
|
|
// `validUntil` is where the LIVE schedule rows became valid. The
|
|
// open-overdue search mints from the live rows, so it must not reach
|
|
// past this boundary into a previous era's cadence.
|
|
prisma.medicationScheduleRevision.groupBy({
|
|
by: ["medicationId"],
|
|
// Superseded rows are audit records — a correction may have
|
|
// shortened the era, so the boundary reads only active rows.
|
|
where: { medication: { userId }, supersededByRevisionId: null },
|
|
_max: { validUntil: true },
|
|
}),
|
|
]);
|
|
|
|
const resolvedSlotsByMedId = new Map<string, Date[]>();
|
|
for (const e of resolvedEvents) {
|
|
const list = resolvedSlotsByMedId.get(e.medicationId);
|
|
if (list) list.push(e.scheduledFor);
|
|
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
|
|
}
|
|
|
|
const eraStartByMedId = new Map<string, Date>();
|
|
for (const f of eraFloors) {
|
|
if (f._max.validUntil) eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
|
}
|
|
|
|
const lastTakenAtByMedicationId = Object.fromEntries(
|
|
latestIntakes.map((entry) => [
|
|
entry.medicationId,
|
|
entry._max.takenAt ? entry._max.takenAt.toISOString() : null,
|
|
]),
|
|
);
|
|
// v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine
|
|
// (rolling cadences re-anchor on it). Same groupBy as the ISO map.
|
|
const lastTakenAtDateByMedicationId = Object.fromEntries(
|
|
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
|
|
);
|
|
const todayEventCountByMedId = Object.fromEntries(
|
|
todayEvents.map(
|
|
(entry: { medicationId: string; _count: { id: number } }) => [
|
|
entry.medicationId,
|
|
entry._count.id,
|
|
],
|
|
),
|
|
);
|
|
|
|
let categoryMap: Record<string, string> = {};
|
|
try {
|
|
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
|
} catch {
|
|
getEvent()?.addWarning("Medication categories could not be loaded");
|
|
}
|
|
|
|
// v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived;
|
|
// the list GET is cached 60 s on userId, so a 60 s staleness window is
|
|
// accepted here as it already is for `todayEventCount`.
|
|
const now = new Date();
|
|
|
|
return medications.map((m) => {
|
|
// v1.16.4 — an OPEN overdue slot (anchor passed, still inside its
|
|
// catch-up band, unresolved) surfaces FIRST with `nextDueOverdue:
|
|
// true`; only a closed or resolved band falls through to the future
|
|
// next-due. Keeps the card on the still-takeable dose instead of
|
|
// jumping ahead the minute the anchor passes.
|
|
const display = computeDisplayDue({
|
|
medication: {
|
|
id: m.id,
|
|
startsOn: m.startsOn,
|
|
endsOn: m.endsOn,
|
|
oneShot: m.oneShot,
|
|
createdAt: m.createdAt,
|
|
},
|
|
schedules: m.schedules,
|
|
now,
|
|
userTz,
|
|
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
|
|
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
|
eraStart: eraStartByMedId.get(m.id) ?? null,
|
|
});
|
|
return {
|
|
...m,
|
|
category: categoryMap[m.id] ?? "OTHER",
|
|
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
|
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
|
nextDueAt: display ? display.at.toISOString() : null,
|
|
nextDueOverdue: display?.overdue ?? false,
|
|
};
|
|
});
|
|
}
|
|
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
|
|
const userTz = user.timezone || "Europe/Berlin";
|
|
|
|
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
|
|
// staleness window for `todayEventCount` (the only time-of-day-derived
|
|
// field in the response); writes via POST/PUT/DELETE flush through
|
|
// `invalidateUserMedications` before the next read lands.
|
|
const result = await cached(
|
|
caches.medications as ServerCache<MedicationsListResult>,
|
|
user.id,
|
|
() => buildMedicationsList(user.id, userTz),
|
|
annotate,
|
|
);
|
|
|
|
annotate({
|
|
action: { name: "medication.list" },
|
|
meta: { count: result.length },
|
|
});
|
|
|
|
return apiSuccess(result);
|
|
});
|
|
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
|
|
if (jsonError) return jsonError;
|
|
const parsed = createMedicationSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
// v1.4.43 W6 — multi-issue 422.
|
|
return returnAllZodIssues(parsed.error, 422);
|
|
}
|
|
|
|
const {
|
|
name,
|
|
dose,
|
|
category,
|
|
treatmentClass,
|
|
dosesPerUnit,
|
|
deliveryForm,
|
|
trackInjectionSites,
|
|
allowedInjectionSites,
|
|
notificationsEnabled,
|
|
liveActivityEnabled,
|
|
criticalAlarmEnabled,
|
|
atcCode,
|
|
rxNormCode,
|
|
schedules,
|
|
startsOn,
|
|
endsOn,
|
|
oneShot,
|
|
} = parsed.data;
|
|
|
|
// ── v1.5 route invariants for the new scheduling primitives ───────
|
|
//
|
|
// The Zod layer already enforces `rrule` xor `rollingIntervalDays`
|
|
// (scheduleSchema.refine). The route layer enforces the cross-field
|
|
// invariants that span medication + schedules:
|
|
//
|
|
// 1. **One-shot consistency** — when `oneShot === true`:
|
|
// a. The medication has at most ONE schedule.
|
|
// b. The single schedule carries neither `rrule` nor
|
|
// `rollingIntervalDays` (a one-shot dose IS the schedule).
|
|
// c. `endsOn` is normalised to equal `startsOn` on write so the
|
|
// worker's date-range cap matches the one-and-only slot.
|
|
//
|
|
// 2. **Recurring default** — when `oneShot !== true` AND the
|
|
// schedule has no `rrule`, no `rollingIntervalDays`, and no
|
|
// legacy `daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"`
|
|
// so the canonical recurrence engine has a clean shape going
|
|
// forward instead of falling through to the legacy parser.
|
|
//
|
|
// 3. **iOS contract dual-write** — when `timesOfDay` is absent or
|
|
// empty, the route stamps `[windowStart]` so the new engine
|
|
// always sees a populated `timesOfDay` while legacy iOS clients
|
|
// (v0.6.x) keep encoding the `windowStart` / `windowEnd` /
|
|
// `daysOfWeek` shape. Both shapes coexist through v1.5.x.
|
|
if (oneShot === true) {
|
|
if (schedules.length > 1) {
|
|
return apiError(
|
|
"A one-shot medication can have at most one schedule",
|
|
422,
|
|
);
|
|
}
|
|
const s = schedules[0];
|
|
if (s.rrule !== undefined || s.rollingIntervalDays !== undefined) {
|
|
return apiError(
|
|
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
|
|
422,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Normalise endsOn for one-shot. Zod gives us `Date | null | undefined`.
|
|
const normalisedEndsOn =
|
|
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
|
|
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId: user.id,
|
|
name,
|
|
dose,
|
|
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
|
|
// wire schema; Prisma fills the default GENERIC when omitted.
|
|
...(treatmentClass !== undefined && { treatmentClass }),
|
|
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
|
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
|
...(deliveryForm !== undefined && { deliveryForm }),
|
|
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
|
// sites; Prisma defaults to false / [] when omitted.
|
|
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
|
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
|
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
|
|
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
|
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
|
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
|
// Validated for format by Zod; stored verbatim when present.
|
|
...(atcCode !== undefined && { atcCode }),
|
|
...(rxNormCode !== undefined && { rxNormCode }),
|
|
// v1.5 — wizard's reminders toggle now ships through the create
|
|
// payload (was orphaned in the initial diff). Prisma defaults to
|
|
// true when omitted, matching the legacy form's behaviour.
|
|
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
|
// v1.5 scheduling primitives — pass-through when supplied.
|
|
...(startsOn !== undefined && { startsOn }),
|
|
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
|
...(oneShot !== undefined && { oneShot }),
|
|
schedules: {
|
|
create: schedules.map((s) => {
|
|
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
|
|
// v1.7.0 — PRN schedules carry no cadence, so never default
|
|
// them to FREQ=DAILY (they would otherwise project + remind).
|
|
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
|
|
const isPrn = s.scheduleType === "PRN";
|
|
const defaultedRrule =
|
|
!oneShot &&
|
|
!isPrn &&
|
|
s.rrule === undefined &&
|
|
s.rollingIntervalDays === undefined &&
|
|
!hasLegacyDays
|
|
? "FREQ=DAILY"
|
|
: s.rrule;
|
|
|
|
// Invariant 3 — dual-write `timesOfDay` from windowStart when
|
|
// the legacy-shape iOS client doesn't send the new field.
|
|
const effectiveTimesOfDay =
|
|
s.timesOfDay && s.timesOfDay.length > 0
|
|
? s.timesOfDay
|
|
: [s.windowStart];
|
|
|
|
return {
|
|
windowStart: s.windowStart,
|
|
windowEnd: s.windowEnd,
|
|
label: s.label ?? null,
|
|
dose: s.dose ?? null,
|
|
daysOfWeek: serializeScheduleRecurrence({
|
|
daysOfWeek: s.daysOfWeek ?? [],
|
|
intervalWeeks: s.intervalWeeks ?? 1,
|
|
}),
|
|
// v1.5 first-class times-of-day.
|
|
timesOfDay: effectiveTimesOfDay,
|
|
...(s.reminderGraceMinutes !== undefined && {
|
|
reminderGraceMinutes: s.reminderGraceMinutes,
|
|
}),
|
|
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
|
...(s.rollingIntervalDays !== undefined && {
|
|
rollingIntervalDays: s.rollingIntervalDays,
|
|
}),
|
|
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
|
|
...(s.scheduleType !== undefined && {
|
|
scheduleType: s.scheduleType,
|
|
}),
|
|
...(s.cyclicOnWeeks !== undefined && {
|
|
cyclicOnWeeks: s.cyclicOnWeeks,
|
|
}),
|
|
...(s.cyclicOffWeeks !== undefined && {
|
|
cyclicOffWeeks: s.cyclicOffWeeks,
|
|
}),
|
|
// v1.15.18 — per-dose configurable on-time windows. Stored as the
|
|
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
|
|
// column NULL (every slot on the default ±1h derivation).
|
|
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
|
|
};
|
|
}),
|
|
},
|
|
},
|
|
include: { schedules: true },
|
|
});
|
|
|
|
const normalizedCategory = await setMedicationCategory(
|
|
medication.id,
|
|
category,
|
|
);
|
|
|
|
await auditLog("medication.create", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: { medicationId: medication.id, name },
|
|
});
|
|
|
|
annotate({
|
|
action: {
|
|
name: "medication.create",
|
|
entity_type: "medication",
|
|
entity_id: medication.id,
|
|
},
|
|
});
|
|
|
|
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
|
// caches so the next read reflects the new schedule.
|
|
invalidateUserMedications(user.id);
|
|
|
|
return apiSuccess(
|
|
{
|
|
...medication,
|
|
category: normalizedCategory,
|
|
},
|
|
201,
|
|
);
|
|
});
|