diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aeedec14..483115844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [1.35.5] — 2026-08-02 + +### Fixed + +- Cycle reminders no longer depend on whether you happened to open the calendar. + The prediction a reminder is sent from was written only while that page was + being viewed, so an account that never opened it had nothing for the reminder + job to read. The prediction is now refreshed nightly for every account that + tracks a cycle, whether or not anyone looks at the page. +- A failed prediction write is no longer silent. It used to be discarded, so + nothing anywhere recorded that it had not happened. +- The sync handshake no longer marks an account as synced. Asking the server + where to resume is not the same as sending anything, and treating it as a sync + moved the resume point past data the app had not fetched yet. The mark is now + set by the three paths that actually receive data, so it can only trail a real + sync rather than run ahead of one. +- Badges are awarded whether or not you open the app. The date a badge was + earned was recorded only when the achievements screen was loaded, so an + account that earned one and did not look never had it written down, and the + date is the part that cannot be reconstructed later. A nightly pass now + records it, using the date it was actually earned rather than the date it was + noticed. + +### Internal + +- Three read endpoints no longer write. A read that writes widens what a + read-only credential can reach, which the code says plainly at the place the + rule is enforced, and it had drifted at three routes. A check now watches + those three. + ## [1.35.4] — 2026-08-02 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cd5d39b81..932c2def9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.35.4 + version: 1.35.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2369,9 +2369,9 @@ paths: tags: - Sync summary: Sync handshake + window metadata - description: Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side - on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads - them rather than hardcoding. + description: Cheap 'should I sync?' summary. Returns the `lastSyncedAt` checkpoint, which the batch ingest endpoints + advance when a client push lands; this route is a read and writes nothing. The `sync` block carries the + incremental-delta window + tombstone retention so the client reads them rather than hardcoding. responses: "200": description: Sync state summary. @@ -18160,8 +18160,9 @@ components: - intakes - sync additionalProperties: false - description: iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the - previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`. + description: iOS SyncMode handshake. Reports the server-side `lastSyncedAt` checkpoint, which the batch ingest endpoints + set when a client push lands; the handshake itself writes nothing. The cheap 'should I sync?' summary; the + durable delta cursor lives on `/api/sync/changes`. SyncChangesEnvelope: type: object properties: diff --git a/package.json b/package.json index 21819c8f7..653629c95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.35.4", + "version": "1.35.5", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc3a14196..ddb70cc5d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -502,6 +502,12 @@ model User { // SwiftData backlog before opening a fresh anchored-query window. // NULL = never synced; the SyncMode store treats that as "force // a full refresh on the next pair". + // + // Written by the client-push ingest boundaries only — the batch / + // bulk endpoints for measurements, mood and intakes (see + // `markSyncCheckpoint`). The handshake that reports it is a GET and + // writes nothing, so the checkpoint can only trail a real sync + // instead of running ahead of one. lastSyncedAt DateTime? @map("last_synced_at") // v0.5.4 ios-coord — daily mood-reminder opt-in. diff --git a/public/sw.js b/public/sw.js index e902efa2a..563dc2dbf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.35.4"; + /* @sw-version-fallback */ "v1.35.5"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/__tests__/read-routes-write-nothing.test.ts b/src/app/api/__tests__/read-routes-write-nothing.test.ts new file mode 100644 index 000000000..4418293a8 --- /dev/null +++ b/src/app/api/__tests__/read-routes-write-nothing.test.ts @@ -0,0 +1,90 @@ +/** + * Structural guard: the three read routes that used to write must stay reads. + * + * `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` — + * an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface + * because those methods are assumed side-effect-free, and adding a + * side-effecting GET silently widens that token's reach. These three routes + * each broke it once. The runtime tests beside each route prove the specific + * write is gone; this one fails on a NEW one. + * + * Deliberately scoped to these three files and to writes issued directly in + * the handler. A repo-wide version is not written today because it cannot be + * written without false positives: several read routes call helpers that + * legitimately upsert a lazily-created row (`getOrCreateCycleProfile` behind + * `requireCycleEnabled` is the clearest one), and a text matcher cannot tell + * that apart from a real side effect. Widening this guard means resolving + * those cases first, not loosening the pattern. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** Mutating Prisma model methods, as they appear at a call site. */ +const PRISMA_WRITE = + /\.(create|createMany|createManyAndReturn|update|updateMany|upsert|delete|deleteMany|executeRaw|executeRawUnsafe)\s*\(/; + +/** + * Write helpers these routes used to reach for. A helper hides its writes + * from the pattern above, so each one that mattered here is named. + */ +const WRITE_HELPERS = + /\b(persistPredictionCache|markSyncCheckpoint|auditLog\s*\()/; + +const ROUTES = [ + "sync/state/route.ts", + "gamification/achievements/route.ts", + "cycle/calendar/route.ts", +]; + +function sourceOf(relative: string): string { + return readFileSync(join(__dirname, "..", relative), "utf8"); +} + +/** + * Strip comments before matching. Every one of these files DESCRIBES the + * write it no longer performs, and a guard that reads prose as code is a + * guard that fires on its own documentation. + */ +function code(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + +describe("read routes issue no writes", () => { + it("the patterns actually match a write — otherwise every case below is vacuous", () => { + expect( + PRISMA_WRITE.test(`await prisma.user.update({ where: { id } });`), + ).toBe(true); + expect( + PRISMA_WRITE.test(`prisma.userAchievement.createMany({ data });`), + ).toBe(true); + expect(WRITE_HELPERS.test(`void persistPredictionCache(userId, p);`)).toBe( + true, + ); + // And do NOT match the reads these routes are made of. + expect(PRISMA_WRITE.test(`await prisma.measurement.findMany({});`)).toBe( + false, + ); + expect(PRISMA_WRITE.test(`await prisma.moodEntry.count({});`)).toBe(false); + }); + + it("scans all three routes and finds real source in each", () => { + // An empty read set would pass every assertion below without looking at + // anything. + expect(ROUTES).toHaveLength(3); + for (const route of ROUTES) { + expect(code(sourceOf(route)).length).toBeGreaterThan(500); + } + }); + + for (const route of ROUTES) { + it(`${route} calls no mutating Prisma method`, () => { + expect(code(sourceOf(route))).not.toMatch(PRISMA_WRITE); + }); + + it(`${route} calls no known write helper`, () => { + expect(code(sourceOf(route))).not.toMatch(WRITE_HELPERS); + }); + } +}); diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts index 83ca9fb97..eb19019b7 100644 --- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -22,6 +22,12 @@ vi.mock("@/lib/db", () => ({ menstrualCycle: { findMany: vi.fn() }, cycleDayLog: { findMany: vi.fn() }, measurement: { findMany: vi.fn() }, + // Present so a re-introduced forecast persist is VISIBLE here rather + // than blowing up on an undefined model. `persistPredictionCache` calls + // `findUnique` synchronously before its first await, so even the old + // fire-and-forget `void persistPredictionCache(...)` lands on this mock + // before the assertion runs. + cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() }, auditLog: { create: vi.fn() }, }, })); @@ -34,10 +40,6 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimitHeaders: () => ({}), })); vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() })); -// The cache persist is fire-and-forget and would reach the database. -vi.mock("@/lib/cycle/prediction-cache", () => ({ - persistPredictionCache: vi.fn().mockResolvedValue(undefined), -})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -142,6 +144,22 @@ afterEach(() => { vi.useRealTimers(); }); +describe("GET /api/cycle/calendar is a read", () => { + it("persists no forecast — the reminder cron's row is the job's, not this route's", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { status, body } = await callCalendar(); + expect(status).toBe(200); + // There IS a forecast to persist, so the absence of a write is a + // decision rather than an empty case. + expect((body.data as Record).prediction).not.toBeNull(); + + expect(prisma.cyclePrediction.findUnique).not.toHaveBeenCalled(); + expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled(); + }); +}); + describe("GET /api/cycle/calendar carries the resolved verdict", () => { it("puts the whole verdict on the wire", async () => { // 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14. diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 830999cfd..0a4c95aad 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -3,9 +3,9 @@ * (ios-contract §2.D). * * Calls the pure engine (`predictCycle` + per-day phase) to build - * `{ profile, prediction, days }`. The synchronous engine call is cheap - * (pure stats); the heavy part is the cache persist, which is debounced - * stale-while-revalidate and fire-and-forget so it never blocks the GET. + * `{ profile, prediction, days }`. The engine call is cheap (pure stats) and + * the route writes nothing — the `CyclePrediction` row the reminder cron + * reads is owned by the `cycle-prediction-refresh` job. * Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and * AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method" * disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF), @@ -31,7 +31,6 @@ import { cycleDisclaimerKey, } from "@/lib/cycle/dto"; import { resolveCycleVerdict } from "@/lib/cycle/verdict"; -import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { BBT_WINDOW } from "@/lib/cycle/types"; import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; @@ -157,13 +156,14 @@ export const GET = apiHandler(async (request: NextRequest) => { goalAllowsFertile, ); - // Persist the cache fire-and-forget (debounced) — never block the read. - let generatedAt = new Date().toISOString(); - if (prediction) { - const now = new Date(); - generatedAt = now.toISOString(); - void persistPredictionCache(user.id, prediction, now); - } + // v1.35.3 — this read persists nothing. It used to fire + // `void persistPredictionCache(...)`: a write on a GET, and a discarded + // promise on top, so a failed write left no trace at all. The forecast the + // response carries is computed right here from the account's rows; the + // `CyclePrediction` row the reminder cron scans is written by the + // `cycle-prediction-refresh` job, which reaches every tracking account + // rather than the ones that opened this page. + const generatedAt = new Date().toISOString(); const locale = await resolveServerLocale({ request, diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts index 76842cf9e..b892cd289 100644 --- a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts +++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts @@ -217,14 +217,8 @@ describe("GET /api/gamification/achievements — per-module badge skipping", () expect(badges.length).toBeGreaterThan(0); }); - it("never persists an unlock for a disabled-module badge", async () => { - // A mood badge would unlock at >=1 entry; with mood OFF the createMany - // must carry no mood achievement row. - await badgesFor({ mood: false }); - const createCalls = vi.mocked(prisma.userAchievement.createMany).mock.calls; - for (const [arg] of createCalls) { - const rows = (arg as { data: Array<{ achievementId: string }> }).data; - expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false); - } - }); + // The "a disabled module never gets its badge pinned" invariant moved with + // the write itself — see `src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts`. + // That this route persists nothing at all is proved in + // `writes-nothing.test.ts`, against a result that HAS a pending unlock. }); diff --git a/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts new file mode 100644 index 000000000..e600c2bd0 --- /dev/null +++ b/src/app/api/gamification/achievements/__tests__/writes-nothing.test.ts @@ -0,0 +1,125 @@ +/** + * `GET /api/gamification/achievements` is a read. + * + * It used to INSERT a `UserAchievement` row for every unlock it noticed, + * which is the side-effecting GET the MCP-audience note in `api-handler.ts` + * warns against. The evaluation still runs here; only the persistence moved + * to the `achievement-unlock-sweep` job. + * + * The builder is faked so the result ALWAYS carries a pending unlock. That is + * the point: an assertion that the route did not write is worth nothing + * against a fixture that had nothing to write, and the fixture in + * `module-gate.test.ts` turns out to be exactly that. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { userAchievement: { findMany: vi.fn(), createMany: vi.fn() } }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + resolveModuleMap: vi.fn(async () => ({})), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + +const PENDING_UNLOCK = { + achievementId: "mood-first-entry", + unlockedAt: new Date("2026-03-02T08:00:00.000Z"), +}; + +vi.mock("@/lib/gamification/achievements-result", () => ({ + buildAchievementsResult: vi.fn(async () => ({ + summary: { unlockedCount: 1, totalCount: 1 }, + achievements: [ + { + id: "mood-first-entry", + category: "mood", + unlocked: true, + completedAt: "2026-03-02T08:00:00.000Z", + titleKey: "achievements.moodFirst.title", + descriptionKey: "achievements.moodFirst.description", + icon: "Smile", + progressPercent: 100, + points: 10, + target: 1, + current: 1, + isHidden: false, + }, + ], + metrics: {}, + pendingUnlocks: [PENDING_UNLOCK], + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { buildAchievementsResult } from "@/lib/gamification/achievements-result"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + locale: "en", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +async function callGet() { + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + return { status: res.status, body: JSON.parse(await res.text()) }; +} + +describe("GET /api/gamification/achievements", () => { + it("has a pending unlock to notice — otherwise the case below is empty", async () => { + await callGet(); + const result = await vi.mocked(buildAchievementsResult).mock.results[0]! + .value; + expect(result.pendingUnlocks).toHaveLength(1); + }); + + it("persists nothing — a GET does not write", async () => { + const { status } = await callGet(); + expect(status).toBe(200); + expect(prisma.userAchievement.createMany).not.toHaveBeenCalled(); + }); + + it("still renders the unlocked badge, so the payload never depended on the row", async () => { + const { body } = await callGet(); + const achievements = (body.data as { achievements: Array }) + .achievements as Array<{ id: string; unlocked: boolean }>; + expect(achievements).toHaveLength(1); + expect(achievements[0]!.unlocked).toBe(true); + }); + + it("does not leak the internal pendingUnlocks carrier onto the wire", async () => { + const { body } = await callGet(); + expect(body.data).not.toHaveProperty("pendingUnlocks"); + }); +}); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index 0661bcbdc..595a1679c 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -1,4 +1,3 @@ -import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; @@ -62,9 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // iOS-format branch runs the locale-aware transform after the cache // read so the cache stays format-agnostic and the achievement-progress // dashboard duplicate (seen twice per dashboard mount in the v1.4.33 - // HAR) coalesces into one builder call. v1.18.0 B5 — unlock persistence - // now happens in the handler after the cache read (idempotent), not as - // a side effect inside the cached factory. + // HAR) coalesces into one builder call. // v1.18.0 B5 — resolve the per-user module map once and pass it into the // builder so badge categories whose owning module is disabled (sleep // badges when sleep is off, mood badges when mood is off) are skipped @@ -76,8 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // 10-minute stale window; the app-wide `AchievementUnlockNotifier` polls // every 2 minutes, so a hard-TTL read always missed and re-paid the cold // build. SWR serves the prior payload instantly and warms one background - // recompute. Persistence of `pendingUnlocks` stays OUTSIDE this read (see - // below), so a stale-served body never skips an unlock write. + // recompute. const result = await cachedSwr( caches.achievements as ServerCache, user.id, @@ -85,25 +81,19 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate, ); - // v1.18.0 B5 — persist newly unlocked achievements OUTSIDE the cached - // factory. `createMany({ skipDuplicates: true })` is idempotent on the - // `(userId, achievementId)` unique, so re-running it on a cache hit is - // a no-op rather than a duplicate, and the write is never skipped just - // because the read was served from cache. - if (result.pendingUnlocks.length > 0) { - await prisma.userAchievement.createMany({ - data: result.pendingUnlocks.map((u) => ({ - userId: user.id, - achievementId: u.achievementId, - unlockedAt: new Date(u.unlockedAt), - })), - skipDuplicates: true, - }); - annotate({ - action: { name: "gamification.achievements" }, - meta: { newUnlocks: result.pendingUnlocks.length }, - }); - } + // v1.35.3 — the unlock rows are NOT written here. A GET must not have side + // effects (`api-handler.ts`, the MCP-audience note above + // `READ_HTTP_METHODS`), and this INSERT was one. The badge grid does not + // need the rows: `unlocked` comes from the live metrics and the completion + // date is derived from the account's own history, so the payload below is + // identical whether or not a row exists. The rows pin that date against a + // later data edit and carry it into the backup, which is durable work and + // belongs on the `achievement-unlock-sweep` job — where it also covers the + // accounts that never open this page. + annotate({ + action: { name: "gamification.achievements" }, + meta: { pendingUnlocks: result.pendingUnlocks.length }, + }); // Strip the internal `pendingUnlocks` carrier; it never goes on the wire. const payload = { diff --git a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts index ab6f7dcf3..01f5cb72d 100644 --- a/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts +++ b/src/app/api/measurements/batch/__tests__/pr-detection-hook.test.ts @@ -156,6 +156,7 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", // This batch declares no trigger, so the trigger field is nulled and the // background timestamp is not written — see the sync-trigger suite. data: { + lastSyncedAt: expect.any(Date), healthKitLastSyncedAt: expect.any(Date), healthKitLastSyncTrigger: null, }, @@ -170,7 +171,12 @@ describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", ); expect(res.status).toBe(200); - expect(prisma.user.update).not.toHaveBeenCalled(); + // The SyncMode checkpoint still advances — a MANUAL push is a client + // sync — but none of the HealthKit-specific fields are touched. + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastSyncedAt: expect.any(Date) }, + }); }); it("surfaces a hard reconciliation failure without checkpointing it", async () => { diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 68cee2ff2..df8ffcd59 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -643,9 +643,18 @@ async function postBatch(request: NextRequest): Promise { ); }); - // This batch route is the native HealthKit ingestion boundary. Stamp only - // after every attempted write has reached a durable, non-failed verdict; - // MANUAL-only and skipped-only batches are not HealthKit syncs. + // This batch route is the native ingestion boundary, so it is also where the + // SyncMode checkpoint (`User.lastSyncedAt`, the same column + // `markSyncCheckpoint` writes) is established — `GET /api/sync/state` only + // reports it. The checkpoint covers MANUAL pushes too: the client synced + // whatever it was holding. A batch carrying a hard failure leaves the + // checkpoint where it was, so the undelivered window stays inside whatever + // the client asks for next. + // + // The HealthKit fields ride the same update and keep their narrower + // condition. Stamp only after every attempted write has reached a durable, + // non-failed verdict; MANUAL-only and skipped-only batches are not + // HealthKit syncs. // // The trigger the client declared is stamped alongside, and a background- or // push-triggered batch additionally dates `healthKitLastBackgroundSyncAt`. @@ -655,17 +664,22 @@ async function postBatch(request: NextRequest): Promise { // trigger writes null over the previous value rather than leaving a stale one // standing: the field describes THIS sync, and an older build's silence is // not evidence about it. - if (healthKitSyncSucceeded) { + if (failedCount === 0) { const syncedAt = new Date(); const deliveredWithoutTheApp = syncTrigger === "background" || syncTrigger === "push"; await prisma.user.update({ where: { id: user.id }, data: { - healthKitLastSyncedAt: syncedAt, - healthKitLastSyncTrigger: syncTrigger ?? null, - ...(deliveredWithoutTheApp - ? { healthKitLastBackgroundSyncAt: syncedAt } + lastSyncedAt: syncedAt, + ...(healthKitSyncSucceeded + ? { + healthKitLastSyncedAt: syncedAt, + healthKitLastSyncTrigger: syncTrigger ?? null, + ...(deliveredWithoutTheApp + ? { healthKitLastBackgroundSyncAt: syncedAt } + : {}), + } : {}), }, }); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 8f1632bde..b8b61c992 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({ // v1.15.19 — resolver-null convergence probe before standalone insert. findFirst: vi.fn(), }, + // The bulk drain stamps the SyncMode checkpoint (`User.lastSyncedAt`) — + // it is a client push, and the push is what the checkpoint describes. + user: { update: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn() }, }, })); @@ -1486,3 +1489,49 @@ describe("POST /api/medications/intake/bulk — identity stability floor", () => expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); }); }); + +// ──────────────────────────────────────────────────────────────────── +// SyncMode checkpoint — the push owns `User.lastSyncedAt` +// ──────────────────────────────────────────────────────────────────── +/** + * `GET /api/sync/state` reports the checkpoint and writes nothing, so the + * ingest boundaries are the only writers. If this one stops stamping, the + * checkpoint stays null forever and every paired client re-pairs full. + */ +describe("POST /api/medications/intake/bulk — sync checkpoint", () => { + beforeEach(() => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1" }, + ] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "row-1", + } as never); + }); + + it("stamps lastSyncedAt when the drain lands", async () => { + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:00.000Z", + takenAt: "2026-06-15T05:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastSyncedAt: expect.any(Date) }, + }); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 996cab697..9feded721 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -52,6 +52,7 @@ import { } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; +import { markSyncCheckpoint } from "@/lib/sync/checkpoint"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForDay, @@ -932,6 +933,12 @@ async function postBulk(request: NextRequest): Promise { })(); } + // The SyncMode checkpoint belongs to the push that moved the data, not to + // the `/api/sync/state` handshake that reports it. This endpoint is one of + // the three domains that handshake summarises, so an intake drain advances + // it. + await markSyncCheckpoint(user.id); + return apiSuccess({ processed: entries.length, inserted, diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 081284866..9062e59dc 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -29,6 +29,9 @@ const { moodEntryMock, txClient } = vi.hoisted(() => { vi.mock("@/lib/db", () => ({ prisma: { moodEntry: moodEntryMock, + // The bulk drain stamps the SyncMode checkpoint (`User.lastSyncedAt`) — + // it is a client push, and the push is what the checkpoint describes. + user: { update: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn() }, $transaction: vi.fn(async (fn: unknown) => { if (typeof fn === "function") { @@ -688,3 +691,32 @@ describe("POST /api/mood-entries/bulk — upsert + tag links are atomic", () => expect(prisma.$transaction).toHaveBeenCalled(); }); }); + +/** + * The SyncMode checkpoint (`User.lastSyncedAt`) belongs to the push that moved + * the data. `GET /api/sync/state` reports it and writes nothing, so if this + * boundary stops stamping, the checkpoint stays null forever and every paired + * client re-pairs with a full backfill. + */ +describe("POST /api/mood-entries/bulk — sync checkpoint", () => { + beforeEach(() => { + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ + id: "entry-1", + } as never); + }); + + it("stamps lastSyncedAt when the drain lands", async () => { + const res = await POST( + postReq({ + entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], + }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { lastSyncedAt: expect.any(Date) }, + }); + }); +}); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index b9ee78003..a8f3bd3d2 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -38,6 +38,7 @@ import { } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; +import { markSyncCheckpoint } from "@/lib/sync/checkpoint"; import { getScoreForMood, moodLevelEnum, @@ -493,6 +494,11 @@ async function postBulk(request: NextRequest): Promise { } } + // The SyncMode checkpoint belongs to the push that moved the data, not to + // the `/api/sync/state` handshake that reports it. This endpoint is one of + // the three domains that handshake summarises, so a mood drain advances it. + await markSyncCheckpoint(user.id); + return apiSuccess({ processed: entries.length, inserted, diff --git a/src/app/api/sync/state/__tests__/route.test.ts b/src/app/api/sync/state/__tests__/route.test.ts new file mode 100644 index 000000000..46c8c3036 --- /dev/null +++ b/src/app/api/sync/state/__tests__/route.test.ts @@ -0,0 +1,92 @@ +/** + * `GET /api/sync/state` is a read. + * + * It used to bump `User.lastSyncedAt` on every call, which is the exact + * side-effecting GET the MCP-audience note in `api-handler.ts` warns against, + * and it advanced the checkpoint past a window the client had not drained. + * The checkpoint is now owned by the ingest boundaries; this route reports it. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findFirst: vi.fn(), count: vi.fn() }, + moodEntry: { findFirst: vi.fn(), count: vi.fn() }, + medicationIntakeEvent: { findFirst: vi.fn(), count: vi.fn() }, + user: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null }), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; + +const CHECKPOINT = new Date("2026-07-30T08:15:00.000Z"); + +type SyncStateBody = { + lastSyncedAt: string | null; + serverNow: string; + timezone: string; +}; + +async function callGet(): Promise { + const res = (await ( + GET as unknown as (req: unknown) => Promise<{ data: SyncStateBody }> + )({})) as { data: SyncStateBody }; + return res.data; +} + +describe("GET /api/sync/state", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never); + vi.mocked(prisma.moodEntry.count).mockResolvedValue(0 as never); + vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0 as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + lastSyncedAt: CHECKPOINT, + timezone: "Europe/Berlin", + } as never); + }); + + it("writes nothing — the handshake never touches the user row", async () => { + await callGet(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("reports the stored checkpoint rather than the previous handshake", async () => { + const body = await callGet(); + expect(body.lastSyncedAt).toBe(CHECKPOINT.toISOString()); + }); + + it("keeps reporting the same checkpoint across repeated calls", async () => { + const first = await callGet(); + const second = await callGet(); + expect(second.lastSyncedAt).toBe(first.lastSyncedAt); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("reports a never-synced account as null so the client re-pairs full", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + lastSyncedAt: null, + timezone: "Europe/Berlin", + } as never); + const body = await callGet(); + expect(body.lastSyncedAt).toBeNull(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts index 18dba6b9b..dd9ce0a4e 100644 --- a/src/app/api/sync/state/route.ts +++ b/src/app/api/sync/state/route.ts @@ -12,7 +12,9 @@ * { * userId, * timezone, - * lastSyncedAt, — User.lastSyncedAt (ISO string or null) + * lastSyncedAt, — User.lastSyncedAt (ISO string or null). + * Set by the ingest boundaries, not here: + * this route is a read and writes nothing. * serverNow, — `new Date().toISOString()` for clock skew * measurements: { lastUpdatedAt, liveCount, tombstonedCount }, * mood: { lastUpdatedAt, liveCount, tombstonedCount }, @@ -45,10 +47,10 @@ import { export const GET = apiHandler(async (_request: NextRequest) => { const { user } = await requireAuth(); - // Per-domain aggregate reads + one user-side bump. Each runs against - // indexed columns (userId, deleted_at / updated_at) so it stays - // O(rows-for-user) and not O(rows-across-tenants). v1.7.0 adds the - // mood + intake blocks now that both carry the sync columns. + // Per-domain aggregate reads. Each runs against indexed columns (userId, + // deleted_at / updated_at) so it stays O(rows-for-user) and not + // O(rows-across-tenants). v1.7.0 adds the mood + intake blocks now that + // both carry the sync columns. const [ measurementLatest, measurementLive, @@ -96,16 +98,14 @@ export const GET = apiHandler(async (_request: NextRequest) => { }), ]); - // The handshake also bumps the user's lastSyncedAt — the call IS - // the handshake. iOS reads the OLD lastSyncedAt from the response - // and then trusts that subsequent server writes after the new - // checkpoint will round-trip via the standard read paths. - const previous = currentUser?.lastSyncedAt ?? null; - const nextCheckpoint = new Date(); - await prisma.user.update({ - where: { id: user.id }, - data: { lastSyncedAt: nextCheckpoint }, - }); + // The handshake reports the checkpoint; it does not set it. The column is + // stamped by the ingest boundaries that actually move data (see + // `markSyncCheckpoint`), so `lastSyncedAt` answers "when did this account + // last push a batch" rather than "when did a client last ask". NULL still + // means never synced, and the value can now only trail the truth — the + // handshake used to advance it past a window the client had not drained. + const lastSyncedAt = currentUser?.lastSyncedAt ?? null; + const serverNow = new Date(); annotate({ action: { name: "sync.state" }, @@ -114,15 +114,15 @@ export const GET = apiHandler(async (_request: NextRequest) => { tombstonedCount: measurementTomb, moodLiveCount: moodLive, intakeLiveCount: intakeLive, - lastSyncedAtBefore: previous?.toISOString() ?? null, + lastSyncedAt: lastSyncedAt?.toISOString() ?? null, }, }); return apiSuccess({ userId: user.id, timezone: currentUser?.timezone ?? "Europe/Berlin", - lastSyncedAt: previous?.toISOString() ?? null, - serverNow: nextCheckpoint.toISOString(), + lastSyncedAt: lastSyncedAt?.toISOString() ?? null, + serverNow: serverNow.toISOString(), measurements: { lastUpdatedAt: measurementLatest?.updatedAt?.toISOString() ?? null, liveCount: measurementLive, diff --git a/src/lib/cycle/__tests__/prediction-refresh.test.ts b/src/lib/cycle/__tests__/prediction-refresh.test.ts new file mode 100644 index 000000000..ae37e6a8d --- /dev/null +++ b/src/lib/cycle/__tests__/prediction-refresh.test.ts @@ -0,0 +1,118 @@ +/** + * `refreshPredictionCacheForUser` — the request-free recompute the nightly + * job runs. + * + * Two things are load-bearing. It has to reach the persistence at all (the + * job's whole purpose is that a forecast exists for an account that never + * opened the calendar), and the write's failure has to escape: the previous + * implementation caught everything and returned, so a cache that had not been + * written for weeks looked exactly like one with nothing to write. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + cycleProfile: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { refreshPredictionCacheForUser } from "@/lib/cycle/prediction-refresh"; + +const NOW = new Date("2026-06-10T02:20:00.000Z"); + +function profile(overrides: Record = {}) { + return { + userId: "user-1", + goal: "GENERAL_HEALTH", + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + predictionEnabled: true, + rawChartMode: false, + secondarySymptom: "MUCUS", + cycleTrackingEnabled: true, + ...overrides, + }; +} + +/** Four period starts 28 days apart, the last one still open. */ +const CYCLE_STARTS = ["2026-03-05", "2026-04-02", "2026-04-30", "2026-05-28"]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue( + profile() as never, + ); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + CYCLE_STARTS.map((startDate, i) => ({ + id: `cyc-${i}`, + userId: "user-1", + startDate, + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + })) as never, + ); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.cyclePrediction.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.cyclePrediction.upsert).mockResolvedValue({} as never); +}); + +describe("refreshPredictionCacheForUser", () => { + it("writes the forecast for an account that never opened the calendar", async () => { + const outcome = await refreshPredictionCacheForUser("user-1", NOW); + + expect(outcome).toBe("persisted"); + expect(prisma.cyclePrediction.upsert).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.cyclePrediction.upsert).mock.calls[0]![0] as { + where: { userId: string }; + create: { nextPeriodStart: string; generatedAt: Date }; + }; + expect(call.where.userId).toBe("user-1"); + // A real forecast, not a placeholder: four 28-day cycles put the next + // period start 28 days after the last one. + expect(call.create.nextPeriodStart).toBe("2026-06-25"); + expect(call.create.generatedAt).toEqual(NOW); + }); + + it("writes nothing for an account with prediction turned off", async () => { + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue( + profile({ predictionEnabled: false }) as never, + ); + + const outcome = await refreshPredictionCacheForUser("user-1", NOW); + + expect(outcome).toBe("no-prediction"); + expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled(); + }); + + it("writes nothing for an account that has no cycle profile", async () => { + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); + + const outcome = await refreshPredictionCacheForUser("user-1", NOW); + + expect(outcome).toBe("no-profile"); + expect(prisma.cyclePrediction.upsert).not.toHaveBeenCalled(); + }); + + it("lets a failed write escape instead of swallowing it", async () => { + vi.mocked(prisma.cyclePrediction.upsert).mockRejectedValue( + new Error("write rejected"), + ); + + await expect( + refreshPredictionCacheForUser("user-1", NOW), + ).rejects.toThrowError("write rejected"); + }); +}); diff --git a/src/lib/cycle/prediction-cache.ts b/src/lib/cycle/prediction-cache.ts index 4ad546003..5eac68203 100644 --- a/src/lib/cycle/prediction-cache.ts +++ b/src/lib/cycle/prediction-cache.ts @@ -2,13 +2,20 @@ * Cycle prediction cache — debounced stale-while-revalidate (the v1.8.7 * insight-assessment cache pattern, ios-contract §2.D). * - * `GET /api/cycle/calendar` runs the pure engine synchronously (it is - * cheap) for the live forecast it returns, AND persists that forecast to - * the `CyclePrediction` cache row so the Coach snapshot + notifications - * read a stable materialised value without re-running the engine. The - * persist is debounced: a re-write only lands when the prior row is older - * than the debounce window OR the forecast actually changed, so a tight - * read loop never thrashes the row. + * The `CyclePrediction` row is what the cycle-reminder cron scans to find + * who is due a period-soon or fertile-soon push. It is written by the + * `cycle-prediction-refresh` job (see `prediction-refresh.ts`), never by a + * read: `GET /api/cycle/calendar` runs the same engine for the forecast it + * returns and persists nothing. + * + * The persist is debounced: a re-write only lands when the prior row is + * older than the debounce window OR the forecast actually changed, so a + * repeated refresh never thrashes the row. + * + * Failures propagate. This used to swallow every error, which made a cache + * that had not been written for weeks indistinguishable from one with + * nothing to write; the caller is a job and puts the failure on the + * operator's failing-jobs surface. */ import { prisma } from "@/lib/db"; import type { CyclePredictionResult } from "@/lib/cycle/types"; @@ -18,63 +25,58 @@ const PREDICTION_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h /** * Persist (or refresh) the cached forecast for a user, debounced. A - * no-op when a fresh-enough identical row already exists. Best-effort: - * never throws into the read path. + * no-op when a fresh-enough identical row already exists. */ export async function persistPredictionCache( userId: string, result: CyclePredictionResult, now: Date = new Date(), ): Promise { - try { - const existing = await prisma.cyclePrediction.findUnique({ - where: { userId }, - select: { - method: true, - nextPeriodStart: true, - nextPeriodStartLow: true, - nextPeriodStartHigh: true, - confidence: true, - cyclesObserved: true, - generatedAt: true, - }, - }); + const existing = await prisma.cyclePrediction.findUnique({ + where: { userId }, + select: { + method: true, + nextPeriodStart: true, + nextPeriodStartLow: true, + nextPeriodStartHigh: true, + confidence: true, + cyclesObserved: true, + generatedAt: true, + }, + }); - const unchanged = - existing !== null && - existing.method === result.method && - existing.nextPeriodStart === result.nextPeriodStart && - existing.nextPeriodStartLow === result.nextPeriodStartLow && - existing.nextPeriodStartHigh === result.nextPeriodStartHigh && - existing.confidence === result.confidence && - existing.cyclesObserved === result.cyclesObserved; + const unchanged = + existing !== null && + existing.method === result.method && + existing.nextPeriodStart === result.nextPeriodStart && + existing.nextPeriodStartLow === result.nextPeriodStartLow && + existing.nextPeriodStartHigh === result.nextPeriodStartHigh && + existing.confidence === result.confidence && + existing.cyclesObserved === result.cyclesObserved; - const fresh = - existing !== null && - now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS; + const fresh = + existing !== null && + now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS; - // Skip the write only when the row is BOTH unchanged AND fresh. - if (unchanged && fresh) return; + // Skip the write only when the row is BOTH unchanged AND fresh. + if (unchanged && fresh) return; - const data = { - method: result.method, - nextPeriodStart: result.nextPeriodStart, - nextPeriodStartLow: result.nextPeriodStartLow, - nextPeriodStartHigh: result.nextPeriodStartHigh, - fertileWindowStart: result.fertileWindowStart, - fertileWindowEnd: result.fertileWindowEnd, - predictedOvulation: result.predictedOvulation, - confidence: result.confidence, - cyclesObserved: result.cyclesObserved, - generatedAt: now, - }; + const data = { + method: result.method, + nextPeriodStart: result.nextPeriodStart, + nextPeriodStartLow: result.nextPeriodStartLow, + nextPeriodStartHigh: result.nextPeriodStartHigh, + fertileWindowStart: result.fertileWindowStart, + fertileWindowEnd: result.fertileWindowEnd, + predictedOvulation: result.predictedOvulation, + confidence: result.confidence, + cyclesObserved: result.cyclesObserved, + generatedAt: now, + }; - await prisma.cyclePrediction.upsert({ - where: { userId }, - create: { userId, ...data }, - update: data, - }); - } catch { - /* best-effort cache write — the live engine result is authoritative */ - } + await prisma.cyclePrediction.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }); } diff --git a/src/lib/cycle/prediction-refresh.ts b/src/lib/cycle/prediction-refresh.ts new file mode 100644 index 000000000..c324788e5 --- /dev/null +++ b/src/lib/cycle/prediction-refresh.ts @@ -0,0 +1,126 @@ +/** + * Recompute the cached cycle forecast for one account from the persisted + * rows, without a request. + * + * `CyclePrediction` is not a cache of a read — it is the index the + * cycle-reminder cron scans to find who is due a period-soon or fertile-soon + * push. Until v1.35.3 its only writer was `GET /api/cycle/calendar`, which + * meant the reminders an account received depended on whether somebody had + * opened the calendar recently, and the write itself was a discarded promise + * inside a `try {} catch {}` that swallowed the failure. Both ends are fixed + * here: the refresh runs on the schedule that consumes it, and a failed write + * throws so the job reports it. + * + * The reads mirror the calendar route's default window so the forecast the + * cron scans and the forecast the calendar renders come out of the same + * inputs. + */ +import { prisma } from "@/lib/db"; +import { predictCycle } from "@/lib/cycle"; +import { + toCycleInputs, + toDayLogInputs, + toProfileInput, +} from "@/lib/cycle/engine-adapter"; +import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; +import { addDays } from "@/lib/cycle/day-math"; +import { BBT_WINDOW } from "@/lib/cycle/types"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; + +/** Day-log lookback: the calendar's default past span or the symptothermal + * window, whichever reaches further back. */ +const LOOKBACK_DAYS = Math.max(90, BBT_WINDOW); + +/** Wrist/skin-temperature lookback feeding the temperature-trend layer. */ +const TEMPERATURE_LOOKBACK_DAYS = 90; + +export type PredictionRefreshOutcome = + /** A forecast was computed and the cache row reflects it. */ + | "persisted" + /** No profile row, so the account has never touched cycle tracking. */ + | "no-profile" + /** Prediction is off for this account (raw-chart mode / explicit opt-out), + * or the engine had nothing to forecast from. */ + | "no-prediction"; + +/** + * Recompute and persist the forecast for one account. Throws if the write + * fails — the caller is a job and owns the failure. + */ +export async function refreshPredictionCacheForUser( + userId: string, + now: Date = new Date(), +): Promise { + const [profile, user] = await Promise.all([ + prisma.cycleProfile.findUnique({ where: { userId } }), + prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }), + ]); + if (!profile) return "no-profile"; + if (!profile.predictionEnabled || profile.rawChartMode) { + return "no-prediction"; + } + + const tz = user?.timezone ?? DEFAULT_TIMEZONE; + const today = moodDateKey(now, tz); + + const [cycles, dayLogs, nightlyTemps] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { + userId, + deletedAt: null, + date: { gte: addDays(today, -LOOKBACK_DAYS) }, + }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + temperatureExcluded: true, + ovulationTest: true, + cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, + }, + }), + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date( + Date.parse( + `${addDays(today, -TEMPERATURE_LOOKBACK_DAYS)}T00:00:00Z`, + ), + ), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + ]); + + const nights = nightlyTemps.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); + + const prediction = predictCycle( + toCycleInputs(cycles), + toDayLogInputs(dayLogs), + toProfileInput(profile), + today, + nights, + ); + + await persistPredictionCache(userId, prediction, now); + return "persisted"; +} diff --git a/src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts b/src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts new file mode 100644 index 000000000..d751a0368 --- /dev/null +++ b/src/lib/jobs/__tests__/achievement-unlock-sweep.test.ts @@ -0,0 +1,135 @@ +/** + * The nightly achievement-unlock sweep. + * + * This is where the `UserAchievement` rows are written now that + * `GET /api/gamification/achievements` is a pure read. Two things have to + * hold: the rows actually get written (a sweep that persists nothing is the + * silent half of a two-ended change), and the module gate the route applied + * still applies here — a badge whose owning module is off must never be + * pinned just because nobody is watching the read path any more. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findMany: vi.fn() }, + userAchievement: { createMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn(async () => ({})), +})); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: async ( + _name: string, + fn: (evt: { addMeta: () => void }) => Promise, + ) => fn({ addMeta: () => {} }), +})); + +import { prisma } from "@/lib/db"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { + runAchievementUnlockSweep, + handleAchievementUnlockSweep, +} from "@/lib/jobs/achievement-unlock-sweep"; + +const USERS = [ + { id: "user-1", timezone: "Europe/Berlin" }, + { id: "user-2", timezone: "Europe/Berlin" }, +]; + +function resultWith(ids: string[]) { + return { + pendingUnlocks: ids.map((achievementId) => ({ + achievementId, + unlockedAt: new Date("2026-07-04T10:00:00.000Z"), + })), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findMany).mockResolvedValue(USERS as never); + vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({ + count: 1, + } as never); +}); + +describe("achievement-unlock-sweep", () => { + it("pins every unpinned unlock it finds", async () => { + const summary = await runAchievementUnlockSweep({ + resolveModules: (async () => ({})) as never, + buildResult: (async () => resultWith(["mood-first"])) as never, + }); + + expect(summary.scanned).toBe(2); + // The count is the point: an empty sweep would satisfy "no duplicates" + // just as well as a working one. + expect(vi.mocked(prisma.userAchievement.createMany)).toHaveBeenCalledTimes( + 2, + ); + expect(summary.persisted).toBe(2); + expect(vi.mocked(prisma.userAchievement.createMany)).toHaveBeenCalledWith({ + data: [ + { + userId: "user-1", + achievementId: "mood-first", + unlockedAt: new Date("2026-07-04T10:00:00.000Z"), + }, + ], + skipDuplicates: true, + }); + }); + + it("writes nothing for an account with the achievements module off", async () => { + const summary = await runAchievementUnlockSweep({ + resolveModules: (async () => ({ achievements: false })) as never, + buildResult: (async () => resultWith(["mood-first"])) as never, + }); + + expect(summary.skipped).toBe(2); + expect(summary.persisted).toBe(0); + expect(vi.mocked(prisma.userAchievement.createMany)).not.toHaveBeenCalled(); + }); + + it("keeps sweeping after one account throws and counts the failure", async () => { + let call = 0; + const summary = await runAchievementUnlockSweep({ + resolveModules: (async () => ({})) as never, + buildResult: (async () => { + call += 1; + if (call === 1) throw new Error("evaluation blew up"); + return resultWith(["mood-first"]); + }) as never, + }); + + expect(summary.failed).toBe(1); + expect(summary.persisted).toBe(1); + expect(vi.mocked(prisma.userAchievement.createMany)).toHaveBeenCalledTimes( + 1, + ); + }); + + it("fails the job when every account failed", async () => { + // A fault that hits every account (a dead pool, a schema drift) is + // systemic and must reach the operator's failing-jobs surface rather + // than pass quietly with a zero count. + vi.mocked(prisma.user.findMany).mockResolvedValue([USERS[0]] as never); + vi.mocked(resolveModuleMap).mockRejectedValue(new Error("pool gone")); + + const outcome = await handleAchievementUnlockSweep([]); + expect(outcome.ok).toBe(false); + }); + + it("succeeds when there was simply nothing to pin", async () => { + const summary = await runAchievementUnlockSweep({ + resolveModules: (async () => ({})) as never, + buildResult: (async () => resultWith([])) as never, + }); + expect(summary.persisted).toBe(0); + expect(summary.failed).toBe(0); + expect(vi.mocked(prisma.userAchievement.createMany)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/__tests__/cycle-prediction-refresh.test.ts b/src/lib/jobs/__tests__/cycle-prediction-refresh.test.ts new file mode 100644 index 000000000..f177b7b5d --- /dev/null +++ b/src/lib/jobs/__tests__/cycle-prediction-refresh.test.ts @@ -0,0 +1,108 @@ +/** + * The nightly cycle-forecast refresh. + * + * `CyclePrediction` is the row the cycle-reminder cron scans, and this job is + * its only writer now that `GET /api/cycle/calendar` is a pure read. The + * property that matters is that the forecast for an account that never opens + * the calendar still gets written — the failure this replaces was silent in + * both directions (a write on a read, and a discarded promise that hid the + * failure). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { cycleProfile: { findMany: vi.fn() } }, +})); + +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: vi.fn(async () => true), +})); + +vi.mock("@/lib/cycle/prediction-refresh", () => ({ + refreshPredictionCacheForUser: vi.fn(async () => "persisted"), +})); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: async ( + _name: string, + fn: (evt: { addMeta: () => void }) => Promise, + ) => fn({ addMeta: () => {} }), +})); + +import { prisma } from "@/lib/db"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { refreshPredictionCacheForUser } from "@/lib/cycle/prediction-refresh"; +import { + runCyclePredictionRefresh, + handleCyclePredictionRefresh, +} from "@/lib/jobs/cycle-prediction-refresh"; + +const NOW = new Date("2026-07-30T02:20:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.cycleProfile.findMany).mockResolvedValue([ + { userId: "user-1" }, + { userId: "user-2" }, + ] as never); + vi.mocked(isCycleAvailableForUser).mockResolvedValue(true); + vi.mocked(refreshPredictionCacheForUser).mockResolvedValue("persisted"); +}); + +describe("cycle-prediction-refresh", () => { + it("refreshes every tracking account, opened calendar or not", async () => { + const summary = await runCyclePredictionRefresh(NOW); + + expect(summary.scanned).toBe(2); + // The count is the point: a cohort query that matched nothing would + // satisfy "no bad writes" just as well as a working sweep. + expect(vi.mocked(refreshPredictionCacheForUser)).toHaveBeenCalledTimes(2); + expect(summary.refreshed).toBe(2); + expect(vi.mocked(refreshPredictionCacheForUser)).toHaveBeenCalledWith( + "user-1", + NOW, + ); + }); + + it("skips an account whose cycle module is off", async () => { + vi.mocked(isCycleAvailableForUser).mockResolvedValue(false); + + const summary = await runCyclePredictionRefresh(NOW); + + expect(summary.skipped).toBe(2); + expect(summary.refreshed).toBe(0); + expect(vi.mocked(refreshPredictionCacheForUser)).not.toHaveBeenCalled(); + }); + + it("counts an account whose refresh throws and carries on", async () => { + vi.mocked(refreshPredictionCacheForUser) + .mockRejectedValueOnce(new Error("bad rows")) + .mockResolvedValueOnce("persisted"); + + const summary = await runCyclePredictionRefresh(NOW); + + expect(summary.failed).toBe(1); + expect(summary.refreshed).toBe(1); + }); + + it("fails the job when every account failed", async () => { + // Systemic, not one account's data — it belongs on the operator's + // failing-jobs surface rather than passing quietly. + vi.mocked(prisma.cycleProfile.findMany).mockResolvedValue([ + { userId: "user-1" }, + ] as never); + vi.mocked(refreshPredictionCacheForUser).mockRejectedValue( + new Error("pool gone"), + ); + + const outcome = await handleCyclePredictionRefresh([]); + expect(outcome.ok).toBe(false); + }); + + it("succeeds when no account tracks a cycle", async () => { + vi.mocked(prisma.cycleProfile.findMany).mockResolvedValue([] as never); + + const outcome = await handleCyclePredictionRefresh([]); + expect(outcome.ok).toBe(true); + }); +}); diff --git a/src/lib/jobs/__tests__/read-side-effect-queues.test.ts b/src/lib/jobs/__tests__/read-side-effect-queues.test.ts new file mode 100644 index 000000000..71c952909 --- /dev/null +++ b/src/lib/jobs/__tests__/read-side-effect-queues.test.ts @@ -0,0 +1,78 @@ +/** + * Dead-queue guard for the two queues that took the writes off the read paths. + * + * Moving a write off a GET only works if the new owner actually runs. A queue + * that is not in `allQueues` is never provisioned, a schedule without a + * `createAndWork` binding drains into nothing, and either failure is silent: + * the cron fires, the job vanishes, and the only symptom is a forecast that + * stops moving or an unlock date that is never pinned. Both look exactly like + * "nothing to do". + * + * Source-text assertions over the registrar, matching the sibling + * `*-queue.test.ts` guards. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { SUBSYSTEM_SURFACE } from "@/lib/jobs/subsystem-surface"; + +const registrar = readFileSync( + join(__dirname, "..", "reminder", "register-maintenance.ts"), + "utf8", +); + +const allQueues = registrar.match(/const allQueues\s*=\s*\[([\s\S]*?)\n\];/); + +const QUEUES = [ + { + label: "cycle-prediction-refresh", + binding: "CYCLE_PREDICTION_REFRESH_QUEUE", + cron: "CYCLE_PREDICTION_REFRESH_CRON", + handler: "handleCyclePredictionRefresh", + name: "cycle-prediction-refresh", + }, + { + label: "achievement-unlock-sweep", + binding: "ACHIEVEMENT_UNLOCK_SWEEP_QUEUE", + cron: "ACHIEVEMENT_UNLOCK_SWEEP_CRON", + handler: "handleAchievementUnlockSweep", + name: "achievement-unlock-sweep", + }, +]; + +describe("queues that own a write moved off a GET", () => { + it("extracted an allQueues list to assert against", () => { + // The regexes below all read from this capture; if it ever stops + // matching, every assertion in this file would pass on an empty string. + expect(allQueues).not.toBeNull(); + expect(allQueues![1].length).toBeGreaterThan(0); + }); + + for (const queue of QUEUES) { + describe(queue.label, () => { + it("is provisioned in the allQueues list", () => { + expect(allQueues![1]).toMatch(new RegExp(`\\b${queue.binding}\\b`)); + }); + + it("carries a cron schedule", () => { + expect(registrar).toMatch( + new RegExp(`\\[\\s*${queue.binding},\\s*${queue.cron}`), + ); + }); + + it("binds a createAndWork handler that drains it", () => { + expect(registrar).toMatch( + new RegExp( + `createAndWork[\\s\\S]{0,200}${queue.binding}[\\s\\S]{0,200}${queue.handler}`, + ), + ); + }); + + it("declares a failure audience", () => { + expect(Object.keys(SUBSYSTEM_SURFACE)).toContain(queue.name); + }); + }); + } +}); diff --git a/src/lib/jobs/achievement-unlock-sweep.ts b/src/lib/jobs/achievement-unlock-sweep.ts new file mode 100644 index 000000000..0a4a12685 --- /dev/null +++ b/src/lib/jobs/achievement-unlock-sweep.ts @@ -0,0 +1,134 @@ +/** + * Nightly sweep that pins newly unlocked achievements. + * + * An unlock date is derived from the account's own history, so the badge + * grid renders correctly whether or not a `UserAchievement` row exists — the + * row is what makes the DATE durable once the underlying rows move, and it + * is what the backup carries ("the date is the part that cannot be + * recovered"). + * + * Until v1.35.3 the only thing that wrote those rows was + * `GET /api/gamification/achievements`. That made a durable record a side + * effect of somebody looking at a page: an account that earned a badge and + * never opened the app pinned nothing, and a read carried an INSERT that an + * MCP-audience read token would have been admitted on the moment any route + * declared a narrow scope. The evaluation is unchanged and still runs on the + * read; only the persistence moved here, where it covers every account + * rather than the ones that happened to look. + * + * The date written is the computed completion date, not the sweep's own + * clock, so a badge pinned tonight and the same badge pinned a week from now + * carry the identical timestamp. Running late costs nothing but exposure to + * a data edit in between. + * + * Runs at 04:25 Europe/Berlin, after the cycle-prediction refresh (04:20). + */ +import type { Job } from "pg-boss"; + +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { buildAchievementsResult } from "@/lib/gamification/achievements-result"; +import { getWorkerPrisma } from "./reminder/shared"; + +export const ACHIEVEMENT_UNLOCK_SWEEP_QUEUE = "achievement-unlock-sweep"; + +export const ACHIEVEMENT_UNLOCK_SWEEP_CRON = "25 4 * * *"; + +export interface AchievementUnlockSweepPayload { + triggeredAt: string; +} + +export interface AchievementUnlockSweepSummary { + /** Accounts the sweep looked at. */ + scanned: number; + /** Accounts with the achievements module off. */ + skipped: number; + /** Unlock rows written across every account. */ + persisted: number; + /** Accounts whose evaluation or write threw. */ + failed: number; +} + +/** + * Pin every unpinned unlock across all accounts. Exported for the test; the + * handler below is the queue binding. + */ +export async function runAchievementUnlockSweep( + options: { + resolveModules?: typeof resolveModuleMap; + buildResult?: typeof buildAchievementsResult; + } = {}, +): Promise { + const resolveModules = options.resolveModules ?? resolveModuleMap; + const buildResult = options.buildResult ?? buildAchievementsResult; + const prisma = getWorkerPrisma(); + + const users = await prisma.user.findMany({ orderBy: { id: "asc" } }); + + const summary: AchievementUnlockSweepSummary = { + scanned: users.length, + skipped: 0, + persisted: 0, + failed: 0, + }; + + for (const user of users) { + try { + const moduleMap = await resolveModules(user.id); + // Same gate the route applies: an account with the module off gets no + // badge evaluation and no unlock persistence. + if (moduleMap.achievements === false) { + summary.skipped += 1; + continue; + } + + const result = await buildResult(user, moduleMap); + if (result.pendingUnlocks.length === 0) continue; + + // Idempotent on the `(userId, achievementId)` unique, so a re-run + // against an already-pinned badge is a no-op rather than a duplicate. + const written = await prisma.userAchievement.createMany({ + data: result.pendingUnlocks.map((u) => ({ + userId: user.id, + achievementId: u.achievementId, + unlockedAt: new Date(u.unlockedAt), + })), + skipDuplicates: true, + }); + summary.persisted += written.count; + } catch { + // One account's evaluation must not cost every other account its + // pinned dates. The count is what makes a systemic failure visible. + summary.failed += 1; + } + } + + return summary; +} + +export async function handleAchievementUnlockSweep( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.achievement_unlock_sweep", async (evt) => { + const summary = await runAchievementUnlockSweep(); + evt.addMeta("achievement_sweep_scanned", summary.scanned); + evt.addMeta("achievement_sweep_skipped", summary.skipped); + evt.addMeta("achievement_sweep_persisted", summary.persisted); + evt.addMeta("achievement_sweep_failed", summary.failed); + + // Every account failing is a systemic fault, not one account's data. + if (summary.failed > 0 && summary.failed === summary.scanned) { + return jobFailed( + `achievement-unlock-sweep failed for all ${summary.failed} accounts`, + ); + } + return jobDone({ + users_scanned: summary.scanned, + skipped: summary.skipped, + persisted: summary.persisted, + users_failed: summary.failed, + }); + }); +} diff --git a/src/lib/jobs/cycle-prediction-refresh.ts b/src/lib/jobs/cycle-prediction-refresh.ts new file mode 100644 index 000000000..9e47d94f6 --- /dev/null +++ b/src/lib/jobs/cycle-prediction-refresh.ts @@ -0,0 +1,121 @@ +/** + * Nightly refresh of the `CyclePrediction` forecast rows. + * + * The cycle-reminder cron scans `CyclePrediction` to decide who is due a + * period-soon or fertile-soon push. Until v1.35.3 those rows were written + * only as a side effect of `GET /api/cycle/calendar`, so an account that + * tracked its cycle but never opened the calendar page was scanned against a + * forecast made from whatever its data looked like the last time somebody + * looked — or was never scanned at all. Moving the write here makes the + * reminder's input a function of the account's data instead of its browsing. + * + * Cohort: accounts that hold a `CycleProfile` with prediction not explicitly + * off, resolved through the same two-layer cycle gate the routes use, so an + * operator-disabled module refreshes nothing. Per-account failures are + * counted and the pass continues; a pass that failed for every account it + * touched fails the job so the operator sees it. + * + * Runs at 04:20 Europe/Berlin, after the 03:xx retention block. The forecast + * moves on a day scale and the reminder windows are days wide, so a fixed + * server-side hour is close enough for every timezone the cohort spans. + */ +import type { Job } from "pg-boss"; + +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { refreshPredictionCacheForUser } from "@/lib/cycle/prediction-refresh"; +import { getWorkerPrisma } from "./reminder/shared"; + +export const CYCLE_PREDICTION_REFRESH_QUEUE = "cycle-prediction-refresh"; + +export const CYCLE_PREDICTION_REFRESH_CRON = "20 4 * * *"; + +export interface CyclePredictionRefreshPayload { + triggeredAt: string; +} + +export interface CyclePredictionRefreshSummary { + /** Profiles the cohort query returned. */ + scanned: number; + /** Accounts whose forecast row now reflects their data. */ + refreshed: number; + /** Accounts skipped: module off, prediction off, or raw-chart mode. */ + skipped: number; + /** Accounts whose refresh threw. */ + failed: number; +} + +/** + * Refresh every eligible account's forecast. Exported for the test; the + * handler below is the queue binding. + */ +export async function runCyclePredictionRefresh( + now: Date = new Date(), + options: { + isAvailable?: typeof isCycleAvailableForUser; + refresh?: typeof refreshPredictionCacheForUser; + } = {}, +): Promise { + const isAvailable = options.isAvailable ?? isCycleAvailableForUser; + const refresh = options.refresh ?? refreshPredictionCacheForUser; + const prisma = getWorkerPrisma(); + + const profiles = await prisma.cycleProfile.findMany({ + where: { NOT: { predictionEnabled: false } }, + select: { userId: true }, + }); + + const summary: CyclePredictionRefreshSummary = { + scanned: profiles.length, + refreshed: 0, + skipped: 0, + failed: 0, + }; + + for (const { userId } of profiles) { + try { + if (!(await isAvailable(userId))) { + summary.skipped += 1; + continue; + } + const outcome = await refresh(userId, now); + if (outcome === "persisted") summary.refreshed += 1; + else summary.skipped += 1; + } catch { + // One account's bad data must not cost every other account its + // reminders. The count is what makes a systemic failure visible. + summary.failed += 1; + } + } + + return summary; +} + +export async function handleCyclePredictionRefresh( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.cycle_prediction_refresh", async (evt) => { + const summary = await runCyclePredictionRefresh(); + evt.addMeta("cycle_prediction_scanned", summary.scanned); + evt.addMeta("cycle_prediction_refreshed", summary.refreshed); + evt.addMeta("cycle_prediction_skipped", summary.skipped); + evt.addMeta("cycle_prediction_failed", summary.failed); + + // Every account failing is a systemic fault (a dead pool, a schema drift), + // not one account's bad data. Anything short of that stays a success with + // the count on the event. + if (summary.failed > 0 && summary.failed === summary.scanned) { + return jobFailed( + `cycle-prediction-refresh failed for all ${summary.failed} accounts`, + ); + } + return jobDone({ + users_scanned: summary.scanned, + refreshed: summary.refreshed, + skipped: summary.skipped, + users_failed: summary.failed, + }); + }); +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 1f4f327ad..332dd8c1d 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -189,6 +189,18 @@ import { handleDocumentPurge, type DocumentPurgePayload, } from "@/lib/jobs/document-purge"; +import { + CYCLE_PREDICTION_REFRESH_QUEUE, + CYCLE_PREDICTION_REFRESH_CRON, + handleCyclePredictionRefresh, + type CyclePredictionRefreshPayload, +} from "@/lib/jobs/cycle-prediction-refresh"; +import { + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, + ACHIEVEMENT_UNLOCK_SWEEP_CRON, + handleAchievementUnlockSweep, + type AchievementUnlockSweepPayload, +} from "@/lib/jobs/achievement-unlock-sweep"; const DATA_BACKUP_QUEUE = "data-backup"; @@ -349,6 +361,13 @@ const allQueues = [ // v1.31.0 — data-arrival reaction-marker prune. Without this entry the // daily schedule silently no-ops and the markers accumulate forever. ARRIVAL_REACTION_CLEANUP_QUEUE, + // v1.35.3 — nightly refresh of the cycle forecast rows the reminder cron + // scans. Without this entry the schedule silently no-ops and the forecast + // is only as fresh as the last time somebody opened the calendar. + CYCLE_PREDICTION_REFRESH_QUEUE, + // v1.35.3 — nightly pin for newly unlocked achievements. Without this entry + // the schedule silently no-ops and no unlock date is ever made durable. + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, // v1.7.0 — soft-deleted measurement tombstone prune. Without this entry // the daily schedule silently no-ops and pruned-past-retention tombstones // pile up forever. @@ -494,6 +513,22 @@ const schedules: ScheduleEntry[] = [ [NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON], // v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens. [MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON, cronIsTheRetry], + // v1.35.3 — daily 04:20 Europe/Berlin refresh of the cycle forecast rows + // the reminder cron scans. Tomorrow's tick is the retry: the forecast moves + // on a day scale and the reminder windows are days wide. + [ + CYCLE_PREDICTION_REFRESH_QUEUE, + CYCLE_PREDICTION_REFRESH_CRON, + cronIsTheRetry, + ], + // v1.35.3 — daily 04:25 Europe/Berlin pin for newly unlocked achievements. + // Tomorrow's tick is the retry: the date written is the computed completion + // date, so a night late writes the identical row. + [ + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, + ACHIEVEMENT_UNLOCK_SWEEP_CRON, + cronIsTheRetry, + ], // v1.25 — daily 04:05 Europe/Berlin medication-note encryption backfill // discovery. The empty cron payload (no `userId`) is the handler's signal to // fan out one per-user job per account still holding a plaintext medication @@ -699,6 +734,24 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleArrivalReactionCleanup, ); + // v1.35.3 — nightly cycle-forecast refresh. Single-flight: two ticks + // recomputing the same cohort is wasted work and the second upsert is a + // no-op against the debounce. + await createAndWork( + boss, + CYCLE_PREDICTION_REFRESH_QUEUE, + { localConcurrency: 1 }, + handleCyclePredictionRefresh, + ); + // v1.35.3 — nightly achievement-unlock pin. Single-flight: the underlying + // createMany is idempotent on the (userId, achievementId) unique, so a + // second concurrent tick would only re-walk the same accounts. + await createAndWork( + boss, + ACHIEVEMENT_UNLOCK_SWEEP_QUEUE, + { localConcurrency: 1 }, + handleAchievementUnlockSweep, + ); // v1.7.0 — daily prune of expired measurement tombstones. Single-flight // like every other cleanup queue. await createAndWork( diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index aa208997b..04550e269 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -116,6 +116,8 @@ const subsystemSurface = { "mood-reminder-cleanup": { audience: "system" }, "push-attempt-cleanup": { audience: "system" }, "arrival-reaction-cleanup": { audience: "system" }, + "cycle-prediction-refresh": { audience: "system" }, + "achievement-unlock-sweep": { audience: "system" }, "measurement-tombstone-cleanup": { audience: "system" }, "coach-message-cleanup": { audience: "system" }, "note-encryption-backfill": { audience: "system" }, diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 001dc5623..a28d266b9 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -55,7 +55,7 @@ const syncStateResponse = z .meta({ id: "SyncStateResponse", description: - "iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.", + "iOS SyncMode handshake. Reports the server-side `lastSyncedAt` checkpoint, which the batch ingest endpoints set when a client push lands; the handshake itself writes nothing. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.", }); const syncMeasurementUpsert = measurementResource @@ -224,7 +224,7 @@ export const syncPaths: NonNullable = { tags: ["Sync"], summary: "Sync handshake + window metadata", description: - "Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.", + "Cheap 'should I sync?' summary. Returns the `lastSyncedAt` checkpoint, which the batch ingest endpoints advance when a client push lands; this route is a read and writes nothing. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.", responses: { "200": { description: "Sync state summary.", diff --git a/src/lib/sync/checkpoint.ts b/src/lib/sync/checkpoint.ts new file mode 100644 index 000000000..a90a85776 --- /dev/null +++ b/src/lib/sync/checkpoint.ts @@ -0,0 +1,33 @@ +/** + * The SyncMode checkpoint — `User.lastSyncedAt`. + * + * One writer contract: the checkpoint is stamped by the client-push ingest + * boundaries (the measurement / mood / intake batch endpoints), never by the + * handshake that reports it. `GET /api/sync/state` is the only reader, so the + * column has to describe a sync that actually moved data rather than the fact + * that a client asked what the server knows. + * + * Stamping it on the handshake was also unsafe in exactly the watermark sense + * the provider pipelines already guard against (`sync-measure-watermark`, + * `sync-dead-token`): it advanced the checkpoint past a window the client had + * not drained yet, and a client that trusts the checkpoint then skips that + * window. Under-advancing a watermark costs a redundant fetch; over-advancing + * loses rows. Stamped here, the checkpoint can only ever trail the truth. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +/** + * Stamp `User.lastSyncedAt`. Call from an ingest boundary once the batch has + * reached a durable verdict — never from a read. + */ +export async function markSyncCheckpoint( + userId: string, + at: Date = new Date(), +): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { lastSyncedAt: at }, + }); + annotate({ meta: { sync_checkpoint_at: at.toISOString() } }); +} diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 446245858..26f755aae 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -480,16 +480,67 @@ describe("cycle calendar", () => { // GENERAL_HEALTH default → fertile window suppressed. expect(json.data.prediction.fertileWindowStart).toBeNull(); - // The cache row is persisted fire-and-forget (the route never blocks - // the read on it), so poll briefly for it to land. - let cache = null; - for (let i = 0; i < 20 && cache === null; i++) { - cache = await prisma.cyclePrediction.findUnique({ + // The read persists nothing. The prediction row exists for the reminder + // job, and writing it here made the job's behaviour depend on whether + // anyone had opened this page. Poll anyway rather than reading once, so + // that a write which merely arrives late still fails the case. + for (let i = 0; i < 20; i++) { + const cache = await prisma.cyclePrediction.findUnique({ where: { userId: FEMALE_USER_ID }, }); - if (cache === null) await new Promise((r) => setTimeout(r, 25)); + expect(cache).toBeNull(); + await new Promise((r) => setTimeout(r, 25)); } - expect(cache).not.toBeNull(); + }); + + it("the nightly refresh writes the prediction the read no longer writes", async () => { + // The other end of the move. Without this the case above would pass + // against a prediction nothing writes at all. + // + // `beforeEach` truncates, so this case seeds its own history rather than + // inheriting the one above. It also never touches the calendar route: the + // point is that the prediction lands without anyone opening that page. + // + // The profile row is seeded directly for the same reason. Every + // `/api/cycle/*` route creates one on the way past, so in production the + // cohort is "has ever used cycle tracking" rather than "opened the + // calendar recently". Calling a route to get one here would defeat the + // case. + const prisma = getPrismaClient(); + await prisma.cycleProfile.create({ data: { userId: FEMALE_USER_ID } }); + for (const [start, end, len] of [ + ["2026-01-01", "2026-01-28", 28], + ["2026-01-29", "2026-02-25", 28], + ] as const) { + await prisma.menstrualCycle.create({ + data: { + userId: FEMALE_USER_ID, + startDate: start, + endDate: end, + lengthDays: len, + periodEndDate: null, + }, + }); + } + await prisma.menstrualCycle.create({ + data: { userId: FEMALE_USER_ID, startDate: "2026-02-26" }, + }); + + expect( + await prisma.cyclePrediction.findUnique({ + where: { userId: FEMALE_USER_ID }, + }), + ).toBeNull(); + + const { refreshPredictionCacheForUser } = + await import("@/lib/cycle/prediction-refresh"); + await refreshPredictionCacheForUser(FEMALE_USER_ID); + + expect( + await prisma.cyclePrediction.findUnique({ + where: { userId: FEMALE_USER_ID }, + }), + ).not.toBeNull(); }); }); diff --git a/tests/integration/sync-state.test.ts b/tests/integration/sync-state.test.ts index 01d349efd..b3318519f 100644 --- a/tests/integration/sync-state.test.ts +++ b/tests/integration/sync-state.test.ts @@ -103,21 +103,41 @@ describe("GET /api/sync/state (real Postgres)", () => { expect(json.data.intakes.liveCount).toBe(0); expect(json.data.intakes.tombstonedCount).toBe(0); - // Server-side, lastSyncedAt now carries a value. + // The handshake asks where to resume. It does not deliver anything, so it + // must not move the resume point: advancing past a window the client has + // not drained is how rows go missing. const after = await getPrismaClient().user.findUnique({ where: { id: TEST_USER_ID }, select: { lastSyncedAt: true }, }); - expect(after?.lastSyncedAt).not.toBeNull(); + expect(after?.lastSyncedAt).toBeNull(); }); - it("returns the previous checkpoint on the second call", async () => { + it("still reports null on the second call, because asking twice is not syncing", async () => { + // The old contract stamped the checkpoint on the first call, so the second + // read found one. Nothing has arrived between these two calls, so the + // honest answer is still null. const { GET } = await import("@/app/api/sync/state/route"); await GET(makeRequest()); const res = await GET(makeRequest()); const json = (await res.json()) as { data: { lastSyncedAt: string | null }; }; + expect(json.data.lastSyncedAt).toBeNull(); + }); + + it("reports a checkpoint once data has actually arrived", async () => { + // The other half of the move: the mark now comes from an ingest boundary. + // Without this case the two above would pass just as well against a column + // nothing writes at all, which is the failure they exist to rule out. + const { markSyncCheckpoint } = await import("@/lib/sync/checkpoint"); + await markSyncCheckpoint(TEST_USER_ID); + + const { GET } = await import("@/app/api/sync/state/route"); + const res = await GET(makeRequest()); + const json = (await res.json()) as { + data: { lastSyncedAt: string | null }; + }; expect(json.data.lastSyncedAt).not.toBeNull(); });