feat(nomgap): add social-fasting + meal-log modules (47 tests, 2 Cosmos containers)
This commit is contained in:
parent
5e8f133816
commit
9a5e93bf05
@ -37,6 +37,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
// 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' },
|
||||
|
||||
193
services/platform-service/src/modules/meal-log/meal-log.test.ts
Normal file
193
services/platform-service/src/modules/meal-log/meal-log.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
146
services/platform-service/src/modules/meal-log/repository.ts
Normal file
146
services/platform-service/src/modules/meal-log/repository.ts
Normal file
@ -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<MealLogDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as MealLogDoc;
|
||||
}
|
||||
|
||||
export async function getMealLog(userId: string, mealId: string): Promise<MealLogDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(mealId, userId).read<MealLogDoc>();
|
||||
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<number>({
|
||||
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
|
||||
parameters: params,
|
||||
})
|
||||
.fetchAll();
|
||||
const total = countResult.resources[0] ?? 0;
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<MealLogDoc>({
|
||||
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<MealLogDoc>
|
||||
): Promise<MealLogDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(mealId, userId).read<MealLogDoc>();
|
||||
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<boolean> {
|
||||
try {
|
||||
await container().item(mealId, userId).delete();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMealLogStats(userId: string): Promise<MealLogStats> {
|
||||
const { resources: meals } = await container()
|
||||
.items.query<MealLogDoc>({
|
||||
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<string, number> = {
|
||||
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<MealType, number>,
|
||||
};
|
||||
}
|
||||
118
services/platform-service/src/modules/meal-log/routes.ts
Normal file
118
services/platform-service/src/modules/meal-log/routes.ts
Normal file
@ -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;
|
||||
});
|
||||
}
|
||||
103
services/platform-service/src/modules/meal-log/types.ts
Normal file
103
services/platform-service/src/modules/meal-log/types.ts
Normal file
@ -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<typeof CreateMealLogSchema>;
|
||||
export type UpdateMealLogInput = z.infer<typeof UpdateMealLogSchema>;
|
||||
export type MealLogQuery = z.infer<typeof MealLogQuerySchema>;
|
||||
|
||||
// ── Stats ──
|
||||
|
||||
export interface MealLogStats {
|
||||
userId: string;
|
||||
totalMeals: number;
|
||||
averageCalories: number | null;
|
||||
averageCarbs: number | null;
|
||||
averageProtein: number | null;
|
||||
averageFat: number | null;
|
||||
mealTypeCounts: Record<MealType, number>;
|
||||
}
|
||||
@ -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<GroupFastDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as GroupFastDoc;
|
||||
}
|
||||
|
||||
export async function getGroupFast(id: string): Promise<GroupFastDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<GroupFastDoc>();
|
||||
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<number>({
|
||||
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
|
||||
parameters: params,
|
||||
})
|
||||
.fetchAll();
|
||||
const total = countResult.resources[0] ?? 0;
|
||||
|
||||
const { resources } = await container()
|
||||
.items.query<GroupFastDoc>({
|
||||
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<GroupFastDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<GroupFastDoc>({
|
||||
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<GroupFastDoc>
|
||||
): Promise<GroupFastDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<GroupFastDoc>();
|
||||
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<LeaderboardEntry[]> {
|
||||
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;
|
||||
}
|
||||
194
services/platform-service/src/modules/social-fasting/routes.ts
Normal file
194
services/platform-service/src/modules/social-fasting/routes.ts
Normal file
@ -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;
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
111
services/platform-service/src/modules/social-fasting/types.ts
Normal file
111
services/platform-service/src/modules/social-fasting/types.ts
Normal file
@ -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<typeof CreateGroupFastSchema>;
|
||||
export type UpdateGroupFastInput = z.infer<typeof UpdateGroupFastSchema>;
|
||||
export type JoinGroupFastInput = z.infer<typeof JoinGroupFastSchema>;
|
||||
export type UpdateParticipantInput = z.infer<typeof UpdateParticipantSchema>;
|
||||
export type GroupFastQuery = z.infer<typeof GroupFastQuerySchema>;
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user