mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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
+12
-2
@@ -4903,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Körperlich",
|
||||
"emotional": "Emotional",
|
||||
"digestive": "Verdauung"
|
||||
"digestive": "Verdauung",
|
||||
"custom": "Eigene"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Krämpfe",
|
||||
@@ -4920,7 +4921,16 @@
|
||||
"foodCravings": "Heißhunger",
|
||||
"nausea": "Übelkeit",
|
||||
"diarrhea": "Durchfall",
|
||||
"constipation": "Verstopfung"
|
||||
"constipation": "Verstopfung",
|
||||
"custom": {
|
||||
"add": "Symptom hinzufügen",
|
||||
"label": "Name",
|
||||
"labelPlaceholder": "z. B. Schwindel",
|
||||
"icon": "Symbol",
|
||||
"limitReached": "Höchstzahl an eigenen Symptomen erreicht.",
|
||||
"remove": "{label} entfernen",
|
||||
"fallbackLabel": "Eigenes Symptom"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst."
|
||||
|
||||
+12
-2
@@ -4903,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Physical",
|
||||
"emotional": "Emotional",
|
||||
"digestive": "Digestive"
|
||||
"digestive": "Digestive",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Cramps",
|
||||
@@ -4920,7 +4921,16 @@
|
||||
"foodCravings": "Food cravings",
|
||||
"nausea": "Nausea",
|
||||
"diarrhea": "Diarrhea",
|
||||
"constipation": "Constipation"
|
||||
"constipation": "Constipation",
|
||||
"custom": {
|
||||
"add": "Add symptom",
|
||||
"label": "Name",
|
||||
"labelPlaceholder": "e.g. Dizziness",
|
||||
"icon": "Icon",
|
||||
"limitReached": "Custom symptom limit reached.",
|
||||
"remove": "Remove {label}",
|
||||
"fallbackLabel": "Custom symptom"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles."
|
||||
|
||||
+12
-2
@@ -4903,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Físico",
|
||||
"emotional": "Emocional",
|
||||
"digestive": "Digestivo"
|
||||
"digestive": "Digestivo",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Calambres",
|
||||
@@ -4920,7 +4921,16 @@
|
||||
"foodCravings": "Antojos",
|
||||
"nausea": "Náuseas",
|
||||
"diarrhea": "Diarrea",
|
||||
"constipation": "Estreñimiento"
|
||||
"constipation": "Estreñimiento",
|
||||
"custom": {
|
||||
"add": "Añadir síntoma",
|
||||
"label": "Nombre",
|
||||
"labelPlaceholder": "p. ej. Mareo",
|
||||
"icon": "Icono",
|
||||
"limitReached": "Se alcanzó el límite de síntomas personalizados.",
|
||||
"remove": "Eliminar {label}",
|
||||
"fallbackLabel": "Síntoma personalizado"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos."
|
||||
|
||||
+12
-2
@@ -4903,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Physique",
|
||||
"emotional": "Émotionnel",
|
||||
"digestive": "Digestif"
|
||||
"digestive": "Digestif",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Crampes",
|
||||
@@ -4920,7 +4921,16 @@
|
||||
"foodCravings": "Fringales",
|
||||
"nausea": "Nausées",
|
||||
"diarrhea": "Diarrhée",
|
||||
"constipation": "Constipation"
|
||||
"constipation": "Constipation",
|
||||
"custom": {
|
||||
"add": "Ajouter un symptôme",
|
||||
"label": "Nom",
|
||||
"labelPlaceholder": "ex. Vertige",
|
||||
"icon": "Icône",
|
||||
"limitReached": "Limite de symptômes personnalisés atteinte.",
|
||||
"remove": "Supprimer {label}",
|
||||
"fallbackLabel": "Symptôme personnalisé"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles."
|
||||
|
||||
+12
-2
@@ -4903,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Fisico",
|
||||
"emotional": "Emotivo",
|
||||
"digestive": "Digestivo"
|
||||
"digestive": "Digestivo",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Crampi",
|
||||
@@ -4920,7 +4921,16 @@
|
||||
"foodCravings": "Voglie alimentari",
|
||||
"nausea": "Nausea",
|
||||
"diarrhea": "Diarrea",
|
||||
"constipation": "Stitichezza"
|
||||
"constipation": "Stitichezza",
|
||||
"custom": {
|
||||
"add": "Aggiungi sintomo",
|
||||
"label": "Nome",
|
||||
"labelPlaceholder": "es. Vertigini",
|
||||
"icon": "Icona",
|
||||
"limitReached": "Limite di sintomi personalizzati raggiunto.",
|
||||
"remove": "Rimuovi {label}",
|
||||
"fallbackLabel": "Sintomo personalizzato"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli."
|
||||
|
||||
+12
-2
@@ -4903,7 +4903,8 @@
|
||||
"symptomCategory": {
|
||||
"physical": "Fizyczne",
|
||||
"emotional": "Emocjonalne",
|
||||
"digestive": "Trawienne"
|
||||
"digestive": "Trawienne",
|
||||
"custom": "Własne"
|
||||
},
|
||||
"symptom": {
|
||||
"cramps": "Skurcze",
|
||||
@@ -4920,7 +4921,16 @@
|
||||
"foodCravings": "Zachcianki",
|
||||
"nausea": "Nudności",
|
||||
"diarrhea": "Biegunka",
|
||||
"constipation": "Zaparcia"
|
||||
"constipation": "Zaparcia",
|
||||
"custom": {
|
||||
"add": "Dodaj objaw",
|
||||
"label": "Nazwa",
|
||||
"labelPlaceholder": "np. Zawroty głowy",
|
||||
"icon": "Ikona",
|
||||
"limitReached": "Osiągnięto limit własnych objawów.",
|
||||
"remove": "Usuń {label}",
|
||||
"fallbackLabel": "Objaw własny"
|
||||
}
|
||||
},
|
||||
"prediction": {
|
||||
"disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli."
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.15.1 — seed the global `custom` cycle-symptom category.
|
||||
--
|
||||
-- Per-user custom symptoms (the v1.13 custom-mood-tag precedent) hang under a
|
||||
-- single global category row, distinguished from the seeded catalogue by a
|
||||
-- non-null `cycle_symptoms.user_id`. The owner column + `label_encrypted`
|
||||
-- already exist (migration 0129); this only adds the parent category so a
|
||||
-- custom symptom has a stable `category_id` to reference.
|
||||
--
|
||||
-- Idempotent: upserts by `key` (deterministic id `csc_custom`), so re-running
|
||||
-- the migration is a no-op. No table or column changes.
|
||||
INSERT INTO "cycle_symptom_categories"
|
||||
("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('csc_custom', 'custom', 'cycle.symptomCategory.custom', 'Tag', 100, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
|
||||
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -1,24 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Droplets, Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
BatteryLow,
|
||||
Brain,
|
||||
CircleDot,
|
||||
Cookie,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
Flame,
|
||||
Frown,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
Loader2,
|
||||
MoonStar,
|
||||
PersonStanding,
|
||||
Pill,
|
||||
Plus,
|
||||
Snowflake,
|
||||
Soup,
|
||||
Stethoscope,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import {
|
||||
useCreateCustomSymptom,
|
||||
useCustomSymptoms,
|
||||
useCycleDayLog,
|
||||
useDeleteCustomSymptom,
|
||||
useDeleteDayLog,
|
||||
useEndPeriod,
|
||||
useLogDay,
|
||||
usePatchDayLog,
|
||||
useStartPeriod,
|
||||
type CustomSymptomDTO,
|
||||
type CycleDayLogPatch,
|
||||
} from "./use-cycle";
|
||||
import type {
|
||||
@@ -75,6 +113,38 @@ const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [
|
||||
];
|
||||
const SEVERITY_LEVELS = [1, 2, 3, 4] as const;
|
||||
|
||||
/**
|
||||
* Resolve a custom symptom's stored Lucide icon NAME to its component. Scoped
|
||||
* to the `CUSTOM_SYMPTOM_ICON_ALLOWLIST` (server-validated on create) so the
|
||||
* import set stays tight; an unknown / null name falls back to `Tag`.
|
||||
*/
|
||||
const CUSTOM_ICON_BY_NAME: Record<string, LucideIcon> = {
|
||||
Tag,
|
||||
Activity,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
Brain,
|
||||
Zap,
|
||||
Flame,
|
||||
Snowflake,
|
||||
Droplet,
|
||||
CircleDot,
|
||||
BatteryLow,
|
||||
MoonStar,
|
||||
PersonStanding,
|
||||
Drama,
|
||||
Frown,
|
||||
Cookie,
|
||||
Soup,
|
||||
Pill,
|
||||
Thermometer,
|
||||
Stethoscope,
|
||||
};
|
||||
|
||||
function customIcon(name: string | null): LucideIcon {
|
||||
return (name && CUSTOM_ICON_BY_NAME[name]) || Tag;
|
||||
}
|
||||
|
||||
/** The sheet's editable form state, lifted out so the two save-payload builders
|
||||
* are pure + unit-testable (the clear-on-edit semantics are the QA W-2 fix). */
|
||||
export interface DayLogFormState {
|
||||
@@ -211,6 +281,8 @@ export function LogDaySheet({
|
||||
// Pre-fill from the persisted day-log so a re-log never nulls untouched
|
||||
// (or HealthKit-sourced) fields — the v1.15 web data-loss fix.
|
||||
const dayLog = useCycleDayLog(open ? date : null);
|
||||
// The caller's custom symptoms, merged into the seeded chip grid.
|
||||
const customSymptoms = useCustomSymptoms();
|
||||
|
||||
const [flow, setFlow] = useState<FlowLevel | null>(null);
|
||||
const [intermenstrual, setIntermenstrual] = useState(false);
|
||||
@@ -440,53 +512,53 @@ export function LogDaySheet({
|
||||
{t(cat.labelKey)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cat.symptoms.map((s) => {
|
||||
const active = symptoms.has(s.key);
|
||||
const sev = symptoms.get(s.key) ?? null;
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-1">
|
||||
<Chip
|
||||
active={active}
|
||||
onClick={() => toggleSymptom(s.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
</Chip>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={sev === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", {
|
||||
level: lvl,
|
||||
})}
|
||||
onClick={() => setSymptomSeverity(s.key, lvl)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
sev === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{cat.symptoms.map((s) => (
|
||||
<SymptomChip
|
||||
key={s.key}
|
||||
symptomKey={s.key}
|
||||
icon={s.icon}
|
||||
label={t(s.labelKey)}
|
||||
active={symptoms.has(s.key)}
|
||||
severity={symptoms.get(s.key) ?? null}
|
||||
onToggle={toggleSymptom}
|
||||
onSeverity={setSymptomSeverity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom symptoms — the user's own, under a `custom` category,
|
||||
plus the dashed ghost-chip that mints a new one. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("cycle.symptomCategory.custom")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(customSymptoms.data?.symptoms ?? []).map((s) => (
|
||||
<SymptomChip
|
||||
key={s.key}
|
||||
symptomKey={s.key}
|
||||
icon={customIcon(s.icon)}
|
||||
label={s.label ?? t("cycle.symptom.custom.fallbackLabel")}
|
||||
active={symptoms.has(s.key)}
|
||||
severity={symptoms.get(s.key) ?? null}
|
||||
custom={s}
|
||||
onToggle={toggleSymptom}
|
||||
onSeverity={setSymptomSeverity}
|
||||
onClear={(key) => {
|
||||
setSymptoms((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AddSymptomChip />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
@@ -649,3 +721,218 @@ function Field({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One symptom chip + its inline 1-4 severity selector (shared by the seeded
|
||||
* catalogue chips and the custom chips so both render identically). A custom
|
||||
* chip additionally carries a tiny remove (×) affordance that soft-hides the
|
||||
* symptom from the catalogue.
|
||||
*/
|
||||
function SymptomChip({
|
||||
symptomKey,
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
severity,
|
||||
custom,
|
||||
onToggle,
|
||||
onSeverity,
|
||||
onClear,
|
||||
}: {
|
||||
symptomKey: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
active: boolean;
|
||||
severity: number | null;
|
||||
custom?: CustomSymptomDTO;
|
||||
onToggle: (key: string) => void;
|
||||
onSeverity: (key: string, severity: number) => void;
|
||||
onClear?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const deleteCustom = useDeleteCustomSymptom();
|
||||
|
||||
async function handleRemove() {
|
||||
if (!custom) return;
|
||||
onClear?.(custom.key);
|
||||
await deleteCustom.mutateAsync(custom.key);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative inline-flex items-center">
|
||||
<Chip active={active} onClick={() => onToggle(symptomKey)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
</Chip>
|
||||
{custom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={deleteCustom.isPending}
|
||||
aria-label={t("cycle.symptom.custom.remove", { label })}
|
||||
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<X className="size-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{active ? (
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("cycle.sheet.severity")}
|
||||
>
|
||||
{SEVERITY_LEVELS.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
aria-pressed={severity === lvl}
|
||||
aria-label={t("cycle.sheet.severityLevel", { level: lvl })}
|
||||
onClick={() => onSeverity(symptomKey, lvl)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
severity === lvl
|
||||
? "border-primary bg-primary/15 text-primary font-semibold"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashed ghost-chip in the symptom grid that opens a compact label + icon
|
||||
* popover to mint a new custom symptom. Matches the chip grid (same rounded-
|
||||
* full pill, same size) so it reads as part of the grid, not a foreign button.
|
||||
*/
|
||||
function AddSymptomChip() {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateCustomSymptom();
|
||||
const [openForm, setOpenForm] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [icon, setIcon] = useState<string>("Tag");
|
||||
|
||||
function reset() {
|
||||
setLabel("");
|
||||
setIcon("Tag");
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
await create.mutateAsync({ label: trimmed, icon });
|
||||
reset();
|
||||
setOpenForm(false);
|
||||
}
|
||||
|
||||
const limitReached = create.isError;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={openForm}
|
||||
onOpenChange={(o) => {
|
||||
setOpenForm(o);
|
||||
if (!o) reset();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full border border-dashed px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("cycle.symptom.custom.add")}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-symptom-label" className="text-xs font-medium">
|
||||
{t("cycle.symptom.custom.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-symptom-label"
|
||||
value={label}
|
||||
maxLength={40}
|
||||
autoFocus
|
||||
placeholder={t("cycle.symptom.custom.labelPlaceholder")}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium">
|
||||
{t("cycle.symptom.custom.icon")}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("cycle.symptom.custom.icon")}
|
||||
>
|
||||
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
|
||||
const IconC = customIcon(name);
|
||||
const selected = icon === name;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={name}
|
||||
onClick={() => setIcon(name)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<IconC className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{limitReached ? (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t("cycle.symptom.custom.limitReached")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpenForm(false);
|
||||
}}
|
||||
>
|
||||
{t("cycle.sheet.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={!label.trim() || create.isPending}
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.symptom.custom.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* v1.15.0 — the seeded cycle-symptom catalog, mirrored client-side.
|
||||
*
|
||||
* The catalog ships seeded-only in v1.15.0 (no per-user custom CRUD yet),
|
||||
* so the keys are fixed at the migration seed (0129). The `key` is the
|
||||
* The keys are fixed at the migration seed (0129). The `key` is the
|
||||
* snake_case DB key the day-log write resolves against; the `labelKey` is
|
||||
* the camelCase i18n leaf under `cycle.symptom.*`; the `icon` is the
|
||||
* Lucide name from the seed. Keep this in lockstep with the seed in
|
||||
* `prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql` and
|
||||
* the `cycle.symptom.*` / `cycle.symptomCategory.*` i18n leaves.
|
||||
*
|
||||
* v1.15.1 layers per-user custom symptoms on top (the `custom` category,
|
||||
* seeded by migration 0134): the log-day sheet fetches them from
|
||||
* `/api/cycle/symptoms/custom` and merges them into this seeded grid. They
|
||||
* are NOT in this static catalog — their labels are user free text held
|
||||
* encrypted at rest, resolved server-side, never an i18n key.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
|
||||
@@ -110,6 +110,61 @@ export function useCycleHistory(limit = 24) {
|
||||
});
|
||||
}
|
||||
|
||||
/** One per-user custom symptom: a `custom:<uuid>` key + decrypted label. */
|
||||
export interface CustomSymptomDTO {
|
||||
key: string;
|
||||
label: string | null;
|
||||
icon: string | null;
|
||||
custom: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's own custom symptoms, labels decrypted server-side. The log-day
|
||||
* sheet merges these into the seeded chip grid under the `custom` category.
|
||||
*/
|
||||
export function useCustomSymptoms() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleCustomSymptoms(),
|
||||
queryFn: () =>
|
||||
fetch("/api/cycle/symptoms/custom").then((r) =>
|
||||
unwrap<{ symptoms: CustomSymptomDTO[] }>(r),
|
||||
),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a custom symptom (mint `custom:<uuid>`, encrypt the label). */
|
||||
export function useCreateCustomSymptom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { label: string; icon?: string | null }) => {
|
||||
const res = await fetch("/api/cycle/symptoms/custom", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return unwrap<CustomSymptomDTO>(res);
|
||||
},
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleCustomSymptoms() }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-hide a custom symptom by its key (history-preserving). */
|
||||
export function useDeleteCustomSymptom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
const res = await fetch(
|
||||
`/api/cycle/symptoms/custom/${encodeURIComponent(key)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
return unwrap<{ key: string; purged: boolean }>(res);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleProfile() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleProfile(),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.15.1 — custom cycle-symptom helpers: key mint/detect, label encrypt /
|
||||
* decrypt fail-soft, and the Zod create/update schemas.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => {
|
||||
if (!s.startsWith("enc:")) throw new Error("bad ciphertext");
|
||||
return s.replace(/^enc:/, "");
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
CUSTOM_SYMPTOM_KEY_PREFIX,
|
||||
MAX_CUSTOM_SYMPTOMS_PER_USER,
|
||||
createCustomSymptomSchema,
|
||||
decryptCustomLabel,
|
||||
encryptCustomLabel,
|
||||
isCustomSymptomKey,
|
||||
mintCustomSymptomKey,
|
||||
updateCustomSymptomSchema,
|
||||
} from "@/lib/cycle/custom-symptoms";
|
||||
|
||||
describe("custom-symptom keys", () => {
|
||||
it("mints a custom:<uuid> key the detector recognises", () => {
|
||||
const key = mintCustomSymptomKey();
|
||||
expect(key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX)).toBe(true);
|
||||
expect(isCustomSymptomKey(key)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a bare catalogue slug as custom", () => {
|
||||
expect(isCustomSymptomKey("cramps")).toBe(false);
|
||||
expect(isCustomSymptomKey("headache")).toBe(false);
|
||||
});
|
||||
|
||||
it("mints unique keys", () => {
|
||||
expect(mintCustomSymptomKey()).not.toBe(mintCustomSymptomKey());
|
||||
});
|
||||
});
|
||||
|
||||
describe("label encryption", () => {
|
||||
it("round-trips a label through encrypt/decrypt", () => {
|
||||
const enc = encryptCustomLabel("Schwindel");
|
||||
expect(enc).toBe("enc:Schwindel");
|
||||
expect(decryptCustomLabel(enc)).toBe("Schwindel");
|
||||
});
|
||||
|
||||
it("decrypts a corrupt/missing ciphertext fail-soft to null", () => {
|
||||
expect(decryptCustomLabel(null)).toBeNull();
|
||||
expect(decryptCustomLabel("not-encrypted")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create schema", () => {
|
||||
it("accepts a label + allow-listed icon", () => {
|
||||
const p = createCustomSymptomSchema.safeParse({
|
||||
label: "Dizziness",
|
||||
icon: "Brain",
|
||||
});
|
||||
expect(p.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty label and an over-long one", () => {
|
||||
expect(createCustomSymptomSchema.safeParse({ label: "" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "x".repeat(41) }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an icon outside the allow-list", () => {
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "X", icon: "Skull" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a categoryKey other than custom", () => {
|
||||
expect(
|
||||
createCustomSymptomSchema.safeParse({ label: "X", categoryKey: "physical" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update schema", () => {
|
||||
it("requires at least one field", () => {
|
||||
expect(updateCustomSymptomSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a partial update", () => {
|
||||
expect(
|
||||
updateCustomSymptomSchema.safeParse({ isActive: false }).success,
|
||||
).toBe(true);
|
||||
expect(updateCustomSymptomSchema.safeParse({ label: "New" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-user cap", () => {
|
||||
it("pins a sane ceiling", () => {
|
||||
expect(MAX_CUSTOM_SYMPTOMS_PER_USER).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* v1.15.1 — `resolveSymptomIds` resolves a mix of seeded catalogue keys AND
|
||||
* the caller's own `custom:` keys (the day-log write path for custom symptoms).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleSymptom: { findMany: 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/cycle/profile", () => ({
|
||||
getOrCreateCycleProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveSymptomIds } from "@/lib/cycle/day-log-write";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveSymptomIds", () => {
|
||||
it("queries the catalogue (userId null) AND the caller's own rows", async () => {
|
||||
db.cycleSymptom.findMany.mockResolvedValue([
|
||||
{ id: "cs_cramps", key: "cramps" },
|
||||
{ id: "id_custom", key: "custom:abc" },
|
||||
]);
|
||||
|
||||
const out = await resolveSymptomIds("user-1", ["cramps", "custom:abc"]);
|
||||
|
||||
expect(out).toEqual([
|
||||
{ key: "cramps", id: "cs_cramps" },
|
||||
{ key: "custom:abc", id: "id_custom" },
|
||||
]);
|
||||
const where = db.cycleSymptom.findMany.mock.calls[0][0].where;
|
||||
expect(where.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
|
||||
expect(where.key.in).toEqual(["cramps", "custom:abc"]);
|
||||
expect(where.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("drops unknown keys (returns only resolved rows)", async () => {
|
||||
db.cycleSymptom.findMany.mockResolvedValue([
|
||||
{ id: "id_custom", key: "custom:abc" },
|
||||
]);
|
||||
const out = await resolveSymptomIds("user-1", [
|
||||
"custom:abc",
|
||||
"custom:not-mine",
|
||||
]);
|
||||
expect(out).toEqual([{ key: "custom:abc", id: "id_custom" }]);
|
||||
});
|
||||
|
||||
it("short-circuits on an empty key set", async () => {
|
||||
const out = await resolveSymptomIds("user-1", []);
|
||||
expect(out).toEqual([]);
|
||||
expect(db.cycleSymptom.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
|
||||
/**
|
||||
* v1.15.1 — per-user custom cycle-symptom helpers (the v1.13 custom-mood-tag
|
||||
* precedent, mirrored 1:1).
|
||||
*
|
||||
* Custom symptoms live in the same `cycle_symptoms` table as the seeded
|
||||
* catalogue, distinguished by a non-null `userId`. Their key carries a
|
||||
* reserved `custom:` prefix so it never collides with a bare catalogue slug,
|
||||
* and the write path (`day-log-write.ts:resolveSymptomIds`) already resolves a
|
||||
* key against the catalogue rows (userId NULL) PLUS the caller's own rows —
|
||||
* so a `custom:` key routes to the user's symptom by construction. The label
|
||||
* is held AES-256-GCM-encrypted at rest (`labelEncrypted`): a symptom name is
|
||||
* intent-revealing free text, the highest-sensitivity category in the
|
||||
* post-Dobbs threat model.
|
||||
*/
|
||||
|
||||
/** Reserved key prefix for a user-minted custom symptom. */
|
||||
export const CUSTOM_SYMPTOM_KEY_PREFIX = "custom:";
|
||||
|
||||
/** Stable category key + seeded id every custom symptom hangs under. */
|
||||
export const CUSTOM_SYMPTOM_CATEGORY_KEY = "custom";
|
||||
export const CUSTOM_SYMPTOM_CATEGORY_ID = "csc_custom";
|
||||
|
||||
/** Per-user ceiling on custom symptoms (422 over the cap). */
|
||||
export const MAX_CUSTOM_SYMPTOMS_PER_USER = 50;
|
||||
|
||||
/**
|
||||
* Allow-list of Lucide icon names a custom symptom may use. Kept to names the
|
||||
* iOS client maps to an SF Symbol so a custom symptom never falls back to the
|
||||
* generic glyph on one platform. An unknown name → 422. `Tag` is the default.
|
||||
*/
|
||||
export const CUSTOM_SYMPTOM_ICON_ALLOWLIST = [
|
||||
"Tag",
|
||||
"Activity",
|
||||
"Heart",
|
||||
"HeartPulse",
|
||||
"Brain",
|
||||
"Zap",
|
||||
"Flame",
|
||||
"Snowflake",
|
||||
"Droplet",
|
||||
"CircleDot",
|
||||
"BatteryLow",
|
||||
"MoonStar",
|
||||
"PersonStanding",
|
||||
"Drama",
|
||||
"Frown",
|
||||
"Cookie",
|
||||
"Soup",
|
||||
"Pill",
|
||||
"Thermometer",
|
||||
"Stethoscope",
|
||||
] as const;
|
||||
|
||||
const ICON_SET = new Set<string>(CUSTOM_SYMPTOM_ICON_ALLOWLIST);
|
||||
|
||||
/** Is `key` a custom-symptom key (vs a bare catalogue slug)? */
|
||||
export function isCustomSymptomKey(key: string): boolean {
|
||||
return key.startsWith(CUSTOM_SYMPTOM_KEY_PREFIX);
|
||||
}
|
||||
|
||||
/** Mint a fresh, collision-proof custom-symptom key. */
|
||||
export function mintCustomSymptomKey(): string {
|
||||
return `${CUSTOM_SYMPTOM_KEY_PREFIX}${randomUUID()}`;
|
||||
}
|
||||
|
||||
/** Encrypt a custom label for storage in `label_encrypted`. */
|
||||
export function encryptCustomLabel(label: string): string {
|
||||
return encrypt(label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored custom label. Returns null on a missing or corrupt
|
||||
* ciphertext so one bad row never throws the whole effective-set read.
|
||||
*/
|
||||
export function decryptCustomLabel(encoded: string | null): string | null {
|
||||
if (!encoded) return null;
|
||||
try {
|
||||
return decrypt(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const labelSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Label must not be empty")
|
||||
.max(40, "Label must be at most 40 characters");
|
||||
|
||||
const iconSchema = z
|
||||
.string()
|
||||
.refine((v) => ICON_SET.has(v), "Unknown icon")
|
||||
.nullish();
|
||||
|
||||
/** POST body — create a custom symptom. */
|
||||
export const createCustomSymptomSchema = z.object({
|
||||
label: labelSchema,
|
||||
icon: iconSchema,
|
||||
// Reserved for a future per-symptom category choice; v1 pins everything to
|
||||
// the `custom` category, so a supplied value other than `custom` is rejected.
|
||||
categoryKey: z.literal(CUSTOM_SYMPTOM_CATEGORY_KEY).optional(),
|
||||
});
|
||||
|
||||
/** PATCH body — update a custom symptom (every field optional). */
|
||||
export const updateCustomSymptomSchema = z
|
||||
.object({
|
||||
label: labelSchema.optional(),
|
||||
icon: iconSchema,
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) =>
|
||||
v.label !== undefined || v.icon !== undefined || v.isActive !== undefined,
|
||||
"At least one field is required",
|
||||
);
|
||||
@@ -518,6 +518,9 @@ export const queryKeys = {
|
||||
cyclePrefs: () => ["cycle", "prefs"] as const,
|
||||
cycleInsights: () => ["cycle", "insights"] as const,
|
||||
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
|
||||
/** The caller's own custom symptoms (decrypted labels) the log-day sheet
|
||||
* merges into the seeded chip grid. */
|
||||
cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user