mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
ca4425b2e0
commit
5d2ae7732e
4 files changed
+193
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user