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 &&