mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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>
73 lines
2.7 KiB
TypeScript
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 };
|
|
}
|