Files
HealthLog/src/lib/cycle/gate.ts
T
Marc-André BombeckandClaude Opus 4.8 5f485ca780 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>
2026-06-06 08:57:12 +02:00

73 lines
2.7 KiB
TypeScript

/**
* 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 };
}