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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 14:57:12 +02:00
1 parent 311adcfdda
commit fe17d06572
10 files changed
+111

No files matched your search

@@ -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({
@@ -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 () => {
@@ -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 },
@@ -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 }),
}),
);
});
});
@@ -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 () => {
@@ -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 });
+10
View File
@@ -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 },
@@ -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 },
+10
View File
@@ -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 },
+5
View File
@@ -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: {