feat(medications): read a dose-history export directly

The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 16:37:10 +02:00
1 parent f0c5b5ae94
commit 11e6402b5a
13 files changed
+1748 -192

No files matched your search

@@ -2,8 +2,8 @@ import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
interface RouteContext {
params: Promise<{ id: string; jobId: string }>;
@@ -20,20 +20,12 @@ export const GET = apiHandler(
meta: { job_id: jobId, medication_id: medicationId },
});
const row = await prisma.medicationIntakeImportJob.findFirst({
where: { id: jobId, medicationId, userId: user.id },
});
if (!row) return apiError("Import job not found", 404);
return apiSuccess({
jobId: row.id,
status: row.status,
progress: row.progress,
result: row.result,
failureReason: row.failureReason,
createdAt: row.createdAt.toISOString(),
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
});
const job = await readMedicationIntakeImportJob(
user.id,
jobId,
medicationId,
);
if (!job) return apiError("Import job not found", 404);
return apiSuccess(job);
},
);
@@ -0,0 +1,30 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readMedicationIntakeImportJob } from "@/lib/medications/intake-import-job-status";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteContext) => {
const { user } = await requireAuth();
const { jobId } = await params;
annotate({
action: { name: "medication.intake.history.import.status" },
meta: { job_id: jobId },
});
// No medication argument: this reads the account-wide job, and the helper
// narrows on `medicationId: null` so a per-medication job id cannot be read
// through here (or the other way round).
const job = await readMedicationIntakeImportJob(user.id, jobId);
if (!job) return apiError("Import job not found", 404);
return apiSuccess(job);
},
);
@@ -0,0 +1,317 @@
/**
* Import a dose history exported from another medication tracker.
*
* The existing per-medication import takes a hand-written list of dates and
* times for one medication. That is what a self-hoster with a ten-column,
* 3,395-row export covering sixteen medications was left translating by hand,
* guessing at what each field meant. This route reads that file's own shape, so
* nobody has to.
*
* What the shape earns it: the export keeps the scheduled time and the time the
* dose was actually taken in two separate columns, which is exactly the pair
* `MedicationIntakeEvent` already models as `scheduledFor` + `takenAt`. That
* distinction is the first casualty of translating a file by hand, and it is the
* one that decides whether a dose reads as on time or late for the rest of its
* life.
*
* Body is the file's raw text — `text/csv` for the ten-column export,
* `application/json` for the documented JSON shape. Capped at 16 MB, the same
* ceiling the measurement CSV import carries.
*
* `?dryRun=1` parses, matches every row against the record, and returns the
* whole verdict WITHOUT writing: how many rows land, how many are refused and
* for which reason, which medication names match nothing. A bulk import of years
* of history should be inspectable before it happens.
*
* The write itself runs on the same background job the per-medication import
* uses — chunked, heartbeated, resumable, with the rollup recompute chunked
* behind it. A path that works on 28 rows and dies on 30,000 is not an import.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
MEDICATION_INTAKE_IMPORT_QUEUE,
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
groupMedicationImportSkips,
totalMedicationImportSkips,
type MedicationImportPayload,
type MedicationImportProgress,
} from "@/lib/jobs/medication-intake-import";
import { annotate } from "@/lib/logging/context";
import {
parseAutoExportCsv,
parseAutoExportJson,
} from "@/lib/medications/import/auto-export-parse";
import {
planAutoExportImport,
type AutoExportPlan,
} from "@/lib/medications/import/auto-export-plan";
import { checkRateLimit } from "@/lib/rate-limit";
/** Same ceiling as the measurement CSV import. */
const MAX_BYTES = 16 * 1024 * 1024;
/**
* Rows the importer will queue in one submission.
*
* The write loop reads the whole payload out of the job row on every chunk, so
* the cost of a submission grows with rows × chunks. At 30,000 rows that is a
* few hundred megabytes of read across a couple of hundred short transactions —
* slow but bounded, heartbeated, and resumable. Beyond it the shape stops being
* the right one, and saying so is better than accepting a file that then crawls.
*/
const MAX_QUEUED_ROWS = 30_000;
/**
* Names echoed back so the person can act on them. Bounded: an unmatched name is
* a hint, and a file naming five hundred unknown medications needs a different
* conversation than a longer list.
*/
const MAX_NAMES_REPORTED = 50;
/** Blast-radius bucket shared with every other bulk import surface. */
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
function cap(names: readonly string[]): string[] {
return names.slice(0, MAX_NAMES_REPORTED);
}
/** The file-level verdict, identical for a dry run and a real submission. */
function describePlan(plan: AutoExportPlan) {
return {
rowsRead: plan.rowsRead,
queued: plan.entries.length,
refused: totalMedicationImportSkips(plan.skippedByReason),
refusedByReason: groupMedicationImportSkips(plan.skippedByReason),
unmatchedMedications: cap(plan.unmatchedMedications),
ambiguousMedications: cap(plan.ambiguousMedications),
mirroredMedications: cap(plan.mirroredMedications),
unknownColumns: cap(plan.unknownColumns),
codingsNotRead: plan.csvCodingsIgnored,
fromArchivedMedications: plan.fromArchivedMedications,
};
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const dryRunParam = new URL(request.url).searchParams.get("dryRun");
const dryRun = dryRunParam === "1" || dryRunParam === "true";
annotate({
action: { name: "medication.intake.history.import" },
meta: { dry_run: dryRun },
});
const rl = await checkRateLimit(
`import:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError(`Maximum ${RATE_LIMIT_MAX} imports per hour`, 429);
}
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
const isJson = contentType.includes("application/json");
const isText =
contentType.includes("text/csv") || contentType.includes("text/plain");
if (!isJson && !isText) {
return apiError("Content-Type must be text/csv or application/json", 415);
}
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) {
return apiError("File exceeds the 16 MB limit", 413);
}
let text: string;
try {
text = await request.text();
} catch {
return apiError("Could not read the request body", 400);
}
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) {
return apiError("File exceeds the 16 MB limit", 413);
}
const parsed = isJson ? parseAutoExportJson(text) : parseAutoExportCsv(text);
if (parsed.fatal) {
annotate({
action: { name: "medication.intake.history.import.refused" },
meta: { reason: parsed.fatal.reason },
});
// The reason travels as a machine-readable code so the client can render the
// sentence that names the actual problem — a JSON export that carries no
// intake time needs a different answer than a truncated file.
return apiError("The file could not be read", 422, {
errorCode: parsed.fatal.reason,
...(parsed.fatal.detail === undefined
? {}
: { detail: parsed.fatal.detail }),
});
}
const medications = await prisma.medication.findMany({
where: { userId: user.id },
// Every medication, active or not: a dose taken in 2023 belongs to the
// medication whether or not the person has since stopped taking it.
select: { id: true, name: true, externalSource: true },
});
const plan = planAutoExportImport(parsed, medications);
if (plan.entries.length > MAX_QUEUED_ROWS) {
return apiError(
`The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`,
422,
{ errorCode: "too_many_rows" },
);
}
const file = describePlan(plan);
if (dryRun) {
return apiSuccess({ dryRun: true, jobId: null, statusUrl: null, file });
}
const payload: MedicationImportPayload = { entries: plan.entries };
// Rows refused before the queue are seeded into the progress the worker
// advances, so the count the person reads at the end covers the whole file and
// not just the part that reached the database.
const progress: MedicationImportProgress = {
processed: 0,
total: plan.entries.length,
imported: 0,
skippedByReason: plan.skippedByReason,
touchedDays: [],
rollupProcessed: 0,
};
const admission = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT "id"
FROM "users"
WHERE "id" = ${user.id}
FOR UPDATE
`;
const now = new Date();
const staleBefore = new Date(
now.getTime() - MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
);
await tx.medicationIntakeImportJob.updateMany({
where: {
userId: user.id,
medicationId: null,
status: { in: ["queued", "running"] },
OR: [
{ heartbeatAt: { lt: staleBefore } },
{ heartbeatAt: null, startedAt: { lt: staleBefore } },
{
heartbeatAt: null,
startedAt: null,
createdAt: { lt: staleBefore },
},
],
},
data: {
status: "failed",
failureReason: "Medication intake import abandoned",
heartbeatAt: now,
completedAt: now,
},
});
const activeJob = await tx.medicationIntakeImportJob.findFirst({
where: {
userId: user.id,
medicationId: null,
status: { in: ["queued", "running"] },
},
select: { id: true },
});
if (activeJob) return { active: true as const };
const importJob = await tx.medicationIntakeImportJob.create({
data: {
userId: user.id,
medicationId: null,
status: "queued",
payload: toJson(payload),
progress: toJson(progress),
},
});
return { active: false as const, importJob };
});
if (admission.active) {
return apiError("A dose-history import is already in progress", 409);
}
const { importJob } = admission;
const statusUrl = `/api/medications/intake/dose-history-import/${importJob.id}/status`;
let bossJobId: string | null = null;
try {
const boss = getGlobalBoss();
if (!boss) throw new Error("worker unavailable");
bossJobId = await boss.send(
MEDICATION_INTAKE_IMPORT_QUEUE,
{ jobId: importJob.id },
MEDICATION_INTAKE_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) throw new Error("queue rejected job");
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: { pgBossJobId: bossJobId },
});
} catch {
const completedAt = new Date();
await prisma.medicationIntakeImportJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason: "Background worker enqueue failed",
heartbeatAt: completedAt,
completedAt,
},
});
await auditLog("medication.intake.import.kickoff.denied", {
userId: user.id,
ipAddress: getClientIp(request),
details: { jobId: importJob.id, reason: "enqueue_failed" },
});
return apiError("Background worker is not available", 503);
}
await auditLog("medication.intake.history.import.kickoff", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
jobId: importJob.id,
bossJobId,
rowsRead: file.rowsRead,
queued: file.queued,
refused: file.refused,
},
});
annotate({
action: { name: "medication.intake.history.import.kickoff" },
meta: {
job_id: importJob.id,
rows_read: file.rowsRead,
queued: file.queued,
refused: file.refused,
},
});
return apiSuccess(
{
dryRun: false,
jobId: importJob.id,
status: "queued" as const,
statusUrl,
file,
},
202,
);
});
+194 -36
View File
@@ -24,8 +24,28 @@ export const MEDICATION_INTAKE_IMPORT_SEND_OPTIONS = {
} as const;
export interface MedicationImportEntry {
takenAt: string;
/**
* The slot instant this dose belongs to, and the fact it is identified by.
* Equals `takenAt` for an ad-hoc dose that belongs to no slot.
*/
scheduledFor: string;
/** When the dose was actually taken. `null` for a deliberate skip. */
takenAt: string | null;
idempotencyKey: string;
/**
* The medication this dose attaches to. Carried per entry because a dose
* history exported from another app covers a whole regimen in one file, so the
* job cannot be scoped to a single medication the way the per-medication
* import route's job is. Absent on that route's jobs, where the job row names
* the medication instead.
*/
medicationId?: string;
/**
* Per-dose override, free text, mirroring `Medication.dose`. Absent when the
* configured schedule dose applies — which is the overwhelming majority, and
* absence is how `MedicationIntakeEvent.doseTaken` already says so.
*/
doseTaken?: string;
}
export interface MedicationImportPayload {
@@ -33,22 +53,40 @@ export interface MedicationImportPayload {
}
/**
* Why a submitted entry did not become a row. Machine-readable; the client
* turns each into a sentence.
* Why a submitted row did not become a row on the ledger. Machine-readable; the
* client turns each into a sentence.
*
* `duplicate_in_file` — two entries in the same submission resolve to the same
* instant, so only one can be written.
* `already_recorded` — the dose is already on the ledger for that instant, from
* an earlier import or from any other intake surface.
* `duplicate_in_file` — two rows of the same submission resolve to the same
* medication and slot, so only one can be written.
* `already_recorded` — the dose is already on the ledger for that slot, from an
* earlier import or from any other intake surface.
*
* They used to be one `skippedDuplicates` number, which is how a file whose
* every row collapsed onto one key could report "27 duplicates skipped" about
* 27 doses that had no duplicate anywhere. Naming the reason is what makes the
* count checkable against what the person actually submitted.
* Those two used to be one `skippedDuplicates` number, which is how a file whose
* every row collapsed onto one key could report "27 duplicates skipped" about 27
* doses that had no duplicate anywhere. Naming the reason is what makes the count
* checkable against what the person actually submitted.
*
* The rest are decided before the queue, when a whole export file is parsed and
* matched against the record. They are seeded into the job's progress so the
* final count covers the entire file rather than only the part that reached the
* database — a row refused at the door is still a row the person needs told
* about, by name.
*/
export const MEDICATION_IMPORT_SKIP_REASONS = [
"duplicate_in_file",
"already_recorded",
"medication_not_found",
"medication_ambiguous",
"medication_is_mirrored",
"status_not_recorded",
"status_unknown",
"missing_timestamp",
"missing_timezone_offset",
"unreadable_timestamp",
"implausible_timestamp",
"missing_medication",
"unreadable_dosage",
"unreadable_row",
] as const;
export type MedicationImportSkipReason =
@@ -197,16 +235,49 @@ function parsePayload(value: Prisma.JsonValue): MedicationImportPayload {
typeof entry !== "object" ||
entry === null ||
Array.isArray(entry) ||
typeof entry.takenAt !== "string" ||
!Number.isFinite(Date.parse(entry.takenAt)) ||
typeof entry.idempotencyKey !== "string" ||
entry.idempotencyKey.length === 0
) {
throw new Error("Medication intake import entry is malformed");
}
const takenAt = entry.takenAt;
if (
takenAt !== null &&
(typeof takenAt !== "string" || !Number.isFinite(Date.parse(takenAt)))
) {
throw new Error("Medication intake import entry is malformed");
}
// `scheduledFor` was added when the importer learnt to read an export that
// states the scheduled time and the time taken as two separate facts. A job
// row enqueued before that carries only `takenAt`, and such a row is an
// ad-hoc dose anchored on itself — which is what the absence means. This is
// for rows already in the database, not for a caller.
const rawScheduledFor =
entry.scheduledFor === undefined ? takenAt : entry.scheduledFor;
if (
typeof rawScheduledFor !== "string" ||
!Number.isFinite(Date.parse(rawScheduledFor))
) {
throw new Error("Medication intake import entry is malformed");
}
if (
entry.medicationId !== undefined &&
(typeof entry.medicationId !== "string" ||
entry.medicationId.length === 0)
) {
throw new Error("Medication intake import entry is malformed");
}
if (entry.doseTaken !== undefined && typeof entry.doseTaken !== "string") {
throw new Error("Medication intake import entry is malformed");
}
return {
takenAt: entry.takenAt,
scheduledFor: rawScheduledFor,
takenAt: takenAt as string | null,
idempotencyKey: entry.idempotencyKey,
...(entry.medicationId === undefined
? {}
: { medicationId: entry.medicationId }),
...(entry.doseTaken === undefined ? {} : { doseTaken: entry.doseTaken }),
};
});
return { entries };
@@ -287,23 +358,64 @@ interface ProcessChunkOutcome {
userId: string | null;
}
/**
* A day whose compliance rollup an import invalidated, as one string.
*
* A job can now span a whole regimen, so a bare day key no longer identifies
* what to recompute — two medications touched on the same day are two rollup
* rows. The medication is folded in ahead of a single space, which occurs in
* neither a cuid nor a `YYYY-MM-DD` key. Not a NUL byte: this value is persisted
* in the progress blob and Postgres rejects `` inside a jsonb string.
*/
const TOUCHED_DAY_SEPARATOR = "";
function encodeTouchedDay(medicationId: string, dayKey: string): string {
return `${medicationId}${TOUCHED_DAY_SEPARATOR}${dayKey}`;
}
/**
* Split a stored touched-day back into its parts.
*
* A value with no separator was written before a job could span more than one
* medication, so it is a bare day key belonging to the job's own medication.
* Returns null when neither reading yields a medication — a per-medication job
* that lost its medication cannot be resolved and the day is left alone rather
* than recomputed against a guess.
*/
function decodeTouchedDay(
value: string,
fallbackMedicationId: string | null,
): { medicationId: string; dayKey: string } | null {
const separator = value.indexOf(TOUCHED_DAY_SEPARATOR);
if (separator === -1) {
if (!fallbackMedicationId) return null;
return { medicationId: fallbackMedicationId, dayKey: value };
}
const medicationId = value.slice(0, separator);
const dayKey = value.slice(separator + 1);
if (medicationId.length === 0 || dayKey.length === 0) return null;
return { medicationId, dayKey };
}
async function recomputeTouchedDays(
client: Pick<PrismaClient, "$executeRaw">,
userId: string,
medicationId: string,
jobMedicationId: string | null,
timezone: string,
days: readonly string[],
): Promise<void> {
await Promise.all(
days.map((day) =>
recomputeMedicationComplianceForDay(
days.map((day) => {
const decoded = decodeTouchedDay(day, jobMedicationId);
if (!decoded) return Promise.resolve();
return recomputeMedicationComplianceForDay(
userId,
medicationId,
day,
decoded.medicationId,
decoded.dayKey,
timezone,
client,
),
),
);
}),
);
}
@@ -426,35 +538,81 @@ async function processNextChunk(
entries.map((entry) => [entry.idempotencyKey, entry]),
).values(),
];
// An entry names its own medication when the job came from an export file
// covering a whole regimen; the per-medication route's jobs name it on the
// job row instead. One of the two must be there, and a payload with
// neither is a malformed job rather than a row to write against a guess.
const resolveMedicationId = (entry: MedicationImportEntry): string => {
const medicationId = entry.medicationId ?? row.medicationId;
if (!medicationId) {
throw new Error("Medication intake import entry names no medication");
}
return medicationId;
};
const created = await tx.medicationIntakeEvent.createManyAndReturn({
data: uniqueEntries.map((entry) => {
const takenAt = new Date(entry.takenAt);
const scheduledFor = new Date(entry.scheduledFor);
const takenAt =
entry.takenAt === null ? null : new Date(entry.takenAt);
return {
userId: row.userId,
medicationId: row.medicationId,
scheduledFor: takenAt,
medicationId: resolveMedicationId(entry),
// The scheduled slot and the time taken are two separate facts, and
// the export states both. Writing the take into both columns — which
// is all the old two-field payload could express — asserts every
// dose landed exactly on time, and no later edit recovers what that
// overwrote.
scheduledFor,
takenAt,
skipped: false,
// A null take on an imported row is a deliberate skip: the source
// recorded that the person chose not to take that dose.
skipped: takenAt === null,
source: "IMPORT" as const,
idempotencyKey: entry.idempotencyKey,
...(entry.doseTaken === undefined
? {}
: { doseTaken: entry.doseTaken }),
};
}),
skipDuplicates: true,
select: { id: true, takenAt: true, scheduledFor: true },
select: {
id: true,
medicationId: true,
takenAt: true,
scheduledFor: true,
},
});
await consumeImportedIntakesBatch({
client: tx,
userId: row.userId,
medicationId: row.medicationId,
events: created.map((event) => ({
eventId: event.id,
intakeAt: event.takenAt ?? event.scheduledFor,
})),
});
// Inventory is per medication and a skip consumes nothing, so the created
// rows are grouped and the skips left out. `consumeImportedIntakesBatch`
// asserts it claimed every event handed to it, which only holds for rows
// that actually carry a take.
const consumedByMedication = new Map<
string,
Array<{ eventId: string; intakeAt: Date }>
>();
for (const event of created) {
if (event.takenAt === null) continue;
const bucket = consumedByMedication.get(event.medicationId);
const entry = { eventId: event.id, intakeAt: event.takenAt };
if (bucket) bucket.push(entry);
else consumedByMedication.set(event.medicationId, [entry]);
}
for (const [medicationId, events] of consumedByMedication) {
await consumeImportedIntakesBatch({
client: tx,
userId: row.userId,
medicationId,
events,
});
}
const touchedDays = created.map((event) =>
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
encodeTouchedDay(
event.medicationId,
dayKeyForScheduledFor(event.scheduledFor, row.user.timezone),
),
);
// Two different facts, counted apart. The Map collapse above is entries
// of this submission that resolve to the same instant; `skipDuplicates`
@@ -0,0 +1,180 @@
/**
* The medication dose-history export format, read directly.
*
* A self-hoster was hand-mapping this export into the intake importer's
* two-field shape and guessing at what belonged where. Hand-mapping is what
* destroyed the part that matters: the export keeps the scheduled time and the
* time actually taken as two separate columns, which is exactly the pair
* `MedicationIntakeEvent` models as `scheduledFor` + `takenAt`. Flattening the
* file collapses them into one.
*
* Two shapes exist. The CSV carries ten columns; the JSON carries the
* documented per-dose object. Both are read here, and every column gets a
* verdict that the importing person can read before choosing a file — a column
* dropped in silence is the failure this format work exists to remove.
*
* Nothing in this module touches a database. It is the vocabulary and the
* verdicts; `auto-export-parse.ts` applies them.
*/
/** The CSV header, in the order the export writes it. */
export const AUTO_EXPORT_CSV_COLUMNS = [
"Date",
"Scheduled Date",
"Medication",
"Nickname",
"Dosage",
"Scheduled Dosage",
"Unit",
"Status",
"Archived",
"Codings",
] as const;
export type AutoExportCsvColumn = (typeof AUTO_EXPORT_CSV_COLUMNS)[number];
/**
* What the importer does with a column.
*
* `honoured` — the value reaches the record, or decides whether a row does.
* `reported` — read, never stored; it appears in the run summary so the
* person can see what the file said.
* `ignored` — deliberately not read, with a stated reason.
*/
export type AutoExportColumnVerdict = "honoured" | "reported" | "ignored";
export interface AutoExportColumnRuling {
column: string;
verdict: AutoExportColumnVerdict;
/**
* Stable key for the sentence explaining the ruling. The UI resolves it with
* a literal `t()` call per key so the i18n call-site guard sees every one.
*/
noteKey: string;
/** True for a field only the JSON shape carries. */
jsonOnly?: boolean;
}
/**
* Every field of both shapes with its ruling. Exhaustive by construction: a
* structural test asserts each CSV column and each documented JSON field
* appears exactly once, so a column cannot be added to the parser without a
* verdict landing here.
*/
export const AUTO_EXPORT_COLUMN_RULINGS: readonly AutoExportColumnRuling[] = [
{ column: "Date", verdict: "honoured", noteKey: "date" },
{ column: "Scheduled Date", verdict: "honoured", noteKey: "scheduledDate" },
{ column: "Medication", verdict: "honoured", noteKey: "medication" },
{ column: "Nickname", verdict: "honoured", noteKey: "nickname" },
{ column: "Dosage", verdict: "honoured", noteKey: "dosage" },
{
column: "Scheduled Dosage",
verdict: "honoured",
noteKey: "scheduledDosage",
},
{ column: "Unit", verdict: "honoured", noteKey: "unit" },
{ column: "Status", verdict: "honoured", noteKey: "status" },
{ column: "Archived", verdict: "reported", noteKey: "archived" },
{ column: "Codings", verdict: "ignored", noteKey: "codings" },
{ column: "form", verdict: "ignored", noteKey: "form", jsonOnly: true },
{ column: "start", verdict: "ignored", noteKey: "start", jsonOnly: true },
{ column: "end", verdict: "ignored", noteKey: "end", jsonOnly: true },
] as const;
/**
* Documented JSON field names. `displayText` is the JSON spelling of the CSV's
* `Medication`, `isArchived` of `Archived`, `scheduledDate` of `Scheduled
* Date`; the CSV's `Scheduled Dosage` has no JSON counterpart at all.
*/
export const AUTO_EXPORT_JSON_FIELDS = [
"displayText",
"nickname",
"form",
"start",
"end",
"scheduledDate",
"status",
"isArchived",
"dosage",
"codings",
] as const;
/**
* Every status the format documents.
*
* `Taken` and `Skipped` each name a decision the person made about a dose, and
* HealthLog already models both — a taken row and a deliberate skip. The rest
* name the absence of a decision: nobody acted, or the app never asked. There
* is no honest row to write for those. Turning "nobody acted" into a missed
* dose would be the importer inventing a compliance fact the export never
* asserted, and writing them as still-pending would put outstanding doses from
* 2023 back in front of the person today.
*/
export const AUTO_EXPORT_STATUSES = [
"Taken",
"Skipped",
"Not Interacted",
"Notification Not Sent",
"Snoozed",
"Not Logged",
"Unspecified",
] as const;
export type AutoExportStatus = (typeof AUTO_EXPORT_STATUSES)[number];
/** The two statuses that become a row. */
export const AUTO_EXPORT_ACTIONABLE_STATUSES = ["Taken", "Skipped"] as const;
export function isAutoExportStatus(value: string): value is AutoExportStatus {
return (AUTO_EXPORT_STATUSES as readonly string[]).includes(value);
}
export function isActionableAutoExportStatus(value: string): boolean {
return (AUTO_EXPORT_ACTIONABLE_STATUSES as readonly string[]).includes(value);
}
/**
* Where the export's codings WOULD land, and why they do not land there today.
*
* The format documents `codings` as `[{ code, system, version }]` with systems
* such as RxNorm and NDC. HealthLog already holds two of those: `atcCode`
* (WHO ATC) and `rxNormCode` (RxCUI) on `Medication`. A code is a far stronger
* identity than a display name someone may have typed differently in each app,
* so it is the natural way to match a row to a medication.
*
* It is not read, for two reasons that are about evidence rather than effort.
* The CSV shape — the only shape that carries a dose history at all — gives the
* `Codings` column no documented encoding, and the sample export leaves it empty
* on every row, so there is nothing to derive a reader from and no way to test
* one. The JSON shape does document the structure but carries no intake time, so
* it is refused whole. A populated `Codings` cell is therefore counted and
* reported rather than guessed at.
*
* If a shape turns up that carries both, these are the columns waiting for it —
* for matching only. `atcCode` and `rxNormCode` are asserted by the person or
* their clinician, and an import file is neither.
*/
/**
* Fold a dose amount and its unit into the free-text per-dose override.
*
* `count` is the export's word for "this many of the configured dose" and
* carries nothing about substance amount, so the number stands alone. Any other
* unit is echoed exactly as the file wrote it — `0.4 mL`, not a translated or
* re-cased guess at which of HealthLog's own unit keys was meant.
*/
export function formatAutoExportDose(amount: number, unit: string): string {
const trimmed = unit.trim();
// `String` already drops a trailing `.0`, which is how the export writes
// whole amounts: `1.0` becomes `1`, `0.25` stays `0.25`.
const rendered = String(amount);
if (trimmed.length === 0 || trimmed.toLowerCase() === "count") {
return rendered;
}
return `${rendered} ${trimmed}`;
}
/** Normalise a medication name for matching: trimmed, folded, case-blind. */
export function normaliseMedicationName(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
}
@@ -0,0 +1,127 @@
/**
* Reading an instant that carries its own UTC offset.
*
* The medication export writes `2023-02-16 08:38:00 +1030` — a space instead
* of `T`, and a compact offset with no colon. Neither form is ISO 8601, so
* `new Date(...)` on it is engine-dependent leniency rather than a contract:
* V8 happens to accept it, another runtime need not, and a silent `Invalid
* Date` or a reinterpretation in the server's zone would move every dose in
* the file by hours. A file spanning an Australian summer carries `+1030` and
* `+0930` on adjacent rows, so the offset is per-row data, not a file-level
* setting to be guessed at once.
*
* So the offset is read explicitly and the epoch computed arithmetically:
* `Date.UTC(wall clock) - offset`. Nothing here parses a date from a string
* through the platform. A value without an offset is refused rather than
* assumed — the server's zone is not evidence about where the dose was taken.
*
* `src/lib/tz/` is the project's timezone layer and it stays the authority for
* the other direction (an instant plus a zone name to a local day key, which
* is what the compliance rollups key on). It carries no parser, by design: it
* converts an offset-less wall clock plus a zone NAME to UTC. An offset is not
* a zone name, so this is the one thing it cannot do, and this module is
* deliberately the narrowest possible complement to it.
*/
/** Widest real UTC offset in either direction (±14:00, Kiritimati). */
const MAX_OFFSET_MINUTES = 14 * 60;
/**
* `YYYY-MM-DD` `HH:MM:SS` with an optional fractional second, then either `Z`
* or a `±HHMM` / `±HH:MM` offset. Both the space and the `T` separator are
* accepted: the CSV uses a space, and a hand-edited file may well use `T`.
*/
const OFFSET_INSTANT_RE =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?\s*(?:(Z|z)|([+-])(\d{2}):?(\d{2}))$/;
export type AutoExportInstantFailure =
/** The cell was blank. Distinct from unreadable: nothing was stated. */
| "absent"
/** Shaped like a timestamp but carries no offset, so the instant is unknown. */
| "missing_offset"
/** Does not match the documented `yyyy-MM-dd HH:mm:ss Z` shape at all. */
| "unreadable"
/** Matches the shape but names no real instant (month 13, offset ±99:00). */
| "out_of_range";
export type AutoExportInstantResult =
| { ok: true; instant: Date; offsetMinutes: number }
| { ok: false; failure: AutoExportInstantFailure };
/**
* True when the value looks like a date-time that simply lost its offset, so
* the refusal can say "no offset" rather than the less useful "unreadable".
*/
const OFFSETLESS_INSTANT_RE =
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?(\.\d{1,9})?$/;
/**
* Parse one `Date` / `Scheduled Date` cell.
*
* Returns the absolute instant and the offset that produced it. The offset is
* handed back rather than discarded so a caller can prove, in a test, that the
* value the file stated is the value that survived.
*/
export function parseAutoExportInstant(
raw: string | null | undefined,
): AutoExportInstantResult {
const value = (raw ?? "").trim();
if (value.length === 0) return { ok: false, failure: "absent" };
const match = OFFSET_INSTANT_RE.exec(value);
if (!match) {
return {
ok: false,
failure: OFFSETLESS_INSTANT_RE.test(value)
? "missing_offset"
: "unreadable",
};
}
const [, y, mo, d, h, mi, s, zulu, sign, offH, offM] = match;
const year = Number(y);
const month = Number(mo);
const day = Number(d);
const hour = Number(h);
const minute = Number(mi);
const second = Number(s);
if (month < 1 || month > 12 || day < 1 || day > 31) {
return { ok: false, failure: "out_of_range" };
}
if (hour > 23 || minute > 59 || second > 59) {
return { ok: false, failure: "out_of_range" };
}
let offsetMinutes = 0;
if (!zulu) {
const offsetHours = Number(offH);
const offsetRemainder = Number(offM);
if (offsetRemainder > 59) return { ok: false, failure: "out_of_range" };
offsetMinutes =
(sign === "-" ? -1 : 1) * (offsetHours * 60 + offsetRemainder);
if (Math.abs(offsetMinutes) > MAX_OFFSET_MINUTES) {
return { ok: false, failure: "out_of_range" };
}
}
// `Date.UTC` rolls a nonexistent calendar day over (Feb 30 becomes Mar 2),
// so the components are read back and compared. A rolled-over value means
// the file named a day that does not exist, which is a refusal, not a
// silently shifted dose.
const wallClockMs = Date.UTC(year, month - 1, day, hour, minute, second);
const probe = new Date(wallClockMs);
if (
probe.getUTCFullYear() !== year ||
probe.getUTCMonth() !== month - 1 ||
probe.getUTCDate() !== day
) {
return { ok: false, failure: "out_of_range" };
}
const instant = new Date(wallClockMs - offsetMinutes * 60_000);
if (Number.isNaN(instant.getTime())) {
return { ok: false, failure: "out_of_range" };
}
return { ok: true, instant, offsetMinutes };
}
@@ -0,0 +1,452 @@
/**
* The medication dose-history export, from file text to per-row facts.
*
* Pure and synchronous: no database, no clock beyond an injectable `now`.
*
* Every row is either a dose or a named refusal. There is no third outcome, and
* nothing is dropped between the two: a test asserts that
* `doses.length + refusals.length` equals the number of data rows read.
*
* Only the CSV shape carries a dose history. The documented JSON shape is read
* too, and refused, for the reason set out in {@link parseAutoExportJson} — a
* refusal that says why is worth more than a lossy import.
*/
import {
ENTRY_INSTANT_FAR_PAST,
isPlausibleEntryInstant,
} from "@/lib/validations/entry-instant";
import { splitCsvRows } from "@/lib/import/csv-measurements";
import {
AUTO_EXPORT_CSV_COLUMNS,
formatAutoExportDose,
isActionableAutoExportStatus,
isAutoExportStatus,
} from "./auto-export-format";
import {
parseAutoExportInstant,
type AutoExportInstantFailure,
} from "./auto-export-instant";
/** One dose the file states, in HealthLog's own terms. */
export interface AutoExportDose {
/** 1-based source line, so a refusal can be pointed at. */
line: number;
/** The name as written, kept verbatim for the unmatched-medication report. */
medicationName: string;
/** The export's optional second name for the same medication. */
nickname: string | null;
/**
* The slot this dose belongs to. The file's `Scheduled Date` when it states
* one; otherwise the actual instant, which is how HealthLog already anchors
* an ad-hoc dose that belongs to no slot.
*/
scheduledFor: Date;
/** When the dose was actually taken. `null` for a deliberate skip. */
takenAt: Date | null;
skipped: boolean;
/** Per-dose override, set only where the file records a real deviation. */
doseTaken: string | null;
/** The UTC offset the file stated for the actual instant, in minutes. */
offsetMinutes: number;
/** True when the row came from a medication archived in the source app. */
fromArchivedMedication: boolean;
/** True when the file named no slot, so this dose is ad-hoc. */
adHoc: boolean;
}
/**
* Why a row did not become a dose. Every one of these is surfaced with a count;
* none of them is a silent drop.
*/
export const AUTO_EXPORT_REFUSAL_REASONS = [
/** Status names no decision about the dose (`Not Interacted`, `Snoozed`, …). */
"status_not_recorded",
/** Status is not a value the format documents at all. */
"status_unknown",
/** `Date` is blank — there is no dose without an instant. */
"missing_timestamp",
/** A timestamp shaped right but carrying no UTC offset. */
"missing_timezone_offset",
/** A timestamp that does not match the documented shape. */
"unreadable_timestamp",
/** A timestamp naming no real instant, or one outside plausible capture. */
"implausible_timestamp",
/** `Medication` and `Nickname` both blank — the row names no medication. */
"missing_medication",
/** A dosage cell that is not a number. */
"unreadable_dosage",
/** A CSV line with fewer cells than the header declared. */
"unreadable_row",
] as const;
export type AutoExportRefusalReason =
(typeof AUTO_EXPORT_REFUSAL_REASONS)[number];
export interface AutoExportRefusal {
line: number;
reason: AutoExportRefusalReason;
}
/** A file-level problem: nothing can be read at all. */
export const AUTO_EXPORT_FATAL_REASONS = [
"empty_file",
"missing_required_columns",
"unreadable_json",
"json_not_an_array",
/** The JSON shape records no time a dose was actually taken. */
"json_carries_no_intake_time",
] as const;
export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number];
export interface AutoExportParseOutcome {
fatal?: { reason: AutoExportFatalReason; detail?: string };
doses: AutoExportDose[];
refusals: AutoExportRefusal[];
/** Data rows read, refusals included. `doses + refusals` must equal it. */
rowsRead: number;
/**
* Header cells the importer has no verdict for. Named rather than dropped: a
* column nobody mentions is the defect this format work exists to remove.
*/
unknownColumns: string[];
/**
* Rows whose `Codings` cell held something. Counted and left unread — the CSV
* shape gives that column no documented encoding, and guessing one would be
* the importer inventing a medication identity.
*/
csvCodingsIgnored: number;
/** Rows that came from a medication the source app has archived. */
fromArchivedMedications: number;
}
/**
* The past floor for an imported dose.
*
* HealthLog's own intake surfaces clamp a backdated dose to five years, which
* would refuse most of a real export — the reported file starts in 2023 and a
* longer history is ordinary. Importing years of history is the entire point of
* this route, so the floor is the absolute 1900 bound the shared validator
* carries and only the future side stays tight.
*/
const IMPORT_PAST_FLOOR_MS = Date.now() - ENTRY_INSTANT_FAR_PAST.getTime();
function refusalForInstantFailure(
failure: AutoExportInstantFailure,
): AutoExportRefusalReason {
switch (failure) {
case "absent":
return "missing_timestamp";
case "missing_offset":
return "missing_timezone_offset";
case "out_of_range":
return "implausible_timestamp";
case "unreadable":
return "unreadable_timestamp";
}
}
/** `Yes` / `true` / `1`; anything else, blank included, reads as not archived. */
function readArchivedFlag(raw: string | null | undefined): boolean {
const value = (raw ?? "").trim().toLowerCase();
return value === "yes" || value === "true" || value === "1";
}
type DosageRead =
| { ok: true; amount: number | null }
| { ok: false; reason: "unreadable_dosage" };
/** A blank dosage cell states nothing; a non-numeric one is a refusal. */
function readDosage(raw: string): DosageRead {
const value = raw.trim();
if (value.length === 0) return { ok: true, amount: null };
if (!/^-?\d+(\.\d+)?$/.test(value)) {
return { ok: false, reason: "unreadable_dosage" };
}
const amount = Number(value);
return Number.isFinite(amount)
? { ok: true, amount }
: { ok: false, reason: "unreadable_dosage" };
}
/**
* The per-dose override, or `null`.
*
* Written only where the file records an actual amount that DIFFERS from the
* amount the schedule expected — the only case where the dose taken is a fact
* the schedule does not already carry. `doseTaken` is documented as "NULL = the
* configured medication / schedule dose applies", so restating an unremarkable
* amount on every one of three thousand rows would be noise dressed as
* information. Where the file states no scheduled amount there is nothing to
* compare against, and absence stays absence.
*/
function resolveDoseOverride(
actual: number | null,
scheduled: number | null,
unit: string,
): string | null {
if (actual === null || scheduled === null) return null;
if (actual === scheduled) return null;
return formatAutoExportDose(actual, unit);
}
interface RowInput {
line: number;
actual: string;
scheduled: string;
medication: string;
nickname: string;
dosage: string;
scheduledDosage: string;
unit: string;
status: string;
archived: string;
}
type RowOutcome =
| { ok: true; dose: AutoExportDose }
| { ok: false; reason: AutoExportRefusalReason };
/** The one place a row becomes a dose. */
function readRow(input: RowInput, now: number): RowOutcome {
const status = input.status.trim();
if (!isAutoExportStatus(status)) {
return { ok: false, reason: "status_unknown" };
}
if (!isActionableAutoExportStatus(status)) {
return { ok: false, reason: "status_not_recorded" };
}
const medicationName = input.medication.trim();
const nickname = input.nickname.trim();
if (medicationName.length === 0 && nickname.length === 0) {
return { ok: false, reason: "missing_medication" };
}
const actual = parseAutoExportInstant(input.actual);
if (!actual.ok) {
return { ok: false, reason: refusalForInstantFailure(actual.failure) };
}
// A blank `Scheduled Date` is not a defect: the export writes it for a dose
// logged outside any slot. HealthLog anchors exactly that case with
// `scheduledFor === takenAt`, so the absence maps onto an existing notion
// instead of being invented into one.
const scheduledRead = parseAutoExportInstant(input.scheduled);
let scheduledFor: Date;
let adHoc: boolean;
if (scheduledRead.ok) {
scheduledFor = scheduledRead.instant;
adHoc = false;
} else if (scheduledRead.failure === "absent") {
scheduledFor = actual.instant;
adHoc = true;
} else {
return {
ok: false,
reason: refusalForInstantFailure(scheduledRead.failure),
};
}
for (const instant of [actual.instant, scheduledFor]) {
if (
!isPlausibleEntryInstant(instant, { now, maxAgeMs: IMPORT_PAST_FLOOR_MS })
) {
return { ok: false, reason: "implausible_timestamp" };
}
}
const dosage = readDosage(input.dosage);
if (!dosage.ok) return { ok: false, reason: dosage.reason };
const scheduledDosage = readDosage(input.scheduledDosage);
if (!scheduledDosage.ok) return { ok: false, reason: scheduledDosage.reason };
const skipped = status === "Skipped";
return {
ok: true,
dose: {
line: input.line,
medicationName: medicationName.length > 0 ? medicationName : nickname,
nickname: nickname.length > 0 ? nickname : null,
scheduledFor,
// A skip is a dose that was deliberately not taken. The file's `Date` on
// such a row is when the person pressed skip, which is not a take, so
// `takenAt` stays null exactly as every other skip path writes it.
takenAt: skipped ? null : actual.instant,
skipped,
doseTaken: skipped
? null
: resolveDoseOverride(
dosage.amount,
scheduledDosage.amount,
input.unit,
),
offsetMinutes: actual.offsetMinutes,
fromArchivedMedication: readArchivedFlag(input.archived),
adHoc,
},
};
}
const CSV_HEADER_LOOKUP = new Map<string, string>(
AUTO_EXPORT_CSV_COLUMNS.map((column) => [column.toLowerCase(), column]),
);
/** `Date`, `Medication` and `Status` are the file's irreducible content. */
const CSV_REQUIRED_COLUMNS = ["Date", "Medication", "Status"] as const;
export interface AutoExportParseOptions {
/** Clock anchor for the plausibility bound. Injectable for tests. */
now?: number;
}
const EMPTY_OUTCOME: Omit<AutoExportParseOutcome, "fatal"> = {
doses: [],
refusals: [],
rowsRead: 0,
unknownColumns: [],
csvCodingsIgnored: 0,
fromArchivedMedications: 0,
};
/** Parse the ten-column CSV export. */
export function parseAutoExportCsv(
text: string,
options: AutoExportParseOptions = {},
): AutoExportParseOutcome {
const now = options.now ?? Date.now();
const rows = splitCsvRows(text);
if (rows.length === 0) {
return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
}
const header = rows[0];
const index = new Map<string, number>();
const unknownColumns: string[] = [];
header.forEach((cell, position) => {
const trimmed = cell.trim();
const canonical = CSV_HEADER_LOOKUP.get(trimmed.toLowerCase());
if (canonical) {
if (!index.has(canonical)) index.set(canonical, position);
return;
}
if (trimmed.length > 0) unknownColumns.push(trimmed);
});
// A slimmer export missing any of the other seven columns still imports, with
// the facts those columns carry honestly absent.
const missing = CSV_REQUIRED_COLUMNS.filter((column) => !index.has(column));
if (missing.length > 0) {
return {
...EMPTY_OUTCOME,
unknownColumns,
fatal: {
reason: "missing_required_columns",
detail: missing.join(", "),
},
};
}
const cellAt = (row: string[], column: string): string => {
const position = index.get(column);
if (position === undefined) return "";
return row[position] ?? "";
};
const doses: AutoExportDose[] = [];
const refusals: AutoExportRefusal[] = [];
let csvCodingsIgnored = 0;
let fromArchivedMedications = 0;
for (let i = 1; i < rows.length; i += 1) {
const row = rows[i];
const line = i + 1;
// A row shorter than the header has lost cells to a malformed quote or a
// hand edit, so the alignment is no longer trustworthy and no cell of it
// can be read at face value.
if (row.length < header.length) {
refusals.push({ line, reason: "unreadable_row" });
continue;
}
if (cellAt(row, "Codings").trim().length > 0) csvCodingsIgnored += 1;
if (readArchivedFlag(cellAt(row, "Archived"))) fromArchivedMedications += 1;
const outcome = readRow(
{
line,
actual: cellAt(row, "Date"),
scheduled: cellAt(row, "Scheduled Date"),
medication: cellAt(row, "Medication"),
nickname: cellAt(row, "Nickname"),
dosage: cellAt(row, "Dosage"),
scheduledDosage: cellAt(row, "Scheduled Dosage"),
unit: cellAt(row, "Unit"),
status: cellAt(row, "Status"),
archived: cellAt(row, "Archived"),
},
now,
);
if (outcome.ok) doses.push(outcome.dose);
else refusals.push({ line, reason: outcome.reason });
}
return {
doses,
refusals,
rowsRead: rows.length - 1,
unknownColumns,
csvCodingsIgnored,
fromArchivedMedications,
};
}
/**
* Read the documented JSON export — and refuse it, deliberately.
*
* The two shapes are not two encodings of the same thing. The CSV is a dose
* log: `Date` is when a dose was actually taken and `Scheduled Date` is the slot
* it belonged to, side by side. The documented JSON carries `scheduledDate`,
* `status`, `dosage`, and the parent medication's `start` / `end` / `form` — and
* no field for the time a dose was actually taken.
*
* So a `Taken` dose from the JSON shape could only be written by putting the
* scheduled time in `takenAt`, which asserts every dose was taken to the minute
* it was due. That is the exact flattening this whole path exists to undo: it
* manufactures a spotless on-time history out of a file that never claimed one,
* and no later correction can recover what was overwritten. A refusal that names
* the missing field leaves the person able to export the CSV, which has it.
*
* The shape is still parsed far enough to tell a JSON medication export apart
* from unreadable text, so the answer can be that sentence rather than a
* generic failure.
*/
export function parseAutoExportJson(text: string): AutoExportParseOutcome {
let decoded: unknown;
try {
decoded = JSON.parse(text);
} catch {
return { ...EMPTY_OUTCOME, fatal: { reason: "unreadable_json" } };
}
// The export wraps the array under a key in some of its output modes; both
// the bare array and a single-key envelope are recognised.
let list: unknown = decoded;
if (!Array.isArray(list) && typeof list === "object" && list !== null) {
const nested = Object.values(list as Record<string, unknown>).find(
(value) => Array.isArray(value),
);
if (nested) list = nested;
}
if (!Array.isArray(list)) {
return { ...EMPTY_OUTCOME, fatal: { reason: "json_not_an_array" } };
}
if (list.length === 0) {
return { ...EMPTY_OUTCOME, fatal: { reason: "empty_file" } };
}
return {
...EMPTY_OUTCOME,
rowsRead: list.length,
fatal: { reason: "json_carries_no_intake_time" },
};
}
@@ -0,0 +1,215 @@
/**
* From parsed doses to the queued import — medication matching and the payload.
*
* Pure: the caller passes the medications the account already holds, so the
* whole matching decision is testable without a database and the route stays a
* thin shell over it.
*
* The one policy decision that lives here is that a medication is never created
* from an import file. See {@link planAutoExportImport}.
*/
import type {
MedicationImportEntry,
MedicationImportSkipCounts,
MedicationImportSkipReason,
} from "@/lib/jobs/medication-intake-import";
import { normaliseMedicationName } from "./auto-export-format";
import type {
AutoExportDose,
AutoExportParseOutcome,
} from "./auto-export-parse";
/** A medication already on the record, as the matcher needs to see it. */
export interface MedicationMatchCandidate {
id: string;
name: string;
/**
* Non-null marks the medication a read-only mirror of an external list (the
* Apple Health medications sync). Such a row is source-exclusive by policy —
* only dose events from that same source attach to it — so an import cannot
* write to it.
*/
externalSource: string | null;
}
export interface AutoExportPlan {
/** What the worker will attempt to write, in file order. */
entries: MedicationImportEntry[];
/**
* Rows refused before the queue, by reason. Seeded into the job's progress so
* the final count the person reads covers the whole file and not just the part
* that reached the database.
*/
skippedByReason: MedicationImportSkipCounts;
/**
* Names the file used that match nothing on the record, deduplicated and in
* first-seen order. Surfaced verbatim so the person can rename or add the
* medication and re-run, rather than being told a number.
*/
unmatchedMedications: string[];
/** Names that match more than one medication, so no single one can be meant. */
ambiguousMedications: string[];
/** Names that match only a medication mirrored from an external list. */
mirroredMedications: string[];
/** Data rows read from the file, refusals included. */
rowsRead: number;
/** Rows the file marked as belonging to a medication archived in the source. */
fromArchivedMedications: number;
/** Rows whose `Codings` cell held something that was left unread. */
csvCodingsIgnored: number;
/** Header cells the importer has no verdict for. */
unknownColumns: string[];
}
/**
* An imported dose is identified by its medication and its slot instant.
*
* That is the grain the database already enforces through the live-row unique on
* `(user_id, medication_id, scheduled_for, source)`, so the replay key is a
* function of exactly the same facts. It was previously built from an optional
* counter field in the request body, which collapsed a month of distinct doses
* onto one key as soon as an export put something non-unique there.
*/
export function autoExportIdempotencyKey(
medicationId: string,
scheduledFor: Date,
): string {
return `import-${medicationId}-${scheduledFor.getTime()}`;
}
function bump(
counts: MedicationImportSkipCounts,
reason: MedicationImportSkipReason,
): void {
counts[reason] = (counts[reason] ?? 0) + 1;
}
/** Why a row's medication could not be resolved to one on the record. */
type MatchFailure =
"medication_not_found" | "medication_ambiguous" | "medication_is_mirrored";
/**
* Resolve every parsed dose against the account's medications and build the
* queue payload.
*
* **A medication is never created from an import file.** The argument for
* creating them is real: a 3,000-row export names its medications, and refusing
* every row of an unknown one makes the person do setup work by hand before the
* import is worth anything. The argument against wins anyway. An import file
* carries a display name and nothing else — no dose, no unit, no schedule, no
* form, no delivery route. A medication conjured from it would be a name with a
* blank dose that every schedule-aware surface then has to tolerate: compliance
* has no slots to expect, the inventory has no units per dose, reminders have
* nothing to fire on. And a name that differs by a word from one already on the
* record would silently fork the history in two, which is worse than a refusal,
* because a refusal is visible and a fork is not. The unmatched names are
* reported verbatim instead, so the person adds the medication properly once and
* re-runs; the import is idempotent, so re-running costs nothing.
*
* A name matching two medications is refused rather than resolved to whichever
* the database returned first.
*/
export function planAutoExportImport(
parsed: AutoExportParseOutcome,
medications: readonly MedicationMatchCandidate[],
): AutoExportPlan {
const skippedByReason: MedicationImportSkipCounts = {};
for (const refusal of parsed.refusals) bump(skippedByReason, refusal.reason);
const byName = new Map<string, MedicationMatchCandidate[]>();
for (const medication of medications) {
const key = normaliseMedicationName(medication.name);
if (key.length === 0) continue;
const bucket = byName.get(key);
if (bucket) bucket.push(medication);
else byName.set(key, [medication]);
}
const named: Record<MatchFailure, string[]> = {
medication_not_found: [],
medication_ambiguous: [],
medication_is_mirrored: [],
};
const namedSeen: Record<MatchFailure, Set<string>> = {
medication_not_found: new Set(),
medication_ambiguous: new Set(),
medication_is_mirrored: new Set(),
};
const entries: MedicationImportEntry[] = [];
const seenKeys = new Set<string>();
const resolve = (
dose: AutoExportDose,
): { ok: true; id: string } | { ok: false; reason: MatchFailure } => {
// The export can name the same medication twice: `Medication` is the display
// name and `Nickname` is the person's own label for it. Either may be the one
// that matches what the record calls it, so both are tried.
const candidates = [dose.medicationName, dose.nickname]
.filter((value): value is string => !!value && value.trim().length > 0)
.map(normaliseMedicationName)
.filter((value) => value.length > 0);
for (const key of candidates) {
const bucket = byName.get(key);
if (!bucket) continue;
if (bucket.length > 1) {
return { ok: false, reason: "medication_ambiguous" };
}
// A mirrored medication is a read-only copy of another app's list and only
// that app's dose events attach to it. Writing an imported dose onto it
// would put a row the mirror never asserted inside the mirror.
if (bucket[0].externalSource !== null) {
return { ok: false, reason: "medication_is_mirrored" };
}
return { ok: true, id: bucket[0].id };
}
return { ok: false, reason: "medication_not_found" };
};
for (const dose of parsed.doses) {
const match = resolve(dose);
if (!match.ok) {
bump(skippedByReason, match.reason);
const label = dose.medicationName;
if (!namedSeen[match.reason].has(label)) {
namedSeen[match.reason].add(label);
named[match.reason].push(label);
}
continue;
}
const idempotencyKey = autoExportIdempotencyKey(
match.id,
dose.scheduledFor,
);
// Two rows of one file resolving to the same medication and slot: only one
// can exist, and saying so here — where the whole file is in hand — counts
// it exactly, instead of leaving the second row to surface later as though
// the record had already held that dose.
if (seenKeys.has(idempotencyKey)) {
bump(skippedByReason, "duplicate_in_file");
continue;
}
seenKeys.add(idempotencyKey);
entries.push({
medicationId: match.id,
scheduledFor: dose.scheduledFor.toISOString(),
takenAt: dose.takenAt === null ? null : dose.takenAt.toISOString(),
idempotencyKey,
...(dose.doseTaken === null ? {} : { doseTaken: dose.doseTaken }),
});
}
return {
entries,
skippedByReason,
unmatchedMedications: named.medication_not_found,
ambiguousMedications: named.medication_ambiguous,
mirroredMedications: named.medication_is_mirrored,
rowsRead: parsed.rowsRead,
fromArchivedMedications: parsed.fromArchivedMedications,
csvCodingsIgnored: parsed.csvCodingsIgnored,
unknownColumns: parsed.unknownColumns,
};
}
@@ -0,0 +1,55 @@
/**
* The one projection of an intake-import job onto the polling response.
*
* Two routes read it — the per-medication import under `/api/medications/[id]/…`
* and the account-wide export import — and the client polls both through the
* same helper. Restating the field list per route is how one of them ends up
* omitting `failureReason` and a failed run reads as a run still going.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
export interface MedicationIntakeImportJobStatus {
jobId: string;
status: string;
progress: Prisma.JsonValue;
result: Prisma.JsonValue | null;
failureReason: string | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
}
/**
* Read one job the caller owns, or `null`.
*
* `medicationId` narrows to a job scoped to that medication; omit it for the
* account-wide import. `userId` is always in the `where`, so a job id belonging
* to somebody else is a 404 and not a leak.
*/
export async function readMedicationIntakeImportJob(
userId: string,
jobId: string,
medicationId?: string,
): Promise<MedicationIntakeImportJobStatus | null> {
const row = await prisma.medicationIntakeImportJob.findFirst({
where: {
id: jobId,
userId,
// An account-wide job carries no medication, and asking for one by id must
// not resolve to the other kind. `null` is the explicit narrowing.
medicationId: medicationId ?? null,
},
});
if (!row) return null;
return {
jobId: row.id,
status: row.status,
progress: row.progress,
result: row.result,
failureReason: row.failureReason,
createdAt: row.createdAt.toISOString(),
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
};
}
@@ -13,6 +13,7 @@ export interface MedicationImportRow {
}
export interface MedicationImportQueueEntry {
scheduledFor: string;
takenAt: string;
idempotencyKey: string;
}
@@ -35,6 +36,11 @@ export function buildMedicationImportEntries(
return rows.map((row) => {
const takenAt = new Date(`${row.datum}T${row.uhrzeit}`);
return {
// This body carries one time per row and nothing to say which slot it
// belonged to, so every dose it describes is ad-hoc: anchored on itself,
// which is how HealthLog already records a dose that belongs to no slot.
// The export-reading route is the one that has both facts to give.
scheduledFor: takenAt.toISOString(),
takenAt: takenAt.toISOString(),
idempotencyKey: `import-${medicationId}-${takenAt.getTime()}`,
};