Files
HealthLog/src/app/api/import/csv/route.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

262 lines
8.8 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import {
parseCsvMeasurements,
type CsvRowResult,
} from "@/lib/import/csv-measurements";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import type {
MeasurementType,
GlucoseContext,
} from "@/generated/prisma/client";
/**
* CSV measurement import (v1.17.1).
*
* The cold-start escape hatch for self-hosters migrating from a spreadsheet
* or another tracker. Reuses the `/api/import` write loop + rollup re-fold;
* the parse + per-row validation + unit conversion lives in the pure
* `parseCsvMeasurements` module so it is unit-tested without a database.
*
* Body is `text/csv` (or `text/plain`) raw text — NOT JSON. Capped at 16 MB
* (the same ceiling as the JSON import; a 16 MB CSV is > 100 000 rows). Rows
* beyond the 10 000 ceiling are rejected so the rollup re-fold stays bounded.
*
* `?dryRun=1` parses + validates + returns the per-row status envelope
* WITHOUT writing — a trust win for a bulk cold-start import.
*
* Response: `{ inserted, updated, skipped, total, dryRun, rows: [{line,
* status, reason}] }`, mirroring the batch route's per-entry envelope.
*/
const MAX_CSV_BYTES = 16 * 1024 * 1024;
const MAX_ROWS = 10000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.csv.upload" } });
const dryRun =
new URL(request.url).searchParams.get("dryRun") === "1" ||
new URL(request.url).searchParams.get("dryRun") === "true";
// Same blast-radius reasoning as the JSON import — share the bucket so a
// CSV upload counts against the same 5/hour quota.
const rl = await checkRateLimit(`import:${user.id}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 5 imports per hour", 429);
}
// Raw text read with an explicit byte cap — `safeJson` is JSON-only, so we
// enforce the same `maxBytes` ceiling here against the Content-Length and
// the materialised body.
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_CSV_BYTES) {
return apiError("CSV 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_CSV_BYTES) {
return apiError("CSV exceeds the 16 MB limit", 413);
}
const parsed = parseCsvMeasurements(text);
if (parsed.fatal) {
return apiError(parsed.fatal.message, 422);
}
const okRows = parsed.rows.filter(
(r): r is CsvRowResult & { row: NonNullable<CsvRowResult["row"]> } =>
r.status === "ok" && r.row !== undefined,
);
if (okRows.length > MAX_ROWS) {
return apiError(
`CSV has ${okRows.length} valid rows — the limit is ${MAX_ROWS} per import`,
422,
);
}
const userId = user.id;
let inserted = 0;
let updated = 0;
let skipped = parsed.rows.filter((r) => r.status === "skipped").length;
const touchedMeasurements: Array<{
type: MeasurementType;
measuredAt: Date;
}> = [];
// Per-line write outcome for rows that reached the DB. Keyed by source line
// so the response envelope can surface `inserted` / `updated` / `duplicate`
// distinctly without polluting the parse-time `CsvRowReason`.
const writeOutcome = new Map<number, "inserted" | "updated" | "duplicate">();
if (!dryRun) {
for (const result of okRows) {
const m = result.row;
try {
if (m.externalId) {
// Idempotent re-import keyed on the source-stable id — re-uploading
// the same file updates in place rather than minting duplicates.
const res = await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: m.type as MeasurementType,
source: "IMPORT",
externalId: m.externalId,
},
},
update: {
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
create: {
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
select: { createdAt: true, updatedAt: true },
});
// A fresh create has createdAt === updatedAt; an in-place update
// bumps updatedAt past createdAt.
if (res.updatedAt.getTime() > res.createdAt.getTime()) {
updated++;
writeOutcome.set(result.line, "updated");
} else {
inserted++;
writeOutcome.set(result.line, "inserted");
}
} else {
await prisma.measurement.create({
data: {
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
});
inserted++;
writeOutcome.set(result.line, "inserted");
}
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt: m.measuredAt,
});
} catch {
// Unique-constraint violation (a NULL-externalId duplicate against an
// existing row) — count as skipped rather than failing the file.
skipped++;
writeOutcome.set(result.line, "duplicate");
}
}
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
// Best-effort: a populator hiccup never fails the importer.
if (touchedMeasurements.length > 0) {
try {
const keys = collapseToTypeDayKeys(touchedMeasurements);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
} catch (err) {
annotate({
meta: {
measurement_rollup_csv_import_failed: true,
measurement_rollup_csv_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("import.csv.upload", {
userId,
ipAddress: getClientIp(request),
details: { inserted, updated, skipped },
});
} else {
// Dry-run preview: no write ran, so report the projected outcome —
// every valid parse-`ok` row would insert (an externalId row could land
// as an update, but that needs a DB read we deliberately skip here).
inserted = okRows.length;
}
const summary = {
inserted,
updated,
skipped,
total: parsed.rows.length,
dryRun,
// Per-row status, mirroring the batch route's per-entry envelope. The
// normalised payload is stripped — the client only needs line + status.
// Parse-time `ok` rows are refined by the write outcome: a row that hit a
// unique-constraint duplicate becomes `skipped`/`duplicate`; an upsert
// that landed on an existing row becomes `updated`. In `dryRun` mode no
// write ran, so every parse-`ok` row stays `inserted` (the projected
// outcome).
rows: parsed.rows.map((r) => {
if (r.status === "skipped") {
return {
line: r.line,
status: "skipped" as const,
...(r.reason ? { reason: r.reason } : {}),
};
}
const outcome = writeOutcome.get(r.line);
if (outcome === "duplicate") {
return {
line: r.line,
status: "skipped" as const,
reason: "duplicate",
};
}
if (outcome === "updated") {
return { line: r.line, status: "updated" as const };
}
return { line: r.line, status: "inserted" as const };
}),
};
annotate({
meta: {
import_csv_inserted: inserted,
import_csv_updated: updated,
import_csv_skipped: skipped,
import_csv_dry_run: dryRun,
},
});
return apiSuccess(summary);
});