fix(mood): entry edits spare archived-tag history and insights speak the custom labels

Editing any field of a mood entry re-sent its tag set, and archived
tag keys can no longer resolve — the diff deleted the entry's links to
them invisibly, against the archive contract the tag suite just
shipped. Submitted tag keys now govern active links only; links to
archived tags survive every edit, and the explicit purge stays the one
removal path. The insights surfaces — tag breakdown, influence, better
days, crosstab, the model snapshot and the narratives — render the
decrypted custom-tag label instead of the raw key, decrypted once per
tag server-side.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:55:50 +02:00
1 parent f3dda21917
commit 97822e9e6d
14 files changed
+372 -22

No files matched your search

@@ -55,6 +55,25 @@ describe("<MoodBetterDays>", () => {
expect(html).toContain('data-direction="up"');
});
it("prefers a custom tag's decrypted label over its raw labelKey", () => {
const html = render(
<MoodBetterDays
factors={[
{
...tagFactor,
key: "custom:abc-123",
labelKey: "custom:abc-123",
label: "Migraine",
categoryKey: "custom",
icon: "Tag",
},
]}
/>,
);
expect(html).toContain("Migraine");
expect(html).not.toContain("custom:abc-123");
});
it("shows a metric factor with its labelled name and r value", () => {
const html = render(<MoodBetterDays factors={[metricFactor]} />);
// sleep maps to the localized correlation title, not the raw key
@@ -79,6 +79,26 @@ describe("<MoodTagInfluence>", () => {
expect(html).not.toContain("charts.weekdaysFull.mon");
});
it("prefers a custom tag's decrypted label over its raw labelKey", () => {
// A custom tag's labelKey mirrors its raw `custom:<uuid>` key — the
// decrypted label must win or the row prints the uuid.
const html = render(
<MoodTagInfluence
rows={[
flatRow({
tag: "custom:abc-123",
labelKey: "custom:abc-123",
label: "Migraine",
categoryKey: "custom",
icon: "Tag",
}),
]}
/>,
);
expect(html).toContain("Migraine");
expect(html).not.toContain("custom:abc-123");
});
it("does not render a local generic disclaimer", () => {
const html = render(
<MoodTagInfluence rows={[flatRow(), flatRow({ tag: "social" })]} />,
@@ -23,6 +23,13 @@ export interface MoodBetterDayFactor {
source: "tag" | "metric";
key: string;
labelKey: string | null;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue / flat /
* metric rows.
*/
label?: string | null;
categoryKey: string | null;
icon: string | null;
direction: "up" | "down";
@@ -78,6 +85,11 @@ export function MoodBetterDays({
let Icon: ReturnType<typeof moodTagIcon> | null = null;
if (factor.source === "metric") {
label = t(METRIC_LABEL_KEY[factor.key] ?? factor.key);
} else if (factor.label) {
// Custom tag — render the decrypted label verbatim (its
// labelKey is the raw `custom:<uuid>` key).
label = factor.label;
Icon = moodTagIcon(factor.icon);
} else if (factor.labelKey) {
label = t(factor.labelKey);
Icon = moodTagIcon(factor.icon);
@@ -16,6 +16,12 @@ export interface MoodStructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue tags.
*/
label?: string | null;
icon: string | null;
count: number;
avgScore: number;
@@ -47,7 +53,7 @@ export function MoodStructuredTagBreakdown({
aria-hidden="true"
/>
<span className="text-foreground w-28 shrink-0 truncate">
{t(row.labelKey)}
{row.label ?? t(row.labelKey)}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
@@ -21,6 +21,12 @@ export type MoodInfluenceConfidence = "low" | "medium" | "high";
export interface MoodTagInfluenceRow {
tag: string;
labelKey: string | null;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue / flat tags.
*/
label?: string | null;
categoryKey: string | null;
icon: string | null;
withDays: number;
@@ -67,7 +73,8 @@ export function MoodTagInfluence({
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
const label = row.labelKey ? t(row.labelKey) : row.tag;
const label =
row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
return (
@@ -24,6 +24,12 @@ export type MoodCrosstabMode = "sameDay" | "nextDay";
export interface MoodTagMetricCrosstabRow {
tag: string;
labelKey: string;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue tags.
*/
label?: string | null;
categoryKey: string;
icon: string | null;
metricKey: string;
@@ -88,7 +94,7 @@ export function MoodTagMetricCrosstab({
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = moodTagIcon(row.icon);
const tagLabel = t(row.labelKey);
const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
@@ -129,6 +129,7 @@ describe("computeStructuredTagSummary", () => {
key: "happy",
categoryKey: "feelings",
labelKey: "mood.tag.happy",
label: null,
icon: "Smile",
count: 2,
avgScore: 4,
@@ -141,6 +142,25 @@ describe("computeStructuredTagSummary", () => {
expect(rows[0].key).toBe("happy");
});
it("carries a custom tag's decrypted label beside its labelKey", () => {
// A custom tag's labelKey mirrors its raw `custom:<uuid>` key; the
// decrypted `label` must survive the summary so the UI never renders
// the raw uuid.
const customRef: StructuredTagRef = {
key: "custom:abc-123",
categoryKey: "custom",
labelKey: "custom:abc-123",
label: "Migraine",
icon: "Tag",
};
const rows = computeStructuredTagSummary(
[entryWithStructured(1, 4, [customRef])],
NOW,
);
expect(rows[0].label).toBe("Migraine");
expect(rows[0].labelKey).toBe("custom:abc-123");
});
it("excludes entries older than the window and ignores entries without structured tags", () => {
const entries: MoodAggregateEntry[] = [
entryWithStructured(1, 5, [
@@ -597,6 +617,38 @@ describe("computeTagInfluence", () => {
);
});
it("threads a custom tag's decrypted label into the influence row and the better-days board", () => {
const customRef: StructuredTagRef = {
key: "custom:abc-123",
categoryKey: "custom",
labelKey: "custom:abc-123",
label: "Migraine",
icon: "Tag",
};
const e = (offset: number, score: number, withTag: boolean): MoodAggregateEntry => ({
...entry(offset, score, null),
structuredTags: withTag ? [customRef] : [],
});
const entries: MoodAggregateEntry[] = [];
for (let i = 0; i < 6; i++) entries.push(e(i, 2, true));
for (let i = 6; i < 12; i++) entries.push(e(i, 4, false));
const influence = computeTagInfluence(entries, NOW);
const row = influence.structured.find((r) => r.tag === "custom:abc-123");
expect(row?.label).toBe("Migraine");
expect(row?.labelKey).toBe("custom:abc-123");
const emptyCorr: MoodMetricCorrelation = { result: null, points: [], n: 0 };
const board = computeBetterDays(influence, {
sleep: emptyCorr,
steps: emptyCorr,
pulse: emptyCorr,
weight: emptyCorr,
bloodPressureSystolic: emptyCorr,
});
const factor = board.find((f) => f.key === "custom:abc-123");
expect(factor?.label).toBe("Migraine");
});
it("caps each axis at the max-rows limit", () => {
const entries: MoodAggregateEntry[] = [];
// 12 distinct tags each present on 6 days, with a shared absent pool
@@ -205,6 +205,35 @@ describe("computeMoodNarratives — anti-platitude thresholds", () => {
expect(lift?.vars.tag).toBeUndefined();
});
it("surfaces a custom structured tag via its decrypted label, never the raw key", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
daily: daily([
{ dayOffset: 0, value: 3 },
{ dayOffset: 1, value: 3 },
{ dayOffset: 2, value: 3 },
]),
structuredTags: [
{
key: "custom:abc-123",
categoryKey: "custom",
// A custom tag's labelKey mirrors its raw key — t() cannot
// resolve it, so the takeaway must carry the decrypted label
// verbatim instead.
labelKey: "custom:abc-123",
label: "Migraine",
icon: "Tag",
count: MOOD_NARRATIVE_MIN_TAG_COUNT,
avgScore: 1.5,
},
],
};
const out = computeMoodNarratives(input);
const drop = out.find((n) => n.kind === "tag-drop");
expect(drop?.vars.tag).toBe("Migraine");
expect(drop?.vars.tagKey).toBeUndefined();
});
it("ranks flat and structured tags in one shared pool", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
+46 -5
View File
@@ -35,6 +35,7 @@ import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-disco
import { toBerlinYmd } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { MeasurementType } from "@/generated/prisma/enums";
@@ -81,6 +82,14 @@ export interface StructuredTagRef {
categoryKey: string;
/** i18n message key for the tag label. */
labelKey: string;
/**
* v1.16.11 — decrypted free-text label for a per-user custom tag
* (`custom:` key), resolved server-side. `null` / absent for catalogue
* tags, whose `labelKey` resolves via i18n — a custom tag's `labelKey`
* just mirrors its key, so without this field every insights surface
* renders the raw `custom:<uuid>`. Renderers use `label ?? t(labelKey)`.
*/
label?: string | null;
/** Lucide icon name, or null. */
icon: string | null;
}
@@ -281,6 +290,8 @@ export interface StructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
/** Decrypted custom-tag label; null for catalogue tags (see `StructuredTagRef`). */
label?: string | null;
icon: string | null;
count: number;
avgScore: number;
@@ -307,6 +318,7 @@ export function computeStructuredTagSummary(
{
categoryKey: string;
labelKey: string;
label: string | null;
icon: string | null;
count: number;
scoreSum: number;
@@ -319,6 +331,7 @@ export function computeStructuredTagSummary(
const current = byKey.get(tag.key) ?? {
categoryKey: tag.categoryKey,
labelKey: tag.labelKey,
label: tag.label ?? null,
icon: tag.icon,
count: 0,
scoreSum: 0,
@@ -333,6 +346,7 @@ export function computeStructuredTagSummary(
key,
categoryKey: stats.categoryKey,
labelKey: stats.labelKey,
label: stats.label,
icon: stats.icon,
count: stats.count,
avgScore: round(stats.scoreSum / stats.count, 2),
@@ -374,6 +388,8 @@ export interface TagInfluenceRow {
* (rendered verbatim).
*/
labelKey: string | null;
/** Decrypted custom-tag label; null for catalogue / flat tags. */
label?: string | null;
categoryKey: string | null;
icon: string | null;
/** Days the tag was present (daily-mean convention). */
@@ -485,7 +501,13 @@ function collapseToTaggedDays(
function influenceForTag(
days: TaggedDay[],
presentOn: (day: TaggedDay) => boolean,
meta: { tag: string; labelKey: string | null; categoryKey: string | null; icon: string | null },
meta: {
tag: string;
labelKey: string | null;
label: string | null;
categoryKey: string | null;
icon: string | null;
},
): TagInfluenceRow | null {
const withVals: number[] = [];
const withoutVals: number[] = [];
@@ -535,6 +557,7 @@ function influenceForTag(
return {
tag: meta.tag,
labelKey: meta.labelKey,
label: meta.label,
categoryKey: meta.categoryKey,
icon: meta.icon,
withDays: withVals.length,
@@ -591,6 +614,7 @@ export function computeTagInfluence(
const row = influenceForTag(days, (d) => d.flatTags.has(key), {
tag: key,
labelKey: null,
label: null,
categoryKey: null,
icon: null,
});
@@ -610,6 +634,7 @@ export function computeTagInfluence(
const row = influenceForTag(days, (d) => d.structuredTags.has(key), {
tag: key,
labelKey: ref.labelKey,
label: ref.label ?? null,
categoryKey: ref.categoryKey,
icon: ref.icon,
});
@@ -640,6 +665,8 @@ export interface BetterDayFactor {
key: string;
/** Flat tags / metrics: null. Structured tags: the i18n label key. */
labelKey: string | null;
/** Decrypted custom-tag label; null for catalogue / flat / metric rows. */
label?: string | null;
categoryKey: string | null;
icon: string | null;
/** "up" = associated with higher mood; "down" = with lower mood. */
@@ -706,6 +733,7 @@ export function computeBetterDays(
source: "tag",
key: row.tag,
labelKey: row.labelKey,
label: row.label ?? null,
categoryKey: row.categoryKey,
icon: row.icon,
direction: row.delta >= 0 ? "up" : "down",
@@ -732,6 +760,7 @@ export function computeBetterDays(
source: "metric",
key,
labelKey: null,
label: null,
categoryKey: null,
icon: null,
// Mood is the x-axis: positive r = higher metric on higher-mood days,
@@ -837,6 +866,8 @@ export interface TagMetricCrosstabRow {
tag: string;
/** i18n label key for the tag (structured tags only — never flat). */
labelKey: string;
/** Decrypted custom-tag label; null for catalogue tags. */
label?: string | null;
/** Parent category key, for grouping/icon. */
categoryKey: string;
/** Lucide icon name, or null. */
@@ -1045,6 +1076,7 @@ export function computeTagMetricCrosstab(args: {
row: {
tag: tagKey,
labelKey: ref.labelKey,
label: ref.label ?? null,
categoryKey: ref.categoryKey,
icon: ref.icon,
metricKey,
@@ -2016,6 +2048,11 @@ export async function fetchMoodAggregates(
select: {
key: true,
labelKey: true,
// v1.16.11 — owner + ciphertext so a custom tag's decrypted
// label can ride beside its labelKey (which just mirrors the
// raw `custom:<uuid>` key). Decrypted once per tag below.
userId: true,
labelEncrypted: true,
icon: true,
kind: true,
scaleMin: true,
@@ -2028,8 +2065,11 @@ export async function fetchMoodAggregates(
},
},
})
.then((rows) =>
rows.reverse().map((row) => ({
.then((rows) => {
// Memoised per-tag decrypt — once per distinct custom tag, never per
// entry link, so a 2000-row read costs at most one decrypt per tag.
const resolveLabel = createCustomLabelResolver();
return rows.reverse().map((row) => ({
date: row.date,
score: row.score,
tags: parseTags(row.tags),
@@ -2043,6 +2083,7 @@ export async function fetchMoodAggregates(
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
label: resolveLabel(link.moodTag),
icon: link.moodTag.icon,
})),
ratedFactors: row.tagLinks
@@ -2060,8 +2101,8 @@ export async function fetchMoodAggregates(
scaleMax: link.moodTag.scaleMax,
inverse: link.moodTag.inverse,
})),
})),
);
}));
});
const measurements = await prisma.measurement
.findMany({
+7 -2
View File
@@ -279,9 +279,14 @@ function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] {
}
for (const row of input.structuredTags) {
if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue;
// v1.16.11 — a custom tag carries its decrypted label (its labelKey
// just mirrors the raw `custom:<uuid>` key, which `t()` cannot
// resolve); it renders verbatim like a flat tag. Catalogue tags keep
// the i18n labelKey path.
const customLabel = row.label ?? null;
candidates.push({
label: null,
labelKey: row.labelKey,
label: customLabel,
labelKey: customLabel != null ? null : row.labelKey,
delta: round(row.avgScore - overall, 2),
count: row.count,
});
+14 -4
View File
@@ -55,6 +55,7 @@ import {
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { toBerlinDayKey } from "@/lib/tz/resolver";
@@ -180,6 +181,13 @@ export async function generateMoodStatusForUser(
select: {
key: true,
labelKey: true,
// v1.16.11 — owner + ciphertext so a custom tag's decrypted
// label reaches the snapshot beside its labelKey (which just
// mirrors the raw `custom:<uuid>` key — the model would
// otherwise reason over an opaque uuid). Decrypted once per
// distinct tag, never per entry link.
userId: true,
labelEncrypted: true,
icon: true,
kind: true,
scaleMin: true,
@@ -192,8 +200,9 @@ export async function generateMoodStatusForUser(
},
},
})
.then((rows) =>
rows.reverse().map((row) => ({
.then((rows) => {
const resolveLabel = createCustomLabelResolver();
return rows.reverse().map((row) => ({
date: row.date,
score: row.score,
tags: row.tags,
@@ -205,6 +214,7 @@ export async function generateMoodStatusForUser(
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
label: resolveLabel(link.moodTag),
icon: link.moodTag.icon,
})),
ratedFactors: (row.tagLinks ?? [])
@@ -222,8 +232,8 @@ export async function generateMoodStatusForUser(
scaleMax: link.moodTag.scaleMax,
inverse: link.moodTag.inverse,
})),
})),
);
}));
});
const now = new Date();
+103
View File
@@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const moodTagFindMany = vi.fn();
const linkCreateMany = vi.fn().mockResolvedValue({ count: 0 });
const linkDeleteMany = vi.fn().mockResolvedValue({ count: 0 });
const linkFindMany = vi.fn().mockResolvedValue([]);
const moodEntryFindUnique = vi.fn();
vi.mock("@/lib/db", () => ({
@@ -20,6 +21,7 @@ vi.mock("@/lib/db", () => ({
moodEntryTagLink: {
createMany: (...a: unknown[]) => linkCreateMany(...a),
deleteMany: (...a: unknown[]) => linkDeleteMany(...a),
findMany: (...a: unknown[]) => linkFindMany(...a),
},
},
}));
@@ -27,6 +29,7 @@ vi.mock("@/lib/db", () => ({
import {
resolveRatedFactors,
createTagLinks,
replaceTagLinks,
replaceRatedFactorLinks,
RatedFactorOutOfRangeError,
MoodEntryOwnershipError,
@@ -36,10 +39,16 @@ beforeEach(() => {
moodTagFindMany.mockReset();
linkCreateMany.mockReset().mockResolvedValue({ count: 0 });
linkDeleteMany.mockReset().mockResolvedValue({ count: 0 });
linkFindMany.mockReset().mockResolvedValue([]);
// Default: the acting user owns the entry under test.
moodEntryFindUnique.mockReset().mockResolvedValue({ userId: "user-1" });
});
/** Existing-link row as `replaceTagLinks` selects it. */
function link(moodTagId: string, isActive: boolean) {
return { moodTagId, moodTag: { isActive } };
}
describe("resolveRatedFactors", () => {
it("resolves a valid factor to its catalog id + rating", async () => {
moodTagFindMany.mockResolvedValue([
@@ -120,6 +129,100 @@ describe("createTagLinks with rated factors", () => {
});
});
describe("replaceTagLinks — archived-tag links survive an entry edit", () => {
it("preserves an archived-tag link when the body omits its key (note-only edit)", async () => {
// The entry carries an archived custom tag + an active catalogue tag.
// The web edit re-sends only the keys the picker can render — the
// active one — so the archived key is absent from the body.
linkFindMany.mockResolvedValue([
link("mt_custom_archived", false),
link("mt_happy", true),
]);
// `resolveTagKeysToIds` (isActive: true pinned) resolves the body keys.
moodTagFindMany.mockResolvedValue([{ id: "mt_happy" }]);
await replaceTagLinks("entry-1", "user-1", ["happy"]);
expect(linkDeleteMany).not.toHaveBeenCalled();
expect(linkCreateMany).not.toHaveBeenCalled();
});
it("preserves archived links even when the body clears every tag", async () => {
linkFindMany.mockResolvedValue([
link("mt_custom_archived", false),
link("mt_happy", true),
]);
moodTagFindMany.mockResolvedValue([]);
await replaceTagLinks("entry-1", "user-1", []);
// Only the ACTIVE link is cleared; the archived link is history.
expect(linkDeleteMany).toHaveBeenCalledWith({
where: { moodEntryId: "entry-1", moodTagId: { in: ["mt_happy"] } },
});
});
it("removes an active tag the body dropped", async () => {
linkFindMany.mockResolvedValue([link("mt_happy", true), link("mt_tired", true)]);
moodTagFindMany.mockResolvedValue([{ id: "mt_happy" }]);
await replaceTagLinks("entry-1", "user-1", ["happy"]);
expect(linkDeleteMany).toHaveBeenCalledWith({
where: { moodEntryId: "entry-1", moodTagId: { in: ["mt_tired"] } },
});
expect(linkCreateMany).not.toHaveBeenCalled();
});
it("adds a new tag while the archived link stays intact", async () => {
linkFindMany.mockResolvedValue([
link("mt_custom_archived", false),
link("mt_happy", true),
]);
moodTagFindMany.mockResolvedValue([{ id: "mt_happy" }, { id: "mt_calm" }]);
await replaceTagLinks("entry-1", "user-1", ["happy", "calm"]);
expect(linkCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [{ moodEntryId: "entry-1", moodTagId: "mt_calm" }],
}),
);
expect(linkDeleteMany).not.toHaveBeenCalled();
});
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
// deleted, so archived history survives a re-ingest by construction.
moodTagFindMany.mockResolvedValue([{ id: "mt_happy" }]);
await createTagLinks("entry-1", "user-1", ["happy", "custom:gone"]);
expect(linkDeleteMany).not.toHaveBeenCalled();
expect(linkCreateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: [{ moodEntryId: "entry-1", moodTagId: "mt_happy" }],
skipDuplicates: true,
}),
);
});
it("replaceRatedFactorLinks scopes its delete to active tags", async () => {
moodTagFindMany.mockResolvedValue([]);
await replaceRatedFactorLinks("entry-1", "user-1", []);
expect(linkDeleteMany).toHaveBeenCalledWith({
where: {
moodEntryId: "entry-1",
rating: { not: null },
moodTag: { isActive: true },
},
});
});
});
describe("entry-ownership guard", () => {
it("createTagLinks throws and writes nothing when the entry is owned by another user", async () => {
moodEntryFindUnique.mockResolvedValue({ userId: "attacker" });
+24
View File
@@ -83,6 +83,30 @@ export function decryptCustomLabel(encoded: string | null): string | null {
}
}
/**
* Memoised per-tag label resolver for read paths that fan a tag's
* catalogue row out across many entry links (insights aggregates, status
* snapshots). Decrypts a custom tag's label at most once per tag key —
* never per entry — and returns null for catalogue rows (whose `labelKey`
* resolves via i18n) or an undecryptable ciphertext (the caller falls
* back to the labelKey).
*/
export function createCustomLabelResolver(): (tag: {
key: string;
userId: string | null;
labelEncrypted: string | null;
}) => string | null {
const cache = new Map<string, string | null>();
return (tag) => {
if (!tag.userId) return null;
const hit = cache.get(tag.key);
if (hit !== undefined) return hit;
const label = decryptCustomLabel(tag.labelEncrypted);
cache.set(tag.key, label);
return label;
};
}
const labelSchema = z
.string()
.trim()
+24 -8
View File
@@ -221,12 +221,23 @@ export async function createTagLinks(
}
/**
* Replace the full 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 link. Asserts the entry belongs to `userId` before
* 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.
*
* The submitted keys govern ACTIVE tag links only. `resolveTagKeysToIds`
* pins `isActive: true`, so an archived tag's key can never resolve back
* into the desired set — without the `isActive` guard below, editing just
* the note of an old entry would land its archived-tag links in
* `toDelete` and silently destroy history the archive contract promises
* to keep (the picker cannot render an archived key as selected, so the
* client body never carries it). Links whose tag is archived
* (`isActive: false`) are therefore preserved untouched no matter what
* the body carries; a purge (`DELETE /api/mood/tags/custom/[key]?purge=true`)
* remains the only path that removes them.
*/
export async function replaceTagLinks(
moodEntryId: string,
@@ -238,11 +249,13 @@ export async function replaceTagLinks(
const desiredIds = new Set(await resolveTagKeysToIds(keys, db, userId));
const existing = await db.moodEntryTagLink.findMany({
where: { moodEntryId },
select: { moodTagId: true },
select: { moodTagId: true, moodTag: { select: { isActive: true } } },
});
const existingIds = new Set(existing.map((row) => row.moodTagId));
const toDelete = [...existingIds].filter((id) => !desiredIds.has(id));
const toDelete = existing
.filter((row) => row.moodTag.isActive && !desiredIds.has(row.moodTagId))
.map((row) => row.moodTagId);
const toCreate = [...desiredIds].filter((id) => !existingIds.has(id));
if (toDelete.length > 0) {
@@ -283,9 +296,12 @@ export async function replaceRatedFactorLinks(
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).
// 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.
await db.moodEntryTagLink.deleteMany({
where: { moodEntryId, rating: { not: null } },
where: { moodEntryId, rating: { not: null }, moodTag: { isActive: true } },
});
if (resolved.length > 0) {