diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 68980745..ad185a21 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -37,6 +37,8 @@ const CONTAINER_DEFS: Record = { // NomGap fasting modules fasting_sessions: { partitionKeyPath: '/userId' }, fasting_protocols: { partitionKeyPath: '/userId' }, + social_fasts: { partitionKeyPath: '/id' }, + meal_logs: { partitionKeyPath: '/userId' }, // ChronoMind timers timers: { partitionKeyPath: '/userId' }, routines: { partitionKeyPath: '/userId' }, diff --git a/services/platform-service/src/modules/meal-log/meal-log.test.ts b/services/platform-service/src/modules/meal-log/meal-log.test.ts new file mode 100644 index 00000000..0af2551f --- /dev/null +++ b/services/platform-service/src/modules/meal-log/meal-log.test.ts @@ -0,0 +1,193 @@ +/** + * Meal log module unit tests — validates schema parsing, type guards, and constants. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateMealLogSchema, + UpdateMealLogSchema, + MealLogQuerySchema, + MEAL_TYPES, + MEAL_SOURCES, +} from './types.js'; + +// ── CreateMealLogSchema ── + +describe('CreateMealLogSchema', () => { + const validMinimal = { + timestamp: 1709000000000, + description: 'Chicken salad', + mealType: 'break_fast', + }; + + it('accepts minimal valid input', () => { + const result = CreateMealLogSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.description).toBe('Chicken salad'); + expect(result.data.mealType).toBe('break_fast'); + expect(result.data.source).toBe('manual'); + expect(result.data.tags).toEqual([]); + expect(result.data.notes).toBe(''); + } + }); + + it('accepts full input with all optional fields', () => { + const result = CreateMealLogSchema.safeParse({ + ...validMinimal, + sessionId: 'fs_abc123', + photoUrl: 'https://example.com/meal.jpg', + estimatedCalories: 450, + macros: { carbs: 30, protein: 40, fat: 15 }, + source: 'photo_ai', + tags: ['high_protein', 'low_carb'], + notes: 'Felt great after this meal', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.macros?.protein).toBe(40); + expect(result.data.tags).toHaveLength(2); + expect(result.data.source).toBe('photo_ai'); + } + }); + + it('rejects empty description', () => { + const result = CreateMealLogSchema.safeParse({ ...validMinimal, description: '' }); + expect(result.success).toBe(false); + }); + + it('rejects negative calories', () => { + const result = CreateMealLogSchema.safeParse({ ...validMinimal, estimatedCalories: -10 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid mealType', () => { + const result = CreateMealLogSchema.safeParse({ ...validMinimal, mealType: 'dinner' }); + expect(result.success).toBe(false); + }); + + it('rejects negative macros', () => { + const result = CreateMealLogSchema.safeParse({ + ...validMinimal, + macros: { carbs: -5, protein: 10, fat: 10 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects too many tags', () => { + const tags = Array.from({ length: 21 }, (_, i) => `tag${i}`); + const result = CreateMealLogSchema.safeParse({ ...validMinimal, tags }); + expect(result.success).toBe(false); + }); + + it('accepts all meal types', () => { + for (const mealType of MEAL_TYPES) { + const result = CreateMealLogSchema.safeParse({ ...validMinimal, mealType }); + expect(result.success).toBe(true); + } + }); + + it('accepts all source types', () => { + for (const source of MEAL_SOURCES) { + const result = CreateMealLogSchema.safeParse({ ...validMinimal, source }); + expect(result.success).toBe(true); + } + }); +}); + +// ── UpdateMealLogSchema ── + +describe('UpdateMealLogSchema', () => { + it('accepts partial update', () => { + const result = UpdateMealLogSchema.safeParse({ description: 'Updated meal' }); + expect(result.success).toBe(true); + }); + + it('accepts macros update', () => { + const result = UpdateMealLogSchema.safeParse({ + macros: { carbs: 50, protein: 30, fat: 20 }, + estimatedCalories: 500, + }); + expect(result.success).toBe(true); + }); + + it('accepts empty object', () => { + const result = UpdateMealLogSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('rejects empty description', () => { + const result = UpdateMealLogSchema.safeParse({ description: '' }); + expect(result.success).toBe(false); + }); + + it('accepts tags update', () => { + const result = UpdateMealLogSchema.safeParse({ tags: ['keto', 'meal_prep'] }); + expect(result.success).toBe(true); + }); +}); + +// ── MealLogQuerySchema ── + +describe('MealLogQuerySchema', () => { + it('accepts empty query (uses defaults)', () => { + const result = MealLogQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + expect(result.data.sortOrder).toBe('desc'); + } + }); + + it('accepts date range filter', () => { + const result = MealLogQuerySchema.safeParse({ + startDate: '1709000000000', + endDate: '1709100000000', + }); + expect(result.success).toBe(true); + }); + + it('accepts mealType filter', () => { + const result = MealLogQuerySchema.safeParse({ mealType: 'break_fast' }); + expect(result.success).toBe(true); + }); + + it('accepts sessionId filter', () => { + const result = MealLogQuerySchema.safeParse({ sessionId: 'fs_abc123' }); + expect(result.success).toBe(true); + }); + + it('coerces string limit/offset', () => { + const result = MealLogQuerySchema.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('rejects invalid mealType', () => { + const result = MealLogQuerySchema.safeParse({ mealType: 'invalid' }); + expect(result.success).toBe(false); + }); +}); + +// ── Constants ── + +describe('constants', () => { + it('MEAL_TYPES has 4 values', () => { + expect(MEAL_TYPES).toHaveLength(4); + expect(MEAL_TYPES).toContain('break_fast'); + expect(MEAL_TYPES).toContain('regular'); + expect(MEAL_TYPES).toContain('last_before_fast'); + expect(MEAL_TYPES).toContain('snack'); + }); + + it('MEAL_SOURCES has 3 values', () => { + expect(MEAL_SOURCES).toHaveLength(3); + expect(MEAL_SOURCES).toContain('manual'); + expect(MEAL_SOURCES).toContain('photo_ai'); + expect(MEAL_SOURCES).toContain('barcode'); + }); +}); diff --git a/services/platform-service/src/modules/meal-log/repository.ts b/services/platform-service/src/modules/meal-log/repository.ts new file mode 100644 index 00000000..65e9e969 --- /dev/null +++ b/services/platform-service/src/modules/meal-log/repository.ts @@ -0,0 +1,146 @@ +/** + * Meal log repository — Cosmos DB CRUD for meal tracking. + * + * Container: meal_logs (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { MealLogDoc, MealLogQuery, MealLogStats, MealType } from './types.js'; + +function container() { + return getContainer('meal_logs'); +} + +export async function createMealLog(doc: MealLogDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as MealLogDoc; +} + +export async function getMealLog(userId: string, mealId: string): Promise { + try { + const { resource } = await container().item(mealId, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listMealLogs( + userId: string, + query: MealLogQuery +): Promise<{ items: MealLogDoc[]; total: number }> { + const conditions: string[] = ['c.userId = @userId']; + const params: { name: string; value: string | number }[] = [{ name: '@userId', value: userId }]; + + if (query.mealType) { + conditions.push('c.mealType = @mealType'); + params.push({ name: '@mealType', value: query.mealType }); + } + if (query.sessionId) { + conditions.push('c.sessionId = @sessionId'); + params.push({ name: '@sessionId', value: query.sessionId }); + } + if (query.startDate) { + conditions.push('c.timestamp >= @startDate'); + params.push({ name: '@startDate', value: query.startDate }); + } + if (query.endDate) { + conditions.push('c.timestamp <= @endDate'); + params.push({ name: '@endDate', value: query.endDate }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const orderDir = query.sortOrder.toUpperCase(); + + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY c.timestamp ${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 updateMealLog( + userId: string, + mealId: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container().item(mealId, userId).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(mealId, userId).replace(merged); + return resource as MealLogDoc; + } catch { + return null; + } +} + +export async function deleteMealLog(userId: string, mealId: string): Promise { + try { + await container().item(mealId, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function getMealLogStats(userId: string): Promise { + const { resources: meals } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.userId = @userId', + parameters: [{ name: '@userId', value: userId }], + }) + .fetchAll(); + + const withCalories = meals.filter(m => m.estimatedCalories != null); + const withMacros = meals.filter(m => m.macros != null); + + const mealTypeCounts: Record = { + break_fast: 0, + regular: 0, + last_before_fast: 0, + snack: 0, + }; + for (const m of meals) { + mealTypeCounts[m.mealType] = (mealTypeCounts[m.mealType] ?? 0) + 1; + } + + return { + userId, + totalMeals: meals.length, + averageCalories: + withCalories.length > 0 + ? Math.round( + withCalories.reduce((s, m) => s + m.estimatedCalories!, 0) / withCalories.length + ) + : null, + averageCarbs: + withMacros.length > 0 + ? Math.round(withMacros.reduce((s, m) => s + m.macros!.carbs, 0) / withMacros.length) + : null, + averageProtein: + withMacros.length > 0 + ? Math.round(withMacros.reduce((s, m) => s + m.macros!.protein, 0) / withMacros.length) + : null, + averageFat: + withMacros.length > 0 + ? Math.round(withMacros.reduce((s, m) => s + m.macros!.fat, 0) / withMacros.length) + : null, + mealTypeCounts: mealTypeCounts as Record, + }; +} diff --git a/services/platform-service/src/modules/meal-log/routes.ts b/services/platform-service/src/modules/meal-log/routes.ts new file mode 100644 index 00000000..81472ff3 --- /dev/null +++ b/services/platform-service/src/modules/meal-log/routes.ts @@ -0,0 +1,118 @@ +/** + * Meal log REST endpoints — NomGap meal tracking. + * + * POST /fasting/meals — log a meal + * GET /fasting/meals — list meals with filters + * GET /fasting/meals/stats — meal nutrition stats + * GET /fasting/meals/:id — single meal + * PUT /fasting/meals/:id — update meal + * DELETE /fasting/meals/:id — delete meal + */ + +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 { + CreateMealLogSchema, + UpdateMealLogSchema, + MealLogQuerySchema, + type MealLogDoc, +} from './types.js'; + +export async function mealLogRoutes(app: FastifyInstance) { + // Stats — registered before :id to avoid param collision + app.get('/fasting/meals/stats', async req => { + const auth = await extractAuth(req); + const stats = await repo.getMealLogStats(auth.sub); + return stats; + }); + + // List meals + app.get('/fasting/meals', async req => { + const auth = await extractAuth(req); + const parsed = MealLogQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listMealLogs(auth.sub, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single meal + app.get('/fasting/meals/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const meal = await repo.getMealLog(auth.sub, id); + if (!meal) throw new NotFoundError('Meal log not found'); + return meal; + }); + + // Create meal + app.post('/fasting/meals', async (req, reply) => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const parsed = CreateMealLogSchema.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: MealLogDoc = { + id: `ml_${crypto.randomUUID()}`, + userId: auth.sub, + productId: pid, + sessionId: input.sessionId, + timestamp: input.timestamp, + photoUrl: input.photoUrl, + description: input.description, + estimatedCalories: input.estimatedCalories, + macros: input.macros, + mealType: input.mealType, + source: input.source, + tags: input.tags, + notes: input.notes, + createdAt: now, + updatedAt: now, + }; + + req.log.info({ mealId: doc.id, mealType: doc.mealType }, 'Creating meal log'); + const created = await repo.createMealLog(doc); + reply.code(201); + return created; + }); + + // Update meal + app.put('/fasting/meals/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const existing = await repo.getMealLog(auth.sub, id); + if (!existing) throw new NotFoundError('Meal log not found'); + + const parsed = UpdateMealLogSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + req.log.info({ mealId: id, updates: Object.keys(parsed.data) }, 'Updating meal log'); + const updated = await repo.updateMealLog(auth.sub, id, parsed.data); + if (!updated) throw new NotFoundError('Meal log update failed'); + return updated; + }); + + // Delete meal + app.delete('/fasting/meals/:id', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const existing = await repo.getMealLog(auth.sub, id); + if (!existing) throw new NotFoundError('Meal log not found'); + + req.log.info({ mealId: id }, 'Deleting meal log'); + const deleted = await repo.deleteMealLog(auth.sub, id); + if (!deleted) throw new NotFoundError('Meal log delete failed'); + reply.code(204); + return; + }); +} diff --git a/services/platform-service/src/modules/meal-log/types.ts b/services/platform-service/src/modules/meal-log/types.ts new file mode 100644 index 00000000..3de939a7 --- /dev/null +++ b/services/platform-service/src/modules/meal-log/types.ts @@ -0,0 +1,103 @@ +/** + * Meal log types — NomGap meal tracking around fasts. + * + * Cosmos container: `meal_logs` (partition key: `/userId`) + * Product-agnostic: every document includes `productId`. + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const MEAL_TYPES = ['break_fast', 'regular', 'last_before_fast', 'snack'] as const; +export type MealType = (typeof MEAL_TYPES)[number]; + +export const MEAL_SOURCES = ['manual', 'photo_ai', 'barcode'] as const; +export type MealSource = (typeof MEAL_SOURCES)[number]; + +// ── Sub-document interfaces ── + +export interface Macros { + carbs: number; + protein: number; + fat: number; +} + +// ── Main document ── + +export interface MealLogDoc { + id: string; + userId: string; + productId: string; + sessionId?: string; + timestamp: number; + photoUrl?: string; + description: string; + estimatedCalories?: number; + macros?: Macros; + mealType: MealType; + source: MealSource; + tags: string[]; + notes: string; + createdAt: string; + updatedAt: string; +} + +// ── Zod schemas ── + +const MacrosSchema = z.object({ + carbs: z.number().min(0), + protein: z.number().min(0), + fat: z.number().min(0), +}); + +export const CreateMealLogSchema = z.object({ + sessionId: z.string().optional(), + timestamp: z.number().int().positive(), + photoUrl: z.string().url().optional(), + description: z.string().min(1).max(2000), + estimatedCalories: z.number().min(0).optional(), + macros: MacrosSchema.optional(), + mealType: z.enum(MEAL_TYPES), + source: z.enum(MEAL_SOURCES).default('manual'), + tags: z.array(z.string().max(50)).max(20).default([]), + notes: z.string().max(5000).default(''), +}); + +export const UpdateMealLogSchema = z.object({ + description: z.string().min(1).max(2000).optional(), + estimatedCalories: z.number().min(0).optional(), + macros: MacrosSchema.optional(), + mealType: z.enum(MEAL_TYPES).optional(), + photoUrl: z.string().url().optional(), + tags: z.array(z.string().max(50)).max(20).optional(), + notes: z.string().max(5000).optional(), +}); + +export const MealLogQuerySchema = z.object({ + startDate: z.coerce.number().int().positive().optional(), + endDate: z.coerce.number().int().positive().optional(), + mealType: z.enum(MEAL_TYPES).optional(), + sessionId: z.string().optional(), + 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 CreateMealLogInput = z.infer; +export type UpdateMealLogInput = z.infer; +export type MealLogQuery = z.infer; + +// ── Stats ── + +export interface MealLogStats { + userId: string; + totalMeals: number; + averageCalories: number | null; + averageCarbs: number | null; + averageProtein: number | null; + averageFat: number | null; + mealTypeCounts: Record; +} diff --git a/services/platform-service/src/modules/social-fasting/repository.ts b/services/platform-service/src/modules/social-fasting/repository.ts new file mode 100644 index 00000000..d181f67b --- /dev/null +++ b/services/platform-service/src/modules/social-fasting/repository.ts @@ -0,0 +1,117 @@ +/** + * Social fasting repository — Cosmos DB CRUD for group fasts. + * + * Container: social_fasts (partition key: /id) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { GroupFastDoc, GroupFastQuery, LeaderboardEntry } from './types.js'; + +function container() { + return getContainer('social_fasts'); +} + +export async function createGroupFast(doc: GroupFastDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as GroupFastDoc; +} + +export async function getGroupFast(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listGroupFasts( + query: GroupFastQuery, + userId?: string +): Promise<{ items: GroupFastDoc[]; total: number }> { + const conditions: string[] = []; + const params: { name: string; value: string | number | boolean }[] = []; + + if (query.status) { + conditions.push('c.status = @status'); + params.push({ name: '@status', value: query.status }); + } + if (query.isPublic !== undefined) { + conditions.push('c.isPublic = @isPublic'); + params.push({ name: '@isPublic', value: query.isPublic }); + } + if (userId) { + conditions.push('ARRAY_CONTAINS(c.participants, {"userId": @userId}, true)'); + params.push({ name: '@userId', value: userId }); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY c.scheduledStart DESC OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getGroupFastByInviteCode(inviteCode: string): Promise { + const { resources } = await container() + .items.query({ + query: 'SELECT * FROM c WHERE c.inviteCode = @inviteCode', + parameters: [{ name: '@inviteCode', value: inviteCode }], + }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function updateGroupFast( + id: string, + updates: Partial +): Promise { + try { + const { resource: existing } = await container().item(id, id).read(); + if (!existing) return null; + const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + const { resource } = await container().item(id, id).replace(merged); + return resource as GroupFastDoc; + } catch { + return null; + } +} + +export async function getLeaderboard(groupFastId: string): Promise { + const doc = await getGroupFast(groupFastId); + if (!doc) return []; + + const entries: LeaderboardEntry[] = doc.participants + .filter(p => p.status !== 'left') + .map(p => ({ + userId: p.userId, + displayName: p.displayName, + avatarUrl: p.avatarUrl, + totalFasts: p.status === 'completed' ? 1 : 0, + totalHours: p.elapsedMs / (1000 * 60 * 60), + currentStreak: p.status === 'completed' ? 1 : 0, + longestStreak: p.status === 'completed' ? 1 : 0, + rank: 0, + })) + .sort((a, b) => b.totalHours - a.totalHours) + .map((entry, index) => ({ ...entry, rank: index + 1 })); + + return entries; +} diff --git a/services/platform-service/src/modules/social-fasting/routes.ts b/services/platform-service/src/modules/social-fasting/routes.ts new file mode 100644 index 00000000..24643a9b --- /dev/null +++ b/services/platform-service/src/modules/social-fasting/routes.ts @@ -0,0 +1,194 @@ +/** + * Social fasting REST endpoints — NomGap group fasts. + * + * POST /fasting/groups — create a group fast + * GET /fasting/groups — list group fasts (my + public) + * GET /fasting/groups/:id — single group fast + * PUT /fasting/groups/:id — update group fast (creator only) + * POST /fasting/groups/join — join via invite code + * PUT /fasting/groups/:id/me — update own participant status + * GET /fasting/groups/:id/leaderboard — leaderboard for this group fast + */ + +import type { FastifyInstance } from 'fastify'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError, NotFoundError, ForbiddenError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateGroupFastSchema, + UpdateGroupFastSchema, + JoinGroupFastSchema, + UpdateParticipantSchema, + GroupFastQuerySchema, + type GroupFastDoc, + type Participant, +} from './types.js'; + +function generateInviteCode(): string { + return crypto.randomUUID().replace(/-/g, '').slice(0, 8).toUpperCase(); +} + +export async function socialFastingRoutes(app: FastifyInstance) { + // List group fasts (user's + public) + app.get('/fasting/groups', async req => { + const auth = await extractAuth(req); + const parsed = GroupFastQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listGroupFasts(parsed.data, auth.sub); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single group fast + app.get('/fasting/groups/:id', async req => { + await extractAuth(req); + const { id } = req.params as { id: string }; + const group = await repo.getGroupFast(id); + if (!group) throw new NotFoundError('Group fast not found'); + return group; + }); + + // Leaderboard + app.get('/fasting/groups/:id/leaderboard', async req => { + await extractAuth(req); + const { id } = req.params as { id: string }; + const leaderboard = await repo.getLeaderboard(id); + return { entries: leaderboard }; + }); + + // Create group fast + app.post('/fasting/groups', async (req, reply) => { + const auth = await extractAuth(req); + const pid = getRequestProductId(req); + const parsed = CreateGroupFastSchema.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 creator: Participant = { + userId: auth.sub, + displayName: auth.email ?? 'Creator', + joinedAt: Date.now(), + status: 'joined', + elapsedMs: 0, + currentStage: 'fed', + }; + + const doc: GroupFastDoc = { + id: `gf_${crypto.randomUUID()}`, + productId: pid, + creatorId: auth.sub, + name: input.name, + description: input.description, + protocolId: input.protocolId, + targetDurationMs: input.targetDurationMs, + scheduledStart: input.scheduledStart, + status: 'scheduled', + maxParticipants: input.maxParticipants, + participants: [creator], + inviteCode: generateInviteCode(), + isPublic: input.isPublic, + createdAt: now, + updatedAt: now, + }; + + req.log.info({ groupId: doc.id, name: doc.name }, 'Creating group fast'); + const created = await repo.createGroupFast(doc); + reply.code(201); + return created; + }); + + // Update group fast (creator only) + app.put('/fasting/groups/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const existing = await repo.getGroupFast(id); + if (!existing) throw new NotFoundError('Group fast not found'); + if (existing.creatorId !== auth.sub) { + throw new ForbiddenError('Only the creator can update this group fast'); + } + + const parsed = UpdateGroupFastSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + req.log.info({ groupId: id, updates: Object.keys(parsed.data) }, 'Updating group fast'); + const updated = await repo.updateGroupFast(id, parsed.data); + if (!updated) throw new NotFoundError('Group fast update failed'); + return updated; + }); + + // Join group fast via invite code + app.post('/fasting/groups/join', async req => { + const auth = await extractAuth(req); + const parsed = JoinGroupFastSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const group = await repo.getGroupFastByInviteCode(parsed.data.inviteCode); + if (!group) throw new NotFoundError('Invalid invite code'); + if (group.status === 'cancelled' || group.status === 'completed') { + throw new BadRequestError('This group fast is no longer active'); + } + if (group.participants.some(p => p.userId === auth.sub)) { + throw new BadRequestError('You have already joined this group fast'); + } + if (group.participants.length >= group.maxParticipants) { + throw new BadRequestError('This group fast is full'); + } + + const participant: Participant = { + userId: auth.sub, + displayName: parsed.data.displayName, + avatarUrl: parsed.data.avatarUrl, + joinedAt: Date.now(), + status: 'joined', + elapsedMs: 0, + currentStage: 'fed', + }; + + const updatedParticipants = [...group.participants, participant]; + req.log.info({ groupId: group.id, userId: auth.sub }, 'User joining group fast'); + const updated = await repo.updateGroupFast(group.id, { participants: updatedParticipants }); + if (!updated) throw new NotFoundError('Failed to join group fast'); + return updated; + }); + + // Update own participant status (progress, complete, break, leave) + app.put('/fasting/groups/:id/me', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const group = await repo.getGroupFast(id); + if (!group) throw new NotFoundError('Group fast not found'); + + const participantIdx = group.participants.findIndex(p => p.userId === auth.sub); + if (participantIdx === -1) { + throw new BadRequestError('You are not a participant in this group fast'); + } + + const parsed = UpdateParticipantSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const updatedParticipants = [...group.participants]; + updatedParticipants[participantIdx] = { + ...updatedParticipants[participantIdx], + ...parsed.data, + }; + + req.log.info( + { groupId: id, userId: auth.sub, status: parsed.data.status }, + 'Updating participant' + ); + const updated = await repo.updateGroupFast(id, { participants: updatedParticipants }); + if (!updated) throw new NotFoundError('Failed to update participant'); + return updated; + }); +} diff --git a/services/platform-service/src/modules/social-fasting/social-fasting.test.ts b/services/platform-service/src/modules/social-fasting/social-fasting.test.ts new file mode 100644 index 00000000..02014e7b --- /dev/null +++ b/services/platform-service/src/modules/social-fasting/social-fasting.test.ts @@ -0,0 +1,219 @@ +/** + * Social fasting module unit tests — validates schema parsing, type guards, and constants. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateGroupFastSchema, + UpdateGroupFastSchema, + JoinGroupFastSchema, + UpdateParticipantSchema, + GroupFastQuerySchema, + GROUP_FAST_STATUSES, + PARTICIPANT_STATUSES, +} from './types.js'; + +// ── CreateGroupFastSchema ── + +describe('CreateGroupFastSchema', () => { + const validMinimal = { + name: 'Friday Fast Club', + protocolId: '16:8', + targetDurationMs: 57600000, + scheduledStart: 1709000000000, + }; + + it('accepts minimal valid input', () => { + const result = CreateGroupFastSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Friday Fast Club'); + expect(result.data.description).toBe(''); + expect(result.data.maxParticipants).toBe(10); + expect(result.data.isPublic).toBe(false); + } + }); + + it('accepts full input with all optional fields', () => { + const result = CreateGroupFastSchema.safeParse({ + ...validMinimal, + description: 'Weekly challenge group', + maxParticipants: 25, + isPublic: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.description).toBe('Weekly challenge group'); + expect(result.data.maxParticipants).toBe(25); + expect(result.data.isPublic).toBe(true); + } + }); + + it('rejects empty name', () => { + const result = CreateGroupFastSchema.safeParse({ ...validMinimal, name: '' }); + expect(result.success).toBe(false); + }); + + it('rejects negative targetDurationMs', () => { + const result = CreateGroupFastSchema.safeParse({ ...validMinimal, targetDurationMs: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects maxParticipants below 2', () => { + const result = CreateGroupFastSchema.safeParse({ ...validMinimal, maxParticipants: 1 }); + expect(result.success).toBe(false); + }); + + it('rejects maxParticipants above 50', () => { + const result = CreateGroupFastSchema.safeParse({ ...validMinimal, maxParticipants: 51 }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateGroupFastSchema ── + +describe('UpdateGroupFastSchema', () => { + it('accepts partial update', () => { + const result = UpdateGroupFastSchema.safeParse({ name: 'Renamed Group' }); + expect(result.success).toBe(true); + }); + + it('accepts status update', () => { + const result = UpdateGroupFastSchema.safeParse({ + status: 'active', + actualStart: 1709000000000, + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid status', () => { + const result = UpdateGroupFastSchema.safeParse({ status: 'invalid' }); + expect(result.success).toBe(false); + }); + + it('accepts empty object', () => { + const result = UpdateGroupFastSchema.safeParse({}); + expect(result.success).toBe(true); + }); +}); + +// ── JoinGroupFastSchema ── + +describe('JoinGroupFastSchema', () => { + it('accepts valid join request', () => { + const result = JoinGroupFastSchema.safeParse({ + inviteCode: 'ABC12345', + displayName: 'Alice', + }); + expect(result.success).toBe(true); + }); + + it('accepts join with avatar', () => { + const result = JoinGroupFastSchema.safeParse({ + inviteCode: 'XYZ99999', + displayName: 'Bob', + avatarUrl: 'https://example.com/avatar.png', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty inviteCode', () => { + const result = JoinGroupFastSchema.safeParse({ inviteCode: '', displayName: 'Test' }); + expect(result.success).toBe(false); + }); + + it('rejects empty displayName', () => { + const result = JoinGroupFastSchema.safeParse({ inviteCode: 'ABC', displayName: '' }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateParticipantSchema ── + +describe('UpdateParticipantSchema', () => { + it('accepts status-only update', () => { + const result = UpdateParticipantSchema.safeParse({ status: 'active' }); + expect(result.success).toBe(true); + }); + + it('accepts full progress update', () => { + const result = UpdateParticipantSchema.safeParse({ + status: 'active', + elapsedMs: 3600000, + currentStage: 'early_fast', + }); + expect(result.success).toBe(true); + }); + + it('accepts completion update', () => { + const result = UpdateParticipantSchema.safeParse({ + status: 'completed', + completedAt: 1709050000000, + elapsedMs: 57600000, + currentStage: 'ketosis', + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid status', () => { + const result = UpdateParticipantSchema.safeParse({ status: 'invalid' }); + expect(result.success).toBe(false); + }); + + it('rejects missing status', () => { + const result = UpdateParticipantSchema.safeParse({ elapsedMs: 1000 }); + expect(result.success).toBe(false); + }); +}); + +// ── GroupFastQuerySchema ── + +describe('GroupFastQuerySchema', () => { + it('accepts empty query (uses defaults)', () => { + const result = GroupFastQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(20); + expect(result.data.offset).toBe(0); + } + }); + + it('accepts status filter', () => { + const result = GroupFastQuerySchema.safeParse({ status: 'active' }); + expect(result.success).toBe(true); + }); + + it('coerces isPublic from string', () => { + const result = GroupFastQuerySchema.safeParse({ isPublic: 'true' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isPublic).toBe(true); + } + }); + + it('rejects invalid status', () => { + const result = GroupFastQuerySchema.safeParse({ status: 'nope' }); + expect(result.success).toBe(false); + }); +}); + +// ── Constants ── + +describe('constants', () => { + it('GROUP_FAST_STATUSES has 4 values', () => { + expect(GROUP_FAST_STATUSES).toHaveLength(4); + expect(GROUP_FAST_STATUSES).toContain('scheduled'); + expect(GROUP_FAST_STATUSES).toContain('active'); + expect(GROUP_FAST_STATUSES).toContain('completed'); + expect(GROUP_FAST_STATUSES).toContain('cancelled'); + }); + + it('PARTICIPANT_STATUSES has 5 values', () => { + expect(PARTICIPANT_STATUSES).toHaveLength(5); + expect(PARTICIPANT_STATUSES).toContain('joined'); + expect(PARTICIPANT_STATUSES).toContain('active'); + expect(PARTICIPANT_STATUSES).toContain('completed'); + expect(PARTICIPANT_STATUSES).toContain('broken'); + expect(PARTICIPANT_STATUSES).toContain('left'); + }); +}); diff --git a/services/platform-service/src/modules/social-fasting/types.ts b/services/platform-service/src/modules/social-fasting/types.ts new file mode 100644 index 00000000..5738e9a5 --- /dev/null +++ b/services/platform-service/src/modules/social-fasting/types.ts @@ -0,0 +1,111 @@ +/** + * Social fasting types — NomGap group fasts & leaderboards. + * + * Cosmos container: `social_fasts` (partition key: `/id`) + * Product-agnostic: every document includes `productId`. + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const GROUP_FAST_STATUSES = ['scheduled', 'active', 'completed', 'cancelled'] as const; +export type GroupFastStatus = (typeof GROUP_FAST_STATUSES)[number]; + +export const PARTICIPANT_STATUSES = ['joined', 'active', 'completed', 'broken', 'left'] as const; +export type ParticipantStatus = (typeof PARTICIPANT_STATUSES)[number]; + +// ── Sub-document interfaces ── + +export interface Participant { + userId: string; + displayName: string; + avatarUrl?: string; + joinedAt: number; + status: ParticipantStatus; + elapsedMs: number; + currentStage: string; + completedAt?: number; +} + +export interface LeaderboardEntry { + userId: string; + displayName: string; + avatarUrl?: string; + totalFasts: number; + totalHours: number; + currentStreak: number; + longestStreak: number; + rank: number; +} + +// ── Main document ── + +export interface GroupFastDoc { + id: string; + productId: string; + creatorId: string; + name: string; + description: string; + protocolId: string; + targetDurationMs: number; + scheduledStart: number; + actualStart?: number; + endedAt?: number; + status: GroupFastStatus; + maxParticipants: number; + participants: Participant[]; + inviteCode: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; +} + +// ── Zod schemas ── + +export const CreateGroupFastSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).default(''), + protocolId: z.string().min(1).max(128), + targetDurationMs: z.number().int().positive(), + scheduledStart: z.number().int().positive(), + maxParticipants: z.number().int().min(2).max(50).default(10), + isPublic: z.boolean().default(false), +}); + +export const UpdateGroupFastSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional(), + scheduledStart: z.number().int().positive().optional(), + status: z.enum(GROUP_FAST_STATUSES).optional(), + actualStart: z.number().int().positive().optional(), + endedAt: z.number().int().positive().optional(), +}); + +export const JoinGroupFastSchema = z.object({ + inviteCode: z.string().min(1).max(32), + displayName: z.string().min(1).max(50), + avatarUrl: z.string().url().optional(), +}); + +export const UpdateParticipantSchema = z.object({ + status: z.enum(PARTICIPANT_STATUSES), + elapsedMs: z.number().int().min(0).optional(), + currentStage: z.string().optional(), + completedAt: z.number().int().positive().optional(), +}); + +export const GroupFastQuerySchema = z.object({ + status: z.enum(GROUP_FAST_STATUSES).optional(), + isPublic: z.coerce.boolean().optional(), + limit: z.coerce.number().int().min(1).max(50).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +// ── Inferred types ── + +export type CreateGroupFastInput = z.infer; +export type UpdateGroupFastInput = z.infer; +export type JoinGroupFastInput = z.infer; +export type UpdateParticipantInput = z.infer; +export type GroupFastQuery = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index dcf3ccd0..d6cdcd0a 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -50,6 +50,8 @@ 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 { socialFastingRoutes } from './modules/social-fasting/routes.js'; +import { mealLogRoutes } from './modules/meal-log/routes.js'; import { timerRoutes } from './modules/timers/routes.js'; import { routineRoutes } from './modules/routines/routes.js'; import { householdRoutes } from './modules/households/routes.js'; @@ -133,6 +135,8 @@ await app.register(publicRoutes, { prefix: '/api' }); await app.register(fastingSessionRoutes, { prefix: '/api' }); await app.register(fastingProtocolRoutes, { prefix: '/api' }); await app.register(bodyStageRoutes, { prefix: '/api' }); +await app.register(socialFastingRoutes, { prefix: '/api' }); +await app.register(mealLogRoutes, { prefix: '/api' }); // ChronoMind modules await app.register(timerRoutes, { prefix: '/api' }); await app.register(routineRoutes, { prefix: '/api' });