From 1744bcf63ffd896a7e949b676d693034c679385c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:32:28 -0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20NomGap=20fasting=20modules=20?= =?UTF-8?q?=E2=80=94=20sessions=20CRUD=20+=20stats,=2014=20built-in=20prot?= =?UTF-8?q?ocols=20+=20custom=20CRUD,=20body=20stages=20+=20autophagy=20co?= =?UTF-8?q?nfidence=20(47=20new=20tests,=20717=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform-service/src/lib/cosmos-init.ts | 3 + .../modules/body-stages/body-stages.test.ts | 264 ++++++++++++++++ .../src/modules/body-stages/routes.ts | 178 +++++++++++ .../src/modules/body-stages/types.ts | 216 ++++++++++++++ .../fasting-protocols.test.ts | 200 +++++++++++++ .../modules/fasting-protocols/repository.ts | 74 +++++ .../src/modules/fasting-protocols/routes.ts | 145 +++++++++ .../src/modules/fasting-protocols/types.ts | 232 ++++++++++++++ .../fasting-sessions/fasting-sessions.test.ts | 282 ++++++++++++++++++ .../modules/fasting-sessions/repository.ts | 258 ++++++++++++++++ .../src/modules/fasting-sessions/routes.ts | 123 ++++++++ .../src/modules/fasting-sessions/types.ts | 199 ++++++++++++ services/platform-service/src/server.ts | 7 + 13 files changed, 2181 insertions(+) create mode 100644 services/platform-service/src/modules/body-stages/body-stages.test.ts create mode 100644 services/platform-service/src/modules/body-stages/routes.ts create mode 100644 services/platform-service/src/modules/body-stages/types.ts create mode 100644 services/platform-service/src/modules/fasting-protocols/fasting-protocols.test.ts create mode 100644 services/platform-service/src/modules/fasting-protocols/repository.ts create mode 100644 services/platform-service/src/modules/fasting-protocols/routes.ts create mode 100644 services/platform-service/src/modules/fasting-protocols/types.ts create mode 100644 services/platform-service/src/modules/fasting-sessions/fasting-sessions.test.ts create mode 100644 services/platform-service/src/modules/fasting-sessions/repository.ts create mode 100644 services/platform-service/src/modules/fasting-sessions/routes.ts create mode 100644 services/platform-service/src/modules/fasting-sessions/types.ts diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index bd53980f..9bd3e8bc 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -34,6 +34,9 @@ const CONTAINER_DEFS: Record = { daily_briefs: { partitionKeyPath: '/userId' }, reflections: { partitionKeyPath: '/userId' }, brain_insights: { partitionKeyPath: '/userId' }, + // NomGap fasting modules + fasting_sessions: { partitionKeyPath: '/userId' }, + fasting_protocols: { partitionKeyPath: '/userId' }, // Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md) telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/body-stages/body-stages.test.ts b/services/platform-service/src/modules/body-stages/body-stages.test.ts new file mode 100644 index 00000000..7382dc3d --- /dev/null +++ b/services/platform-service/src/modules/body-stages/body-stages.test.ts @@ -0,0 +1,264 @@ +/** + * Body stages module unit tests — validates stage definitions and autophagy confidence calculation. + */ + +import { describe, it, expect } from 'vitest'; +import { AutophagyConfidenceRequestSchema, BODY_STAGES, getStageForDuration } from './types.js'; +import { calculateAutophagyConfidence } from './routes.js'; + +// ── Stage definitions ── + +describe('BODY_STAGES', () => { + it('has exactly 6 stages', () => { + expect(BODY_STAGES).toHaveLength(6); + }); + + it('stages are in chronological order', () => { + for (let i = 1; i < BODY_STAGES.length; i++) { + expect(BODY_STAGES[i].timeRangeHours.min).toBeGreaterThanOrEqual( + BODY_STAGES[i - 1].timeRangeHours.min + ); + } + }); + + it('all stages have unique IDs', () => { + const ids = BODY_STAGES.map(s => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('all stages have visualization data', () => { + for (const stage of BODY_STAGES) { + expect(stage.visualizationData).toBeDefined(); + expect(stage.visualizationData.primaryColor).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(stage.visualizationData.secondaryColor).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(stage.visualizationData.glowIntensity).toBeGreaterThanOrEqual(0); + expect(stage.visualizationData.glowIntensity).toBeLessThanOrEqual(1); + expect(stage.visualizationData.animationStyle).toBeTruthy(); + } + }); + + it('all stages have organ systems', () => { + for (const stage of BODY_STAGES) { + expect(stage.organSystems.length).toBeGreaterThan(0); + for (const organ of stage.organSystems) { + expect(organ.name).toBeTruthy(); + expect(organ.description).toBeTruthy(); + } + } + }); + + it('all stages have status labels', () => { + for (const stage of BODY_STAGES) { + expect(stage.statusLabel).toBeTruthy(); + } + }); + + it('first stage is fed (0–4h)', () => { + expect(BODY_STAGES[0].id).toBe('fed'); + expect(BODY_STAGES[0].timeRangeHours.min).toBe(0); + expect(BODY_STAGES[0].timeRangeHours.max).toBe(4); + }); + + it('last stage is extended (48–168h)', () => { + const last = BODY_STAGES[BODY_STAGES.length - 1]; + expect(last.id).toBe('extended'); + expect(last.timeRangeHours.min).toBe(48); + }); +}); + +// ── getStageForDuration ── + +describe('getStageForDuration', () => { + it('returns fed for 0 hours', () => { + expect(getStageForDuration(0).id).toBe('fed'); + }); + + it('returns fed for 3 hours', () => { + expect(getStageForDuration(3).id).toBe('fed'); + }); + + it('returns early_fast for 6 hours', () => { + expect(getStageForDuration(6).id).toBe('early_fast'); + }); + + it('returns fasted for 14 hours', () => { + expect(getStageForDuration(14).id).toBe('fasted'); + }); + + it('returns ketosis for 20 hours', () => { + expect(getStageForDuration(20).id).toBe('ketosis'); + }); + + it('returns deep_autophagy for 30 hours', () => { + expect(getStageForDuration(30).id).toBe('deep_autophagy'); + }); + + it('returns extended for 60 hours', () => { + expect(getStageForDuration(60).id).toBe('extended'); + }); + + it('returns extended for 100 hours', () => { + expect(getStageForDuration(100).id).toBe('extended'); + }); +}); + +// ── AutophagyConfidenceRequestSchema ── + +describe('AutophagyConfidenceRequestSchema', () => { + it('accepts minimal input', () => { + const result = AutophagyConfidenceRequestSchema.safeParse({ durationHours: 16 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.activityLevel).toBe('sedentary'); + } + }); + + it('accepts full input', () => { + const result = AutophagyConfidenceRequestSchema.safeParse({ + durationHours: 24, + lastMealCarbs: 50, + activityLevel: 'moderate', + sleepHours: 8, + completionHistory: { totalFasts: 20, completionRate: 0.85 }, + hrvData: { restingHR: 58, hrv: 55 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects negative durationHours', () => { + const result = AutophagyConfidenceRequestSchema.safeParse({ durationHours: -5 }); + expect(result.success).toBe(false); + }); + + it('rejects durationHours > 168', () => { + const result = AutophagyConfidenceRequestSchema.safeParse({ durationHours: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid activityLevel', () => { + const result = AutophagyConfidenceRequestSchema.safeParse({ + durationHours: 16, + activityLevel: 'extreme', + }); + expect(result.success).toBe(false); + }); +}); + +// ── calculateAutophagyConfidence ── + +describe('calculateAutophagyConfidence', () => { + it('returns low confidence for 0 hours', () => { + const result = calculateAutophagyConfidence({ + durationHours: 0, + activityLevel: 'sedentary', + }); + expect(result.confidence).toBeLessThan(30); + expect(['unlikely', 'possible']).toContain(result.label); + expect(result.currentStage).toBe('fed'); + }); + + it('returns moderate confidence for 16 hours with defaults', () => { + const result = calculateAutophagyConfidence({ + durationHours: 16, + activityLevel: 'sedentary', + }); + expect(result.confidence).toBeGreaterThan(20); + expect(result.confidence).toBeLessThan(70); + expect(result.currentStage).toBe('fasted'); + }); + + it('returns high confidence for 24+ hours with good inputs', () => { + const result = calculateAutophagyConfidence({ + durationHours: 30, + lastMealCarbs: 15, + activityLevel: 'active', + sleepHours: 8, + completionHistory: { totalFasts: 60, completionRate: 0.9 }, + hrvData: { restingHR: 55, hrv: 60 }, + }); + expect(result.confidence).toBeGreaterThan(70); + expect(['very_likely', 'near_certain']).toContain(result.label); + }); + + it('returns near_certain for 72 hours with optimal inputs', () => { + const result = calculateAutophagyConfidence({ + durationHours: 72, + lastMealCarbs: 10, + activityLevel: 'very_active', + sleepHours: 8, + completionHistory: { totalFasts: 100, completionRate: 1.0 }, + hrvData: { restingHR: 50, hrv: 70 }, + }); + expect(result.confidence).toBeGreaterThanOrEqual(85); + expect(result.label).toBe('near_certain'); + }); + + it('confidence never exceeds 100', () => { + const result = calculateAutophagyConfidence({ + durationHours: 168, + lastMealCarbs: 0, + activityLevel: 'very_active', + sleepHours: 8, + completionHistory: { totalFasts: 1000, completionRate: 1.0 }, + hrvData: { restingHR: 40, hrv: 200 }, + }); + expect(result.confidence).toBeLessThanOrEqual(100); + }); + + it('breakdown components sum to confidence', () => { + const result = calculateAutophagyConfidence({ + durationHours: 20, + lastMealCarbs: 60, + activityLevel: 'moderate', + sleepHours: 7, + completionHistory: { totalFasts: 10, completionRate: 0.8 }, + hrvData: { restingHR: 65, hrv: 40 }, + }); + const sum = + result.breakdown.duration + + result.breakdown.meal + + result.breakdown.activity + + result.breakdown.sleep + + result.breakdown.history + + result.breakdown.hrv; + // Confidence = min(100, sum), so sum >= confidence + expect(sum).toBeGreaterThanOrEqual(result.confidence); + }); + + it('low carb last meal gives higher meal score', () => { + const lowCarb = calculateAutophagyConfidence({ + durationHours: 20, + lastMealCarbs: 15, + activityLevel: 'sedentary', + }); + const highCarb = calculateAutophagyConfidence({ + durationHours: 20, + lastMealCarbs: 250, + activityLevel: 'sedentary', + }); + expect(lowCarb.breakdown.meal).toBeGreaterThan(highCarb.breakdown.meal); + }); + + it('more active gives higher activity score', () => { + const active = calculateAutophagyConfidence({ + durationHours: 20, + activityLevel: 'very_active', + }); + const sedentary = calculateAutophagyConfidence({ + durationHours: 20, + activityLevel: 'sedentary', + }); + expect(active.breakdown.activity).toBeGreaterThan(sedentary.breakdown.activity); + }); + + it('returns correct current stage', () => { + const r1 = calculateAutophagyConfidence({ durationHours: 2, activityLevel: 'sedentary' }); + expect(r1.currentStage).toBe('fed'); + + const r2 = calculateAutophagyConfidence({ durationHours: 8, activityLevel: 'sedentary' }); + expect(r2.currentStage).toBe('early_fast'); + + const r3 = calculateAutophagyConfidence({ durationHours: 50, activityLevel: 'sedentary' }); + expect(r3.currentStage).toBe('extended'); + }); +}); diff --git a/services/platform-service/src/modules/body-stages/routes.ts b/services/platform-service/src/modules/body-stages/routes.ts new file mode 100644 index 00000000..b74defee --- /dev/null +++ b/services/platform-service/src/modules/body-stages/routes.ts @@ -0,0 +1,178 @@ +/** + * Body stages REST endpoints — NomGap. + * + * GET /fasting/stages — all 6 stage definitions with visualization metadata + * POST /fasting/autophagy-confidence — calculate personalized autophagy confidence score + * + * Both routes are public (no auth) — stage info is educational. + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError } from '../../lib/errors.js'; +import { + AutophagyConfidenceRequestSchema, + BODY_STAGES, + getStageForDuration, + type AutophagyConfidenceRequest, + type AutophagyConfidenceBreakdown, + type AutophagyConfidenceResponse, +} from './types.js'; + +export async function bodyStageRoutes(app: FastifyInstance) { + // Get all stage definitions + app.get('/fasting/stages', async () => { + return { stages: BODY_STAGES, total: BODY_STAGES.length }; + }); + + // Calculate autophagy confidence + app.post('/fasting/autophagy-confidence', async req => { + const parsed = AutophagyConfidenceRequestSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const result = calculateAutophagyConfidence(parsed.data); + return result; + }); +} + +/** + * Calculate personalized autophagy confidence score. + * + * Weights (from PRD §5.3): + * Duration: 40% + * Last meal (carbs): 20% + * Activity level: 15% + * Sleep quality: 10% + * Completion history: 10% + * HRV data: 5% + */ +export function calculateAutophagyConfidence( + input: AutophagyConfidenceRequest +): AutophagyConfidenceResponse { + const breakdown: AutophagyConfidenceBreakdown = { + duration: 0, + meal: 0, + activity: 0, + sleep: 0, + history: 0, + hrv: 0, + }; + + // Duration score (40% weight, max 40 points) + // Autophagy starts around 16-18h, peaks 24-48h + if (input.durationHours <= 12) { + breakdown.duration = Math.round((input.durationHours / 12) * 10); + } else if (input.durationHours <= 18) { + breakdown.duration = Math.round(10 + ((input.durationHours - 12) / 6) * 10); + } else if (input.durationHours <= 24) { + breakdown.duration = Math.round(20 + ((input.durationHours - 18) / 6) * 10); + } else if (input.durationHours <= 48) { + breakdown.duration = Math.round(30 + ((input.durationHours - 24) / 24) * 10); + } else { + breakdown.duration = 40; + } + + // Last meal carbs score (20% weight, max 20 points) + // Lower carbs = faster glycogen depletion = earlier autophagy + if (input.lastMealCarbs !== undefined) { + if (input.lastMealCarbs <= 20) { + breakdown.meal = 20; // Very low carb + } else if (input.lastMealCarbs <= 50) { + breakdown.meal = 15; + } else if (input.lastMealCarbs <= 100) { + breakdown.meal = 10; + } else if (input.lastMealCarbs <= 200) { + breakdown.meal = 5; + } else { + breakdown.meal = 2; + } + } else { + // Default to moderate if unknown + breakdown.meal = 10; + } + + // Activity level score (15% weight, max 15 points) + // More activity = faster glycogen depletion + const activityScores: Record = { + sedentary: 5, + light: 8, + moderate: 11, + active: 13, + very_active: 15, + }; + breakdown.activity = activityScores[input.activityLevel] ?? 5; + + // Sleep score (10% weight, max 10 points) + if (input.sleepHours !== undefined) { + if (input.sleepHours >= 7 && input.sleepHours <= 9) { + breakdown.sleep = 10; // Optimal sleep + } else if (input.sleepHours >= 6) { + breakdown.sleep = 7; + } else if (input.sleepHours >= 5) { + breakdown.sleep = 4; + } else { + breakdown.sleep = 2; + } + } else { + breakdown.sleep = 5; // Default moderate + } + + // Completion history score (10% weight, max 10 points) + if (input.completionHistory) { + const { totalFasts, completionRate } = input.completionHistory; + // Experienced fasters may enter autophagy more efficiently + const experienceBonus = Math.min(totalFasts / 50, 1) * 5; // Up to 5 points for 50+ fasts + const rateBonus = completionRate * 5; // Up to 5 points for 100% rate + breakdown.history = Math.round(experienceBonus + rateBonus); + } else { + breakdown.history = 3; // Default for new users + } + + // HRV data score (5% weight, max 5 points) + if (input.hrvData) { + let hrvScore = 0; + // Lower resting HR generally indicates better metabolic health + if (input.hrvData.restingHR !== undefined) { + if (input.hrvData.restingHR < 60) hrvScore += 2; + else if (input.hrvData.restingHR < 70) hrvScore += 1.5; + else hrvScore += 1; + } + // Higher HRV generally indicates better parasympathetic tone + if (input.hrvData.hrv !== undefined) { + if (input.hrvData.hrv > 50) hrvScore += 3; + else if (input.hrvData.hrv > 30) hrvScore += 2; + else hrvScore += 1; + } + breakdown.hrv = Math.round(Math.min(hrvScore, 5)); + } else { + breakdown.hrv = 2; // Default moderate + } + + const confidence = Math.min( + 100, + breakdown.duration + + breakdown.meal + + breakdown.activity + + breakdown.sleep + + breakdown.history + + breakdown.hrv + ); + + // Label + let label: AutophagyConfidenceResponse['label']; + if (confidence < 20) label = 'unlikely'; + else if (confidence < 40) label = 'possible'; + else if (confidence < 65) label = 'likely'; + else if (confidence < 85) label = 'very_likely'; + else label = 'near_certain'; + + const currentStage = getStageForDuration(input.durationHours); + + return { + confidence, + label, + breakdown, + currentStage: currentStage.id, + }; +} diff --git a/services/platform-service/src/modules/body-stages/types.ts b/services/platform-service/src/modules/body-stages/types.ts new file mode 100644 index 00000000..c21f0b12 --- /dev/null +++ b/services/platform-service/src/modules/body-stages/types.ts @@ -0,0 +1,216 @@ +/** + * Body stages types — NomGap body visualization stages + autophagy confidence. + * + * No Cosmos container needed — this module is pure computation. + */ + +import { z } from 'zod'; + +// ── Body Stage definition ── + +export interface OrganSystem { + name: string; + description: string; + active: boolean; +} + +export interface BodyStageDefinition { + id: string; + name: string; + timeRangeHours: { min: number; max: number }; + description: string; + detailedDescription: string; + organSystems: OrganSystem[]; + visualizationData: { + primaryColor: string; + secondaryColor: string; + glowIntensity: number; + animationStyle: string; + }; + statusLabel: string; +} + +// ── Autophagy confidence ── + +export const AutophagyConfidenceRequestSchema = z.object({ + durationHours: z.number().min(0).max(168), + lastMealCarbs: z.number().min(0).max(1000).optional(), + activityLevel: z + .enum(['sedentary', 'light', 'moderate', 'active', 'very_active']) + .default('sedentary'), + sleepHours: z.number().min(0).max(24).optional(), + completionHistory: z + .object({ + totalFasts: z.number().int().min(0).default(0), + completionRate: z.number().min(0).max(1).default(0), + }) + .optional(), + hrvData: z + .object({ + restingHR: z.number().min(20).max(250).optional(), + hrv: z.number().min(0).max(300).optional(), + }) + .optional(), +}); + +export type AutophagyConfidenceRequest = z.infer; + +export interface AutophagyConfidenceBreakdown { + duration: number; + meal: number; + activity: number; + sleep: number; + history: number; + hrv: number; +} + +export interface AutophagyConfidenceResponse { + confidence: number; + label: 'unlikely' | 'possible' | 'likely' | 'very_likely' | 'near_certain'; + breakdown: AutophagyConfidenceBreakdown; + currentStage: string; +} + +// ── 6 body stages (aligned with PRD §5.2) ── + +export const BODY_STAGES: BodyStageDefinition[] = [ + { + id: 'fed', + name: 'Fed State', + timeRangeHours: { min: 0, max: 4 }, + description: 'Your body is actively digesting food and absorbing nutrients.', + detailedDescription: + 'Insulin levels are elevated as your body processes the meal. Glucose is being absorbed into the bloodstream and distributed to cells. Excess glucose is converted to glycogen and stored in the liver and muscles.', + organSystems: [ + { name: 'Stomach', description: 'Actively breaking down food', active: true }, + { name: 'Pancreas', description: 'Releasing insulin to manage blood sugar', active: true }, + { name: 'Liver', description: 'Storing glucose as glycogen', active: true }, + { name: 'Small Intestine', description: 'Absorbing nutrients', active: true }, + ], + visualizationData: { + primaryColor: '#FF8C42', + secondaryColor: '#FFB366', + glowIntensity: 0.6, + animationStyle: 'warm_pulse', + }, + statusLabel: 'Digesting', + }, + { + id: 'early_fast', + name: 'Early Fasting', + timeRangeHours: { min: 4, max: 12 }, + description: 'Insulin is dropping and your body begins tapping glycogen stores.', + detailedDescription: + 'Blood sugar normalizes as insulin drops. Your liver begins releasing stored glycogen to maintain blood glucose levels. The migrating motor complex (gut cleaning wave) activates, sweeping debris from the intestines.', + organSystems: [ + { name: 'Liver', description: 'Releasing stored glycogen', active: true }, + { name: 'Pancreas', description: 'Insulin production decreasing', active: true }, + { name: 'Gut', description: 'Cleaning wave (MMC) active', active: true }, + ], + visualizationData: { + primaryColor: '#4ECDC4', + secondaryColor: '#7EDDD6', + glowIntensity: 0.4, + animationStyle: 'gentle_transition', + }, + statusLabel: 'Transitioning', + }, + { + id: 'fasted', + name: 'Fasted State', + timeRangeHours: { min: 12, max: 18 }, + description: 'Fat burning begins as glycogen depletes. Early ketones appear.', + detailedDescription: + 'Glycogen stores are running low. Your body shifts to burning fat for energy. The liver begins converting fatty acids into ketone bodies. Early, mild autophagy processes start activating in cells. Growth hormone begins to rise.', + organSystems: [ + { name: 'Fat Cells', description: 'Beginning to release fatty acids', active: true }, + { name: 'Liver', description: 'Converting fat to ketones', active: true }, + { name: 'Cells', description: 'Early autophagy activation', active: true }, + ], + visualizationData: { + primaryColor: '#45B7D1', + secondaryColor: '#6DC8E0', + glowIntensity: 0.5, + animationStyle: 'sparkle_glow', + }, + statusLabel: 'Fat Burning Begins', + }, + { + id: 'ketosis', + name: 'Ketosis', + timeRangeHours: { min: 18, max: 24 }, + description: 'Deep fat burning. Brain shifts to ketones. Autophagy ramps up.', + detailedDescription: + 'Your body is now in full ketosis. The brain is using ketone bodies for fuel, often producing a sense of mental clarity. Fat cells are actively shrinking as stored fat is mobilized. Autophagy cleaning crews are actively recycling damaged cellular components.', + organSystems: [ + { name: 'Brain', description: 'Running on ketones — mental clarity', active: true }, + { name: 'Fat Cells', description: 'Actively shrinking', active: true }, + { name: 'Cells', description: 'Autophagy cleaning crews active', active: true }, + { name: 'Pituitary', description: 'Growth hormone elevated', active: true }, + ], + visualizationData: { + primaryColor: '#5A8CFF', + secondaryColor: '#8AB4FF', + glowIntensity: 0.7, + animationStyle: 'blue_energy', + }, + statusLabel: 'Deep Fat Burn + Autophagy', + }, + { + id: 'deep_autophagy', + name: 'Deep Autophagy', + timeRangeHours: { min: 24, max: 48 }, + description: 'Peak cellular renewal. Old proteins consumed, new cells emerge.', + detailedDescription: + 'Dramatic cellular transformation is underway. Damaged proteins and organelles are being broken down and recycled. New, healthy cellular components are being built. Growth hormone levels are significantly elevated, protecting muscle mass while fat continues to burn.', + organSystems: [ + { + name: 'All Cells', + description: 'Peak autophagy — recycling damaged components', + active: true, + }, + { name: 'Fat Cells', description: 'Sustained fat burning', active: true }, + { name: 'Pituitary', description: 'Growth hormone surge', active: true }, + { name: 'Immune System', description: 'Beginning to regenerate', active: true }, + ], + visualizationData: { + primaryColor: '#A855F7', + secondaryColor: '#C084FC', + glowIntensity: 0.85, + animationStyle: 'transformation_pulse', + }, + statusLabel: 'Peak Renewal', + }, + { + id: 'extended', + name: 'Extended Fast', + timeRangeHours: { min: 48, max: 168 }, + description: 'Immune system reboot. Stem cell regeneration. Full body renewal.', + detailedDescription: + 'The immune system undergoes a significant regeneration process. Old white blood cells are broken down and new ones are produced from stem cells. This is often referred to as an "immune reset." Consult your doctor before fasting this long.', + organSystems: [ + { name: 'Immune System', description: 'White blood cell regeneration', active: true }, + { name: 'Bone Marrow', description: 'Stem cell division active', active: true }, + { name: 'All Cells', description: 'Deep renewal continuing', active: true }, + { name: 'Fat Cells', description: 'Continued fat mobilization', active: true }, + ], + visualizationData: { + primaryColor: '#F59E0B', + secondaryColor: '#FBBF24', + glowIntensity: 1.0, + animationStyle: 'golden_renewal', + }, + statusLabel: 'Immune Reset', + }, +]; + +// ── Helper: get current stage by hours ── + +export function getStageForDuration(durationHours: number): BodyStageDefinition { + for (let i = BODY_STAGES.length - 1; i >= 0; i--) { + if (durationHours >= BODY_STAGES[i].timeRangeHours.min) { + return BODY_STAGES[i]; + } + } + return BODY_STAGES[0]; +} diff --git a/services/platform-service/src/modules/fasting-protocols/fasting-protocols.test.ts b/services/platform-service/src/modules/fasting-protocols/fasting-protocols.test.ts new file mode 100644 index 00000000..19b2998a --- /dev/null +++ b/services/platform-service/src/modules/fasting-protocols/fasting-protocols.test.ts @@ -0,0 +1,200 @@ +/** + * Fasting protocols module unit tests — validates schemas, built-in protocols, and type guards. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateCustomProtocolSchema, + UpdateCustomProtocolSchema, + FastingProtocolSchema, + BUILT_IN_PROTOCOLS, + PROTOCOL_TYPES, + DIFFICULTY_LEVELS, +} from './types.js'; + +// ── Built-in protocols ── + +describe('BUILT_IN_PROTOCOLS', () => { + it('has exactly 14 built-in protocols', () => { + expect(BUILT_IN_PROTOCOLS).toHaveLength(14); + }); + + it('all built-in protocols have isCustom = false', () => { + for (const p of BUILT_IN_PROTOCOLS) { + expect(p.isCustom).toBe(false); + } + }); + + it('all built-in protocols have unique IDs', () => { + const ids = BUILT_IN_PROTOCOLS.map(p => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('all built-in protocols have valid types', () => { + for (const p of BUILT_IN_PROTOCOLS) { + expect(PROTOCOL_TYPES).toContain(p.type); + } + }); + + it('all built-in protocols have valid difficulty', () => { + for (const p of BUILT_IN_PROTOCOLS) { + expect(DIFFICULTY_LEVELS).toContain(p.difficulty); + } + }); + + it('all built-in protocols pass FastingProtocolSchema', () => { + for (const p of BUILT_IN_PROTOCOLS) { + const result = FastingProtocolSchema.safeParse(p); + expect(result.success).toBe(true); + } + }); + + it('includes 16:8 protocol', () => { + const found = BUILT_IN_PROTOCOLS.find(p => p.name === '16:8'); + expect(found).toBeDefined(); + expect(found!.fastHours).toBe(16); + expect(found!.eatHours).toBe(8); + expect(found!.difficulty).toBe('moderate'); + }); + + it('includes OMAD protocol', () => { + const found = BUILT_IN_PROTOCOLS.find(p => p.name === 'OMAD'); + expect(found).toBeDefined(); + expect(found!.fastHours).toBe(23); + expect(found!.eatHours).toBe(1); + }); + + it('includes Ramadan as religious protocol with locationAware', () => { + const found = BUILT_IN_PROTOCOLS.find(p => p.name === 'Ramadan'); + expect(found).toBeDefined(); + expect(found!.type).toBe('religious'); + expect(found!.religionId).toBe('islam'); + expect(found!.locationAware).toBe(true); + }); + + it('includes Ekadashi as religious protocol', () => { + const found = BUILT_IN_PROTOCOLS.find(p => p.name === 'Ekadashi'); + expect(found).toBeDefined(); + expect(found!.type).toBe('religious'); + expect(found!.religionId).toBe('hinduism'); + }); + + it('has extended fasts (36h, 48h, 72h)', () => { + const extended = BUILT_IN_PROTOCOLS.filter(p => p.type === 'extended'); + expect(extended.length).toBeGreaterThanOrEqual(3); + const hours = extended.map(p => p.fastHours); + expect(hours).toContain(36); + expect(hours).toContain(48); + expect(hours).toContain(72); + }); +}); + +// ── CreateCustomProtocolSchema ── + +describe('CreateCustomProtocolSchema', () => { + const validMinimal = { + name: 'My Custom Fast', + fastHours: 20, + eatHours: 4, + }; + + it('accepts minimal valid input with defaults', () => { + const result = CreateCustomProtocolSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('My Custom Fast'); + expect(result.data.type).toBe('custom'); + expect(result.data.difficulty).toBe('moderate'); + expect(result.data.description).toBe(''); + } + }); + + it('accepts full input with all optional fields', () => { + const result = CreateCustomProtocolSchema.safeParse({ + ...validMinimal, + type: 'religious', + description: 'A special spiritual fast', + difficulty: 'hard', + religionId: 'buddhism', + locationAware: true, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing name', () => { + const result = CreateCustomProtocolSchema.safeParse({ + fastHours: 20, + eatHours: 4, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing fastHours', () => { + const result = CreateCustomProtocolSchema.safeParse({ + name: 'Test', + eatHours: 4, + }); + expect(result.success).toBe(false); + }); + + it('rejects fastHours > 168 (one week)', () => { + const result = CreateCustomProtocolSchema.safeParse({ + name: 'Too Long', + fastHours: 200, + eatHours: 0, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid difficulty', () => { + const result = CreateCustomProtocolSchema.safeParse({ + ...validMinimal, + difficulty: 'impossible', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid type', () => { + const result = CreateCustomProtocolSchema.safeParse({ + ...validMinimal, + type: 'invalid', + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateCustomProtocolSchema ── + +describe('UpdateCustomProtocolSchema', () => { + it('accepts empty object (no updates)', () => { + const result = UpdateCustomProtocolSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts partial name update', () => { + const result = UpdateCustomProtocolSchema.safeParse({ name: 'Renamed Protocol' }); + expect(result.success).toBe(true); + }); + + it('accepts partial fastHours update', () => { + const result = UpdateCustomProtocolSchema.safeParse({ fastHours: 22 }); + expect(result.success).toBe(true); + }); + + it('rejects name > 128 chars', () => { + const result = UpdateCustomProtocolSchema.safeParse({ name: 'x'.repeat(200) }); + expect(result.success).toBe(false); + }); +}); + +// ── Constants ── + +describe('type constants', () => { + it('has expected protocol types', () => { + expect(PROTOCOL_TYPES).toEqual(['interval', 'extended', 'alternate', 'religious', 'custom']); + }); + + it('has expected difficulty levels', () => { + expect(DIFFICULTY_LEVELS).toEqual(['easy', 'moderate', 'hard', 'very_hard', 'expert']); + }); +}); diff --git a/services/platform-service/src/modules/fasting-protocols/repository.ts b/services/platform-service/src/modules/fasting-protocols/repository.ts new file mode 100644 index 00000000..37b121cd --- /dev/null +++ b/services/platform-service/src/modules/fasting-protocols/repository.ts @@ -0,0 +1,74 @@ +/** + * Fasting protocols repository — Cosmos DB CRUD for custom protocols. + * + * Built-in protocols are hardcoded in types.ts (no DB). + * Custom protocols stored in container: fasting_protocols (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { FastingProtocolDoc } from './types.js'; + +function container() { + return getContainer('fasting_protocols'); +} + +export async function getCustomProtocols(userId: string): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.deleted = false ORDER BY c.createdAt DESC', + parameters: [{ name: '@userId', value: userId }], + }) + .fetchAll(); + return resources; +} + +export async function getCustomProtocol( + userId: string, + protocolId: string +): Promise { + try { + const { resource } = await container().item(protocolId, userId).read(); + if (!resource || resource.deleted) return null; + return resource; + } catch { + return null; + } +} + +export async function createCustomProtocol(doc: FastingProtocolDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as FastingProtocolDoc; +} + +export async function updateCustomProtocol( + userId: string, + protocolId: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container() + .item(protocolId, userId) + .read(); + if (!existing || existing.deleted) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(protocolId, userId).replace(merged); + return resource as FastingProtocolDoc; + } catch { + return null; + } +} + +export async function deleteCustomProtocol(userId: string, protocolId: string): Promise { + try { + const { resource: existing } = await container() + .item(protocolId, userId) + .read(); + if (!existing || existing.deleted) return false; + const merged = { ...existing, deleted: true, updatedAt: new Date().toISOString() }; + await container().item(protocolId, userId).replace(merged); + return true; + } catch { + return false; + } +} diff --git a/services/platform-service/src/modules/fasting-protocols/routes.ts b/services/platform-service/src/modules/fasting-protocols/routes.ts new file mode 100644 index 00000000..4fa5c6d0 --- /dev/null +++ b/services/platform-service/src/modules/fasting-protocols/routes.ts @@ -0,0 +1,145 @@ +/** + * Fasting protocols REST endpoints — NomGap. + * + * GET /fasting/protocols — list all (built-in + user custom) + * GET /fasting/protocols/:id — single protocol + * POST /fasting/protocols — create custom protocol (auth required) + * PUT /fasting/protocols/:id — update custom (auth, owner only) + * DELETE /fasting/protocols/:id — delete custom (auth, owner only) + */ + +import type { FastifyInstance } from 'fastify'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, ForbiddenError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateCustomProtocolSchema, + UpdateCustomProtocolSchema, + BUILT_IN_PROTOCOLS, + type FastingProtocolDoc, + type FastingProtocol, +} from './types.js'; + +export async function fastingProtocolRoutes(app: FastifyInstance) { + // List all protocols (built-in + user custom) + app.get('/fasting/protocols', async req => { + const auth = await extractAuth(req); + const customProtocols = await repo.getCustomProtocols(auth.sub); + + // Merge built-in + custom, return as FastingProtocol shape + const all: FastingProtocol[] = [...BUILT_IN_PROTOCOLS, ...customProtocols.map(toProtocol)]; + return { protocols: all, total: all.length }; + }); + + // Get single protocol + app.get('/fasting/protocols/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + // Check built-in first + const builtIn = BUILT_IN_PROTOCOLS.find(p => p.id === id); + if (builtIn) return builtIn; + + // Check custom + const custom = await repo.getCustomProtocol(auth.sub, id); + if (!custom) throw new NotFoundError('Protocol not found'); + return toProtocol(custom); + }); + + // Create custom protocol + app.post('/fasting/protocols', async (req, reply) => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const parsed = CreateCustomProtocolSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + const now = new Date().toISOString(); + + const doc: FastingProtocolDoc = { + id: `proto_${crypto.randomUUID()}`, + userId: auth.sub, + productId: pid, + name: input.name, + type: input.type, + fastHours: input.fastHours, + eatHours: input.eatHours, + description: input.description, + difficulty: input.difficulty, + isCustom: true, + religionId: input.religionId, + locationAware: input.locationAware, + deleted: false, + createdAt: now, + updatedAt: now, + }; + + req.log.info({ protocolId: doc.id, name: doc.name }, 'Creating custom fasting protocol'); + const created = await repo.createCustomProtocol(doc); + reply.code(201); + return toProtocol(created); + }); + + // Update custom protocol (owner only) + app.put('/fasting/protocols/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + // Cannot update built-in protocols + if (BUILT_IN_PROTOCOLS.some(p => p.id === id)) { + throw new ForbiddenError('Cannot modify built-in protocols'); + } + + const existing = await repo.getCustomProtocol(auth.sub, id); + if (!existing) throw new NotFoundError('Protocol not found'); + if (existing.userId !== auth.sub) throw new ForbiddenError('Not the protocol owner'); + + const parsed = UpdateCustomProtocolSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + req.log.info({ protocolId: id, updates: Object.keys(parsed.data) }, 'Updating custom protocol'); + const updated = await repo.updateCustomProtocol(auth.sub, id, parsed.data); + if (!updated) throw new NotFoundError('Protocol update failed'); + return toProtocol(updated); + }); + + // Delete custom protocol (owner only, soft delete) + app.delete('/fasting/protocols/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + // Cannot delete built-in protocols + if (BUILT_IN_PROTOCOLS.some(p => p.id === id)) { + throw new ForbiddenError('Cannot delete built-in protocols'); + } + + const existing = await repo.getCustomProtocol(auth.sub, id); + if (!existing) throw new NotFoundError('Protocol not found'); + if (existing.userId !== auth.sub) throw new ForbiddenError('Not the protocol owner'); + + req.log.info({ protocolId: id }, 'Deleting custom protocol'); + const success = await repo.deleteCustomProtocol(auth.sub, id); + if (!success) throw new NotFoundError('Protocol deletion failed'); + return { success: true }; + }); +} + +/** Strip Cosmos-specific fields to return a clean FastingProtocol. */ +function toProtocol(doc: FastingProtocolDoc): FastingProtocol { + return { + id: doc.id, + name: doc.name, + type: doc.type, + fastHours: doc.fastHours, + eatHours: doc.eatHours, + description: doc.description, + difficulty: doc.difficulty, + isCustom: doc.isCustom, + religionId: doc.religionId, + locationAware: doc.locationAware, + }; +} diff --git a/services/platform-service/src/modules/fasting-protocols/types.ts b/services/platform-service/src/modules/fasting-protocols/types.ts new file mode 100644 index 00000000..17c5e122 --- /dev/null +++ b/services/platform-service/src/modules/fasting-protocols/types.ts @@ -0,0 +1,232 @@ +/** + * Fasting protocol types — NomGap protocol definitions. + * + * Cosmos container: `fasting_protocols` (partition key: `/userId`) + * Built-in protocols are hardcoded; custom protocols stored in Cosmos. + * Product-agnostic: every custom protocol document includes `productId`. + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const PROTOCOL_TYPES = ['interval', 'extended', 'alternate', 'religious', 'custom'] as const; +export type ProtocolType = (typeof PROTOCOL_TYPES)[number]; + +export const DIFFICULTY_LEVELS = ['easy', 'moderate', 'hard', 'very_hard', 'expert'] as const; +export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number]; + +// ── Main interface ── + +export interface FastingProtocol { + id: string; + name: string; + type: ProtocolType; + fastHours: number; + eatHours: number; + description: string; + difficulty: DifficultyLevel; + isCustom: boolean; + religionId?: string; + locationAware?: boolean; +} + +// ── Cosmos document (custom protocols only) ── + +export interface FastingProtocolDoc extends FastingProtocol { + userId: string; + productId: string; + deleted: boolean; + createdAt: string; + updatedAt: string; +} + +// ── Zod schemas ── + +export const FastingProtocolSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(128), + type: z.enum(PROTOCOL_TYPES), + fastHours: z.number().min(0).max(168), + eatHours: z.number().min(0).max(168), + description: z.string().max(2000), + difficulty: z.enum(DIFFICULTY_LEVELS), + isCustom: z.boolean(), + religionId: z.string().max(64).optional(), + locationAware: z.boolean().optional(), +}); + +export const CreateCustomProtocolSchema = z.object({ + name: z.string().min(1).max(128), + type: z.enum(PROTOCOL_TYPES).default('custom'), + fastHours: z.number().min(1).max(168), + eatHours: z.number().min(0).max(168), + description: z.string().max(2000).default(''), + difficulty: z.enum(DIFFICULTY_LEVELS).default('moderate'), + religionId: z.string().max(64).optional(), + locationAware: z.boolean().optional(), +}); + +export const UpdateCustomProtocolSchema = z.object({ + name: z.string().min(1).max(128).optional(), + fastHours: z.number().min(1).max(168).optional(), + eatHours: z.number().min(0).max(168).optional(), + description: z.string().max(2000).optional(), + difficulty: z.enum(DIFFICULTY_LEVELS).optional(), + religionId: z.string().max(64).optional(), + locationAware: z.boolean().optional(), +}); + +// ── Inferred types ── + +export type CreateCustomProtocolInput = z.infer; +export type UpdateCustomProtocolInput = z.infer; + +// ── Built-in protocols (14 total, aligned with PRD §5.1) ── + +export const BUILT_IN_PROTOCOLS: FastingProtocol[] = [ + { + id: 'protocol_12_12', + name: '12:12', + type: 'interval', + fastHours: 12, + eatHours: 12, + description: 'Beginner-friendly, gentle start. Equal fasting and eating windows.', + difficulty: 'easy', + isCustom: false, + }, + { + id: 'protocol_14_10', + name: '14:10', + type: 'interval', + fastHours: 14, + eatHours: 10, + description: 'Transition protocol. A gentle step up from 12:12.', + difficulty: 'easy', + isCustom: false, + }, + { + id: 'protocol_16_8', + name: '16:8', + type: 'interval', + fastHours: 16, + eatHours: 8, + description: 'Most popular IF protocol. Skip breakfast or dinner.', + difficulty: 'moderate', + isCustom: false, + }, + { + id: 'protocol_18_6', + name: '18:6', + type: 'interval', + fastHours: 18, + eatHours: 6, + description: 'Enhanced fat burning with a narrower eating window.', + difficulty: 'moderate', + isCustom: false, + }, + { + id: 'protocol_20_4', + name: '20:4 (Warrior)', + type: 'interval', + fastHours: 20, + eatHours: 4, + description: 'One large meal plus a small snack in a 4-hour window.', + difficulty: 'hard', + isCustom: false, + }, + { + id: 'protocol_omad', + name: 'OMAD', + type: 'interval', + fastHours: 23, + eatHours: 1, + description: 'One Meal A Day. Maximum daily fasting window.', + difficulty: 'hard', + isCustom: false, + }, + { + id: 'protocol_5_2', + name: '5:2', + type: 'alternate', + fastHours: 24, + eatHours: 0, + description: 'Five normal eating days plus two restricted days (500-600 cal).', + difficulty: 'moderate', + isCustom: false, + }, + { + id: 'protocol_adf', + name: 'ADF (Alternate Day)', + type: 'alternate', + fastHours: 24, + eatHours: 24, + description: 'Alternate day fasting: eat one day, fast the next.', + difficulty: 'hard', + isCustom: false, + }, + { + id: 'protocol_24h', + name: '24h Fast', + type: 'extended', + fastHours: 24, + eatHours: 0, + description: 'Full day fast. Typically done weekly or bi-weekly.', + difficulty: 'hard', + isCustom: false, + }, + { + id: 'protocol_36h', + name: '36h Fast', + type: 'extended', + fastHours: 36, + eatHours: 0, + description: 'Day and a half fast. Intermediate extended fasting.', + difficulty: 'very_hard', + isCustom: false, + }, + { + id: 'protocol_48h', + name: '48h Fast', + type: 'extended', + fastHours: 48, + eatHours: 0, + description: 'Two day fast. Deep autophagy window.', + difficulty: 'very_hard', + isCustom: false, + }, + { + id: 'protocol_72h', + name: '72h Fast', + type: 'extended', + fastHours: 72, + eatHours: 0, + description: 'Three day fast. Immune system reset fast. Consult a doctor.', + difficulty: 'expert', + isCustom: false, + }, + { + id: 'protocol_ramadan', + name: 'Ramadan', + type: 'religious', + fastHours: 14, + eatHours: 10, + description: 'Islamic fasting from dawn to sunset. Duration adjusts by location and date.', + difficulty: 'moderate', + isCustom: false, + religionId: 'islam', + locationAware: true, + }, + { + id: 'protocol_ekadashi', + name: 'Ekadashi', + type: 'religious', + fastHours: 24, + eatHours: 0, + description: 'Hindu fasting on the 11th day of each lunar cycle, sunrise to sunrise.', + difficulty: 'hard', + isCustom: false, + religionId: 'hinduism', + locationAware: true, + }, +]; diff --git a/services/platform-service/src/modules/fasting-sessions/fasting-sessions.test.ts b/services/platform-service/src/modules/fasting-sessions/fasting-sessions.test.ts new file mode 100644 index 00000000..33e89bc4 --- /dev/null +++ b/services/platform-service/src/modules/fasting-sessions/fasting-sessions.test.ts @@ -0,0 +1,282 @@ +/** + * Fasting sessions module unit tests — validates schema parsing, type guards, and constants. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateFastingSessionSchema, + UpdateFastingSessionSchema, + FastingSessionQuerySchema, + SESSION_STATUSES, + BODY_STAGES, + MEAL_TYPES, +} from './types.js'; + +// ── CreateFastingSessionSchema ── + +describe('CreateFastingSessionSchema', () => { + const validMinimal = { + protocolId: '16:8', + startedAt: 1709000000000, + targetDurationMs: 57600000, // 16h + }; + + it('accepts minimal valid input', () => { + const result = CreateFastingSessionSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.protocolId).toBe('16:8'); + expect(result.data.status).toBe('active'); + expect(result.data.waterIntake).toBe(0); + expect(result.data.notes).toBe(''); + expect(result.data.stages).toEqual([]); + expect(result.data.moodCheckins).toEqual([]); + } + }); + + it('accepts full input with all optional fields', () => { + const result = CreateFastingSessionSchema.safeParse({ + ...validMinimal, + status: 'paused', + stages: [{ stage: 'fed', enteredAt: 1709000000000, autophagyConfidence: 0 }], + moodCheckins: [{ timestamp: 1709000000000, energy: 3, mood: 4, hunger: 2 }], + waterIntake: 5, + notes: 'Feeling good', + lastMealBeforeFast: { + id: 'meal_1', + timestamp: 1708999000000, + description: 'Chicken salad', + mealType: 'last_before_fast', + macros: { carbs: 30, protein: 40, fat: 15 }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.stages).toHaveLength(1); + expect(result.data.moodCheckins).toHaveLength(1); + expect(result.data.lastMealBeforeFast?.macros?.protein).toBe(40); + } + }); + + it('rejects missing protocolId', () => { + const result = CreateFastingSessionSchema.safeParse({ + startedAt: 1709000000000, + targetDurationMs: 57600000, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing startedAt', () => { + const result = CreateFastingSessionSchema.safeParse({ + protocolId: '16:8', + targetDurationMs: 57600000, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing targetDurationMs', () => { + const result = CreateFastingSessionSchema.safeParse({ + protocolId: '16:8', + startedAt: 1709000000000, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid status', () => { + const result = CreateFastingSessionSchema.safeParse({ + ...validMinimal, + status: 'invalid', + }); + expect(result.success).toBe(false); + }); + + it('rejects negative targetDurationMs', () => { + const result = CreateFastingSessionSchema.safeParse({ + ...validMinimal, + targetDurationMs: -1, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid mood checkin ratings', () => { + const result = CreateFastingSessionSchema.safeParse({ + ...validMinimal, + moodCheckins: [{ timestamp: 1709000000000, energy: 6, mood: 4, hunger: 2 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid body stage in stages array', () => { + const result = CreateFastingSessionSchema.safeParse({ + ...validMinimal, + stages: [{ stage: 'invalid_stage', enteredAt: 1709000000000, autophagyConfidence: 50 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects autophagy confidence > 100', () => { + const result = CreateFastingSessionSchema.safeParse({ + ...validMinimal, + stages: [{ stage: 'ketosis', enteredAt: 1709000000000, autophagyConfidence: 150 }], + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateFastingSessionSchema ── + +describe('UpdateFastingSessionSchema', () => { + it('accepts empty object (no updates)', () => { + const result = UpdateFastingSessionSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts status update only', () => { + const result = UpdateFastingSessionSchema.safeParse({ status: 'completed' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('completed'); + } + }); + + it('accepts complete session update with metrics', () => { + const result = UpdateFastingSessionSchema.safeParse({ + status: 'completed', + endedAt: 1709057600000, + metrics: { + actualDurationMs: 57600000, + completionRatio: 1.0, + peakAutophagyConfidence: 45, + totalPausedMs: 0, + moodCheckinCount: 3, + averageEnergy: 3.5, + averageMood: 4.0, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts break fast meal addition', () => { + const result = UpdateFastingSessionSchema.safeParse({ + breakFastMeal: { + id: 'meal_2', + timestamp: 1709057600000, + description: 'Bone broth and eggs', + mealType: 'break_fast', + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid status', () => { + const result = UpdateFastingSessionSchema.safeParse({ status: 'deleted' }); + expect(result.success).toBe(false); + }); + + it('rejects metrics with completionRatio > 1', () => { + const result = UpdateFastingSessionSchema.safeParse({ + metrics: { + actualDurationMs: 57600000, + completionRatio: 1.5, + peakAutophagyConfidence: 45, + totalPausedMs: 0, + moodCheckinCount: 0, + averageEnergy: null, + averageMood: null, + }, + }); + expect(result.success).toBe(false); + }); +}); + +// ── FastingSessionQuerySchema ── + +describe('FastingSessionQuerySchema', () => { + it('provides defaults for empty query', () => { + const result = FastingSessionQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('startedAt'); + expect(result.data.sortOrder).toBe('desc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('coerces string numbers for limit and offset', () => { + const result = FastingSessionQuerySchema.safeParse({ limit: '25', offset: '10' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(10); + } + }); + + it('accepts date range filter', () => { + const result = FastingSessionQuerySchema.safeParse({ + startDate: '1709000000000', + endDate: '1709100000000', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startDate).toBe(1709000000000); + expect(result.data.endDate).toBe(1709100000000); + } + }); + + it('accepts status filter', () => { + const result = FastingSessionQuerySchema.safeParse({ status: 'completed' }); + expect(result.success).toBe(true); + }); + + it('accepts protocolId filter', () => { + const result = FastingSessionQuerySchema.safeParse({ protocolId: 'omad' }); + expect(result.success).toBe(true); + }); + + it('rejects limit > 100', () => { + const result = FastingSessionQuerySchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects negative offset', () => { + const result = FastingSessionQuerySchema.safeParse({ offset: -5 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid sortBy', () => { + const result = FastingSessionQuerySchema.safeParse({ sortBy: 'random' }); + expect(result.success).toBe(false); + }); +}); + +// ── Constants ── + +describe('type constants', () => { + it('has expected session statuses', () => { + expect(SESSION_STATUSES).toEqual(['active', 'paused', 'completed', 'broken', 'abandoned']); + }); + + it('has expected body stages', () => { + expect(BODY_STAGES).toEqual([ + 'fed', + 'early_fast', + 'fasted', + 'ketosis', + 'deep_autophagy', + 'extended', + ]); + }); + + it('has expected meal types', () => { + expect(MEAL_TYPES).toEqual(['break_fast', 'regular', 'last_before_fast']); + }); + + it('has 5 session statuses', () => { + expect(SESSION_STATUSES).toHaveLength(5); + }); + + it('has 6 body stages', () => { + expect(BODY_STAGES).toHaveLength(6); + }); +}); diff --git a/services/platform-service/src/modules/fasting-sessions/repository.ts b/services/platform-service/src/modules/fasting-sessions/repository.ts new file mode 100644 index 00000000..3f6fe46b --- /dev/null +++ b/services/platform-service/src/modules/fasting-sessions/repository.ts @@ -0,0 +1,258 @@ +/** + * Fasting sessions repository — Cosmos DB CRUD + stats aggregation. + * + * Container: fasting_sessions (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { + FastingSessionDoc, + FastingSessionQuery, + UserFastingStats, + WeeklyFastingStats, +} from './types.js'; + +function container() { + return getContainer('fasting_sessions'); +} + +export async function createSession(doc: FastingSessionDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as FastingSessionDoc; +} + +export async function getSession( + userId: string, + sessionId: string +): Promise { + try { + const { resource } = await container().item(sessionId, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listSessions( + userId: string, + query: FastingSessionQuery +): Promise<{ items: FastingSessionDoc[]; total: number }> { + const conditions: string[] = ['c.userId = @userId']; + const params: { name: string; value: string | number }[] = [{ name: '@userId', value: userId }]; + + if (query.status) { + conditions.push('c.status = @status'); + params.push({ name: '@status', value: query.status }); + } + if (query.protocolId) { + conditions.push('c.protocolId = @protocolId'); + params.push({ name: '@protocolId', value: query.protocolId }); + } + if (query.startDate) { + conditions.push('c.startedAt >= @startDate'); + params.push({ name: '@startDate', value: query.startDate }); + } + if (query.endDate) { + conditions.push('c.startedAt <= @endDate'); + params.push({ name: '@endDate', value: query.endDate }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const sortField = `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + // Count query + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + // Data query with pagination + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function updateSession( + userId: string, + sessionId: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container() + .item(sessionId, userId) + .read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(sessionId, userId).replace(merged); + return resource as FastingSessionDoc; + } catch { + return null; + } +} + +export async function getUserStats(userId: string): Promise { + const { resources: allSessions } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.startedAt DESC', + parameters: [{ name: '@userId', value: userId }], + }) + .fetchAll(); + + const completed = allSessions.filter(s => s.status === 'completed'); + const totalHoursMs = completed.reduce((sum, s) => sum + s.metrics.actualDurationMs, 0); + const totalHours = totalHoursMs / (1000 * 60 * 60); + const avgDuration = completed.length > 0 ? totalHours / completed.length : 0; + const completionRate = + allSessions.length > 0 + ? completed.length / + allSessions.filter(s => s.status !== 'active' && s.status !== 'paused').length || 0 + : 0; + + // Streak calculation — consecutive completed sessions by day + let currentStreak = 0; + let longestStreak = 0; + + if (completed.length > 0) { + const sortedByDate = [...completed].sort((a, b) => b.startedAt - a.startedAt); + const daySet = new Set( + sortedByDate.map(s => { + const d = new Date(s.startedAt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + }) + ); + const uniqueDays = [...daySet]; + + // Current streak: count consecutive days from today backwards + const today = new Date(); + const todayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + const yesterday = new Date(today.getTime() - 86400000); + const yesterdayKey = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`; + + // Start counting if today or yesterday has a fast + if (uniqueDays[0] === todayKey || uniqueDays[0] === yesterdayKey) { + let streak = 1; + for (let i = 1; i < uniqueDays.length; i++) { + // Check if consecutive (simplified — just counting unique fast days in a row) + const prevDate = new Date( + completed.find(s => { + const d = new Date(s.startedAt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i - 1]; + })!.startedAt + ); + const currDate = new Date( + completed.find(s => { + const d = new Date(s.startedAt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i]; + })!.startedAt + ); + + const dayDiff = Math.floor((prevDate.getTime() - currDate.getTime()) / 86400000); + if (dayDiff <= 1) { + streak++; + } else { + break; + } + } + currentStreak = streak; + } + + // Longest streak + let tempStreak = 1; + longestStreak = 1; + for (let i = 1; i < uniqueDays.length; i++) { + const prevDate = new Date( + completed.find(s => { + const d = new Date(s.startedAt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i - 1]; + })!.startedAt + ); + const currDate = new Date( + completed.find(s => { + const d = new Date(s.startedAt); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` === uniqueDays[i]; + })!.startedAt + ); + + const dayDiff = Math.floor((prevDate.getTime() - currDate.getTime()) / 86400000); + if (dayDiff <= 1) { + tempStreak++; + } else { + tempStreak = 1; + } + longestStreak = Math.max(longestStreak, tempStreak); + } + longestStreak = Math.max(longestStreak, currentStreak); + } + + return { + userId, + totalFasts: allSessions.length, + totalHours: Math.round(totalHours * 100) / 100, + averageDurationHours: Math.round(avgDuration * 100) / 100, + completionRate: Math.round(completionRate * 100) / 100, + currentStreak, + longestStreak, + totalCompletedFasts: completed.length, + }; +} + +export async function getWeeklyStats(userId: string): Promise { + const now = new Date(); + const dayOfWeek = now.getDay(); + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - dayOfWeek); + weekStart.setHours(0, 0, 0, 0); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + + const weekStartMs = weekStart.getTime(); + const weekEndMs = weekEnd.getTime(); + + const { resources: weekSessions } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.startedAt >= @weekStart AND c.startedAt < @weekEnd', + parameters: [ + { name: '@userId', value: userId }, + { name: '@weekStart', value: weekStartMs }, + { name: '@weekEnd', value: weekEndMs }, + ], + }) + .fetchAll(); + + const completed = weekSessions.filter(s => s.status === 'completed'); + const finishedSessions = weekSessions.filter(s => s.status !== 'active' && s.status !== 'paused'); + const totalHoursMs = completed.reduce((sum, s) => sum + s.metrics.actualDurationMs, 0); + const totalHours = totalHoursMs / (1000 * 60 * 60); + const avgDuration = completed.length > 0 ? totalHours / completed.length : 0; + const longestFastMs = + completed.length > 0 ? Math.max(...completed.map(s => s.metrics.actualDurationMs)) : 0; + + return { + userId, + weekStart: weekStart.toISOString(), + weekEnd: weekEnd.toISOString(), + fastsStarted: weekSessions.length, + fastsCompleted: completed.length, + totalHours: Math.round(totalHours * 100) / 100, + averageDurationHours: Math.round(avgDuration * 100) / 100, + longestFastHours: Math.round((longestFastMs / (1000 * 60 * 60)) * 100) / 100, + completionRate: + finishedSessions.length > 0 + ? Math.round((completed.length / finishedSessions.length) * 100) / 100 + : 0, + }; +} diff --git a/services/platform-service/src/modules/fasting-sessions/routes.ts b/services/platform-service/src/modules/fasting-sessions/routes.ts new file mode 100644 index 00000000..c7080eb1 --- /dev/null +++ b/services/platform-service/src/modules/fasting-sessions/routes.ts @@ -0,0 +1,123 @@ +/** + * Fasting sessions REST endpoints — NomGap. + * + * POST /fasting/sessions — create or sync a session + * GET /fasting/sessions — list with pagination + date range + * GET /fasting/sessions/:id — single session + * PUT /fasting/sessions/:id — update (break, complete, add mood checkin) + * GET /fasting/stats — aggregated user stats + * GET /fasting/stats/weekly — this week's summary + */ + +import type { FastifyInstance } from 'fastify'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateFastingSessionSchema, + UpdateFastingSessionSchema, + FastingSessionQuerySchema, + type FastingSessionDoc, + type FastMetrics, +} from './types.js'; + +export async function fastingSessionRoutes(app: FastifyInstance) { + // Stats — must be registered before :id param route + app.get('/fasting/stats', async req => { + const auth = await extractAuth(req); + const stats = await repo.getUserStats(auth.sub); + return stats; + }); + + // Weekly stats + app.get('/fasting/stats/weekly', async req => { + const auth = await extractAuth(req); + const stats = await repo.getWeeklyStats(auth.sub); + return stats; + }); + + // List sessions + app.get('/fasting/sessions', async req => { + const auth = await extractAuth(req); + const parsed = FastingSessionQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listSessions(auth.sub, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get session + app.get('/fasting/sessions/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const session = await repo.getSession(auth.sub, id); + if (!session) throw new NotFoundError('Fasting session not found'); + return session; + }); + + // Create session + app.post('/fasting/sessions', async (req, reply) => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const parsed = CreateFastingSessionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + const now = new Date().toISOString(); + + const defaultMetrics: FastMetrics = { + actualDurationMs: 0, + completionRatio: 0, + peakAutophagyConfidence: 0, + totalPausedMs: 0, + moodCheckinCount: input.moodCheckins.length, + averageEnergy: null, + averageMood: null, + }; + + const doc: FastingSessionDoc = { + id: `fs_${crypto.randomUUID()}`, + userId: auth.sub, + productId: pid, + protocolId: input.protocolId, + startedAt: input.startedAt, + targetDurationMs: input.targetDurationMs, + status: input.status, + totalPausedMs: 0, + stages: input.stages, + moodCheckins: input.moodCheckins, + waterIntake: input.waterIntake, + notes: input.notes, + lastMealBeforeFast: input.lastMealBeforeFast, + metrics: defaultMetrics, + createdAt: now, + updatedAt: now, + }; + + req.log.info({ sessionId: doc.id, protocolId: doc.protocolId }, 'Creating fasting session'); + const created = await repo.createSession(doc); + reply.code(201); + return created; + }); + + // Update session + app.put('/fasting/sessions/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const existing = await repo.getSession(auth.sub, id); + if (!existing) throw new NotFoundError('Fasting session not found'); + + const parsed = UpdateFastingSessionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + req.log.info({ sessionId: id, updates: Object.keys(parsed.data) }, 'Updating fasting session'); + const updated = await repo.updateSession(auth.sub, id, parsed.data); + if (!updated) throw new NotFoundError('Fasting session update failed'); + return updated; + }); +} diff --git a/services/platform-service/src/modules/fasting-sessions/types.ts b/services/platform-service/src/modules/fasting-sessions/types.ts new file mode 100644 index 00000000..dabb082e --- /dev/null +++ b/services/platform-service/src/modules/fasting-sessions/types.ts @@ -0,0 +1,199 @@ +/** + * Fasting session types — NomGap fasting tracker. + * + * Cosmos container: `fasting_sessions` (partition key: `/userId`) + * Product-agnostic: every document includes `productId`. + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const SESSION_STATUSES = ['active', 'paused', 'completed', 'broken', 'abandoned'] as const; +export type SessionStatus = (typeof SESSION_STATUSES)[number]; + +export const BODY_STAGES = [ + 'fed', + 'early_fast', + 'fasted', + 'ketosis', + 'deep_autophagy', + 'extended', +] as const; +export type BodyStage = (typeof BODY_STAGES)[number]; + +export const MEAL_TYPES = ['break_fast', 'regular', 'last_before_fast'] as const; +export type MealType = (typeof MEAL_TYPES)[number]; + +export const RATING_SCALE = [1, 2, 3, 4, 5] as const; + +// ── Sub-document interfaces ── + +export interface MoodCheckin { + timestamp: number; + energy: 1 | 2 | 3 | 4 | 5; + mood: 1 | 2 | 3 | 4 | 5; + hunger: 1 | 2 | 3 | 4 | 5; + focus?: 1 | 2 | 3 | 4 | 5; + notes?: string; +} + +export interface StageTransition { + stage: BodyStage; + enteredAt: number; + autophagyConfidence: number; +} + +export interface MealLog { + id: string; + timestamp: number; + photoUrl?: string; + description: string; + estimatedCalories?: number; + macros?: { carbs: number; protein: number; fat: number }; + mealType: MealType; +} + +export interface FastMetrics { + actualDurationMs: number; + completionRatio: number; + peakAutophagyConfidence: number; + totalPausedMs: number; + moodCheckinCount: number; + averageEnergy: number | null; + averageMood: number | null; +} + +// ── Main document ── + +export interface FastingSessionDoc { + id: string; + userId: string; + productId: string; + protocolId: string; + startedAt: number; + targetDurationMs: number; + endedAt?: number; + status: SessionStatus; + pausedAt?: number; + totalPausedMs: number; + stages: StageTransition[]; + moodCheckins: MoodCheckin[]; + waterIntake: number; + notes: string; + breakFastMeal?: MealLog; + lastMealBeforeFast?: MealLog; + metrics: FastMetrics; + createdAt: string; + updatedAt: string; +} + +// ── Zod schemas ── + +const MoodCheckinSchema = z.object({ + timestamp: z.number().int().positive(), + energy: z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>, + mood: z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>, + hunger: z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>, + focus: (z.number().int().min(1).max(5) as z.ZodType<1 | 2 | 3 | 4 | 5>).optional(), + notes: z.string().max(1000).optional(), +}); + +const StageTransitionSchema = z.object({ + stage: z.enum(BODY_STAGES), + enteredAt: z.number().int().positive(), + autophagyConfidence: z.number().min(0).max(100), +}); + +const MacrosSchema = z.object({ + carbs: z.number().min(0), + protein: z.number().min(0), + fat: z.number().min(0), +}); + +const MealLogSchema = z.object({ + id: z.string().min(1), + timestamp: z.number().int().positive(), + photoUrl: z.string().url().optional(), + description: z.string().max(2000), + estimatedCalories: z.number().min(0).optional(), + macros: MacrosSchema.optional(), + mealType: z.enum(MEAL_TYPES), +}); + +export const CreateFastingSessionSchema = z.object({ + protocolId: z.string().min(1).max(128), + startedAt: z.number().int().positive(), + targetDurationMs: z.number().int().positive(), + status: z.enum(SESSION_STATUSES).default('active'), + stages: z.array(StageTransitionSchema).default([]), + moodCheckins: z.array(MoodCheckinSchema).default([]), + waterIntake: z.number().int().min(0).default(0), + notes: z.string().max(5000).default(''), + lastMealBeforeFast: MealLogSchema.optional(), +}); + +export const UpdateFastingSessionSchema = z.object({ + status: z.enum(SESSION_STATUSES).optional(), + endedAt: z.number().int().positive().optional(), + pausedAt: z.number().int().positive().optional(), + totalPausedMs: z.number().int().min(0).optional(), + stages: z.array(StageTransitionSchema).optional(), + moodCheckins: z.array(MoodCheckinSchema).optional(), + waterIntake: z.number().int().min(0).optional(), + notes: z.string().max(5000).optional(), + breakFastMeal: MealLogSchema.optional(), + metrics: z + .object({ + actualDurationMs: z.number().int().min(0), + completionRatio: z.number().min(0).max(1), + peakAutophagyConfidence: z.number().min(0).max(100), + totalPausedMs: z.number().int().min(0), + moodCheckinCount: z.number().int().min(0), + averageEnergy: z.number().min(0).max(5).nullable(), + averageMood: z.number().min(0).max(5).nullable(), + }) + .optional(), +}); + +export const FastingSessionQuerySchema = z.object({ + startDate: z.coerce.number().int().positive().optional(), + endDate: z.coerce.number().int().positive().optional(), + status: z.enum(SESSION_STATUSES).optional(), + protocolId: z.string().optional(), + sortBy: z.enum(['startedAt', 'endedAt', 'createdAt']).default('startedAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +// ── Inferred types ── + +export type CreateFastingSessionInput = z.infer; +export type UpdateFastingSessionInput = z.infer; +export type FastingSessionQuery = z.infer; + +// ── Stats interfaces ── + +export interface UserFastingStats { + userId: string; + totalFasts: number; + totalHours: number; + averageDurationHours: number; + completionRate: number; + currentStreak: number; + longestStreak: number; + totalCompletedFasts: number; +} + +export interface WeeklyFastingStats { + userId: string; + weekStart: string; + weekEnd: string; + fastsStarted: number; + fastsCompleted: number; + totalHours: number; + averageDurationHours: number; + longestFastHours: number; + completionRate: number; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 88d7e21a..da3f7897 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -47,6 +47,9 @@ import { tokenRoutes } from './modules/tokens/routes.js'; import { themeRoutes } from './modules/themes/routes.js'; import { waitlistRoutes } from './modules/waitlist/routes.js'; import { telemetryRoutes } from './modules/telemetry/routes.js'; +import { fastingSessionRoutes } from './modules/fasting-sessions/routes.js'; +import { fastingProtocolRoutes } from './modules/fasting-protocols/routes.js'; +import { bodyStageRoutes } from './modules/body-stages/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; @@ -121,5 +124,9 @@ await app.register(waitlistRoutes, { prefix: '/api' }); await app.register(telemetryRoutes, { prefix: '/api' }); // Public routes — no auth, registered at top level await app.register(publicRoutes, { prefix: '/api' }); +// NomGap fasting modules +await app.register(fastingSessionRoutes, { prefix: '/api' }); +await app.register(fastingProtocolRoutes, { prefix: '/api' }); +await app.register(bodyStageRoutes, { prefix: '/api' }); await startService(app, { port: config.PORT, host: config.HOST });