feat(api): accept an optional source on the measurements batch endpoint

Add an optional per-entry `source` to POST /api/measurements/batch,
restricted to the {APPLE_HEALTH, MANUAL} subset of MeasurementSource and
defaulting to APPLE_HEALTH when absent so existing web and iOS callers
are unchanged. The standalone iOS client can now tag adopt-on-pair
backfill rows that the user entered by hand as MANUAL instead of having
them mis-attributed to HealthKit.

source is threaded into the (userId, type, source, externalId) dedup
key, the existence probe, and the stats:* overwrite scope, so a MANUAL
row and an APPLE_HEALTH row sharing an externalId are distinct rows and
the stats:* re-post overwrite only touches the matching source.
WITHINGS and IMPORT stay server-owned and are rejected here with 422.

Regenerate the OpenAPI spec and add integration coverage for the
absent-source default, explicit MANUAL, cross-source distinctness, and
the rejected-source 422 path.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:54:20 +02:00
1 parent ca4425b2e0
commit 5d2ae7732e
4 files changed
+193 -6

No files matched your search

+9
View File
@@ -1992,6 +1992,15 @@ components:
type: string
minLength: 1
maxLength: 120
source:
description: Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user
entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source,
externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` /
`IMPORT` are not accepted here.
type: string
enum:
- APPLE_HEALTH
- MANUAL
deviceType:
description: Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same
source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server
+36 -6
View File
@@ -72,6 +72,17 @@ const MAX_BATCH_ENTRIES = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
// v1.8.6 W6 — the set of `source` values this client-facing ingest
// route accepts. Deliberately narrower than the full `MeasurementSource`
// enum: `WITHINGS` and `IMPORT` are server-owned (the Withings webhook
// and the CSV importer mint those rows respectively), so letting an iOS
// client forge rows attributed to them would let an authenticated client
// pollute the per-source canonical picker with rows the server never saw
// from those integrations. The standalone iOS client only needs to tag
// its own adopt-on-pair backfill as `MANUAL` vs the default
// `APPLE_HEALTH` passthrough, so we accept exactly that pair.
const batchSourceEnum = z.enum(["APPLE_HEALTH", "MANUAL"]);
const batchEntrySchema = z.object({
hkIdentifier: z.string().min(1).max(120),
value: z.number().finite(),
@@ -81,6 +92,14 @@ const batchEntrySchema = z.object({
sleepStage: z.number().int().min(0).max(20).optional(),
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
// `APPLE_HEALTH` so every pre-W6 caller (web + current iOS) stays
// byte-for-byte unchanged. The iOS standalone adopt-on-pair backfill
// sends `MANUAL` for rows the user entered by hand on-device so they
// are not mis-attributed to HealthKit. `source` is part of the
// `(userId, type, source, externalId)` dedup key, so a `MANUAL` row
// and an `APPLE_HEALTH` row sharing an externalId are distinct rows.
source: batchSourceEnum.optional(),
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
// `HKDevice.model` of each sample to one of the canonical device
// classes (watch | band | ring | phone | scale | other | unknown).
@@ -228,7 +247,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
type: mapped.type,
value: mapped.value,
unit: mapped.unit,
source: "APPLE_HEALTH",
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
source: entry.source ?? "APPLE_HEALTH",
measuredAt: mapped.takenAt,
externalId: entry.externalId,
externalSourceVersion: entry.externalSourceVersion ?? null,
@@ -251,31 +272,37 @@ async function postBatch(request: NextRequest): Promise<Response> {
// (createMany with skipDuplicates does the dedup but doesn't tell
// us *which* rows it skipped). We look up existing rows under the
// composite unique key first, then createMany the survivors.
// v1.8.6 W6 — `source` is now part of the dedup key, so the lookup
// matches on (type, source, externalId) per incoming row rather than
// a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an
// `APPLE_HEALTH` row sharing an externalId are distinct, so each must
// carry its source through the existence probe and the composite key.
const incomingKeys = prepared.map((p) => ({
type: p.row.type as Prisma.MeasurementCreateManyInput["type"],
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
externalId: p.row.externalId as string,
}));
const existing = await prisma.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
OR: incomingKeys.map((k) => ({
type: k.type,
source: k.source,
externalId: k.externalId,
})),
},
select: { type: true, externalId: true },
select: { type: true, source: true, externalId: true },
});
const existingSet = new Set(
existing.map((row) => `${row.type}::${row.externalId}`),
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
);
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.externalId}`;
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
if (existingSet.has(key)) {
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects
@@ -300,7 +327,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
await tx.measurement.updateMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
// v1.8.6 W6 — scope the overwrite to the row's own source
// so a `stats:*` re-post only touches the matching
// (type, source, externalId) row.
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
+11
View File
@@ -283,6 +283,17 @@ const batchEntrySchema = z
.max(120)
.describe("HKSample.uuid string — the dedup key."),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
// `APPLE_HEALTH` server-side when omitted, so legacy clients are
// unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of
// `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and
// rejected on this client-facing route.
source: z
.enum(["APPLE_HEALTH", "MANUAL"])
.optional()
.describe(
"Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.",
),
// v1.4.25 W8c — optional device-type tag fed into the canonical
// source picker's second axis. NULL is treated as `unknown`;
// legacy iOS builds that don't ship the field continue to work.
@@ -69,6 +69,7 @@ interface BatchEntryFixture {
externalId: string;
externalSourceVersion?: string;
sleepStage?: number;
source?: string;
}
function makeRequest(
@@ -698,4 +699,140 @@ describe("POST /api/measurements/batch (real Postgres)", () => {
expect(stepsRow?.value).toBeCloseTo(8400, 5);
});
});
// v1.8.6 W6 — optional per-entry `source`. The standalone iOS client
// tags adopt-on-pair backfill rows it knows were entered by hand as
// `MANUAL`; every other caller (web + current iOS) omits the field and
// must keep storing rows as `APPLE_HEALTH`. `source` is part of the
// `(userId, type, source, externalId)` dedup key.
describe("optional source field (W6)", () => {
it("persists a row as APPLE_HEALTH when source is absent (backward-compat)", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const response = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80.2,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-source-absent-001",
},
],
}),
);
expect(response.status).toBe(200);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId: "uuid-source-absent-001" },
});
expect(stored).toHaveLength(1);
expect(stored[0]!.source).toBe("APPLE_HEALTH");
});
it("persists a row as MANUAL when source is explicitly MANUAL", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const response = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 79.8,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-source-manual-001",
source: "MANUAL",
},
],
}),
);
expect(response.status).toBe(200);
const json = (await response.json()) as { data: { inserted: number } };
expect(json.data.inserted).toBe(1);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId: "uuid-source-manual-001" },
});
expect(stored).toHaveLength(1);
expect(stored[0]!.source).toBe("MANUAL");
});
it("treats the same externalId under different sources as two distinct rows", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const sharedExternalId = "uuid-source-collision-001";
const response = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.0,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: sharedExternalId,
source: "APPLE_HEALTH",
},
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 81.0,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: sharedExternalId,
source: "MANUAL",
},
],
}),
);
expect(response.status).toBe(200);
const json = (await response.json()) as {
data: { inserted: number; duplicates: number };
};
// Distinct dedup keys — both insert, neither is a duplicate.
expect(json.data.inserted).toBe(2);
expect(json.data.duplicates).toBe(0);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId: sharedExternalId },
});
expect(stored).toHaveLength(2);
expect(new Set(stored.map((r) => r.source))).toEqual(
new Set(["APPLE_HEALTH", "MANUAL"]),
);
});
it("rejects an out-of-range source value with 422", async () => {
const { POST } = await import("@/app/api/measurements/batch/route");
const response = await POST(
makeRequest({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 80.5,
unit: "kg",
startDate: "2026-05-09T07:30:00.000Z",
endDate: "2026-05-09T07:30:00.000Z",
externalId: "uuid-source-bad-001",
// WITHINGS is server-owned and not accepted on this
// client-facing route — must surface as a 422, not silently
// mint a forged Withings-attributed row.
source: "WITHINGS",
},
],
}),
);
expect(response.status).toBe(422);
const stored = await getPrismaClient().measurement.findMany({
where: { userId: TEST_USER_ID, externalId: "uuid-source-bad-001" },
});
expect(stored).toHaveLength(0);
});
});
});