mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
ea0d7681e2
commit
5f485ca780
34 files changed
+4738
-178
No files matched your search
+1483
-170
File diff suppressed because it is too large.
Load diff
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ const openApiBase: Pick<
|
||||
{ name: "Devices" },
|
||||
{ name: "Export" },
|
||||
{ name: "Sync" },
|
||||
{ name: "Cycle" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user