feat(nomgap): add social-fasting + meal-log modules (47 tests, 2 Cosmos containers)

This commit is contained in:
saravanakumardb1 2026-02-28 00:37:46 -08:00
parent 5e8f133816
commit 9a5e93bf05
10 changed files with 1207 additions and 0 deletions

View File

@ -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' },

View 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');
});
});

View 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>,
};
}

View 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;
});
}

View 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>;
}

View File

@ -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;
}

View 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;
});
}

View File

@ -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');
});
});

View 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>;

View File

@ -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' });