mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(cycle): user-defined custom symptoms
Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint their own symptoms beyond the 15 seeded ones, picked from a label + icon popover via a dashed ghost-chip in the log-day sheet's symptom grid. - POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM), per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by default, hard-purge on ?purge=true. - The day-log write already resolves a symptom key against the catalogue plus the caller's own rows, so a custom: key links by construction. - The log-day sheet fetches the user's customs and merges them under a new custom category in the chip grid; the ghost-chip + popover reuse the chip styling and the Lucide icon set. - Seed the global custom symptom category (migration 0134). - The purge route (DELETE /api/cycle/all) now drops the user's custom symptom rows too, so no intent-revealing label survives a purge. - The label is never logged: only the icon + counts reach annotate/auditLog. - i18n: cycle.symptom.custom.* across all six locales.
This commit is contained in:
1 parent
81ec98fc97
commit
985e197db4
18 files changed
+1233
-59
No files matched your search
@@ -37,6 +37,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const dayLogs = await tx.cycleDayLog.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
// Per-user custom symptoms carry an intent-revealing free-text label
|
||||
// (encrypted at rest). A purge that promises "nothing reproductive
|
||||
// persists" must drop them too; their links cascade off the row delete.
|
||||
const customSymptoms = await tx.cycleSymptom.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const predictions = await tx.cyclePrediction.deleteMany({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
@@ -79,6 +85,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
return {
|
||||
dayLogs: dayLogs.count,
|
||||
customSymptoms: customSymptoms.count,
|
||||
predictions: predictions.count,
|
||||
cycles: cycles.count,
|
||||
auditRows: auditRows.count,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* v1.15.1 — update / delete a per-user custom cycle-symptom.
|
||||
*
|
||||
* Both handlers resolve the `custom:`-prefixed key against the CALLER's own
|
||||
* rows only — another user's key (or a catalogue key) is a 404, so a symptom
|
||||
* can never be edited or removed across the ownership boundary. Gated
|
||||
* (`cycle.disabled` 403). The decrypted label is NEVER logged.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
updateCustomSymptomSchema,
|
||||
decryptCustomLabel,
|
||||
encryptCustomLabel,
|
||||
isCustomSymptomKey,
|
||||
} from "@/lib/cycle/custom-symptoms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ key: string }> };
|
||||
|
||||
/** `PATCH /api/cycle/symptoms/custom/:key` — rename / recolour / (de)activate. */
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { key } = await params;
|
||||
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
|
||||
|
||||
const parsed = updateCustomSymptomSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.symptom.custom.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const owned = await prisma.cycleSymptom.findFirst({
|
||||
where: { key, userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return apiError("Custom symptom not found", 404);
|
||||
|
||||
const updated = await prisma.cycleSymptom.update({
|
||||
where: { id: owned.id },
|
||||
data: {
|
||||
...(parsed.data.label !== undefined
|
||||
? { labelEncrypted: encryptCustomLabel(parsed.data.label) }
|
||||
: {}),
|
||||
...(parsed.data.icon !== undefined ? { icon: parsed.data.icon } : {}),
|
||||
...(parsed.data.isActive !== undefined
|
||||
? { isActive: parsed.data.isActive }
|
||||
: {}),
|
||||
},
|
||||
select: { key: true, icon: true, isActive: true, labelEncrypted: true },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "cycle.symptom.custom.update" } });
|
||||
|
||||
return apiSuccess({
|
||||
key: updated.key,
|
||||
label: decryptCustomLabel(updated.labelEncrypted),
|
||||
icon: updated.icon,
|
||||
isActive: updated.isActive,
|
||||
custom: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* `DELETE /api/cycle/symptoms/custom/:key` — soft-deactivate by default
|
||||
* (history intact); `?purge=true` hard-deletes the row and cascades its links.
|
||||
*/
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { key } = await params;
|
||||
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
|
||||
|
||||
const owned = await prisma.cycleSymptom.findFirst({
|
||||
where: { key, userId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return apiError("Custom symptom not found", 404);
|
||||
|
||||
const purge = request.nextUrl.searchParams.get("purge") === "true";
|
||||
if (purge) {
|
||||
// FK cascade removes the `cycle_symptom_links` rows too.
|
||||
await prisma.cycleSymptom.delete({ where: { id: owned.id } });
|
||||
} else {
|
||||
await prisma.cycleSymptom.update({
|
||||
where: { id: owned.id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("cycle.symptom.custom.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { purge },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.symptom.custom.delete" },
|
||||
meta: { purge },
|
||||
});
|
||||
|
||||
return apiSuccess({ key, purged: purge });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* v1.15.1 — custom cycle-symptom CRUD route guards: create cap + encrypted
|
||||
* label, ownership 404 on edit/delete, non-custom-key rejection, soft-delete
|
||||
* vs purge, and the gate refusal.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleSymptom: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({
|
||||
requireCycleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH, DELETE } from "../[key]/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
|
||||
};
|
||||
|
||||
function jsonReq(url: string, method: string, body: unknown) {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: {} as never,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/cycle/symptoms/custom", () => {
|
||||
it("returns the caller's active customs with decrypted labels", async () => {
|
||||
db.cycleSymptom.findMany.mockResolvedValue([
|
||||
{ key: "custom:abc", icon: "Brain", labelEncrypted: "enc:Dizziness" },
|
||||
]);
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { symptoms: { key: string; label: string; custom: boolean }[] };
|
||||
};
|
||||
expect(body.data.symptoms).toEqual([
|
||||
{ key: "custom:abc", label: "Dizziness", icon: "Brain", custom: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/cycle/symptoms/custom", () => {
|
||||
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(2);
|
||||
db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "Dizziness",
|
||||
icon: "Brain",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as {
|
||||
data: { custom: boolean; label: string; key: string };
|
||||
};
|
||||
expect(body.data).toMatchObject({
|
||||
custom: true,
|
||||
label: "Dizziness",
|
||||
key: "custom:abc",
|
||||
});
|
||||
// Label is stored encrypted, never plaintext.
|
||||
expect(db.cycleSymptom.create.mock.calls[0][0].data.labelEncrypted).toBe(
|
||||
"enc:Dizziness",
|
||||
);
|
||||
// Owner-scoped + hung under the custom category.
|
||||
expect(db.cycleSymptom.create.mock.calls[0][0].data.userId).toBe("user-1");
|
||||
expect(db.cycleSymptom.create.mock.calls[0][0].data.categoryId).toBe(
|
||||
"csc_custom",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects over the per-user cap with 422", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when cycle tracking is disabled (403)", async () => {
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError("Cycle tracking is not enabled", 403),
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
const params = (key: string) => ({ params: Promise.resolve({ key }) });
|
||||
|
||||
it("404s when the key is not the caller's own custom symptom", async () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue(null);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:x", "PATCH", {
|
||||
isActive: false,
|
||||
}),
|
||||
params("custom:x"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s a non-custom key without touching the DB", async () => {
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/cycle/symptoms/custom/cramps"),
|
||||
params("cramps"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(db.cycleSymptom.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
|
||||
const soft = await DELETE(
|
||||
new NextRequest("http://localhost/api/cycle/symptoms/custom/custom:abc"),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(soft.status).toBe(200);
|
||||
expect(db.cycleSymptom.update).toHaveBeenCalledWith({
|
||||
where: { id: "id1" },
|
||||
data: { isActive: false },
|
||||
});
|
||||
expect(db.cycleSymptom.delete).not.toHaveBeenCalled();
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireCycleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
profile: {} as never,
|
||||
});
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" });
|
||||
const purge = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/cycle/symptoms/custom/custom:abc?purge=true",
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(purge.status).toBe(200);
|
||||
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.15.1 — per-user custom cycle-symptom catalogue.
|
||||
*
|
||||
* GET /api/cycle/symptoms/custom — the caller's active custom symptoms,
|
||||
* labels decrypted, so the log-day sheet can merge them into the
|
||||
* seeded chip grid.
|
||||
* POST /api/cycle/symptoms/custom — create one (`{ label, icon?,
|
||||
* categoryKey? }`). Mints a `custom:<uuid>` key, encrypts the label at
|
||||
* rest, stores the row under the global `custom` category owned by the
|
||||
* caller. Capped per user.
|
||||
*
|
||||
* Gated (`cycle.disabled` 403) + owner-scoped — a Bearer token for a disabled
|
||||
* account never reaches the custom catalogue. The label is intent-revealing
|
||||
* free text, so it is NEVER surfaced in a wide-event / audit excerpt (only the
|
||||
* icon + key are annotated, mirroring the mood custom-tag precedent).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
createCustomSymptomSchema,
|
||||
decryptCustomLabel,
|
||||
encryptCustomLabel,
|
||||
mintCustomSymptomKey,
|
||||
CUSTOM_SYMPTOM_CATEGORY_ID,
|
||||
MAX_CUSTOM_SYMPTOMS_PER_USER,
|
||||
} from "@/lib/cycle/custom-symptoms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rows = await prisma.cycleSymptom.findMany({
|
||||
where: { userId: user.id, isActive: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { key: true, icon: true, labelEncrypted: true },
|
||||
});
|
||||
|
||||
const symptoms = rows.map((r) => ({
|
||||
key: r.key,
|
||||
label: decryptCustomLabel(r.labelEncrypted),
|
||||
icon: r.icon,
|
||||
custom: true,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.symptom.custom.read" },
|
||||
meta: { count: symptoms.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ symptoms });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = createCustomSymptomSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "cycle.symptom.custom.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const activeCount = await prisma.cycleSymptom.count({
|
||||
where: { userId: user.id, isActive: true },
|
||||
});
|
||||
if (activeCount >= MAX_CUSTOM_SYMPTOMS_PER_USER) {
|
||||
return apiError(
|
||||
`Custom symptom limit reached (${MAX_CUSTOM_SYMPTOMS_PER_USER})`,
|
||||
422,
|
||||
{ errorCode: "cycle.symptom.custom.limit" },
|
||||
);
|
||||
}
|
||||
|
||||
const key = mintCustomSymptomKey();
|
||||
const created = await prisma.cycleSymptom.create({
|
||||
data: {
|
||||
categoryId: CUSTOM_SYMPTOM_CATEGORY_ID,
|
||||
key,
|
||||
// Catalogue rows resolve `labelKey` against the locale; a custom symptom
|
||||
// renders its decrypted `label` instead, so labelKey just mirrors the
|
||||
// key for a stable, non-empty value.
|
||||
labelKey: key,
|
||||
labelEncrypted: encryptCustomLabel(parsed.data.label),
|
||||
icon: parsed.data.icon ?? null,
|
||||
sortOrder: activeCount,
|
||||
userId: user.id,
|
||||
},
|
||||
select: { key: true, icon: true },
|
||||
});
|
||||
|
||||
// Audit the create (same tier as a mood-tag custom) — counts + the icon
|
||||
// only, NEVER the decrypted label.
|
||||
await auditLog("cycle.symptom.custom.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { icon: created.icon },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.symptom.custom.create" },
|
||||
meta: { icon: created.icon },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
key: created.key,
|
||||
label: parsed.data.label,
|
||||
icon: created.icon,
|
||||
custom: true,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user