diff --git a/messages/de.json b/messages/de.json index 917b68859..ff74825d3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index e040f7fb4..57a864f32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index 1733028ff..834746511 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." diff --git a/messages/fr.json b/messages/fr.json index 9aded9315..a40242778 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index bbaf665f7..d6dd8ef64 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/pl.json b/messages/pl.json index ad3a65dc3..516459dd2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." diff --git a/prisma/migrations/0134_v1151_cycle_custom_symptom_category/migration.sql b/prisma/migrations/0134_v1151_cycle_custom_symptom_category/migration.sql new file mode 100644 index 000000000..cc37312f9 --- /dev/null +++ b/prisma/migrations/0134_v1151_cycle_custom_symptom_category/migration.sql @@ -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"; diff --git a/src/app/api/cycle/all/route.ts b/src/app/api/cycle/all/route.ts index 170586667..f289432d7 100644 --- a/src/app/api/cycle/all/route.ts +++ b/src/app/api/cycle/all/route.ts @@ -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, diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts new file mode 100644 index 000000000..2ed227c90 --- /dev/null +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts new file mode 100644 index 000000000..e78df3059 --- /dev/null +++ b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts @@ -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)(); + 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" } }); + }); +}); diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts new file mode 100644 index 000000000..87683267c --- /dev/null +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -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:` 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, + ); +}); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 2ef9e9eb8..036813566 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -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 = { + 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(null); const [intermenstrual, setIntermenstrual] = useState(false); @@ -440,53 +512,53 @@ export function LogDaySheet({ {t(cat.labelKey)}
- {cat.symptoms.map((s) => { - const active = symptoms.has(s.key); - const sev = symptoms.get(s.key) ?? null; - return ( -
- toggleSymptom(s.key)} - > - - - - {active ? ( -
- {SEVERITY_LEVELS.map((lvl) => ( - - ))} -
- ) : null} -
- ); - })} + {cat.symptoms.map((s) => ( + + ))}
))} + + {/* Custom symptoms — the user's own, under a `custom` category, + plus the dashed ghost-chip that mints a new one. */} +
+
+
+
+ {(customSymptoms.data?.symptoms ?? []).map((s) => ( + { + setSymptoms((prev) => { + const next = new Map(prev); + next.delete(key); + return next; + }); + }} + /> + ))} + +
+
@@ -649,3 +721,218 @@ function Field({ ); } + +/** + * 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 ( +
+ + onToggle(symptomKey)}> + + + + {custom ? ( + + ) : null} + + {active ? ( +
+ {SEVERITY_LEVELS.map((lvl) => ( + + ))} +
+ ) : null} +
+ ); +} + +/** + * 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("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 ( + { + setOpenForm(o); + if (!o) reset(); + }} + > + + + + +
+ + setLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} + /> +
+
+

+ {t("cycle.symptom.custom.icon")} +

+
+ {CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => { + const IconC = customIcon(name); + const selected = icon === name; + return ( + + ); + })} +
+
+ {limitReached ? ( +

+ {t("cycle.symptom.custom.limitReached")} +

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/src/components/cycle/symptom-catalog.ts b/src/components/cycle/symptom-catalog.ts index 5351cfccc..e2c0386d9 100644 --- a/src/components/cycle/symptom-catalog.ts +++ b/src/components/cycle/symptom-catalog.ts @@ -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, diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d4ee8bf4a..8a7c430f8 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -110,6 +110,61 @@ export function useCycleHistory(limit = 24) { }); } +/** One per-user custom symptom: a `custom:` 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:`, 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(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(), diff --git a/src/lib/cycle/__tests__/custom-symptoms.test.ts b/src/lib/cycle/__tests__/custom-symptoms.test.ts new file mode 100644 index 000000000..ef89a3bc7 --- /dev/null +++ b/src/lib/cycle/__tests__/custom-symptoms.test.ts @@ -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: 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); + }); +}); diff --git a/src/lib/cycle/__tests__/resolve-symptom-ids.test.ts b/src/lib/cycle/__tests__/resolve-symptom-ids.test.ts new file mode 100644 index 000000000..1337f8c4e --- /dev/null +++ b/src/lib/cycle/__tests__/resolve-symptom-ids.test.ts @@ -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(); + }); +}); diff --git a/src/lib/cycle/custom-symptoms.ts b/src/lib/cycle/custom-symptoms.ts new file mode 100644 index 000000000..3028c29eb --- /dev/null +++ b/src/lib/cycle/custom-symptoms.ts @@ -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(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", + ); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 30717c68b..bb3ba5fa2 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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, }; /**