fix(mood): preserve rated factors during edits

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:11:03 +02:00
1 parent d31a33e14c
commit 1f94249bd5
6 files changed
+403 -56

No files matched your search

@@ -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<typeof TagLinksModule>()),
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<string, unknown> }) => {
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,
);
});
});
+58 -16
View File
@@ -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,
});
},
);
@@ -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(
/<MoodTagPicker[\s\S]*ratedFactors=\{editRatedFactors\}[\s\S]*onRateFactor=\{rateEditFactor\}/,
);
expect(listSrc).toContain("ratedFactors: editRatedFactors,");
});
});
describe("MoodList — delete failure toast (v1.11.5)", () => {
it("surfaces a toast on a rejected delete instead of failing silently", () => {
expect(listSrc).toContain('import { toast } from "sonner"');
+17 -1
View File
@@ -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<string[]>([]);
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editError, setEditError] = useState<string | null>(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 = {}) {
<MoodTagPicker
selected={editTagKeys}
onToggle={toggleEditTagKey}
ratedFactors={editRatedFactors}
onRateFactor={rateEditFactor}
/>
</div>
+52 -5
View File
@@ -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", () => {
+45 -26
View File
@@ -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<string[]> {
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<void> {
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) {