mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user