diff --git a/src/app/api/cycle/all/route.ts b/src/app/api/cycle/all/route.ts index f289432d7..3d54a91ef 100644 --- a/src/app/api/cycle/all/route.ts +++ b/src/app/api/cycle/all/route.ts @@ -68,7 +68,13 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const pushRows = await tx.pushAttempt.deleteMany({ where: { userId: user.id, - eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] }, + eventType: { + in: [ + "CYCLE_PERIOD_SOON", + "CYCLE_PERIOD_CONFIRM", + "CYCLE_FERTILE_SOON", + ], + }, }, }); // Reset the reproductive INTENT carried on the profile (goal + cycle/period/ diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts index 2ed227c90..1d9b74d4b 100644 --- a/src/app/api/cycle/symptoms/custom/[key]/route.ts +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -18,12 +18,14 @@ import { import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { updateCustomSymptomSchema, decryptCustomLabel, encryptCustomLabel, isCustomSymptomKey, + MAX_CUSTOM_SYMPTOMS_PER_USER, } from "@/lib/cycle/custom-symptoms"; export const dynamic = "force-dynamic"; @@ -38,6 +40,15 @@ export const PATCH = apiHandler( const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; + const rl = await checkRateLimit( + `cycle:symptom:custom:${user.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + const { key } = await params; if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404); @@ -50,10 +61,27 @@ export const PATCH = apiHandler( const owned = await prisma.cycleSymptom.findFirst({ where: { key, userId: user.id }, - select: { id: true }, + select: { id: true, isActive: true }, }); if (!owned) return apiError("Custom symptom not found", 404); + // Reactivation (soft-hidden → active) re-enters the active set, so it must + // re-clear the per-user cap — a hidden row doesn't count toward it, and + // skipping the recheck here would let a user exceed the limit by hiding + // then re-enabling rows. + if (parsed.data.isActive === true && !owned.isActive) { + 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 updated = await prisma.cycleSymptom.update({ where: { id: owned.id }, data: { @@ -91,6 +119,15 @@ export const DELETE = apiHandler( const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; + const rl = await checkRateLimit( + `cycle:symptom:custom:${user.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + const { key } = await params; if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404); 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 index e78df3059..5c6e1f287 100644 --- a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts +++ b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts @@ -33,6 +33,9 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn(), })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 29 }), +})); vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -161,6 +164,39 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => { expect(db.cycleSymptom.findFirst).not.toHaveBeenCalled(); }); + it("rechecks the cap on reactivation (hidden → active) and 422s when full", async () => { + // A soft-hidden row doesn't count toward the active cap; flipping it back + // to active must re-clear the limit so hide-then-reenable can't bypass it. + db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false }); + db.cycleSymptom.count.mockResolvedValue(50); + const res = await PATCH( + jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", { + isActive: true, + }), + params("custom:abc"), + ); + expect(res.status).toBe(422); + expect(db.cycleSymptom.update).not.toHaveBeenCalled(); + }); + + it("does not recheck the cap when reactivation is not requested", async () => { + db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: true }); + db.cycleSymptom.update.mockResolvedValue({ + key: "custom:abc", + icon: "Tag", + isActive: true, + labelEncrypted: "enc:Renamed", + }); + const res = await PATCH( + jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", { + label: "Renamed", + }), + params("custom:abc"), + ); + expect(res.status).toBe(200); + expect(db.cycleSymptom.count).not.toHaveBeenCalled(); + }); + it("soft-deactivates by default and hard-deletes on ?purge=true", async () => { db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1" }); const soft = await DELETE( diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index 87683267c..956f93c41 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -26,6 +26,7 @@ import { import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { createCustomSymptomSchema, @@ -71,6 +72,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; + // Cap mutation rate so a single session can't flood the encrypted-label + // catalogue (each create runs an encrypt + audit write). + const rl = await checkRateLimit(`cycle:symptom:custom:${user.id}`, 30, 60_000); + if (!rl.allowed) { + return apiError("Too many requests, try again later", 429); + } + const parsed = createCustomSymptomSchema.safeParse(await request.json()); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index 84b16ad90..f9b46304a 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -254,11 +254,11 @@ export function CycleCalendar({ {isConfirmedOvulation ? (