From fe17d06572c8d929785b1c37ae4b461fb04af811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 14:57:12 +0200 Subject: [PATCH] Give mood-tag and cycle-symptom edits an audit row Editing a custom mood tag or group, and editing a custom cycle symptom, wrote nothing to the audit ledger, while their delete siblings did. Two of these paths destroy data: a custom mood tag or cycle symptom purged with ?purge=true cascades its entry links and unpicks historical entries, so a destruction left no trace of who ran it or when. Every vocabulary-mutating verb now records an audit row: create, edit, and delete for both custom tags and custom groups, and edit for a custom cycle symptom. Encrypted labels are never logged; the row carries only the field names touched and the resulting active state. The two display-only writes on this surface, hiding a catalogue tag and reordering the layout, stay ledger-free by design and say so inline, since a per-user display preference is not a health-data write and a row on every drag-to-reorder would be noise rather than a record. --- .../api/cycle/symptoms/custom/[key]/route.ts | 13 ++++++++++++ .../custom/__tests__/custom-crud.test.ts | 9 ++++++++ src/app/api/mood/tags/[key]/hidden/route.ts | 5 +++++ .../mood/tags/__tests__/custom-crud.test.ts | 9 ++++++++ .../mood/tags/__tests__/groups-crud.test.ts | 8 +++++++ src/app/api/mood/tags/custom/[key]/route.ts | 21 +++++++++++++++++++ src/app/api/mood/tags/custom/route.ts | 10 +++++++++ src/app/api/mood/tags/groups/[key]/route.ts | 21 +++++++++++++++++++ src/app/api/mood/tags/groups/route.ts | 10 +++++++++ src/app/api/mood/tags/layout/route.ts | 5 +++++ 10 files changed, 111 insertions(+) diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts index 7db44e80f..d11b7df09 100644 --- a/src/app/api/cycle/symptoms/custom/[key]/route.ts +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -101,6 +101,19 @@ export const PATCH = apiHandler( select: { key: true, icon: true, isActive: true, labelEncrypted: true }, }); + // Symmetry with the DELETE sibling: an edit to a custom symptom leaves a + // ledger row too. The decrypted label is never logged (see the docstring); + // we record only which fields the write touched and the resulting active + // state, so the trace stays free of reproductive free text. + await auditLog("cycle.symptom.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + fields: Object.keys(parsed.data), + isActive: updated.isActive, + }, + }); + annotate({ action: { name: "cycle.symptom.custom.update" } }); return apiSuccess({ diff --git a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts index b093d3a64..eec847a26 100644 --- a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts +++ b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts @@ -50,6 +50,7 @@ import { PATCH, DELETE } from "../[key]/route"; import { getSession } from "@/lib/auth/session"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { apiError } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -215,6 +216,14 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => { ); expect(res.status).toBe(200); expect(db.cycleSymptom.count).not.toHaveBeenCalled(); + // An edit leaves a ledger row too, symmetric with the DELETE sibling. + // The decrypted label is never in the audit details, only the field names. + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "cycle.symptom.custom.update", + expect.objectContaining({ + details: expect.objectContaining({ fields: expect.any(Array) }), + }), + ); }); it("soft-deactivates by default and hard-deletes on ?purge=true", async () => { diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts index fb7dc4d6a..c1ec8157c 100644 --- a/src/app/api/mood/tags/[key]/hidden/route.ts +++ b/src/app/api/mood/tags/[key]/hidden/route.ts @@ -62,6 +62,11 @@ export const PUT = apiHandler( }); } + // No audit row by design: hiding or showing a catalogue tag is a per-user + // display preference, not a health-data write. It destroys nothing (the + // catalogue tag and every entry that used it are untouched), so a ledger + // row here would be noise rather than forensics. The wide-event annotate + // still carries it for observability. annotate({ action: { name: "mood.tag.hidden.set" }, meta: { hidden: parsed.data.hidden }, diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts index 24e5e363e..e3c8d83da 100644 --- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts @@ -44,6 +44,7 @@ import { POST } from "../custom/route"; import { PATCH, DELETE } from "../custom/[key]/route"; import { PUT } from "../[key]/hidden/route"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -247,6 +248,14 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { ); expect(purge.status).toBe(200); expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } }); + // A purge cascades `mood_entry_tag_links` and unpicks history, so it must + // leave a ledger row recording that it happened. + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "mood.tag.custom.delete", + expect.objectContaining({ + details: expect.objectContaining({ purge: true }), + }), + ); }); }); diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts index a66d250c8..ce4ca9ad9 100644 --- a/src/app/api/mood/tags/__tests__/groups-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts @@ -55,6 +55,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../groups/route"; import { PATCH, DELETE } from "../groups/[key]/route"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags"; const SESSION_OK = { @@ -228,6 +229,13 @@ describe("DELETE /api/mood/tags/groups/:key", () => { expect(db.moodTagCategory.update).not.toHaveBeenCalled(); // Re-home still ran first — purge removes the group, never the tags. expect(db.moodTag.updateMany).toHaveBeenCalled(); + // A group delete/purge leaves a ledger row recording purge + re-home. + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "mood.tag.group.delete", + expect.objectContaining({ + details: expect.objectContaining({ purge: true }), + }), + ); }); it("404s a non-custom key and a foreign group", async () => { diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index 3b28228e7..2c46dd878 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -4,11 +4,13 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { updateCustomTagSchema, encryptCustomLabel, @@ -89,6 +91,17 @@ export const PATCH = apiHandler( }, }); + // The custom label is encrypted at rest and never logged; the ledger row + // records only which fields the edit touched and the resulting active state. + await auditLog("mood.tag.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + fields: Object.keys(parsed.data), + isActive: updated.isActive, + }, + }); + annotate({ action: { name: "mood.tag.custom.update" } }); return apiSuccess({ @@ -133,6 +146,14 @@ export const DELETE = apiHandler( }); } + // A purge hard-deletes the tag and cascades its `mood_entry_tag_links`, + // unpicking historical entries — a destruction that must leave a ledger row. + await auditLog("mood.tag.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { purge }, + }); + annotate({ action: { name: "mood.tag.custom.delete" }, meta: { purge } }); return apiSuccess({ key, purged: purge }); diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index 4aece227e..713099674 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -4,11 +4,13 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { createCustomTagSchema, mintCustomTagKey, @@ -86,6 +88,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + // The custom label is encrypted at rest and never logged; the ledger row + // records the mint (icon only) so a created tag leaves a trace. + await auditLog("mood.tag.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { icon: created.icon }, + }); + annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon }, diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts index bdc07e508..61bbf92e5 100644 --- a/src/app/api/mood/tags/groups/[key]/route.ts +++ b/src/app/api/mood/tags/groups/[key]/route.ts @@ -4,11 +4,13 @@ import { prisma, toJson } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { updateCustomGroupSchema, encryptCustomLabel, @@ -72,6 +74,17 @@ export const PATCH = apiHandler( }, }); + // The group label is encrypted at rest and never logged; the ledger row + // records only which fields changed and the resulting active state. + await auditLog("mood.tag.group.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + fields: Object.keys(parsed.data), + isActive: updated.isActive, + }, + }); + annotate({ action: { name: "mood.tag.group.update" } }); return apiSuccess({ @@ -144,6 +157,14 @@ export const DELETE = apiHandler( return rehomed.count; }); + // Non-destructive to tags and links (they re-home), but the group row is + // retired or purged; the ledger records which, and how many tags re-homed. + await auditLog("mood.tag.group.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { purge, rehomedCount }, + }); + annotate({ action: { name: "mood.tag.group.delete" }, meta: { purge, rehomed_count: rehomedCount }, diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts index b0b385809..beb867ba0 100644 --- a/src/app/api/mood/tags/groups/route.ts +++ b/src/app/api/mood/tags/groups/route.ts @@ -4,11 +4,13 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { createCustomGroupSchema, mintCustomCategoryKey, @@ -67,6 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => { select: { key: true, icon: true }, }); + // The group label is encrypted at rest and never logged; the ledger row + // records the mint (icon only) so a created group leaves a trace. + await auditLog("mood.tag.group.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { icon: created.icon }, + }); + annotate({ action: { name: "mood.tag.group.create" }, meta: { icon: created.icon }, diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts index 2e1ea0464..3294d78a0 100644 --- a/src/app/api/mood/tags/layout/route.ts +++ b/src/app/api/mood/tags/layout/route.ts @@ -121,6 +121,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); if ("conflict" in guarded) return guarded.conflict; + // No audit row by design: the layout blob is a per-user display ordering + // (group order + tag placements), not a health-data write. It destroys + // nothing and fires on every drag-to-reorder save, so a ledger row here + // would be noise rather than forensics. The wide-event annotate still + // carries it for observability. annotate({ action: { name: "mood.tag.layout.update" }, meta: {