Files
HealthLog/src/app/api/nutrients/batch/route.ts
T
Marc-André Bombeck 0cc73d3c88 fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.

A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.

The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
2026-09-25 07:39:07 +02:00

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `POST /api/nutrients/batch` — micronutrient day-total ingest (v1.28).
*
* iOS posts daily totals for the closed 26-code catalog (vitamins,
* minerals, water, caffeine) computed via `HKStatisticsCollectionQuery`
* day-anchored cumulative sums; `day` arrives as YYYY-MM-DD in the
* user's IANA timezone (the `stats:` day-key contract — the server
* trusts the string after regex + calendar sanity, no re-derivation).
*
* Upsert key = the composite PK (userId, day, nutrient, source), and this
* route always owns the APPLE_HEALTH row. Re-post
* replaces amount / unit / externalSourceVersion — last-writer-wins,
* the day-total contract; per-entry status is `updated` then, never
* `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range`
* | `day_invalid`) are always isolated per entry. The DB write itself runs
* as two batched groups — a single indexed existence read splits valid
* entries into a bulk `createMany` (new pairs) and a `$transaction` of
* per-row updates (existing pairs), the measurements-batch shape. A write
* failure marks its WHOLE group `skipped upsert_failed` rather than
* isolating the one entry that would have caused it (unavoidable once the
* write is batched) — the request itself never fails. That reason is
* transient, unlike the validation skips: the client sends those entries
* again, and the response is marked `no-store` so an idempotent retry is not
* answered from the cache.
*
* Module gate FIRST: the opt-in `nutrients` module (default off)
* refuses ingest with the 403 `module.disabled` envelope, so a phone
* whose user never opted in cannot land rows server-side
* (refuse-ingest posture, mirroring mental health — not surface-only).
*/
import { NextRequest } from "next/server";
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 {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds";
import {
MAX_NUTRIENT_ENTRIES_PER_BATCH,
nutrientBatchSchema,
} from "@/lib/validations/nutrients";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/**
* Calendar sanity for an already regex-shaped YYYY-MM-DD key: reject
* impossible dates (2026-02-31) via a UTC round-trip.
*/
function isRealCalendarDay(day: string): boolean {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return (
dt.getUTCFullYear() === y &&
dt.getUTCMonth() === m - 1 &&
dt.getUTCDate() === d
);
}
type EntryStatus = "inserted" | "updated" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Module gate first: a disabled module must refuse before any body
// work, so iOS can branch on the 403 `module.disabled` errorCode and
// stop syncing rather than retry.
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`nutrients:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 1024 * 1024,
});
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length >
MAX_NUTRIENT_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_NUTRIENT_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "nutrient.batch.too_large" },
);
}
const parsed = nutrientBatchSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "nutrient.batch.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "nutrient.batch.invalid",
});
}
const { entries } = parsed.data;
const dayCeiling = maxAcceptableNutrientDay(new Date());
const results: EntryResult[] = new Array(entries.length);
const skipped: Array<{ index: number; reason: string }> = [];
let inserted = 0;
let updated = 0;
interface ValidEntry {
index: number;
day: string;
nutrient: (typeof entries)[number]["nutrient"];
amount: number;
unit: string;
externalSourceVersion: string | null;
}
const validEntries: ValidEntry[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const definition = NUTRIENT_CATALOG[entry.nutrient];
// The wire carries the unit deliberately: a µg/mg confusion is a
// silent 1000× corruption; anything but the catalog's canonical
// unit skips the entry rather than converting.
if (entry.unit !== definition.unit) {
skipped.push({ index: i, reason: "unit_mismatch" });
results[i] = { index: i, status: "skipped", reason: "unit_mismatch" };
continue;
}
if (entry.amount > definition.plausibleDailyMax) {
skipped.push({ index: i, reason: "value_out_of_range" });
results[i] = {
index: i,
status: "skipped",
reason: "value_out_of_range",
};
continue;
}
if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) {
skipped.push({ index: i, reason: "day_invalid" });
results[i] = { index: i, status: "skipped", reason: "day_invalid" };
continue;
}
validEntries.push({
index: i,
day: entry.day,
nutrient: entry.nutrient,
amount: entry.amount,
unit: definition.unit,
externalSourceVersion: entry.externalSourceVersion ?? null,
});
}
// v1.29 — `source` joined the composite PK (migration 0249) so a manual
// water entry (source=MANUAL, written by `POST /api/nutrients/water`)
// coexists with the Apple-synced day total instead of one clobbering the
// other on the next sync. The batch route always owns the APPLE_HEALTH
// row.
//
// Perf (measurements/batch shape): ONE indexed existence read replaces the
// per-entry probe-then-upsert (up to 1000 sequential round trips pre-fix).
// Genuinely new (day, nutrient) pairs land via a single chunked
// `createMany`; pairs that already exist update via a batched
// `$transaction` of per-row `update` calls (values differ per row, so a
// single bulk statement isn't available without raw SQL — this still
// collapses the per-entry PROBE, and pipelines the writes through one
// transaction instead of N standalone round trips).
if (validEntries.length > 0) {
const existing = await prisma.nutrientIntakeDay.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
OR: validEntries.map((e) => ({ day: e.day, nutrient: e.nutrient })),
},
select: { day: true, nutrient: true },
});
const existingKeys = new Set(
existing.map((r) => `${r.day}::${r.nutrient}`),
);
const toInsert: ValidEntry[] = [];
const toUpdate: ValidEntry[] = [];
for (const e of validEntries) {
(existingKeys.has(`${e.day}::${e.nutrient}`) ? toUpdate : toInsert).push(
e,
);
}
if (toInsert.length > 0) {
try {
const result = await prisma.nutrientIntakeDay.createMany({
data: toInsert.map((e) => ({
userId: user.id,
day: e.day,
nutrient: e.nutrient,
amount: e.amount,
unit: e.unit,
source: "APPLE_HEALTH" as const,
externalSourceVersion: e.externalSourceVersion,
})),
skipDuplicates: true,
});
inserted += result.count;
for (const e of toInsert) {
results[e.index] = { index: e.index, status: "inserted" };
}
// Rare race: a concurrent request claimed one of these keys between
// our existence probe and this createMany, so skipDuplicates
// silently dropped that row's write here — measurements/batch
// accepts the same count-only trade-off for its own createMany race.
// The DB itself stays consistent (the racing writer's row is
// stored); only this response's per-row label may be stale for
// that one entry.
if (result.count < toInsert.length) {
annotate({
action: { name: "nutrient.batch.insert_race" },
meta: { attempted: toInsert.length, stored: result.count },
});
}
} catch (err: unknown) {
console.error("[nutrient-batch] bulk insert failed", err);
for (const e of toInsert) {
skipped.push({ index: e.index, reason: "upsert_failed" });
results[e.index] = {
index: e.index,
status: "skipped",
reason: "upsert_failed",
};
}
}
}
if (toUpdate.length > 0) {
try {
await prisma.$transaction(
toUpdate.map((e) =>
prisma.nutrientIntakeDay.update({
where: {
userId_day_nutrient_source: {
userId: user.id,
day: e.day,
nutrient: e.nutrient,
source: "APPLE_HEALTH",
},
},
data: {
// Last-writer-wins on the day total: iOS re-posts the
// current and previous local day on every sync as the
// totals grow.
amount: e.amount,
unit: e.unit,
externalSourceVersion: e.externalSourceVersion,
},
}),
),
);
updated += toUpdate.length;
for (const e of toUpdate) {
results[e.index] = { index: e.index, status: "updated" };
}
} catch (err: unknown) {
// The client-facing `reason` is a closed set (see the file header) —
// never echo raw exception text into it. Log the real error
// server-side only (SWC keeps `console.error` in prod) and return
// the fixed `upsert_failed` member.
console.error("[nutrient-batch] bulk update failed", err);
for (const e of toUpdate) {
skipped.push({ index: e.index, reason: "upsert_failed" });
results[e.index] = {
index: e.index,
status: "skipped",
reason: "upsert_failed",
};
}
}
}
}
// v1.34.3 — per-reason skip breakdown, durable AND ephemeral. Computed
// once from the closed `reason` set (never raw exception text — see the
// upsert `catch` blocks above), so this carries only counts and reason
// codes, never a per-entry health value or anything user-typed.
const skippedByReason: Record<string, number> = {};
for (const s of skipped) {
skippedByReason[s.reason] = (skippedByReason[s.reason] ?? 0) + 1;
}
// v1.34.3 — audit UNCONDITIONALLY. This used to fire only when
// `inserted + updated > 0`, which made it the ONLY durable trace of the
// call — so a batch where every entry was skipped (say, a systemic unit
// mismatch) left the exact same empty AuditLog record as a phone that
// never posted at all. An operator investigating an empty
// `nutrient_intake_days` table could not tell "the client never called"
// from "the client called and every entry was rejected" without that
// record. Mirrors `measurement.batch.ingest` / `workout.batch.ingest`
// (unconditional, counts-only, no per-entry health values) plus the
// aggregate skip-reason breakdown so "did it post, and why did nothing
// land" is answerable from the durable row alone, without a Wide-Event
// trail that (unlike this table) isn't guaranteed to still exist.
await auditLog("nutrient.batch.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
updated,
skipped: skipped.length,
skippedByReason,
},
});
if (inserted + updated > 0) {
// v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient
// tile; without this the snapshot kept serving the pre-sync figure
// until the analytics fresh TTL lapsed (~3 min). Mirrors the manual
// water POST's own hard-evict (`nutrients/water/route.ts`) so an
// Apple-Health-synced total reflects on the very next dashboard read,
// same as a manually logged one.
invalidateUserDashboardSnapshot(user.id);
}
annotate({
action: { name: "nutrient.batch.ingest" },
meta: {
processed: entries.length,
inserted,
updated,
skipped: skipped.length,
skipped_by_reason: skippedByReason,
},
});
const response = apiSuccess({
processed: entries.length,
inserted,
updated,
skipped,
entries: results,
});
// `upsert_failed` is transient: nothing was stored for those entries and the
// client sends them again. `no-store` keeps the idempotency layer from
// caching this answer, so a retry under the same key writes instead of
// replaying the failure. The measurement batch does the same for `failed`.
if (skippedByReason.upsert_failed) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}