From 9a390aaceadd49d3eca1065d758689c16133a571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:36:09 +0200 Subject: [PATCH 01/24] feat(mood): add the level-A dimension table and the A1 derivation map - MOOD_DIMENSIONS carries the five self-state dimensions in capture order, each with its column, its label and anchor keys, its orientation and its 0-10 bounds - getA1ForMood maps the five-point label onto the pleasantness scale once, so the quick check-in stays a full-value capture path - only stress is inverse-oriented, and a test pins that count - the secret scan reads a dotted message key on a *Key field as a token; allowlist the dimension keys the way the existing message keys are --- .gitleaks.toml | 4 + src/lib/mood/__tests__/dimensions.test.ts | 87 +++++++++++ src/lib/mood/dimensions.ts | 138 ++++++++++++++++++ .../validations/__tests__/mood-a1-map.test.ts | 69 +++++++++ src/lib/validations/mood.ts | 37 +++++ 5 files changed, 335 insertions(+) create mode 100644 src/lib/mood/__tests__/dimensions.test.ts create mode 100644 src/lib/mood/dimensions.ts create mode 100644 src/lib/validations/__tests__/mood-a1-map.test.ts diff --git a/.gitleaks.toml b/.gitleaks.toml index 1b98529ce..b5d9433d0 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -28,6 +28,10 @@ regexes = [ # Coach cadence-suggestion label keys are dotted message keys, not secrets; # one trips the generic-api-key entropy heuristic. '''coach\.reminderSuggestion\.cadence''', + # Level-A mood dimension label and anchor keys are dotted message keys, not + # secrets; they sit on fields named `labelKey` / `lowAnchorKey`, and the + # generic-api-key rule reads any `*Key: ""` as a token. + '''mood\.dimension\.''', ] # Specific commit fingerprints from prior false-positive findings (kept for reproducibility). diff --git a/src/lib/mood/__tests__/dimensions.test.ts b/src/lib/mood/__tests__/dimensions.test.ts new file mode 100644 index 000000000..5782f1900 --- /dev/null +++ b/src/lib/mood/__tests__/dimensions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + MOOD_DIMENSIONS, + MOOD_DIMENSION_MAX, + MOOD_DIMENSION_MIN, + getMoodDimension, + orientForComparison, +} from "@/lib/mood/dimensions"; + +describe("MOOD_DIMENSIONS", () => { + it("carries the five level-A dimensions in capture order", () => { + expect(MOOD_DIMENSIONS.map((d) => d.key)).toEqual([ + "a1", + "a2", + "a3", + "a4", + "a5", + ]); + }); + + it("binds each dimension to its own column", () => { + expect(MOOD_DIMENSIONS.map((d) => d.column)).toEqual([ + "moodA1", + "stressA2", + "energyA3", + "connectionA4", + "stabilityA5", + ]); + const columns = new Set(MOOD_DIMENSIONS.map((d) => d.column)); + expect(columns.size).toBe(MOOD_DIMENSIONS.length); + }); + + it("runs 0..10 on every dimension", () => { + for (const d of MOOD_DIMENSIONS) { + expect(d.min).toBe(MOOD_DIMENSION_MIN); + expect(d.max).toBe(MOOD_DIMENSION_MAX); + } + }); + + it("marks exactly one dimension inverse, and it is stress", () => { + const inverse = MOOD_DIMENSIONS.filter((d) => d.inverse); + expect(inverse.map((d) => d.key)).toEqual(["a2"]); + }); + + it("gives every dimension a distinct label and two distinct anchors", () => { + const keys: string[] = []; + for (const d of MOOD_DIMENSIONS) { + expect(d.labelKey).toBe(`mood.dimension.${d.key}.label`); + expect(d.lowAnchorKey).toBe(`mood.dimension.${d.key}.low`); + expect(d.highAnchorKey).toBe(`mood.dimension.${d.key}.high`); + keys.push(d.labelKey, d.lowAnchorKey, d.highAnchorKey); + } + expect(new Set(keys).size).toBe(keys.length); + }); + + it("resolves a dimension by wire key and refuses anything else", () => { + expect(getMoodDimension("a3")?.column).toBe("energyA3"); + expect(getMoodDimension("a6")).toBeUndefined(); + expect(getMoodDimension("moodA1")).toBeUndefined(); + expect(getMoodDimension("")).toBeUndefined(); + }); +}); + +describe("orientForComparison", () => { + const a1 = MOOD_DIMENSIONS[0]; + const a2 = MOOD_DIMENSIONS[1]; + + it("leaves an upright dimension alone", () => { + expect(orientForComparison(a1, 0)).toBe(0); + expect(orientForComparison(a1, 7)).toBe(7); + expect(orientForComparison(a1, 10)).toBe(10); + }); + + it("flips the inverse dimension around the scale", () => { + expect(orientForComparison(a2, 0)).toBe(10); + expect(orientForComparison(a2, 3)).toBe(7); + expect(orientForComparison(a2, 10)).toBe(0); + }); + + it("answers null for an absent value rather than a neutral one", () => { + expect(orientForComparison(a1, null)).toBeNull(); + expect(orientForComparison(a1, undefined)).toBeNull(); + expect(orientForComparison(a2, null)).toBeNull(); + expect(orientForComparison(a2, Number.NaN)).toBeNull(); + }); +}); diff --git a/src/lib/mood/dimensions.ts b/src/lib/mood/dimensions.ts new file mode 100644 index 000000000..31f6b6ba9 --- /dev/null +++ b/src/lib/mood/dimensions.ts @@ -0,0 +1,138 @@ +/** + * The five level-A self-state dimensions of a mood entry. + * + * A mood entry has carried one 1–5 value (`MoodEntry.score`) since the first + * release. That value keeps its meaning and its scale — eleven surfaces read + * it on the assumption that it runs 1..5 — and the five dimensions below ride + * beside it as their own 0–10 columns. The two coexist by design: `score` is + * "how was the day, in one word", the dimensions are "how was the day, in the + * terms the day was actually made of". + * + * Each dimension is stored literally, exactly as the user set it against the + * anchors they read. A2 (stress) is inverse-oriented — a higher value means a + * worse day — and it is stored that way rather than flipped at write time. An + * analytics surface that needs "up is better" flips the sign through + * `inverse` on this table; nothing flips inline. The same rule already governs + * rated mood factors (`MoodTag.inverse`), and it exists because a stored value + * that disagrees with the label the user saw is unrecoverable afterwards. + * + * This module is pure: it holds i18n keys and column names, and imports + * neither Prisma nor the message bundles. The German wording behind every key + * lives in `messages/de.json` and its five siblings. + */ + +/** Wire/DTO key of a level-A dimension. */ +export type MoodDimensionKey = "a1" | "a2" | "a3" | "a4" | "a5"; + +/** `MoodEntry` column a level-A dimension is stored in. */ +export type MoodDimensionColumn = + "moodA1" | "stressA2" | "energyA3" | "connectionA4" | "stabilityA5"; + +export interface MoodDimension { + /** Wire key (`a1`…`a5`) accepted by the create schema and echoed on reads. */ + key: MoodDimensionKey; + /** Prisma field on `MoodEntry` this dimension is stored in. */ + column: MoodDimensionColumn; + /** i18n key of the dimension's own name. */ + labelKey: string; + /** i18n key of the wording at the bottom of the scale. */ + lowAnchorKey: string; + /** i18n key of the wording at the top of the scale. */ + highAnchorKey: string; + /** + * A higher stored value means a worse day. Exactly one dimension carries + * this today (A2, stress), and the unit test pins that count, so a second + * one cannot be flipped quietly. + */ + inverse: boolean; + min: 0; + max: 10; +} + +/** + * The dimensions in capture order — the order the sliders render in and the + * order every per-dimension surface iterates. Concept order, not alphabetical: + * pleasantness first because it is the one the quick check-in already answers. + */ +export const MOOD_DIMENSIONS: readonly MoodDimension[] = [ + { + key: "a1", + column: "moodA1", + labelKey: "mood.dimension.a1.label", + lowAnchorKey: "mood.dimension.a1.low", + highAnchorKey: "mood.dimension.a1.high", + inverse: false, + min: 0, + max: 10, + }, + { + key: "a2", + column: "stressA2", + labelKey: "mood.dimension.a2.label", + lowAnchorKey: "mood.dimension.a2.low", + highAnchorKey: "mood.dimension.a2.high", + inverse: true, + min: 0, + max: 10, + }, + { + key: "a3", + column: "energyA3", + labelKey: "mood.dimension.a3.label", + lowAnchorKey: "mood.dimension.a3.low", + highAnchorKey: "mood.dimension.a3.high", + inverse: false, + min: 0, + max: 10, + }, + { + key: "a4", + column: "connectionA4", + labelKey: "mood.dimension.a4.label", + lowAnchorKey: "mood.dimension.a4.low", + highAnchorKey: "mood.dimension.a4.high", + inverse: false, + min: 0, + max: 10, + }, + { + key: "a5", + column: "stabilityA5", + labelKey: "mood.dimension.a5.label", + lowAnchorKey: "mood.dimension.a5.low", + highAnchorKey: "mood.dimension.a5.high", + inverse: false, + min: 0, + max: 10, + }, +] as const; + +/** Lowest value any dimension accepts. */ +export const MOOD_DIMENSION_MIN = 0; +/** Highest value any dimension accepts. */ +export const MOOD_DIMENSION_MAX = 10; + +const BY_KEY = new Map( + MOOD_DIMENSIONS.map((d) => [d.key, d]), +); + +/** The dimension carrying this wire key, or `undefined` for anything else. */ +export function getMoodDimension(key: string): MoodDimension | undefined { + return BY_KEY.get(key); +} + +/** + * Turn a stored value into one where "up" reads as "better". + * + * The only legal way to normalise an inverse dimension. Returns `null` for an + * absent value, because an unanswered dimension is not a neutral one — the + * whole point of leaving A2..A5 nullable is that absence stays readable as + * absence. + */ +export function orientForComparison( + dimension: MoodDimension, + value: number | null | undefined, +): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return dimension.inverse ? dimension.max - value : value; +} diff --git a/src/lib/validations/__tests__/mood-a1-map.test.ts b/src/lib/validations/__tests__/mood-a1-map.test.ts new file mode 100644 index 000000000..86aae60c3 --- /dev/null +++ b/src/lib/validations/__tests__/mood-a1-map.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + getA1ForMood, + getScoreForMood, + moodA1Map, +} from "@/lib/validations/mood"; + +/** + * The five-point label → A1 map is the single derivation point every writer + * shares, so every anchor is asserted by value rather than by shape. A test + * that only checked monotonicity would pass a map that had drifted a point. + */ +describe("getA1ForMood", () => { + it("maps every five-point label onto its level-A anchor", () => { + expect(getA1ForMood("LAUSIG")).toBe(1); + expect(getA1ForMood("SCHLECHT")).toBe(3); + expect(getA1ForMood("OKAY")).toBe(5); + expect(getA1ForMood("GUT")).toBe(7); + expect(getA1ForMood("SUPER_GUT")).toBe(9); + }); + + it("keeps the endpoints inside the scale rather than on it", () => { + // 0 and 10 stay reachable only by a value the user set by hand: a mapped + // historical entry must not read as more extreme than every future one. + expect(getA1ForMood("LAUSIG")).toBeGreaterThan(0); + expect(getA1ForMood("SUPER_GUT")).toBeLessThan(10); + }); + + it("answers the same neutral midpoint an unknown label gets from the score map", () => { + // getScoreForMood answers 3 on a 1..5 axis for an unknown label — the + // midpoint. A1's answer is the midpoint of its own axis, and the two are + // asserted against each other so a change to one is loud in the other. + expect(getScoreForMood("NOT_A_MOOD")).toBe(3); + expect(getA1ForMood("NOT_A_MOOD")).toBe(5); + expect(getA1ForMood("")).toBe(5); + }); + + it("is ordered and evenly spaced across the five labels", () => { + const ordered = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"].map( + getA1ForMood, + ); + expect(ordered).toEqual([1, 3, 5, 7, 9]); + for (let i = 1; i < ordered.length; i++) { + expect(ordered[i] - ordered[i - 1]).toBe(2); + } + }); + + it("exposes exactly the five labels and nothing else", () => { + expect(Object.keys(moodA1Map()).sort()).toEqual([ + "GUT", + "LAUSIG", + "OKAY", + "SCHLECHT", + "SUPER_GUT", + ]); + }); + + it("agrees with the score map on ordering", () => { + // Both axes must rank the five labels identically; a disagreement would + // mean a row whose score and A1 tell opposite stories. + const labels = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"]; + const byScore = [...labels].sort( + (a, b) => getScoreForMood(a) - getScoreForMood(b), + ); + const byA1 = [...labels].sort((a, b) => getA1ForMood(a) - getA1ForMood(b)); + expect(byA1).toEqual(byScore); + }); +}); diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index 117e98a17..c5d88c745 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -46,6 +46,43 @@ export function getScoreForMood(mood: string): number { return MOOD_SCORE_MAP[mood] ?? 3; } +/** + * The five-point mood label mapped onto the level-A pleasantness scale (A1). + * + * The quick check-in asks one question and offers five faces, and it stays a + * full-value capture path: its answer produces a real A1 rather than a + * degraded one. This is the single place those five numbers exist. A second + * copy anywhere in the tree — including inside a migration — is a drift + * waiting to happen, which is why the backfill's SQL is read back and compared + * against this map by a test rather than trusted to stay in step. + * + * The endpoints are 1 and 9, not 0 and 10. "LAUSIG" means as bad as this + * instrument can express, not as bad as a person can feel; pinning it to 0 + * would make every mapped historical entry look more extreme than every future + * hand-set one and would leave the true extremes unreachable. 5 is the + * concept's neutral midpoint and is where "OKAY" belongs. + */ +const MOOD_A1_MAP: Record = { + SUPER_GUT: 9, + GUT: 7, + OKAY: 5, + SCHLECHT: 3, + LAUSIG: 1, +}; + +/** + * A1 for a five-point mood label. An unrecognised label answers the neutral + * midpoint, matching what `getScoreForMood` answers for the same input. + */ +export function getA1ForMood(mood: string): number { + return MOOD_A1_MAP[mood] ?? 5; +} + +/** Read-only view of the A1 map, for the guards that pin the backfill to it. */ +export function moodA1Map(): Readonly> { + return MOOD_A1_MAP; +} + // v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`). // Additive alongside the flat free-text `tags`: an entry can carry both. // Bounded so a single create can't fan out an unbounded link set. From ad4f5c04f0e5c4b21f41c22902b900b96fe9768a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:37:41 +0200 Subject: [PATCH 02/24] feat(mood): add the five level-A columns to the mood entry - migration 0310 adds mood_a1, stress_a2, energy_a3, connection_a4 and stability_a5, all nullable, no table rewrite - the schema comment states the four things a later reader needs: score keeps its 1-5 axis, the columns are queryable plaintext on purpose, stress is stored literally, and a read delegate sees them by design - the backup guard is red until the carriers land alongside --- .../migration.sql | 21 +++++++++++ prisma/schema.prisma | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 prisma/migrations/0310_mood_level_a_dimensions/migration.sql diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql new file mode 100644 index 000000000..173bc03f2 --- /dev/null +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -0,0 +1,21 @@ +-- v1.37 — the five level-A self-state values on a mood entry. +-- +-- A mood entry has carried one 1-5 value since the first release. That value +-- keeps its scale and its meaning; these five ride beside it, each 0-10 with +-- its own anchors: pleasantness, stress, energy, connectedness and stability. +-- +-- All five are nullable, which is what makes this an additive change rather +-- than a table rewrite, and nullable is also the honest shape: only the first +-- one can be derived from what a row already carries, and the other four stay +-- empty until somebody answers them. A default would fill a hundred thousand +-- historical rows with a number nobody chose. +-- +-- No CHECK constraint on the range. Bounds are enforced server-side, the same +-- way `mood_entry_tag_links.rating` documents: a future change to the scale +-- must not strand rows written under the old one. +ALTER TABLE "mood_entries" + ADD COLUMN "mood_a1" INTEGER, + ADD COLUMN "stress_a2" INTEGER, + ADD COLUMN "energy_a3" INTEGER, + ADD COLUMN "connection_a4" INTEGER, + ADD COLUMN "stability_a5" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa1aeaa28..169a853d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4016,6 +4016,41 @@ model MoodEntry { date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 + /// v1.37 — the five level-A self-state values, each 0-10 with its own + /// anchors. `MOOD_DIMENSIONS` (`src/lib/mood/dimensions.ts`) is the one + /// table binding a column to its label, its anchors and its orientation. + /// + /// `score` above stays the 1-5 legacy axis and is not re-scaled: eleven + /// surfaces derive numbers from it on that assumption, three of them into + /// prose an assistant then states as fact. The two axes coexist by design. + /// `moodA1` is derived from the five-point label on every write, so a quick + /// check-in is a full entry rather than a degraded one; A2 to A5 are only + /// ever set by hand, and stay NULL when nobody answered them. Absence reads + /// as absence — a fabricated middle value would poison both the trend and + /// any later model trained on these columns. + /// + /// Queryable plaintext, deliberately — the same trade `CycleDayLog` + /// documents at its `basalBodyTempC`. Encrypting them would remove the + /// trend read, the factor crosstab and every aggregate at once, and the + /// values are self-ratings on a fixed scale rather than prose. The free-text + /// note beside them stays encrypted, because that is where the prose is. + /// + /// `stressA2` is stored literally, exactly as the user set it against the + /// anchors they read, so a higher value means more stress and a worse day. + /// A surface that needs "up is better" flips the sign through the `inverse` + /// flag on the dimension table, never inline. The same rule already governs + /// `MoodTag.inverse`, and it exists because a stored value that disagrees + /// with the label the user saw cannot be recovered afterwards. + /// + /// A delegate holding a READ grant sees these columns, because a grant is + /// whole-record by design and carries no per-domain scope. That is + /// deliberate, not an oversight: withholding part of a record from a + /// delegate would be a new capability, not a field select. + moodA1 Int? @map("mood_a1") // 0-10, pleasantness + stressA2 Int? @map("stress_a2") // 0-10, inverse-oriented + energyA3 Int? @map("energy_a3") // 0-10 + connectionA4 Int? @map("connection_a4") // 0-10 + stabilityA5 Int? @map("stability_a5") // 0-10 tags String? // JSON array of tag keys /// v1.4.30 — first-class free-text note. Replaces the /// `tags: ["note:"]` workaround the iOS app shipped through From a1c78eceef0afb9aeefb764b3aa20c20811f6cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 00:49:15 +0200 Subject: [PATCH 03/24] feat(mood): derive pleasantness on every write path - one helper resolves the level-A values for a write; the five writers call it instead of carrying their own copy of the derivation - the create schema takes a1 to a5, and a value the user set wins over the derived one; the batch request shape is untouched - both arms of both upserts restate the values, so a re-post cannot leave a stale reading beside a mood that has since changed - the JSON import derives from the row's own label rather than the file's score, which is the one place the two can disagree - correcting an entry's label re-derives pleasantness the way it re-derives the score; an explicit null takes an answer back - a round trip through all five writers against real Postgres asserts the rows agree and that nothing invents the other four values --- src/app/api/import/route.ts | 11 + src/app/api/mood-entries/[id]/route.ts | 22 +- src/app/api/mood-entries/bulk/route.ts | 8 + src/app/api/mood-entries/route.ts | 37 ++ src/lib/mcp/writes.ts | 5 + src/lib/mood/create-from-telegram.ts | 5 + src/lib/mood/level-a.ts | 100 ++++++ src/lib/validations/mood.ts | 25 ++ tests/integration/mood-writers-agree.test.ts | 343 +++++++++++++++++++ 9 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 src/lib/mood/level-a.ts create mode 100644 tests/integration/mood-writers-agree.test.ts diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index f1c32149f..e81f51a54 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { isP2002, isP2025 } from "@/lib/prisma-errors"; import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; // Derived from canonical enum so round-trip export → import covers every @@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + // v1.37 — pleasantness derived from the row's own label, not + // from the file's `score`. This is the one writer whose score + // comes out of the file rather than out of the label, so the + // two can disagree; the column that records a self-assessment + // has to agree with the label the user actually picked. + moodA1: deriveA1(e.mood), tags: e.tags || null, moodLoggedAt: loggedAt, }, @@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + moodA1: deriveA1(e.mood), tags: e.tags || null, source: "IMPORT", externalId: e.externalId, @@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => { date: e.date, mood: e.mood, score: e.score, + // See the upsert arm above: derived from the label, because + // an imported file's `score` is its own field. + moodA1: deriveA1(e.mood), tags: e.tags || null, source: "IMPORT", moodLoggedAt: loggedAt, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index b6f1c4412..f6b01ba4d 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1, levelAForWire } from "@/lib/mood/level-a"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -57,7 +58,12 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) }); + return apiSuccess({ + ...shapeMoodNote(entry), + // v1.37 — level-A values under the keys the write path takes. + ...levelAForWire(entry), + tags: parseTags(entry.tags), + }); }, ); @@ -120,7 +126,19 @@ export const PUT = apiHandler( if (data.mood !== undefined) { updateData.mood = data.mood; updateData.score = getScoreForMood(data.mood); + // v1.37 — pleasantness follows the label the same way `score` does, so + // an entry corrected from "okay" to "bad" cannot keep yesterday's A1. + // An explicit `a1` in the same request overrides it below. + updateData.moodA1 = deriveA1(data.mood); } + // v1.37 — level-A values are per-field on this path: omitted keeps the + // stored value, an explicit number replaces it, an explicit null clears + // it. Built one at a time from the parsed body, never spread. + if (data.a1 !== undefined) updateData.moodA1 = data.a1; + if (data.a2 !== undefined) updateData.stressA2 = data.a2; + if (data.a3 !== undefined) updateData.energyA3 = data.a3; + if (data.a4 !== undefined) updateData.connectionA4 = data.a4; + if (data.a5 !== undefined) updateData.stabilityA5 = data.a5; if (data.moodLoggedAt !== undefined) { // v1.4.25 W7b — re-anchor the row's `date` to the user's current // displayTimezone. Also refresh the `tz` column so the row's @@ -291,6 +309,8 @@ export const PUT = apiHandler( return apiSuccess({ ...entry, + // v1.37 — level-A values under the keys the write path takes. + ...levelAForWire(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the update response renders the tag set without a diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index a8f3bd3d2..938be2a60 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -51,6 +51,7 @@ import { type UnstableExternalIdShape, } from "@/lib/validations/external-id"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; @@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise { const date = moodDateKey(entry.moodLoggedAt, tz); const score = getScoreForMood(entry.mood); + // v1.37 — pleasantness derived from the same label the score comes from. + // The batch request shape is unchanged: this path carries no level-A + // input, so A2 to A5 are left alone rather than written as nulls, and a + // row that already carries hand-set values keeps them across a re-import. + const moodA1 = deriveA1(entry.mood); try { // v1.12.1 — when the entry carries a source-stable `externalId`, @@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise { tz, mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), @@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise { // the same row. mood: entry.mood, score, + moodA1, tags: entry.tags ? JSON.stringify(entry.tags) : null, note: null, noteEncrypted: encryptNote(entry.note ?? null), diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e92baa7d1..b03b3bb04 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { levelAForWire, levelAForWrite } from "@/lib/mood/level-a"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -126,6 +127,10 @@ export const GET = apiHandler(async (request: NextRequest) => { const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({ // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. ...shapeMoodNote(e), + // v1.37 — the five level-A values under the keys the create path takes, + // so the edit form reads back what it wrote instead of mapping column + // names to wire names by hand. + ...levelAForWire(e), tags: parseTags(e.tags), // v1.8.5 — flat list of binary structured-tag keys attached to the // entry (rated factors are surfaced separately below). @@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) { source, externalId, } = parsed.data; + // v1.37 — the five level-A values, built field by field from the parsed + // body (never spread) and resolved once for every branch below. A1 falls + // back to the derivation from `mood`; the other four are whatever the user + // set, or nothing. + const levelA = levelAForWrite(mood, { + a1: parsed.data.a1, + a2: parsed.data.a2, + a3: parsed.data.a3, + a4: parsed.data.a4, + a5: parsed.data.a5, + }); // v1.4.25 W7b (Decision A) — anchor the `date` string to the user's // current displayTimezone and store the resolved zone on the row. // Legacy rows with `tz IS NULL` continue to read as Europe/Berlin @@ -265,6 +281,11 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -272,11 +293,20 @@ async function postMoodEntry(request: NextRequest) { externalId, moodLoggedAt, }, + // A re-post replaces the row, so the level-A values are restated + // here too. Omitting them would leave a stress reading from an + // earlier post sitting beside a mood the user has since changed + // — the same staleness the `score` restatement already avoids. update: { date, tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -290,6 +320,11 @@ async function postMoodEntry(request: NextRequest) { tz, mood, score, + moodA1: levelA.moodA1, + stressA2: levelA.stressA2, + energyA3: levelA.energyA3, + connectionA4: levelA.connectionA4, + stabilityA5: levelA.stabilityA5, tags: tags ? JSON.stringify(tags) : null, note: null, noteEncrypted: encryptNote(note ?? null), @@ -375,6 +410,8 @@ async function postMoodEntry(request: NextRequest) { { // v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext. ...shapeMoodNote(entry), + // v1.37 — level-A values under their wire keys (see the list read). + ...levelAForWire(entry), tags: parseTags(entry.tags), // v1.8.5 — surface the persisted structured-tag keys so a client // hydrating from the create response renders the tag set without a diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index 83183d2db..a25903f19 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/mood"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { deriveA1 } from "@/lib/mood/level-a"; import { classifyExternalId, unstableExternalIdMessage, @@ -648,6 +649,10 @@ export async function logMcpMood(input: { tz, mood, score, + // v1.37 — pleasantness derived from the same label the score comes + // from. The tool still takes a 1-5 score; that is its input and it is + // unchanged. + moodA1: deriveA1(mood), note: null, noteEncrypted: encryptNote(note), source: MCP_SOURCE, diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index 1e1c9371d..3b5c4d3eb 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/mood"; +import { deriveA1 } from "@/lib/mood/level-a"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; @@ -79,6 +80,10 @@ export async function logTelegramMood(input: { tz, mood, score, + // v1.37 — pleasantness derived from the same label the score comes + // from, so a mood tapped in the chat carries the value a mood typed + // into the web form would. Nothing here can answer the other four. + moodA1: deriveA1(mood), // v1.23 — encrypt the note at rest; legacy plaintext column nulled. note: null, noteEncrypted: encryptNote(input.note ?? null), diff --git a/src/lib/mood/level-a.ts b/src/lib/mood/level-a.ts new file mode 100644 index 000000000..15505eb75 --- /dev/null +++ b/src/lib/mood/level-a.ts @@ -0,0 +1,100 @@ +/** + * The one place a mood write resolves its five level-A values. + * + * Five writers reach `MoodEntry`, and every one of them sends exactly what it + * sent before this existed: a five-point label and nothing else. The server + * derives pleasantness (A1) from that label so a quick check-in is a full + * entry rather than a degraded one, and so a Telegram row, an imported row and + * a row typed into the web form all carry the same value for the same day. + * + * The other four are never derived. There is nothing in a five-point label to + * derive stress, energy, connectedness or stability from, and a fabricated + * value would read as an answer nobody gave — it would poison the trend and + * any later model trained on these columns. They stay NULL until somebody + * moves the slider. + */ +import { getA1ForMood } from "@/lib/validations/mood"; + +/** Level-A values as a client may send them, all optional. */ +export interface LevelAInput { + a1?: number | null; + a2?: number | null; + a3?: number | null; + a4?: number | null; + a5?: number | null; +} + +/** Level-A values as they are written to `MoodEntry`. */ +export interface LevelAColumns { + moodA1: number | null; + stressA2: number | null; + energyA3: number | null; + connectionA4: number | null; + stabilityA5: number | null; +} + +/** + * The five columns for a write of `mood`, with any explicit client value + * winning over the derivation. + * + * A client that sends only a label gets the derived A1 and four NULLs. A + * client that sends `a1` gets its own number — that is what makes the detail + * sliders a real capture surface rather than a suggestion the server overrides + * on the way to the database. + * + * Every field is stated explicitly, including the NULLs, because the callers + * are upserts: on the `update:` arm an omitted field keeps a value from an + * earlier post, and a stale stress reading sitting beside a mood the user just + * changed is exactly the mismatch these columns exist to avoid. Callers whose + * write semantics are per-field rather than whole-row (the PATCH route) build + * their own object and use `deriveA1` directly. + */ +export function levelAForWrite( + mood: string, + explicit?: LevelAInput, +): LevelAColumns { + return { + moodA1: explicit?.a1 ?? getA1ForMood(mood), + stressA2: explicit?.a2 ?? null, + energyA3: explicit?.a3 ?? null, + connectionA4: explicit?.a4 ?? null, + stabilityA5: explicit?.a5 ?? null, + }; +} + +/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */ +export function deriveA1(mood: string): number { + return getA1ForMood(mood); +} + +/** The five columns as a client reads them back. */ +export interface LevelAWire { + a1: number | null; + a2: number | null; + a3: number | null; + a4: number | null; + a5: number | null; +} + +/** + * Turn a stored row's five columns into the wire keys the client sends. + * + * A read that answered `moodA1` while the write took `a1` would leave every + * form mapping the pair by hand, and one of them would eventually map it + * wrong. The names match in both directions. + */ +export function levelAForWire(row: { + moodA1: number | null; + stressA2: number | null; + energyA3: number | null; + connectionA4: number | null; + stabilityA5: number | null; +}): LevelAWire { + return { + a1: row.moodA1, + a2: row.stressA2, + a3: row.energyA3, + a4: row.connectionA4, + a5: row.stabilityA5, + }; +} diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index c5d88c745..6c58ac819 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -106,6 +106,14 @@ const ratedFactor = z.object({ }); const ratedFactors = z.array(ratedFactor).max(30); +// v1.37 — the five level-A self-state values, each 0-10. Optional on every +// path: a client that sends only a five-point label still writes a complete +// entry, because the server derives A1 from the label. A2 to A5 are only ever +// what the user set, so an omitted one stays absent rather than defaulting to +// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS` +// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean. +const levelADimension = z.number().int().min(0).max(10); + export const createMoodEntrySchema = z .object({ mood: moodLevelEnum, @@ -120,6 +128,14 @@ export const createMoodEntrySchema = z // `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are // rejected (422) / dropped server-side per the catalog. ratedFactors: ratedFactors.optional(), + // v1.37 — the five level-A values the detail sliders capture. Absent + // means the user did not answer; `a1` absent means the server derives it + // from `mood`, which keeps the one-tap check-in a full entry. + a1: levelADimension.optional(), + a2: levelADimension.optional(), + a3: levelADimension.optional(), + a4: levelADimension.optional(), + a5: levelADimension.optional(), // v1.4.30 H-5 — first-class free-text note. Replaces the // `tags: ["note:"]` workaround. Capped at 500 chars so the // Coach evidence shelf renders cleanly without truncating chips. @@ -155,6 +171,15 @@ export const updateMoodEntrySchema = z.object({ // v1.12.0 — full replacement of the rated-factor set when present. // `null` clears every rated link; omit to leave them untouched. ratedFactors: ratedFactors.nullable().optional(), + // v1.37 — level-A values on the edit path. Omitted leaves the stored value + // alone; an explicit `null` clears it, which is how a user takes back an + // answer they did not mean to give. Editing the five-point label without + // sending `a1` re-derives A1, the same way it re-derives `score`. + a1: levelADimension.nullable().optional(), + a2: levelADimension.nullable().optional(), + a3: levelADimension.nullable().optional(), + a4: levelADimension.nullable().optional(), + a5: levelADimension.nullable().optional(), note: z.string().max(500).nullable().optional(), // v1.17 W1b — same plausibility bound on the edit path. moodLoggedAt: validateEntryInstant( diff --git a/tests/integration/mood-writers-agree.test.ts b/tests/integration/mood-writers-agree.test.ts new file mode 100644 index 000000000..feb797bd3 --- /dev/null +++ b/tests/integration/mood-writers-agree.test.ts @@ -0,0 +1,343 @@ +/** + * v1.37 — every mood writer resolves the same pleasantness value. + * + * Five paths write a `MoodEntry`, and only one of them is the web form. If + * the derivation lived at the call site, a mood tapped in a chat and the same + * mood typed into the form would drift apart the first time one of the five + * was edited without the others. This test posts the identical mood through + * all five and asserts the rows agree, against real Postgres and the real + * handlers: + * + * 1. POST /api/mood-entries (web + iOS single) + * 2. POST /api/mood-entries/bulk (iOS backfill) + * 3. logTelegramMood (the bot) + * 4. POST /api/import (JSON restore) + * 5. logMcpMood (the assistant tool) + * + * It also pins the three rules the columns exist for: a re-post refreshes the + * value rather than leaving a stale one beside a fresh mood, an explicit + * client value beats the derivation, and nothing ever invents A2 to A5. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-mood-writers-agree"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function createUserSession() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "mood-writers-agree", + email: "mood-writers-agree@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: USER, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function jsonRequest(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("mood writers agree on the derived level-A value", () => { + it("writes the same moodA1 through all five writers for the same mood", async () => { + await createUserSession(); + + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + const { POST: postImport } = await import("@/app/api/import/route"); + const { logTelegramMood } = await import("@/lib/mood/create-from-telegram"); + const { logMcpMood } = await import("@/lib/mcp/writes"); + + // 1 — single create. + const single = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "writer-single", + }), + ); + expect(single.status).toBe(201); + + // 2 — bulk backfill. The request shape carries no level-A field; the + // server derives it, which is the whole point of this phase being + // invisible on the wire. + const bulk = await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "GUT", + moodLoggedAt: "2026-05-16T09:00:00.000Z", + externalId: "writer-bulk", + }, + ], + }), + ); + expect(bulk.status).toBe(200); + + // 3 — the bot. Its input is the 1-5 score; it resolves the label itself. + const telegram = await logTelegramMood({ + userId: USER, + score: 4, + tz: "Europe/Berlin", + externalId: "telegram:mood:1:2:4", + }); + expect(telegram.created).toBe(true); + + // 4 — the JSON import. Deliberately carries a `score` that disagrees with + // its own label: this is the one writer whose score comes out of the file + // rather than out of the label, and the self-assessment column has to + // follow the label the user picked, not the number the file asserts. + const imported = await postImport( + jsonRequest("/api/import", { + moodEntries: [ + { + date: "2026-05-16", + mood: "GUT", + score: 2, + loggedAt: "2026-05-16T10:00:00.000Z", + externalId: "writer-import", + }, + ], + }), + ); + expect(imported.status).toBe(200); + + // 5 — the assistant tool. + const mcp = await logMcpMood({ + userId: USER, + score: 4, + tz: "Europe/Berlin", + idempotencyKey: "writer-mcp-1", + }); + expect(mcp.status).toBe("written"); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + orderBy: { source: "asc" }, + select: { + source: true, + mood: true, + score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + }, + }); + + // Five writers, five rows — and one of them is the bulk path, which must + // not have been folded into the single path's row. + expect(rows).toHaveLength(5); + expect(rows.map((r) => r.source).sort()).toEqual([ + "IMPORT", + "MANUAL", + "MANUAL", + "MCP", + "TELEGRAM", + ]); + + for (const row of rows) { + expect(row.mood).toBe("GUT"); + // GUT sits at 7 on the level-A scale. Asserted by value on every row, + // not by comparing the rows to each other: five rows agreeing on a + // wrong number would pass a same-value assertion. + expect(row.moodA1).toBe(7); + // Nothing derives the other four, on any path. + expect(row.stressA2).toBeNull(); + expect(row.energyA3).toBeNull(); + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + } + + // The imported row keeps the file's own score while its level-A value + // follows the label — the disagreement the file carried is not copied + // into the new column. + const importedRow = rows.find((r) => r.source === "IMPORT"); + expect(importedRow?.score).toBe(2); + expect(importedRow?.moodA1).toBe(7); + }); + + it("refreshes moodA1 when an externalId is re-posted with a different mood", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { POST: postBulk } = + await import("@/app/api/mood-entries/bulk/route"); + + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T08:00:00.000Z", + externalId: "repost-single", + }), + ); + await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T08:10:00.000Z", + externalId: "repost-single", + }), + ); + + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "LAUSIG", + moodLoggedAt: "2026-05-16T11:00:00.000Z", + externalId: "repost-bulk", + }, + ], + }), + ); + await postBulk( + jsonRequest("/api/mood-entries/bulk", { + entries: [ + { + mood: "SUPER_GUT", + moodLoggedAt: "2026-05-16T11:10:00.000Z", + externalId: "repost-bulk", + }, + ], + }), + ); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + orderBy: { externalId: "asc" }, + select: { externalId: true, mood: true, score: true, moodA1: true }, + }); + + // One row per externalId — the re-post updated in place, as before. + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.mood).toBe("SUPER_GUT"); + expect(row.score).toBe(5); + // The update arm of both upserts restates the value. Without it the row + // would carry LAUSIG's 1 beside SUPER_GUT's 5. + expect(row.moodA1).toBe(9); + } + }); + + it("lets an explicit client value win over the derivation, and stores the other four literally", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + + const res = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "OKAY", + moodLoggedAt: "2026-05-16T12:00:00.000Z", + // A day that felt middling overall but was genuinely stressful: the + // whole reason five values beat one. + a1: 4, + a2: 9, + a3: 2, + }), + ); + expect(res.status).toBe(201); + const json = (await res.json()) as { + data: { a1: number; a2: number; a3: number; a4: null; a5: null }; + }; + // The response echoes the values under the keys the request used. + expect(json.data.a1).toBe(4); + expect(json.data.a2).toBe(9); + expect(json.data.a5).toBeNull(); + + const row = await getPrismaClient().moodEntry.findFirstOrThrow({ + where: { userId: USER }, + }); + // Not 5 — OKAY's derived value lost to the number the user set. + expect(row.moodA1).toBe(4); + // Stored literally, higher meaning more stress. A value flipped on the + // way in could never be recovered. + expect(row.stressA2).toBe(9); + expect(row.energyA3).toBe(2); + // Untouched sliders write nothing at all. + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + // The five-point axis is untouched by any of this. + expect(row.score).toBe(3); + }); + + it("re-derives A1 when an entry's mood is corrected, and clears a value on an explicit null", async () => { + await createUserSession(); + const { POST: postSingle } = await import("@/app/api/mood-entries/route"); + const { PUT } = await import("@/app/api/mood-entries/[id]/route"); + + const created = await postSingle( + jsonRequest("/api/mood-entries", { + mood: "SCHLECHT", + moodLoggedAt: "2026-05-16T13:00:00.000Z", + a2: 8, + }), + ); + const createdJson = (await created.json()) as { data: { id: string } }; + const id = createdJson.data.id; + + const corrected = await PUT( + new NextRequest(`http://localhost/api/mood-entries/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mood: "GUT", a2: null }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(corrected.status).toBe(200); + + const row = await getPrismaClient().moodEntry.findUniqueOrThrow({ + where: { id }, + }); + expect(row.mood).toBe("GUT"); + expect(row.score).toBe(4); + // The label moved, so pleasantness moved with it — the same way `score` + // does. A stale 3 here would contradict the entry's own label. + expect(row.moodA1).toBe(7); + // An explicit null takes back an answer. + expect(row.stressA2).toBeNull(); + }); +}); From 643d8a5a00b5f407db246093b63918ad1ea911f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:03:05 +0200 Subject: [PATCH 04/24] feat(mood): map existing entries onto the pleasantness scale - the migration fills mood_a1 from the five-point label for every row that has none, and leaves the other four columns empty because there is nothing in a label to derive them from - guarded on the column being empty, so re-applying the migration is a no-op rather than an overwrite of an answer somebody gave since - the test reads the CASE arms back out of the .sql and compares them to the runtime map in both directions, so neither can drift alone - rows are asserted by value after the statement runs, and again after a second run, including one hand-set value that must survive it --- .../migration.sql | 28 +++ tests/integration/mood-a1-backfill.test.ts | 229 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/integration/mood-a1-backfill.test.ts diff --git a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql index 173bc03f2..e07fec8ee 100644 --- a/prisma/migrations/0310_mood_level_a_dimensions/migration.sql +++ b/prisma/migrations/0310_mood_level_a_dimensions/migration.sql @@ -19,3 +19,31 @@ ALTER TABLE "mood_entries" ADD COLUMN "energy_a3" INTEGER, ADD COLUMN "connection_a4" INTEGER, ADD COLUMN "stability_a5" INTEGER; + +-- Map every entry written before these columns existed onto the pleasantness +-- scale. The five-point label is the only thing a historical row can answer +-- from, so it is the only column filled: stress, energy, connectedness and +-- stability stay NULL, because there is nothing to derive them from and an +-- invented number would be indistinguishable from one somebody gave. +-- +-- The anchors are 1/3/5/7/9, matching `MOOD_A1_MAP` in +-- `src/lib/validations/mood.ts` — the runtime map and these arms are compared +-- to each other by a test, so the two cannot drift apart. Not 0 and 10 at the +-- ends: the five-point endpoints mean as bad or as good as that instrument +-- can express, not as bad or as good as a person can feel, and pinning them +-- to the extremes would make every mapped row read as more extreme than every +-- hand-set one. +-- +-- `WHERE mood_a1 IS NULL` makes a re-run a no-op instead of an overwrite. A +-- migration re-applied against a database that has since taken real answers +-- must not replace them with the mapped value. +UPDATE mood_entries + SET mood_a1 = CASE mood + WHEN 'LAUSIG' THEN 1 + WHEN 'SCHLECHT' THEN 3 + WHEN 'OKAY' THEN 5 + WHEN 'GUT' THEN 7 + WHEN 'SUPER_GUT' THEN 9 + END + WHERE mood_a1 IS NULL + AND mood IN ('LAUSIG', 'SCHLECHT', 'OKAY', 'GUT', 'SUPER_GUT'); diff --git a/tests/integration/mood-a1-backfill.test.ts b/tests/integration/mood-a1-backfill.test.ts new file mode 100644 index 000000000..a98efdac3 --- /dev/null +++ b/tests/integration/mood-a1-backfill.test.ts @@ -0,0 +1,229 @@ +/** + * v1.37 — the level-A backfill, read out of the migration and executed. + * + * The statement that maps a hundred thousand historical rows onto the + * pleasantness scale carries its own copy of five numbers, and the runtime map + * carries the other. Two copies of the same five numbers is a drift waiting to + * happen, so this test reads the arms back out of the `.sql` file and compares + * them to `MOOD_A1_MAP` arm by arm — a migration edited without the map, or a + * map edited without the migration, fails here. + * + * The rest of it executes the statement against real Postgres on rows inserted + * the way legacy rows actually look (`mood_a1 IS NULL`), and asserts the rows, + * not the fact that the statement ran. It runs a second time and asserts + * nothing moved: a migration re-applied against a database that has since + * taken real answers must leave them alone. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { moodA1Map } from "@/lib/validations/mood"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION_PATH = path.join( + process.cwd(), + "prisma", + "migrations", + "0310_mood_level_a_dimensions", + "migration.sql", +); + +const USER = "user-mood-a1-backfill"; + +function migrationSql(): string { + return readFileSync(MIGRATION_PATH, "utf8"); +} + +/** The `UPDATE mood_entries … ;` statement, comments stripped. */ +function backfillStatement(sql: string): string { + const withoutComments = sql + .split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n"); + const match = withoutComments.match(/UPDATE\s+mood_entries[\s\S]*?;/i); + if (!match) { + throw new Error( + `No UPDATE statement found in ${MIGRATION_PATH}. The backfill is the point of this test; an absent statement is a failure, not a skip.`, + ); + } + return match[0]; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("mood a1 backfill — the statement agrees with the runtime map", () => { + it("carries one CASE arm per label, with the value the map answers", () => { + const statement = backfillStatement(migrationSql()); + const arms = [ + ...statement.matchAll(/WHEN\s+'([A-Z_]+)'\s+THEN\s+(\d+)/gi), + ].map((m) => [m[1], Number(m[2])] as const); + + // An empty match set means the matcher stopped matching, not that the + // migration is correct. A guard that cannot fail is worse than none. + expect(arms.length).toBeGreaterThan(0); + + const map = moodA1Map(); + expect(arms.length).toBe(Object.keys(map).length); + for (const [label, value] of arms) { + expect( + map[label], + `CASE arm '${label}' has no entry in MOOD_A1_MAP`, + ).toBe(value); + } + // And the other direction: a label in the map with no arm would leave + // those rows NULL after the backfill claimed to have covered them. + for (const label of Object.keys(map)) { + expect( + arms.map(([l]) => l), + `MOOD_A1_MAP entry '${label}' has no CASE arm in the migration`, + ).toContain(label); + } + }); + + it("is guarded on the column being empty, so a re-run cannot overwrite an answer", () => { + const statement = backfillStatement(migrationSql()); + expect(statement.replace(/\s+/g, " ")).toMatch( + /WHERE\s+mood_a1\s+IS\s+NULL/i, + ); + }); +}); + +describe("mood a1 backfill — executed against real rows", () => { + async function seedLegacyRows() { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: USER, + username: "mood-a1-backfill", + email: "mood-a1-backfill@example.test", + timezone: "Europe/Berlin", + }, + }); + + // Inserted through raw SQL so the columns look exactly like a row written + // before they existed: every level-A column NULL. Going through Prisma + // would let a default or a hook fill one in and the test would be + // proving the seed rather than the migration. + const rows: Array<[string, string, number, string | null]> = [ + ["legacy-lausig", "LAUSIG", 1, null], + ["legacy-schlecht", "SCHLECHT", 2, null], + ["legacy-okay", "OKAY", 3, null], + ["legacy-gut", "GUT", 4, null], + ["legacy-super", "SUPER_GUT", 5, null], + // A tombstoned row is still the user's history and still restorable. + ["legacy-deleted", "GUT", 4, "2026-05-01T00:00:00.000Z"], + // A label nothing recognises: no arm matches, so it stays NULL rather + // than being handed the midpoint. + ["legacy-unknown", "NOT_A_MOOD", 3, null], + ]; + + for (const [index, [id, mood, score, deletedAt]] of rows.entries()) { + // An explicit instant per row, one hour apart. `NOW()` would be the + // transaction's own clock, and two inserts landing on the same tick + // collide on the `(user_id, date, mood_logged_at)` unique — a seed that + // fails on a fast machine and passes on a slow one. + const loggedAt = `2026-05-16T0${index}:00:00.000Z`; + await prisma.$executeRawUnsafe( + `INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, created_at, updated_at, sync_version, deleted_at) + VALUES ($1, $2, '2026-05-16', $3, $4, 'MANUAL', $5::timestamptz, NOW(), NOW(), NOW(), 0, $6::timestamptz)`, + id, + USER, + mood, + score, + loggedAt, + deletedAt, + ); + } + + const before = await prisma.$queryRawUnsafe>( + `SELECT COUNT(*)::bigint AS count FROM mood_entries WHERE mood_a1 IS NULL`, + ); + expect(Number(before[0].count)).toBe(rows.length); + return rows.length; + } + + it("maps every legacy row onto its anchor and leaves the other four empty", async () => { + const prisma = getPrismaClient(); + const seeded = await seedLegacyRows(); + + const changed = await prisma.$executeRawUnsafe( + backfillStatement(migrationSql()), + ); + // Every row but the unknown label — the statement's IN list excludes it. + expect(changed).toBe(seeded - 1); + + const after = await prisma.moodEntry.findMany({ + where: { userId: USER }, + orderBy: { id: "asc" }, + select: { + id: true, + mood: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, + }, + }); + expect(after).toHaveLength(seeded); + + const byId = new Map(after.map((r) => [r.id, r])); + expect(byId.get("legacy-lausig")?.moodA1).toBe(1); + expect(byId.get("legacy-schlecht")?.moodA1).toBe(3); + expect(byId.get("legacy-okay")?.moodA1).toBe(5); + expect(byId.get("legacy-gut")?.moodA1).toBe(7); + expect(byId.get("legacy-super")?.moodA1).toBe(9); + // A soft-deleted row is backfilled too: it is still in the backup and + // still comes back from a restore. + expect(byId.get("legacy-deleted")?.moodA1).toBe(7); + // An unrecognised label gets nothing rather than a fabricated midpoint. + expect(byId.get("legacy-unknown")?.moodA1).toBeNull(); + + // Nothing derived the other four, on any row. + for (const row of after) { + expect(row.stressA2).toBeNull(); + expect(row.energyA3).toBeNull(); + expect(row.connectionA4).toBeNull(); + expect(row.stabilityA5).toBeNull(); + } + }); + + it("changes nothing on a second run, including a value set since the first", async () => { + const prisma = getPrismaClient(); + await seedLegacyRows(); + const statement = backfillStatement(migrationSql()); + + await prisma.$executeRawUnsafe(statement); + + // Somebody answers the slider on one of the backfilled rows, contradicting + // the mapped value. A re-run must not take that answer away. + await prisma.moodEntry.update({ + where: { id: "legacy-okay" }, + data: { moodA1: 8 }, + }); + + const beforeSecond = await prisma.moodEntry.findMany({ + where: { userId: USER }, + orderBy: { id: "asc" }, + select: { id: true, moodA1: true, updatedAt: true }, + }); + + const changedOnSecondRun = await prisma.$executeRawUnsafe(statement); + expect(changedOnSecondRun).toBe(0); + + const afterSecond = await prisma.moodEntry.findMany({ + where: { userId: USER }, + orderBy: { id: "asc" }, + select: { id: true, moodA1: true, updatedAt: true }, + }); + expect(afterSecond).toEqual(beforeSecond); + expect( + afterSecond.find((r) => r.id === "legacy-okay")?.moodA1, + "the hand-set answer survived the re-run", + ).toBe(8); + }); +}); From ef35fe681df111205eba987b77fe4fb64cc2815c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:05:31 +0200 Subject: [PATCH 05/24] fix(admin): wipe every account's data, not nine tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin "Delete all data" action carried its delete list inline. That list named nine tables, which was most of the schema on the day it was written, and it stayed at nine while the schema grew to a hundred and twenty-two models. Laboratory results, documents, cycle logs, journals, mood, workouts, sleep and Coach conversations all survived the wipe an operator runs before handing a box on, and the toast said it was done. The per-account wipe had the same defect and grew a derived plan for it. The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS minus a single documented subtraction, so a model added to the schema lands in both wipes at once. - AccountGrant is the one difference, with its reason in the plan: it is the only access path to a managed profile, and this action preserves accounts by definition. Deleting every grant would leave records nobody can open, administer or delete. - The audit history now goes with everything else. It is a record of who did what from where, which is exactly what clearing a box is for; the receipt written after the transaction commits replaces it. - One transaction with the same budget as the account wipe. A half-applied wipe is worse than none, because the person is told it succeeded either way. - The result line reports rows and tables instead of nine fixed names, which is how nobody noticed: it read the same whether the wipe had cleared nine tables or ninety. Guards, both watched red before landing: admin-data-wipe-completeness.test.ts fails when a user-scoped model reaches neither the wipe nor a written exemption, and when the route names a table inline again. The integration guard seeds every table in the schema for two accounts, runs the route, and asks the database what is left — drop one model from the plan and it names the surviving table. --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- .../admin-data-wipe-completeness.test.ts | 170 ++++++++++ src/__tests__/data-wipe-completeness.test.ts | 79 +---- src/__tests__/helpers/wipe-schema-shape.ts | 102 ++++++ src/app/api/admin/data/route.ts | 113 ++++--- src/components/admin/danger-zone-section.tsx | 27 +- src/lib/data-wipe/wipe-plan.ts | 100 +++++- tests/integration/admin-data-wipe.test.ts | 308 ++++++++++++++---- tests/integration/settings-data-wipe.test.ts | 250 +------------- tests/integration/wipe-seeder.ts | 265 +++++++++++++++ 15 files changed, 966 insertions(+), 484 deletions(-) create mode 100644 src/__tests__/admin-data-wipe-completeness.test.ts create mode 100644 src/__tests__/helpers/wipe-schema-shape.ts create mode 100644 tests/integration/wipe-seeder.ts diff --git a/messages/de.json b/messages/de.json index 7468d4f41..77f3d40e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6483,13 +6483,13 @@ "outcomeFailed": "Fehlgeschlagen", "auditDetails": "Details", "deleteAllData": "Alle Daten global löschen", - "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Nutzer. Nutzerkonten bleiben bestehen.", + "deleteAllDescription": "Löscht alle Datensätze aller Konten: Messwerte, Medikamente, Laborwerte, Dokumente, Zyklus-Einträge, Stimmung, Workouts, Schlaf, Coach-Unterhaltungen, Integrationen und die Audit-Historie. Die Konten selbst und die Zugangsdaten, mit denen man sich anmeldet, bleiben erhalten.", "deleteAllConfirm": "Alle Daten wirklich löschen?", - "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Nutzer unwiderruflich. Nutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", + "deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.", "deleteButton": "Alle Daten löschen", "deleteAllPromptLabel": "Tippe {token} zum Fortfahren", "finalDelete": "Endgültig löschen", - "deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.", + "deletedResult": "Gelöscht: {rows} Zeilen in {tables} Tabellen. Nutzerkonten und Anmelde-Zugangsdaten bleiben erhalten.", "authRegister": "Registrierung", "authLogin": "Anmeldung", "authLoginPasskey": "Passkey-Anmeldung", diff --git a/messages/en.json b/messages/en.json index 1b9ce1268..144329328 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6483,13 +6483,13 @@ "outcomeFailed": "Failed", "auditDetails": "Details", "deleteAllData": "Delete all data globally", - "deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.", + "deleteAllDescription": "Deletes every record every account holds: measurements, medications, laboratory results, documents, cycle logs, mood, workouts, sleep, Coach conversations, integrations and the audit history. The accounts themselves and the credentials that sign into them are kept.", "deleteAllConfirm": "Really delete all data?", - "deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.", + "deleteAllConfirmDescription": "This permanently deletes every account's records across the whole database, not a selection of tables. Kept: the user accounts and the credentials that sign into them, the instance configuration, and the delegation grants that keep managed profiles reachable. A receipt for this wipe is written to the audit log afterwards; the audit history before it goes with everything else.", "deleteButton": "Delete all data", "deleteAllPromptLabel": "Type {token} to continue", "finalDelete": "Delete permanently", - "deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Deleted {rows} rows across {tables} tables. User accounts and sign-in credentials were kept.", "authRegister": "Registration", "authLogin": "Login", "authLoginPasskey": "Passkey login", diff --git a/messages/es.json b/messages/es.json index 35193f614..d88b7b800 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6483,13 +6483,13 @@ "outcomeFailed": "Fallido", "auditDetails": "Detalles", "deleteAllData": "Eliminar todos los datos globalmente", - "deleteAllDescription": "Elimina todos los datos de mediciones, medicación, integraciones y registros de todos los usuarios. Las cuentas de usuario se conservarán.", + "deleteAllDescription": "Elimina todos los registros de todas las cuentas: mediciones, medicamentos, resultados de laboratorio, documentos, registros de ciclo, estado de ánimo, entrenamientos, sueño, conversaciones del Coach, integraciones y el historial de auditoría. Las cuentas y las credenciales de inicio de sesión se conservan.", "deleteAllConfirm": "¿Eliminar realmente todos los datos?", - "deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.", + "deleteAllConfirmDescription": "Esto elimina permanentemente todos los registros de todas las cuentas en toda la base de datos, no una selección de tablas. Se conservan: las cuentas de usuario y sus credenciales de inicio de sesión, la configuración de la instancia y las concesiones de acceso que mantienen accesibles los perfiles gestionados. Después se escribe un comprobante de este borrado en el registro de auditoría; el historial anterior se va con todo lo demás.", "deleteButton": "Eliminar todos los datos", "deleteAllPromptLabel": "Escribe {token} para continuar", "finalDelete": "Eliminar permanentemente", - "deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Eliminadas {rows} filas en {tables} tablas. Se conservaron las cuentas de usuario y las credenciales de inicio de sesión.", "authRegister": "Registro", "authLogin": "Iniciar sesión", "authLoginPasskey": "Inicio de sesión con passkey", diff --git a/messages/fr.json b/messages/fr.json index b3df467d1..5be13d90f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6483,13 +6483,13 @@ "outcomeFailed": "Échec", "auditDetails": "Détails", "deleteAllData": "Supprimer toutes les données globalement", - "deleteAllDescription": "Supprime toutes les données de mesures, de médicaments, d'intégrations et de journaux pour tous les utilisateurs. Les comptes utilisateurs sont conservés.", + "deleteAllDescription": "Supprime tous les enregistrements de tous les comptes : mesures, médicaments, résultats de laboratoire, documents, journaux de cycle, humeur, entraînements, sommeil, conversations du Coach, intégrations et historique d'audit. Les comptes et les identifiants de connexion sont conservés.", "deleteAllConfirm": "Vraiment supprimer toutes les données ?", - "deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.", + "deleteAllConfirmDescription": "Cette action supprime définitivement tous les enregistrements de tous les comptes dans l'ensemble de la base de données, et non une sélection de tables. Sont conservés : les comptes utilisateurs et leurs identifiants de connexion, la configuration de l'instance et les délégations qui gardent les profils gérés accessibles. Un reçu de cet effacement est ensuite écrit dans le journal d'audit ; l'historique antérieur part avec le reste.", "deleteButton": "Supprimer toutes les données", "deleteAllPromptLabel": "Tapez {token} pour continuer", "finalDelete": "Supprimer définitivement", - "deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "{rows} lignes supprimées dans {tables} tables. Les comptes utilisateurs et les identifiants de connexion ont été conservés.", "authRegister": "Inscription", "authLogin": "Connexion", "authLoginPasskey": "Connexion par passkey", diff --git a/messages/it.json b/messages/it.json index 7f96ce248..8b414c808 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6483,13 +6483,13 @@ "outcomeFailed": "Non riuscito", "auditDetails": "Dettagli", "deleteAllData": "Elimina tutti i dati globalmente", - "deleteAllDescription": "Elimina tutti i dati di misurazioni, farmaci, integrazioni e log di tutti gli utenti. Gli account utente vengono conservati.", + "deleteAllDescription": "Elimina tutti i record di tutti gli account: misurazioni, farmaci, risultati di laboratorio, documenti, registri del ciclo, umore, allenamenti, sonno, conversazioni del Coach, integrazioni e cronologia di audit. Gli account e le credenziali di accesso vengono conservati.", "deleteAllConfirm": "Eliminare davvero tutti i dati?", - "deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.", + "deleteAllConfirmDescription": "Questa operazione elimina definitivamente tutti i record di tutti gli account nell'intero database, non una selezione di tabelle. Vengono conservati: gli account utente e le loro credenziali di accesso, la configurazione dell'istanza e le deleghe che mantengono raggiungibili i profili gestiti. Al termine nel log di audit viene scritta una ricevuta di questa cancellazione; la cronologia precedente se ne va con tutto il resto.", "deleteButton": "Elimina tutti i dati", "deleteAllPromptLabel": "Digita {token} per continuare", "finalDelete": "Elimina definitivamente", - "deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Eliminate {rows} righe in {tables} tabelle. Account utente e credenziali di accesso conservati.", "authRegister": "Registrazione", "authLogin": "Accedi", "authLoginPasskey": "Accesso con passkey", diff --git a/messages/pl.json b/messages/pl.json index 2da87ee8b..65f222ced 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6483,13 +6483,13 @@ "outcomeFailed": "Nieudane", "auditDetails": "Szczegóły", "deleteAllData": "Usuń wszystkie dane globalnie", - "deleteAllDescription": "Usuwa wszystkie dane pomiarów, leków, integracji i logów wszystkich użytkowników. Konta użytkowników zostaną zachowane.", + "deleteAllDescription": "Usuwa wszystkie rekordy wszystkich kont: pomiary, leki, wyniki laboratoryjne, dokumenty, wpisy cyklu, nastrój, treningi, sen, rozmowy z Coachem, integracje i historię audytu. Konta i dane logowania zostają zachowane.", "deleteAllConfirm": "Na pewno usunąć wszystkie dane?", - "deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.", + "deleteAllConfirmDescription": "Ta operacja trwale usuwa wszystkie rekordy wszystkich kont w całej bazie danych, a nie wybrane tabele. Zachowane zostają: konta użytkowników i ich dane logowania, konfiguracja instancji oraz uprawnienia, dzięki którym profile zarządzane pozostają dostępne. Po zakończeniu do dziennika audytu trafia potwierdzenie tego usunięcia; wcześniejsza historia znika razem z resztą.", "deleteButton": "Usuń wszystkie dane", "deleteAllPromptLabel": "Wpisz {token}, aby kontynuować", "finalDelete": "Usuń trwale", - "deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.", + "deletedResult": "Usunięto {rows} wierszy w {tables} tabelach. Konta użytkowników i dane logowania zostały zachowane.", "authRegister": "Rejestracja", "authLogin": "Zaloguj", "authLoginPasskey": "Logowanie kluczem passkey", diff --git a/src/__tests__/admin-data-wipe-completeness.test.ts b/src/__tests__/admin-data-wipe-completeness.test.ts new file mode 100644 index 000000000..b7782808b --- /dev/null +++ b/src/__tests__/admin-data-wipe-completeness.test.ts @@ -0,0 +1,170 @@ +/** + * Structural guard binding `prisma/schema.prisma` to the ADMIN wipe. + * + * The per-account wipe grew a derived plan and a completeness test after it + * was found deleting thirteen tables out of eighty-odd. The admin wipe — the + * one an operator runs before handing a box on, "Deletes all data for all + * users" — kept its inline list of nine `deleteMany` calls, written when nine + * was most of the schema. It was still nine against a hundred and twenty-two + * models: laboratory results, documents, cycle logs, journals, mood, workouts, + * sleep and Coach conversations all survived a confirmation the operator typed + * by hand. Nothing failed, because nothing was asking. + * + * So the route reads `ADMIN_WIPE_MODELS` — `WIPE_MODELS` minus the entries in + * `ADMIN_WIPE_EXEMPT` — and this file asks the question its sibling asks for + * the account wipe. + * + * What it proves: + * 1. every model carrying a `userId` column is deleted by the admin wipe or + * excused by a named list with a written reason; + * 2. every model without one either reaches an admin-wiped model through a + * chain of required `onDelete: Cascade` relations or is declared + * instance-scoped; + * 3. every admin-only exemption names a real model that the per-account wipe + * DOES delete, so the entry is a deliberate subtraction rather than a + * stale name nobody re-read; + * 4. the route carries no table list of its own — it iterates the plan. + * + * Its honest limit is the same as its sibling's: it proves classification, not + * judgement. It cannot tell that a model was excused for a bad reason, only + * that somebody had to write the reason down. And rule 4 is source-shaped: it + * reads the route file for `deleteMany` calls on a literal delegate, so a + * future inline list built some other way would slip it. + * + * Mutation check: remove any entry from `WIPE_MODELS` and this goes red naming + * that model as unclassified; put an inline `tx.measurement.deleteMany({})` + * back into the route and rule 4 goes red naming it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +import { + parseWipeSchema, + reachedByCascade, +} from "@/__tests__/helpers/wipe-schema-shape"; +import { + ADMIN_WIPE_EXEMPT, + ADMIN_WIPE_MODELS, + INSTANCE_SCOPED, + WIPE_EXEMPT, + WIPE_MODELS, +} from "@/lib/data-wipe/wipe-plan"; + +const ROUTE = join( + process.cwd(), + "src", + "app", + "api", + "admin", + "data", + "route.ts", +); + +const models = parseWipeSchema(); +const adminWipeSet = new Set(ADMIN_WIPE_MODELS); + +/** Every list that is allowed to answer "why did this model survive?". */ +function survivorReason(model: string): string | null { + return ( + ADMIN_WIPE_EXEMPT[model] ?? + WIPE_EXEMPT[model] ?? + INSTANCE_SCOPED[model] ?? + null + ); +} + +describe("admin data-wipe completeness", () => { + it("deletes essentially the whole user-scoped schema", () => { + // A floor, not a target. The defect this file exists for was a wipe that + // covered nine tables while reporting success; a silent shrink back + // towards that number fails here even if every survivor is classified. + expect(ADMIN_WIPE_MODELS.length).toBeGreaterThan(80); + }); + + it("every model with a userId column is wiped or excused with a reason", () => { + const userScoped = [...models] + .filter(([, shape]) => shape.scalars.includes("userId")) + .map(([name]) => name); + + expect(userScoped.length).toBeGreaterThan(80); + + const unclassified = userScoped.filter( + (name) => !adminWipeSet.has(name) && survivorReason(name) === null, + ); + + if (unclassified.length > 0) { + throw new Error( + `${unclassified.length} user-scoped model(s) survive the admin wipe with nothing saying why:\n` + + unclassified.map((n) => ` ❌ ${n}`).join("\n") + + "\n\nAn operator who types DELETE ALL before handing this server on " + + "expects these rows gone. Add each to WIPE_MODELS, or to " + + "ADMIN_WIPE_EXEMPT / WIPE_EXEMPT / INSTANCE_SCOPED with the reason " + + "keeping it is not keeping somebody's record.", + ); + } + }); + + it("every model without a userId column is reached by cascade or declared instance-scoped", () => { + const covered = reachedByCascade(models, adminWipeSet); + + const orphans = [...models] + .filter(([, shape]) => !shape.scalars.includes("userId")) + .map(([name]) => name) + .filter((name) => !covered.has(name) && survivorReason(name) === null); + + if (orphans.length > 0) { + throw new Error( + `${orphans.length} model(s) carry no userId, are not removed by any cascade from an admin-wiped row, and are not declared instance-scoped:\n` + + orphans.map((n) => ` ❌ ${n}`).join("\n"), + ); + } + }); + + it("excuses only models the per-account wipe does delete, each with a reason", () => { + for (const [name, reason] of Object.entries(ADMIN_WIPE_EXEMPT)) { + expect( + models.has(name), + `ADMIN_WIPE_EXEMPT names "${name}", which is not a model`, + ).toBe(true); + // The list exists to record where the two wipes DIFFER. A name the + // account wipe does not delete either is not a difference; it is a + // stale entry hiding behind a plausible reason. + expect( + (WIPE_MODELS as readonly string[]).includes(name), + `ADMIN_WIPE_EXEMPT names "${name}", which the per-account wipe does not delete either — the entry records no difference`, + ).toBe(true); + expect( + reason.length, + `ADMIN_WIPE_EXEMPT.${name} needs a real reason`, + ).toBeGreaterThan(30); + } + }); + + it("derives the model set instead of listing tables in the route", () => { + const source = readFileSync(ROUTE, "utf8"); + + expect( + source.includes("ADMIN_WIPE_MODELS"), + "the admin wipe route no longer reads the shared plan", + ).toBe(true); + + // `tx.measurement.deleteMany(` and friends — the shape the inline list + // had. The loop calls `resolveGlobalWipeDelegate(tx, model).deleteMany`, + // which names no table and does not match. + const inline = [ + ...source.matchAll( + /\b(?:tx|prisma)\s*\.\s*(\w+)\s*\.\s*deleteMany\s*\(/g, + ), + ].map((m) => m[1]); + + expect( + inline, + `the admin wipe names tables inline again:\n${inline.join("\n")}\n\n` + + "That list is how it drifted to nine tables: it was correct the day " + + "it was written and nothing noticed when the schema moved past it. " + + "Delete through ADMIN_WIPE_MODELS.", + ).toEqual([]); + }); +}); diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 745992427..af4b2d4b5 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -35,11 +35,12 @@ * that model; add a column to `model User` without classifying it and it goes * red naming the column. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, it, expect } from "vitest"; +import { + parseWipeSchema, + reachedByCascade, +} from "@/__tests__/helpers/wipe-schema-shape"; import { INSTANCE_SCOPED, USER_KEPT_FIELDS, @@ -51,61 +52,7 @@ import { wipeWhere, } from "@/lib/data-wipe/wipe-plan"; -const SCHEMA = join(process.cwd(), "prisma", "schema.prisma"); - -interface Relation { - /** Target model name. */ - target: string; - cascade: boolean; - /** `true` when the relation field is optional (`Model?`). */ - optional: boolean; -} - -interface ModelShape { - scalars: string[]; - relations: Relation[]; -} - -function parseSchema(schema: string): Map { - const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map( - (m) => ({ name: m[1], body: m[2] }), - ); - const modelNames = new Set(blocks.map((b) => b.name)); - - const out = new Map(); - for (const block of blocks) { - const scalars: string[] = []; - const relations: Relation[] = []; - for (const rawLine of block.body.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("//") || line.startsWith("@@")) continue; - const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line); - if (!match) continue; - const [, field, type, isList, isOptional] = match; - if (modelNames.has(type)) { - // A list side of a relation carries no column and cannot cascade. - if (isList) continue; - relations.push({ - target: type, - cascade: line.includes("onDelete: Cascade"), - optional: Boolean(isOptional), - }); - continue; - } - if (isList && type === type.toUpperCase()) { - // Scalar / enum list column — still a column. - scalars.push(field); - continue; - } - scalars.push(field); - } - out.set(block.name, { scalars, relations }); - } - return out; -} - -const schemaText = readFileSync(SCHEMA, "utf8"); -const models = parseSchema(schemaText); +const models = parseWipeSchema(); const wipeSet = new Set(WIPE_MODELS); describe("data-wipe completeness", () => { @@ -158,21 +105,7 @@ describe("data-wipe completeness", () => { it("every model without a userId column is reached by cascade or declared instance-scoped", () => { // Fixpoint over required cascade relations: a model is covered when the // database removes it as a consequence of a row the wipe deletes. - const covered = new Set(wipeSet); - for (;;) { - let grew = false; - for (const [name, shape] of models) { - if (covered.has(name)) continue; - const reached = shape.relations.some( - (r) => r.cascade && !r.optional && covered.has(r.target), - ); - if (reached) { - covered.add(name); - grew = true; - } - } - if (!grew) break; - } + const covered = reachedByCascade(models, wipeSet); const orphans = [...models] .filter(([, shape]) => !shape.scalars.includes("userId")) diff --git a/src/__tests__/helpers/wipe-schema-shape.ts b/src/__tests__/helpers/wipe-schema-shape.ts new file mode 100644 index 000000000..1e6f915f2 --- /dev/null +++ b/src/__tests__/helpers/wipe-schema-shape.ts @@ -0,0 +1,102 @@ +/** + * The shape of `prisma/schema.prisma` the two wipe guards read. + * + * Both of them ask the same two questions — which models carry a `userId`, and + * which models the database removes on its own when a row they do delete goes + * — so the parser answering them lives in one place. Two copies would drift, + * and a guard that answers from its own private idea of the schema is a guard + * that can be green for the wrong reason. + * + * Deliberately regex-shaped rather than a real parser: it runs inside + * `pnpm test` with no database and no Prisma engine, and model blocks plus + * field lines are the most stable syntax in the file. Every consumer asserts a + * floor on how much it found, so a parser that silently stops matching fails + * loudly instead of passing vacuously. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface Relation { + /** Target model name. */ + target: string; + cascade: boolean; + /** `true` when the relation field is optional (`Model?`). */ + optional: boolean; +} + +export interface ModelShape { + scalars: string[]; + relations: Relation[]; +} + +export const WIPE_SCHEMA_PATH = join(process.cwd(), "prisma", "schema.prisma"); + +export function parseWipeSchema( + schemaPath: string = WIPE_SCHEMA_PATH, +): Map { + const schema = readFileSync(schemaPath, "utf8"); + const blocks = [...schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)].map( + (m) => ({ name: m[1], body: m[2] }), + ); + const modelNames = new Set(blocks.map((b) => b.name)); + + const out = new Map(); + for (const block of blocks) { + const scalars: string[] = []; + const relations: Relation[] = []; + for (const rawLine of block.body.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const match = /^(\w+)\s+(\w+)(\[\])?(\?)?/.exec(line); + if (!match) continue; + const [, field, type, isList, isOptional] = match; + if (modelNames.has(type)) { + // A list side of a relation carries no column and cannot cascade. + if (isList) continue; + relations.push({ + target: type, + cascade: line.includes("onDelete: Cascade"), + optional: Boolean(isOptional), + }); + continue; + } + if (isList && type === type.toUpperCase()) { + // Scalar / enum list column — still a column. + scalars.push(field); + continue; + } + scalars.push(field); + } + out.set(block.name, { scalars, relations }); + } + return out; +} + +/** + * Every model the database itself removes once the seed models are deleted. + * + * A fixpoint over REQUIRED cascade relations: an optional relation survives + * its parent, so it proves nothing about the row going away. The result + * includes the seeds. + */ +export function reachedByCascade( + models: Map, + seeds: Iterable, +): Set { + const covered = new Set(seeds); + for (;;) { + let grew = false; + for (const [name, shape] of models) { + if (covered.has(name)) continue; + const reached = shape.relations.some( + (r) => r.cascade && !r.optional && covered.has(r.target), + ); + if (reached) { + covered.add(name); + grew = true; + } + } + if (!grew) break; + } + return covered; +} diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index abd69fd15..9fcbb9afe 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -6,12 +6,36 @@ import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextRequest, NextResponse } from "next/server"; import { invalidateAllCaches } from "@/lib/cache/invalidate"; +import { + ADMIN_WIPE_MODELS, + resolveGlobalWipeDelegate, + USER_RESET, + wipeDelegateKey, + WIPE_TRANSACTION_MAX_WAIT_MS, + WIPE_TRANSACTION_TIMEOUT_MS, +} from "@/lib/data-wipe/wipe-plan"; export const dynamic = "force-dynamic"; /** - * Admin-only global wipe of all health/integration data. + * Admin-only global wipe of every account's data. * Keeps users/passkeys so access to the app remains possible. + * + * The set of tables is not written here. It is declared once in + * `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe + * reads — and held against `prisma/schema.prisma` by + * `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling, + * so a model added to the schema cannot quietly land outside this wipe. It had + * done exactly that: the inline list this route used to carry named nine + * tables, written when nine was most of the schema, and stayed at nine while + * the schema grew past a hundred. Laboratory results, documents, cycle logs, + * journals, mood, workouts, sleep and Coach conversations all survived the + * wipe an operator runs before handing the box on. + * + * `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in + * `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance + * configuration, the delegation grants that keep managed profiles reachable — + * carries its reason in that file. */ export const DELETE = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request); @@ -59,72 +83,61 @@ export const DELETE = apiHandler(async (request: NextRequest) => { } // Audit-log the *intent* before the transaction begins so the trail - // describing "an admin is about to wipe data" survives even if the - // operation crashes midway. AuditLog rows are intentionally NOT - // deleted as part of the wipe — the entire point of an audit trail - // is to outlive the data it documents. + // describing "an admin is about to wipe data" survives a crash midway or a + // rollback. On success the transaction takes this row with the rest of the + // history — an audit trail is a record of who did what from where, which is + // exactly the personal data an operator clearing a box means to remove — and + // the receipt written after the commit replaces it. await auditLog("admin.data.clear.start", { userId: user.id, ipAddress: getClientIp(request), details: { confirm: "DELETE ALL" }, }); - const result = await prisma.$transaction(async (tx) => { - const measurements = await tx.measurement.deleteMany({}); - const intakeEvents = await tx.medicationIntakeEvent.deleteMany({}); - const medications = await tx.medication.deleteMany({}); - const apiTokens = await tx.apiToken.deleteMany({}); - const withingsConnections = await tx.withingsConnection.deleteMany({}); - const authChallenges = await tx.authChallenge.deleteMany({}); + const counts = await prisma.$transaction( + async (tx) => { + const perModel: Record = {}; + for (const model of ADMIN_WIPE_MODELS) { + const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany( + {}, + ); + if (count > 0) perModel[wipeDelegateKey(model)] = count; + } - // v1.4.6 left these out, so encrypted Telegram bot tokens - // (NotificationChannel.config) and Web Push endpoints survived a - // wipe. Cascading via NotificationPreference is handled by the - // schema's onDelete: Cascade on the channel FK. - const notificationChannels = await tx.notificationChannel.deleteMany({}); - const pushSubscriptions = await tx.pushSubscription.deleteMany({}); - const telegramScheduledDeletions = - await tx.telegramScheduledDeletion.deleteMany({}); + // Every account row survives; the personal data carried on its own + // columns does not. Same classification contract as the model list. + await tx.user.updateMany({ data: USER_RESET }); - await tx.user.updateMany({ - data: { - heightCm: null, - dateOfBirth: null, - gender: null, - insightsPrivacyMode: "aggregated", - insightsCachedAt: null, - insightsCachedText: null, - telegramBotToken: null, - telegramChatId: null, - telegramEnabled: false, - withingsClientIdEncrypted: null, - withingsClientSecretEncrypted: null, - onboardingCompletedAt: null, - }, - }); + return perModel; + }, + { + timeout: WIPE_TRANSACTION_TIMEOUT_MS, + maxWait: WIPE_TRANSACTION_MAX_WAIT_MS, + }, + ); - return { - measurements: measurements.count, - intakeEvents: intakeEvents.count, - medications: medications.count, - apiTokens: apiTokens.count, - withingsConnections: withingsConnections.count, - authChallenges: authChallenges.count, - notificationChannels: notificationChannels.count, - pushSubscriptions: pushSubscriptions.count, - telegramScheduledDeletions: telegramScheduledDeletions.count, - }; - }); + const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0); + // Written after the transaction commits, so the row cannot be removed by the + // AuditLog delete inside it. The instance's whole audit history goes; this + // one row stays as the receipt for the erasure the operator asked for. await auditLog("admin.data.clear", { userId: user.id, ipAddress: getClientIp(request), - details: result, + details: { deletedRows, models: counts }, + }); + + annotate({ + meta: { + deleted_rows: deletedRows, + models_wiped: ADMIN_WIPE_MODELS.length, + models_with_rows: Object.keys(counts).length, + }, }); // v1.16.9 — the wipe touched every user's rows; clear every cache // bucket so no per-user payload survives the reset. invalidateAllCaches(); - return apiSuccess({ cleared: true, ...result }); + return apiSuccess({ cleared: true, deletedRows, models: counts }); }); diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 105e33893..4ee8f3172 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -46,16 +46,14 @@ export function DangerZoneSection() { // Idempotency-Key prevents a double-submit from re-running the // destructive transaction, matching the Backups restore path. const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`; + // The server returns one count per table that actually held rows. It + // used to return nine fixed names, which is how nobody noticed the + // wipe had stopped covering the schema: the result line read the same + // whether it had cleared nine tables or ninety. return apiDelete<{ - measurements: number; - intakeEvents: number; - medications: number; - apiTokens: number; - withingsConnections: number; - authChallenges: number; - notificationChannels: number; - pushSubscriptions: number; - telegramScheduledDeletions: number; + cleared: boolean; + deletedRows: number; + models: Record; }>( "/api/admin/data", { confirm: WIPE_CONFIRM_TOKEN }, @@ -66,15 +64,8 @@ export function DangerZoneSection() { queryClient.invalidateQueries(); setWipeMsg( t("admin.deletedResult", { - measurements: data.measurements, - medications: data.medications, - intakeEvents: data.intakeEvents, - apiTokens: data.apiTokens, - withingsConnections: data.withingsConnections, - authChallenges: data.authChallenges, - notificationChannels: data.notificationChannels, - pushSubscriptions: data.pushSubscriptions, - telegramScheduledDeletions: data.telegramScheduledDeletions, + rows: data.deletedRows, + tables: Object.keys(data.models).length, }), ); }, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index b5f14fcd2..5b4778f77 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -434,6 +434,46 @@ export const WIPE_OWNER_FIELDS: Readonly> = { AccountGrant: ["grantorId", "granteeId"], }; +/** + * Models the ADMIN wipe keeps although the per-account wipe deletes them. + * + * `DELETE /api/admin/data` is the same action pointed at every account at + * once: the operator clearing a box before handing it on. It therefore reads + * {@link WIPE_MODELS} rather than a list of its own — that list was written + * inline once and had drifted to nine tables against a schema of a hundred and + * twenty-two by the time anybody counted. + * + * The two actions differ in exactly one place, and it is written here rather + * than folded into the route so the difference has to be argued for. + */ +export const ADMIN_WIPE_EXEMPT: Readonly> = { + AccountGrant: + "a grant is the only way into a managed profile — that record has no credentials of its own, so deleting every grant at once would leave a set of accounts nobody can open, administer or delete. This action preserves accounts by definition, and an access edge without the record behind it is not personal data: everything those profiles hold is wiped with everyone else's.", +}; + +/** + * Every model the admin wipe deletes, for all accounts, children first. + * + * Derived, so a model added to {@link WIPE_MODELS} is in both wipes at once + * and a model excused from this one has to say why above. + */ +export const ADMIN_WIPE_MODELS: readonly string[] = WIPE_MODELS.filter( + (model) => !(model in ADMIN_WIPE_EXEMPT), +); + +/** + * Budget for a wipe transaction. + * + * Prisma's default interactive-transaction timeout is 5 s, which was already + * optimistic for thirteen `deleteMany` statements and is not survivable for + * the full model set on an account with a large import behind it — let alone + * for the admin wipe, which runs the same set across every account. The whole + * action is one transaction on purpose: a half-applied wipe is worse than + * none, because the person is told it succeeded either way. + */ +export const WIPE_TRANSACTION_TIMEOUT_MS = 120_000; +export const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000; + /** Prisma client delegate key for a model name (`MoodEntry` → `moodEntry`). */ export function wipeDelegateKey(model: string): string { return model.charAt(0).toLowerCase() + model.slice(1); @@ -456,14 +496,37 @@ export interface UserScopedDeleteDelegate { deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>; } +/** The same slice for the admin wipe, which selects no account at all. */ +export interface UnscopedDeleteDelegate { + deleteMany(args: Record): Promise<{ count: number }>; +} + +interface RawDeleteDelegate { + deleteMany(args: { where?: object }): Promise<{ count: number }>; +} + +/** + * The Prisma delegate for a model name, or a thrown error naming the model. + * + * One documented cast: {@link WIPE_MODELS} is a literal list of model names + * checked against `schema.prisma` by the completeness test, and every entry + * indexes a delegate that exposes `deleteMany`. The test also asserts every + * entry resolves, so an unresolvable name fails the build rather than the + * request. + */ +function rawDelegate(client: object, model: string): RawDeleteDelegate { + const delegate = (client as Record)[ + wipeDelegateKey(model) + ]; + if (!delegate || typeof delegate.deleteMany !== "function") { + throw new Error(`No Prisma delegate for wipe model "${model}"`); + } + return delegate; +} + /** * Resolve a model name to its delegate on a client or transaction handle. * - * One documented cast: `WIPE_MODELS` is a literal list of model names checked - * against `schema.prisma` by the completeness test, and every entry indexes a - * delegate that exposes `deleteMany`. The test also asserts every entry - * resolves, so an unresolvable name fails the build rather than the request. - * * A model listed in {@link WIPE_OWNER_FIELDS} gets a wrapper that rewrites the * caller's `{ userId }` into that model's own ownership predicate. The caller * still says "this account's rows" and still gets a count back; only the @@ -474,15 +537,7 @@ export function resolveWipeDelegate( client: object, model: string, ): UserScopedDeleteDelegate { - const delegate = ( - client as Record< - string, - { deleteMany(args: { where: object }): Promise<{ count: number }> } - > - )[wipeDelegateKey(model)]; - if (!delegate || typeof delegate.deleteMany !== "function") { - throw new Error(`No Prisma delegate for wipe model "${model}"`); - } + const delegate = rawDelegate(client, model); if (!WIPE_OWNER_FIELDS[model]) { return delegate as UserScopedDeleteDelegate; } @@ -491,3 +546,20 @@ export function resolveWipeDelegate( delegate.deleteMany({ where: wipeWhere(model, where.userId) }), }; } + +/** + * Resolve a model name to a delegate that deletes every row of it. + * + * The admin wipe asks a different question from the per-account one — "every + * row of this model" rather than "this account's rows" — so it gets its own + * resolver instead of a `where` the caller has to remember to leave out. + * {@link WIPE_OWNER_FIELDS} is irrelevant here by construction: with no + * account to select, a row that belongs to two of them is deleted once. + */ +export function resolveGlobalWipeDelegate( + client: object, + model: string, +): UnscopedDeleteDelegate { + const delegate = rawDelegate(client, model); + return { deleteMany: () => delegate.deleteMany({}) }; +} diff --git a/tests/integration/admin-data-wipe.test.ts b/tests/integration/admin-data-wipe.test.ts index 948cbe054..1aaeda45f 100644 --- a/tests/integration/admin-data-wipe.test.ts +++ b/tests/integration/admin-data-wipe.test.ts @@ -1,24 +1,44 @@ /** - * Integration regression guard for `DELETE /api/admin/data`. + * Integration guard for `DELETE /api/admin/data` — the global "wipe all data". * - * The admin "wipe all data" path nulls personal columns on `User` and - * deletes a curated list of personal-data tables in one transaction. - * v1.4.6 left three notification-channel-style tables out of that - * list, leaving encrypted Telegram bot tokens (NotificationChannel.config) - * and Web Push endpoints behind. This test is the regression guard - * that the wipe now extends to: + * This is the wipe an operator runs before handing a box on, and it deleted + * nine tables. The list was inline, written when nine was most of the schema, + * and it stayed at nine while the schema grew past a hundred: laboratory + * results, documents, cycle logs, journals, mood, workouts, sleep and Coach + * conversations all survived a confirmation typed by hand. The previous + * version of this file asserted the three tables a v1.4.6 fix had added and + * was green the whole time — it could only see the tables it named. * - * - notification_channels (Telegram/ntfy/web-push channel config) - * - push_subscriptions (raw browser push endpoints) - * - telegram_scheduled_deletions (queued message-delete tickets) + * So it now works the way the account-wipe guard does: seed a row into every + * table in the schema, for two different accounts, run the wipe, and ask the + * database what is left. What it asserts: * - * AuditLog rows are intentionally PRESERVED — a wipe must not erase the - * audit trail of the operation itself. + * - every model in `ADMIN_WIPE_MODELS` is empty across the WHOLE table, not + * merely for the admin who pressed the button; + * - every table reached only through a database cascade is empty too; + * - the exempt sets still hold their rows, so the "kept" half of the + * confirmation copy is true; + * - the instance-scoped tables are untouched; + * - every `User` row survives with `USER_RESET` applied; + * - the audit history goes and exactly one receipt row replaces it. + * + * Its honest limit: it proves the wipe removes what the plan declares. The + * plan being right is what `src/__tests__/admin-data-wipe-completeness.test.ts` + * holds against `schema.prisma`. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder"; +import { + ADMIN_WIPE_EXEMPT, + ADMIN_WIPE_MODELS, + INSTANCE_SCOPED, + USER_KEPT_FIELDS, + USER_RESET, + WIPE_EXEMPT, +} from "@/lib/data-wipe/wipe-plan"; // next/headers cookie + ip headers stub for getSession() inside // requireAdmin(). The admin route reads x-forwarded-for too; we stub @@ -56,19 +76,89 @@ beforeEach(async () => { headerJar.clear(); }); -describe("DELETE /api/admin/data wipe scope", () => { - it("clears notification channels, push subscriptions, telegram deletions; preserves AuditLog", async () => { +async function runWipe(): Promise { + const { DELETE } = await import("@/app/api/admin/data/route"); + const req = new Request("http://localhost/api/admin/data", { + method: "DELETE", + headers: { + "content-type": "application/json", + "x-forwarded-for": "127.0.0.1", + }, + body: JSON.stringify({ confirm: "DELETE ALL" }), + }); + return DELETE(req as unknown as Parameters[0]); +} + +describe("DELETE /api/admin/data leaves nothing it promised to delete", () => { + it("empties every in-scope table for every account and keeps exactly the declared set", async () => { const prisma = getPrismaClient(); + const tables = modelTableMap(); - // ── arrange ── const admin = await prisma.user.create({ data: { username: "wipe-admin", email: "wipe-admin@example.test", role: "ADMIN", + passwordHash: "argon2-placeholder", + heightCm: 180, + dateOfBirth: new Date("1990-01-01T00:00:00Z"), + telegramBotToken: "secret-token", + telegramEnabled: true, + locale: "en", + unitPreference: "metric", }, }); + // The whole point of this route is that it reaches accounts other than + // the one pressing the button. A second account's rows are seeded by + // hand across the domains the old nine-table list left behind. + const other = await prisma.user.create({ + data: { + username: "wipe-bystander", + email: "wipe-bystander@example.test", + passwordHash: "argon2-placeholder", + heightCm: 165, + onboardingCompletedAt: new Date(), + }, + }); + await prisma.measurement.create({ + data: { + userId: other.id, + type: "WEIGHT", + value: 70, + unit: "kg", + measuredAt: new Date(), + }, + }); + await prisma.labResult.create({ + data: { + userId: other.id, + analyte: "HbA1c", + value: 5.4, + unit: "%", + takenAt: new Date(), + }, + }); + await prisma.coachConversation.create({ + data: { userId: other.id, title: "bystander conversation" }, + }); + await prisma.workout.create({ + data: { + userId: other.id, + sportType: "running", + startedAt: new Date("2026-01-01T06:00:00Z"), + endedAt: new Date("2026-01-01T06:30:00Z"), + durationSec: 1800, + }, + }); + + const seeded = await seedEveryTable(prisma, admin.id); + expect( + seeded.length, + "the seeder should reach essentially the whole schema", + ).toBeGreaterThan(100); + + // Created after the generic seed so the cookie carries this session's id. const session = await prisma.session.create({ data: { userId: admin.id, @@ -77,65 +167,155 @@ describe("DELETE /api/admin/data wipe scope", () => { }); cookieJar.set("healthlog_session", session.id); - // Seed the channels & adjacent rows the wipe must clear. - await prisma.notificationChannel.create({ + // ── act ── + const response = await runWipe(); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { cleared: boolean; deletedRows: number }; + }; + expect(body.data.cleared).toBe(true); + expect(body.data.deletedRows).toBeGreaterThan(50); + + // ── assert: nothing the plan claims to delete is left, for anyone ── + const survivors: string[] = []; + for (const model of ADMIN_WIPE_MODELS) { + const table = tables.get(model)!; + // `AuditLog` is wiped inside the transaction and then receives exactly + // one new row after it commits: the receipt for the wipe itself. + const expected = model === "AuditLog" ? 1 : 0; + if ((await countRows(prisma, table)) > expected) { + survivors.push(`${model} (${table})`); + } + } + expect( + survivors, + `rows survived a wipe that told the operator everything was deleted:\n${survivors.join("\n")}`, + ).toEqual([]); + + const auditRows = await prisma.auditLog.findMany({ + select: { action: true }, + }); + expect(auditRows.map((r) => r.action)).toEqual(["admin.data.clear"]); + + // ── assert: the kept set is exactly what the copy promises ── + const missing: string[] = []; + for (const model of [ + ...Object.keys(WIPE_EXEMPT), + ...Object.keys(ADMIN_WIPE_EXEMPT), + ]) { + const table = tables.get(model)!; + if ((await countRows(prisma, table)) === 0) { + missing.push(`${model} (${table})`); + } + } + expect( + missing, + `the copy says these are kept, and they are gone:\n${missing.join("\n")}`, + ).toEqual([]); + + // ── assert: the instance's own tables are untouched ── + for (const model of Object.keys(INSTANCE_SCOPED)) { + if (model === "User") continue; + const table = tables.get(model)!; + expect( + await countRows(prisma, table), + `${model} belongs to the instance and must survive the data wipe`, + ).toBeGreaterThan(0); + } + + // ── assert: everything reached only by cascade is gone too ── + const wipedTables = new Set(ADMIN_WIPE_MODELS.map((m) => tables.get(m)!)); + const keptTables = new Set( + [ + ...Object.keys(WIPE_EXEMPT), + ...Object.keys(ADMIN_WIPE_EXEMPT), + ...Object.keys(INSTANCE_SCOPED), + ].map((m) => tables.get(m)!), + ); + const leftovers: string[] = []; + for (const table of seeded) { + if (wipedTables.has(table)) continue; + if (keptTables.has(table)) continue; + if ((await countRows(prisma, table)) > 0) leftovers.push(table); + } + expect( + leftovers, + `tables the wipe reaches only through a database cascade still hold rows:\n${leftovers.join("\n")}`, + ).toEqual([]); + + // ── assert: both accounts survive, reset but signed-in-able ── + const rows = await prisma.user.findMany({ orderBy: { username: "asc" } }); + expect(rows.map((r) => r.username)).toEqual([ + "wipe-admin", + "wipe-bystander", + ]); + + for (const row of rows) { + const record = row as unknown as Record; + expect(record.passwordHash).toBe("argon2-placeholder"); + for (const column of Object.keys(USER_KEPT_FIELDS)) { + expect(record).toHaveProperty(column); + } + + const stillSet: string[] = []; + for (const column of Object.keys(USER_RESET)) { + const value = record[column]; + const cleared = + value === null || + value === false || + value === 0 || + (Array.isArray(value) && value.length === 0) || + value === "aggregated" || + value === "disconnected"; + if (!cleared) stillSet.push(`${column} = ${JSON.stringify(value)}`); + } + expect( + stillSet, + `${record.username}: User columns the plan resets still carry a value:\n${stillSet.join("\n")}`, + ).toEqual([]); + } + + // The admin who ran it is still signed in — the session that carried the + // request has to outlive it or the operator is logged out mid-action. + expect(await prisma.session.count({ where: { id: session.id } })).toBe(1); + }); + + it("refuses a wipe without the typed confirmation", async () => { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ data: { - userId: admin.id, - type: "TELEGRAM", - enabled: true, - // Pretend-encrypted blob — content is irrelevant, presence is. - config: "encrypted:test", + username: "wipe-admin-unconfirmed", + email: "wipe-admin-unconfirmed@example.test", + role: "ADMIN", }, }); - await prisma.pushSubscription.create({ - data: { - userId: admin.id, - endpoint: "https://push.example/sub-" + admin.id, - p256dh: "p256dh-enc", - auth: "auth-enc", - }, + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) }, }); - await prisma.telegramScheduledDeletion.create({ + cookieJar.set("healthlog_session", session.id); + await prisma.measurement.create({ data: { userId: admin.id, - chatId: "1234", - messageId: 42, - deleteAfter: new Date(Date.now() + 60_000), + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: new Date(), }, }); - // ── act: invoke the route handler exactly as Next.js would ── const { DELETE } = await import("@/app/api/admin/data/route"); - const req = new Request("http://localhost/api/admin/data", { - method: "DELETE", - headers: { - "content-type": "application/json", - "x-forwarded-for": "127.0.0.1", - }, - body: JSON.stringify({ confirm: "DELETE ALL" }), - }); - const res = await DELETE(req as unknown as Parameters[0]); - expect(res.status).toBe(200); + const res = await DELETE( + new Request("http://localhost/api/admin/data", { + method: "DELETE", + headers: { + "content-type": "application/json", + "x-forwarded-for": "127.0.0.1", + }, + body: JSON.stringify({ confirm: "delete all" }), + }) as unknown as Parameters[0], + ); - // ── assert: notification-related tables are empty ── - expect( - await prisma.notificationChannel.count({ where: { userId: admin.id } }), - ).toBe(0); - expect( - await prisma.pushSubscription.count({ where: { userId: admin.id } }), - ).toBe(0); - expect( - await prisma.telegramScheduledDeletion.count({ - where: { userId: admin.id }, - }), - ).toBe(0); - - // ── assert: AuditLog rows survive ── - // The wipe writes admin.data.clear.start AND admin.data.clear audit - // entries. They must outlive the operation (audit log is immune). - const auditCount = await prisma.auditLog.count({ - where: { userId: admin.id }, - }); - expect(auditCount).toBeGreaterThanOrEqual(2); + expect(res.status).toBe(422); + expect(await prisma.measurement.count()).toBe(1); }); }); diff --git a/tests/integration/settings-data-wipe.test.ts b/tests/integration/settings-data-wipe.test.ts index 60449a68d..72b9e174b 100644 --- a/tests/integration/settings-data-wipe.test.ts +++ b/tests/integration/settings-data-wipe.test.ts @@ -10,7 +10,8 @@ * than a hand-written fixture, for the same reason the wipe list is derived * rather than hand-written: a fixture written today covers the schema of * today. A table added next year is seeded automatically and therefore - * asserted automatically. + * asserted automatically. It lives in `./wipe-seeder` because the admin wipe + * has to be measured with the same ruler. * * What it asserts: * - every model in `WIPE_MODELS` has zero rows for the account afterwards; @@ -25,20 +26,17 @@ * plan being right is what `src/__tests__/data-wipe-completeness.test.ts` * holds against `schema.prisma`. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { countRows, modelTableMap, seedEveryTable } from "./wipe-seeder"; import { INSTANCE_SCOPED, USER_KEPT_FIELDS, USER_RESET, WIPE_EXEMPT, WIPE_MODELS, - WIPE_OWNER_FIELDS, } from "@/lib/data-wipe/wipe-plan"; import type { PrismaClient } from "@/generated/prisma/client"; import { @@ -71,248 +69,6 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -// ── schema.prisma model → table name ──────────────────────────────────────── - -function modelTableMap(): Map { - const schema = readFileSync( - join(process.cwd(), "prisma", "schema.prisma"), - "utf8", - ); - const map = new Map(); - for (const block of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) { - const mapped = /@@map\("([^"]+)"\)/.exec(block[2]); - map.set(block[1], mapped ? mapped[1] : block[1]); - } - return map; -} - -// ── generic seeder ────────────────────────────────────────────────────────── - -interface ColumnRow { - table_name: string; - column_name: string; - is_nullable: string; - column_default: string | null; - udt_name: string; -} - -interface ForeignKeyRow { - table_name: string; - column_name: string; - ref_table: string; - ref_column: string; - cascades: boolean; -} - -/** Tables the seeder never writes: Prisma's own ledger, and the account row. */ -const SEED_SKIP = new Set(["_prisma_migrations", "users"]); - -function synthesise(udt: string, enums: Map, tag: string) { - if (udt.startsWith("_")) return `'{}'::${udt.slice(1)}[]`; - switch (udt) { - case "text": - case "varchar": - case "bpchar": - return `'${tag}'`; - case "int2": - case "int4": - case "int8": - case "numeric": - case "float4": - case "float8": - return "1"; - case "bool": - return "false"; - case "timestamp": - case "timestamptz": - case "date": - return "now()"; - case "bytea": - return "'\\x00'::bytea"; - case "json": - case "jsonb": - return `'{}'::${udt}`; - case "uuid": - return "gen_random_uuid()"; - default: { - const label = enums.get(udt); - if (label) return `'${label}'::"${udt}"`; - throw new Error(`seeder has no value for column type "${udt}"`); - } - } -} - -/** - * Insert one row into every table in `public`, parents first, wiring every - * foreign key to the row already created for its target and every `user_id` to - * the account under test. - */ -async function seedEveryTable( - prisma: PrismaClient, - userId: string, -): Promise { - const columns = await prisma.$queryRaw` - SELECT table_name, column_name, is_nullable, column_default, udt_name - FROM information_schema.columns - WHERE table_schema = 'public'`; - - const foreignKeys = await prisma.$queryRaw` - SELECT con.conrelid::regclass::text AS table_name, - att.attname AS column_name, - con.confrelid::regclass::text AS ref_table, - ratt.attname AS ref_column, - con.confdeltype = 'c' AS cascades - FROM pg_constraint con - JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true - JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS rk(attnum, ord) - ON rk.ord = ck.ord - JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ck.attnum - JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = rk.attnum - WHERE con.contype = 'f' AND con.connamespace = 'public'::regnamespace`; - - const enumRows = await prisma.$queryRaw< - Array<{ typname: string; enumlabel: string }> - >` - SELECT t.typname, e.enumlabel - FROM pg_type t - JOIN pg_enum e ON e.enumtypid = t.oid - ORDER BY t.typname, e.enumsortorder`; - const enums = new Map(); - for (const row of enumRows) { - if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel); - } - - const byTable = new Map(); - for (const column of columns) { - if (SEED_SKIP.has(column.table_name)) continue; - const bucket = byTable.get(column.table_name) ?? []; - bucket.push(column); - byTable.set(column.table_name, bucket); - } - - const fkByTable = new Map(); - for (const fk of foreignKeys) { - const bucket = fkByTable.get(fk.table_name) ?? []; - bucket.push(fk); - fkByTable.set(fk.table_name, bucket); - } - - // Parents before children. A self-reference or an optional cycle is skipped - // by only ordering on the columns the insert actually fills. - const pending = new Set(byTable.keys()); - const ordered: string[] = []; - while (pending.size > 0) { - let progressed = false; - for (const table of [...pending]) { - const blocked = (fkByTable.get(table) ?? []).some( - (fk) => fk.ref_table !== table && pending.has(fk.ref_table), - ); - if (blocked) continue; - ordered.push(table); - pending.delete(table); - progressed = true; - } - if (!progressed) { - throw new Error( - `foreign-key cycle among: ${[...pending].sort().join(", ")}`, - ); - } - } - - const insertedId = new Map(); - let tag = 0; - - for (const table of ordered) { - const tableColumns = byTable.get(table)!; - const fks = fkByTable.get(table) ?? []; - const names: string[] = []; - const values: string[] = []; - - for (const column of tableColumns) { - const fk = fks.find((f) => f.column_name === column.column_name); - const required = - column.is_nullable === "NO" && column.column_default === null; - const isUserColumn = column.column_name === "user_id"; - - if (fk?.ref_table === "users") { - // Always bind to the account under test, nullable or not — a NULL - // owner would put the row outside the wipe and make the assertion - // vacuous. - names.push(column.column_name); - values.push(`'${userId}'`); - continue; - } - if (!required && !isUserColumn) continue; - if (fk) { - const parent = insertedId.get(fk.ref_table); - if (!parent) { - throw new Error( - `${table}.${column.column_name} references ${fk.ref_table}, which was not seeded`, - ); - } - names.push(column.column_name); - values.push(`'${parent}'`); - continue; - } - names.push(column.column_name); - values.push(synthesise(column.udt_name, enums, `seed-${tag++}`)); - } - - const sql = - names.length === 0 - ? `INSERT INTO "${table}" DEFAULT VALUES ON CONFLICT DO NOTHING` - : `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${values.join(", ")}) ON CONFLICT DO NOTHING`; - await prisma.$executeRawUnsafe(sql); - - const hasId = tableColumns.some((c) => c.column_name === "id"); - if (hasId) { - const rows = await prisma.$queryRawUnsafe>( - `SELECT id FROM "${table}" LIMIT 1`, - ); - if (rows[0]) insertedId.set(table, rows[0].id); - } - } - - return ordered; -} - -/** - * The columns that bind a row of `model` to an account, as SQL identifiers. - * - * `user_id` is the convention. A model that relates two accounts cannot use - * it, and the wipe plan already declares its columns — read them from there - * rather than keeping a second list here, or the two would answer differently - * and this file would be asserting against its own idea of ownership instead - * of the one the route uses. - */ -function ownerColumns(model: string | undefined): string[] { - const declared = model ? WIPE_OWNER_FIELDS[model] : undefined; - if (!declared) return ["user_id"]; - return declared.map((field) => - field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`), - ); -} - -async function countRows( - prisma: PrismaClient, - table: string, - userId?: string, - model?: string, -): Promise { - const predicate = ownerColumns(model) - .map((column) => `"${column}" = $1`) - .join(" OR "); - const rows = userId - ? await prisma.$queryRawUnsafe>( - `SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`, - userId, - ) - : await prisma.$queryRawUnsafe>( - `SELECT count(*)::bigint AS n FROM "${table}"`, - ); - return Number(rows[0].n); -} - async function managedProfileLockIsHeld( prisma: PrismaClient, profileId: string, diff --git a/tests/integration/wipe-seeder.ts b/tests/integration/wipe-seeder.ts new file mode 100644 index 000000000..c23514fc9 --- /dev/null +++ b/tests/integration/wipe-seeder.ts @@ -0,0 +1,265 @@ +/** + * Schema-driven seeding + counting for the two wipe integration guards. + * + * The whole defect class here is about rows that SURVIVE, so a mocked Prisma + * proves nothing: it can only show that the calls a route makes were made. + * Both guards therefore seed a row into every table in the schema, run their + * wipe against real Postgres, and then ask the database what is left. + * + * The seeding is driven by `information_schema` and `pg_constraint` rather + * than a hand-written fixture, for the same reason the wipe list is derived + * rather than hand-written: a fixture written today covers the schema of + * today. A table added next year is seeded automatically and therefore + * asserted automatically. + * + * It lives here rather than in either test file because the account wipe and + * the admin wipe have to be measured with the same ruler. Two copies would + * drift, and the copy that drifted would be the one whose route was wrong. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { PrismaClient } from "@/generated/prisma/client"; +import { WIPE_OWNER_FIELDS } from "@/lib/data-wipe/wipe-plan"; + +// ── schema.prisma model → table name ──────────────────────────────────────── + +export function modelTableMap(): Map { + const schema = readFileSync( + join(process.cwd(), "prisma", "schema.prisma"), + "utf8", + ); + const map = new Map(); + for (const block of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) { + const mapped = /@@map\("([^"]+)"\)/.exec(block[2]); + map.set(block[1], mapped ? mapped[1] : block[1]); + } + return map; +} + +// ── generic seeder ────────────────────────────────────────────────────────── + +interface ColumnRow { + table_name: string; + column_name: string; + is_nullable: string; + column_default: string | null; + udt_name: string; +} + +interface ForeignKeyRow { + table_name: string; + column_name: string; + ref_table: string; + ref_column: string; + cascades: boolean; +} + +/** Tables the seeder never writes: Prisma's own ledger, and the account row. */ +const SEED_SKIP = new Set(["_prisma_migrations", "users"]); + +function synthesise(udt: string, enums: Map, tag: string) { + if (udt.startsWith("_")) return `'{}'::${udt.slice(1)}[]`; + switch (udt) { + case "text": + case "varchar": + case "bpchar": + return `'${tag}'`; + case "int2": + case "int4": + case "int8": + case "numeric": + case "float4": + case "float8": + return "1"; + case "bool": + return "false"; + case "timestamp": + case "timestamptz": + case "date": + return "now()"; + case "bytea": + return "'\\x00'::bytea"; + case "json": + case "jsonb": + return `'{}'::${udt}`; + case "uuid": + return "gen_random_uuid()"; + default: { + const label = enums.get(udt); + if (label) return `'${label}'::"${udt}"`; + throw new Error(`seeder has no value for column type "${udt}"`); + } + } +} + +/** + * Insert one row into every table in `public`, parents first, wiring every + * foreign key to the row already created for its target and every `user_id` to + * the account under test. + */ +export async function seedEveryTable( + prisma: PrismaClient, + userId: string, +): Promise { + const columns = await prisma.$queryRaw` + SELECT table_name, column_name, is_nullable, column_default, udt_name + FROM information_schema.columns + WHERE table_schema = 'public'`; + + const foreignKeys = await prisma.$queryRaw` + SELECT con.conrelid::regclass::text AS table_name, + att.attname AS column_name, + con.confrelid::regclass::text AS ref_table, + ratt.attname AS ref_column, + con.confdeltype = 'c' AS cascades + FROM pg_constraint con + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true + JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS rk(attnum, ord) + ON rk.ord = ck.ord + JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ck.attnum + JOIN pg_attribute ratt ON ratt.attrelid = con.confrelid AND ratt.attnum = rk.attnum + WHERE con.contype = 'f' AND con.connamespace = 'public'::regnamespace`; + + const enumRows = await prisma.$queryRaw< + Array<{ typname: string; enumlabel: string }> + >` + SELECT t.typname, e.enumlabel + FROM pg_type t + JOIN pg_enum e ON e.enumtypid = t.oid + ORDER BY t.typname, e.enumsortorder`; + const enums = new Map(); + for (const row of enumRows) { + if (!enums.has(row.typname)) enums.set(row.typname, row.enumlabel); + } + + const byTable = new Map(); + for (const column of columns) { + if (SEED_SKIP.has(column.table_name)) continue; + const bucket = byTable.get(column.table_name) ?? []; + bucket.push(column); + byTable.set(column.table_name, bucket); + } + + const fkByTable = new Map(); + for (const fk of foreignKeys) { + const bucket = fkByTable.get(fk.table_name) ?? []; + bucket.push(fk); + fkByTable.set(fk.table_name, bucket); + } + + // Parents before children. A self-reference or an optional cycle is skipped + // by only ordering on the columns the insert actually fills. + const pending = new Set(byTable.keys()); + const ordered: string[] = []; + while (pending.size > 0) { + let progressed = false; + for (const table of [...pending]) { + const blocked = (fkByTable.get(table) ?? []).some( + (fk) => fk.ref_table !== table && pending.has(fk.ref_table), + ); + if (blocked) continue; + ordered.push(table); + pending.delete(table); + progressed = true; + } + if (!progressed) { + throw new Error( + `foreign-key cycle among: ${[...pending].sort().join(", ")}`, + ); + } + } + + const insertedId = new Map(); + let tag = 0; + + for (const table of ordered) { + const tableColumns = byTable.get(table)!; + const fks = fkByTable.get(table) ?? []; + const names: string[] = []; + const values: string[] = []; + + for (const column of tableColumns) { + const fk = fks.find((f) => f.column_name === column.column_name); + const required = + column.is_nullable === "NO" && column.column_default === null; + const isUserColumn = column.column_name === "user_id"; + + if (fk?.ref_table === "users") { + // Always bind to the account under test, nullable or not — a NULL + // owner would put the row outside the wipe and make the assertion + // vacuous. + names.push(column.column_name); + values.push(`'${userId}'`); + continue; + } + if (!required && !isUserColumn) continue; + if (fk) { + const parent = insertedId.get(fk.ref_table); + if (!parent) { + throw new Error( + `${table}.${column.column_name} references ${fk.ref_table}, which was not seeded`, + ); + } + names.push(column.column_name); + values.push(`'${parent}'`); + continue; + } + names.push(column.column_name); + values.push(synthesise(column.udt_name, enums, `seed-${tag++}`)); + } + + const sql = + names.length === 0 + ? `INSERT INTO "${table}" DEFAULT VALUES ON CONFLICT DO NOTHING` + : `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(", ")}) VALUES (${values.join(", ")}) ON CONFLICT DO NOTHING`; + await prisma.$executeRawUnsafe(sql); + + const hasId = tableColumns.some((c) => c.column_name === "id"); + if (hasId) { + const rows = await prisma.$queryRawUnsafe>( + `SELECT id FROM "${table}" LIMIT 1`, + ); + if (rows[0]) insertedId.set(table, rows[0].id); + } + } + + return ordered; +} + +/** + * The columns that bind a row of `model` to an account, as SQL identifiers. + * + * `user_id` is the convention. A model that relates two accounts cannot use + * it, and the wipe plan already declares its columns — read them from there + * rather than keeping a second list here, or the two would answer differently + * and this file would be asserting against its own idea of ownership instead + * of the one the route uses. + */ +export function ownerColumns(model: string | undefined): string[] { + const declared = model ? WIPE_OWNER_FIELDS[model] : undefined; + if (!declared) return ["user_id"]; + return declared.map((field) => + field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`), + ); +} + +export async function countRows( + prisma: PrismaClient, + table: string, + userId?: string, + model?: string, +): Promise { + const predicate = ownerColumns(model) + .map((column) => `"${column}" = $1`) + .join(" OR "); + const rows = userId + ? await prisma.$queryRawUnsafe>( + `SELECT count(*)::bigint AS n FROM "${table}" WHERE ${predicate}`, + userId, + ) + : await prisma.$queryRawUnsafe>( + `SELECT count(*)::bigint AS n FROM "${table}"`, + ); + return Number(rows[0].n); +} From 0eac84aa953b6ba0ba32e205fa9faefb66d4114c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:05:44 +0200 Subject: [PATCH 06/24] fix(geo): read the offline databases first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver queried the online provider first and read the mounted GeoLite2 databases only when that missed. Every document describing the feature said the opposite, including the self-hosting runbook and the admin notification that tells an operator their login IPs are leaving the server. So an operator who mounted the databases in order to stop that egress kept all of it, and nothing said so: the location resolved, the admin overview filled in, and the only difference was where the address had been. Now the local databases answer and the provider only ever sees an address they could not place. A host with the City database mounted and a hit makes no request at all — including when the ASN database is absent, because reaching out for a carrier string would send the address off the host anyway, which is the thing the mount was for. IP_GEO_LOOKUP_DISABLED=1 remains the full off-switch: it disables the online step only, so an offline hit still resolves and an offline miss returns nothing rather than going online. The wide-event warning on the fallback path named ipwho.is even when the operator had pointed IP_GEO_LOOKUP_URL somewhere else; it now names the host the runtime actually queries. The admin notification copy needed no change — it fires only when the databases are absent, and then every lookup does go online. The two cases in geo-asn.test.ts that pinned the old order are the watched red for this change. --- docs/self-hosting/geolite2.md | 26 ++- src/lib/__tests__/geo-asn.test.ts | 25 ++- src/lib/__tests__/geo-offline-first.test.ts | 234 ++++++++++++++++++++ src/lib/geo.ts | 104 +++++---- 4 files changed, 321 insertions(+), 68 deletions(-) create mode 100644 src/lib/__tests__/geo-offline-first.test.ts diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md index b9dacd0a0..4e69c1c3e 100644 --- a/docs/self-hosting/geolite2.md +++ b/docs/self-hosting/geolite2.md @@ -3,15 +3,15 @@ The admin login overview shows a coarse `City, CC` location and a carrier next to every sign-in. Two tiers can produce that: -- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, - keyless, and on by default — which also means every login IP address of - every account on the instance leaves the host. - **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read from a local directory. The ASN database is the authoritative source - for the carrier and the AS number. Note that the resolver is - online-first: having the databases in place is not by itself enough to - stop the outbound lookup, see "Turning the online lookup off entirely" - below. + for the carrier and the AS number. The resolver reads these first: an + address the local databases can place never leaves the host. +- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, + keyless, and the only tier on a host without the databases — which + means every login IP address of every account on that instance leaves + the host. With the databases mounted it sees only the addresses they + could not place, and you can switch it off entirely (below). The offline tier is optional and absent by default. The published image only carries the databases when the image build had a MaxMind licence key @@ -73,8 +73,8 @@ is the subject of this page. ## Turning the online lookup off entirely -Once the offline tier resolves, the third-party lookup is redundant. To -stop all IP egress: +With the databases mounted, the third-party lookup already only sees +addresses they could not place. To remove that remainder too: ```env IP_GEO_LOOKUP_DISABLED="1" @@ -127,6 +127,8 @@ the value is in `.env` but not on the `environment:` whitelist — compare against `docker-compose.yml`. **The carrier column is empty.** The carrier also comes from the online -provider's ISP field, so it is normally filled even without the ASN -database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no -`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty. +provider's ISP field, but the online lookup only runs for an address the +City database could not place. On a host with `GeoLite2-City.mmdb` and no +`GeoLite2-ASN.mmdb`, a location that resolves locally has no carrier to +go with it — mount the ASN database too. With neither database, the +online provider fills both, unless `IP_GEO_LOOKUP_DISABLED="1"` is set. diff --git a/src/lib/__tests__/geo-asn.test.ts b/src/lib/__tests__/geo-asn.test.ts index f10903253..437afe6cc 100644 --- a/src/lib/__tests__/geo-asn.test.ts +++ b/src/lib/__tests__/geo-asn.test.ts @@ -188,11 +188,12 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => { }); }); -// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the -// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback -// consulted only when the online lookup misses (provider down / non-ok) or -// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. -describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => { +// Offline-first resolver: the GeoLite2 City MMDB answers when it is mounted +// and holds the address, and the `ipwho.is` HTTPS lookup only sees what it +// could not place. Between v1.18.10 and v1.37 it ran the other way round — +// these two cases pinned that order, and they are the reason a host with the +// databases mounted still sent every login IP to a third party. +describe("lookupIpLocation — offline-first city resolver", () => { // Helper: a non-ok online response forces the offline fallback to run. function onlineMiss(): Response { return new Response("", { @@ -201,7 +202,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => }); } - it("prefers the online provider even when the offline DB is present", async () => { + it("prefers the offline DB over the online provider, and sends nothing", async () => { readerState.cityExists = true; readerState.city = () => ({ city: { names: { de: "München", en: "Munich" } }, @@ -221,12 +222,13 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); - // Online wins by default; the offline München record is not consulted. - expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE"); - expect(fetchSpy).toHaveBeenCalledTimes(1); + // The mounted database answers, so the provider is never asked and the + // address never leaves the host. + expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it("falls back to the offline German exonym when the online lookup misses", async () => { + it("uses the German exonym the offline DB carries rather than the provider's fold", async () => { readerState.cityExists = true; readerState.city = () => ({ city: { names: { de: "München", en: "Munich" } }, @@ -237,8 +239,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => const { lookupIpLocation } = await import("../geo"); expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); - // Online was tried first (and missed), then the offline tier resolved. - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("offline fallback uses the English city name when the German exonym is absent", async () => { diff --git a/src/lib/__tests__/geo-offline-first.test.ts b/src/lib/__tests__/geo-offline-first.test.ts new file mode 100644 index 000000000..097511712 --- /dev/null +++ b/src/lib/__tests__/geo-offline-first.test.ts @@ -0,0 +1,234 @@ +/** + * The offline tier is consulted FIRST, and a local hit makes no request. + * + * From v1.18.10 until v1.37 the resolver queried the online provider first + * and read the MMDBs only when that missed. Every document describing the + * feature said the opposite — the contributor notes, the self-hosting runbook, + * the admin notification that tells an operator their login IPs are leaving + * the server. So an operator who mounted the databases in order to stop that + * egress kept every bit of it, and nothing said so: the location resolved, the + * admin overview filled in, and the only difference was where the address had + * been. + * + * The assertion that matters is therefore about the fetch that does NOT + * happen. Watched red: restore the old order (online lookup first, offline as + * the fallback) and the first two cases fail on a call count of 1. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// The MMDB readers are stubbed: `loadMmdbReader` only needs the file to +// exist, and what the reader answers is this table. +const cityRows = new Map(); +const asnRows = new Map(); + +vi.mock("mmdb-lib", () => ({ + Reader: class { + private readonly rows: Map; + constructor(buf: Buffer) { + // The two readers are told apart by the stub file's own content, which + // is the only thing distinguishing them at construction time. + this.rows = buf.toString().startsWith("asn") ? asnRows : cityRows; + } + get(ip: string): unknown { + return this.rows.get(ip) ?? null; + } + }, +})); + +// safeFetch's requirePublicHost path runs through undici's own `fetch`. +// Delegate it to the global stub so an outbound call is visible either way. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn(async () => undefined), +})); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findMany: vi.fn(async () => []) } }, +})); + +const ORIGINAL_ENV = { ...process.env }; +let tmpRoot: string; + +function jsonOk(value: unknown): Response { + return new Response( + new TextEncoder().encode(JSON.stringify(value)).buffer as ArrayBuffer, + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +function writeCityDb(): void { + writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub")); +} + +function writeAsnDb(): void { + writeFileSync(join(tmpRoot, "GeoLite2-ASN.mmdb"), Buffer.from("asn-stub")); +} + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.IP_GEO_LOOKUP_URL; + delete process.env.IP_GEO_LOOKUP_DISABLED; + delete process.env.IP_GEO_ALLOW_INSECURE; + tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-offline-first-")); + process.env.GEOLITE2_DIR = tmpRoot; + cityRows.clear(); + asnRows.clear(); + vi.resetModules(); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("geo resolver is offline-first", () => { + it("makes no outbound request when the MMDBs place the address", async () => { + writeCityDb(); + writeAsnDb(); + cityRows.set("8.8.8.8", { + city: { names: { de: "Nürnberg", en: "Nuremberg" } }, + country: { iso_code: "DE" }, + }); + asnRows.set("8.8.8.8", { + autonomous_system_number: 3320, + autonomous_system_organization: "Deutsche Telekom AG", + }); + + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("8.8.8.8")).toEqual({ + location: "Nürnberg, DE", + asn: 3320, + carrier: "Deutsche Telekom AG", + }); + expect( + fetchSpy, + "an address the local databases placed still left the host", + ).not.toHaveBeenCalled(); + }); + + it("makes no outbound request when only the City MMDB is mounted", async () => { + // The ASN database is a separate download and an operator may have only + // the City one. Reaching out for a carrier string would send the address + // off the host anyway, which is the thing the mount was for. + writeCityDb(); + cityRows.set("9.9.9.9", { + city: { names: { en: "Zurich" } }, + country: { iso_code: "CH" }, + }); + + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("9.9.9.9")).toEqual({ + location: "Zurich, CH", + asn: null, + carrier: null, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("falls through to the online provider when the MMDB holds no row", async () => { + writeCityDb(); + writeAsnDb(); + // Databases present, this address simply not in them. + const fetchSpy = vi.fn().mockResolvedValue( + jsonOk({ + success: true, + city: "Berlin", + country_code: "DE", + connection: { asn: 3209, isp: "Vodafone" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("1.1.1.1")).toEqual({ + location: "Berlin, DE", + asn: 3209, + carrier: "Vodafone", + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("keeps the offline ASN authoritative when the location came from online", async () => { + writeCityDb(); + writeAsnDb(); + asnRows.set("1.1.1.1", { + autonomous_system_number: 13335, + autonomous_system_organization: "Cloudflare, Inc.", + }); + const fetchSpy = vi.fn().mockResolvedValue( + jsonOk({ + success: true, + city: "Berlin", + country_code: "DE", + connection: { asn: 3209, isp: "Vodafone" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("1.1.1.1")).toEqual({ + location: "Berlin, DE", + asn: 13335, + carrier: "Cloudflare, Inc.", + }); + }); + + it("leaves the online path unchanged when no MMDB is mounted", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + jsonOk({ success: true, city: "Berlin", country_code: "DE" }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("8.8.8.8")).toEqual({ + location: "Berlin, DE", + asn: null, + carrier: null, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8"); + }); + + it("resolves locally with IP_GEO_LOOKUP_DISABLED set, and never reaches the network without the databases", async () => { + // The kill switch is unchanged by the reordering: it disables step 3 + // only, so an offline hit still resolves and an offline miss returns + // null rather than going online. + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + writeCityDb(); + cityRows.set("8.8.8.8", { + city: { names: { en: "Vienna" } }, + country: { iso_code: "AT" }, + }); + + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect((await lookupIpGeo("8.8.8.8")).location).toBe("Vienna, AT"); + expect((await lookupIpGeo("1.1.1.1")).location).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 81a4591d4..7813c32c9 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -1,18 +1,24 @@ /** * IP geolocation lookup for audit-log enrichment. * - * v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup - * (free, no key) is the DEFAULT path — every self-host resolves a - * location out of the box with no MaxMind licence configured. The - * bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb` - * is an OPTIONAL fallback (perspective: removed later): it is consulted - * only when the online lookup misses (provider down / rate-limited) or - * when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. + * OFFLINE-FIRST resolver. The MaxMind GeoLite2 MMDBs at `/opt/geolite2/` + * (or wherever `GEOLITE2_DIR` points) answer whenever they can, and the + * online provider only ever sees an address they could not place. That + * ordering is the whole reason an operator mounts the databases: a lookup + * that resolves locally sends nothing anywhere. The databases stay OPTIONAL + * — a self-host without them resolves every location through the online + * provider, which needs no licence and works out of the box. * - * 1. Private / loopback / opt-out → null (no lookup). + * 1. Private / loopback → null (no lookup). * 2. Per-IP cache hit → return immediately. - * 3. Online `ipwho.is` lookup → return on success. - * 4. Offline MMDB present → local read fallback on an online miss. + * 3. Offline MMDB present → local read; on a hit the request ends here. + * 4. Online lookup → only on an offline miss, and only when + * `IP_GEO_LOOKUP_DISABLED` is not set. + * + * It was the other way round from v1.18.10 until v1.37: online first, the + * local databases as the fallback. The documentation described this order the + * whole time, so a host with the MMDBs mounted was sending every login IP to a + * third party while its own runbook said it was not. * * The legacy contract still holds: `lookupIpLocation` returns a * `"City, CC"` string or `null`, never throws. `lookupIpGeo` is the @@ -121,8 +127,9 @@ const PRIVATE_IP = // the ip-api.com shape (`status` + `countryCode`), so operators whose egress // ipwho.is rejects can point `IP_GEO_LOOKUP_URL` at `https://ip-api.com/json` // (or any keyed provider matching one of those shapes) with no code change. -// The offline GeoLite2 MMDB tier stays OPTIONAL — consulted only when the -// online lookup misses, and skipped entirely when the databases are absent. +// The offline GeoLite2 MMDB tier stays OPTIONAL, but it goes FIRST when it is +// there: this provider is consulted only for an address the local databases +// could not place, and not at all on a host that has them and hits. const DEFAULT_GEO_URL = "https://ipwho.is"; // The host portion of DEFAULT_GEO_URL — the provider name the admin status @@ -261,10 +268,10 @@ export function __resetGeoLite2CacheForTests(): void { // // The CI workflow drops an `.empty` marker into the geo asset directory // when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in -// which case the City + ASN MMDBs are absent and every lookup hits the -// online fallback. `offlineGeoReady()` is the canonical truth used by -// `/api/version` and the admin status surface so the two paths cannot -// disagree. +// which case the City + ASN MMDBs are absent and every lookup goes to the +// online provider. `offlineGeoReady()` is the canonical truth used by +// `/api/version`, the admin status surface, and the resolver's own +// offline-first branch, so the three cannot disagree. // // `notifiedThisProcess` is a module-level latch so the admin only // receives the "offline geo is disabled" notification once per worker @@ -313,7 +320,7 @@ async function notifyOfflineGeoUnavailable(): Promise { return; } getEvent()?.addWarning( - "geo: offline databases unavailable, falling back to ipwho.is — notifying admins", + `geo: offline databases unavailable, every lookup goes online to ${resolveGeoProviderHost()} — notifying admins`, ); for (const admin of admins) { await dispatchLocalisedNotification({ @@ -507,15 +514,24 @@ async function lookupIpOnline(ip: string): Promise { /** * Resolve location + carrier + AS number for an IP in a single pass. * - * v1.25.8 — the online provider lookup now also yields the carrier, so a - * self-host without the optional offline GeoLite2-ASN MMDB still surfaces a - * network operator in the admin sign-in overview. The merge order: + * OFFLINE FIRST. The order below is the one the documentation has always + * described and the code did not do: for two releases the online provider was + * queried first and the local databases were the fallback, so an operator who + * had mounted the MMDBs precisely so that no address would leave the host was + * sending every login IP to a third party anyway. The MMDBs answer; the online + * provider only sees an address the local databases could not place. * - * 1. Online lookup (one request) → location + carrier + ASN. - * 2. Offline City MMDB → location fallback on an online miss (if present). - * 3. Offline ASN MMDB → AUTHORITATIVE carrier/ASN when present (it carries - * the canonical org name the short-label folder expects); the online - * carrier is the fallback so a host without the DBs still resolves one. + * 1. Offline ASN MMDB → carrier + AS number when configured (authoritative: + * it carries the canonical org name the short-label folder expects). + * 2. Offline City MMDB → location. Present and resolving means the request + * ends here, with no egress at all. + * 3. Online lookup → only on an offline miss (databases absent, unreadable, + * or holding no row for this address). Fills the location, and the + * carrier/ASN when the offline tier had none, so a host without the + * databases still surfaces an operator in the admin sign-in overview. + * + * `IP_GEO_LOOKUP_DISABLED=1` remains the full off-switch for step 3 — with it + * set, an offline miss resolves to null rather than reaching the network. * * Never throws; the auth-audit caller is fire-and-forget. The resolved * record (including all-null misses) is cached per IP for a bounded TTL. @@ -527,28 +543,28 @@ export async function lookupIpGeo(ip: string | null): Promise { const cached = getCachedGeo(ip); if (cached) return cached; - // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup - // is the primary resolver for every self-host: it needs no MaxMind licence - // and resolves out of the box. The bundled GeoLite2 offline tier is an - // OPTIONAL fallback, so a missing offline tier is the expected baseline. - const online = await lookupIpOnline(ip); - let location = online?.location ?? null; - let asn = online?.asn ?? null; - let carrier = online?.carrier ?? null; - - // Offline City MMDB fills the location when the online tier missed it. - if (!location && offlineGeoReady()) { - location = lookupIpLocationOffline(ip); - } - - // Offline ASN MMDB is authoritative for the carrier/ASN when configured; - // prefer it over the online ISP string but keep the online value as the - // fallback. `lookupIpAsn` also fires the one-shot "no resolver" admin alert - // when neither an offline ASN reader nor the offline tier is present. + // Offline ASN MMDB first, and unconditionally: it is a local file read, and + // it is also what fires the one-shot "no offline resolver" admin alert when + // neither an ASN reader nor the offline tier is present. + let asn: number | null = null; + let carrier: string | null = null; const offlineAsn = lookupIpAsn(ip); if (offlineAsn) { asn = offlineAsn.asn; - if (offlineAsn.carrier) carrier = offlineAsn.carrier; + carrier = offlineAsn.carrier; + } + + let location = offlineGeoReady() ? lookupIpLocationOffline(ip) : null; + + // The address the local databases could not place is the only one that + // travels. A host with the MMDBs mounted and a hit makes no request at all. + if (!location) { + const online = await lookupIpOnline(ip); + if (online) { + location = online.location; + if (asn === null) asn = online.asn; + if (!carrier) carrier = online.carrier; + } } const resolved: GeoResolved = { location, asn, carrier }; From cb12adcccf906bed8659c3c4a85391bed3d91994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:06:24 +0200 Subject: [PATCH 07/24] docs(security): correct the image publish trigger The supply-chain section said the publish workflow runs on every push to main. That trigger was removed in v1.4.34, when the squash-merge and the release tag each kicked off their own build of the same SHA; the workflow has fired on v* tags only since. Pull requests still build the image, and do not publish it. --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 787a2c731..5210c4ba8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -64,7 +64,7 @@ For more details, see the [security documentation](https://docs.healthlog.dev/se HealthLog Docker images are built and published from this repository's CI: -- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag +- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — publishes on every `v*` tag; pull requests build the image without publishing it - **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog) - **Architectures**: `linux/amd64` and `linux/arm64` - **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition From 9e44c8754ec4c177257c5afdaa71c33c125066bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:08:27 +0200 Subject: [PATCH 08/24] feat(mood): carry the five level-A values through backup, restore and CSV - the backup select and the payload carry all five, on both the portable and the disaster-recovery arm - the restore writes what the file holds and nothing else: a file from before these columns restores them empty rather than deriving them - the CSV appends them after the existing columns so nothing shifts under a spreadsheet, and an unanswered value is an empty cell rather than a zero, which is a real answer on this scale - the round trip asserts each of the five by value, including a zero --- .../api/admin/backups/[id]/restore/route.ts | 9 ++++ src/lib/__tests__/export.test.ts | 48 +++++++++++++++++++ src/lib/export.ts | 15 ++++++ src/lib/export/full-backup-payload.ts | 14 ++++++ src/lib/validations/backup.ts | 9 ++++ .../mood-backup-round-trip.test.ts | 31 ++++++++++++ 6 files changed, 126 insertions(+) diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 8ede74cac..a6d393133 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -774,6 +774,15 @@ const handler = apiHandler( date: entry.date, mood: entry.mood, score: entry.score, + // The five level-A values. `?? null` rather than a derivation: + // a file that carries none is a file from before they existed, + // and filling them in here would put a number the person never + // gave into a restored row and call it their answer. + moodA1: entry.a1 ?? null, + stressA2: entry.a2 ?? null, + energyA3: entry.a3 ?? null, + connectionA4: entry.a4 ?? null, + stabilityA5: entry.a5 ?? null, tags: entry.tags ?? null, // The note comes back the way every other dual-column note // does: ciphertext verbatim when the file carries it, and a diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index c4a2a7ef1..8fcb32467 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -400,3 +400,51 @@ describe("formatMoodEntriesForExport with userTz", () => { expect(result[0].loggedAt).toBe("2026-05-15T14:30:00-04:00"); }); }); + +describe("formatMoodEntriesForExport level-A columns", () => { + const base = { + date: "2026-05-15", + mood: "OKAY", + score: 3, + tags: null, + source: "MANUAL", + moodLoggedAt: new Date("2026-05-15T18:30:00Z"), + }; + + it("appends the five values without moving an existing column", () => { + const csv = toCSV( + formatMoodEntriesForExport([ + { ...base, moodA1: 4, stressA2: 9, energyA3: 0 }, + ]), + ); + const [header, row] = csv.split("\n"); + // The pre-existing columns keep their order and their positions. Somebody's + // spreadsheet has a formula pointed at column D. + expect(header).toBe( + "date,mood,score,tags,note,source,loggedAt,a1,a2,a3,a4,a5", + ); + expect(row.split(",").slice(0, 3)).toEqual(["2026-05-15", "OKAY", "3"]); + }); + + it("writes an empty cell for an unanswered value and a zero for a zero", () => { + const csv = toCSV( + formatMoodEntriesForExport([ + { ...base, moodA1: 4, stressA2: 9, energyA3: 0 }, + ]), + ); + const cells = csv.split("\n")[1].split(","); + // a1, a2, a3, a4, a5 — the last two were never answered. + expect(cells.slice(-5)).toEqual(["4", "9", "0", "", ""]); + }); + + it("leaves every level-A cell empty for an entry written before the columns", () => { + const csv = toCSV(formatMoodEntriesForExport([base])); + expect(csv.split("\n")[1].split(",").slice(-5)).toEqual([ + "", + "", + "", + "", + "", + ]); + }); +}); diff --git a/src/lib/export.ts b/src/lib/export.ts index 5ed09e5a5..0dd083bab 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -336,6 +336,12 @@ export function formatMoodEntriesForExport( note?: string | null; source: string; moodLoggedAt: Date; + /** The five level-A values, absent on entries written before they existed. */ + moodA1?: number | null; + stressA2?: number | null; + energyA3?: number | null; + connectionA4?: number | null; + stabilityA5?: number | null; }>, userTz?: string, ): ExportableRecord[] { @@ -347,5 +353,14 @@ export function formatMoodEntriesForExport( note: e.note ?? "", source: e.source, loggedAt: formatTimestamp(e.moodLoggedAt, userTz), + // Appended after the existing columns rather than slotted in beside + // `score`, because this file lands in somebody's spreadsheet and a column + // that moves breaks every formula pointed at it. An unanswered value is an + // empty cell, not a zero: zero is a real answer on this scale. + a1: e.moodA1 ?? "", + a2: e.stressA2 ?? "", + a3: e.energyA3 ?? "", + a4: e.connectionA4 ?? "", + a5: e.stabilityA5 ?? "", })); } diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9f4506de5..0d3375c99 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -117,6 +117,11 @@ const MOOD_ENTRY_BACKUP_SELECT = { date: true, mood: true, score: true, + moodA1: true, + stressA2: true, + energyA3: true, + connectionA4: true, + stabilityA5: true, tags: true, note: true, noteEncrypted: true, @@ -480,6 +485,15 @@ export async function buildFullBackupPayload( date: moodEntry.date, mood: moodEntry.mood, score: moodEntry.score, + // The five level-A values, under the keys the write path takes. Both + // arms carry them: they are the entry's own answers, not a storage + // detail, and a portable export that dropped them would hand back a + // file describing the day less well than the row it came from. + a1: moodEntry.moodA1, + a2: moodEntry.stressA2, + a3: moodEntry.energyA3, + a4: moodEntry.connectionA4, + a5: moodEntry.stabilityA5, tags: moodEntry.tags, source: moodEntry.source, loggedAt: moodEntry.moodLoggedAt.toISOString(), diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 3f1a981e1..374847ed1 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -254,6 +254,15 @@ const moodEntrySchema = z date: z.string().min(1), mood: z.string().min(1), score: z.number().int().min(0).max(10), + // The five level-A values. Optional and nullable: a file written before + // they existed carries none, and an entry whose sliders were never touched + // carries nulls. Absent restores as NULL, never as a defaulted midpoint — + // a restore that invents an answer is worse than one that admits none. + a1: z.number().int().min(0).max(10).nullable().optional(), + a2: z.number().int().min(0).max(10).nullable().optional(), + a3: z.number().int().min(0).max(10).nullable().optional(), + a4: z.number().int().min(0).max(10).nullable().optional(), + a5: z.number().int().min(0).max(10).nullable().optional(), tags: moodEntryTagsSchema, // The free text, in both storage shapes. A portable export carries the // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts index 42c43e088..677b40f6d 100644 --- a/tests/integration/mood-backup-round-trip.test.ts +++ b/tests/integration/mood-backup-round-trip.test.ts @@ -186,6 +186,15 @@ describe("mood backup round trip", () => { date: "2026-07-19", mood: "OKAY", score: 3, + // All five level-A values, and deliberately not the ones the label + // would derive: a restore that re-derived them instead of carrying + // them would look right on this row and be wrong on every row where + // the person disagreed with their own five-point pick. + moodA1: 4, + stressA2: 9, + energyA3: 2, + connectionA4: 6, + stabilityA5: 0, tags: JSON.stringify(["free-text-tag"]), note: null, noteEncrypted: encryptNote(NOTE), @@ -268,6 +277,14 @@ describe("mood backup round trip", () => { expect(restored.updatedAt).toEqual(UPDATED_AT); expect(restored.date).toBe("2026-07-19"); expect(restored.score).toBe(3); + // Each of the five asserted by value. Zero is included on purpose: it is a + // real answer on this scale, and a carrier that treated it as absence + // would round-trip it as NULL and pass a truthiness check. + expect(restored.moodA1).toBe(4); + expect(restored.stressA2).toBe(9); + expect(restored.energyA3).toBe(2); + expect(restored.connectionA4).toBe(6); + expect(restored.stabilityA5).toBe(0); expect(restored.tags).toBe(JSON.stringify(["free-text-tag"])); expect(restored.deletedAt).toBeNull(); @@ -276,6 +293,11 @@ describe("mood backup round trip", () => { }); expect(tombstone.deletedAt).toEqual(DELETED_AT); expect(tombstone.syncVersion).toBe(5); + // The tombstone was written without level-A values and comes back without + // them. A restore filling these in would put an answer nobody gave into a + // row the person had already deleted. + expect(tombstone.moodA1).toBeNull(); + expect(tombstone.stressA2).toBeNull(); const links = await prisma.moodEntryTagLink.findMany({ where: { moodEntryId: "mood-rt-entry" }, @@ -338,6 +360,7 @@ describe("mood backup round trip", () => { expect(parsed.moodEntries[0].structuredTags).toEqual([]); expect(parsed.moodEntries[0].tz).toBeUndefined(); expect(parsed.moodEntries[0].syncVersion).toBeUndefined(); + expect(parsed.moodEntries[0].a1).toBeUndefined(); const response = await restoreFromPayload( prisma, @@ -360,6 +383,14 @@ describe("mood backup round trip", () => { expect(restored.score).toBe(4); expect(restored.externalId).toBe("moodlog-legacy"); expect(restored.deletedAt).toBeNull(); + // A file from before these columns carries no answer for them, and the + // restore does not invent one. `GUT` would derive to 7; a restore that + // derived instead of carrying would write a number this file never held. + expect(restored.moodA1).toBeNull(); + expect(restored.stressA2).toBeNull(); + expect(restored.energyA3).toBeNull(); + expect(restored.connectionA4).toBeNull(); + expect(restored.stabilityA5).toBeNull(); const links = await prisma.moodEntryTagLink.findMany({ where: { moodEntryId: "mood-rt-legacy" }, From 7eaede257e685a2ab02668be1319604b6ae90ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:12:52 +0200 Subject: [PATCH 09/24] feat(ui): add a slider primitive for bounded numeric entry - Slider wraps the Radix control the way Switch and Progress already do; no new dependency - SliderField adds the label, the value and the two scale anchors, which are what make a 7 mean anything - a null value renders as unanswered rather than as the value the parked thumb sits on, and zero renders as the answer it is - the announced text carries the scale, not only the number --- src/components/ui/__tests__/slider.test.tsx | 120 ++++++++++++++ src/components/ui/slider.tsx | 167 ++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/components/ui/__tests__/slider.test.tsx create mode 100644 src/components/ui/slider.tsx diff --git a/src/components/ui/__tests__/slider.test.tsx b/src/components/ui/__tests__/slider.test.tsx new file mode 100644 index 000000000..1908fc897 --- /dev/null +++ b/src/components/ui/__tests__/slider.test.tsx @@ -0,0 +1,120 @@ +/** + * `` / `` — the shape and the unanswered state. + * + * The suite renders server-side (`@testing-library/react` is not a dependency + * here), so nothing below drags a thumb or presses an arrow key; the keyboard + * behaviour is Radix's own contract and the manual pass covers it. What is + * proven here is what the component decides: that an unanswered field renders + * no number and announces itself as unanswered rather than as the value its + * parked thumb sits on, that a zero is rendered as an answer, and that the + * anchors reach the markup as muted legend text. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { Slider, SliderField } from "../slider"; + +function field(props: Partial> = {}) { + return renderToStaticMarkup( + {}} + emptyLabel="nicht angegeben" + valueText="7 von 10" + lowAnchor="vollkommen entspannt" + highAnchor="extrem belastet" + {...props} + />, + ); +} + +describe("", () => { + it("renders the Radix parts under their slots", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="slider"'); + expect(html).toContain('data-slot="slider-track"'); + expect(html).toContain('data-slot="slider-range"'); + expect(html).toContain('data-slot="slider-thumb"'); + }); + + it("announces the caller's text instead of the bare number", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain( + 'aria-valuetext="6 von 10: sehr unangenehm bis sehr angenehm"', + ); + expect(html).toContain('aria-label="Stimmung"'); + // The thumb is a slider to assistive tech, with the bounds published. + // `aria-valuenow` is deliberately not asserted: Radix sets it after mount, + // so it is absent from server markup and a test demanding it here would be + // measuring the renderer rather than the component. + expect(html).toContain('role="slider"'); + expect(html).toContain('aria-valuemin="0"'); + expect(html).toContain('aria-valuemax="10"'); + expect(html).toContain('aria-orientation="horizontal"'); + }); +}); + +describe("", () => { + it("renders the label, the value and both anchors", () => { + const html = field(); + expect(html).toContain("Stress"); + expect(html).toContain("vollkommen entspannt"); + expect(html).toContain("extrem belastet"); + expect(html).toContain('data-slot="slider-field-value"'); + expect(html).toContain(">7<"); + }); + + it("puts the answer in content colour and the anchors in muted", () => { + const html = field(); + expect(html).toContain("tabular-nums"); + expect(html).toContain("text-foreground"); + expect(html).toContain("text-muted-foreground"); + expect(html).toContain("text-xs"); + }); + + it("renders an unanswered field as unanswered, not as its parked value", () => { + const html = field({ value: null, valueText: "should not be announced" }); + expect(html).toContain('data-unset="true"'); + expect(html).toContain("nicht angegeben"); + // The thumb sits at the low end so the track reads empty, but nothing + // claims that low end as the person's answer. + expect(html).toContain('aria-valuetext="nicht angegeben"'); + expect(html).not.toContain("should not be announced"); + }); + + it("renders a zero as an answer", () => { + // Zero is a real point on this scale. A field that treated it as absence + // would show the empty label and lose an answer somebody gave. + const html = field({ value: 0, valueText: "0 von 10" }); + expect(html).not.toContain('data-unset="true"'); + expect(html).not.toContain("nicht angegeben"); + expect(html).toContain('aria-valuetext="0 von 10"'); + }); + + it("offers to clear an answer only when there is one to clear", () => { + const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" }); + expect(set).toContain('data-slot="slider-field-clear"'); + expect(set).toContain("zurücksetzen"); + + const unanswered = field({ + value: null, + onClear: () => {}, + clearLabel: "zurücksetzen", + }); + expect(unanswered).not.toContain('data-slot="slider-field-clear"'); + + const noHandler = field(); + expect(noHandler).not.toContain('data-slot="slider-field-clear"'); + }); +}); diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 000000000..53ed01642 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,167 @@ +"use client"; + +import * as React from "react"; +import { Slider as SliderPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +/** + * Numeric range entry. + * + * The alternative this replaces is eleven discrete buttons, which is what the + * rated-factor picker does for a 1-5 scale. That pattern does not survive the + * jump to 0-10: eleven 32 px targets with 4 px gaps come to roughly 392 px + * before the label, and the narrow phone this app is mostly used on is 390 px + * wide. A slider answers the same question in a fixed width at any scale, and + * arrow keys move it a step at a time, which eleven buttons never did. + * + * `Slider` is the bare control. `SliderField` is the labelled row around it — + * name on the left, value on the right, the two scale anchors underneath — and + * it is what a capture surface should reach for, because the anchors are what + * make a 7 mean anything. + */ +function Slider({ + className, + valueText, + thumbLabel, + ...props +}: React.ComponentProps & { + /** + * What a screen reader announces instead of the bare number. A slider that + * says "6" tells somebody who cannot see the anchors nothing at all; the + * caller passes the number together with what the ends of the scale mean. + */ + valueText?: string; + /** Accessible name for the thumb when the field label is not adjacent. */ + thumbLabel?: string; +}) { + return ( + + + + + + + ); +} + +/** + * A labelled slider with its scale anchors. + * + * `value` is nullable and the null is load-bearing: a question nobody answered + * is not a question answered in the middle. An unset field renders no number + * and fills no track, and only an interaction commits a value. `onClear`, when + * given, renders the control that takes an answer back — without it a user who + * nudged a slider by accident can never return the field to empty. + */ +function SliderField({ + label, + value, + onValueChange, + onClear, + clearLabel, + emptyLabel, + valueText, + lowAnchor, + highAnchor, + min = 0, + max = 10, + step = 1, + className, + ...props +}: { + label: string; + value: number | null; + onValueChange: (value: number) => void; + onClear?: () => void; + clearLabel?: string; + /** Rendered in place of the number while the field is unanswered. */ + emptyLabel: string; + /** Announced by a screen reader; carries the anchors, not only the number. */ + valueText?: string; + lowAnchor: string; + highAnchor: string; + min?: number; + max?: number; + step?: number; + className?: string; +} & Omit< + React.ComponentProps, + "value" | "onValueChange" | "min" | "max" | "step" | "className" +>) { + const unset = value === null; + return ( +
+
+ {/* Content colour for the label and the value, muted for the anchors + below: the anchors are the legend, the value is the answer. */} + {label} + + + {unset ? emptyLabel : value} + + {!unset && onClear ? ( + + ) : null} + +
+ onValueChange(next[0])} + min={min} + max={max} + step={step} + thumbLabel={label} + valueText={unset ? emptyLabel : valueText} + {...props} + /> +
+ {lowAnchor} + {highAnchor} +
+
+ ); +} + +export { Slider, SliderField }; From 97582dedc0774aae1cbe116a455229f71b572e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:24:04 +0200 Subject: [PATCH 10/24] feat(mood): capture the five values without lengthening the quick check-in - the five faces are untouched: pick one, save, done in a tap - a closed section below them opens onto five sliders; picking a face suggests a pleasantness value and moving that slider takes the suggestion over, while the face selection stays where it is - an untouched slider writes nothing, and clearing one takes the answer back rather than leaving a middle value behind - the list's edit dialog shows and changes the same five, so nothing is written on one screen and invisible on the other - correcting an entry no longer re-derives pleasantness when the label did not move, which used to overwrite a hand-set value on any edit - factor ratings are no longer offered on a new entry and nothing was deleted: existing ratings still render, edit and feed the crosstab - six locales, and the dimension table's keys are declared dynamic --- messages/de.json | 35 +++- messages/en.json | 35 +++- messages/es.json | 35 +++- messages/fr.json | 35 +++- messages/it.json | 35 +++- messages/pl.json | 35 +++- prisma/schema.prisma | 10 + src/__tests__/i18n-reverse-coverage.test.ts | 6 + src/app/api/mood-entries/[id]/route.ts | 9 +- .../mood/__tests__/mood-qol-fixes.test.tsx | 58 ++++-- src/components/mood/mood-form.tsx | 82 ++++---- src/components/mood/mood-level-a-fields.tsx | 186 ++++++++++++++++++ src/components/mood/mood-list.tsx | 36 ++++ 13 files changed, 530 insertions(+), 67 deletions(-) create mode 100644 src/components/mood/mood-level-a-fields.tsx diff --git a/messages/de.json b/messages/de.json index 455bad74f..b22c5e078 100644 --- a/messages/de.json +++ b/messages/de.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Sonstiges" }, "sectionMoreTags": "Weitere Tags", - "sectionFactors": "Faktoren", "sectionNoteDetails": "Notiz & Details", "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", "quickTagsExpand": "Weitere", - "loadError": "Stimmungseinträge konnten nicht geladen werden." + "loadError": "Stimmungseinträge konnten nicht geladen werden.", + "sectionLevelA": "Fünf Skalen", + "sectionLevelAHelp": "Optional. Was du nicht angibst, bleibt leer.", + "dimensionNotAnswered": "nicht angegeben", + "dimensionClear": "leeren", + "dimensionValueText": "{value} von {max}: {low} bis {high}", + "dimension": { + "a1": { + "label": "Stimmung", + "low": "sehr unangenehm", + "high": "sehr angenehm" + }, + "a2": { + "label": "Stress", + "low": "vollkommen entspannt", + "high": "extrem belastet" + }, + "a3": { + "label": "Energie", + "low": "erschöpft", + "high": "energiegeladen" + }, + "a4": { + "label": "Verbundenheit", + "low": "isoliert", + "high": "verstanden und unterstützt" + }, + "a5": { + "label": "Stabilität", + "low": "vollständig überfordert", + "high": "sehr handlungsfähig" + } + } }, "medications": { "title": "Medikamente", diff --git a/messages/en.json b/messages/en.json index 2041766c0..107c00816 100644 --- a/messages/en.json +++ b/messages/en.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Other" }, "sectionMoreTags": "More tags", - "sectionFactors": "Factors", "sectionNoteDetails": "Note & details", "quickTagsHelp": "Your frequent tags — one tap", "quickTagsExpand": "More", - "loadError": "Could not load your mood entries." + "loadError": "Could not load your mood entries.", + "sectionLevelA": "Five scales", + "sectionLevelAHelp": "Optional. Anything you leave alone stays empty.", + "dimensionNotAnswered": "not recorded", + "dimensionClear": "clear", + "dimensionValueText": "{value} of {max}: {low} to {high}", + "dimension": { + "a1": { + "label": "Mood", + "low": "very unpleasant", + "high": "very pleasant" + }, + "a2": { + "label": "Stress", + "low": "completely relaxed", + "high": "extremely strained" + }, + "a3": { + "label": "Energy", + "low": "drained", + "high": "full of energy" + }, + "a4": { + "label": "Connectedness", + "low": "isolated", + "high": "understood and supported" + }, + "a5": { + "label": "Stability", + "low": "completely overwhelmed", + "high": "fully able to act" + } + } }, "medications": { "title": "Medications", diff --git a/messages/es.json b/messages/es.json index bafa72e6e..fbd062826 100644 --- a/messages/es.json +++ b/messages/es.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Otros" }, "sectionMoreTags": "Más etiquetas", - "sectionFactors": "Factores", "sectionNoteDetails": "Nota y detalles", "quickTagsHelp": "Tus etiquetas frecuentes: un toque", "quickTagsExpand": "Más", - "loadError": "No se pudieron cargar tus registros de estado de ánimo." + "loadError": "No se pudieron cargar tus registros de estado de ánimo.", + "sectionLevelA": "Cinco escalas", + "sectionLevelAHelp": "Opcional. Lo que no toques queda vacío.", + "dimensionNotAnswered": "sin indicar", + "dimensionClear": "borrar", + "dimensionValueText": "{value} de {max}: {low} a {high}", + "dimension": { + "a1": { + "label": "Estado de ánimo", + "low": "muy desagradable", + "high": "muy agradable" + }, + "a2": { + "label": "Estrés", + "low": "completamente relajado", + "high": "extremadamente tenso" + }, + "a3": { + "label": "Energía", + "low": "agotado", + "high": "lleno de energía" + }, + "a4": { + "label": "Conexión", + "low": "aislado", + "high": "comprendido y apoyado" + }, + "a5": { + "label": "Estabilidad", + "low": "completamente desbordado", + "high": "con plena capacidad de acción" + } + } }, "medications": { "title": "Medicamentos", diff --git a/messages/fr.json b/messages/fr.json index 5324f563a..632b225ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Autres" }, "sectionMoreTags": "Plus de tags", - "sectionFactors": "Facteurs", "sectionNoteDetails": "Note et détails", "quickTagsHelp": "Vos tags fréquents — en un geste", "quickTagsExpand": "Plus", - "loadError": "Impossible de charger vos entrées d'humeur." + "loadError": "Impossible de charger vos entrées d'humeur.", + "sectionLevelA": "Cinq échelles", + "sectionLevelAHelp": "Facultatif. Ce que vous ne touchez pas reste vide.", + "dimensionNotAnswered": "non renseigné", + "dimensionClear": "effacer", + "dimensionValueText": "{value} sur {max} : {low} à {high}", + "dimension": { + "a1": { + "label": "Humeur", + "low": "très désagréable", + "high": "très agréable" + }, + "a2": { + "label": "Stress", + "low": "parfaitement détendu", + "high": "extrêmement tendu" + }, + "a3": { + "label": "Énergie", + "low": "épuisé", + "high": "plein d'énergie" + }, + "a4": { + "label": "Lien social", + "low": "isolé", + "high": "compris et soutenu" + }, + "a5": { + "label": "Stabilité", + "low": "complètement dépassé", + "high": "pleinement capable d'agir" + } + } }, "medications": { "title": "Médicaments", diff --git a/messages/it.json b/messages/it.json index 38af4fc16..3a558c56d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Altro" }, "sectionMoreTags": "Altri tag", - "sectionFactors": "Fattori", "sectionNoteDetails": "Nota e dettagli", "quickTagsHelp": "I tuoi tag frequenti: un tocco", "quickTagsExpand": "Altro", - "loadError": "Impossibile caricare le voci dell'umore." + "loadError": "Impossibile caricare le voci dell'umore.", + "sectionLevelA": "Cinque scale", + "sectionLevelAHelp": "Facoltativo. Quello che non tocchi resta vuoto.", + "dimensionNotAnswered": "non indicato", + "dimensionClear": "cancella", + "dimensionValueText": "{value} su {max}: da {low} a {high}", + "dimension": { + "a1": { + "label": "Umore", + "low": "molto spiacevole", + "high": "molto piacevole" + }, + "a2": { + "label": "Stress", + "low": "completamente rilassato", + "high": "estremamente sotto pressione" + }, + "a3": { + "label": "Energia", + "low": "esausto", + "high": "pieno di energia" + }, + "a4": { + "label": "Connessione", + "low": "isolato", + "high": "capito e sostenuto" + }, + "a5": { + "label": "Stabilità", + "low": "completamente sopraffatto", + "high": "pienamente capace di agire" + } + } }, "medications": { "title": "Farmaci", diff --git a/messages/pl.json b/messages/pl.json index 702ad81c5..872cad733 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -885,11 +885,42 @@ "iconGroupMisc": "Inne" }, "sectionMoreTags": "Więcej tagów", - "sectionFactors": "Czynniki", "sectionNoteDetails": "Notatka i szczegóły", "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", "quickTagsExpand": "Więcej", - "loadError": "Nie udało się załadować wpisów nastroju." + "loadError": "Nie udało się załadować wpisów nastroju.", + "sectionLevelA": "Pięć skal", + "sectionLevelAHelp": "Opcjonalne. To, czego nie ruszysz, zostaje puste.", + "dimensionNotAnswered": "nie podano", + "dimensionClear": "wyczyść", + "dimensionValueText": "{value} z {max}: od {low} do {high}", + "dimension": { + "a1": { + "label": "Nastrój", + "low": "bardzo nieprzyjemnie", + "high": "bardzo przyjemnie" + }, + "a2": { + "label": "Stres", + "low": "całkowicie odprężony", + "high": "skrajnie obciążony" + }, + "a3": { + "label": "Energia", + "low": "wyczerpany", + "high": "pełen energii" + }, + "a4": { + "label": "Więź", + "low": "odizolowany", + "high": "rozumiany i wspierany" + }, + "a5": { + "label": "Stabilność", + "low": "całkowicie przytłoczony", + "high": "w pełni zdolny do działania" + } + } }, "medications": { "title": "Leki", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 169a853d9..c8aef7520 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4213,6 +4213,16 @@ model MoodTag { /// mood *factor* the user scores per entry (e.g. work, social, sleep /// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one /// taxonomy + one ingestion path for both flavours. + /// + /// v1.37 — `RATED` is frozen, not removed. The capture sheet stopped + /// offering factor ratings when the level-A columns arrived on `MoodEntry`: + /// a 1-5 score for "work stress" and a 0-10 stress self-state are different + /// questions, and asking both on one entry invites two answers to one + /// thing. Nothing was deleted — no tag row, no link, no rating — so every + /// existing score keeps rendering, keeps editing and keeps feeding the + /// factor crosstab. Re-enabling the offer on the create sheet is a decision + /// about what to ask, not a bug fix; do not restore it because it looks + /// like an omission. kind String @default("BINARY") @map("kind") // "BINARY" | "RATED" /// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale /// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags. diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 1feac2a38..cb5a0d316 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -53,6 +53,12 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); const DYNAMIC_ALLOWLIST_PREFIXES = [ "mood.tag", "mood.tagCategory", + // `mood.dimension`: the five level-A dimensions are a table + // (`src/lib/mood/dimensions.ts`) whose rows carry their own label and anchor + // keys, so every leaf is resolved from a value rather than written as a + // literal. The table is the reason the keys cannot drift apart from the + // columns they describe. + "mood.dimension", // The whole namespace is a runtime lookup table: the client resolves an // error envelope's `meta.errorCode` through it (`localizedApiError`), so no // leaf ever appears as a literal at a call site. Adding a key here is only diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index f6b01ba4d..9e7ea9565 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -128,8 +128,13 @@ export const PUT = apiHandler( updateData.score = getScoreForMood(data.mood); // v1.37 — pleasantness follows the label the same way `score` does, so // an entry corrected from "okay" to "bad" cannot keep yesterday's A1. - // An explicit `a1` in the same request overrides it below. - updateData.moodA1 = deriveA1(data.mood); + // Only when the label actually moved, though: the edit dialog sends the + // whole entry back on every save, and re-deriving on an unchanged label + // would overwrite a value the person set by hand every time they + // corrected the timestamp. An explicit `a1` below overrides either way. + if (data.mood !== existing.mood) { + updateData.moodA1 = deriveA1(data.mood); + } } // v1.37 — level-A values are per-field on this path: omitted keeps the // stored value, an explicit number replaces it, an explicit null clears diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 8670b5575..6073a01e6 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -85,7 +85,10 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => { expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/); expect(formSrc).toContain('tagsInput.trim() !== ""'); expect(formSrc).toContain("tagKeys.length > 0"); - expect(formSrc).toContain("ratedFactors.length > 0"); + // v1.37 — a level-A value the user set is typed input too. The rated + // factors this line used to name are no longer captured here; see the + // level-A describe below. + expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)"); expect(formSrc).toContain('note.trim() !== ""'); // The `isDirty` expression itself must not read `moodLoggedAt`. const isDirtyExpr = formSrc.slice( @@ -115,31 +118,38 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => { }); }); -describe("MoodForm — rated factors (v1.12.0)", () => { - it("lifts a ratedFactors set and threads it to the tag picker", () => { - expect(formSrc).toContain("const [ratedFactors, setRatedFactors]"); - expect(formSrc).toMatch( - /function rateFactor\(key: string, rating: number \| null\)/, - ); - // The picker receives both the binary toggle and the factor rate - // callbacks so it can render BINARY chips and RATED controls. - expect(formSrc).toMatch( - / { - expect(formSrc).toContain( - "ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,", - ); +describe("MoodForm — level A replaces the factor ratings (v1.37)", () => { + it("no longer offers a factor rating on a new entry", () => { + // A 1-5 score for "work stress" and a 0-10 stress self-state are two + // answers to one question. The create sheet asks the second one only. + // Nothing was deleted: the catalogue rows, the stored ratings and the + // edit dialog below all still work, which the next describe pins. + expect(formSrc).not.toContain("onRateFactor="); + expect(formSrc).not.toContain('mode="rated"'); + expect(formSrc).not.toContain("ratedFactors"); // The binary structured-tag contract stays byte-identical. + expect(formSrc).toContain('mode="binary"'); expect(formSrc).toContain( "tagKeys: tagKeys.length > 0 ? tagKeys : undefined,", ); }); + + it("offers the five level-A sliders and sends only the answered ones", () => { + expect(formSrc).toContain(" setA1Touched(true)}"); + // Nothing here defaults a value: the state starts empty and stays empty. + expect(formSrc).toContain("useState(EMPTY_LEVEL_A)"); + }); }); describe("MoodList — rated factor editing", () => { + // Unchanged by v1.37 on purpose: the create sheet stopped asking, and an + // entry that already carries a rating must still be correctable. it("hydrates, renders, and submits the entry's current ratedFactors", () => { expect(listSrc).toContain("ratedFactors: RatedFactor[];"); expect(listSrc).toContain( @@ -156,6 +166,18 @@ describe("MoodList — rated factor editing", () => { }); }); +describe("MoodList — level A on the edit path (v1.37)", () => { + it("seeds the five values from the entry and sends them back, nulls included", () => { + // What the capture sheet writes, this dialog has to show. A value visible + // in a trend and nowhere else is a value the person cannot correct. + expect(listSrc).toContain("levelA: levelAFromEntry(entry)"); + expect(listSrc).toContain("setEditLevelA(seed.levelA)"); + expect(listSrc).toContain(" { it("surfaces a toast on a rejected delete instead of failing silently", () => { expect(listSrc).toContain('import { toast } from "sonner"'); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index ce89a80e8..7c779cf26 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -31,7 +31,6 @@ import { MoreHorizontal, Plus, RotateCcw, - SlidersHorizontal, StickyNote, } from "lucide-react"; import { toast } from "sonner"; @@ -45,7 +44,14 @@ import { import { apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; -import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { MoodTagPicker } from "./mood-tag-picker"; +import { + EMPTY_LEVEL_A, + MoodLevelASection, + levelACreatePayload, + seedA1FromMood, + type LevelAState, +} from "./mood-level-a-fields"; import { MoodQuickTags } from "./mood-quick-tags"; import { useRecentTags } from "./recent-tags"; import { moodFaceIcon } from "./mood-tag-icons"; @@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({ count: MOOD_LEVELS.length, selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood), - onSelect: (index) => setMood(MOOD_LEVELS[index]!.value), + onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value), }); + // v1.37 — the five level-A values. Pleasantness follows the picked face + // until the user moves it themselves; the other four start and stay empty + // unless somebody answers them. + const [levelA, setLevelA] = useState(EMPTY_LEVEL_A); + const [a1Touched, setA1Touched] = useState(false); + const [levelAOpen, setLevelAOpen] = useState(false); const [tagsInput, setTagsInput] = useState(""); // v1.8.5 — structured-tag keys picked from the taxonomy catalog. const [tagKeys, setTagKeys] = useState([]); - // v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on - // their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`. - const [ratedFactors, setRatedFactors] = useState([]); // v1.8.5 (C1) — first-class free-text note. The model + API already // accepted `note`; the web form was the only surface that couldn't // write it. @@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { mood !== "" || tagsInput.trim() !== "" || tagKeys.length > 0 || - ratedFactors.length > 0 || + // A level-A value the user set counts as typed input: Reset must ask + // before it discards an answer that took five slider moves. + Object.values(levelA).some((v) => v !== null) || note.trim() !== ""; + // v1.37 — picking a face suggests a pleasantness value; the suggestion + // stands until the user moves that slider, after which the two are + // independent. Moving the slider never changes which face is lit. + function pickMood(value: string) { + setMood(value); + setLevelA((prev) => seedA1FromMood(prev, value, a1Touched)); + } + function toggleTagKey(key: string) { setTagKeys((prev) => prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key], ); } - // v1.12.0 — set or clear a rated factor's score. `rating === null` - // removes the factor from the set (re-tapping the active step clears). - function rateFactor(key: string, rating: number | null) { - setRatedFactors((prev) => { - const rest = prev.filter((f) => f.key !== key); - return rating === null ? rest : [...rest, { key, rating }]; - }); - } - // v1.4.27 MB3 — error banner descriptor for the timestamp input. const errorId = useId(); const errorDescriptor = error ? errorId : undefined; @@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { function resetForm() { setMood(""); + setLevelA(EMPTY_LEVEL_A); + setA1Touched(false); + setLevelAOpen(false); setTagsInput(""); setTagKeys([]); - setRatedFactors([]); setNote(""); setMoodLoggedAt(getDefaultMoodLoggedAtValue()); setError(null); @@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { mood, tags: tags.length > 0 ? tags : undefined, tagKeys: tagKeys.length > 0 ? tagKeys : undefined, - // v1.12.0 — rated factors as a parallel [{key,rating}] array. - ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined, note: trimmedNote.length > 0 ? trimmedNote : undefined, + // v1.37 — only the values somebody answered. An untouched slider + // sends nothing, and the server writes NULL rather than a middle. + ...levelACreatePayload(levelA), moodLoggedAt: timestamp, }); @@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { aria-label={t(level.labelKey)} data-slot="mood-face" data-mood={level.value} - onClick={() => setMood(level.value)} + onClick={() => pickMood(level.value)} {...getMoodRadioProps(index)} // v1.12 — selected-state unified with the tag tiles and // factor steps: `border-primary bg-primary/15 text-primary` @@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { /> - {/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept - exactly as the existing segmented control. */} - } - summary={} - > - - + {/* v1.37 — the five level-A values, closed by default. The rated + factors that used to sit here are no longer offered on a new + entry: a 1-5 score for "work stress" and a 0-10 stress + self-state are different questions, and asking both invites + two answers to one thing. Existing ratings keep working + everywhere they already do, including the edit dialog, and no + catalogue row was removed. */} + setA1Touched(true)} + open={levelAOpen} + onOpenChange={setLevelAOpen} + /> {/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags, and the note field. The badge counts whichever of these carry diff --git a/src/components/mood/mood-level-a-fields.tsx b/src/components/mood/mood-level-a-fields.tsx new file mode 100644 index 000000000..8b1f15cda --- /dev/null +++ b/src/components/mood/mood-level-a-fields.tsx @@ -0,0 +1,186 @@ +"use client"; + +/** + * The five level-A sliders, and the state rules around them. + * + * One component for both mood surfaces — the create sheet and the list's edit + * dialog — because what one writes the other has to be able to show. A value a + * user set on Monday and cannot see on Tuesday is worse than one they were + * never offered. + * + * The section is closed by default and nothing in it is required. The five + * faces above it remain a complete entry on their own: one tap still saves. + */ +import { Gauge } from "lucide-react"; + +import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; +import { SliderField } from "@/components/ui/slider"; +import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions"; +import { getA1ForMood } from "@/lib/validations/mood"; +import { useTranslations } from "@/lib/i18n/context"; + +/** The five values as the form holds them. `null` means unanswered. */ +export interface LevelAState { + a1: number | null; + a2: number | null; + a3: number | null; + a4: number | null; + a5: number | null; +} + +export const EMPTY_LEVEL_A: LevelAState = { + a1: null, + a2: null, + a3: null, + a4: null, + a5: null, +}; + +/** How many of the five carry an answer — the collapsed section's badge. */ +export function levelAAnsweredCount(state: LevelAState): number { + return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length; +} + +/** + * The five values as the API takes them, with the unanswered ones left out. + * + * Omitted rather than sent as null on the create path: absent means "nothing + * to say", and the server writes NULL for it. Nothing here ever sends a + * default, which is the whole reason the columns are nullable. + */ +export function levelACreatePayload( + state: LevelAState, +): Partial> { + const out: Partial> = {}; + for (const dimension of MOOD_DIMENSIONS) { + const value = state[dimension.key]; + if (value !== null) out[dimension.key] = value; + } + return out; +} + +/** + * The five values as the edit path takes them — nulls included. + * + * The edit path replaces rather than adds, so a value the user cleared has to + * travel as an explicit null. Sending nothing would leave the old answer in + * place and the dialog would lie about what it just saved. + */ +export function levelAUpdatePayload(state: LevelAState): LevelAState { + return { ...state }; +} + +/** + * Seed the state from an entry being edited. + * + * The reader is the API's own wire shape, so a row that has never carried + * these values arrives as `undefined` and lands as `null` — unanswered, which + * is what it is. + */ +export function levelAFromEntry(entry: { + a1?: number | null; + a2?: number | null; + a3?: number | null; + a4?: number | null; + a5?: number | null; +}): LevelAState { + return { + a1: entry.a1 ?? null, + a2: entry.a2 ?? null, + a3: entry.a3 ?? null, + a4: entry.a4 ?? null, + a5: entry.a5 ?? null, + }; +} + +/** + * Re-seed pleasantness from a freshly picked face — but only while the user + * has not moved that slider themselves. + * + * This is the one interaction in the sheet that will confuse a later reader, + * so it is written down here rather than inferred from two call sites: picking + * a face suggests a pleasantness value, and moving the slider takes the + * suggestion over. After that the faces and the slider are independent, and in + * particular moving the slider never changes which face is lit — the two are + * different questions at different resolutions, not two views of one answer. + */ +export function seedA1FromMood( + state: LevelAState, + mood: string, + a1Touched: boolean, +): LevelAState { + if (a1Touched) return state; + return { ...state, a1: mood ? getA1ForMood(mood) : null }; +} + +interface MoodLevelASectionProps { + value: LevelAState; + onChange: (next: LevelAState) => void; + /** Called when the user moves pleasantness themselves, which ends the re-seeding. */ + onA1Touched?: () => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function MoodLevelASection({ + value, + onChange, + onA1Touched, + open, + onOpenChange, +}: MoodLevelASectionProps) { + const { t } = useTranslations(); + + return ( + } + open={open} + onOpenChange={onOpenChange} + summary={} + > +
+

+ {t("mood.sectionLevelAHelp")} +

+ {MOOD_DIMENSIONS.map((dimension) => { + const current = value[dimension.key]; + const low = t(dimension.lowAnchorKey); + const high = t(dimension.highAnchorKey); + return ( + { + if (dimension.key === "a1") onA1Touched?.(); + onChange({ ...value, [dimension.key]: null }); + }} + onValueChange={(next) => { + if (dimension.key === "a1") onA1Touched?.(); + onChange({ ...value, [dimension.key]: next }); + }} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 7cd920d1c..94b5d7cc9 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -87,6 +87,13 @@ import { } from "@/components/data-list"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker"; +import { + EMPTY_LEVEL_A, + MoodLevelASection, + levelAFromEntry, + levelAUpdatePayload, + type LevelAState, +} from "./mood-level-a-fields"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; @@ -104,6 +111,12 @@ interface MoodEntry { // v1.8.5 — structured-tag keys + free-text note. tagKeys: string[]; ratedFactors: RatedFactor[]; + // v1.37 — the five level-A values, absent on entries written before them. + a1?: number | null; + a2?: number | null; + a3?: number | null; + a4?: number | null; + a5?: number | null; note: string | null; source: string; moodLoggedAt: string; @@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const [editTagsInput, setEditTagsInput] = useState(""); const [editTagKeys, setEditTagKeys] = useState([]); const [editRatedFactors, setEditRatedFactors] = useState([]); + // v1.37 — the five level-A values on the edit path. Seeded from the entry, + // so what the capture sheet wrote is visible and changeable here. + const [editLevelA, setEditLevelA] = useState(EMPTY_LEVEL_A); const [editNote, setEditNote] = useState(""); const [editMoodLoggedAt, setEditMoodLoggedAt] = useState(""); const [editSeed, setEditSeed] = useState<{ @@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tagsInput: string; tagKeys: string[]; ratedFactors: RatedFactor[]; + levelA: LevelAState; note: string; moodLoggedAt: string; }>({ @@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tagsInput: "", tagKeys: [], ratedFactors: [], + levelA: EMPTY_LEVEL_A, note: "", moodLoggedAt: "", }); @@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags, tagKeys, ratedFactors, + levelA, note, moodLoggedAt, }: { @@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags: string[] | null; tagKeys: string[]; ratedFactors: RatedFactor[]; + levelA: LevelAState; note: string | null; moodLoggedAt: string; }) => { @@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags, tagKeys, ratedFactors, + // Nulls included: this path replaces rather than adds, so a value the + // user cleared has to travel as an explicit null or the old answer + // survives an edit that visibly removed it. + ...levelAUpdatePayload(levelA), note, moodLoggedAt, }); @@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ratedFactors: [...editRatedFactors].sort((a, b) => a.key.localeCompare(b.key), ), + levelA: editLevelA, note: editNote, moodLoggedAt: editMoodLoggedAt, }); @@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ratedFactors: [...editRatedFactors].sort((a, b) => a.key.localeCompare(b.key), ), + levelA: editLevelA, note: editNote, moodLoggedAt: editMoodLoggedAt, }, @@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) => a.key.localeCompare(b.key), ), + levelA: levelAFromEntry(entry), note: entry.note ?? "", moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt), }; @@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { setEditTagsInput(seed.tagsInput); setEditTagKeys(seed.tagKeys); setEditRatedFactors(seed.ratedFactors); + setEditLevelA(seed.levelA); setEditNote(seed.note); setEditMoodLoggedAt(seed.moodLoggedAt); setEditSeed(seed); @@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { tags: tags.length > 0 ? tags : null, tagKeys: editTagKeys, ratedFactors: editRatedFactors, + levelA: editLevelA, note: trimmedNote.length > 0 ? trimmedNote : null, moodLoggedAt: measuredDate.toISOString(), }); @@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { /> + {/* v1.37 — the five level-A values, seeded from the entry. + Pleasantness is not re-seeded from the face here: this dialog + edits an entry that already has an answer, and replacing it + with the derived one would quietly discard what the person + set. */} + + {/* v1.8.5 (C1) — free-text note. */}