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:
Marc-André Bombeck committed 2026-06-06 16:54:43 +02:00
1 parent 81ec98fc97
commit 985e197db4
18 files changed
+1233 -59

No files matched your search

+7
View File
@@ -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" } });
});
});
+131
View File
@@ -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,
);
});