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

+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.