mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps — vitamins, minerals, water, caffeine — can now sync as daily totals into a dedicated NutrientIntakeDay store (migration 0241): composite (user, day, nutrient) identity, re-post replaces, no soft delete, no rollup coupling. A code-side catalog carries the 26 nutrient codes with canonical units, plausibility caps, and EFSA dietary reference values (direction-aware; chromium cites NIH ODS where EFSA declined a DRV). Deliberately not a nutrition feature: no diary, no calories, no dashboard surface. POST /api/nutrients/batch is gated behind the new opt-in nutrients module (default off — with the module off the server refuses ingest entirely), rate-limited, idempotency-wrapped, with per-entry unit-mismatch and out-of-range skips. GET /api/nutrients serves a catalog-ordered summary; the only visible surface is a read-only card under Settings → Sources. Coach/correlation access is a later slice; the iOS reading side is coordinated in the companion-app tracker.
This commit is contained in:
1 parent
14a9a08a6e
commit
5445fc67ea
34 files changed
+2766
-12
No files matched your search
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* v1.28 — `POST /api/nutrients/batch` contract tests.
|
||||
*
|
||||
* Pins the ingest posture: module gate first (403 `module.disabled`),
|
||||
* per-entry skip guards (unit mismatch / plausibility / day key), the
|
||||
* insert-vs-update statuses off the composite-PK probe, and the batch
|
||||
* envelope errors (`too_large`, multi-issue `invalid`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
nutrientIntakeDay: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/nutrients/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Yesterday's UTC day key — always a valid, in-window day. */
|
||||
function recentDay(offsetDays = 1): string {
|
||||
return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
interface BatchResponse {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
entries: Array<{ index: number; status: string; reason?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — module gate", () => {
|
||||
it("refuses ingest with the 403 module.disabled envelope when the module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "nutrients" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "nutrients",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "ug", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("module.disabled");
|
||||
expect(body.meta?.module).toBe("nutrients");
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — envelope errors", () => {
|
||||
it("rejects a 501-entry batch with 422 nutrient.batch.too_large before Zod", async () => {
|
||||
const entries = Array.from({ length: 501 }, () => ({
|
||||
day: recentDay(),
|
||||
nutrient: "vitamin_c",
|
||||
unit: "mg",
|
||||
amount: 100,
|
||||
}));
|
||||
const res = await POST(postReq({ entries }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("nutrient.batch.too_large");
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous Zod issues as one 422 nutrient.batch.invalid", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: "not-a-day", nutrient: "vitamin_d", unit: "ug", amount: 20 },
|
||||
{ day: recentDay(), nutrient: "kryptonite", unit: "mg", amount: 1 },
|
||||
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: -5 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("nutrient.batch.invalid");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate bucket is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1500 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — per-entry skip guards", () => {
|
||||
it("skips a unit mismatch (µg posted as mg) without touching the store", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
// vitamin_d is canonically µg; posting mg is the 1000× hazard.
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries[0]).toEqual({
|
||||
index: 0,
|
||||
status: "skipped",
|
||||
reason: "unit_mismatch",
|
||||
});
|
||||
expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a value above the plausibility cap", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
// caffeine cap is 2000 mg.
|
||||
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 2001 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries[0].status).toBe("skipped");
|
||||
expect(body.data.entries[0].reason).toBe("value_out_of_range");
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips an impossible calendar day and a far-future day as day_invalid", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
// Regex-shaped but not a real date.
|
||||
{ day: "2026-02-31", nutrient: "iron", unit: "mg", amount: 10 },
|
||||
// Beyond tomorrow in every IANA timezone.
|
||||
{ day: "2999-01-01", nutrient: "iron", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries.map((e) => e.reason)).toEqual([
|
||||
"day_invalid",
|
||||
"day_invalid",
|
||||
]);
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a skipped entry never fails the batch — siblings still land", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
{ day: recentDay(), nutrient: "magnesium", unit: "mg", amount: 300 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.processed).toBe(2);
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.skipped).toHaveLength(1);
|
||||
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — upsert semantics", () => {
|
||||
it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => {
|
||||
const day = recentDay();
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
day,
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
amount: 22.5,
|
||||
externalSourceVersion: "yazio-9.9",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.updated).toBe(0);
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
|
||||
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_day_nutrient: {
|
||||
userId: "user-1",
|
||||
day,
|
||||
nutrient: "vitamin_d",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: "user-1",
|
||||
day,
|
||||
nutrient: "vitamin_d",
|
||||
amount: 22.5,
|
||||
unit: "ug",
|
||||
externalSourceVersion: "yazio-9.9",
|
||||
},
|
||||
update: {
|
||||
amount: 22.5,
|
||||
unit: "ug",
|
||||
externalSourceVersion: "yazio-9.9",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports updated (never duplicate) when the composite key already exists", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.inserted).toBe(0);
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(
|
||||
body.data.entries.some((e) => e.status === ("duplicate" as string)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1800 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"nutrient.batch.ingest",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("a thrown upsert marks only that entry skipped upsert_failed", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.upsert)
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries[0].status).toBe("skipped");
|
||||
expect(body.data.entries[1].status).toBe("inserted");
|
||||
expect(body.data.inserted).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* `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). Re-post
|
||||
* replaces amount / unit / externalSourceVersion — last-writer-wins,
|
||||
* the day-total contract; per-entry status is `updated` then, never
|
||||
* `duplicate`. Per-entry failures come back `skipped` with a reason
|
||||
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
|
||||
* `upsert_failed`), never a batch failure — the measurements-batch
|
||||
* posture.
|
||||
*
|
||||
* 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 { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper day bound with timezone slack: "tomorrow" in the most-ahead
|
||||
* IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
|
||||
* key beyond that cannot be a legitimate local day and is corruption.
|
||||
*/
|
||||
function maxAcceptableDay(now: Date): string {
|
||||
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
return limit.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
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 = maxAcceptableDay(new Date());
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
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.push({ index: i, status: "skipped", reason: "unit_mismatch" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.amount > definition.plausibleDailyMax) {
|
||||
skipped.push({ index: i, reason: "value_out_of_range" });
|
||||
results.push({
|
||||
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.push({ index: i, status: "skipped", reason: "day_invalid" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = {
|
||||
userId_day_nutrient: {
|
||||
userId: user.id,
|
||||
day: entry.day,
|
||||
nutrient: entry.nutrient,
|
||||
},
|
||||
};
|
||||
|
||||
// Probe-then-upsert so the response reliably distinguishes
|
||||
// `inserted` from `updated` (the mood-bulk pattern; two round
|
||||
// trips per entry is fine under the 500-entry cap).
|
||||
const existing = await prisma.nutrientIntakeDay.findUnique({
|
||||
where: key,
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
await prisma.nutrientIntakeDay.upsert({
|
||||
where: key,
|
||||
create: {
|
||||
userId: user.id,
|
||||
day: entry.day,
|
||||
nutrient: entry.nutrient,
|
||||
amount: entry.amount,
|
||||
unit: definition.unit,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the day total: iOS re-posts the current
|
||||
// and previous local day on every sync as the totals grow.
|
||||
amount: entry.amount,
|
||||
unit: definition.unit,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
updated += 1;
|
||||
results.push({ index: i, status: "updated" });
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted" });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
}
|
||||
}
|
||||
|
||||
if (inserted + updated > 0) {
|
||||
await auditLog("nutrient.batch.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "nutrient.batch.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user