mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(mood): preserve rated factors during edits
This commit is contained in:
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user