diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 5b885aa9e..6d3e30d85 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -194,7 +194,8 @@ interface EntryResult { } /** - * v1.31.1 — emit one data arrival per workout this batch actually inserted. + * v1.31.1 — emit one data arrival per workout this batch actually inserted, + * and per hand-entered workout whose edit it actually wrote. * * `createManyAndReturn` gives the transaction the exact inserted ids. The * index-keyed map avoids a second lookup and keeps equal external ids from @@ -541,6 +542,8 @@ async function postBatch(request: NextRequest): Promise { let enrichedCount = 0; let updatedCount = 0; const insertedIdByIndex = new Map(); + /** Workout ids of the manual edits this request actually wrote. */ + const overwrittenIdByIndex = new Map(); if (survivors.length > 0) { // v1.4.42 W5 — only the write-time dedup survivors are probed @@ -709,6 +712,19 @@ async function postBatch(request: NextRequest): Promise { externalId: o.externalId, }, }); + // The paragraph described the old values (a 12 km run corrected + // to 1.2 km must not keep its 12 km story). It goes with them, + // and the claim is let go of its workout the way a delete does + // (`ON DELETE SET NULL`): the row stays as the day's ledger, so + // the edit buys no slot back, and the arrival emitted below can + // claim a fresh one through the ordinary path, under the same cap. + await tx.workoutInsight.deleteMany({ + where: { userId: user.id, workoutId: o.workoutId }, + }); + await tx.workoutInsightGenerationClaim.updateMany({ + where: { userId: user.id, workoutId: o.workoutId }, + data: { workoutId: null }, + }); } } @@ -841,7 +857,10 @@ async function postBatch(request: NextRequest): Promise { index: o.index, status: landed ? "updated" : "duplicate", }; - if (landed) updatedCount += 1; + if (landed) { + updatedCount += 1; + overwrittenIdByIndex.set(o.index, o.workoutId); + } duplicateCount += 1; } } @@ -914,13 +933,17 @@ async function postBatch(request: NextRequest): Promise { invalidateUserMeasurements(user.id); } - if (insertedCount > 0) { + if (insertedCount > 0 || overwrittenIdByIndex.size > 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( - () => {}, - ); + // A hand-entered workout whose edit landed raises one again, so its + // paragraph (dropped with the old values) is written from the new ones. + void emitWorkoutArrivals( + user.id, + prepared, + new Map([...insertedIdByIndex, ...overwrittenIdByIndex]), + ).catch(() => {}); } return apiSuccess({ diff --git a/tests/integration/workout-manual-entry.test.ts b/tests/integration/workout-manual-entry.test.ts index e6ee0bd20..434d4839c 100644 --- a/tests/integration/workout-manual-entry.test.ts +++ b/tests/integration/workout-manual-entry.test.ts @@ -11,6 +11,9 @@ * - a second submit of the same form is a `duplicate`, not a second row; * - a submit after an edit is `updated` and the one row carries the edit, * while a synced id with changed values stays first-write-wins; + * - an edit drops the paragraph written about the old values, lets go of + * its generation claim without freeing the day's slot, and raises the + * workout's arrival again so the paragraph is written from the new ones; * - delete removes a hand-entered workout and refuses a synced one. */ import { NextRequest } from "next/server"; @@ -45,6 +48,11 @@ vi.mock("next/headers", async () => { }; }); +// The arrival seam is observed, not run: the edit test asserts the route +// raises one for the edited workout; what the spine then does is its own suite. +const emitDataArrival = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("@/lib/arrivals/emit-shared", () => ({ emitDataArrival })); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -216,6 +224,97 @@ describe("a workout entered by hand", () => { ]); }); + it("drops the paragraph about the old values when an edit lands", async () => { + const prisma = getPrismaClient(); + const entry = formEntry(newManualWorkoutExternalId()); + await post({ workouts: [entry] }); + const stored = await prisma.workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + // What the worker leaves behind once it wrote the paragraph. + const claim = await prisma.workoutInsightGenerationClaim.create({ + data: { + userId: TEST_USER_ID, + workoutId: stored.id, + localDate: "2026-10-03", + providerInvokedAt: new Date(), + completedAt: new Date(), + }, + }); + await prisma.workoutInsight.create({ + data: { + userId: TEST_USER_ID, + workoutId: stored.id, + paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"), + inputHash: "hash-of-the-old-values", + promptVersion: "1.0.0", + providerType: "local", + locale: "en", + generatedAt: new Date(), + }, + }); + emitDataArrival.mockClear(); + + // A run corrected from 8 km to 1.2 km. + const res = await post({ + workouts: [{ ...entry, totalDistanceM: 1200 }], + }); + expect((await res.json()).data.entries).toEqual([ + { index: 0, status: "updated" }, + ]); + + expect( + await prisma.workoutInsight.count({ where: { workoutId: stored.id } }), + ).toBe(0); + // The claim stays as the day's ledger, detached from the workout, so the + // edit frees no slot and a fresh claim for the workout is possible. + const ledger = await prisma.workoutInsightGenerationClaim.findUniqueOrThrow( + { where: { id: claim.id } }, + ); + expect(ledger.workoutId).toBeNull(); + expect(ledger.providerInvokedAt).not.toBeNull(); + await vi.waitFor(() => + expect(emitDataArrival).toHaveBeenCalledWith( + expect.objectContaining({ + userId: TEST_USER_ID, + kind: "workout", + refId: stored.id, + }), + ), + ); + }); + + it("keeps the paragraph when the same form is sent again unchanged", async () => { + const prisma = getPrismaClient(); + const entry = formEntry(newManualWorkoutExternalId()); + await post({ workouts: [entry] }); + const stored = await prisma.workout.findFirstOrThrow({ + where: { userId: TEST_USER_ID }, + }); + await prisma.workoutInsight.create({ + data: { + userId: TEST_USER_ID, + workoutId: stored.id, + paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"), + inputHash: "hash-of-the-values", + promptVersion: "1.0.0", + providerType: "local", + locale: "en", + generatedAt: new Date(), + }, + }); + emitDataArrival.mockClear(); + + const res = await post({ workouts: [entry] }); + expect((await res.json()).data.entries).toEqual([ + { index: 0, status: "duplicate" }, + ]); + expect( + await prisma.workoutInsight.count({ where: { workoutId: stored.id } }), + ).toBe(1); + expect(emitDataArrival).not.toHaveBeenCalled(); + }); + it("keeps a synced workout first-write-wins under the same resend", async () => { const prisma = getPrismaClient(); const entry = {