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