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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 21:17:02 +02:00
1 parent d5580917cc
commit 78d3478ad2
3 files changed
+102 -36

No files matched your search

+33 -14
View File
@@ -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,
});
},
);
@@ -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",
+45 -22
View File
@@ -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 &&