fix(workouts): drop a hand-entered workout's paragraph when an edit changes its values

The paragraph described the old values. It is deleted with them, its generation claim is detached the way a delete detaches it (the day's slot stays spent), and the workout's arrival is raised again so the paragraph is written from the corrected values through the ordinary path, under the same daily cap.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:26:48 +02:00
1 parent e811c64bfb
commit 62d47d7056
2 files changed
+128 -6

No files matched your search

+29 -6
View File
@@ -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<Response> {
let enrichedCount = 0;
let updatedCount = 0;
const insertedIdByIndex = new Map<number, string>();
/** Workout ids of the manual edits this request actually wrote. */
const overwrittenIdByIndex = new Map<number, string>();
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<Response> {
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<Response> {
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<Response> {
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({
@@ -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 = {