mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user