From 78d3478ad28b82ef7b5ae37c80e6e042fdadc7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 21:11:47 +0200 Subject: [PATCH] fix(mood): return tag keys from create and update The list GET surfaces tagKeys per entry but the create and update responses omitted it, so a client hydrating from the response body showed an empty tag set until the next refetch. Read the persisted link keys back inside the write transaction and include them in both response bodies. --- src/app/api/mood-entries/[id]/route.ts | 47 +++++++++---- .../api/mood-entries/__tests__/route.test.ts | 24 +++++++ src/app/api/mood-entries/route.ts | 67 +++++++++++++------ 3 files changed, 102 insertions(+), 36 deletions(-) diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index aff2f1548..f0e940154 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -139,21 +139,33 @@ export const PUT = apiHandler( // too — otherwise a client retry on the 5xx double-applies the edit // (and the `syncVersion` increment). The tx client is threaded through // the helper so both writes commit (or abort) together. - const entry = await prisma.$transaction(async (tx) => { - const updated = await tx.moodEntry.update({ - where: { id }, - data: updateData, - }); + const { entry, persistedTagKeys } = await prisma.$transaction( + async (tx) => { + const updated = await tx.moodEntry.update({ + where: { id }, + data: updateData, + }); - // Full replacement of the structured-tag link set when `tagKeys` - // is present in the body. `null` clears every link; an omitted - // field leaves the links untouched. - if (data.tagKeys !== undefined) { - await replaceTagLinks(id, data.tagKeys ?? [], tx); - } + // Full replacement of the structured-tag link set when `tagKeys` + // is present in the body. `null` clears every link; an omitted + // field leaves the links untouched. + if (data.tagKeys !== undefined) { + await replaceTagLinks(id, data.tagKeys ?? [], tx); + } - return updated; - }); + // v1.8.5 — read the persisted link keys back so the update + // response mirrors the list GET shape exactly. + const links = await tx.moodEntryTagLink.findMany({ + where: { moodEntryId: id }, + select: { moodTag: { select: { key: true } } }, + }); + + return { + entry: updated, + persistedTagKeys: links.map((link) => link.moodTag.key), + }; + }, + ); await auditLog("moodEntry.update", { userId: user.id, @@ -194,7 +206,14 @@ export const PUT = apiHandler( }); } - return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); + return apiSuccess({ + ...entry, + tags: parseTags(entry.tags), + // v1.8.5 — surface the persisted structured-tag keys so a client + // hydrating from the update response renders the tag set without a + // refetch (shape-matches the list GET). + tagKeys: persistedTagKeys, + }); }, ); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index a6f45e5dd..d15b8852c 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -243,6 +243,30 @@ describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () expect(createTagLinks).toHaveBeenCalledWith("mood-1", ["happy"], txClient); }); + it("returns the persisted tagKeys in the create response", async () => { + txClient.moodEntry.create.mockResolvedValue({ + id: "mood-1", + tags: null, + moodLoggedAt: new Date(VALID_BODY.moodLoggedAt), + mood: "GUT", + note: null, + source: "MANUAL", + date: "2026-06-01", + }); + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { moodTag: { key: "happy" } }, + ]); + + const res = await POST(postReq(VALID_BODY)); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { id: string; tagKeys: string[] }; + }; + // Read-after-write parity: the create response carries the same + // `tagKeys` the list GET surfaces. + expect(body.data.tagKeys).toEqual(["happy"]); + }); + it("rolls the entry back when the tag-link write fails (no commit)", async () => { txClient.moodEntry.create.mockResolvedValue({ id: "mood-2", diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 3c85035db..40a95fe20 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -172,29 +172,42 @@ async function postMoodEntry(request: NextRequest) { // tag-link failure must roll the entry back too — otherwise a client // retry on the 5xx mints a duplicate entry. The tx client is threaded // through the helper so both writes commit (or abort) together. - const entry = await prisma.$transaction(async (tx) => { - const created = await tx.moodEntry.create({ - data: { - userId: user.id, - date, - tz, - mood, - score, - tags: tags ? JSON.stringify(tags) : null, - note: note ?? null, - source: source ?? "MANUAL", - moodLoggedAt, - }, - }); + const { entry, persistedTagKeys } = await prisma.$transaction( + async (tx) => { + const created = await tx.moodEntry.create({ + data: { + userId: user.id, + date, + tz, + mood, + score, + tags: tags ? JSON.stringify(tags) : null, + note: note ?? null, + source: source ?? "MANUAL", + moodLoggedAt, + }, + }); - if (tagKeys && tagKeys.length > 0) { - // Unknown keys are dropped inside the helper (the catalog is the - // source of truth). - await createTagLinks(created.id, tagKeys, tx); - } + if (tagKeys && tagKeys.length > 0) { + // Unknown keys are dropped inside the helper (the catalog is the + // source of truth). + await createTagLinks(created.id, tagKeys, tx); + } - return created; - }); + // v1.8.5 — read the persisted link keys back so the create + // response mirrors the list GET shape exactly (unknown keys + // already filtered out). + const links = await tx.moodEntryTagLink.findMany({ + where: { moodEntryId: created.id }, + select: { moodTag: { select: { key: true } } }, + }); + + return { + entry: created, + persistedTagKeys: links.map((link) => link.moodTag.key), + }; + }, + ); await auditLog("moodEntry.create", { userId: user.id, @@ -250,7 +263,17 @@ async function postMoodEntry(request: NextRequest) { // rejection in the Next.js runtime. }); - return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201); + return apiSuccess( + { + ...entry, + tags: parseTags(entry.tags), + // v1.8.5 — surface the persisted structured-tag keys so a client + // hydrating from the create response renders the tag set without a + // refetch (shape-matches the list GET). + tagKeys: persistedTagKeys, + }, + 201, + ); } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError &&