mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'main' into feat/mood-level-b
This commit is contained in:
commit
61c220b7b8
8 files changed
+906
-109
No files matched your search
@@ -23,7 +23,7 @@ vi.mock("@/lib/db", () => ({
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
workoutSamples: {
|
||||
createMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
@@ -109,6 +109,23 @@ function makeRequest(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `workout.findMany` serves two callers in this route: the pre-flight
|
||||
* probe for rows that already exist (it passes a `where`), and — inside
|
||||
* the fake above — the stand-in for `INSERT ... RETURNING` (`select`
|
||||
* only). Keying on `select.id` alone stopped telling the two apart once
|
||||
* the probe began selecting `id` so an enrichment can address the stored
|
||||
* workout, so the RETURNING arm is the one WITHOUT a `where`.
|
||||
*/
|
||||
function mockInsertReturning(
|
||||
rows: Array<{ id: string; source: string; externalId: string }>,
|
||||
) {
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
where?: unknown;
|
||||
select?: Record<string, unknown>;
|
||||
}) => (args.where === undefined && args.select?.id ? rows : [])) as never);
|
||||
}
|
||||
|
||||
function validWorkout(
|
||||
externalId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
@@ -161,7 +178,13 @@ beforeEach(() => {
|
||||
);
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 });
|
||||
// The series write reports which rows landed, so an enrichment can tell
|
||||
// an attach from a row a concurrent writer got in first. The fake
|
||||
// echoes every row back as landed.
|
||||
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
|
||||
(async (args: { data: Array<{ workoutId: string }> }) =>
|
||||
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
|
||||
);
|
||||
// v1.4.43 W9 — default to no per-user source-priority override so
|
||||
// the write-time picker walks the canonical default ladder. Tests
|
||||
// that exercise a custom ladder override this in-line.
|
||||
@@ -434,25 +457,18 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
|
||||
createdAt: new Date("2026-05-14T10:00:01.000Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: Record<string, unknown>;
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "apple-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
{
|
||||
id: "manual-id",
|
||||
source: "MANUAL",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "apple-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
{
|
||||
id: "manual-id",
|
||||
source: "MANUAL",
|
||||
externalId: "shared-external-id",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -480,20 +496,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
// Post-insert lookup returns the newly-inserted workout id so the
|
||||
// route can be attached by FK.
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true; source?: true; externalId?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-fresh-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-with-route",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "wkt-fresh-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-with-route",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -523,20 +532,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-indoor-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-indoor",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "wkt-indoor-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-indoor",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -556,8 +558,9 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
// The route table is untouched — an indoor workout has no GPS route.
|
||||
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
|
||||
// The HR series lands in the dedicated child table by FK.
|
||||
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0];
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
|
||||
.calls[0]?.[0];
|
||||
const rows = (
|
||||
call as {
|
||||
data: Array<{
|
||||
@@ -575,20 +578,13 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
|
||||
it("attaches both a route and an HR series on one workout", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-both-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-both",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
mockInsertReturning([
|
||||
{
|
||||
id: "wkt-both-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-both",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -610,14 +606,15 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const routeRows = (
|
||||
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
const sampleRows = (
|
||||
vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as {
|
||||
vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
|
||||
.calls[0]?.[0] as {
|
||||
data: Array<{ workoutId: string }>;
|
||||
}
|
||||
).data;
|
||||
@@ -632,7 +629,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
|
||||
makeRequest({ workouts: [validWorkout("uuid-no-series")] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.workoutSamples.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
* - `Content-Length` ceiling at 5 MB before parsing — anything
|
||||
* larger returns 413 so the iOS client falls back to one workout
|
||||
* per call.
|
||||
* - Per-entry status (`inserted | duplicate | skipped`) so the iOS
|
||||
* sync cursor can checkpoint accurately.
|
||||
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
|
||||
* the iOS sync cursor can checkpoint accurately.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
|
||||
@@ -39,6 +39,13 @@
|
||||
* composite index. Re-posting the same batch surfaces duplicates
|
||||
* rather than failing the call.
|
||||
*
|
||||
* Heart-rate series enrichment: a re-post that matches a stored
|
||||
* workout and carries a `samples` array attaches that series when the
|
||||
* workout has none. The workout row itself stays first-write-wins —
|
||||
* nothing on it is written, not one column — and a workout that already
|
||||
* holds a series is a no-op, so the client's history sweep is repeatable
|
||||
* as often as it likes. See the enrichment block below.
|
||||
*
|
||||
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
|
||||
* Prisma's `createManyAndReturn`. The returned composite keys identify the
|
||||
* exact rows this request inserted, so per-entry statuses, child rows, and
|
||||
@@ -66,10 +73,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout";
|
||||
import {
|
||||
createBatchWorkoutSchema,
|
||||
MAX_WORKOUTS_PER_BATCH,
|
||||
} from "@/lib/validations/workout";
|
||||
INVALID_SAMPLES_REASON,
|
||||
parseWorkoutBatch,
|
||||
} from "@/lib/workouts/batch-ingest-parse";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMeta,
|
||||
@@ -106,16 +114,14 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Per-entry outcome the iOS client uses to advance its sync cursor.
|
||||
* `inserted` and `duplicate` both indicate the row landed (or was
|
||||
* already present); the client can checkpoint past them identically.
|
||||
* `skipped` covers entries the server cannot durably store — currently
|
||||
* unused by this endpoint (the Zod schema rejects malformed entries
|
||||
* with a 422 before we reach the per-entry pass), but reserved on the
|
||||
* envelope so the response shape parallels the measurements batch and
|
||||
* future server-side validation can surface fine-grained skips without
|
||||
* breaking the iOS DTO.
|
||||
* `inserted`, `duplicate` and `enriched` all indicate the row landed (or
|
||||
* was already present); the client can checkpoint past them identically.
|
||||
* `enriched` additionally says the entry's heart-rate series was attached
|
||||
* to a workout that had none, which is what lets a history sweep count
|
||||
* its own progress. `skipped` covers entries the server refused: an
|
||||
* unusable external id, or a `samples` array that failed validation.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -251,15 +257,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
return apiError(issue?.message ?? "Invalid batch", 400, {
|
||||
// Strict on every field, per-entry on `samples` alone — see
|
||||
// `parseWorkoutBatch` for why the series is the one field whose
|
||||
// failure must not take the rest of a history sweep down with it.
|
||||
const parsed = parseWorkoutBatch(rawBody);
|
||||
if (!parsed.ok) {
|
||||
return apiError(parsed.message, 400, {
|
||||
errorCode: "workout.batch.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { workouts } = parsed.data;
|
||||
const refusedSampleIndices = new Set(parsed.refusedSampleIndices);
|
||||
|
||||
// Pre-flight duplicate detection so we can return per-entry status.
|
||||
// The composite unique index is `(userId, source, externalId)` —
|
||||
@@ -289,6 +298,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const results: EntryResult[] = new Array(workouts.length);
|
||||
const unstableShapes: UnstableExternalIdShape[] = [];
|
||||
const prepared: Prepared[] = workouts.flatMap((w, index) => {
|
||||
// An entry whose series failed validation is refused whole. Storing
|
||||
// the workout without the curve the caller asked us to keep would be
|
||||
// a silent loss, and the row may well already exist anyway.
|
||||
if (refusedSampleIndices.has(index)) {
|
||||
results[index] = {
|
||||
index,
|
||||
status: "skipped",
|
||||
reason: INVALID_SAMPLES_REASON,
|
||||
};
|
||||
return [];
|
||||
}
|
||||
|
||||
// The `(userId, source, externalId)` dedup key is only idempotent
|
||||
// while the id is STABLE across client launches. An id that rotates
|
||||
// per process (an object description carrying a memory address)
|
||||
@@ -408,6 +429,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
|
||||
let duplicateCount = droppedByWriteDedup.length;
|
||||
let insertedCount = 0;
|
||||
let enrichedCount = 0;
|
||||
const insertedIdByIndex = new Map<number, string>();
|
||||
|
||||
if (survivors.length > 0) {
|
||||
@@ -418,6 +440,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
.map((p) => p.dedupKey)
|
||||
.filter((k): k is { source: string; externalId: string } => k !== null);
|
||||
|
||||
// The same probe answers both questions: does the row exist, and
|
||||
// does it already carry a series. `id` comes along so an enrichment
|
||||
// can address the stored workout without a second round trip.
|
||||
const existing = dedupCandidates.length
|
||||
? await prisma.workout.findMany({
|
||||
where: {
|
||||
@@ -427,30 +452,65 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: k.externalId,
|
||||
})),
|
||||
},
|
||||
select: { source: true, externalId: true },
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
samples: { select: { workoutId: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const existingSet = new Set(
|
||||
existing.map((row) => `${row.source}::${row.externalId}`),
|
||||
);
|
||||
const existingByKey = new Map<string, { id: string; hasSeries: boolean }>();
|
||||
for (const row of existing) {
|
||||
if (row.externalId === null) continue;
|
||||
existingByKey.set(`${row.source}::${row.externalId}`, {
|
||||
id: row.id,
|
||||
hasSeries: row.samples !== null,
|
||||
});
|
||||
}
|
||||
|
||||
const toInsert: Prepared[] = [];
|
||||
/** Stored workouts this batch hands a heart-rate series to. */
|
||||
const toEnrich: Array<{
|
||||
index: number;
|
||||
workoutId: string;
|
||||
samples: NonNullable<Prepared["samples"]>;
|
||||
}> = [];
|
||||
/** Workouts already claimed by an earlier entry of THIS batch. */
|
||||
const enrichClaimed = new Set<string>();
|
||||
for (const p of survivors) {
|
||||
const keyTuple = p.dedupKey
|
||||
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
|
||||
: null;
|
||||
if (keyTuple !== null && existingSet.has(keyTuple)) {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined;
|
||||
if (known) {
|
||||
if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) {
|
||||
enrichClaimed.add(known.id);
|
||||
// Enrichment. The status is assigned after the write, because
|
||||
// a concurrent writer may get the series in first — in which
|
||||
// case this entry is the plain duplicate it always was. A
|
||||
// second entry of this batch naming the same workout takes the
|
||||
// duplicate arm above: one workout, one series, one `enriched`.
|
||||
toEnrich.push({
|
||||
index: p.index,
|
||||
workoutId: known.id,
|
||||
samples: p.samples,
|
||||
});
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
}
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
toInsert.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
if (toInsert.length > 0 || toEnrich.length > 0) {
|
||||
const CHUNK = 100;
|
||||
/** Workout ids whose series row this request actually wrote. */
|
||||
const seriesLanded = new Set<string>();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
|
||||
@@ -517,6 +577,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Enrichment rides the SAME child write as a fresh workout's
|
||||
// series — one mechanism, one place where a series is stored.
|
||||
// The only difference is where the `workoutId` came from: the
|
||||
// pre-flight probe instead of this request's INSERT ... RETURNING.
|
||||
// Nothing here writes to the workout row.
|
||||
for (const e of toEnrich) {
|
||||
samplesToInsert.push({
|
||||
workoutId: e.workoutId,
|
||||
samples: e.samples.samples as Prisma.InputJsonValue,
|
||||
sampleCount: e.samples.sampleCount,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutRoute.createMany({
|
||||
data: routesToInsert.slice(i, i + CHUNK),
|
||||
@@ -524,10 +597,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutSamples.createMany({
|
||||
// RETURNING names the rows that actually landed. `workoutId` is
|
||||
// unique, so a workout that gained a series between the probe
|
||||
// and this write is skipped here and reports as a duplicate —
|
||||
// the re-post stays a no-op and never overwrites a stored curve.
|
||||
const landed = await tx.workoutSamples.createManyAndReturn({
|
||||
data: samplesToInsert.slice(i, i + CHUNK),
|
||||
skipDuplicates: true,
|
||||
select: { workoutId: true },
|
||||
});
|
||||
for (const row of landed) seriesLanded.add(row.workoutId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -540,6 +619,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// An enriched entry is still a duplicate of the workout row: the
|
||||
// top-level counters count rows, and no row was inserted for it.
|
||||
// The per-entry status carries what happened to the series.
|
||||
for (const e of toEnrich) {
|
||||
results[e.index] = {
|
||||
index: e.index,
|
||||
status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate",
|
||||
};
|
||||
if (seriesLanded.has(e.workoutId)) enrichedCount += 1;
|
||||
duplicateCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +645,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -584,12 +676,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Counts only. The series itself never reaches a wide event.
|
||||
annotate({
|
||||
action: { name: "workout.batch.ingest" },
|
||||
meta: {
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
enriched: enrichedCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
@@ -598,11 +692,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// caches when at least one row landed. Workouts ride on the
|
||||
// measurements bucket because achievements / analytics also touch
|
||||
// workout-derived metrics.
|
||||
if (insertedCount > 0) {
|
||||
// An enrichment changes what the workout reads as — the detail seam
|
||||
// gains its curve, the list gains its glyph — so it busts the cache
|
||||
// too, even though no workout row was written.
|
||||
if (insertedCount > 0 || enrichedCount > 0) {
|
||||
invalidateUserMeasurements(user.id);
|
||||
}
|
||||
|
||||
if (insertedCount > 0) {
|
||||
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
|
||||
// stop at the shared salience classifier before any queue work.
|
||||
// An enrichment raises none: the workout arrived when it was stored.
|
||||
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
|
||||
() => {},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user