mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
refactor(medications): move the medication create out of its route
POST /api/medications keeps authentication, the body, the validation answer and the HTTP mapping; the create (the mirrored-medication dedupe and cap, the one-shot shape refusal, the initial course, the nested schedules, the insert and its tail) moves verbatim to lib/medications/create-medication.ts and answers created, existing or refused. The route falls from 541 to 94 lines. The parity walk recorded before the move is unchanged, and the sharing guard reads the service beside the route for the handler body.
This commit is contained in:
1 parent
8bb58d8760
commit
3a4e97733d
3 files changed
+601
-464
No files matched your search
@@ -434,6 +434,7 @@ function callsSymbol(rel: string, symbol: string): boolean {
|
||||
* cannot outlive the delegation it describes.
|
||||
*/
|
||||
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
|
||||
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": [
|
||||
"lib/medications/update-intake-event.ts",
|
||||
"lib/medications/delete-intake-event.ts",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -10,45 +7,15 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
courseRefusalResponse,
|
||||
resolveCourseFields,
|
||||
} from "@/lib/medications/courses";
|
||||
import {
|
||||
dateOfDayKey,
|
||||
dayKeyOfDate,
|
||||
validateCourses,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
createMedicationSchema,
|
||||
isCustomMedicationCategoryKey,
|
||||
} from "@/lib/validations/medication";
|
||||
import { createMedicationSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
unstableExternalIdMeta,
|
||||
unstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import {
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
|
||||
import { createMedication } from "@/lib/medications/create-medication";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
|
||||
|
||||
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
|
||||
// A client that mints a fresh `externalId` for the same source concept on
|
||||
// every sync sweep (a known iOS unstable-key bug class) never matches the
|
||||
// idempotency triple, so each sweep would otherwise create a new mirror
|
||||
// row unbounded. Apple's own medication list makes >30 genuinely distinct
|
||||
// meds implausible, so this caps the damage a buggy client can do without
|
||||
// breaking any legitimate use.
|
||||
const APPLE_HEALTH_MIRROR_CAP = 30;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
@@ -73,66 +40,12 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.28 — idempotent response for a re-mirrored medication. The create
|
||||
* route returns the EXISTING row (status 200, vs 201 for a fresh create)
|
||||
* when the caller re-posts a `(externalSource, externalId)` pair this
|
||||
* user already mirrors — the iOS re-sync replays its full HealthKit
|
||||
* medication list, and a replay must neither duplicate nor 500. Response
|
||||
* shape mirrors the fresh-create envelope (schedules included, category
|
||||
* joined, Decimal unwrapped); no audit row and no cache eviction because
|
||||
* nothing was written.
|
||||
*/
|
||||
async function respondWithExistingMirror(
|
||||
medication: Record<string, unknown> & {
|
||||
id: string;
|
||||
unitsPerDose: unknown;
|
||||
trackIntake: boolean;
|
||||
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
|
||||
},
|
||||
): Promise<Response> {
|
||||
let category = "OTHER";
|
||||
let categoryLabel: string | null = null;
|
||||
try {
|
||||
const categories = await resolveMedicationCategories([medication.id]);
|
||||
category = categories[medication.id]?.category ?? "OTHER";
|
||||
categoryLabel = categories[medication.id]?.categoryLabel ?? null;
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.create",
|
||||
entity_type: "medication",
|
||||
entity_id: medication.id,
|
||||
},
|
||||
meta: { idempotent_replay: true },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
Number(medication.unitsPerDose),
|
||||
),
|
||||
),
|
||||
category,
|
||||
categoryLabel,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The create is wrapped in `withIdempotency` so a client replaying its offline
|
||||
* outbox after a lost success response replays the first answer instead of
|
||||
* minting a second medication. The `(externalSource, externalId)` dedupe below
|
||||
* only ever covered a MIRRORED create; a manually entered medication carries
|
||||
* neither field and had nothing to collapse a retry onto.
|
||||
* minting a second medication. The `(externalSource, externalId)` dedupe in
|
||||
* `createMedication` only ever covered a MIRRORED create; a manually entered
|
||||
* medication carries neither field and had nothing to collapse a retry onto.
|
||||
*/
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMedication));
|
||||
|
||||
@@ -165,377 +78,17 @@ async function postMedication(request: NextRequest): Promise<Response> {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
externalSource,
|
||||
externalId,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
autoMarkTaken,
|
||||
} = parsed.data;
|
||||
|
||||
// v1.40 (#1041) — a custom category must be one of this record's own.
|
||||
if (
|
||||
category !== undefined &&
|
||||
!(await isAssignableCategory(user.id, category))
|
||||
) {
|
||||
return apiError("Unknown medication category", 422, {
|
||||
errorCode: "medications.category.unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.28 — idempotent mirror create. A mirrored medication (Apple
|
||||
// Health) is keyed by the `(userId, externalSource, externalId)`
|
||||
// unique; a re-post of the same concept id (iOS re-sync, retry after a
|
||||
// dropped response) returns the EXISTING medication with 200 instead
|
||||
// of minting a duplicate or 500-ing on P2002. The pre-query covers the
|
||||
// common replay; the P2002 catch around the create below covers the
|
||||
// concurrent race.
|
||||
if (externalSource !== undefined && externalId !== undefined) {
|
||||
const existing = await prisma.medication.findFirst({
|
||||
where: { userId: user.id, externalSource, externalId },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (existing) {
|
||||
return respondWithExistingMirror(existing);
|
||||
}
|
||||
|
||||
// v1.32.25 — growth guard. This point is only reached when the
|
||||
// idempotency lookup MISSED, i.e. this is a genuinely NEW mirror row,
|
||||
// not a replay of one the user already holds (a replay returned 200
|
||||
// above). An idempotent re-post of an existing mirror is therefore
|
||||
// never blocked, even for a user already at the cap. Refuse only the
|
||||
// minting of a fresh row once the user already holds the cap of
|
||||
// Apple-Health-mirrored medications, so a client feeding a rotating
|
||||
// key can no longer fill the list with duplicates. The count runs on
|
||||
// the mirror-create-miss path only; native creates (no
|
||||
// `externalSource`) never reach this branch and pay nothing.
|
||||
const mirroredCount = await prisma.medication.count({
|
||||
where: { userId: user.id, externalSource: "APPLE_HEALTH" },
|
||||
});
|
||||
if (mirroredCount >= APPLE_HEALTH_MIRROR_CAP) {
|
||||
annotate({
|
||||
action: { name: "medication.mirror.limit_exceeded" },
|
||||
meta: { existing: mirroredCount },
|
||||
});
|
||||
return apiError(
|
||||
"This account already mirrors the maximum number of medications from an external source",
|
||||
422,
|
||||
{ errorCode: "medications.mirror.limit_exceeded" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
// 4. **As-needed (v1.16.11, #316)** — when `asNeeded === true` the
|
||||
// medication carries ZERO schedules (the Zod refine already
|
||||
// rejects a populated array, mutual exclusion with `oneShot`
|
||||
// included). Every "never due / never reminded / never scored"
|
||||
// surface follows structurally from the empty schedule list.
|
||||
const scheduleInputs = schedules ?? [];
|
||||
if (oneShot === true) {
|
||||
if (scheduleInputs.length > 1) {
|
||||
return apiError(
|
||||
"A one-shot medication can have at most one schedule",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const s = scheduleInputs[0];
|
||||
if (s && (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);
|
||||
|
||||
// v1.40 (#1024) — a medication created with a window starts with one
|
||||
// course; the row projects it. An end without a start has always meant
|
||||
// "running since creation", so the course starts today (or on the end day,
|
||||
// when that lies earlier). No window: no course, chronic since creation.
|
||||
let initialCourse: { startsOn: Date; endsOn: Date | null } | null = null;
|
||||
if (startsOn || normalisedEndsOn) {
|
||||
const todayKey = userDayKey(new Date(), user.timezone || DEFAULT_TIMEZONE);
|
||||
const endKey = normalisedEndsOn ? dayKeyOfDate(normalisedEndsOn) : null;
|
||||
initialCourse = {
|
||||
startsOn:
|
||||
startsOn ??
|
||||
dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey),
|
||||
endsOn: normalisedEndsOn ?? null,
|
||||
};
|
||||
const refusal = validateCourses(
|
||||
[initialCourse],
|
||||
todayKey,
|
||||
oneShot === true,
|
||||
);
|
||||
if (refusal) {
|
||||
const r = courseRefusalResponse(refusal);
|
||||
return apiError(r.message, r.status, { errorCode: r.errorCode });
|
||||
}
|
||||
}
|
||||
|
||||
const createMedication = () =>
|
||||
prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
dose,
|
||||
// v1.4.25 — treatmentClass + dosesPerUnit are optional in the
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.17.0 — optional reorder lead override; null/omitted = inherit
|
||||
// the user-level default in the low-stock alert.
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
// 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.28 — mirrored-medication provenance (Apple Health). Zod
|
||||
// enforces the both-or-neither pairing; field-by-field like every
|
||||
// other column.
|
||||
...(externalSource !== undefined && { externalSource }),
|
||||
...(externalId !== undefined && { externalId }),
|
||||
// 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 }),
|
||||
...(initialCourse && {
|
||||
startsOn: initialCourse.startsOn,
|
||||
endsOn: initialCourse.endsOn,
|
||||
courses: {
|
||||
create: {
|
||||
userId: user.id,
|
||||
startsOn: initialCourse.startsOn,
|
||||
endsOn: initialCourse.endsOn,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
|
||||
// carries an empty `scheduleInputs`, so the nested create below
|
||||
// persists zero schedule rows.
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// v1.39.1 (#1033) — intake tracking, field-by-field. Created off,
|
||||
// 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.
|
||||
// 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,
|
||||
// #219 — per-schedule inventory units. Field-by-field (no mass
|
||||
// assignment); absent leaves the column NULL so the consume
|
||||
// hook inherits the medication-level units_per_dose.
|
||||
...(s.unitsPerDose !== undefined && {
|
||||
unitsPerDose: s.unitsPerDose,
|
||||
}),
|
||||
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 },
|
||||
});
|
||||
|
||||
let medication: Awaited<ReturnType<typeof createMedication>>;
|
||||
try {
|
||||
medication = await createMedication();
|
||||
} catch (err: unknown) {
|
||||
// v1.28 — P2002 on the mirror triple: a concurrent create of the
|
||||
// same `(userId, externalSource, externalId)` won the race between
|
||||
// the pre-query above and this insert. Resolve to the winning row
|
||||
// and answer idempotently instead of 500-ing. Any other unique
|
||||
// violation (there is none on this table today) rethrows.
|
||||
const isP2002 =
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: string }).code === "P2002";
|
||||
if (isP2002 && externalSource !== undefined && externalId !== undefined) {
|
||||
const winner = await prisma.medication.findFirst({
|
||||
where: { userId: user.id, externalSource, externalId },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (winner) return respondWithExistingMirror(winner);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const normalizedCategory = await setMedicationCategory(
|
||||
medication.id,
|
||||
category,
|
||||
);
|
||||
const categoryLabel = isCustomMedicationCategoryKey(normalizedCategory)
|
||||
? ((await resolveMedicationCategories([medication.id]))[medication.id]
|
||||
?.categoryLabel ?? null)
|
||||
: null;
|
||||
|
||||
await auditLog("medication.create", {
|
||||
userId: user.id,
|
||||
const outcome = await createMedication({
|
||||
user,
|
||||
data: parsed.data,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: medication.id,
|
||||
name,
|
||||
// v1.42.2 — a medication created with its doses recorded
|
||||
// automatically says so in its audit row.
|
||||
autoMarkTaken: medication.autoMarkTaken,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.create",
|
||||
entity_type: "medication",
|
||||
entity_id: medication.id,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.34 — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the new schedule.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel,
|
||||
// v1.40 (#1024) — the courses, as every medication read carries them.
|
||||
...(
|
||||
await resolveCourseFields(
|
||||
[medication],
|
||||
new Date(),
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
)
|
||||
).get(medication.id),
|
||||
},
|
||||
201,
|
||||
);
|
||||
switch (outcome.kind) {
|
||||
case "refused":
|
||||
return apiError(outcome.message, outcome.status, outcome.meta);
|
||||
case "existing":
|
||||
return apiSuccess(outcome.body);
|
||||
case "created":
|
||||
return apiSuccess(outcome.body, 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Create a medication with its nested schedule: `POST /api/medications`.
|
||||
*
|
||||
* The route authenticates (WRITE over the `medications` section), reads the
|
||||
* body and answers the schema refusal; everything from the category check to
|
||||
* the cache eviction runs here, in the order it always ran. The result says
|
||||
* what the route answers: a refusal, the existing row of a mirrored concept
|
||||
* the record already holds, or the new row.
|
||||
*/
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import type { SessionUser } from "@/lib/auth/session-user";
|
||||
import {
|
||||
courseRefusalResponse,
|
||||
resolveCourseFields,
|
||||
} from "@/lib/medications/courses";
|
||||
import {
|
||||
dateOfDayKey,
|
||||
dayKeyOfDate,
|
||||
validateCourses,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
isCustomMedicationCategoryKey,
|
||||
type createMedicationSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import {
|
||||
isAssignableCategory,
|
||||
resolveMedicationCategories,
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
|
||||
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
|
||||
|
||||
export type CreateMedicationInput = z.output<typeof createMedicationSchema>;
|
||||
type ScheduleInput = NonNullable<CreateMedicationInput["schedules"]>[number];
|
||||
|
||||
/**
|
||||
* Blast-radius cap on externally-mirrored medications per user. A client
|
||||
* that mints a fresh `externalId` for the same source concept on every sync
|
||||
* sweep (a known iOS unstable-key bug class) never matches the idempotency
|
||||
* triple, so each sweep would otherwise create a new mirror row unbounded.
|
||||
* Apple's own medication list makes >30 genuinely distinct meds implausible,
|
||||
* so this caps the damage a buggy client can do without breaking any
|
||||
* legitimate use.
|
||||
*/
|
||||
const APPLE_HEALTH_MIRROR_CAP = 30;
|
||||
|
||||
export type CreateMedicationOutcome =
|
||||
| {
|
||||
kind: "refused";
|
||||
message: string;
|
||||
status: number;
|
||||
meta?: { errorCode: string };
|
||||
}
|
||||
| { kind: "existing"; body: Record<string, unknown> }
|
||||
| { kind: "created"; body: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* Idempotent response for a re-mirrored medication. The create returns the
|
||||
* EXISTING row (status 200, vs 201 for a fresh create) when the caller
|
||||
* re-posts a `(externalSource, externalId)` pair this user already mirrors —
|
||||
* the iOS re-sync replays its full HealthKit medication list, and a replay
|
||||
* must neither duplicate nor 500. Response shape mirrors the fresh-create
|
||||
* envelope (schedules included, category joined, Decimal unwrapped); no
|
||||
* audit row and no cache eviction because nothing was written.
|
||||
*/
|
||||
async function existingMirrorBody(
|
||||
medication: Record<string, unknown> & {
|
||||
id: string;
|
||||
unitsPerDose: unknown;
|
||||
trackIntake: boolean;
|
||||
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
|
||||
},
|
||||
): Promise<CreateMedicationOutcome> {
|
||||
let category = "OTHER";
|
||||
let categoryLabel: string | null = null;
|
||||
try {
|
||||
const categories = await resolveMedicationCategories([medication.id]);
|
||||
category = categories[medication.id]?.category ?? "OTHER";
|
||||
categoryLabel = categories[medication.id]?.categoryLabel ?? null;
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.create",
|
||||
entity_type: "medication",
|
||||
entity_id: medication.id,
|
||||
},
|
||||
meta: { idempotent_replay: true },
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "existing",
|
||||
body: {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
// (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
Number(medication.unitsPerDose),
|
||||
),
|
||||
),
|
||||
category,
|
||||
categoryLabel,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent mirror create. A mirrored medication (Apple Health) is keyed by
|
||||
* the `(userId, externalSource, externalId)` unique; a re-post of the same
|
||||
* concept id (iOS re-sync, retry after a dropped response) returns the
|
||||
* EXISTING medication with 200 instead of minting a duplicate or 500-ing on
|
||||
* P2002. The pre-query covers the common replay; the P2002 catch around the
|
||||
* create covers the concurrent race. Null when the create goes ahead.
|
||||
*/
|
||||
async function existingMirrorOrCap(
|
||||
userId: string,
|
||||
externalSource: NonNullable<CreateMedicationInput["externalSource"]>,
|
||||
externalId: string,
|
||||
): Promise<CreateMedicationOutcome | null> {
|
||||
const existing = await prisma.medication.findFirst({
|
||||
where: { userId, externalSource, externalId },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (existing) {
|
||||
return existingMirrorBody(existing);
|
||||
}
|
||||
|
||||
// Growth guard. This point is only reached when the idempotency lookup
|
||||
// MISSED, i.e. this is a genuinely NEW mirror row, not a replay of one the
|
||||
// user already holds (a replay returned 200 above). An idempotent re-post
|
||||
// of an existing mirror is therefore never blocked, even for a user already
|
||||
// at the cap. Refuse only the minting of a fresh row once the user already
|
||||
// holds the cap of Apple-Health-mirrored medications, so a client feeding a
|
||||
// rotating key can no longer fill the list with duplicates. The count runs
|
||||
// on the mirror-create-miss path only; native creates (no `externalSource`)
|
||||
// never reach this branch and pay nothing.
|
||||
const mirroredCount = await prisma.medication.count({
|
||||
where: { userId, externalSource: "APPLE_HEALTH" },
|
||||
});
|
||||
if (mirroredCount >= APPLE_HEALTH_MIRROR_CAP) {
|
||||
annotate({
|
||||
action: { name: "medication.mirror.limit_exceeded" },
|
||||
meta: { existing: mirroredCount },
|
||||
});
|
||||
return {
|
||||
kind: "refused",
|
||||
message:
|
||||
"This account already mirrors the maximum number of medications from an external source",
|
||||
status: 422,
|
||||
meta: { errorCode: "medications.mirror.limit_exceeded" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route invariants for the scheduling primitives.
|
||||
*
|
||||
* The Zod layer already enforces `rrule` xor `rollingIntervalDays`
|
||||
* (scheduleSchema.refine). This 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
|
||||
* create 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 create 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.
|
||||
* 4. **As-needed (#316)** — when `asNeeded === true` the medication carries
|
||||
* ZERO schedules (the Zod refine already rejects a populated array,
|
||||
* mutual exclusion with `oneShot` included). Every "never due / never
|
||||
* reminded / never scored" surface follows structurally from the empty
|
||||
* schedule list.
|
||||
*/
|
||||
function refuseOneShotShape(
|
||||
oneShot: boolean | undefined,
|
||||
scheduleInputs: readonly ScheduleInput[],
|
||||
): CreateMedicationOutcome | null {
|
||||
if (oneShot === true) {
|
||||
if (scheduleInputs.length > 1) {
|
||||
return {
|
||||
kind: "refused",
|
||||
message: "A one-shot medication can have at most one schedule",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
const s = scheduleInputs[0];
|
||||
if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) {
|
||||
return {
|
||||
kind: "refused",
|
||||
message:
|
||||
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A medication created with a window starts with one course; the row
|
||||
* projects it (#1024). An end without a start has always meant "running
|
||||
* since creation", so the course starts today (or on the end day, when that
|
||||
* lies earlier). No window: no course, chronic since creation.
|
||||
*/
|
||||
function resolveInitialCourse(
|
||||
timezone: string,
|
||||
startsOn: Date | null | undefined,
|
||||
normalisedEndsOn: Date | undefined,
|
||||
oneShot: boolean | undefined,
|
||||
):
|
||||
| { initialCourse: { startsOn: Date; endsOn: Date | null } | null }
|
||||
| { refusal: CreateMedicationOutcome } {
|
||||
let initialCourse: { startsOn: Date; endsOn: Date | null } | null = null;
|
||||
if (startsOn || normalisedEndsOn) {
|
||||
const todayKey = userDayKey(new Date(), timezone || DEFAULT_TIMEZONE);
|
||||
const endKey = normalisedEndsOn ? dayKeyOfDate(normalisedEndsOn) : null;
|
||||
initialCourse = {
|
||||
startsOn:
|
||||
startsOn ??
|
||||
dateOfDayKey(endKey && endKey < todayKey ? endKey : todayKey),
|
||||
endsOn: normalisedEndsOn ?? null,
|
||||
};
|
||||
const refusal = validateCourses(
|
||||
[initialCourse],
|
||||
todayKey,
|
||||
oneShot === true,
|
||||
);
|
||||
if (refusal) {
|
||||
const r = courseRefusalResponse(refusal);
|
||||
return {
|
||||
refusal: {
|
||||
kind: "refused",
|
||||
message: r.message,
|
||||
status: r.status,
|
||||
meta: { errorCode: r.errorCode },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return { initialCourse };
|
||||
}
|
||||
|
||||
/** One nested schedule row, field-by-field from the parsed input. */
|
||||
function scheduleCreateData(s: ScheduleInput, oneShot: boolean | undefined) {
|
||||
// Invariant 2 — default to FREQ=DAILY when nothing else is set. 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,
|
||||
// #219 — per-schedule inventory units. Field-by-field (no mass
|
||||
// assignment); absent leaves the column NULL so the consume hook
|
||||
// inherits the medication-level units_per_dose.
|
||||
...(s.unitsPerDose !== undefined && {
|
||||
unitsPerDose: s.unitsPerDose,
|
||||
}),
|
||||
daysOfWeek: serializeScheduleRecurrence({
|
||||
daysOfWeek: s.daysOfWeek ?? [],
|
||||
intervalWeeks: s.intervalWeeks ?? 1,
|
||||
}),
|
||||
// First-class times-of-day.
|
||||
timesOfDay: effectiveTimesOfDay,
|
||||
...(s.reminderGraceMinutes !== undefined && {
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
}),
|
||||
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
|
||||
...(s.rollingIntervalDays !== undefined && {
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
}),
|
||||
// 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,
|
||||
}),
|
||||
// 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The insert itself, field-by-field from the parsed input. */
|
||||
function insertMedication(
|
||||
userId: string,
|
||||
data: CreateMedicationInput,
|
||||
shape: {
|
||||
scheduleInputs: readonly ScheduleInput[];
|
||||
normalisedEndsOn: Date | undefined;
|
||||
initialCourse: { startsOn: Date; endsOn: Date | null } | null;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
reorderLeadDays,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
externalSource,
|
||||
externalId,
|
||||
startsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
autoMarkTaken,
|
||||
} = data;
|
||||
const { scheduleInputs, normalisedEndsOn, initialCourse } = shape;
|
||||
return prisma.medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name,
|
||||
dose,
|
||||
// treatmentClass + dosesPerUnit are optional in the wire schema; Prisma
|
||||
// fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// Units consumed per dose; Prisma defaults to 1 when omitted.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// Optional reorder lead override; null/omitted = inherit the
|
||||
// user-level default in the low-stock alert.
|
||||
...(reorderLeadDays !== undefined && { reorderLeadDays }),
|
||||
// Route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// Injection-site tracking opt-in + per-medication allowed sites; Prisma
|
||||
// defaults to false / [] when omitted.
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
// iOS reminder flags; Prisma defaults both to false.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// Optional drug-classification codes (ATC / RxNorm). Validated for
|
||||
// format by Zod; stored verbatim when present.
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// Mirrored-medication provenance (Apple Health). Zod enforces the
|
||||
// both-or-neither pairing; field-by-field like every other column.
|
||||
...(externalSource !== undefined && { externalSource }),
|
||||
...(externalId !== undefined && { externalId }),
|
||||
// The wizard's reminders toggle ships through the create payload.
|
||||
// Prisma defaults to true when omitted, matching the legacy form's
|
||||
// behaviour.
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// Scheduling primitives — pass-through when supplied.
|
||||
...(startsOn !== undefined && { startsOn }),
|
||||
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
|
||||
...(initialCourse && {
|
||||
startsOn: initialCourse.startsOn,
|
||||
endsOn: initialCourse.endsOn,
|
||||
courses: {
|
||||
create: {
|
||||
userId,
|
||||
startsOn: initialCourse.startsOn,
|
||||
endsOn: initialCourse.endsOn,
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(oneShot !== undefined && { oneShot }),
|
||||
// As-needed flag, field-by-field. An asNeeded create carries an empty
|
||||
// `scheduleInputs`, so the nested create below persists zero schedule
|
||||
// rows.
|
||||
...(asNeeded !== undefined && { asNeeded }),
|
||||
// (#1033) — intake tracking, field-by-field. Created off, 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 }),
|
||||
// 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) => scheduleCreateData(s, oneShot)),
|
||||
},
|
||||
},
|
||||
include: { schedules: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMedication(input: {
|
||||
user: Pick<SessionUser, "id" | "timezone">;
|
||||
data: CreateMedicationInput;
|
||||
ipAddress: string | null;
|
||||
}): Promise<CreateMedicationOutcome> {
|
||||
const { user, data } = input;
|
||||
const {
|
||||
name,
|
||||
category,
|
||||
externalSource,
|
||||
externalId,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
oneShot,
|
||||
} = data;
|
||||
|
||||
// (#1041) — a custom category must be one of this record's own.
|
||||
if (
|
||||
category !== undefined &&
|
||||
!(await isAssignableCategory(user.id, category))
|
||||
) {
|
||||
return {
|
||||
kind: "refused",
|
||||
message: "Unknown medication category",
|
||||
status: 422,
|
||||
meta: { errorCode: "medications.category.unknown" },
|
||||
};
|
||||
}
|
||||
|
||||
if (externalSource !== undefined && externalId !== undefined) {
|
||||
const mirror = await existingMirrorOrCap(
|
||||
user.id,
|
||||
externalSource,
|
||||
externalId,
|
||||
);
|
||||
if (mirror) return mirror;
|
||||
}
|
||||
|
||||
const scheduleInputs = schedules ?? [];
|
||||
const oneShotRefusal = refuseOneShotShape(oneShot, scheduleInputs);
|
||||
if (oneShotRefusal) return oneShotRefusal;
|
||||
|
||||
// Normalise endsOn for one-shot. Zod gives us `Date | null | undefined`.
|
||||
const normalisedEndsOn =
|
||||
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
|
||||
|
||||
const course = resolveInitialCourse(
|
||||
user.timezone,
|
||||
startsOn,
|
||||
normalisedEndsOn,
|
||||
oneShot,
|
||||
);
|
||||
if ("refusal" in course) return course.refusal;
|
||||
const { initialCourse } = course;
|
||||
|
||||
let medication: Awaited<ReturnType<typeof insertMedication>>;
|
||||
try {
|
||||
medication = await insertMedication(user.id, data, {
|
||||
scheduleInputs,
|
||||
normalisedEndsOn,
|
||||
initialCourse,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// P2002 on the mirror triple: a concurrent create of the same
|
||||
// `(userId, externalSource, externalId)` won the race between the
|
||||
// pre-query above and this insert. Resolve to the winning row and answer
|
||||
// idempotently instead of 500-ing. Any other unique violation (there is
|
||||
// none on this table today) rethrows.
|
||||
const isP2002 =
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code: string }).code === "P2002";
|
||||
if (isP2002 && externalSource !== undefined && externalId !== undefined) {
|
||||
const winner = await prisma.medication.findFirst({
|
||||
where: { userId: user.id, externalSource, externalId },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (winner) return existingMirrorBody(winner);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const normalizedCategory = await setMedicationCategory(
|
||||
medication.id,
|
||||
category,
|
||||
);
|
||||
const categoryLabel = isCustomMedicationCategoryKey(normalizedCategory)
|
||||
? ((await resolveMedicationCategories([medication.id]))[medication.id]
|
||||
?.categoryLabel ?? null)
|
||||
: null;
|
||||
|
||||
await auditLog("medication.create", {
|
||||
userId: user.id,
|
||||
ipAddress: input.ipAddress,
|
||||
details: {
|
||||
medicationId: medication.id,
|
||||
name,
|
||||
// A medication created with its doses recorded automatically says so
|
||||
// in its audit row.
|
||||
autoMarkTaken: medication.autoMarkTaken,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.create",
|
||||
entity_type: "medication",
|
||||
entity_id: medication.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Bust per-user medications + compliance + achievement caches so the next
|
||||
// read reflects the new schedule.
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return {
|
||||
kind: "created",
|
||||
body: {
|
||||
...medication,
|
||||
unitsPerDose: Number(medication.unitsPerDose),
|
||||
// #219 — Decimal → number for the per-schedule column too.
|
||||
// (#1033) — record-only medications ship `schedules: []`.
|
||||
...scheduleWireFields(
|
||||
medication.trackIntake,
|
||||
serializeScheduleUnitsPerDose(
|
||||
medication.schedules,
|
||||
medication.unitsPerDose,
|
||||
),
|
||||
),
|
||||
category: normalizedCategory,
|
||||
categoryLabel,
|
||||
// (#1024) — the courses, as every medication read carries them.
|
||||
...(
|
||||
await resolveCourseFields(
|
||||
[medication],
|
||||
new Date(),
|
||||
user.timezone || DEFAULT_TIMEZONE,
|
||||
)
|
||||
).get(medication.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user