From a635c1de0526ef176a754a3c805e11b9cfea44e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 17:19:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(cycle):=20v1.15.1=20QA=20reconcile=20?= =?UTF-8?q?=E2=80=94=20purge,=20rate=20limits,=20marker=20contrast,=20toke?= =?UTF-8?q?n=20discipline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CYCLE_FERTILE_SOON to the cycle-purge push_attempts eventType list so a data reset leaves no dated reproductive-intent delivery trace. - Rate-limit the custom-symptom mutations (POST create, PATCH, DELETE) under a shared cycle:symptom:custom: bucket (30/min), mirroring the insights route, so a session can't flood the encrypted-label catalogue. - Re-run the active-count cap inside PATCH when a symptom reactivates (hidden → active); a hidden row doesn't count toward the cap, so without the recheck a user could exceed it by hiding then re-enabling rows. - Gate the log-day "limit reached" copy on the cap errorCode rather than any create failure, so a transient/validation error no longer masquerades as the cap being hit; thread the returned key so a new custom symptom auto-selects. - Move the history-chart bar fill alphas into named phase-tokens (HISTORY_REST_OPACITY / HISTORY_PERIOD_OPACITY) so bar intensity lives in one token source alongside FLOW_OPACITY. - Strengthen the confirmed-ovulation calendar marker (ring 1.5px → 2px, fill alpha 0.16 → 0.22) so it stays distinct from the predicted dot on small light screens; keeps its data-attr + aria naming. - Raise the phase-education chip border from /10 to /15 for legible contrast on the tinted tile. --- src/app/api/cycle/all/route.ts | 8 +++- .../api/cycle/symptoms/custom/[key]/route.ts | 39 ++++++++++++++++++- .../custom/__tests__/custom-crud.test.ts | 36 +++++++++++++++++ src/app/api/cycle/symptoms/custom/route.ts | 8 ++++ src/components/cycle/cycle-calendar.tsx | 4 +- src/components/cycle/cycle-history-chart.tsx | 12 ++++-- src/components/cycle/log-day-sheet.tsx | 30 +++++++++++--- src/components/cycle/phase-education-card.tsx | 2 +- src/components/cycle/phase-tokens.ts | 10 +++++ src/components/cycle/use-cycle.ts | 26 ++++++++++++- 10 files changed, 160 insertions(+), 15 deletions(-) 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 ? (