mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
18 files changed
+326
-3
No files matched your search
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [1.37.6] — 2026-08-09
|
||||
|
||||
### Security
|
||||
|
||||
- A mental-wellbeing screening reminder (PHQ-9, GAD-7, WHO-5, SCI) can no longer be marked done through a crafted request. A screening resolves only from the score a completed check-in produces, so the satisfy and complete routes now refuse a screening reminder outright rather than trusting that the app never offers that action for one.
|
||||
|
||||
### Changed
|
||||
|
||||
- Editing a custom mood tag, a custom tag group or a custom cycle symptom now records an audit entry, the same way deleting one already did. Removing a custom tag or symptom together with its history leaves a trace of the change. Hiding a catalogue tag and reordering the layout stay out of the audit log by design, since those are display preferences rather than a change to your record.
|
||||
|
||||
## [1.37.5] — 2026-08-09
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.37.5
|
||||
version: 1.37.6
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.37.5",
|
||||
"version": "1.37.6",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.37.5";
|
||||
/* @sw-version-fallback */ "v1.37.6";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -144,4 +144,33 @@ describe("POST /api/measurement-reminders/[id]/complete", () => {
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => {
|
||||
// A crafted completion of a PHQ-9 screening: the client never offers this
|
||||
// (it routes to /mental-wellbeing), and a screening may only resolve from
|
||||
// the server-written score row. The route must not let it read as done.
|
||||
findFirstMock.mockResolvedValue({ ...ROW, measurementType: "PHQ9_SCORE" });
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(409);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows a manual completion of a typed numeric reminder", async () => {
|
||||
// Typed numeric reminders (a BP row here) legitimately support a manual
|
||||
// "complete"; the screening guard must not catch them.
|
||||
findFirstMock.mockResolvedValue(ROW); // measurementType: BLOOD_PRESSURE_SYS
|
||||
satisfyReminderMock.mockResolvedValue({
|
||||
satisfied: true,
|
||||
nextDueAt: new Date("2026-06-25T07:00:00Z"),
|
||||
});
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -53,6 +54,19 @@ export const POST = apiHandler(
|
||||
return apiError("Measurement reminder not found", 404);
|
||||
}
|
||||
|
||||
// Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI)
|
||||
// resolves ONLY from the server-written *_SCORE row a completed check-in
|
||||
// produces — never from a manual "complete." The client routes a screening
|
||||
// to `/mental-wellbeing` instead of offering this action, so a completion
|
||||
// landing here is a crafted request; refusing it keeps a screening from
|
||||
// reading as completed with no assessment behind it.
|
||||
if (isScreeningReminderType(existing.measurementType)) {
|
||||
return apiError(
|
||||
"Screening reminders resolve from a completed check-in, not a manual completion",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* v1.17.1 — manual "Erledigt" (satisfy) route.
|
||||
*
|
||||
* Covers: a free-text Vorsorge resolves through the shared primitive,
|
||||
* owner-scoped 404 on a cross-user / tombstoned id, and the defense-in-depth
|
||||
* screening refusal (a screening reminder may only resolve from the
|
||||
* server-written score row, never a crafted manual satisfy).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "u1", locale: "en" },
|
||||
actor: { id: "u1", locale: "en" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const satisfyReminderMock = vi.fn();
|
||||
vi.mock("@/lib/measurement-reminders/satisfy", () => ({
|
||||
satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args),
|
||||
}));
|
||||
|
||||
const findFirstMock = vi.fn();
|
||||
const findUserMock = vi.fn();
|
||||
const findUniqueOrThrowMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurementReminder: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
|
||||
},
|
||||
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const ROW = {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
label: "Blutbild",
|
||||
measurementType: null, // free-text Vorsorge (resolves only on a manual satisfy)
|
||||
intervalDays: 365,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
nextDueAt: new Date("2026-06-25T07:00:00Z"),
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-18T00:00:00Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/measurement-reminders/r1/satisfy",
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
const params = { params: Promise.resolve({ id: "r1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
});
|
||||
|
||||
describe("POST /api/measurement-reminders/[id]/satisfy", () => {
|
||||
it("satisfies a free-text Vorsorge via the shared primitive", async () => {
|
||||
findFirstMock.mockResolvedValue(ROW);
|
||||
satisfyReminderMock.mockResolvedValue({
|
||||
satisfied: true,
|
||||
nextDueAt: new Date("2027-06-25T07:00:00Z"),
|
||||
});
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.id).toBe("r1");
|
||||
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => {
|
||||
findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" });
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s a tombstoned / missing reminder", async () => {
|
||||
findFirstMock.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => {
|
||||
findFirstMock.mockResolvedValue({ ...ROW, measurementType: "GAD7_SCORE" });
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(409);
|
||||
expect(satisfyReminderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows a manual satisfy of a typed numeric reminder", async () => {
|
||||
findFirstMock.mockResolvedValue({
|
||||
...ROW,
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
});
|
||||
satisfyReminderMock.mockResolvedValue({
|
||||
satisfied: true,
|
||||
nextDueAt: new Date("2026-07-02T07:00:00Z"),
|
||||
});
|
||||
findUniqueOrThrowMock.mockResolvedValue({
|
||||
...ROW,
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest(), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -39,6 +40,19 @@ export const POST = apiHandler(
|
||||
return apiError("Measurement reminder not found", 404);
|
||||
}
|
||||
|
||||
// Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI)
|
||||
// resolves ONLY from the server-written *_SCORE row a completed check-in
|
||||
// produces — never from a manual "done." The client already routes a
|
||||
// screening to `/mental-wellbeing` instead of offering this action, so a
|
||||
// satisfy landing here is a crafted request; refusing it keeps a screening
|
||||
// from reading as completed with no assessment behind it.
|
||||
if (isScreeningReminderType(existing.measurementType)) {
|
||||
return apiError(
|
||||
"Screening reminders resolve from a completed check-in, not a manual satisfy",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
|
||||
@@ -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