mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'fix/v1391-contract-and-ingest' into release/v1.39.1
# Conflicts: # docs/api/openapi.yaml # src/lib/openapi/routes/measurements.ts
This commit is contained in:
commit
d0e321c54a
28 files changed
+2399
-87
No files matched your search
@@ -49,10 +49,7 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
resolveModuleMap,
|
||||
@@ -279,9 +276,7 @@ export const GET = apiHandler(async () => {
|
||||
// The zone the server cuts this account's days in: the stored zone, or
|
||||
// the instance default when the stored one is unusable. Clients bucket
|
||||
// by this value, so it must be the resolved one, not the raw column.
|
||||
timezone: isValidTimezone(user.timezone)
|
||||
? user.timezone
|
||||
: await resolveServerDefaultTimezone(),
|
||||
timezone: await resolveStoredTimezone(user.timezone),
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
// v1.39 (C1) — the needs-based setup flow, published additively:
|
||||
|
||||
@@ -203,3 +203,82 @@ describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () =>
|
||||
expect(upsertCycleDayLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — refusal codes", () => {
|
||||
/**
|
||||
* The native client files rows under a permanent-refusal register as soon
|
||||
* as it can tell the refusal is permanent. It can only tell from the code,
|
||||
* so both whole-batch 422s are pinned here, with the envelope they ride.
|
||||
*/
|
||||
async function refusal(body: unknown) {
|
||||
const res = await POST(postReq(body));
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details?: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("names a malformed batch cycle.bulk.invalid, with every issue listed", async () => {
|
||||
const { status, body } = await refusal({
|
||||
entries: [{ date: "not-a-day" }, { date: "2026-13-40" }],
|
||||
});
|
||||
expect(status).toBe(422);
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
|
||||
expect(body.details?.issues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(upsertCycleDayLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names a batch with no entries array cycle.bulk.invalid", async () => {
|
||||
const { status, body } = await refusal({});
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.invalid");
|
||||
});
|
||||
|
||||
it("names an over-cap batch cycle.bulk.too_large", async () => {
|
||||
const entries = Array.from({ length: 501 }, (_, i) => ({
|
||||
date: "2026-01-01",
|
||||
externalId: `e${i}`,
|
||||
}));
|
||||
const { status, body } = await refusal({ entries });
|
||||
expect(status).toBe(422);
|
||||
expect(body.meta?.errorCode).toBe("cycle.bulk.too_large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/day-logs/bulk — a failed write is not cached", () => {
|
||||
it("marks a response carrying upsert_failed no-store and leaves a clean one alone", async () => {
|
||||
vi.mocked(upsertCycleDayLog).mockRejectedValueOnce(new Error("reset"));
|
||||
const failed = await POST(
|
||||
postReq({
|
||||
entries: [{ date: "2026-01-01", loggedAt: "2026-01-01T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(failed.status).toBe(200);
|
||||
const body = (await failed.json()) as {
|
||||
data: { entries: Array<{ status: string; reason?: string }> };
|
||||
};
|
||||
expect(body.data.entries[0]).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "upsert_failed",
|
||||
});
|
||||
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
|
||||
vi.mocked(upsertCycleDayLog).mockResolvedValueOnce({
|
||||
id: "d1",
|
||||
existed: false,
|
||||
changed: true,
|
||||
} as never);
|
||||
const clean = await POST(
|
||||
postReq({
|
||||
entries: [{ date: "2026-01-02", loggedAt: "2026-01-02T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
@@ -257,4 +257,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
// `upsert_failed` is a write that did not happen, not a verdict on the
|
||||
// entry, and the client sends it again. `no-store` keeps the idempotency
|
||||
// layer from caching this answer, so a retry under the same key writes
|
||||
// instead of replaying the failure.
|
||||
if (results.some((r) => r.reason === "upsert_failed")) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* `POST /api/measurements/batch` — what a refusal tells the client.
|
||||
*
|
||||
* Two gaps the native client reported against v1.39.0:
|
||||
*
|
||||
* - The whole-batch validation 422 carried no `meta.errorCode`, so the
|
||||
* client could not tell a permanent refusal from a transient one and had
|
||||
* to hold its anchor for several syncs before giving up on the rows.
|
||||
* The too-large and source refusals already had codes; this one now has
|
||||
* `measurement.batch.invalid`.
|
||||
* - A `value_out_of_range` skip said neither what the value became after
|
||||
* conversion nor which band it broke, so a unit-mapping slip (a fraction
|
||||
* sent where a percent was expected) looked the same as a sensor glitch.
|
||||
* The entry now carries `convertedValue` and `range`.
|
||||
*
|
||||
* Both are additive: status codes, the envelope and every existing field
|
||||
* are unchanged.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { update: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
return (fn as (tx: unknown) => unknown)(prisma);
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
|
||||
reconcileExternalMeasurement: vi.fn(
|
||||
async (_tx: unknown, input: Record<string, unknown>) => ({
|
||||
status: "inserted",
|
||||
row: { id: "row-1", ...input },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
|
||||
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/emit-shared", () => ({
|
||||
emitDataArrival: 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 { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function post(body: unknown) {
|
||||
return POST(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const AT = new Date(Date.now() - 3_600_000).toISOString();
|
||||
|
||||
function entry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 64,
|
||||
unit: "count/min",
|
||||
startDate: AT,
|
||||
endDate: AT,
|
||||
externalId: "uuid-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type EntryResult = {
|
||||
index: number;
|
||||
status: string;
|
||||
reason?: string;
|
||||
convertedValue?: number;
|
||||
range?: { min: number; max: number; unit: string };
|
||||
};
|
||||
|
||||
async function entriesFor(entries: unknown[]): Promise<EntryResult[]> {
|
||||
const res = await post({ entries });
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: { entries: EntryResult[] } }).data
|
||||
.entries;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — validation refusal code", () => {
|
||||
it("names a malformed batch measurement.batch.invalid and keeps the 422 envelope", async () => {
|
||||
const res = await post({ entries: [entry({ startDate: "yesterday" })] });
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.details.issues.length).toBeGreaterThan(0);
|
||||
expect(body.meta?.errorCode).toBe("measurement.batch.invalid");
|
||||
});
|
||||
|
||||
it("names an empty entries array the same way", async () => {
|
||||
const res = await post({ entries: [] });
|
||||
expect(res.status).toBe(422);
|
||||
expect(
|
||||
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
|
||||
).toBe("measurement.batch.invalid");
|
||||
});
|
||||
|
||||
it("keeps measurement.batch.too_large for an over-cap batch", async () => {
|
||||
const entries = Array.from({ length: 501 }, (_, i) =>
|
||||
entry({ externalId: `uuid-${i}` }),
|
||||
);
|
||||
const res = await post({ entries });
|
||||
expect(res.status).toBe(422);
|
||||
expect(
|
||||
((await res.json()) as { meta?: { errorCode?: string } }).meta?.errorCode,
|
||||
).toBe("measurement.batch.too_large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — value_out_of_range detail", () => {
|
||||
it("reports the converted value and the band it broke, in the stored unit", async () => {
|
||||
// HealthKit reports body fat as a fraction; 0.9 becomes 90 %, above 80.
|
||||
const [result] = await entriesFor([
|
||||
entry({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
|
||||
value: 0.9,
|
||||
unit: "%",
|
||||
}),
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
index: 0,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 90,
|
||||
range: { min: 1, max: 80, unit: "%" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the lower edge too", async () => {
|
||||
const [result] = await entriesFor([entry({ value: 12 })]);
|
||||
expect(result).toMatchObject({
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 12,
|
||||
range: { min: 20, max: 300, unit: "bpm" },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the same detail when the write-side gate is the one that refuses", async () => {
|
||||
vi.mocked(reconcileExternalMeasurement).mockResolvedValueOnce({
|
||||
status: "rejected_range",
|
||||
rejection: { type: "PULSE", direction: "above_max" },
|
||||
});
|
||||
const [result] = await entriesFor([entry({ value: 64 })]);
|
||||
expect(result).toMatchObject({
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
convertedValue: 64,
|
||||
range: { min: 20, max: 300, unit: "bpm" },
|
||||
});
|
||||
});
|
||||
|
||||
it("adds nothing to an entry that landed or was skipped for another reason", async () => {
|
||||
const results = await entriesFor([
|
||||
entry({ externalId: "uuid-ok" }),
|
||||
entry({ hkIdentifier: "HKQuantityTypeIdentifierNotAThing" }),
|
||||
]);
|
||||
expect(results[0]).toEqual({ index: 0, status: "inserted" });
|
||||
expect(results[1]).toEqual({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unmappable_identifier",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,11 @@ import {
|
||||
UNSTABLE_EXTERNAL_ID_REASON,
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import {
|
||||
VALUE_RANGES,
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
@@ -217,6 +221,36 @@ interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
/**
|
||||
* Only on a `value_out_of_range` skip: the value after conversion into the
|
||||
* stored unit, which is what the band was checked against.
|
||||
*/
|
||||
convertedValue?: number;
|
||||
/** Only on a `value_out_of_range` skip: the band applied, in the stored unit. */
|
||||
range?: { min: number; max: number; unit: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `value_out_of_range` skip, with what a client needs to say which limit
|
||||
* applied. The value is the entry's own reading after conversion, handed back
|
||||
* to the account that sent it; a fraction sent where a percent was expected
|
||||
* shows up here as a converted value a hundred times too small or too large.
|
||||
*/
|
||||
function outOfRangeResult(
|
||||
index: number,
|
||||
type: string,
|
||||
convertedValue: number,
|
||||
): EntryResult {
|
||||
const band = VALUE_RANGES[type];
|
||||
return {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
...(Number.isFinite(convertedValue) ? { convertedValue } : {}),
|
||||
...(band
|
||||
? { range: { min: band.min, max: band.max, unit: getUnitForType(type) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
@@ -277,10 +311,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
|
||||
const parsed = batchPayloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
// The code is what lets a client tell this refusal from a transient one:
|
||||
// the batch as sent will never be accepted, so resending it is pointless.
|
||||
return apiValidationError(
|
||||
parsed.error.issues[0]?.message ?? "Invalid batch",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{ errorCode: "measurement.batch.invalid" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,11 +410,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// sensor reading shouldn't poison an otherwise clean ingest.
|
||||
const rangeError = validateMeasurementRange(mapped.type, mapped.value);
|
||||
if (rangeError !== null) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
};
|
||||
results[index] = outOfRangeResult(index, mapped.type, mapped.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -678,11 +711,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// already applied above, so reaching here means the two disagreed —
|
||||
// report it exactly as the earlier guard would have, and let the
|
||||
// wide-event tally record which end refused.
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
};
|
||||
results[p.index] = outOfRangeResult(
|
||||
p.index,
|
||||
p.row.type as string,
|
||||
p.row.value as number,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,21 @@ import {
|
||||
pickMainNightAndNaps,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
isNamedSleepStageBody,
|
||||
sleepStageEntrySchema,
|
||||
toSleepStageSegment,
|
||||
} from "@/lib/validations/sleep-stage-entry";
|
||||
import {
|
||||
MeasurementReconciliationError,
|
||||
reconcileExternalMeasurement,
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
import {
|
||||
MEASURED_AT_TOLERANCE_MS,
|
||||
isMergeableSource,
|
||||
isSameReadingAcrossSource,
|
||||
oppositeMergeSource,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
@@ -765,6 +780,13 @@ async function postMeasurement(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// One sleep-stage segment, the stage named rather than coded. A bridge
|
||||
// (Tasker, Home Assistant) posts here with its narrow token; the row and
|
||||
// its dedupe are the batch route's. See `sleep-stage-entry.ts`.
|
||||
if (isNamedSleepStageBody(body)) {
|
||||
return postNamedSleepStage(request, user.id, body, scoped);
|
||||
}
|
||||
|
||||
// Batch mode (array of measurements, e.g. combined BP + Pulse)
|
||||
if (Array.isArray(body)) {
|
||||
const parsed = createBatchMeasurementSchema.safeParse({
|
||||
@@ -1084,3 +1106,177 @@ async function postMeasurement(request: NextRequest) {
|
||||
|
||||
return apiSuccess(shapeMeasurementNotes(measurement), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/measurements` with a named `sleepStage`: one segment, stored as
|
||||
* the batch route stores a HealthKit sleep sample.
|
||||
*
|
||||
* The same three steps as the batch route, in the same order: a same-reading
|
||||
* row of the opposite source (MANUAL against APPLE_HEALTH) makes this one a
|
||||
* duplicate; otherwise `reconcileExternalMeasurement` settles both unique
|
||||
* identities at once, reporting an exact external-id match as a duplicate
|
||||
* and adopting a row that only shares the natural identity. So a bridge that
|
||||
* re-posts the same segment gets one row, whichever path it used first.
|
||||
*
|
||||
* `201` with the stored row when it was inserted, `200` when it was already
|
||||
* there (`duplicate`) or was corrected in place (`updated`). The row carries
|
||||
* a `status` saying which.
|
||||
*/
|
||||
async function postNamedSleepStage(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
body: unknown,
|
||||
scoped: boolean,
|
||||
): Promise<Response> {
|
||||
const parsed = sleepStageEntrySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "measurements.create.sleep-stage.validation-failed" },
|
||||
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.create.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const segment = toSleepStageSegment(parsed.data);
|
||||
// Under a scoped credential the source is resolved from the credential,
|
||||
// as on the other arms of this route: the refusal above already returned
|
||||
// on any body that named one.
|
||||
const source = (
|
||||
scoped ? EXTERNAL_SOURCE : parsed.data.source
|
||||
) as MeasurementSource;
|
||||
const type: MeasurementType = "SLEEP_DURATION";
|
||||
|
||||
// Cross-source same reading, exactly as the batch route decides it.
|
||||
if (isMergeableSource(source)) {
|
||||
const candidates = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
type,
|
||||
source: oppositeMergeSource(source),
|
||||
measuredAt: {
|
||||
gte: new Date(segment.end.getTime() - MEASURED_AT_TOLERANCE_MS),
|
||||
lte: new Date(segment.end.getTime() + MEASURED_AT_TOLERANCE_MS),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
source: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
},
|
||||
});
|
||||
const twin = candidates.find((candidate) =>
|
||||
isSameReadingAcrossSource(
|
||||
{ type, source, value: segment.minutes, measuredAt: segment.end },
|
||||
candidate,
|
||||
),
|
||||
);
|
||||
if (twin) {
|
||||
return namedSleepStageResponse(twin.id, "duplicate", {
|
||||
stage: segment.stage,
|
||||
source,
|
||||
crossSource: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const verdict = await prisma.$transaction((tx) =>
|
||||
reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
value: segment.minutes,
|
||||
unit: getUnitForType(type),
|
||||
source,
|
||||
measuredAt: segment.end,
|
||||
externalId: segment.externalId,
|
||||
externalSourceVersion: null,
|
||||
sleepStage: segment.stage,
|
||||
deviceType: parsed.data.deviceType ?? null,
|
||||
},
|
||||
{ exactExternalMatch: "duplicate" },
|
||||
),
|
||||
);
|
||||
|
||||
if (verdict.status === "failed") {
|
||||
throw new MeasurementReconciliationError(verdict);
|
||||
}
|
||||
if (verdict.status === "rejected_range") {
|
||||
// The schema applies the same band, so this is the two gates disagreeing.
|
||||
return apiError("Value out of plausible range", 422, {
|
||||
errorCode: "measurement.create.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const status =
|
||||
verdict.status === "inserted"
|
||||
? "inserted"
|
||||
: verdict.status === "duplicate"
|
||||
? "duplicate"
|
||||
: "updated";
|
||||
|
||||
if (status !== "duplicate") {
|
||||
await auditLog("measurement.create", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
type,
|
||||
measurementId: verdict.row.id,
|
||||
sleepStage: segment.stage,
|
||||
status,
|
||||
},
|
||||
});
|
||||
invalidateUserMeasurements(userId, { evict: true });
|
||||
fireAndForget(enqueueReminderSatisfy(userId), {
|
||||
action: "reminder.satisfy.enqueue",
|
||||
});
|
||||
if (status === "inserted") {
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
[verdict.row],
|
||||
"manual",
|
||||
).catch(() => {});
|
||||
void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {});
|
||||
}
|
||||
await afterMeasurementMutation(userId, [
|
||||
{ type, measuredAt: segment.end },
|
||||
...(verdict.dirtyIdentities ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
return namedSleepStageResponse(verdict.row.id, status, {
|
||||
stage: segment.stage,
|
||||
source,
|
||||
crossSource: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function namedSleepStageResponse(
|
||||
id: string,
|
||||
status: "inserted" | "updated" | "duplicate",
|
||||
meta: { stage: string; source: string; crossSource: boolean },
|
||||
): Promise<Response> {
|
||||
annotate({
|
||||
action: {
|
||||
name: "measurement.create.sleep-stage",
|
||||
entity_type: "measurement",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
status,
|
||||
stage: meta.stage,
|
||||
source: meta.source,
|
||||
cross_source_merge: meta.crossSource,
|
||||
},
|
||||
});
|
||||
const row = await prisma.measurement.findUniqueOrThrow({ where: { id } });
|
||||
return apiSuccess(
|
||||
{ ...shapeMeasurementNotes(row), status },
|
||||
status === "inserted" ? 201 : 200,
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { nutrientEntryResultSchema } from "@/lib/validations/nutrients";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -449,6 +450,46 @@ describe("POST /api/nutrients/batch — write semantics", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("marks a response carrying upsert_failed no-store, so a retry with the same Idempotency-Key writes again", async () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue(
|
||||
new Error("connection reset"),
|
||||
);
|
||||
const failed = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(failed.status).toBe(200);
|
||||
expect(failed.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
spy.mockRestore();
|
||||
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const clean = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(clean.headers.get("Cache-Control")).not.toBe("private, no-store");
|
||||
});
|
||||
|
||||
it("documents upsert_failed as a transient write failure to retry, and the validation skips as terminal", () => {
|
||||
const description = String(
|
||||
(nutrientEntryResultSchema.meta() as { description?: string })
|
||||
.description,
|
||||
);
|
||||
expect(description).toMatch(/`upsert_failed` is a write failure/);
|
||||
expect(description).toMatch(/sent again on a later sync/);
|
||||
expect(description).not.toMatch(/log and drop a skipped entry/i);
|
||||
expect(description).toMatch(/unit_mismatch/);
|
||||
});
|
||||
|
||||
it("a race that drops one createMany row still counts consistently and is observable", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({
|
||||
count: 1,
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
* 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.
|
||||
* 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
|
||||
@@ -360,11 +363,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -60,6 +61,12 @@ const SESSION_OK = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
|
||||
// The operator's default zone, which the server falls back to when a
|
||||
// stored one is unusable. Distinct from the built-in default so a test can
|
||||
// tell the resolver ran.
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
defaultUserTimezone: "America/Chicago",
|
||||
} as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -438,3 +445,81 @@ describe("PATCH /api/user/profile", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `/api/auth/me` has returned the zone the server actually cuts days in —
|
||||
* the stored one, or the instance default when the stored value is unusable
|
||||
* — while this alias returned the raw column. A client reading this endpoint
|
||||
* could bucket a day differently from the server. Both now go through the
|
||||
* same resolution.
|
||||
*/
|
||||
describe("/api/user/profile — timezone is the resolved zone", () => {
|
||||
function profileRow(timezone: string | null) {
|
||||
return {
|
||||
username: "testuser",
|
||||
displayName: null,
|
||||
email: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: null,
|
||||
timezone,
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumberEncrypted: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getZone(stored: string | null): Promise<string> {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
profileRow(stored) as never,
|
||||
);
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: { timezone: string } }).data.timezone;
|
||||
}
|
||||
|
||||
it("returns a usable stored zone as stored", async () => {
|
||||
expect(await getZone("Asia/Tokyo")).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("returns the instance default in place of a bare offset", async () => {
|
||||
expect(await getZone("+05:30")).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("returns the instance default in place of a name no runtime knows", async () => {
|
||||
expect(await getZone("Mars/Olympus_Mons")).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("returns the instance default when the row is missing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await callGet(makeGetReq());
|
||||
const body = (await res.json()) as { data: { timezone: string } };
|
||||
expect(body.data.timezone).toBe("America/Chicago");
|
||||
});
|
||||
|
||||
it("answers a PATCH that leaves an unusable zone untouched with the resolved one", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
...profileRow("+05:30"),
|
||||
id: "user-1",
|
||||
role: "USER",
|
||||
displayName: "Alex T.",
|
||||
} as never);
|
||||
const res = await PATCH(
|
||||
new NextRequest("http://localhost/api/user/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ displayName: "Alex T." }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { timezone: string } };
|
||||
expect(body.data.timezone).toBe("America/Chicago");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { resolveStoredTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -77,7 +78,10 @@ export const GET = apiHandler(async () => {
|
||||
gender: dbUser?.gender ?? null,
|
||||
heightCm: dbUser?.heightCm ?? null,
|
||||
locale: dbUser?.locale ?? null,
|
||||
timezone: dbUser?.timezone ?? "Europe/Berlin",
|
||||
// The zone the server cuts this account's days in, exactly as
|
||||
// `/api/auth/me` reports it: the stored zone, or the instance default
|
||||
// when the stored value is unusable. Never the raw column.
|
||||
timezone: await resolveStoredTimezone(dbUser?.timezone),
|
||||
timeFormat: dbUser?.timeFormat ?? "AUTO",
|
||||
dateFormat: dbUser?.dateFormat ?? "AUTO",
|
||||
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
|
||||
@@ -118,7 +122,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
gender: result.user.gender,
|
||||
heightCm: result.user.heightCm,
|
||||
locale: result.user.locale,
|
||||
timezone: result.user.timezone,
|
||||
timezone: await resolveStoredTimezone(result.user.timezone),
|
||||
timeFormat: result.user.timeFormat,
|
||||
dateFormat: result.user.dateFormat,
|
||||
moodReminderEnabled: result.user.moodReminderEnabled,
|
||||
|
||||
Reference in new issue
Block a user