mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(cycle): v1.15.1 QA reconcile — purge, rate limits, marker contrast, token discipline
- 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:<userId> 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.
This commit is contained in:
1 parent
2f44654dbe
commit
a635c1de05
10 files changed
+160
-15
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -254,11 +254,11 @@ export function CycleCalendar({
|
||||
{isConfirmedOvulation ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-1 inset-y-2 rounded-full ring-[1.5px] ring-inset"
|
||||
className="absolute inset-x-1 inset-y-2 rounded-full ring-2 ring-inset"
|
||||
style={
|
||||
{
|
||||
backgroundColor: OVULATION_HUE,
|
||||
opacity: 0.16,
|
||||
opacity: 0.22,
|
||||
"--tw-ring-color": OVULATION_HUE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
||||
@@ -13,7 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CycleHistoryResponse, MenstrualCycleDTO } from "./types";
|
||||
import { FLOW_HUE, OVULATION_HUE, PHASE_HUE } from "./phase-tokens";
|
||||
import {
|
||||
FLOW_HUE,
|
||||
OVULATION_HUE,
|
||||
PHASE_HUE,
|
||||
HISTORY_REST_OPACITY,
|
||||
HISTORY_PERIOD_OPACITY,
|
||||
} from "./phase-tokens";
|
||||
|
||||
/**
|
||||
* v1.15.1 — the cycle-length history chart.
|
||||
@@ -296,7 +302,7 @@ function BarField({
|
||||
height={Math.max(restH, 0)}
|
||||
rx={2}
|
||||
fill={REST_HUE}
|
||||
fillOpacity={0.5}
|
||||
fillOpacity={HISTORY_REST_OPACITY}
|
||||
/>
|
||||
) : null}
|
||||
{/* Period segment (lower) — rose, the bleeding portion. */}
|
||||
@@ -308,7 +314,7 @@ function BarField({
|
||||
height={periodH}
|
||||
rx={2}
|
||||
fill={FLOW_HUE}
|
||||
fillOpacity={0.85}
|
||||
fillOpacity={HISTORY_PERIOD_OPACITY}
|
||||
data-period-segment="true"
|
||||
>
|
||||
<title>{periodLabel}</title>
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
useLogDay,
|
||||
usePatchDayLog,
|
||||
useStartPeriod,
|
||||
CustomSymptomError,
|
||||
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE,
|
||||
type CustomSymptomDTO,
|
||||
type CycleDayLogPatch,
|
||||
} from "./use-cycle";
|
||||
@@ -556,7 +558,7 @@ export function LogDaySheet({
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AddSymptomChip />
|
||||
<AddSymptomChip onCreated={toggleSymptom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -813,7 +815,11 @@ function SymptomChip({
|
||||
* 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() {
|
||||
function AddSymptomChip({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateCustomSymptom();
|
||||
const [openForm, setOpenForm] = useState(false);
|
||||
@@ -828,12 +834,24 @@ function AddSymptomChip() {
|
||||
async function handleCreate() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
await create.mutateAsync({ label: trimmed, icon });
|
||||
reset();
|
||||
setOpenForm(false);
|
||||
try {
|
||||
const created = await create.mutateAsync({ label: trimmed, icon });
|
||||
// Auto-select the freshly-minted symptom so the user doesn't have to tap
|
||||
// the new chip after adding it.
|
||||
onCreated(created.key);
|
||||
reset();
|
||||
setOpenForm(false);
|
||||
} catch {
|
||||
// Keep the popover open; `create.error` drives the inline message
|
||||
// (cap copy on the limit errorCode, generic otherwise).
|
||||
}
|
||||
}
|
||||
|
||||
const limitReached = create.isError;
|
||||
// Only the per-user cap surfaces the "limit reached" copy — a transient or
|
||||
// validation error must not masquerade as the cap being hit.
|
||||
const limitReached =
|
||||
create.error instanceof CustomSymptomError &&
|
||||
create.error.errorCode === CUSTOM_SYMPTOM_LIMIT_ERROR_CODE;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
||||
@@ -178,7 +178,7 @@ export function PhaseEducationCard({
|
||||
return (
|
||||
<li
|
||||
key={row.symptomKey}
|
||||
className="border-foreground/10 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
|
||||
className="border-foreground/15 bg-background/55 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
|
||||
@@ -44,6 +44,16 @@ export const FLOW_OPACITY: Record<Exclude<FlowLevel, "NONE">, number> = {
|
||||
/** The default period-day fill alpha when a logged day carries no flow grade. */
|
||||
export const FLOW_OPACITY_DEFAULT = 0.32;
|
||||
|
||||
/**
|
||||
* History-chart bar fill alphas — the two-segment stacked bar (rest-of-cycle
|
||||
* upper, period lower) keeps its intensity here so the chart and the calendar
|
||||
* draw their fills from one token source rather than inline literals. The
|
||||
* period segment sits denser than the rest span so the bleeding portion reads
|
||||
* as the anchor of each bar.
|
||||
*/
|
||||
export const HISTORY_REST_OPACITY = 0.5;
|
||||
export const HISTORY_PERIOD_OPACITY = 0.85;
|
||||
|
||||
/** Resolve the flow-shading alpha for a logged period day (graceful fallback). */
|
||||
export function flowOpacity(flow: string | null | undefined): number {
|
||||
if (flow && flow !== "NONE" && flow in FLOW_OPACITY) {
|
||||
|
||||
@@ -133,6 +133,20 @@ export function useCustomSymptoms() {
|
||||
});
|
||||
}
|
||||
|
||||
/** The `errorCode` the create POST returns when the per-user cap is hit. */
|
||||
export const CUSTOM_SYMPTOM_LIMIT_ERROR_CODE = "cycle.symptom.custom.limit";
|
||||
|
||||
/** An error that preserves the envelope `errorCode` so callers can branch. */
|
||||
export class CustomSymptomError extends Error {
|
||||
constructor(
|
||||
public readonly errorCode: string | null,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(errorCode ?? `Request failed: ${status}`);
|
||||
this.name = "CustomSymptomError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a custom symptom (mint `custom:<uuid>`, encrypt the label). */
|
||||
export function useCreateCustomSymptom() {
|
||||
const qc = useQueryClient();
|
||||
@@ -143,7 +157,17 @@ export function useCreateCustomSymptom() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return unwrap<CustomSymptomDTO>(res);
|
||||
if (!res.ok) {
|
||||
// Preserve the envelope errorCode so the UI can tell the cap hit
|
||||
// (cycle.symptom.custom.limit) apart from a transient/validation error.
|
||||
const errorCode = await res
|
||||
.json()
|
||||
.then((j) => (j?.meta?.errorCode as string | undefined) ?? null)
|
||||
.catch(() => null);
|
||||
throw new CustomSymptomError(errorCode, res.status);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data as CustomSymptomDTO;
|
||||
},
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleCustomSymptoms() }),
|
||||
|
||||
Reference in new issue
Block a user