diff --git a/src/components/insights/mood/__tests__/mood-better-days.test.tsx b/src/components/insights/mood/__tests__/mood-better-days.test.tsx index 5f7c048b1..e39efb3aa 100644 --- a/src/components/insights/mood/__tests__/mood-better-days.test.tsx +++ b/src/components/insights/mood/__tests__/mood-better-days.test.tsx @@ -55,6 +55,25 @@ describe("", () => { expect(html).toContain('data-direction="up"'); }); + it("prefers a custom tag's decrypted label over its raw labelKey", () => { + const html = render( + , + ); + 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(); // sleep maps to the localized correlation title, not the raw key diff --git a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx index e08d4f1b9..565098528 100644 --- a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx +++ b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx @@ -79,6 +79,26 @@ describe("", () => { 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:` key — the + // decrypted label must win or the row prints the uuid. + const html = render( + , + ); + expect(html).toContain("Migraine"); + expect(html).not.toContain("custom:abc-123"); + }); + it("does not render a local generic disclaimer", () => { const html = render( , diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx index 047b6f099..8247b6ef8 100644 --- a/src/components/insights/mood/mood-better-days.tsx +++ b/src/components/insights/mood/mood-better-days.tsx @@ -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:` 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 | 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:` key). + label = factor.label; + Icon = moodTagIcon(factor.icon); } else if (factor.labelKey) { label = t(factor.labelKey); Icon = moodTagIcon(factor.icon); diff --git a/src/components/insights/mood/mood-structured-tag-breakdown.tsx b/src/components/insights/mood/mood-structured-tag-breakdown.tsx index fd98d2c5e..ca6e3cec3 100644 --- a/src/components/insights/mood/mood-structured-tag-breakdown.tsx +++ b/src/components/insights/mood/mood-structured-tag-breakdown.tsx @@ -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:` 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" /> - {t(row.labelKey)} + {row.label ?? t(row.labelKey)}
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}` diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx index 1db777339..99cf293ce 100644 --- a/src/components/insights/mood/mood-tag-influence.tsx +++ b/src/components/insights/mood/mood-tag-influence.tsx @@ -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:` 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({
    {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 ( diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index ce7b836ec..f347c0df3 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -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:` 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({
      {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, ); diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 4acf39706..93b6d01e1 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -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:` 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 diff --git a/src/lib/insights/__tests__/mood-narratives.test.ts b/src/lib/insights/__tests__/mood-narratives.test.ts index de8f27846..87f47a73a 100644 --- a/src/lib/insights/__tests__/mood-narratives.test.ts +++ b/src/lib/insights/__tests__/mood-narratives.test.ts @@ -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(), diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 219238db9..a07fa4ab8 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -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:`. 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:` 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({ diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 0a9039a22..32aad7103 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -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:` 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, }); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 2fefef2ef..f4a97999c 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -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:` 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(); diff --git a/src/lib/mood/__tests__/tag-links.test.ts b/src/lib/mood/__tests__/tag-links.test.ts index 1b8c4e3b4..701d9f511 100644 --- a/src/lib/mood/__tests__/tag-links.test.ts +++ b/src/lib/mood/__tests__/tag-links.test.ts @@ -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" }); diff --git a/src/lib/mood/custom-tags.ts b/src/lib/mood/custom-tags.ts index f18280e2b..53f12540a 100644 --- a/src/lib/mood/custom-tags.ts +++ b/src/lib/mood/custom-tags.ts @@ -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(); + 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() diff --git a/src/lib/mood/tag-links.ts b/src/lib/mood/tag-links.ts index 4d268d229..3b6f80622 100644 --- a/src/lib/mood/tag-links.ts +++ b/src/lib/mood/tag-links.ts @@ -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) {