feat(cycle): add cycle-tracking API surface + offline sync

Add the `/api/cycle/*` capture, calendar, history, and settings routes
plus the offline-sync delta extension for the v1.15 cycle-tracking
feature, all against the locked iOS contract.

- Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile
  (migration 0132). A null toggle derives from gender (FEMALE → on).
  `isCycleEnabled()` is the single gate resolver; every `/api/cycle/*`
  route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved
  flag surfaces additively on GET /api/auth/me.
- Routes (apiHandler + Zod + envelope + gate + annotate + auditLog):
  POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent,
  capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/
  skipped), POST period boundary, GET calendar (runs the deterministic
  engine, debounced stale-while-revalidate cache, goal-gated fertile
  window), GET cycles (history + median/MAD stats), GET profile,
  DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge).
- Day-log note encrypts at rest (notesEncrypted); intent fields stay
  queryable plaintext. Upsert keys on (userId, source, externalId) when
  externalId present, else (userId, date).
- Offline sync: extend GET /api/sync/changes + the cursor with the
  `cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays
  tombstones key on externalId else id; cycles on id.
- OpenAPI: register every route + DTO; regenerate the committed spec.
- i18n: cycle prediction disclaimer across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 08:57:12 +02:00
1 parent ea0d7681e2
commit 5f485ca780
34 files changed
+4738 -178

No files matched your search

+1483 -170
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -4890,6 +4890,9 @@
"nausea": "Übelkeit",
"diarrhea": "Durchfall",
"constipation": "Verstopfung"
},
"prediction": {
"disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst."
}
}
}
+3
View File
@@ -4890,6 +4890,9 @@
"nausea": "Nausea",
"diarrhea": "Diarrhea",
"constipation": "Constipation"
},
"prediction": {
"disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles."
}
}
}
+3
View File
@@ -4890,6 +4890,9 @@
"nausea": "Náuseas",
"diarrhea": "Diarrea",
"constipation": "Estreñimiento"
},
"prediction": {
"disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos."
}
}
}
+3
View File
@@ -4890,6 +4890,9 @@
"nausea": "Nausées",
"diarrhea": "Diarrhée",
"constipation": "Constipation"
},
"prediction": {
"disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles."
}
}
}
+3
View File
@@ -4890,6 +4890,9 @@
"nausea": "Nausea",
"diarrhea": "Diarrea",
"constipation": "Stitichezza"
},
"prediction": {
"disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli."
}
}
}
+3
View File
@@ -4890,6 +4890,9 @@
"nausea": "Nudności",
"diarrhea": "Biegunka",
"constipation": "Zaparcia"
},
"prediction": {
"disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli."
}
}
}
@@ -0,0 +1,17 @@
-- v1.15.0 — cycle-tracking feature enable flag.
--
-- Adds the per-user `cycle_tracking_enabled` toggle to `cycle_profiles`.
-- The column is NULLABLE on purpose: NULL means "derive from gender"
-- (`FEMALE` → enabled, everything else → disabled), so no row backfill
-- is required — existing profiles read NULL and resolve through the
-- `isCycleEnabled()` gate helper. An explicit `true` opts a non-FEMALE
-- account in (Settings toggle); an explicit `false` opts a FEMALE
-- account out. Every `/api/cycle/*` route returns
-- `403 { error, meta:{ errorCode:"cycle.disabled" } }` when the resolved
-- value is false.
--
-- Reversibility (down):
-- ALTER TABLE "cycle_profiles" DROP COLUMN IF EXISTS "cycle_tracking_enabled";
-- AlterTable
ALTER TABLE "cycle_profiles" ADD COLUMN "cycle_tracking_enabled" BOOLEAN;
+8
View File
@@ -3308,6 +3308,14 @@ model CycleProfile {
/// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal).
goal CycleTrackingGoal @default(GENERAL_HEALTH)
/// Per-user feature enable. NULL = derive from gender (`FEMALE` → on,
/// everything else → off) so the column never needs a backfill; an
/// explicit `true` opts a non-FEMALE account in, an explicit `false`
/// opts a FEMALE account out. Resolved through `isCycleEnabled()` —
/// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"`
/// when the resolved value is false.
cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled")
/// Optional user-supplied priors. The engine applies a luteal default
/// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is
/// NULL, so a future constant change never strands historical rows.
+131
View File
@@ -0,0 +1,131 @@
/**
* v1.15.0 — per-user cycle-tracking preferences.
*
* GET /api/auth/me/cycle-prefs — current resolved CycleProfileDTO.
* PATCH /api/auth/me/cycle-prefs — body `{ enabled?, goal?, rawChartMode?,
* predictionEnabled?, discreetNotifications?,
* sensitiveCategoryEncryption?,
* typicalCycleLength?, typicalPeriodLength?,
* lutealPhaseLength? }`. Deep-merges the
* supplied fields over the persisted row
* (the notification-prefs precedent) and
* returns the merged CycleProfileDTO.
*
* This route does NOT enforce the cycle gate — it is the surface that
* FLIPS the gate (`enabled` → `cycleTrackingEnabled`). `enabled` is
* idempotent last-writer-wins; the route is otherwise idempotent.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.cycle-prefs.get" } });
const profile = await getOrCreateCycleProfile(user.id);
const resolved = isCycleEnabled(user.gender, profile);
return apiSuccess(toCycleProfileDTO(profile, resolved));
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`cycle-prefs:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
return apiError("Invalid JSON body", 422, {
errorCode: "cycle-prefs.body.invalid_json",
});
}
const parsed = cyclePrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.cycle-prefs.patch.invalid_shape" },
meta: { issues: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle-prefs.invalid",
});
}
// Ensure the row exists, then deep-merge: only the supplied fields are
// touched; siblings are left untouched (field-by-field, no mass
// assignment). `enabled` maps onto the `cycleTrackingEnabled` column.
await getOrCreateCycleProfile(user.id);
const p = parsed.data;
const updated = await prisma.cycleProfile.update({
where: { userId: user.id },
data: {
...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }),
...(p.goal !== undefined && { goal: p.goal }),
...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }),
...(p.predictionEnabled !== undefined && {
predictionEnabled: p.predictionEnabled,
}),
...(p.discreetNotifications !== undefined && {
discreetNotifications: p.discreetNotifications,
}),
...(p.sensitiveCategoryEncryption !== undefined && {
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption,
}),
...(p.typicalCycleLength !== undefined && {
typicalCycleLength: p.typicalCycleLength,
}),
...(p.typicalPeriodLength !== undefined && {
typicalPeriodLength: p.typicalPeriodLength,
}),
...(p.lutealPhaseLength !== undefined && {
lutealPhaseLength: p.lutealPhaseLength,
}),
},
});
const resolved = isCycleEnabled(user.gender, updated);
await auditLog("user.cycle-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: { changed: Object.keys(p) },
});
annotate({
action: { name: "user.cycle_prefs.update" },
meta: { changed: Object.keys(p), enabled: resolved },
});
return apiSuccess(toCycleProfileDTO(updated, resolved));
});
+14
View File
@@ -4,6 +4,8 @@ import { annotate } from "@/lib/logging/context";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { isCycleEnabled } from "@/lib/cycle/gate";
export const dynamic = "force-dynamic";
@@ -19,6 +21,15 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me" } });
// v1.15.0 — resolved cycle-tracking gate. Read the profile without
// forcing a row (a NULL toggle derives from gender); the resolver
// collapses both gates into the single boolean iOS hides the tab on.
const cycleProfile = await prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
});
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
@@ -68,5 +79,8 @@ export const GET = apiHandler(async () => {
insurerName: user.insurerName ?? null,
insurerIkNumber: user.insurerIkNumber ?? null,
insuranceNumber,
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
// hides the whole cycle tab when this is false.
cycleTrackingEnabled,
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* `GET /api/cycle/calendar?from&to` — predicted calendar read
* (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.
* Fertile-window fields are goal-gated (null unless TRYING_TO_CONCEIVE),
* suppressed server-side.
*
* Default range: current cycle − 90d … +180d forward.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
import {
buildCalendar,
type CalendarDayLogRow,
} from "@/lib/cycle/engine-adapter";
import {
toCyclePredictionDTO,
goalAllowsFertileWindow,
} from "@/lib/cycle/dto";
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
import { addDays } from "@/lib/cycle/day-math";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
const DEFAULT_PAST_DAYS = 90;
const DEFAULT_FORWARD_DAYS = 180;
/** Hard cap on the rendered span (days) to bound the day-grid build. */
const MAX_SPAN_DAYS = 400;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const profile = gate.profile;
const url = new URL(request.url);
const parsed = cycleCalendarQuerySchema.safeParse({
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid calendar query", 422, {
errorCode: "cycle.calendar.invalid",
});
}
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(new Date(), tz);
const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS);
const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS);
// Reject an inverted or absurdly wide range up front.
const fromMs = Date.parse(`${from}T12:00:00Z`);
const toMs = Date.parse(`${to}T12:00:00Z`);
if (toMs < fromMs || (toMs - fromMs) / 86_400_000 > MAX_SPAN_DAYS) {
return apiError("Calendar range too wide or inverted", 422, {
errorCode: "cycle.calendar.range",
});
}
const [cycles, dayLogRows, nightlyTemps] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
ovulationTest: true,
cervicalMucus: true,
_count: { select: { symptomLinks: true } },
},
}),
// Apple Watch wrist/skin temperature feeds the temperature-trend
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
// values; the engine derives the trailing-mean deviation itself.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
]);
const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
hasSymptoms: l._count.symptomLinks > 0,
}));
const nights = nightlyTemps.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
const goalAllowsFertile = goalAllowsFertileWindow(profile.goal);
const { prediction, days } = buildCalendar(
profile,
cycles,
dayLogs,
nights,
from,
to,
today,
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);
}
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? undefined,
});
const t = getServerTranslator(locale);
const disclaimer = t.t("cycle.prediction.disclaimer");
annotate({
action: { name: "cycle.calendar.read" },
meta: {
days: days.length,
cycles_observed: prediction?.cyclesObserved ?? 0,
has_prediction: prediction !== null,
},
});
return apiSuccess({
profile: {
goal: profile.goal,
rawChartMode: profile.rawChartMode,
predictionEnabled: profile.predictionEnabled,
cyclesObserved: prediction?.cyclesObserved ?? cycles.length,
},
prediction: prediction
? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer)
: null,
days,
meta: { generatedAt },
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.menstrualCycle.findUnique({
where: { id },
select: { id: true, userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Cycle not found", 404);
}
await prisma.menstrualCycle.update({
where: { id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
await auditLog("cycle.cycle.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { cycleId: id },
});
annotate({
action: {
name: "cycle.cycle.delete",
entity_type: "menstrual_cycle",
entity_id: id,
},
});
return new Response(null, { status: 204 });
},
);
+99
View File
@@ -0,0 +1,99 @@
/**
* `GET /api/cycle/cycles?limit=24` — cycle history + summary stats
* (ios-contract §2.E).
*
* Returns the most recent observed cycles (newest first) plus
* `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays,
* regularity }`. The median / MAD reuse the engine helpers so the stats
* match the prediction's own variability math.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleHistoryQuerySchema } from "@/lib/validations/cycle";
import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
import { median, mad } from "@/lib/cycle";
import { dayDiff } from "@/lib/cycle/day-math";
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
const DEFAULT_LIMIT = 24;
/**
* Regularity gate. < MIN_CYCLES → LEARNING; a length MAD at/under 2 days
* (the clinical "regular cycle" rule of thumb) → REGULAR, else IRREGULAR.
*/
const REGULAR_MAD_THRESHOLD = 2;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const url = new URL(request.url);
const parsed = cycleHistoryQuerySchema.safeParse({
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid history query", 422, {
errorCode: "cycle.cycles.invalid",
});
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
const rows = await prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "desc" },
take: limit,
});
// Completed lengths (non-null lengthDays) drive the length stats.
const lengths = rows
.map((r) => r.lengthDays)
.filter((l): l is number => l != null && l > 0);
// Period lengths from periodEndDate − startDate + 1.
const periodLengths = rows
.filter((r) => r.periodEndDate != null)
.map((r) => dayDiff(r.periodEndDate as string, r.startDate) + 1)
.filter((l) => l > 0);
let avgLengthDays: number | null = null;
let lengthVariabilityDays: number | null = null;
let avgPeriodLengthDays: number | null = null;
let regularity: "REGULAR" | "IRREGULAR" | "LEARNING" = "LEARNING";
if (lengths.length > 0) {
const med = median(lengths);
avgLengthDays = Math.round(med);
lengthVariabilityDays = Math.round(mad(lengths, med) * 10) / 10;
if (lengths.length < MIN_CYCLES_TO_PREDICT) {
regularity = "LEARNING";
} else {
regularity =
lengthVariabilityDays <= REGULAR_MAD_THRESHOLD ? "REGULAR" : "IRREGULAR";
}
}
if (periodLengths.length > 0) {
avgPeriodLengthDays = Math.round(median(periodLengths));
}
annotate({
action: { name: "cycle.cycles.read" },
meta: { returned: rows.length, regularity },
});
return apiSuccess({
cycles: rows.map(toMenstrualCycleDTO),
stats: {
avgLengthDays,
lengthVariabilityDays,
avgPeriodLengthDays,
regularity,
},
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* `PATCH /api/cycle/day-logs/{id}` — edit a single day-log
* (ios-contract §2.A).
* `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F):
* set `deletedAt` + bump `syncVersion`, emit a tombstone on the next
* sync page. 204. Idempotent.
*
* Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by
* another user 404s).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { encrypt } from "@/lib/crypto";
import { cycleDayLogPatchSchema } from "@/lib/validations/cycle";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
type RouteParams = { params: Promise<{ id: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.cycleDayLog.findFirst({
where: { id, deletedAt: null },
select: { id: true, userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Day-log not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = cycleDayLogPatchSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "cycle.day-log.patch.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.day-log.invalid",
});
}
const body = parsed.data;
// Field-by-field update (no mass assignment). Every field is
// optional; an omitted field is left untouched. `note` re-encrypts;
// an explicit null clears it.
await prisma.cycleDayLog.update({
where: { id },
data: {
...(body.flow !== undefined && { flow: body.flow }),
...(body.intermenstrualBleeding !== undefined && {
intermenstrualBleeding: body.intermenstrualBleeding,
}),
...(body.basalBodyTempC !== undefined && {
basalBodyTempC: body.basalBodyTempC,
}),
...(body.ovulationTest !== undefined && {
ovulationTest: body.ovulationTest,
}),
...(body.cervicalMucus !== undefined && {
cervicalMucus: body.cervicalMucus,
}),
...(body.sexualActivity !== undefined && {
sexualActivity: body.sexualActivity,
}),
...(body.protectedSex !== undefined && {
protectedSex: body.protectedSex,
}),
...(body.pregnancyTest !== undefined && {
pregnancyTest: body.pregnancyTest,
}),
...(body.progesteroneTest !== undefined && {
progesteroneTest: body.progesteroneTest,
}),
...(body.contraceptive !== undefined && {
contraceptive: body.contraceptive,
}),
...(body.note !== undefined && {
notesEncrypted: body.note ? encrypt(body.note) : null,
}),
syncVersion: { increment: 1 },
},
});
// Replace symptom links only when `symptoms` was supplied.
if (body.symptoms !== undefined) {
const keys = Array.from(new Set(body.symptoms.map((s) => s.key)));
const symptoms =
keys.length > 0
? await prisma.cycleSymptom.findMany({
where: {
key: { in: keys },
isActive: true,
OR: [{ userId: null }, { userId: user.id }],
},
select: { id: true },
})
: [];
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: id } });
if (symptoms.length > 0) {
await prisma.cycleSymptomLink.createMany({
data: symptoms.map((s) => ({ dayLogId: id, symptomId: s.id })),
skipDuplicates: true,
});
}
}
const row = await prisma.cycleDayLog.findUniqueOrThrow({
where: { id },
include: dayLogSymptomInclude,
});
await auditLog("cycle.day-log.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: id },
});
annotate({
action: {
name: "cycle.day-log.update",
entity_type: "cycle_day_log",
entity_id: id,
},
});
return apiSuccess(toCycleDayLogDTO(row));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.cycleDayLog.findUnique({
where: { id },
select: { id: true, userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Day-log not found", 404);
}
// Soft-delete: leave the row in place so the `/api/sync/changes`
// delta feed surfaces it as a tombstone. A re-delete re-bumps
// `syncVersion` harmlessly (idempotent).
await prisma.cycleDayLog.update({
where: { id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
await auditLog("cycle.day-log.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: id },
});
annotate({
action: {
name: "cycle.day-log.delete",
entity_type: "cycle_day_log",
entity_id: id,
},
});
return new Response(null, { status: 204 });
},
);
+169
View File
@@ -0,0 +1,169 @@
/**
* `POST /api/cycle/day-logs/bulk` — Outbox / HealthKit drain
* (ios-contract §2.B / §3).
*
* The route iOS's Outbox flushes to. Per-entry UPSERT keyed on the
* NULL-distinct `(userId, source, externalId)` (or `(userId, date)`
* when no externalId). Capped at 500, wrapped in `withIdempotency`,
* rate-limited `cycle:day-logs:bulk:{userId}` 60/min. Always 200; each
* entry carries `{ index, status, id?, externalId?, reason? }` where
* status is `inserted` | `duplicate` | `updated` | `skipped`. `updated`
* = an externalId re-post that changed a field; `duplicate` = an
* unchanged re-post.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleBulkSchema, MAX_CYCLE_BULK_ENTRIES } from "@/lib/validations/cycle";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
type EntryStatus = "inserted" | "duplicate" | "updated" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
id?: string;
externalId?: string;
reason?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`cycle:day-logs:bulk:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length > MAX_CYCLE_BULK_ENTRIES
) {
return apiError(
`Batch exceeds the ${MAX_CYCLE_BULK_ENTRIES}-entry limit`,
422,
{ errorCode: "cycle.bulk.too_large" },
);
}
const parsed = cycleBulkSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "cycle.bulk.validation-failed" },
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.bulk.invalid",
});
}
const { entries } = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const results: EntryResult[] = [];
let inserted = 0;
let updated = 0;
let duplicates = 0;
let skipped = 0;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
try {
const cycleId = await findOwningCycleId(user.id, entry.date);
const r = await upsertCycleDayLog(user.id, entry, tz, cycleId);
let status: EntryStatus;
if (!r.existed) {
status = "inserted";
inserted += 1;
} else if (r.changed) {
status = "updated";
updated += 1;
} else {
status = "duplicate";
duplicates += 1;
}
results.push({
index: i,
status,
id: r.id,
...(entry.externalId ? { externalId: entry.externalId } : {}),
});
} catch (err: unknown) {
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
skipped += 1;
results.push({
index: i,
status: "skipped",
reason,
...(entry.externalId ? { externalId: entry.externalId } : {}),
});
}
}
await auditLog("cycle.bulk.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
},
});
annotate({
action: { name: "cycle.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
},
});
return apiSuccess({
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
entries: results,
});
}
+83
View File
@@ -0,0 +1,83 @@
/**
* `POST /api/cycle/day-logs` — single cycle day-log capture
* (ios-contract §2.A).
*
* Upserts on `(userId, source, externalId)` when an `externalId` is
* present (the cross-device dedup key), else the canonical `(userId,
* date)` key. `note` is encrypted at rest (`notesEncrypted`); the intent
* fields stay queryable plaintext (they feed the rollup / correlation
* tier). Returns the full `CycleDayLogDTO`: 201 on insert, 200 on update.
*
* Gated: a disabled / non-FEMALE-without-opt-in account 403s with
* `errorCode:"cycle.disabled"` even with a valid Bearer token.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleDayLogInputSchema } from "@/lib/validations/cycle";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
async function postDayLog(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "cycle.day-log.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.day-log.invalid",
});
}
const entry = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const cycleId = await findOwningCycleId(user.id, entry.date);
const result = await upsertCycleDayLog(user.id, entry, tz, cycleId);
const row = await prisma.cycleDayLog.findUniqueOrThrow({
where: { id: result.id },
include: dayLogSymptomInclude,
});
await auditLog("cycle.day-log.upsert", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: result.id, existed: result.existed },
});
annotate({
action: {
name: "cycle.day-log.upsert",
entity_type: "cycle_day_log",
entity_id: result.id,
},
meta: { existed: result.existed, source: entry.source },
});
return apiSuccess(toCycleDayLogDTO(row), result.existed ? 200 : 201);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* `POST /api/cycle/period` — one-tap period-boundary shortcut
* (ios-contract §2.C).
*
* `action:"start"` opens a new `MenstrualCycle` anchored at `date`,
* closes the prior open cycle (endDate = day before, lengthDays set),
* and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"`
* stamps the current cycle's `periodEndDate` and writes the boundary
* day-log. Convenience over §2.A; returns
* `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cyclePeriodSchema } from "@/lib/validations/cycle";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import {
toCycleDayLogDTO,
toMenstrualCycleDTO,
dayLogSymptomInclude,
} from "@/lib/cycle/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = cyclePeriodSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "cycle.period.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.period.invalid",
});
}
const { action, date, externalId, loggedAt } = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
let cycleId: string;
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),
syncVersion: { increment: 1 },
},
});
}
// 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({
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
orderBy: { startDate: "desc" },
select: { id: true },
});
if (!current) {
return apiSuccessNoCycle();
}
await prisma.menstrualCycle.update({
where: { id: current.id },
data: { periodEndDate: date, syncVersion: { increment: 1 } },
});
cycleId = current.id;
}
// 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",
loggedAt,
source: "MANUAL",
...(externalId ? { externalId } : {}),
},
tz,
cycleId,
);
const [cycleRow, dayLogRow] = await Promise.all([
prisma.menstrualCycle.findUniqueOrThrow({ where: { id: cycleId } }),
prisma.cycleDayLog.findFirstOrThrow({
where: { userId: user.id, date, deletedAt: null },
include: dayLogSymptomInclude,
}),
]);
await auditLog("cycle.period.boundary", {
userId: user.id,
ipAddress: getClientIp(request),
details: { action, date, cycleId },
});
annotate({
action: {
name: "cycle.period.boundary",
entity_type: "menstrual_cycle",
entity_id: cycleId,
},
meta: { boundary: action },
});
return apiSuccess({
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 });
}
+27
View File
@@ -0,0 +1,27 @@
/**
* `GET /api/cycle/profile` — full CycleProfileDTO (ios-contract §2.G).
*
* Gated like every cycle route; returns the lazily-upserted profile with
* the resolved `cycleTrackingEnabled`.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const resolved = isCycleEnabled(user.gender, gate.profile);
annotate({ action: { name: "cycle.profile.read" } });
return apiSuccess(toCycleProfileDTO(gate.profile, resolved));
});
+96 -2
View File
@@ -8,7 +8,9 @@
* domain now soft-delete (set `deletedAt` + bump `syncVersion`) rather
* than hard-delete.
*
* Domains served: `measurements`, `mood`, `intakes`. The iOS consumer
* Domains served: `measurements`, `mood`, `intakes`, `cycleDays`,
* `cycles` (the last two added v1.15.0; `cycleDays` tombstones key on
* `externalId` when present else `id`, `cycles` on `id`). The iOS consumer
* is measurements-only this cycle (iOS-coord
* `v1.7.0-ios-offline-sync-answers.md` §7.1); mood + intakes are
* forward-prep so the contract is complete + tested before the
@@ -57,6 +59,13 @@ import {
type SyncCursor,
type SyncDomain,
} from "@/lib/sync/cursor";
import {
toCycleDayLogDTO,
toMenstrualCycleDTO,
dayLogSymptomInclude,
type CycleDayLogDTO,
type MenstrualCycleDTO,
} from "@/lib/cycle/dto";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
@@ -126,6 +135,25 @@ interface IntakeTombstone {
updatedAt: string;
}
// v1.15.0 — cycle domains. cycleDays key tombstones on `externalId` when
// present (HealthKit-origin rows are externalId-keyed cross-device, like
// measurements) else the server `id`; cycles key on the server `id`
// (computed rows carry no externalId).
interface CycleDayTombstone {
id: string;
externalId: string | null;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
interface CycleTombstone {
id: string;
syncVersion: number;
deletedAt: string;
updatedAt: string;
}
/**
* Build the `(updatedAt, id)` keyset predicate for a domain's watermark.
* Empty object (full scan) when the client has no watermark for it yet.
@@ -196,6 +224,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
measurements: { upserts: [], tombstones: [] },
mood: { upserts: [], tombstones: [] },
intakes: { upserts: [], tombstones: [] },
cycleDays: { upserts: [], tombstones: [] },
cycles: { upserts: [], tombstones: [] },
},
});
}
@@ -204,7 +234,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
// same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a
// row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore`
// without a count query.
const [measurementRows, moodRows, intakeRows] = await Promise.all([
const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] =
await Promise.all([
prisma.measurement.findMany({
where: { userId: user.id, ...keysetFilter(cursor.measurements) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
@@ -257,6 +288,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
updatedAt: true,
},
}),
prisma.cycleDayLog.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
include: dayLogSymptomInclude,
}),
prisma.menstrualCycle.findMany({
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit + 1,
}),
]);
const nextCursor: SyncCursor = { ...cursor };
@@ -354,6 +396,49 @@ export const GET = apiHandler(async (request: NextRequest) => {
advanceCursor(nextCursor, "intakes", intakePage);
hasMore = hasMore || intakeHasMore;
// ── cycleDays ─────────────────────────────────────────────
const cycleDayHasMore = cycleDayRows.length > limit;
const cycleDayPage = cycleDayHasMore
? cycleDayRows.slice(0, limit)
: cycleDayRows;
const cycleDayUpserts: CycleDayLogDTO[] = [];
const cycleDayTombstones: CycleDayTombstone[] = [];
for (const row of cycleDayPage) {
if (row.deletedAt) {
cycleDayTombstones.push({
id: row.id,
externalId: row.externalId,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
} else {
cycleDayUpserts.push(toCycleDayLogDTO(row));
}
}
advanceCursor(nextCursor, "cycleDays", cycleDayPage);
hasMore = hasMore || cycleDayHasMore;
// ── cycles ────────────────────────────────────────────────
const cycleHasMore = cycleRows.length > limit;
const cyclePage = cycleHasMore ? cycleRows.slice(0, limit) : cycleRows;
const cycleUpserts: MenstrualCycleDTO[] = [];
const cycleTombstones: CycleTombstone[] = [];
for (const row of cyclePage) {
if (row.deletedAt) {
cycleTombstones.push({
id: row.id,
syncVersion: row.syncVersion,
deletedAt: row.deletedAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
} else {
cycleUpserts.push(toMenstrualCycleDTO(row));
}
}
advanceCursor(nextCursor, "cycles", cyclePage);
hasMore = hasMore || cycleHasMore;
annotate({
action: { name: "sync.changes.pull" },
meta: {
@@ -363,6 +448,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
mood_tombstones: moodTombstones.length,
intake_upserts: intakeUpserts.length,
intake_tombstones: intakeTombstones.length,
cycle_day_upserts: cycleDayUpserts.length,
cycle_day_tombstones: cycleDayTombstones.length,
cycle_upserts: cycleUpserts.length,
cycle_tombstones: cycleTombstones.length,
has_more: hasMore,
cursor_present: Boolean(parsed.data.cursor),
},
@@ -380,6 +469,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
mood: { upserts: moodUpserts, tombstones: moodTombstones },
intakes: { upserts: intakeUpserts, tombstones: intakeTombstones },
cycleDays: {
upserts: cycleDayUpserts,
tombstones: cycleDayTombstones,
},
cycles: { upserts: cycleUpserts, tombstones: cycleTombstones },
},
});
});
@@ -0,0 +1,126 @@
/**
* v1.15.0 — calendar composition over the deterministic engine.
*/
import { describe, it, expect } from "vitest";
import { buildCalendar, type CalendarDayLogRow } from "../engine-adapter";
import type { CycleProfile, MenstrualCycle } from "@/generated/prisma/client";
function profile(overrides: Partial<CycleProfile> = {}): CycleProfile {
return {
id: "p1",
userId: "u1",
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: false,
sensitiveCategoryEncryption: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as CycleProfile;
}
function cycle(startDate: string, overrides: Partial<MenstrualCycle> = {}): MenstrualCycle {
return {
id: `c-${startDate}`,
userId: "u1",
startDate,
endDate: null,
periodEndDate: null,
lengthDays: null,
ovulationDate: null,
ovulationConfirmed: false,
isPredicted: false,
tz: null,
syncVersion: 0,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as MenstrualCycle;
}
describe("buildCalendar", () => {
// Three ~28-day cycles → a real prediction.
const cycles = [
cycle("2026-01-01", { endDate: "2026-01-28", lengthDays: 28, periodEndDate: "2026-01-05" }),
cycle("2026-01-29", { endDate: "2026-02-25", lengthDays: 28, periodEndDate: "2026-02-02" }),
cycle("2026-02-26", { periodEndDate: "2026-03-02" }),
];
it("labels logged-flow days and emits a forecast for ≥2 cycles", () => {
const dayLogs: CalendarDayLogRow[] = [
{ date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, ovulationTest: null, cervicalMucus: null, hasSymptoms: true },
];
const { prediction, days } = buildCalendar(
profile(),
cycles,
dayLogs,
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
false,
);
expect(prediction).not.toBeNull();
expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(2);
const logged = days.find((d) => d.date === "2026-02-26")!;
expect(logged.isPeriodLogged).toBe(true);
expect(logged.flow).toBe("MEDIUM");
expect(logged.hasSymptoms).toBe(true);
// The forecast next-period start falls inside a predicted-period day.
const predStart = prediction!.nextPeriodStart;
const predDay = days.find((d) => d.date === predStart);
expect(predDay?.isPredictedPeriod).toBe(true);
});
it("suppresses the fertile window at the grid when the goal disallows it", () => {
const { days } = buildCalendar(
profile({ goal: "GENERAL_HEALTH" }),
cycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
false,
);
expect(days.every((d) => !d.isFertileWindow)).toBe(true);
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
});
it("surfaces the fertile window for the conception goal", () => {
const { days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
cycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
true,
);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
});
it("emits no prediction in raw-chart mode", () => {
const { prediction } = buildCalendar(
profile({ rawChartMode: true }),
cycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
false,
);
expect(prediction).toBeNull();
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* v1.15.0 — cycle-tracking feature gate resolver.
*/
import { describe, it, expect } from "vitest";
import { isCycleEnabled, CYCLE_DISABLED_ERROR_CODE } from "../gate";
describe("isCycleEnabled", () => {
it("derives from gender when the toggle is null", () => {
expect(isCycleEnabled("FEMALE", { cycleTrackingEnabled: null })).toBe(true);
expect(isCycleEnabled("MALE", { cycleTrackingEnabled: null })).toBe(false);
expect(isCycleEnabled(null, { cycleTrackingEnabled: null })).toBe(false);
});
it("derives from gender when no profile row exists", () => {
expect(isCycleEnabled("FEMALE", null)).toBe(true);
expect(isCycleEnabled("MALE", undefined)).toBe(false);
expect(isCycleEnabled(null, null)).toBe(false);
});
it("an explicit true opts a non-FEMALE account in", () => {
expect(isCycleEnabled("MALE", { cycleTrackingEnabled: true })).toBe(true);
expect(isCycleEnabled(null, { cycleTrackingEnabled: true })).toBe(true);
});
it("an explicit false opts a FEMALE account out", () => {
expect(isCycleEnabled("FEMALE", { cycleTrackingEnabled: false })).toBe(
false,
);
});
it("gates on UPPERCASE FEMALE only (server enum spelling)", () => {
// Lowercase / other spellings never enable by derivation.
expect(isCycleEnabled("female", { cycleTrackingEnabled: null })).toBe(false);
expect(isCycleEnabled("OTHER", { cycleTrackingEnabled: null })).toBe(false);
});
it("exports the wire errorCode the iOS retry classifier branches on", () => {
expect(CYCLE_DISABLED_ERROR_CODE).toBe("cycle.disabled");
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Attribute a day-log date to its owning `MenstrualCycle` span.
*
* A day-log belongs to the cycle whose `startDate <= date` and which is
* the latest such start (the cycle is open-ended until the next cycle's
* start). Returns the cycle id or null when no cycle precedes the date.
*/
import { prisma } from "@/lib/db";
export async function findOwningCycleId(
userId: string,
date: string,
): Promise<string | null> {
const cycle = await prisma.menstrualCycle.findFirst({
where: {
userId,
deletedAt: null,
startDate: { lte: date },
},
orderBy: { startDate: "desc" },
select: { id: true },
});
return cycle?.id ?? null;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Cycle day-log write helper — the shared upsert behind the single POST,
* the PATCH, and the bulk drain (ios-contract §2.A / §2.B).
*
* UPSERT key: `(userId, source, externalId)` when `externalId` is present
* (the NULL-distinct cross-device dedup key, like MoodEntry), else the
* canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM);
* every other field is queryable plaintext. Symptom keys resolve against
* the seeded catalog (unknown keys dropped silently) and write the
* `CycleSymptomLink` join.
*
* Returns the upserted row id + whether a row already existed (so the
* caller can map `inserted`/`updated`/`duplicate`) plus whether any field
* actually changed on a re-post (drives the bulk `updated` vs `duplicate`
* distinction).
*/
import { prisma } from "@/lib/db";
import { encrypt } from "@/lib/crypto";
import type { CycleDayLogInput } from "@/lib/validations/cycle";
export interface DayLogWriteResult {
id: string;
existed: boolean;
/** A pre-existing row whose persisted fields differ from the re-post. */
changed: boolean;
}
/** Resolve catalog symptom keys → ids, dropping unknown keys silently. */
async function resolveSymptomIds(
userId: string,
keys: readonly string[],
): Promise<string[]> {
if (keys.length === 0) return [];
const unique = Array.from(new Set(keys));
// Catalog rows (userId null) plus this user's own custom symptoms.
const rows = await prisma.cycleSymptom.findMany({
where: {
key: { in: unique },
isActive: true,
OR: [{ userId: null }, { userId }],
},
select: { id: true },
});
return rows.map((r) => r.id);
}
/**
* Upsert one cycle day-log. `tz` anchors the date string. When
* `cycleId` is supplied the row is attributed to that cycle span.
*/
export async function upsertCycleDayLog(
userId: string,
entry: CycleDayLogInput,
tz: string | null,
cycleId: string | null = null,
): Promise<DayLogWriteResult> {
const source = entry.source;
const notesEncrypted = entry.note ? encrypt(entry.note) : null;
const where = entry.externalId
? {
userId_source_externalId: {
userId,
source,
externalId: entry.externalId,
},
}
: { userId_date: { userId, date: entry.date } };
// Probe so the caller can distinguish inserted / updated / duplicate
// and (for an externalId re-post) detect whether a field changed.
const existing = await prisma.cycleDayLog.findUnique({
where,
select: {
id: true,
date: true,
flow: true,
intermenstrualBleeding: true,
basalBodyTempC: true,
ovulationTest: true,
cervicalMucus: true,
sexualActivity: true,
protectedSex: true,
pregnancyTest: true,
progesteroneTest: true,
contraceptive: true,
notesEncrypted: true,
deletedAt: true,
},
});
// Field-by-field create data (no mass assignment).
const baseData = {
flow: entry.flow ?? null,
intermenstrualBleeding: entry.intermenstrualBleeding ?? false,
basalBodyTempC: entry.basalBodyTempC ?? null,
ovulationTest: entry.ovulationTest ?? null,
cervicalMucus: entry.cervicalMucus ?? null,
sexualActivity: entry.sexualActivity ?? false,
protectedSex: entry.protectedSex ?? null,
pregnancyTest: entry.pregnancyTest ?? null,
progesteroneTest: entry.progesteroneTest ?? null,
contraceptive: entry.contraceptive ?? null,
notesEncrypted,
};
// Last-writer-wins on the field set. An externalId re-post also refreshes
// `date` (a re-zoned re-import lands the corrected wall-clock). A
// soft-deleted row is resurrected on a fresh write (deletedAt cleared).
const updateData = {
...baseData,
...(entry.externalId ? { date: entry.date } : {}),
...(cycleId !== null ? { cycleId } : {}),
deletedAt: null,
syncVersion: { increment: 1 },
};
const row = await prisma.cycleDayLog.upsert({
where,
create: {
userId,
date: entry.date,
tz,
source,
externalId: entry.externalId ?? null,
cycleId,
...baseData,
},
update: updateData,
select: { id: true },
});
// Replace the symptom-link set when symptoms were supplied. Absent
// `symptoms` leaves existing links untouched (a partial day-log edit
// that doesn't mention symptoms must not wipe them).
if (entry.symptoms !== undefined) {
const ids = await resolveSymptomIds(
userId,
entry.symptoms.map((s) => s.key),
);
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: row.id } });
if (ids.length > 0) {
await prisma.cycleSymptomLink.createMany({
data: ids.map((symptomId) => ({ dayLogId: row.id, symptomId })),
skipDuplicates: true,
});
}
}
// `changed` only matters for the bulk `updated` vs `duplicate` split on
// an existing row: did any persisted field differ from the re-post?
let changed = false;
if (existing) {
changed =
existing.flow !== baseData.flow ||
existing.intermenstrualBleeding !== baseData.intermenstrualBleeding ||
existing.basalBodyTempC !== baseData.basalBodyTempC ||
existing.ovulationTest !== baseData.ovulationTest ||
existing.cervicalMucus !== baseData.cervicalMucus ||
existing.sexualActivity !== baseData.sexualActivity ||
existing.protectedSex !== baseData.protectedSex ||
existing.pregnancyTest !== baseData.pregnancyTest ||
existing.progesteroneTest !== baseData.progesteroneTest ||
existing.contraceptive !== baseData.contraceptive ||
existing.notesEncrypted !== baseData.notesEncrypted ||
existing.deletedAt !== null ||
(Boolean(entry.externalId) && existing.date !== entry.date);
}
return { id: row.id, existed: existing !== null, changed };
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Cycle row → wire-DTO mappers (ios-contract §2 + §3).
*
* The canonical `CycleDayLogDTO` and `MenstrualCycleDTO` shapes the iOS
* client mirrors. `notesEncrypted` is decrypted fail-soft on read (a
* key-rotation gap on one row reads `null`, never 500s the whole page).
* Symptom links are flattened to `[{ key, severity }]` using the catalog
* key (severity is not yet persisted per-link in v1.15.0 — the join is a
* presence link; severity rides the input but the catalog link carries
* presence only, so it reads back `null`).
*/
import { decrypt } from "@/lib/crypto";
import type { CyclePredictionResult } from "@/lib/cycle/types";
import type {
CycleDayLog,
CycleSymptomLink,
CycleSymptom,
MenstrualCycle,
} from "@/generated/prisma/client";
export interface CycleSymptomDTO {
key: string;
severity: number | null;
}
export interface CycleDayLogDTO {
id: string;
date: string;
cycleId: string | null;
flow: string | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
ovulationTest: string | null;
cervicalMucus: string | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
progesteroneTest: string | null;
contraceptive: string | null;
symptoms: CycleSymptomDTO[];
note: string | null;
source: string;
externalId: string | null;
syncVersion: number;
updatedAt: string;
deletedAt: string | null;
}
export interface MenstrualCycleDTO {
id: string;
startDate: string;
endDate: string | null;
periodEndDate: string | null;
lengthDays: number | null;
ovulationDate: string | null;
ovulationConfirmed: boolean;
isPredicted: boolean;
syncVersion: number;
updatedAt: string;
}
type DayLogWithLinks = CycleDayLog & {
symptomLinks?: (CycleSymptomLink & { symptom: Pick<CycleSymptom, "key"> })[];
};
/** Decrypt a day-log note fail-soft (null on missing / undecryptable). */
function decryptNote(notesEncrypted: string | null): string | null {
if (!notesEncrypted) return null;
try {
return decrypt(notesEncrypted);
} catch {
return null;
}
}
export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
return {
id: row.id,
date: row.date,
cycleId: row.cycleId,
flow: row.flow,
intermenstrualBleeding: row.intermenstrualBleeding,
basalBodyTempC: row.basalBodyTempC,
ovulationTest: row.ovulationTest,
cervicalMucus: row.cervicalMucus,
sexualActivity: row.sexualActivity,
protectedSex: row.protectedSex,
pregnancyTest: row.pregnancyTest,
progesteroneTest: row.progesteroneTest,
contraceptive: row.contraceptive,
symptoms: (row.symptomLinks ?? []).map((l) => ({
key: l.symptom.key,
severity: null,
})),
note: decryptNote(row.notesEncrypted),
source: row.source,
externalId: row.externalId,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
};
}
export function toMenstrualCycleDTO(row: MenstrualCycle): MenstrualCycleDTO {
return {
id: row.id,
startDate: row.startDate,
endDate: row.endDate,
periodEndDate: row.periodEndDate,
lengthDays: row.lengthDays,
ovulationDate: row.ovulationDate,
ovulationConfirmed: row.ovulationConfirmed,
isPredicted: row.isPredicted,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
};
}
/** The Prisma `include` that hydrates symptom links → DTO keys. */
export const dayLogSymptomInclude = {
symptomLinks: { include: { symptom: { select: { key: true } } } },
} as const;
export interface CyclePredictionDTO {
method: string;
nextPeriodStart: string;
nextPeriodStartLow: string;
nextPeriodStartHigh: string;
fertileWindowStart: string | null;
fertileWindowEnd: string | null;
predictedOvulation: string | null;
confidence: number;
cyclesObserved: number;
stillLearning: boolean;
disclaimer: string;
}
/**
* Map the engine result to the wire `CyclePredictionDTO`. Fertile-window
* fields (and the predicted ovulation) are suppressed unless the goal
* permits them (ios-contract §2.D — GENERAL_HEALTH / PERIMENOPAUSE / OFF
* never surface a fertile window; the suppression is server-side, never
* iOS).
*/
export function toCyclePredictionDTO(
result: CyclePredictionResult,
goalAllowsFertile: boolean,
disclaimer: string,
): CyclePredictionDTO {
return {
method: result.method,
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null,
fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null,
predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
stillLearning: result.stillLearning,
disclaimer,
};
}
/** The full profile read DTO (ios-contract §2.G). */
export interface CycleProfileDTO {
goal: string;
cycleTrackingEnabled: boolean;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
updatedAt: string;
}
export function toCycleProfileDTO(
row: MenstrualCycleProfileRow,
cycleTrackingEnabled: boolean,
): CycleProfileDTO {
return {
goal: row.goal,
cycleTrackingEnabled,
rawChartMode: row.rawChartMode,
predictionEnabled: row.predictionEnabled,
discreetNotifications: row.discreetNotifications,
sensitiveCategoryEncryption: row.sensitiveCategoryEncryption,
typicalCycleLength: row.typicalCycleLength,
typicalPeriodLength: row.typicalPeriodLength,
lutealPhaseLength: row.lutealPhaseLength,
updatedAt: row.updatedAt.toISOString(),
};
}
type MenstrualCycleProfileRow = {
goal: string;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
updatedAt: Date;
};
/**
* Whether a goal surfaces the fertile window. Per the v1.15 contract the
* window is gated to the conception goal only — GENERAL_HEALTH /
* AVOID_PREGNANCY / PERIMENOPAUSE / OFF suppress it (the suppression is
* always server-side, never iOS).
*/
export function goalAllowsFertileWindow(goal: string): boolean {
return goal === "TRYING_TO_CONCEIVE";
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Prisma-row → engine-input adapters + calendar composition
* (ios-contract §2.D). The engine itself (`predictCycle`, `phaseSeries`)
* is pure + DB-free; this module bridges the persisted rows to the plain
* input shapes and assembles the `CalendarDayDTO` grid the read returns.
*/
import {
predictCycle,
phaseForDay,
addDays,
dayDiff,
isWithin,
type CycleInput,
type DayLogInput,
type NightlyTempInput,
type CycleProfileInput,
type CyclePredictionResult,
type CyclePhase,
type PhaseCycle,
LUTEAL_DEFAULT,
} from "@/lib/cycle";
import type {
CycleProfile,
CycleDayLog,
MenstrualCycle,
} from "@/generated/prisma/client";
export interface CalendarDayDTO {
date: string;
phase: CyclePhase | null;
isPredictedPeriod: boolean;
isFertileWindow: boolean;
isPredictedOvulation: boolean;
isPeriodLogged: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;
}
/** Rows the calendar needs from each day-log. */
export type CalendarDayLogRow = Pick<
CycleDayLog,
"date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus"
> & { hasSymptoms: boolean };
/** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */
export function toCycleInputs(
cycles: readonly Pick<
MenstrualCycle,
"startDate" | "endDate" | "periodEndDate" | "ovulationDate" | "ovulationConfirmed"
>[],
): CycleInput[] {
return cycles.map((c) => ({
startDate: c.startDate,
endDate: c.endDate,
periodEndDate: c.periodEndDate,
ovulationDate: c.ovulationDate,
ovulationConfirmed: c.ovulationConfirmed,
}));
}
/** Map CycleDayLog rows to engine `DayLogInput`. */
export function toDayLogInputs(
logs: readonly Pick<
CycleDayLog,
"date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus"
>[],
): DayLogInput[] {
return logs.map((l) => ({
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
}));
}
/** Map a CycleProfile row to the engine's `CycleProfileInput`. */
export function toProfileInput(profile: CycleProfile): CycleProfileInput {
return {
goal: profile.goal,
typicalCycleLength: profile.typicalCycleLength,
typicalPeriodLength: profile.typicalPeriodLength,
lutealPhaseLength: profile.lutealPhaseLength,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
};
}
/** Period-length of an observed cycle: periodEnd − start + 1, or default. */
function periodLengthOf(
cycle: Pick<MenstrualCycle, "startDate" | "periodEndDate">,
): number | null {
if (!cycle.periodEndDate) return null;
return dayDiff(cycle.periodEndDate, cycle.startDate) + 1;
}
/**
* Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's
* span ends where the next begins; the latest open cycle runs to the
* predicted next-period start). Used to label every calendar day's phase.
*/
function buildPhaseCycles(
cycles: readonly Pick<
MenstrualCycle,
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
>[],
predictedNextStart: string | null,
lutealLength: number,
): PhaseCycle[] {
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
const out: PhaseCycle[] = [];
for (let i = 0; i < sorted.length; i++) {
const c = sorted[i];
const next = sorted[i + 1];
const nextStart =
next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28);
out.push({
startDate: c.startDate,
nextStart,
ovulationDate: c.ovulationDate,
periodLength: periodLengthOf(c),
lutealLength,
});
}
return out;
}
/** Phase of a single date across the contiguous cycle list (or null). */
function phaseAcross(date: string, phaseCycles: readonly PhaseCycle[]): CyclePhase | null {
for (const pc of phaseCycles) {
const r = phaseForDay(date, pc);
if (r.phase !== null) return r.phase;
}
return null;
}
export interface CalendarBuildResult {
prediction: CyclePredictionResult | null;
days: CalendarDayDTO[];
}
/**
* Compose the calendar grid for `[from, to]` inclusive. Runs the engine
* once for the forward forecast, then labels each day's phase, predicted
* period bar, fertile window, and logged-flow overlay.
*
* Fertile-window fields are caller-gated: pass `goalAllowsFertile=false`
* (GENERAL_HEALTH / PERIMENOPAUSE) to suppress `isFertileWindow` +
* `isPredictedOvulation` at the grid level (the prediction's own window
* fields are nulled upstream by the engine for those goals).
*/
export function buildCalendar(
profile: CycleProfile,
cycles: readonly MenstrualCycle[],
dayLogs: readonly CalendarDayLogRow[],
nights: readonly NightlyTempInput[],
from: string,
to: string,
today: string,
goalAllowsFertile: boolean,
): CalendarBuildResult {
const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
// Forecast (only when prediction is on + not raw-chart mode).
let prediction: CyclePredictionResult | null = null;
if (profile.predictionEnabled && !profile.rawChartMode) {
prediction = predictCycle(
toCycleInputs(cycles),
toDayLogInputs(dayLogs),
toProfileInput(profile),
today,
nights,
);
}
const phaseCycles = buildPhaseCycles(
cycles,
prediction?.nextPeriodStart ?? null,
lutealLength,
);
// Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).
const predictedPeriodStart = prediction?.nextPeriodStart ?? null;
const predictedPeriodEnd =
prediction && predictedPeriodStart
? addDays(
predictedPeriodStart,
Math.max(0, prediction.predictedPeriodLength - 1),
)
: null;
const logByDate = new Map<string, CalendarDayLogRow>();
for (const l of dayLogs) logByDate.set(l.date, l);
const days: CalendarDayDTO[] = [];
const span = dayDiff(to, from);
for (let i = 0; i <= span; i++) {
const date = addDays(from, i);
const log = logByDate.get(date);
const isPredictedPeriod =
predictedPeriodStart !== null &&
predictedPeriodEnd !== null &&
isWithin(date, predictedPeriodStart, predictedPeriodEnd);
const isFertileWindow =
goalAllowsFertile &&
prediction?.fertileWindowStart != null &&
prediction.fertileWindowEnd != null &&
isWithin(date, prediction.fertileWindowStart, prediction.fertileWindowEnd);
const isPredictedOvulation =
goalAllowsFertile &&
prediction?.predictedOvulation != null &&
prediction.predictedOvulation === date;
days.push({
date,
phase: phaseAcross(date, phaseCycles),
isPredictedPeriod,
isFertileWindow,
isPredictedOvulation,
isPeriodLogged:
log?.flow != null && log.flow !== "NONE",
flow: log?.flow ?? null,
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
});
}
return { prediction, days };
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Cycle-tracking feature gate.
*
* Two gates collapse into one resolved boolean (ios-contract §1):
* (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and
* (b) a gender-derived default when that toggle is NULL.
*
* `isCycleEnabled` is the single source of truth. The server gender enum
* ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys on
* `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit
* `true`/`false` overrides the derivation (so a non-FEMALE account can
* opt in and a FEMALE account can opt out).
*
* NOTE: the gender enum normalisation (adding `OTHER` / lowercasing) is a
* separate, iOS-blocked decision (migration 0131, reserved). Until that
* lands, `OTHER` / non-binary accounts gate purely on the explicit
* `cycleTrackingEnabled = true` opt-in via the Settings toggle.
*/
import { apiError } from "@/lib/api-response";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import type { CycleProfile } from "@/generated/prisma/client";
/** Wire `errorCode` the iOS retry classifier branches on. */
export const CYCLE_DISABLED_ERROR_CODE = "cycle.disabled";
/**
* Resolve whether cycle tracking is enabled for an account.
*
* @param gender the user's `gender` column (`"MALE" | "FEMALE" | null`).
* @param profile the user's `CycleProfile`, or just its
* `cycleTrackingEnabled` field (NULL ⇒ derive from gender).
*/
export function isCycleEnabled(
gender: string | null | undefined,
profile: { cycleTrackingEnabled: boolean | null } | null | undefined,
): boolean {
const toggle = profile?.cycleTrackingEnabled;
if (toggle === true) return true;
if (toggle === false) return false;
// NULL toggle → derive from gender.
return gender === "FEMALE";
}
/**
* Route-guard outcome: either the resolved profile (enabled) or a ready
* 403 envelope (disabled) to return verbatim.
*/
export type CycleGateResult =
| { enabled: true; profile: CycleProfile }
| { enabled: false; response: Response };
/**
* Enforce the gate for a `/api/cycle/*` route. Lazily upserts the
* `CycleProfile` (so the toggle row exists), resolves `isCycleEnabled`,
* and returns a 403 `cycle.disabled` envelope when the account may not
* write/read cycle data — even with a valid Bearer token.
*/
export async function requireCycleEnabled(
userId: string,
gender: string | null | undefined,
): Promise<CycleGateResult> {
const profile = await getOrCreateCycleProfile(userId);
if (!isCycleEnabled(gender, profile)) {
return {
enabled: false,
response: apiError("Cycle tracking is not enabled", 403, {
errorCode: CYCLE_DISABLED_ERROR_CODE,
}),
};
}
return { enabled: true, profile };
}
+80
View File
@@ -0,0 +1,80 @@
/**
* 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.
*/
import { prisma } from "@/lib/db";
import type { CyclePredictionResult } from "@/lib/cycle/types";
/** Don't re-persist an unchanged forecast more often than this. */
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.
*/
export async function persistPredictionCache(
userId: string,
result: CyclePredictionResult,
now: Date = new Date(),
): Promise<void> {
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 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;
// 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,
};
await prisma.cyclePrediction.upsert({
where: { userId },
create: { userId, ...data },
update: data,
});
} catch {
/* best-effort cache write — the live engine result is authoritative */
}
}
+1
View File
@@ -67,6 +67,7 @@ const openApiBase: Pick<
{ name: "Devices" },
{ name: "Export" },
{ name: "Sync" },
{ name: "Cycle" },
{ name: "Admin" },
{ name: "Meta" },
],
+480
View File
@@ -54,6 +54,18 @@ import {
VITALS_BASELINE_TYPES,
} from "@/lib/insights/derived/registry";
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
import {
flowLevelEnum,
ovulationTestEnum,
cervicalMucusEnum,
homeTestResultEnum,
cycleTrackingGoalEnum,
cycleDayLogInputSchema,
cycleDayLogPatchSchema,
cycleBulkSchema,
cyclePeriodSchema,
cyclePrefsSchema,
} from "@/lib/validations/cycle";
/**
* Common envelopes — every HealthLog API response wraps payload in
@@ -2148,9 +2160,477 @@ const coachFactDeletedResponse = z.object({
),
});
// ── Cycle tracking (v1.15.0) ─────────────────────────────────────────
// The `/api/cycle/*` capture / calendar / history / settings surface +
// the cycle-prefs PATCH. Request bodies come from the Zod validation
// module so the spec stays single-source; response DTOs are declared
// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also
// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off.
const predictionMethodEnumOpenapi = z
.enum(["CALENDAR", "SYMPTOTHERMAL", "TEMPERATURE_TREND", "BLENDED"])
.meta({ id: "CyclePredictionMethod" });
const cyclePhaseEnumOpenapi = z
.enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"])
.meta({ id: "CyclePhase" });
flowLevelEnum.meta({ id: "FlowLevel", description: "Menstrual-flow intensity." });
ovulationTestEnum.meta({
id: "OvulationTest",
description: "Ovulation predictor-kit (OPK) reading.",
});
cervicalMucusEnum.meta({ id: "CervicalMucus", description: "Cervical-mucus quality." });
homeTestResultEnum.meta({
id: "HomeTestResult",
description: "At-home test result (pregnancy / progesterone).",
});
cycleTrackingGoalEnum.meta({
id: "CycleTrackingGoal",
description: "Drives cycle copy + fertile-window gating.",
});
cycleDayLogInputSchema.meta({
id: "CycleDayLogInput",
description:
"One day's cycle capture. `note` is encrypted at rest; every other field is queryable plaintext. UPSERT key: `(userId, source, externalId)` when externalId present, else `(userId, date)`. Shared by the single POST, the bulk drain, and the period shortcut.",
});
cycleBulkSchema.meta({
id: "CycleDayLogBulkRequest",
description:
"Outbox / HealthKit drain. Up to 500 entries per call; wrapped in `withIdempotency`; rate-limited 60/min. Each entry upserts per the day-log key.",
});
cyclePeriodSchema.meta({
id: "CyclePeriodRequest",
description:
"One-tap period boundary. `start` opens a new cycle (closing the prior), `end` stamps the current cycle's periodEndDate; both write a boundary day-log.",
});
cyclePrefsSchema.meta({
id: "CyclePrefsRequest",
description:
"Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.",
});
cycleDayLogPatchSchema.meta({
id: "CycleDayLogPatchRequest",
description:
"Partial day-log edit. Every field optional; `note` re-encrypts (explicit null clears it). `date` / `source` / `externalId` are immutable on update.",
});
const cycleSymptomDto = z.object({
key: z.string(),
severity: z.number().int().min(1).max(4).nullable(),
});
const cycleDayLogDto = z
.object({
id: z.string(),
date: z.string(),
cycleId: z.string().nullable(),
flow: flowLevelEnum.nullable(),
intermenstrualBleeding: z.boolean(),
basalBodyTempC: z.number().nullable(),
ovulationTest: ovulationTestEnum.nullable(),
cervicalMucus: cervicalMucusEnum.nullable(),
sexualActivity: z.boolean(),
protectedSex: z.boolean().nullable(),
pregnancyTest: homeTestResultEnum.nullable(),
progesteroneTest: homeTestResultEnum.nullable(),
contraceptive: z.string().nullable(),
symptoms: z.array(cycleSymptomDto),
note: z.string().nullable(),
source: z.string(),
externalId: z.string().nullable(),
syncVersion: z.number().int(),
updatedAt: z.iso.datetime({ offset: true }),
deletedAt: z.iso.datetime({ offset: true }).nullable(),
})
.meta({
id: "CycleDayLogDTO",
description:
"The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.",
});
const menstrualCycleDto = z
.object({
id: z.string(),
startDate: z.string(),
endDate: z.string().nullable(),
periodEndDate: z.string().nullable(),
lengthDays: z.number().int().nullable(),
ovulationDate: z.string().nullable(),
ovulationConfirmed: z.boolean(),
isPredicted: z.boolean(),
syncVersion: z.number().int(),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "MenstrualCycleDTO",
description: "One menstrual cycle (observed or forward-predicted).",
});
const cyclePredictionDto = z
.object({
method: predictionMethodEnumOpenapi,
nextPeriodStart: z.string(),
nextPeriodStartLow: z.string(),
nextPeriodStartHigh: z.string(),
fertileWindowStart: z.string().nullable(),
fertileWindowEnd: z.string().nullable(),
predictedOvulation: z.string().nullable(),
confidence: z.number(),
cyclesObserved: z.number().int(),
stillLearning: z.boolean(),
disclaimer: z.string(),
})
.meta({
id: "CyclePredictionDTO",
description:
"The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is TRYING_TO_CONCEIVE.",
});
const cycleCalendarDayDto = z.object({
date: z.string(),
phase: cyclePhaseEnumOpenapi.nullable(),
isPredictedPeriod: z.boolean(),
isFertileWindow: z.boolean(),
isPredictedOvulation: z.boolean(),
isPeriodLogged: z.boolean(),
flow: flowLevelEnum.nullable(),
hasSymptoms: z.boolean(),
confidence: z.number(),
});
const cycleProfileDto = z
.object({
goal: cycleTrackingGoalEnum,
cycleTrackingEnabled: z.boolean(),
rawChartMode: z.boolean(),
predictionEnabled: z.boolean(),
discreetNotifications: z.boolean(),
sensitiveCategoryEncryption: z.boolean(),
typicalCycleLength: z.number().int().nullable(),
typicalPeriodLength: z.number().int().nullable(),
lutealPhaseLength: z.number().int().nullable(),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "CycleProfileDTO",
description: "The full per-user cycle settings row.",
});
const cycleCalendarResponse = z.object({
profile: z.object({
goal: cycleTrackingGoalEnum,
rawChartMode: z.boolean(),
predictionEnabled: z.boolean(),
cyclesObserved: z.number().int(),
}),
prediction: cyclePredictionDto.nullable(),
days: z.array(cycleCalendarDayDto),
meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }),
});
const cycleHistoryResponse = z.object({
cycles: z.array(menstrualCycleDto),
stats: z.object({
avgLengthDays: z.number().int().nullable(),
lengthVariabilityDays: z.number().nullable(),
avgPeriodLengthDays: z.number().int().nullable(),
regularity: z.enum(["REGULAR", "IRREGULAR", "LEARNING"]),
}),
});
const cyclePeriodResponse = z.object({
cycle: menstrualCycleDto.nullable(),
dayLog: cycleDayLogDto.nullable(),
});
const cycleBulkEntryResult = z.object({
index: z.number().int(),
status: z.enum(["inserted", "duplicate", "updated", "skipped"]),
id: z.string().optional(),
externalId: z.string().optional(),
reason: z.string().optional(),
});
const cycleBulkResponse = z.object({
processed: z.number().int(),
inserted: z.number().int(),
updated: z.number().int(),
duplicates: z.number().int(),
skipped: z.number().int(),
entries: z.array(cycleBulkEntryResult),
});
// A reusable 403 the cycle routes carry (the feature gate).
const cycleDisabledResponse = {
"403": {
description:
"Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
// ── Path table ───────────────────────────────────────────────────────
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/cycle/day-logs": {
post: {
tags: ["Cycle"],
summary: "Capture a single cycle day-log (v1.15.0)",
description:
"Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.",
requestBody: {
required: true,
content: { "application/json": { schema: cycleDayLogInputSchema } },
},
responses: {
"200": {
description: "Existing day-log updated.",
content: {
"application/json": {
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogEnvelope"),
},
},
},
"201": {
description: "New day-log created.",
content: {
"application/json": {
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogCreatedEnvelope"),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/day-logs/{id}": {
patch: {
tags: ["Cycle"],
summary: "Edit a single cycle day-log (v1.15.0)",
description:
"Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: cycleDayLogPatchSchema } },
},
responses: {
"200": {
description: "Day-log updated.",
content: {
"application/json": {
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"),
},
},
},
"404": {
description: "Day-log not found / not owned.",
content: { "application/json": { schema: errorEnvelope } },
},
...cycleDisabledResponse,
...stdResponses,
},
},
delete: {
tags: ["Cycle"],
summary: "Soft-delete a cycle day-log (v1.15.0)",
description:
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Day-log not found / not owned.",
content: { "application/json": { schema: errorEnvelope } },
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/day-logs/bulk": {
post: {
tags: ["Cycle"],
summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)",
description:
"Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:<userId>` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.",
requestBody: {
required: true,
content: { "application/json": { schema: cycleBulkSchema } },
},
responses: {
"200": {
description: "Batch processed (per-entry results).",
content: {
"application/json": {
schema: dataEnvelope(cycleBulkResponse, "CycleBulkEnvelope"),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/period": {
post: {
tags: ["Cycle"],
summary: "Period-boundary shortcut (v1.15.0)",
description:
"One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.",
requestBody: {
required: true,
content: { "application/json": { schema: cyclePeriodSchema } },
},
responses: {
"200": {
description: "Cycle + boundary day-log.",
content: {
"application/json": {
schema: dataEnvelope(cyclePeriodResponse, "CyclePeriodEnvelope"),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/calendar": {
get: {
tags: ["Cycle"],
summary: "Predicted cycle calendar (v1.15.0)",
description:
"Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated.",
requestParams: {
query: z.object({
from: z.string().optional(),
to: z.string().optional(),
}),
},
responses: {
"200": {
description: "Calendar grid + forecast.",
content: {
"application/json": {
schema: dataEnvelope(cycleCalendarResponse, "CycleCalendarEnvelope"),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/cycles": {
get: {
tags: ["Cycle"],
summary: "Cycle history + stats (v1.15.0)",
description:
"Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, regularity }`. `limit` default 24. Gated.",
requestParams: {
query: z.object({
limit: z.coerce.number().int().min(1).max(60).optional(),
}),
},
responses: {
"200": {
description: "Cycle history.",
content: {
"application/json": {
schema: dataEnvelope(cycleHistoryResponse, "CycleHistoryEnvelope"),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/cycles/{id}": {
delete: {
tags: ["Cycle"],
summary: "Soft-delete a menstrual cycle (v1.15.0)",
description:
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Cycle not found / not owned.",
content: { "application/json": { schema: errorEnvelope } },
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/cycle/profile": {
get: {
tags: ["Cycle"],
summary: "Read the full cycle profile (v1.15.0)",
description: "Returns the resolved CycleProfileDTO. Gated.",
responses: {
"200": {
description: "Cycle profile.",
content: {
"application/json": {
schema: dataEnvelope(cycleProfileDto, "CycleProfileEnvelope"),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
},
"/api/auth/me/cycle-prefs": {
get: {
tags: ["Cycle"],
summary: "Read cycle preferences (v1.15.0)",
description:
"Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate.",
responses: {
"200": {
description: "Cycle preferences.",
content: {
"application/json": {
schema: dataEnvelope(cycleProfileDto, "CyclePrefsGetEnvelope"),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Cycle"],
summary: "Update cycle preferences (v1.15.0)",
description:
"Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.",
requestBody: {
required: true,
content: { "application/json": { schema: cyclePrefsSchema } },
},
responses: {
"200": {
description: "Merged cycle preferences.",
content: {
"application/json": {
schema: dataEnvelope(cycleProfileDto, "CyclePrefsPatchEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/meta/capabilities": {
get: {
tags: ["Meta"],
+19 -4
View File
@@ -42,21 +42,36 @@ describe("sync cursor codec", () => {
expect(decodeCursor("")).toBeNull();
// valid base64url but not the expected JSON shape
expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull();
// wrong version
// 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.
expect(
decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")),
decodeCursor(Buffer.from('{"v":1,"d":{}}').toString("base64url")),
).toBeNull();
// missing the `d` envelope
expect(
decodeCursor(Buffer.from('{"v":1}').toString("base64url")),
decodeCursor(Buffer.from('{"v":2}').toString("base64url")),
).toBeNull();
});
it("decodes a current-version empty envelope to a fresh-scan map", () => {
expect(
decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")),
).toEqual({});
});
it("round-trips the v1.15.0 cycle domains", () => {
const cursor: SyncCursor = {
cycleDays: { updatedAtMs: 1_717_001_000_000, id: "clx0cday" },
cycles: { updatedAtMs: 1_717_001_500_000, id: "clx0cyc" },
};
expect(decodeCursor(encodeCursor(cursor))).toEqual(cursor);
});
it("drops a domain whose watermark is malformed but keeps valid ones", () => {
// measurements has a string `u` (invalid); mood is well-formed.
const token = Buffer.from(
JSON.stringify({
v: 1,
v: 2,
d: {
measurements: { u: "x", i: "z" },
mood: { u: 5, i: "clx0mood" },
+13 -2
View File
@@ -20,12 +20,19 @@
*/
/** The sync domains the feed serves. */
export type SyncDomain = "measurements" | "mood" | "intakes";
export type SyncDomain =
| "measurements"
| "mood"
| "intakes"
| "cycleDays"
| "cycles";
export const SYNC_DOMAINS: readonly SyncDomain[] = [
"measurements",
"mood",
"intakes",
"cycleDays",
"cycles",
];
/** A single domain's keyset position. */
@@ -42,7 +49,11 @@ export interface DomainWatermark {
*/
export type SyncCursor = Partial<Record<SyncDomain, DomainWatermark>>;
const CURSOR_VERSION = 1;
// 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.
const CURSOR_VERSION = 2;
/** Encode a per-domain keyset map into an opaque token. */
export function encodeCursor(cursor: SyncCursor): string {
+185
View File
@@ -0,0 +1,185 @@
/**
* Cycle-tracking request/response validation (v1.15.0).
*
* Source of truth for the `/api/cycle/*` wire contract (ios-contract §2).
* The Zod schemas here are reused by the OpenAPI registry so the spec
* stays single-source. `userId` is NEVER a body field — it is narrowed
* from the session/Bearer in every route.
*
* Dates are `YYYY-MM-DD` strings (the MoodEntry tz-anchored convention);
* instants are ISO-8601 with offset.
*/
import { z } from "zod/v4";
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
export const flowLevelEnum = z.enum([
"NONE",
"SPOTTING",
"LIGHT",
"MEDIUM",
"HEAVY",
]);
export const ovulationTestEnum = z.enum([
"NEGATIVE",
"POSITIVE_LH_SURGE",
"ESTROGEN_SURGE",
"INDETERMINATE",
]);
export const cervicalMucusEnum = z.enum([
"DRY",
"STICKY",
"CREAMY",
"WATERY",
"EGG_WHITE",
]);
export const homeTestResultEnum = z.enum([
"NEGATIVE",
"POSITIVE",
"INDETERMINATE",
]);
export const contraceptiveKindEnum = z.enum([
"NONE",
"UNSPECIFIED",
"IMPLANT",
"INJECTION",
"IUD",
"INTRAVAGINAL_RING",
"ORAL",
"PATCH",
"EMERGENCY",
]);
export const cycleTrackingGoalEnum = z.enum([
"GENERAL_HEALTH",
"AVOID_PREGNANCY",
"TRYING_TO_CONCEIVE",
"PERIMENOPAUSE",
"OFF",
]);
/**
* Day-log source. MANUAL (web / single POST) or APPLE_HEALTH (the
* HealthKit batch ingest). Mirrors `MeasurementSource`; the wire spelling
* is APPLE_HEALTH, not "HEALTHKIT".
*/
export const cycleSourceEnum = z.enum(["MANUAL", "APPLE_HEALTH"]);
const dateString = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
/* ── day-log capture (single + bulk shared input) ────────────────── */
/**
* The canonical day-log input. Shared by the single POST, the PATCH, and
* the bulk drain. `note` is encrypted at rest (`notesEncrypted`); every
* other field is queryable plaintext (feeds rollups / correlation).
*/
export const cycleDayLogInputSchema = z.object({
date: dateString,
flow: flowLevelEnum.optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().min(30).max(45).optional(),
ovulationTest: ovulationTestEnum.optional(),
cervicalMucus: cervicalMucusEnum.optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: homeTestResultEnum.optional(),
progesteroneTest: homeTestResultEnum.optional(),
contraceptive: contraceptiveKindEnum.optional(),
symptoms: z
.array(
z.object({
key: z.string().min(1).max(80),
severity: z.number().int().min(1).max(4).optional(),
}),
)
.max(40)
.optional(),
note: z.string().max(500).optional(),
loggedAt: z.iso.datetime({ offset: true }),
source: cycleSourceEnum.optional().default("MANUAL"),
externalId: z.string().min(1).max(120).optional(),
});
export type CycleDayLogInput = z.infer<typeof cycleDayLogInputSchema>;
/** PATCH body — every field optional; `date`/`source`/`externalId` are immutable on update. */
export const cycleDayLogPatchSchema = z.object({
flow: flowLevelEnum.nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().min(30).max(45).nullable().optional(),
ovulationTest: ovulationTestEnum.nullable().optional(),
cervicalMucus: cervicalMucusEnum.nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: homeTestResultEnum.nullable().optional(),
progesteroneTest: homeTestResultEnum.nullable().optional(),
contraceptive: contraceptiveKindEnum.nullable().optional(),
symptoms: z
.array(
z.object({
key: z.string().min(1).max(80),
severity: z.number().int().min(1).max(4).optional(),
}),
)
.max(40)
.optional(),
note: z.string().max(500).nullable().optional(),
loggedAt: z.iso.datetime({ offset: true }).optional(),
});
export const MAX_CYCLE_BULK_ENTRIES = 500;
export const cycleBulkSchema = z.object({
entries: z.array(cycleDayLogInputSchema).min(1).max(MAX_CYCLE_BULK_ENTRIES),
});
/* ── period-boundary shortcut ────────────────────────────────────── */
export const cyclePeriodSchema = z.object({
action: z.enum(["start", "end"]),
date: dateString,
externalId: z.string().min(1).max(120).optional(),
loggedAt: z.iso.datetime({ offset: true }),
});
/* ── calendar + history queries ──────────────────────────────────── */
export const cycleCalendarQuerySchema = z.object({
from: dateString.optional(),
to: dateString.optional(),
});
export const cycleHistoryQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(60).optional(),
});
/* ── cycle preferences (PATCH /api/auth/me/cycle-prefs) ──────────── */
/**
* Partial deep-merge body for the cycle-prefs PATCH. Mirrors the
* notification-prefs precedent: an omitted field is left untouched.
*/
export const cyclePrefsSchema = z
.object({
enabled: z.boolean().optional(),
goal: cycleTrackingGoalEnum.optional(),
rawChartMode: z.boolean().optional(),
predictionEnabled: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
sensitiveCategoryEncryption: z.boolean().optional(),
typicalCycleLength: z.number().int().min(15).max(60).nullable().optional(),
typicalPeriodLength: z.number().int().min(1).max(15).nullable().optional(),
lutealPhaseLength: z.number().int().min(8).max(20).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, {
message: "At least one preference field is required",
});
export type CyclePrefsInput = z.infer<typeof cyclePrefsSchema>;
+362
View File
@@ -0,0 +1,362 @@
/**
* Integration suite for the v1.15.0 cycle-tracking routes against a real
* Postgres in a testcontainer. Asserts the iOS-facing contract end-to-end:
*
* - the feature gate (`cycle.disabled` 403) keys on gender + the
* `cycleTrackingEnabled` toggle, even with a valid session
* - single day-log capture upserts (201 insert → 200 update), encrypts
* the note, and reads it back decrypted
* - the bulk drain returns per-entry inserted / updated / duplicate
* - the period shortcut opens a cycle + boundary day-log
* - the calendar read runs the engine + goal-gates the fertile window
* - the cycle-prefs PATCH flips the gate for a non-FEMALE account
* - `/api/sync/changes` carries the cycleDays + cycles domains incl.
* tombstones after a soft-delete
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-cycle-routes-integration-32-bytes-min-1234567890";
// Crypto-at-rest needs a key for the note encrypt/decrypt round-trip.
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const FEMALE_USER_ID = "user-cycle-female";
const MALE_USER_ID = "user-cycle-male";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function loginAs(userId: string): Promise<void> {
cookieJar.clear();
headerJar.clear();
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: FEMALE_USER_ID,
username: "cycle-female",
email: "cycle-female@example.test",
gender: "FEMALE",
},
});
await prisma.user.create({
data: {
id: MALE_USER_ID,
username: "cycle-male",
email: "cycle-male@example.test",
gender: "MALE",
},
});
});
function jsonRequest(
path: string,
method: string,
body?: unknown,
opts: { idempotencyKey?: string } = {},
): NextRequest {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
return new NextRequest(`http://localhost${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
describe("cycle routes — feature gate", () => {
it("403s a MALE account on every cycle route", async () => {
await loginAs(MALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const res = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-01",
flow: "MEDIUM",
loggedAt: "2026-03-01T08:00:00.000Z",
}),
);
expect(res.status).toBe(403);
const json = await res.json();
expect(json.meta?.errorCode).toBe("cycle.disabled");
});
it("admits a FEMALE account by gender derivation", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const res = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-01",
flow: "MEDIUM",
loggedAt: "2026-03-01T08:00:00.000Z",
}),
);
expect(res.status).toBe(201);
});
});
describe("cycle day-logs — single capture", () => {
it("upserts (201 insert → 200 update) and round-trips the encrypted note", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const insert = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-02",
flow: "LIGHT",
note: "tender, mild cramps",
loggedAt: "2026-03-02T08:00:00.000Z",
}),
);
expect(insert.status).toBe(201);
const insertJson = await insert.json();
expect(insertJson.data.flow).toBe("LIGHT");
expect(insertJson.data.note).toBe("tender, mild cramps");
// The note column is ciphertext, not plaintext.
const row = await getPrismaClient().cycleDayLog.findFirstOrThrow({
where: { userId: FEMALE_USER_ID, date: "2026-03-02" },
});
expect(row.notesEncrypted).not.toBeNull();
expect(row.notesEncrypted).not.toContain("cramps");
const update = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-02",
flow: "HEAVY",
loggedAt: "2026-03-02T09:00:00.000Z",
}),
);
expect(update.status).toBe(200);
const updateJson = await update.json();
expect(updateJson.data.flow).toBe("HEAVY");
expect(updateJson.data.id).toBe(insertJson.data.id);
});
});
describe("cycle day-logs — bulk drain", () => {
it("returns per-entry inserted / updated / duplicate", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/bulk/route");
const first = await POST(
jsonRequest("/api/cycle/day-logs/bulk", "POST", {
entries: [
{
date: "2026-03-05",
flow: "MEDIUM",
source: "APPLE_HEALTH",
externalId: "hk-aaa",
loggedAt: "2026-03-05T08:00:00.000Z",
},
],
}),
);
expect(first.status).toBe(200);
const firstJson = await first.json();
expect(firstJson.data.inserted).toBe(1);
expect(firstJson.data.entries[0].status).toBe("inserted");
// Re-post unchanged → duplicate; re-post changed → updated.
const second = await POST(
jsonRequest("/api/cycle/day-logs/bulk", "POST", {
entries: [
{
date: "2026-03-05",
flow: "MEDIUM",
source: "APPLE_HEALTH",
externalId: "hk-aaa",
loggedAt: "2026-03-05T08:00:00.000Z",
},
{
date: "2026-03-05",
flow: "HEAVY",
source: "APPLE_HEALTH",
externalId: "hk-aaa",
loggedAt: "2026-03-05T08:00:00.000Z",
},
],
}),
);
const secondJson = await second.json();
expect(secondJson.data.entries[0].status).toBe("duplicate");
expect(secondJson.data.entries[1].status).toBe("updated");
});
});
describe("cycle period shortcut", () => {
it("opens a cycle + writes the boundary day-log", async () => {
await loginAs(FEMALE_USER_ID);
const { POST } = await import("@/app/api/cycle/period/route");
const res = await POST(
jsonRequest("/api/cycle/period", "POST", {
action: "start",
date: "2026-03-10",
loggedAt: "2026-03-10T08:00:00.000Z",
}),
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.cycle.startDate).toBe("2026-03-10");
expect(json.data.dayLog.flow).toBe("MEDIUM");
});
});
describe("cycle calendar", () => {
it("runs the engine and goal-gates the fertile window", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
// Three ~28d cycles → a real prediction.
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" },
});
const { GET } = await import("@/app/api/cycle/calendar/route");
const res = await GET(
new NextRequest(
"http://localhost/api/cycle/calendar?from=2026-02-20&to=2026-04-15",
),
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.prediction).not.toBeNull();
expect(json.data.days.length).toBeGreaterThan(0);
// 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({
where: { userId: FEMALE_USER_ID },
});
if (cache === null) await new Promise((r) => setTimeout(r, 25));
}
expect(cache).not.toBeNull();
});
});
describe("cycle-prefs gate flip", () => {
it("enables a MALE account via the prefs PATCH, then admits cycle routes", async () => {
await loginAs(MALE_USER_ID);
const { PATCH } = await import("@/app/api/auth/me/cycle-prefs/route");
const patch = await PATCH(
jsonRequest("/api/auth/me/cycle-prefs", "PATCH", { enabled: true }),
);
expect(patch.status).toBe(200);
const patchJson = await patch.json();
expect(patchJson.data.cycleTrackingEnabled).toBe(true);
// Re-login (fresh session) and confirm the gate now opens.
await loginAs(MALE_USER_ID);
const { POST } = await import("@/app/api/cycle/day-logs/route");
const res = await POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-03-15",
flow: "LIGHT",
loggedAt: "2026-03-15T08:00:00.000Z",
}),
);
expect(res.status).toBe(201);
});
});
describe("sync/changes — cycle domains", () => {
it("carries cycleDays + cycles upserts and tombstones after a soft-delete", async () => {
await loginAs(FEMALE_USER_ID);
const prisma = getPrismaClient();
const cycle = await prisma.menstrualCycle.create({
data: { userId: FEMALE_USER_ID, startDate: "2026-03-20" },
});
const dayLog = await prisma.cycleDayLog.create({
data: {
userId: FEMALE_USER_ID,
date: "2026-03-20",
flow: "MEDIUM",
source: "APPLE_HEALTH",
externalId: "hk-sync-1",
},
});
const { GET } = await import("@/app/api/sync/changes/route");
const first = await GET(
new NextRequest("http://localhost/api/sync/changes"),
);
const firstJson = await first.json();
expect(firstJson.data.changes.cycleDays.upserts.length).toBe(1);
expect(firstJson.data.changes.cycles.upserts.length).toBe(1);
expect(firstJson.data.changes.cycleDays.upserts[0].externalId).toBe(
"hk-sync-1",
);
// Soft-delete both → next pull surfaces tombstones.
await prisma.cycleDayLog.update({
where: { id: dayLog.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
await prisma.menstrualCycle.update({
where: { id: cycle.id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const second = await GET(
new NextRequest("http://localhost/api/sync/changes"),
);
const secondJson = await second.json();
const dayTombs = secondJson.data.changes.cycleDays.tombstones;
const cycleTombs = secondJson.data.changes.cycles.tombstones;
expect(dayTombs.length).toBe(1);
expect(dayTombs[0].externalId).toBe("hk-sync-1");
expect(cycleTombs.length).toBe(1);
expect(cycleTombs[0].id).toBe(cycle.id);
});
});