diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index cc866aa62..31a11a0e1 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -22,8 +22,10 @@ import { safeJson, } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { withIdempotency } from "@/lib/idempotency"; import { cyclePeriodSchema } from "@/lib/validations/cycle"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import type { FlowLevel } from "@/lib/cycle/types"; import { toCycleDayLogDTO, toMenstrualCycleDTO, @@ -32,7 +34,9 @@ import { import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; -export const POST = apiHandler(async (request: NextRequest) => { +export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod)); + +async function postPeriod(request: NextRequest): Promise { const { user } = await requireAuth(); const gate = await requireCycleEnabled(user.id, user.gender); @@ -55,59 +59,85 @@ export const POST = apiHandler(async (request: NextRequest) => { const { action, date, externalId, loggedAt } = parsed.data; const tz = user.timezone ?? DEFAULT_TIMEZONE; - let cycleId: string; + // The close-prior + open-new (start) or stamp-current (end) mutation set + // is run inside a single transaction so two concurrent taps can never + // double-close a prior cycle or compute the new cycle's length against a + // stale read. `withIdempotency` on the route additionally collapses an + // exact-key replay; the transaction guards the interleaving case. + const txResult = await prisma.$transaction(async (db) => { + if (action === "start") { + // Close the prior open cycle (its end is the day before this start) + // and record its observed length. + const prior = await db.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lt: date } }, + orderBy: { startDate: "desc" }, + select: { id: true, startDate: true }, + }); + if (prior) { + await db.menstrualCycle.update({ + where: { id: prior.id }, + data: { + endDate: addDays(date, -1), + lengthDays: dayDiff(date, prior.startDate), + syncVersion: { increment: 1 }, + }, + }); + } - if (action === "start") { - // Close the prior open cycle (its end is the day before this start) - // and record its observed length. - const prior = await prisma.menstrualCycle.findFirst({ - where: { userId: user.id, deletedAt: null, startDate: { lt: date } }, - orderBy: { startDate: "desc" }, - select: { id: true, startDate: true }, - }); - if (prior) { - await prisma.menstrualCycle.update({ - where: { id: prior.id }, - data: { - endDate: addDays(date, -1), - lengthDays: dayDiff(date, prior.startDate), + // Upsert the new cycle on the `(userId, startDate)` unique so a + // re-tap on the same day is idempotent. + const cycle = await db.menstrualCycle.upsert({ + where: { userId_startDate: { userId: user.id, startDate: date } }, + create: { userId: user.id, startDate: date, tz, isPredicted: false }, + update: { + deletedAt: null, + isPredicted: false, syncVersion: { increment: 1 }, }, }); + return { cycleId: cycle.id as string | null }; } - // Upsert the new cycle on the `(userId, startDate)` unique so a - // re-tap on the same day is idempotent. - const cycle = await prisma.menstrualCycle.upsert({ - where: { userId_startDate: { userId: user.id, startDate: date } }, - create: { userId: user.id, startDate: date, tz, isPredicted: false }, - update: { deletedAt: null, isPredicted: false, syncVersion: { increment: 1 } }, - }); - cycleId = cycle.id; - } else { // `end`: stamp the current cycle's periodEndDate. - const current = await prisma.menstrualCycle.findFirst({ + const current = await db.menstrualCycle.findFirst({ where: { userId: user.id, deletedAt: null, startDate: { lte: date } }, orderBy: { startDate: "desc" }, select: { id: true }, }); if (!current) { - return apiSuccessNoCycle(); + return { cycleId: null }; } - await prisma.menstrualCycle.update({ + await db.menstrualCycle.update({ where: { id: current.id }, data: { periodEndDate: date, syncVersion: { increment: 1 } }, }); - cycleId = current.id; + return { cycleId: current.id as string | null }; + }); + + if (txResult.cycleId === null) { + return apiSuccessNoCycle(); } + const cycleId: string = txResult.cycleId; + + // Boundary flow. Start → MEDIUM opens the bleed; end → SPOTTING tail. + // Never downgrade a richer flow already logged for the same day (e.g. a + // manual HEAVY entry must survive a later one-tap "start") — only set the + // boundary flow when it ranks at or above what's stored. + const boundaryFlow: FlowLevel = action === "start" ? "MEDIUM" : "SPOTTING"; + const existingDay = await prisma.cycleDayLog.findFirst({ + where: { userId: user.id, date, deletedAt: null }, + select: { flow: true }, + }); + const flow = + flowRank(boundaryFlow) >= flowRank(existingDay?.flow ?? null) + ? boundaryFlow + : undefined; - // Boundary day-log. Start → MEDIUM flow opens the bleed; end → SPOTTING - // tail (the last logged bleeding day). await upsertCycleDayLog( user.id, { date, - flow: action === "start" ? "MEDIUM" : "SPOTTING", + ...(flow !== undefined ? { flow } : {}), loggedAt, source: "MANUAL", ...(externalId ? { externalId } : {}), @@ -143,9 +173,31 @@ export const POST = apiHandler(async (request: NextRequest) => { cycle: toMenstrualCycleDTO(cycleRow), dayLog: toCycleDayLogDTO(dayLogRow), }); -}); +} /** `end` with no preceding cycle is a no-op the client should not hit. */ function apiSuccessNoCycle(): Response { return apiSuccess({ cycle: null, dayLog: null }); } + +/** + * Ordinal rank of a flow level (NONE = 0 … HEAVY = 5; a missing flow ranks + * below NONE). Used to keep the one-tap boundary flow from downgrading a + * richer same-day entry. + */ +function flowRank(flow: FlowLevel | string | null): number { + switch (flow) { + case "NONE": + return 1; + case "SPOTTING": + return 2; + case "LIGHT": + return 3; + case "MEDIUM": + return 4; + case "HEAVY": + return 5; + default: + return 0; + } +} diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts index 586004099..7c50f6f23 100644 --- a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -139,6 +139,32 @@ describe("buildCycleSnapshotBlock", () => { ).toBe(true); }); + it("derives 'today' from the threaded user timezone, not wall-clock", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + + // NOW is 2026-03-22T12:00Z. In Berlin (UTC+1) the local day is the + // 22nd → day-of-cycle 21 (cycle started 2026-03-02). In Kiritimati + // (UTC+14) the local day has already rolled to the 23rd → day 22. + const berlin = await buildCycleSnapshotBlock( + "u1", + "FEMALE", + NOW, + "Europe/Berlin", + ); + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + const kiritimati = await buildCycleSnapshotBlock( + "u1", + "FEMALE", + NOW, + "Pacific/Kiritimati", + ); + + expect(berlin!.dayOfCycle).toBe(21); + expect(kiritimati!.dayOfCycle).toBe(22); + }); + it("includes the headline phase insight when a contrast clears FDR", async () => { prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index ad090cc06..c3c3e24f2 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -126,6 +126,7 @@ export async function buildCycleSnapshotBlock( userId: string, gender: string | null | undefined, now: Date = new Date(), + timezone: string | null | undefined = DEFAULT_TIMEZONE, ): Promise { // Test environments may mock only part of prisma — bow out silently if the // cycle models are absent (matches "no cycle data" production behaviour). @@ -154,7 +155,11 @@ export async function buildCycleSnapshotBlock( rawChartMode: profile.rawChartMode, }; - const tz = DEFAULT_TIMEZONE; + // Derive "today" from the user's timezone — matches how the calendar + // (calendar/route.ts) and insights routes resolve it, so the Coach's + // "you are on day N / period in M days" never disagrees with the calendar + // near local midnight. + const tz = timezone ?? DEFAULT_TIMEZONE; const today = moodDateKey(now, tz); const from = addDays(today, -WINDOW_DAYS); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 78e42ca89..e54166c25 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -812,7 +812,7 @@ async function buildCoachSnapshotImpl( prefsRow?.cycleProfile ?? null, ); const cycleBlockPromise = cycleEnabled - ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now) + ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz) : null; const [ diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts index fd32fbf0a..e3de4100d 100644 --- a/src/lib/jobs/__tests__/cycle-reminder.test.ts +++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts @@ -462,3 +462,47 @@ describe("runCycleReminderTick", () => { expect(summary.dispatchedPeriodSoon).toBe(1); // u2 still processed }); }); + +describe("runCycleReminderTick — windowing query", () => { + it("pushes the cohort gate into the findMany where-clause", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const prisma = { + cyclePrediction: { findMany }, + pushAttempt: { findFirst: vi.fn(async () => null) }, + menstrualCycle: { findMany: vi.fn(async () => []) }, + }; + const dispatch = vi.fn(async () => OK); + await runCycleReminderTick( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + AT_0900_BERLIN, + { + dispatch: + dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification, + }, + ); + + expect(findMany).toHaveBeenCalledTimes(1); + const arg = findMany.mock.calls[0][0] as unknown as { + where: { + nextPeriodStart: { gte: string; lte: string }; + user: { + cycleProfile: { + cycleTrackingEnabled: boolean; + NOT: { predictionEnabled: boolean }; + }; + }; + }; + }; + // Tracking + prediction gate is in the query, not JS. + expect(arg.where.user.cycleProfile.cycleTrackingEnabled).toBe(true); + expect(arg.where.user.cycleProfile.NOT.predictionEnabled).toBe(false); + // The window is a YYYY-MM-DD string range straddling "now". At + // 2026-05-17 it spans [today-grace-1 .. today+lead+1] in UTC. + expect(arg.where.nextPeriodStart.gte).toBe("2026-05-13"); + expect(arg.where.nextPeriodStart.lte).toBe("2026-05-20"); + expect(arg.where.nextPeriodStart.gte < arg.where.nextPeriodStart.lte).toBe( + true, + ); + }); +}); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index 93c55c9d1..11bee8c0d 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -3,8 +3,10 @@ * * Mirrors `mood-reminder.ts` and the medication-reminder cron: a cron tick * fires every 15 minutes, and when it lands inside the user's reminder hour - * in their local timezone this module scans every cycle-enabled user with a - * cached `CyclePrediction`, decides who is in-window, and dispatches at most + * in their local timezone this module loads only the in-window cohort — + * cycle-tracking-enabled users whose cached `CyclePrediction.nextPeriodStart` + * falls inside the reminder window — decides who is in-window, and dispatches + * at most * one push per event per local day through the existing * APNs → Telegram → ntfy → Web-Push cascade (`dispatchNotification`), which * records every attempt in `push_attempts`. @@ -81,6 +83,20 @@ export interface CycleReminderSummary { failed: number; } +/** + * `YYYY-MM-DD` for `now + offsetDays` in UTC. Used only to bound the + * `nextPeriodStart` query window generously across timezones; the exact + * per-user-local window is applied by `evaluateCycleReminder`. + */ +function utcDateString(now: Date, offsetDays: number): string { + const d = new Date(now.getTime() + offsetDays * 86_400_000); + return `${d.getUTCFullYear().toString().padStart(4, "0")}-${( + d.getUTCMonth() + 1 + ) + .toString() + .padStart(2, "0")}-${d.getUTCDate().toString().padStart(2, "0")}`; +} + function resolveLocale(locale: string | null | undefined): Locale { return locales.includes(locale as Locale) ? (locale as Locale) @@ -244,9 +260,35 @@ export async function runCycleReminderTick( const dispatchImpl = options.dispatch ?? dispatchNotification; const summary = emptySummary(); - // Only users with a cached forecast can be in-window; the join also - // narrows to the cohort that has logged enough to predict. + // Push the gating into the query so the per-tick cost scales with the + // in-window cohort, not the whole `CyclePrediction` table. Three filters + // run server-side: + // + // 1. the user's `cycleProfile` must have `cycleTrackingEnabled` (an + // account that never opted in can never receive a cycle push), and + // `predictionEnabled` must not be explicitly off, + // 2. `nextPeriodStart` (a `YYYY-MM-DD` string) must fall inside the + // reminder window [today − grace, today + lead]. The string range is + // computed against a tz-generous span (±1 day past the exact + // lead/grace) so a user whose local date differs from UTC by a day is + // never excluded at the query layer — `evaluateCycleReminder` still + // applies the exact per-user-timezone window below. + // + // The pure-string `gte`/`lte` range is correct because `YYYY-MM-DD` + // ordering is lexicographic-equals-chronological. + const windowFloor = utcDateString(now, -(PERIOD_CONFIRM_GRACE_DAYS + 1)); + const windowCeil = utcDateString(now, PERIOD_SOON_LEAD_DAYS + 1); + const predictions = await prisma.cyclePrediction.findMany({ + where: { + nextPeriodStart: { gte: windowFloor, lte: windowCeil }, + user: { + cycleProfile: { + cycleTrackingEnabled: true, + NOT: { predictionEnabled: false }, + }, + }, + }, select: { userId: true, nextPeriodStart: true, diff --git a/src/lib/sync/__tests__/cursor.test.ts b/src/lib/sync/__tests__/cursor.test.ts index d8faff9bc..6136b1451 100644 --- a/src/lib/sync/__tests__/cursor.test.ts +++ b/src/lib/sync/__tests__/cursor.test.ts @@ -42,10 +42,13 @@ describe("sync cursor codec", () => { expect(decodeCursor("")).toBeNull(); // valid base64url but not the expected JSON shape expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull(); - // wrong version — v1.15.0 bumped CURSOR_VERSION to 2, so a v1 token - // (the pre-cycle format) now decodes to null → clean re-init. + // unsupported version (v3 is not yet a thing) → clean re-init. expect( - decodeCursor(Buffer.from('{"v":1,"d":{}}').toString("base64url")), + decodeCursor(Buffer.from('{"v":3,"d":{}}').toString("base64url")), + ).toBeNull(); + // a non-numeric version is garbage too. + expect( + decodeCursor(Buffer.from('{"v":"1","d":{}}').toString("base64url")), ).toBeNull(); // missing the `d` envelope expect( @@ -53,6 +56,32 @@ describe("sync cursor codec", () => { ).toBeNull(); }); + it("decodes a live v1 beta token additively — keeps known domains, fresh-scans the two new ones", () => { + // A pre-cycle v1 cursor only ever carried measurements/mood/intakes. + // It must NOT force a full re-init now that v2 added cycleDays/cycles; + // the known watermarks survive and the new domains decode as absent + // (a fresh scan of just those two). + const v1Token = Buffer.from( + JSON.stringify({ + v: 1, + d: { + measurements: { u: 1_717_000_000_000, i: "clx0meas" }, + mood: { u: 1_717_000_500_000, i: "clx0mood" }, + intakes: { u: 1_717_000_900_000, i: "clx0intk" }, + }, + }), + ).toString("base64url"); + const decoded = decodeCursor(v1Token); + expect(decoded).toEqual({ + measurements: { updatedAtMs: 1_717_000_000_000, id: "clx0meas" }, + mood: { updatedAtMs: 1_717_000_500_000, id: "clx0mood" }, + intakes: { updatedAtMs: 1_717_000_900_000, id: "clx0intk" }, + }); + // cycleDays + cycles absent → caller fresh-scans only those domains. + expect(decoded?.cycleDays).toBeUndefined(); + expect(decoded?.cycles).toBeUndefined(); + }); + it("decodes a current-version empty envelope to a fresh-scan map", () => { expect( decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")), diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index b74d40152..bb260f71f 100644 --- a/src/lib/sync/cursor.ts +++ b/src/lib/sync/cursor.ts @@ -14,9 +14,16 @@ * token back verbatim and never parses it (iOS-coord §7.6), so the * server is free to change this format without a client release. The * current encoding is base64url-of-JSON; nothing depends on that choice. - * A device on the old single-domain token simply decodes to null (the - * shape no longer matches) and re-inits cleanly — acceptable because no - * live consumer cursors exist yet. + * + * Version compatibility is additive, not breaking. The iOS client has + * been in public beta since v1.5 and the delta feed shipped in v1.7.0, so + * live v1 cursors DO exist in the field. A v1 token (which only carried + * the measurements / mood / intakes domains) decodes normally: its known + * domain watermarks are preserved and the two domains added in v2 + * (`cycleDays`, `cycles`) are simply absent → a fresh scan of just those + * two domains on the next sync, with no re-download of the measurement / + * mood / intake history. Only a genuinely malformed / unparseable token + * re-inits from scratch. */ /** The sync domains the feed serves. */ @@ -50,11 +57,16 @@ export interface DomainWatermark { export type SyncCursor = Partial>; // v1.15.0 — bumped to 2 when the `cycleDays` + `cycles` domains were -// added. An in-flight v1 token decodes to null (wrong version) → clean -// re-init, which is acceptable because the cursor is opaque and the -// client re-syncs from scratch. +// added. New tokens are stamped v2; decoding accepts both v1 and v2 +// envelopes (see `decodeCursor`) so live beta v1 cursors keep their +// measurement / mood / intake watermarks and only fresh-scan the two new +// domains rather than re-initialising the whole feed. const CURSOR_VERSION = 2; +/** Versions whose envelope shape `decodeCursor` accepts. v1 lacked the + * `cycleDays`/`cycles` domains; they decode as absent (fresh scan). */ +const SUPPORTED_CURSOR_VERSIONS: readonly number[] = [1, 2]; + /** Encode a per-domain keyset map into an opaque token. */ export function encodeCursor(cursor: SyncCursor): string { const d: Record = {}; @@ -87,10 +99,12 @@ export function decodeCursor(token: string): SyncCursor | null { try { const json = Buffer.from(token, "base64url").toString("utf-8"); const parsed = JSON.parse(json) as unknown; + const version = (parsed as { v?: unknown }).v; if ( typeof parsed !== "object" || parsed === null || - (parsed as { v?: unknown }).v !== CURSOR_VERSION || + typeof version !== "number" || + !SUPPORTED_CURSOR_VERSIONS.includes(version) || typeof (parsed as { d?: unknown }).d !== "object" || (parsed as { d: unknown }).d === null ) { diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index d7855fe4d..037c95804 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -233,6 +233,103 @@ describe("cycle period shortcut", () => { expect(json.data.cycle.startDate).toBe("2026-03-10"); expect(json.data.dayLog.flow).toBe("MEDIUM"); }); + + it("closes the prior cycle atomically when a new start is inserted", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/cycle/period/route"); + + await POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date: "2026-04-01", + loggedAt: "2026-04-01T08:00:00.000Z", + }), + ); + await POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date: "2026-04-29", + loggedAt: "2026-04-29T08:00:00.000Z", + }), + ); + + const prior = await prisma.menstrualCycle.findFirstOrThrow({ + where: { userId: FEMALE_USER_ID, startDate: "2026-04-01" }, + }); + // Close-prior + open-new ran as one unit: the prior cycle is stamped. + expect(prior.endDate).toBe("2026-04-28"); + expect(prior.lengthDays).toBe(28); + }); + + it("replays the same idempotency key without a second mutation", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/cycle/period/route"); + const key = `period-${Date.now()}`; + + const first = await POST( + jsonRequest( + "/api/cycle/period", + "POST", + { + action: "start", + date: "2026-05-10", + loggedAt: "2026-05-10T08:00:00.000Z", + }, + { idempotencyKey: key }, + ), + ); + expect(first.status).toBe(200); + + const replay = await POST( + jsonRequest( + "/api/cycle/period", + "POST", + { + action: "start", + date: "2026-05-10", + loggedAt: "2026-05-10T08:00:00.000Z", + }, + { idempotencyKey: key }, + ), + ); + expect(replay.status).toBe(200); + + const cycles = await prisma.menstrualCycle.count({ + where: { userId: FEMALE_USER_ID, startDate: "2026-05-10", deletedAt: null }, + }); + expect(cycles).toBe(1); + }); + + it("never downgrades a richer same-day flow on the boundary write", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + const dayLogs = await import("@/app/api/cycle/day-logs/route"); + const period = await import("@/app/api/cycle/period/route"); + + // A manual HEAVY entry exists for the day… + await dayLogs.POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-06-01", + flow: "HEAVY", + loggedAt: "2026-06-01T07:00:00.000Z", + }), + ); + // …a one-tap "start" (boundary flow MEDIUM) must not downgrade it. + await period.POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date: "2026-06-01", + loggedAt: "2026-06-01T08:00:00.000Z", + }), + ); + + const row = await prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: FEMALE_USER_ID, date: "2026-06-01", deletedAt: null }, + }); + expect(row.flow).toBe("HEAVY"); + }); }); describe("cycle calendar", () => {