mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
/**
|
||
* `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;
|
||
}
|