From 1f94249bd502b68bf9fd0ed80bc9b58f49d53836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 20 Jul 2026 11:11:03 +0200 Subject: [PATCH] fix(mood): preserve rated factors during edits --- .../mood-entries/[id]/__tests__/route.test.ts | 225 +++++++++++++++++- src/app/api/mood-entries/[id]/route.ts | 74 ++++-- .../mood/__tests__/mood-qol-fixes.test.tsx | 14 ++ src/components/mood/mood-list.tsx | 18 +- src/lib/mood/__tests__/tag-links.test.ts | 57 ++++- src/lib/mood/tag-links.ts | 71 ++++-- 6 files changed, 403 insertions(+), 56 deletions(-) diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index ea8cbb29d..c036c0268 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -3,6 +3,16 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import type * as TagLinksModule from "@/lib/mood/tag-links"; +const txClient = vi.hoisted(() => ({ + moodEntry: { + update: vi.fn(), + }, + moodEntryTagLink: { + findMany: vi.fn(), + }, +})); + vi.mock("@/lib/db", () => ({ prisma: { @@ -12,10 +22,20 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), delete: vi.fn(), }, + moodEntryTagLink: { + findMany: vi.fn(), + }, auditLog: { create: vi.fn() }, + $transaction: vi.fn(), }, })); +vi.mock("@/lib/mood/tag-links", async (importOriginal) => ({ + ...(await importOriginal()), + replaceTagLinks: vi.fn(), + replaceRatedFactorLinks: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -42,12 +62,37 @@ vi.mock("next/headers", () => ({ import { PUT } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { + RatedFactorOutOfRangeError, + replaceRatedFactorLinks, + replaceTagLinks, +} from "@/lib/mood/tag-links"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { id: "user-1", username: "tester", role: "USER" as const }, }; +const EXISTING_ENTRY = { + id: "me1", + userId: "user-1", + date: "2026-06-01", + mood: "OKAY", + score: 3, + tags: null, + note: null, + noteEncrypted: null, + source: "MANUAL", + moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"), + syncVersion: 7, +}; + +const UPDATED_ENTRY = { + ...EXISTING_ENTRY, + moodLoggedAt: new Date("2026-06-02T08:00:00.000Z"), + syncVersion: 8, +}; + function putReq(body: unknown): NextRequest { return new NextRequest("http://localhost/api/mood-entries/me1", { method: "PUT", @@ -62,16 +107,21 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation(async (callback) => + callback(txClient as never), + ); + txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never); + txClient.moodEntryTagLink.findMany.mockResolvedValue([]); + vi.mocked(replaceTagLinks).mockResolvedValue(undefined); + vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined); // v1.7.0 sync — PUT now looks the row up via `findFirst` with a // `deletedAt: null` guard (refuses to resurrect-edit a tombstone). - vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({ - id: "me1", - userId: "user-1", - } as never); - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "me1", - userId: "user-1", - } as never); + vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue( + EXISTING_ENTRY as never, + ); + vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue( + EXISTING_ENTRY as never, + ); }); describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { @@ -139,3 +189,162 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { expect(res.status).toBe(422); }); }); + +describe("PUT /api/mood-entries/[id] — split tag replacement", () => { + it("preserves RATED links for a note/time/tagKeys-only edit", async () => { + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { rating: null, moodTag: { key: "happy", kind: "BINARY" } }, + { rating: 4, moodTag: { key: "factor_work", kind: "RATED" } }, + ]); + + const res = await PUT( + putReq({ + note: null, + moodLoggedAt: "2026-06-02T08:00:00.000Z", + tagKeys: ["happy"], + }), + ROUTE_CTX, + ); + + expect(res.status).toBe(200); + expect(replaceTagLinks).toHaveBeenCalledWith( + "me1", + "user-1", + ["happy"], + txClient, + ); + expect(replaceRatedFactorLinks).not.toHaveBeenCalled(); + const body = (await res.json()) as { + data: { + tagKeys: string[]; + ratedFactors: Array<{ key: string; rating: number }>; + }; + error: null; + }; + expect(body.data.tagKeys).toEqual(["happy"]); + expect(body.data.ratedFactors).toEqual([ + { key: "factor_work", rating: 4 }, + ]); + expect(body.error).toBeNull(); + }); + + it("preserves BINARY links and updates scores for a ratedFactors-only edit", async () => { + txClient.moodEntryTagLink.findMany.mockResolvedValue([ + { rating: null, moodTag: { key: "happy", kind: "BINARY" } }, + { rating: 5, moodTag: { key: "factor_work", kind: "RATED" } }, + ]); + + const res = await PUT( + putReq({ ratedFactors: [{ key: "factor_work", rating: 5 }] }), + ROUTE_CTX, + ); + + expect(res.status).toBe(200); + expect(replaceTagLinks).not.toHaveBeenCalled(); + expect(replaceRatedFactorLinks).toHaveBeenCalledWith( + "me1", + "user-1", + [{ key: "factor_work", rating: 5 }], + txClient, + ); + const body = (await res.json()) as { + data: { + tagKeys: string[]; + ratedFactors: Array<{ key: string; rating: number }>; + }; + }; + expect(body.data.tagKeys).toEqual(["happy"]); + expect(body.data.ratedFactors).toEqual([ + { key: "factor_work", rating: 5 }, + ]); + }); + + it("returns 422 when a factor score is outside its catalog scale", async () => { + vi.mocked(replaceRatedFactorLinks).mockRejectedValueOnce( + new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2), + ); + + const res = await PUT( + putReq({ ratedFactors: [{ key: "factor_conflict", rating: 5 }] }), + ROUTE_CTX, + ); + + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + meta: { errorCode: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta.errorCode).toBe("mood.ratedFactor.out_of_range"); + }); + + it("rolls back the mood row, both link sets, and syncVersion when the second replacement fails", async () => { + let persisted = { + mood: "OKAY", + syncVersion: 7, + binary: ["happy"], + rated: [{ key: "factor_work", rating: 2 }], + }; + let draft = structuredClone(persisted); + const transactionClient = { + moodEntry: { + update: vi.fn(async ({ data }: { data: Record }) => { + if (typeof data.mood === "string") draft.mood = data.mood; + if (data.syncVersion) draft.syncVersion += 1; + return UPDATED_ENTRY; + }), + }, + moodEntryTagLink: { + findMany: vi.fn(), + }, + }; + vi.mocked(replaceTagLinks).mockImplementationOnce(async () => { + draft.binary = ["calm"]; + }); + vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => { + draft.rated = [{ key: "factor_work", rating: 5 }]; + throw new Error("injected rated-link replacement failure"); + }); + vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => { + draft = structuredClone(persisted); + const result = await callback(transactionClient as never); + persisted = draft; + return result; + }); + + const res = await PUT( + putReq({ + mood: "GUT", + tagKeys: ["calm"], + ratedFactors: [{ key: "factor_work", rating: 5 }], + }), + ROUTE_CTX, + ); + + expect(replaceTagLinks).toHaveBeenCalledTimes(1); + expect(replaceRatedFactorLinks).toHaveBeenCalledTimes(1); + expect(res.status).toBe(500); + expect(persisted).toEqual({ + mood: "OKAY", + syncVersion: 7, + binary: ["happy"], + rated: [{ key: "factor_work", rating: 2 }], + }); + }); + + it.each([ + { ratedFactors: null }, + { ratedFactors: [] }, + ])("clears RATED links for an explicit $ratedFactors", async (payload) => { + const res = await PUT(putReq(payload), ROUTE_CTX); + + expect(res.status).toBe(200); + expect(replaceRatedFactorLinks).toHaveBeenCalledWith( + "me1", + "user-1", + [], + txClient, + ); + }); +}); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 95813f5db..f5d242fba 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -19,7 +19,11 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; -import { replaceTagLinks } from "@/lib/mood/tag-links"; +import { + RatedFactorOutOfRangeError, + replaceRatedFactorLinks, + replaceTagLinks, +} from "@/lib/mood/tag-links"; type RouteParams = { params: Promise<{ id: string }> }; @@ -140,38 +144,75 @@ export const PUT = apiHandler( // monotonic value and paired clients reconcile higher-wins. updateData.syncVersion = { increment: 1 }; - // v1.8.5 — update the entry and replace its structured-tag links in - // one transaction. A tag-link failure must roll the entry update back - // 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, persistedTagKeys } = await prisma.$transaction( - async (tx) => { + // Update the row and replace each submitted half of the structured-link + // contract in one transaction. A failure in either replacement rolls + // back the mood edit and its syncVersion increment as one unit. + const persistUpdate = () => + 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. + // Omission preserves the corresponding link set. Explicit null/empty + // clears it, matching the validated update contract. if (data.tagKeys !== undefined) { await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx); } + if (data.ratedFactors !== undefined) { + await replaceRatedFactorLinks( + id, + user.id, + data.ratedFactors ?? [], + tx, + ); + } - // v1.8.5 — read the persisted link keys back so the update - // response mirrors the list GET shape exactly. + // Return the same split shape as list/create: binary keys are + // independent from rated-factor keys and their per-entry scores. const links = await tx.moodEntryTagLink.findMany({ where: { moodEntryId: id }, - select: { moodTag: { select: { key: true } } }, + select: { + rating: true, + moodTag: { select: { key: true, kind: true } }, + }, }); return { entry: updated, - persistedTagKeys: links.map((link) => link.moodTag.key), + persistedTagKeys: links + .filter((link) => link.moodTag.kind !== "RATED") + .map((link) => link.moodTag.key), + persistedRatedFactors: links + .filter( + (link) => link.moodTag.kind === "RATED" && link.rating !== null, + ) + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), }; - }, + }); + + const transactionOutcome = await persistUpdate().then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }), ); + if (!transactionOutcome.ok) { + const { error } = transactionOutcome; + if (error instanceof RatedFactorOutOfRangeError) { + annotate({ + action: { name: "mood-entries.update.rated-factor-out-of-range" }, + meta: { scaleMin: error.scaleMin, scaleMax: error.scaleMax }, + }); + return apiError(error.message, 422, { + errorCode: "mood.ratedFactor.out_of_range", + }); + } + throw error; + } + const { entry, persistedTagKeys, persistedRatedFactors } = + transactionOutcome.result; await auditLog("moodEntry.update", { userId: user.id, @@ -219,6 +260,7 @@ export const PUT = apiHandler( // hydrating from the update response renders the tag set without a // refetch (shape-matches the list GET). tagKeys: persistedTagKeys, + ratedFactors: persistedRatedFactors, }); }, ); diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index d38567d46..1b3e85f2c 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -139,6 +139,20 @@ describe("MoodForm — rated factors (v1.12.0)", () => { }); }); +describe("MoodList — rated factor editing", () => { + it("hydrates, renders, and submits the entry's current ratedFactors", () => { + expect(listSrc).toContain("ratedFactors: RatedFactor[];"); + expect(listSrc).toContain( + "const [editRatedFactors, setEditRatedFactors] = useState<", + ); + expect(listSrc).toContain("setEditRatedFactors(entry.ratedFactors ?? [])"); + expect(listSrc).toMatch( + / { it("surfaces a toast on a rejected delete instead of failing silently", () => { expect(listSrc).toContain('import { toast } from "sonner"'); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 76f1f4686..2348772dd 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -81,7 +81,7 @@ import { selectedCountOnPage, } from "@/components/data-list"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; -import { MoodTagPicker } from "./mood-tag-picker"; +import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; // Re-export the score map under the legacy local name to keep the @@ -97,6 +97,7 @@ interface MoodEntry { tags: string[]; // v1.8.5 — structured-tag keys + free-text note. tagKeys: string[]; + ratedFactors: RatedFactor[]; note: string | null; source: string; moodLoggedAt: string; @@ -174,6 +175,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { }); const [editTagsInput, setEditTagsInput] = useState(""); const [editTagKeys, setEditTagKeys] = useState([]); + const [editRatedFactors, setEditRatedFactors] = useState([]); const [editNote, setEditNote] = useState(""); const [editMoodLoggedAt, setEditMoodLoggedAt] = useState(""); const [editError, setEditError] = useState(null); @@ -183,6 +185,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key], ); } + + function rateEditFactor(key: string, rating: number | null) { + setEditRatedFactors((prev) => { + const rest = prev.filter((factor) => factor.key !== key); + return rating === null ? rest : [...rest, { key, rating }]; + }); + } const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); // v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot. const editFormId = useId(); @@ -357,6 +366,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood, tags, tagKeys, + ratedFactors, note, moodLoggedAt, }: { @@ -364,6 +374,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood: string; tags: string[] | null; tagKeys: string[]; + ratedFactors: RatedFactor[]; note: string | null; moodLoggedAt: string; }) => { @@ -371,6 +382,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood, tags, tagKeys, + ratedFactors, note, moodLoggedAt, }); @@ -393,6 +405,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { setEditMood(entry.mood); setEditTagsInput(entry.tags.join(", ")); setEditTagKeys(entry.tagKeys ?? []); + setEditRatedFactors(entry.ratedFactors ?? []); setEditNote(entry.note ?? ""); setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt)); setEditError(null); @@ -440,6 +453,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { mood: editMood, tags: tags.length > 0 ? tags : null, tagKeys: editTagKeys, + ratedFactors: editRatedFactors, note: trimmedNote.length > 0 ? trimmedNote : null, moodLoggedAt: measuredDate.toISOString(), }); @@ -909,6 +923,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index 1591a1495..e1abeb0ab 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -45,8 +45,12 @@ beforeEach(() => { }); /** Existing-link row as `replaceTagLinks` selects it. */ -function link(moodTagId: string, isActive: boolean) { - return { moodTagId, moodTag: { isActive } }; +function link( + moodTagId: string, + isActive: boolean, + kind: "BINARY" | "RATED" = "BINARY", +) { + return { moodTagId, moodTag: { isActive, kind } }; } describe("resolveRatedFactors", () => { @@ -196,6 +200,27 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { expect(linkDeleteMany).not.toHaveBeenCalled(); }); + it("preserves active RATED links while replacing binary links", async () => { + linkFindMany.mockResolvedValue([ + link("mt_happy", true), + link("mt_factor_work", true, "RATED"), + ]); + moodTagFindMany.mockResolvedValue([]); + + await replaceTagLinks("entry-1", "user-1", []); + + expect(linkDeleteMany).toHaveBeenCalledWith({ + where: { moodEntryId: "entry-1", moodTagId: { in: ["mt_happy"] } }, + }); + expect(linkDeleteMany).not.toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + moodTagId: { in: expect.arrayContaining(["mt_factor_work"]) }, + }), + }), + ); + }); + it("createTagLinks (the bulk-ingest path) is additive — it never deletes a link", async () => { // The bulk route re-posts an entry via `createTagLinks`; an archived // key cannot resolve (isActive pinned) but no existing link is ever @@ -213,7 +238,7 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { ); }); - it("replaceRatedFactorLinks scopes its delete to active tags", async () => { + it("replaceRatedFactorLinks deletes every active RATED row, including a null-rated legacy link", async () => { moodTagFindMany.mockResolvedValue([]); await replaceRatedFactorLinks("entry-1", "user-1", []); @@ -221,11 +246,33 @@ describe("replaceTagLinks — archived-tag links survive an entry edit", () => { expect(linkDeleteMany).toHaveBeenCalledWith({ where: { moodEntryId: "entry-1", - rating: { not: null }, - moodTag: { isActive: true }, + moodTag: { isActive: true, kind: "RATED" }, }, }); }); + it("replaces RATED scores without deleting binary links", async () => { + moodTagFindMany.mockResolvedValue([ + { id: "mt_factor_work", key: "factor_work", scaleMin: 1, scaleMax: 5 }, + ]); + + await replaceRatedFactorLinks("entry-1", "user-1", [ + { key: "factor_work", rating: 4 }, + ]); + + expect(linkDeleteMany).toHaveBeenCalledWith({ + where: { + moodEntryId: "entry-1", + moodTag: { isActive: true, kind: "RATED" }, + }, + }); + expect(linkCreateMany).toHaveBeenCalledWith({ + data: [ + { moodEntryId: "entry-1", moodTagId: "mt_factor_work", rating: 4 }, + ], + skipDuplicates: true, + }); + }); + }); describe("entry-ownership guard", () => { diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 3b6f80622..5ad4c07ed 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -150,11 +150,16 @@ export async function resolveRatedFactors( * the resolution is catalogue-only (`user_id IS NULL`), never matching any * custom row — the safe default, since `{ userId: undefined }` in a Prisma * filter would otherwise match every row. + * + * `kind` narrows resolution when a caller owns only one half of the split + * link contract. Edit-time `tagKeys` are binary-only; rated factors use + * `resolveRatedFactors` and carry their score separately. */ export async function resolveTagKeysToIds( keys: string[], db: TagLinkDb = prisma, ownerUserId?: string, + kind?: "BINARY" | "RATED", ): Promise { const unique = Array.from(new Set(keys)); if (unique.length === 0) return []; @@ -162,7 +167,12 @@ export async function resolveTagKeysToIds( ? { OR: [{ userId: null }, { userId: ownerUserId }] } : { userId: null }; const rows = await db.moodTag.findMany({ - where: { key: { in: unique }, isActive: true, ...ownerClause }, + where: { + key: { in: unique }, + isActive: true, + ...(kind ? { kind } : {}), + ...ownerClause, + }, select: { id: true }, }); return rows.map((row) => row.id); @@ -221,12 +231,12 @@ export async function createTagLinks( } /** - * Replace the structured-tag link set for an entry. `keys` is the desired - * set; the helper deletes links no longer present and inserts the new - * ones, leaving unchanged links in place. Passing an empty array clears - * every ACTIVE link. Asserts the entry belongs to `userId` before - * touching links — a defensive guard against editing links on an entry - * the acting session does not own. + * Replace the active BINARY structured-tag link set for an entry. `keys` is + * the desired binary set; RATED links are owned by + * `replaceRatedFactorLinks` and remain untouched. Passing an empty array + * clears every active binary link. Asserts the entry belongs to `userId` + * before touching links — a defensive guard against editing links on an + * entry the acting session does not own. * * The submitted keys govern ACTIVE tag links only. `resolveTagKeysToIds` * pins `isActive: true`, so an archived tag's key can never resolve back @@ -246,15 +256,25 @@ export async function replaceTagLinks( db: TagLinkDb = prisma, ): Promise { await assertEntryOwnership(moodEntryId, userId, db); - const desiredIds = new Set(await resolveTagKeysToIds(keys, db, userId)); + const desiredIds = new Set( + await resolveTagKeysToIds(keys, db, userId, "BINARY"), + ); const existing = await db.moodEntryTagLink.findMany({ where: { moodEntryId }, - select: { moodTagId: true, moodTag: { select: { isActive: true } } }, + select: { + moodTagId: true, + moodTag: { select: { isActive: true, kind: true } }, + }, }); const existingIds = new Set(existing.map((row) => row.moodTagId)); const toDelete = existing - .filter((row) => row.moodTag.isActive && !desiredIds.has(row.moodTagId)) + .filter( + (row) => + row.moodTag.isActive && + row.moodTag.kind === "BINARY" && + !desiredIds.has(row.moodTagId), + ) .map((row) => row.moodTagId); const toCreate = [...desiredIds].filter((id) => !existingIds.has(id)); @@ -273,16 +293,13 @@ export async function replaceTagLinks( /** * v1.12.0 — replace the full rated-factor link set for an entry. A - * changed rating on the same factor is a real change (not a no-op), so - * the simplest correct shape is "delete every RATED link for the entry, - * re-insert the desired set". Binary links are left untouched. Passing an - * empty array clears every rated link. Throws `RatedFactorOutOfRangeError` - * (→ route 422) before any write when a rating is out of scale. - * - * Not wired into a route yet (the POST + bulk ingestion contract is the - * v1.12.0 scope); exported for the PATCH edit path a later wave adds. - * Asserts the entry belongs to `userId` before any write — a defensive - * guard for that future edit route against touching another user's entry. + * changed rating on the same factor is a real change, so the helper deletes + * every active RATED link and re-inserts the desired set. This also upgrades + * legacy/null-rated RATED rows without colliding with their join key. Binary + * links are left untouched. Passing an empty array clears every rated link. + * Throws `RatedFactorOutOfRangeError` (→ route 422) before any write when a + * rating is out of scale. Asserts the entry belongs to `userId` before any + * write. */ export async function replaceRatedFactorLinks( moodEntryId: string, @@ -295,13 +312,15 @@ export async function replaceRatedFactorLinks( // aborts the replace cleanly. const resolved = await resolveRatedFactors(factors, db); - // Drop every existing RATED link for the entry (a `rating IS NOT NULL` - // row is, by construction, a rated-factor link). Same archive contract - // as `replaceTagLinks`: a link whose tag has been archived since the - // entry was logged is history, not editable state — it survives the - // replace untouched. + // Drop every active RATED link, including a legacy row whose rating is + // null. Same archive contract as `replaceTagLinks`: a link whose tag has + // been archived since the entry was logged is history, not editable state + // and survives the replacement. await db.moodEntryTagLink.deleteMany({ - where: { moodEntryId, rating: { not: null }, moodTag: { isActive: true } }, + where: { + moodEntryId, + moodTag: { isActive: true, kind: "RATED" }, + }, }); if (resolved.length > 0) {