Phase 8
This commit is contained in:
parent
029940b079
commit
e396f5088c
36 changed files with 4199 additions and 22 deletions
|
|
@ -42,6 +42,8 @@ import recipesRoutes from './modules/recipes/recipes.routes.js';
|
|||
import pantryRoutes from './modules/pantry/pantry.routes.js';
|
||||
import freshnessRulesRoutes from './modules/freshness-rules/freshness-rules.routes.js';
|
||||
import productsRoutes from './modules/products/products.routes.js';
|
||||
import mealPlansRoutes from './modules/meal-plans/meal-plans.routes.js';
|
||||
import nutritionTargetsRoutes from './modules/nutrition-targets/nutrition-target.routes.js';
|
||||
|
||||
export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
||||
const app = Fastify({
|
||||
|
|
@ -125,6 +127,8 @@ export async function buildApp(opts: { logger?: boolean | object } = {}) {
|
|||
await app.register(recipesRoutes);
|
||||
await app.register(pantryRoutes);
|
||||
await app.register(freshnessRulesRoutes);
|
||||
await app.register(mealPlansRoutes);
|
||||
await app.register(nutritionTargetsRoutes);
|
||||
|
||||
// Global error handler
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import { MealPlanStatus } from '@meshitrack/shared';
|
||||
|
||||
const { mockSave, MockMealPlanModel } = vi.hoisted(() => {
|
||||
const mockSave = vi.fn();
|
||||
function MockModel(this: { save: typeof mockSave }, data: unknown) {
|
||||
Object.assign(this, data);
|
||||
this.save = mockSave;
|
||||
}
|
||||
Object.assign(MockModel, {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOneAndUpdate: vi.fn(),
|
||||
findOneAndDelete: vi.fn(),
|
||||
});
|
||||
return { mockSave, MockMealPlanModel: MockModel };
|
||||
});
|
||||
|
||||
vi.mock('../../schemas/meal-plan.schema.js', () => ({
|
||||
MealPlanModel: MockMealPlanModel,
|
||||
}));
|
||||
|
||||
const { MealPlanModel } = await import('../../schemas/meal-plan.schema.js');
|
||||
|
||||
function makeChain(result: unknown = null) {
|
||||
return {
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe(MealPlanRepository.name, () => {
|
||||
let repo: MealPlanRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new MealPlanRepository();
|
||||
});
|
||||
|
||||
describe('findByHousehold', () => {
|
||||
it('applies householdId filter', async () => {
|
||||
const chain = makeChain([]);
|
||||
vi.mocked(MealPlanModel.find).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findByHousehold('hh1', { limit: 20 });
|
||||
|
||||
expect(MealPlanModel.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ householdId: 'hh1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByWeek', () => {
|
||||
it('queries by householdId and weekStartDate', async () => {
|
||||
const mockPlan = { _id: 'mp1', weekStartDate: '2026-05-18' };
|
||||
vi.mocked(MealPlanModel.findOne).mockReturnValue(makeChain(mockPlan) as never);
|
||||
|
||||
const result = await repo.findByWeek('hh1', '2026-05-18');
|
||||
expect(MealPlanModel.findOne).toHaveBeenCalledWith({
|
||||
householdId: 'hh1',
|
||||
weekStartDate: '2026-05-18',
|
||||
});
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns new document', async () => {
|
||||
const plainDoc = { _id: 'new-id', weekStartDate: '2026-05-18' };
|
||||
mockSave.mockResolvedValue({ toObject: () => plainDoc });
|
||||
|
||||
const result = await repo.create({ weekStartDate: '2026-05-18' });
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(plainDoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('updates status only', async () => {
|
||||
vi.mocked(MealPlanModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'mp1' }) as never);
|
||||
|
||||
await repo.updateStatus('mp1', 'hh1', MealPlanStatus.ACTIVE);
|
||||
expect(MealPlanModel.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ _id: 'mp1', householdId: 'hh1' },
|
||||
{ $set: { status: MealPlanStatus.ACTIVE } },
|
||||
{ new: true, lean: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
packages/api/src/modules/meal-plans/meal-plans.repository.ts
Normal file
63
packages/api/src/modules/meal-plans/meal-plans.repository.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { MealPlanModel } from '../../schemas/meal-plan.schema.js';
|
||||
import type { MealPlanQueryInput, MealPlanStatus } from '@meshitrack/shared';
|
||||
|
||||
export class MealPlanRepository {
|
||||
public async findByHousehold(householdId: string, query: MealPlanQueryInput) {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.cursor) {
|
||||
const id = Buffer.from(query.cursor, 'base64').toString();
|
||||
filter['_id'] = { $gt: id };
|
||||
}
|
||||
|
||||
const limit = query.limit;
|
||||
const items = await MealPlanModel.find(filter)
|
||||
.sort({ _id: 1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const data = hasMore ? items.slice(0, limit) : items;
|
||||
const cursor =
|
||||
data.length > 0
|
||||
? Buffer.from(data[data.length - 1]!._id.toString()).toString('base64')
|
||||
: null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return MealPlanModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
|
||||
public async findByWeek(householdId: string, weekStartDate: string) {
|
||||
return MealPlanModel.findOne({ householdId, weekStartDate }).lean().exec();
|
||||
}
|
||||
|
||||
public async create(data: Record<string, unknown>) {
|
||||
const doc = new MealPlanModel(data);
|
||||
const saved = await doc.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: Record<string, unknown>) {
|
||||
return MealPlanModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async updateStatus(id: string, householdId: string, status: MealPlanStatus) {
|
||||
return MealPlanModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: { status } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
return MealPlanModel.findOneAndDelete({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
}
|
||||
246
packages/api/src/modules/meal-plans/meal-plans.routes.test.ts
Normal file
246
packages/api/src/modules/meal-plans/meal-plans.routes.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
import { MealPlanStatus } from '@meshitrack/shared';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockFindByHousehold,
|
||||
mockFindById,
|
||||
mockFindByWeek,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockUpdateStatus,
|
||||
mockDelete,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByHousehold: vi.fn(),
|
||||
mockFindById: vi.fn(),
|
||||
mockFindByWeek: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockUpdateStatus: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./meal-plans.repository.js', () => ({
|
||||
MealPlanRepository: class {
|
||||
findByHousehold = mockFindByHousehold;
|
||||
findById = mockFindById;
|
||||
findByWeek = mockFindByWeek;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
updateStatus = mockUpdateStatus;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock prerequisite repositories to allow SuggestionEngine/Gap to resolve
|
||||
vi.mock('../recipes/recipes.repository.js', () => ({
|
||||
RecipesRepository: class {
|
||||
findByHousehold = vi.fn().mockResolvedValue({ data: [] });
|
||||
findById = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../pantry/pantry.repository.js', () => ({
|
||||
PantryRepository: class {
|
||||
findActiveByHousehold = vi.fn().mockResolvedValue([]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../nutrition-targets/nutrition-target.repository.js', () => ({
|
||||
NutritionTargetRepository: class {
|
||||
findByUser = vi.fn().mockResolvedValue(null);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../products/products.repository.js', () => ({
|
||||
ProductsRepository: class {
|
||||
findByIds = vi.fn().mockResolvedValue([]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import mealPlanRoutes from './meal-plans.routes.js';
|
||||
|
||||
const emptyNutrition = { calories: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, sugar: 0, sodium: 0, saturatedFat: 0, cholesterol: 0 };
|
||||
|
||||
function makePlan(overrides = {}) {
|
||||
return {
|
||||
_id: 'plan-1',
|
||||
householdId: 'hh1',
|
||||
weekStartDate: '2026-05-10',
|
||||
days: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: emptyNutrition,
|
||||
})),
|
||||
status: MealPlanStatus.DRAFT,
|
||||
createdBy: 'kc-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('meal-plan.routes', () => {
|
||||
let app: any;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(mealPlanRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans', () => {
|
||||
it('returns paginated results', async () => {
|
||||
mockFindByHousehold.mockResolvedValue({
|
||||
data: [makePlan()],
|
||||
pagination: { cursor: null, hasMore: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0]._id).toBe('plan-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/week/:weekStartDate', () => {
|
||||
it('returns matched weekly plan', async () => {
|
||||
mockFindByWeek.mockResolvedValue(makePlan());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/week/2026-05-10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()._id).toBe('plan-1');
|
||||
});
|
||||
|
||||
it('returns not-found message structure if missing', async () => {
|
||||
mockFindByWeek.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/week/2026-05-10',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().message).toBe('No meal plan scheduled for this week');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/meal-plans', () => {
|
||||
it('creates a new plan', async () => {
|
||||
mockFindByWeek.mockResolvedValue(null);
|
||||
mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-plan-id', createdAt: new Date(), updatedAt: new Date() }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/meal-plans',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
weekStartDate: '2026-05-10',
|
||||
days: Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: emptyNutrition,
|
||||
})),
|
||||
status: 'draft',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body._id).toBe('new-plan-id');
|
||||
expect(body.status).toBe('draft');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/suggestions', () => {
|
||||
it('returns list of scored recipe recommendations', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/suggestions?limit=2',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/meal-plans/:id/gap', () => {
|
||||
it('returns missing elements report', async () => {
|
||||
mockFindById.mockResolvedValue(makePlan());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/meal-plans/plan-1/gap',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.mealPlanId).toBe('plan-1');
|
||||
expect(Array.isArray(body.missingItems)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
356
packages/api/src/modules/meal-plans/meal-plans.routes.ts
Normal file
356
packages/api/src/modules/meal-plans/meal-plans.routes.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
CreateMealPlanSchema,
|
||||
UpdateMealPlanSchema,
|
||||
MealPlanQuerySchema,
|
||||
MealPlanResponseSchema,
|
||||
MealPlanListResponseSchema,
|
||||
MealPlanStatus,
|
||||
} from '@meshitrack/shared';
|
||||
import { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import { MealPlanService } from './meal-plans.service.js';
|
||||
import { SuggestionEngineService } from './suggestion-engine.service.js';
|
||||
import { ShoppingGapService } from './shopping-gap.service.js';
|
||||
import { RecipesRepository } from '../recipes/recipes.repository.js';
|
||||
import { PantryRepository } from '../pantry/pantry.repository.js';
|
||||
import { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js';
|
||||
import { ProductsRepository } from '../products/products.repository.js';
|
||||
|
||||
type AnyNutritionInfo = {
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number | null;
|
||||
sugar?: number | null;
|
||||
sodium?: number | null;
|
||||
saturatedFat?: number | null;
|
||||
cholesterol?: number | null;
|
||||
};
|
||||
|
||||
type AnyMealDoc = {
|
||||
id: string;
|
||||
type: string;
|
||||
recipeId?: any;
|
||||
recipeName: string;
|
||||
servings: number;
|
||||
customName?: string | null;
|
||||
customNutrition?: AnyNutritionInfo | null;
|
||||
perServingNutrition: AnyNutritionInfo;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
type AnyDayDoc = {
|
||||
date: string;
|
||||
meals: AnyMealDoc[];
|
||||
dailyNutritionTotal: AnyNutritionInfo;
|
||||
};
|
||||
|
||||
type AnyPlanDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
householdId: string;
|
||||
weekStartDate: string;
|
||||
days: AnyDayDoc[];
|
||||
status: string;
|
||||
shoppingListId?: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | Date): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
|
||||
function cleanNutrition(n: AnyNutritionInfo) {
|
||||
return {
|
||||
calories: n.calories,
|
||||
protein: n.protein,
|
||||
carbs: n.carbs,
|
||||
fat: n.fat,
|
||||
...(n.fiber != null ? { fiber: n.fiber } : {}),
|
||||
...(n.sugar != null ? { sugar: n.sugar } : {}),
|
||||
...(n.sodium != null ? { sodium: n.sodium } : {}),
|
||||
...(n.saturatedFat != null ? { saturatedFat: n.saturatedFat } : {}),
|
||||
...(n.cholesterol != null ? { cholesterol: n.cholesterol } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toPlanResponse(doc: AnyPlanDoc): z.infer<typeof MealPlanResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
householdId: doc.householdId,
|
||||
weekStartDate: doc.weekStartDate,
|
||||
days: doc.days.map((day) => ({
|
||||
date: day.date,
|
||||
meals: day.meals.map((meal) => ({
|
||||
id: meal.id,
|
||||
type: meal.type as any,
|
||||
...(meal.recipeId ? { recipeId: toStr(meal.recipeId) } : {}),
|
||||
recipeName: meal.recipeName,
|
||||
servings: meal.servings,
|
||||
...(meal.customName ? { customName: meal.customName } : {}),
|
||||
...(meal.customNutrition ? { customNutrition: cleanNutrition(meal.customNutrition) } : {}),
|
||||
perServingNutrition: cleanNutrition(meal.perServingNutrition),
|
||||
...(meal.notes ? { notes: meal.notes } : {}),
|
||||
})),
|
||||
dailyNutritionTotal: cleanNutrition(day.dailyNutritionTotal),
|
||||
})),
|
||||
status: doc.status as MealPlanStatus,
|
||||
...(doc.shoppingListId ? { shoppingListId: doc.shoppingListId } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
mealPlanRepository: MealPlanRepository;
|
||||
mealPlanService: MealPlanService;
|
||||
suggestionEngineService: SuggestionEngineService;
|
||||
shoppingGapService: ShoppingGapService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
// Ensure prerequisite repositories from other modules are registered if not already done
|
||||
// Fastify modules run sequentially, but it's safe to register singletons if they're missing in Awilix.
|
||||
fastify.diContainer.register({
|
||||
mealPlanRepository: asClass(MealPlanRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
mealPlanService: asClass(MealPlanService, { lifetime: Lifetime.SINGLETON }),
|
||||
suggestionEngineService: asClass(SuggestionEngineService, { lifetime: Lifetime.SINGLETON }),
|
||||
shoppingGapService: asClass(ShoppingGapService, { lifetime: Lifetime.SINGLETON }),
|
||||
|
||||
// Make sure other module deps are available for classes instantiated by SuggestionEngine/ShoppingGap
|
||||
recipesRepository: asClass(RecipesRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
pantryRepository: asClass(PantryRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
nutritionTargetRepository: asClass(NutritionTargetRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const householdParams = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/meal-plans — query/paginate list
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/meal-plans',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: MealPlanQuerySchema,
|
||||
response: { 200: MealPlanListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
return reply.send({
|
||||
data: result.data.map(toPlanResponse),
|
||||
pagination: result.pagination,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/meal-plans/suggestions — get algorithmic recommendations
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/meal-plans/suggestions',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
querystring: z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).default(5),
|
||||
}),
|
||||
response: {
|
||||
200: z.array(
|
||||
z.object({
|
||||
recipeId: z.string(),
|
||||
recipeName: z.string(),
|
||||
totalScore: z.number(),
|
||||
scores: z.object({
|
||||
coverage: z.number(),
|
||||
urgency: z.number(),
|
||||
nutrition: z.number(),
|
||||
variety: z.number(),
|
||||
}),
|
||||
reasoning: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const engine = fastify.diContainer.resolve('suggestionEngineService');
|
||||
const suggestions = await engine.getSuggestions(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
{ limit: request.query.limit }
|
||||
);
|
||||
return reply.send(suggestions);
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/meal-plans/week/:weekStartDate — fetch exactly by week start date
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/meal-plans/week/:weekStartDate',
|
||||
schema: {
|
||||
params: householdParams.extend({
|
||||
weekStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
}),
|
||||
response: {
|
||||
200: z.union([
|
||||
MealPlanResponseSchema,
|
||||
z.object({ message: z.literal('No meal plan scheduled for this week') }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
const plan = await service.getByWeek(
|
||||
request.params.householdId,
|
||||
request.params.weekStartDate
|
||||
);
|
||||
if (!plan) {
|
||||
return reply.status(200).send({ message: 'No meal plan scheduled for this week' });
|
||||
}
|
||||
return reply.send(toPlanResponse(plan as AnyPlanDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/meal-plans/:id — fetch by ID
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/meal-plans/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 200: MealPlanResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
const plan = await service.getById(request.params.id, request.params.householdId);
|
||||
return reply.send(toPlanResponse(plan as AnyPlanDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/meal-plans — create a new weekly plan
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/meal-plans',
|
||||
schema: {
|
||||
params: householdParams,
|
||||
body: CreateMealPlanSchema,
|
||||
response: { 201: MealPlanResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
const plan = await service.create(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body
|
||||
);
|
||||
return reply.status(201).send(toPlanResponse(plan as AnyPlanDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /api/v1/households/:householdId/meal-plans/:id — granular structural updates
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/meal-plans/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: UpdateMealPlanSchema,
|
||||
response: { 200: MealPlanResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
const plan = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body
|
||||
);
|
||||
return reply.send(toPlanResponse(plan as AnyPlanDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// PATCH /api/v1/households/:householdId/meal-plans/:id/status — simple status transition (draft -> active -> archived)
|
||||
app.route({
|
||||
method: 'PATCH',
|
||||
url: '/api/v1/households/:householdId/meal-plans/:id/status',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
body: z.object({
|
||||
status: z.nativeEnum(MealPlanStatus),
|
||||
}),
|
||||
response: { 200: MealPlanResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
const plan = await service.updateStatus(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body.status
|
||||
);
|
||||
return reply.send(toPlanResponse(plan as AnyPlanDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// DELETE /api/v1/households/:householdId/meal-plans/:id — remove plan
|
||||
app.route({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/households/:householdId/meal-plans/:id',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: { 204: z.undefined() },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('mealPlanService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/meal-plans/:id/gap — report shopping deficiencies
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/meal-plans/:id/gap',
|
||||
schema: {
|
||||
params: householdParams.extend({ id: z.string() }),
|
||||
response: {
|
||||
200: z.object({
|
||||
mealPlanId: z.string(),
|
||||
missingItems: z.array(
|
||||
z.object({
|
||||
productId: z.string(),
|
||||
productName: z.string(),
|
||||
category: z.string(),
|
||||
requiredQuantity: z.number(),
|
||||
pantryQuantity: z.number(),
|
||||
missingQuantity: z.number(),
|
||||
unit: z.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingGapService');
|
||||
const result = await service.calculateGap(
|
||||
request.params.householdId,
|
||||
request.params.id
|
||||
);
|
||||
return reply.send(result);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'meal-plans-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
}
|
||||
);
|
||||
237
packages/api/src/modules/meal-plans/meal-plans.service.test.ts
Normal file
237
packages/api/src/modules/meal-plans/meal-plans.service.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MealPlanService } from './meal-plans.service.js';
|
||||
import type { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import { MealPlanStatus, MealType } from '@meshitrack/shared';
|
||||
import { BadRequestError, NotFoundError } from '../../common/errors.js';
|
||||
|
||||
describe(MealPlanService.name, () => {
|
||||
let service: MealPlanService;
|
||||
let mockRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByWeek: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateStatus: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as never;
|
||||
|
||||
service = new MealPlanService({
|
||||
mealPlanRepository: mockRepo as unknown as MealPlanRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const query = { limit: 10 };
|
||||
const mockResult = { data: [], pagination: { cursor: null, hasMore: false } };
|
||||
mockRepo.findByHousehold.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.list('hh1', query);
|
||||
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', query);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns plan if found', async () => {
|
||||
const mockPlan = { _id: 'p1' };
|
||||
mockRepo.findById.mockResolvedValue(mockPlan);
|
||||
|
||||
const result = await service.getById('p1', 'hh1');
|
||||
expect(mockRepo.findById).toHaveBeenCalledWith('p1', 'hh1');
|
||||
expect(result).toEqual(mockPlan);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getById('p1', 'hh1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const mockPerServingNutrition = {
|
||||
calories: 100,
|
||||
protein: 10,
|
||||
carbs: 20,
|
||||
fat: 5,
|
||||
fiber: 2,
|
||||
sugar: 3,
|
||||
sodium: 100,
|
||||
saturatedFat: 1,
|
||||
cholesterol: 10,
|
||||
};
|
||||
|
||||
const emptyDays = Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
sodium: 0,
|
||||
saturatedFat: 0,
|
||||
cholesterol: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
it('calculates day totals and delegates to repository', async () => {
|
||||
mockRepo.findByWeek.mockResolvedValue(null);
|
||||
mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' }));
|
||||
|
||||
const daysWithMeal = [...emptyDays];
|
||||
daysWithMeal[0] = {
|
||||
date: '2026-05-10',
|
||||
meals: [
|
||||
{
|
||||
id: 'meal-uuid-1',
|
||||
type: MealType.BREAKFAST,
|
||||
recipeName: 'Eggs',
|
||||
servings: 2,
|
||||
perServingNutrition: mockPerServingNutrition,
|
||||
},
|
||||
],
|
||||
// Let's deliberately pass incorrect values to verify the service forces recalculation!
|
||||
dailyNutritionTotal: { calories: 999, protein: 99, carbs: 99, fat: 99 },
|
||||
};
|
||||
|
||||
const input = {
|
||||
weekStartDate: '2026-05-10',
|
||||
days: daysWithMeal,
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
const result = await service.create('hh1', 'user1', input);
|
||||
|
||||
expect(mockRepo.findByWeek).toHaveBeenCalledWith('hh1', '2026-05-10');
|
||||
expect(mockRepo.create).toHaveBeenCalled();
|
||||
|
||||
// Verify recalculation happened (perServing x 2 servings)
|
||||
expect(result.days[0].dailyNutritionTotal).toEqual({
|
||||
calories: 200,
|
||||
protein: 20,
|
||||
carbs: 40,
|
||||
fat: 10,
|
||||
fiber: 4,
|
||||
sugar: 6,
|
||||
sodium: 200,
|
||||
saturatedFat: 2,
|
||||
cholesterol: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses customNutrition over perServingNutrition if present', async () => {
|
||||
mockRepo.findByWeek.mockResolvedValue(null);
|
||||
mockRepo.create.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id' }));
|
||||
|
||||
const daysWithCustom = [...emptyDays];
|
||||
daysWithCustom[1] = {
|
||||
date: '2026-05-11',
|
||||
meals: [
|
||||
{
|
||||
id: 'meal-uuid-2',
|
||||
type: MealType.LUNCH,
|
||||
recipeName: 'Custom Item',
|
||||
servings: 1,
|
||||
perServingNutrition: mockPerServingNutrition, // 100 calories
|
||||
customNutrition: {
|
||||
calories: 300,
|
||||
protein: 30,
|
||||
carbs: 5,
|
||||
fat: 15,
|
||||
},
|
||||
},
|
||||
],
|
||||
dailyNutritionTotal: { calories: 0, protein: 0, carbs: 0, fat: 0 },
|
||||
};
|
||||
|
||||
const input = {
|
||||
weekStartDate: '2026-05-10',
|
||||
days: daysWithCustom,
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
const result = await service.create('hh1', 'user1', input);
|
||||
expect(result.days[1].dailyNutritionTotal.calories).toBe(300);
|
||||
expect(result.days[1].dailyNutritionTotal.protein).toBe(30);
|
||||
});
|
||||
|
||||
it('throws BadRequestError if plan already exists for the week', async () => {
|
||||
mockRepo.findByWeek.mockResolvedValue({ _id: 'existing-id' });
|
||||
const input = {
|
||||
weekStartDate: '2026-05-10',
|
||||
days: emptyDays,
|
||||
status: MealPlanStatus.DRAFT,
|
||||
};
|
||||
|
||||
await expect(service.create('hh1', 'user1', input)).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const existingPlan = { _id: 'p1', householdId: 'hh1', status: MealPlanStatus.DRAFT };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo.findById.mockResolvedValue(existingPlan);
|
||||
});
|
||||
|
||||
it('updates values and recalculates days if updated', async () => {
|
||||
mockRepo.update.mockImplementation((id, hh, data) => Promise.resolve({ ...existingPlan, ...data }));
|
||||
|
||||
const emptyDays = Array.from({ length: 7 }, (_, i) => ({
|
||||
date: `2026-05-${10 + i}`,
|
||||
meals: [],
|
||||
dailyNutritionTotal: {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
sodium: 0,
|
||||
saturatedFat: 0,
|
||||
cholesterol: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await service.update('p1', 'hh1', {
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
days: emptyDays,
|
||||
});
|
||||
|
||||
expect(mockRepo.update).toHaveBeenCalledWith('p1', 'hh1', {
|
||||
status: MealPlanStatus.ACTIVE,
|
||||
days: emptyDays,
|
||||
});
|
||||
expect(result.status).toBe(MealPlanStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('delegates update status to repository', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.updateStatus.mockResolvedValue({ _id: 'p1', status: MealPlanStatus.ARCHIVED });
|
||||
|
||||
const result = await service.updateStatus('p1', 'hh1', MealPlanStatus.ARCHIVED);
|
||||
expect(mockRepo.updateStatus).toHaveBeenCalledWith('p1', 'hh1', MealPlanStatus.ARCHIVED);
|
||||
expect(result.status).toBe(MealPlanStatus.ARCHIVED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('delegates deletion if found', async () => {
|
||||
mockRepo.findById.mockResolvedValue({ _id: 'p1' });
|
||||
mockRepo.delete.mockResolvedValue({ _id: 'p1' });
|
||||
|
||||
const result = await service.delete('p1', 'hh1');
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('p1', 'hh1');
|
||||
expect(result).toEqual({ _id: 'p1' });
|
||||
});
|
||||
});
|
||||
});
|
||||
166
packages/api/src/modules/meal-plans/meal-plans.service.ts
Normal file
166
packages/api/src/modules/meal-plans/meal-plans.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import type { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
import type {
|
||||
CreateMealPlanInput,
|
||||
UpdateMealPlanInput,
|
||||
MealPlanQueryInput,
|
||||
MealPlanStatus,
|
||||
NutritionInfo,
|
||||
MealPlanDaySchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
type MealPlanDay = z.infer<typeof MealPlanDaySchema>;
|
||||
|
||||
interface Deps {
|
||||
mealPlanRepository: MealPlanRepository;
|
||||
}
|
||||
|
||||
export class MealPlanService {
|
||||
private readonly mealPlanRepository: MealPlanRepository;
|
||||
|
||||
public constructor({ mealPlanRepository }: Deps) {
|
||||
this.mealPlanRepository = mealPlanRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: MealPlanQueryInput) {
|
||||
return this.mealPlanRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
const plan = await this.mealPlanRepository.findById(id, householdId);
|
||||
if (!plan) {
|
||||
throw new NotFoundError('Meal plan not found');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
public async getByWeek(householdId: string, weekStartDate: string) {
|
||||
return this.mealPlanRepository.findByWeek(householdId, weekStartDate);
|
||||
}
|
||||
|
||||
public async create(
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
input: CreateMealPlanInput
|
||||
) {
|
||||
// Prevent overlapping meal plans for same household/week
|
||||
const existing = await this.mealPlanRepository.findByWeek(
|
||||
householdId,
|
||||
input.weekStartDate
|
||||
);
|
||||
if (existing) {
|
||||
throw new BadRequestError(
|
||||
`A meal plan already exists for household ${householdId} starting ${input.weekStartDate}`
|
||||
);
|
||||
}
|
||||
|
||||
// Force re-calculation of daily totals to ensure correctness
|
||||
const updatedDays = input.days.map((day) => this.computeDayTotals(day));
|
||||
|
||||
return this.mealPlanRepository.create({
|
||||
...input,
|
||||
days: updatedDays,
|
||||
householdId,
|
||||
createdBy,
|
||||
});
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
input: UpdateMealPlanInput
|
||||
) {
|
||||
const existing = await this.getById(id, householdId);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (input.status !== undefined) data.status = input.status;
|
||||
if (input.shoppingListId !== undefined) data.shoppingListId = input.shoppingListId;
|
||||
|
||||
if (input.days !== undefined) {
|
||||
// Force re-calculation of daily totals
|
||||
data.days = input.days.map((day) => this.computeDayTotals(day));
|
||||
}
|
||||
|
||||
const updated = await this.mealPlanRepository.update(id, householdId, data);
|
||||
if (!updated) {
|
||||
throw new NotFoundError('Meal plan not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async updateStatus(
|
||||
id: string,
|
||||
householdId: string,
|
||||
status: MealPlanStatus
|
||||
) {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.mealPlanRepository.updateStatus(id, householdId, status);
|
||||
if (!updated) {
|
||||
throw new NotFoundError('Meal plan not found');
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
await this.getById(id, householdId);
|
||||
const deleted = await this.mealPlanRepository.delete(id, householdId);
|
||||
if (!deleted) {
|
||||
throw new NotFoundError('Meal plan not found');
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Calculates standard daily totals based on the component meals. */
|
||||
private computeDayTotals(day: MealPlanDay): MealPlanDay {
|
||||
const total: NutritionInfo = {
|
||||
calories: 0,
|
||||
protein: 0,
|
||||
carbs: 0,
|
||||
fat: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
sodium: 0,
|
||||
saturatedFat: 0,
|
||||
cholesterol: 0,
|
||||
};
|
||||
|
||||
for (const meal of day.meals) {
|
||||
const servings = meal.servings;
|
||||
// Use customNutrition if provided, otherwise scale perServingNutrition
|
||||
const source = meal.customNutrition ?? meal.perServingNutrition;
|
||||
|
||||
total.calories += source.calories * servings;
|
||||
total.protein += source.protein * servings;
|
||||
total.carbs += source.carbs * servings;
|
||||
total.fat += source.fat * servings;
|
||||
|
||||
if (source.fiber != null) total.fiber = (total.fiber ?? 0) + source.fiber * servings;
|
||||
if (source.sugar != null) total.sugar = (total.sugar ?? 0) + source.sugar * servings;
|
||||
if (source.sodium != null) total.sodium = (total.sodium ?? 0) + source.sodium * servings;
|
||||
if (source.saturatedFat != null) {
|
||||
total.saturatedFat = (total.saturatedFat ?? 0) + source.saturatedFat * servings;
|
||||
}
|
||||
if (source.cholesterol != null) {
|
||||
total.cholesterol = (total.cholesterol ?? 0) + source.cholesterol * servings;
|
||||
}
|
||||
}
|
||||
|
||||
// Round final totals to 2 decimal places
|
||||
return {
|
||||
...day,
|
||||
dailyNutritionTotal: {
|
||||
calories: Math.round(total.calories * 100) / 100,
|
||||
protein: Math.round(total.protein * 100) / 100,
|
||||
carbs: Math.round(total.carbs * 100) / 100,
|
||||
fat: Math.round(total.fat * 100) / 100,
|
||||
fiber: Math.round((total.fiber ?? 0) * 100) / 100,
|
||||
sugar: Math.round((total.sugar ?? 0) * 100) / 100,
|
||||
sodium: Math.round((total.sodium ?? 0) * 100) / 100,
|
||||
saturatedFat: Math.round((total.saturatedFat ?? 0) * 100) / 100,
|
||||
cholesterol: Math.round((total.cholesterol ?? 0) * 100) / 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
110
packages/api/src/modules/meal-plans/shopping-gap.service.test.ts
Normal file
110
packages/api/src/modules/meal-plans/shopping-gap.service.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ShoppingGapService } from './shopping-gap.service.js';
|
||||
import type { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import type { RecipesRepository } from '../recipes/recipes.repository.js';
|
||||
import type { PantryRepository } from '../pantry/pantry.repository.js';
|
||||
import type { ProductsRepository } from '../products/products.repository.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
describe(ShoppingGapService.name, () => {
|
||||
let service: ShoppingGapService;
|
||||
let mockMealRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockProductsRepo: { [K in keyof ProductsRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockMealRepo = { findById: vi.fn() } as never;
|
||||
mockRecipesRepo = { findById: vi.fn() } as never;
|
||||
mockPantryRepo = { findActiveByHousehold: vi.fn() } as never;
|
||||
mockProductsRepo = { findByIds: vi.fn() } as never;
|
||||
|
||||
service = new ShoppingGapService({
|
||||
mealPlanRepository: mockMealRepo as unknown as MealPlanRepository,
|
||||
recipesRepository: mockRecipesRepo as unknown as RecipesRepository,
|
||||
pantryRepository: mockPantryRepo as unknown as PantryRepository,
|
||||
productsRepository: mockProductsRepo as unknown as ProductsRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateGap', () => {
|
||||
it('throws NotFoundError if plan is missing', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.calculateGap('hh1', 'p1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('correctly scales recipe ingredients and contrasts against pantry', async () => {
|
||||
// 1. Setup Meal Plan with 1 meal
|
||||
// Recipe A planned for 4 servings.
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan1',
|
||||
days: [
|
||||
{
|
||||
meals: [
|
||||
{ recipeId: 'recipe1', servings: 4 }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 2. Recipe 1: serves 2, needs 100g of ProdA (total needed = 200g for 4 servings)
|
||||
mockRecipesRepo.findById.mockResolvedValue({
|
||||
_id: 'recipe1',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{ productId: 'prodA', quantity: 100, unit: 'g', isOptional: false }
|
||||
]
|
||||
});
|
||||
|
||||
// 3. Products Info
|
||||
mockProductsRepo.findByIds.mockResolvedValue([
|
||||
{ _id: 'prodA', name: 'Flour', category: 'baking' }
|
||||
]);
|
||||
|
||||
// 4. Pantry only has 50g of ProdA. Missing amount should be 150g!
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{ productId: 'prodA', quantity: 50 }
|
||||
]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan1');
|
||||
|
||||
expect(result.mealPlanId).toBe('plan1');
|
||||
expect(result.missingItems.length).toBe(1);
|
||||
|
||||
const gap = result.missingItems[0]!;
|
||||
expect(gap.productId).toBe('prodA');
|
||||
expect(gap.productName).toBe('Flour');
|
||||
expect(gap.requiredQuantity).toBe(200); // 100g * (4 planned / 2 base)
|
||||
expect(gap.pantryQuantity).toBe(50);
|
||||
expect(gap.missingQuantity).toBe(150);
|
||||
expect(gap.unit).toBe('g');
|
||||
});
|
||||
|
||||
it('does not include products that are fully stocked', async () => {
|
||||
mockMealRepo.findById.mockResolvedValue({
|
||||
_id: 'plan2',
|
||||
days: [
|
||||
{
|
||||
meals: [{ recipeId: 'recipe1', servings: 2 }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
mockRecipesRepo.findById.mockResolvedValue({
|
||||
_id: 'recipe1',
|
||||
servings: 2,
|
||||
ingredients: [
|
||||
{ productId: 'prodB', quantity: 50, unit: 'g', isOptional: false }
|
||||
]
|
||||
});
|
||||
|
||||
mockProductsRepo.findByIds.mockResolvedValue([{ _id: 'prodB', name: 'Salt' }]);
|
||||
|
||||
// Pantry has 100g (more than enough)
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([{ productId: 'prodB', quantity: 100 }]);
|
||||
|
||||
const result = await service.calculateGap('hh1', 'plan2');
|
||||
expect(result.missingItems.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
packages/api/src/modules/meal-plans/shopping-gap.service.ts
Normal file
162
packages/api/src/modules/meal-plans/shopping-gap.service.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import type { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import type { RecipesRepository } from '../recipes/recipes.repository.js';
|
||||
import type { PantryRepository } from '../pantry/pantry.repository.js';
|
||||
import type { ProductsRepository } from '../products/products.repository.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
mealPlanRepository: MealPlanRepository;
|
||||
recipesRepository: RecipesRepository;
|
||||
pantryRepository: PantryRepository;
|
||||
productsRepository: ProductsRepository;
|
||||
}
|
||||
|
||||
export interface GapItem {
|
||||
productId: string;
|
||||
productName: string;
|
||||
category: string;
|
||||
requiredQuantity: number;
|
||||
pantryQuantity: number;
|
||||
missingQuantity: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface ShoppingGapResult {
|
||||
mealPlanId: string;
|
||||
missingItems: GapItem[];
|
||||
}
|
||||
|
||||
export class ShoppingGapService {
|
||||
private readonly mealPlanRepository: MealPlanRepository;
|
||||
private readonly recipesRepository: RecipesRepository;
|
||||
private readonly pantryRepository: PantryRepository;
|
||||
private readonly productsRepository: ProductsRepository;
|
||||
|
||||
public constructor({
|
||||
mealPlanRepository,
|
||||
recipesRepository,
|
||||
pantryRepository,
|
||||
productsRepository,
|
||||
}: Deps) {
|
||||
this.mealPlanRepository = mealPlanRepository;
|
||||
this.recipesRepository = recipesRepository;
|
||||
this.pantryRepository = pantryRepository;
|
||||
this.productsRepository = productsRepository;
|
||||
}
|
||||
|
||||
public async calculateGap(
|
||||
householdId: string,
|
||||
mealPlanId: string
|
||||
): Promise<ShoppingGapResult> {
|
||||
const plan = await this.mealPlanRepository.findById(mealPlanId, householdId);
|
||||
if (!plan) {
|
||||
throw new NotFoundError('Meal plan not found');
|
||||
}
|
||||
|
||||
// 1. Collect unique recipe IDs in the plan
|
||||
const recipeIdSet = new Set<string>();
|
||||
const plannedMealsList: Array<{ recipeId: string; plannedServings: number }> = [];
|
||||
|
||||
const recPlan = plan as Record<string, unknown>;
|
||||
const days = (recPlan.days as any[]) || [];
|
||||
for (const day of days) {
|
||||
const meals = (day.meals as any[]) || [];
|
||||
for (const meal of meals) {
|
||||
if (meal.recipeId) {
|
||||
recipeIdSet.add(meal.recipeId);
|
||||
plannedMealsList.push({
|
||||
recipeId: meal.recipeId,
|
||||
plannedServings: meal.servings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch all referenced recipes in parallel
|
||||
const recipeIds = Array.from(recipeIdSet);
|
||||
const recipeDocs = await Promise.all(
|
||||
recipeIds.map((id) => this.recipesRepository.findById(id, householdId))
|
||||
);
|
||||
const recipesMap = new Map<string, any>();
|
||||
for (const doc of recipeDocs) {
|
||||
if (doc) {
|
||||
recipesMap.set((doc as any)._id.toString(), doc);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Aggregate required quantities of each Product
|
||||
// Map of productId -> { qty, unit }
|
||||
const requiredMap = new Map<string, { qty: number; unit: string }>();
|
||||
|
||||
for (const planned of plannedMealsList) {
|
||||
const recipe = recipesMap.get(planned.recipeId);
|
||||
if (!recipe) continue;
|
||||
|
||||
const baseServings = recipe.servings || 1;
|
||||
const scalingFactor = planned.plannedServings / baseServings;
|
||||
|
||||
const ingredients = (recipe.ingredients as any[]) || [];
|
||||
for (const ing of ingredients) {
|
||||
if (ing.isOptional) continue;
|
||||
|
||||
const productId = ing.productId as string;
|
||||
const scaledQty = (ing.quantity as number) * scalingFactor;
|
||||
const unit = (ing.unit as string) || 'g';
|
||||
|
||||
const existing = requiredMap.get(productId);
|
||||
if (existing) {
|
||||
existing.qty += scaledQty;
|
||||
} else {
|
||||
requiredMap.set(productId, { qty: scaledQty, unit });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fetch active pantry inventory & relevant Product models
|
||||
const activePantry = await this.pantryRepository.findActiveByHousehold(householdId);
|
||||
const requiredProductIds = Array.from(requiredMap.keys());
|
||||
const productDocs = await this.productsRepository.findByIds(householdId, requiredProductIds);
|
||||
|
||||
const productsInfoMap = new Map<string, any>();
|
||||
for (const p of productDocs) {
|
||||
productsInfoMap.set(p._id.toString(), p);
|
||||
}
|
||||
|
||||
// Aggregate current Pantry items by productId
|
||||
const pantryMap = new Map<string, number>();
|
||||
for (const item of activePantry) {
|
||||
const rec = item as Record<string, unknown>;
|
||||
const prodId = rec.productId as string;
|
||||
const qty = (rec.quantity as number) || 0;
|
||||
pantryMap.set(prodId, (pantryMap.get(prodId) || 0) + qty);
|
||||
}
|
||||
|
||||
// 5. Contrast requirements vs inventory to find missing gaps
|
||||
const missingItems: GapItem[] = [];
|
||||
|
||||
for (const [productId, req] of requiredMap.entries()) {
|
||||
const pantryQty = pantryMap.get(productId) || 0;
|
||||
|
||||
if (pantryQty < req.qty) {
|
||||
const productDoc = productsInfoMap.get(productId);
|
||||
const productName = productDoc?.name || 'Unknown Ingredient';
|
||||
const category = productDoc?.category || 'other';
|
||||
|
||||
missingItems.push({
|
||||
productId,
|
||||
productName,
|
||||
category,
|
||||
requiredQuantity: Math.round(req.qty * 100) / 100,
|
||||
pantryQuantity: Math.round(pantryQty * 100) / 100,
|
||||
missingQuantity: Math.round((req.qty - pantryQty) * 100) / 100,
|
||||
unit: req.unit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mealPlanId,
|
||||
missingItems: missingItems.sort((a, b) => a.productName.localeCompare(b.productName)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SuggestionEngineService } from './suggestion-engine.service.js';
|
||||
import type { RecipesRepository } from '../recipes/recipes.repository.js';
|
||||
import type { PantryRepository } from '../pantry/pantry.repository.js';
|
||||
import type { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import type { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js';
|
||||
|
||||
describe(SuggestionEngineService.name, () => {
|
||||
let service: SuggestionEngineService;
|
||||
let mockRecipesRepo: { [K in keyof RecipesRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPantryRepo: { [K in keyof PantryRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockMealPlanRepo: { [K in keyof MealPlanRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockNutritionRepo: { [K in keyof NutritionTargetRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-20T00:00:00Z'));
|
||||
|
||||
mockRecipesRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
} as never;
|
||||
|
||||
mockPantryRepo = {
|
||||
findActiveByHousehold: vi.fn(),
|
||||
} as never;
|
||||
|
||||
mockMealPlanRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
} as never;
|
||||
|
||||
mockNutritionRepo = {
|
||||
findByUser: vi.fn(),
|
||||
} as never;
|
||||
|
||||
service = new SuggestionEngineService({
|
||||
recipesRepository: mockRecipesRepo as unknown as RecipesRepository,
|
||||
pantryRepository: mockPantryRepo as unknown as PantryRepository,
|
||||
mealPlanRepository: mockMealPlanRepo as unknown as MealPlanRepository,
|
||||
nutritionTargetRepository: mockNutritionRepo as unknown as NutritionTargetRepository,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getSuggestions', () => {
|
||||
it('correctly ranks recipes based on inventory coverage and freshness', async () => {
|
||||
// 1. Set up recipes:
|
||||
// - Recipe A: Needs Product 1 (2 units) and Product 2 (1 unit)
|
||||
// - Recipe B: Needs Product 3 (1 unit)
|
||||
const recipeA = {
|
||||
_id: 'recipeA',
|
||||
name: 'Recipe A',
|
||||
ingredients: [
|
||||
{ productId: 'prod1', quantity: 2, isOptional: false },
|
||||
{ productId: 'prod2', quantity: 1, isOptional: false },
|
||||
],
|
||||
perServingNutrition: { calories: 400, protein: 30, carbs: 40, fat: 10 }, // balanced
|
||||
};
|
||||
|
||||
const recipeB = {
|
||||
_id: 'recipeB',
|
||||
name: 'Recipe B',
|
||||
ingredients: [
|
||||
{ productId: 'prod3', quantity: 1, isOptional: false },
|
||||
],
|
||||
perServingNutrition: { calories: 600, protein: 10, carbs: 100, fat: 15 }, // high carb
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({
|
||||
data: [recipeA, recipeB],
|
||||
pagination: { hasMore: false },
|
||||
});
|
||||
|
||||
// 2. Set up Pantry inventory:
|
||||
// We have Product 1 in abundance (expiringSoon).
|
||||
// We have Product 2 (fresh).
|
||||
// Product 3 is NOT in pantry.
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{
|
||||
productId: 'prod1',
|
||||
quantity: 10,
|
||||
freshnessEstimate: { daysRemaining: 2, urgency: 'expiringSoon' },
|
||||
},
|
||||
{
|
||||
productId: 'prod2',
|
||||
quantity: 5,
|
||||
freshnessEstimate: { daysRemaining: 15, urgency: 'fresh' },
|
||||
},
|
||||
]);
|
||||
|
||||
// 3. Set up standard nutrition target (Maintenance: 30p/40c/30f split)
|
||||
// Macro split match logic:
|
||||
// Recipe A: 400cals, 30g Protein(120cals=30%), 40g Carbs(160cals=40%), 10g Fat(90cals=22.5%) -> highly aligned!
|
||||
mockNutritionRepo.findByUser.mockResolvedValue({
|
||||
dailyCalories: 2000,
|
||||
proteinG: 150, // (150 * 4) = 600cals (30%)
|
||||
carbsG: 200, // (200 * 4) = 800cals (40%)
|
||||
fatG: 67, // (67 * 9) = 603cals (30%)
|
||||
});
|
||||
|
||||
// 4. Set up recent meal plans (empty history -> 100% Variety for all)
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
// Run suggestion fetch
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
|
||||
// Assertions:
|
||||
expect(suggestions.length).toBe(2);
|
||||
|
||||
// Recipe A should clearly rank #1 (100% Coverage, using urgent items, highly nutritious match)
|
||||
const top = suggestions[0]!;
|
||||
expect(top.recipeId).toBe('recipeA');
|
||||
expect(top.scores.coverage).toBe(1); // full coverage
|
||||
// Urgency: (expiringSoon[0.7] + fresh[0.1]) / 2 = 0.4
|
||||
expect(top.scores.urgency).toBeGreaterThan(0.3);
|
||||
expect(top.scores.variety).toBe(1); // never eaten
|
||||
|
||||
// Recipe B should have 0 coverage and thus lower totalScore
|
||||
const bottom = suggestions[1]!;
|
||||
expect(bottom.recipeId).toBe('recipeB');
|
||||
expect(bottom.scores.coverage).toBe(0);
|
||||
expect(bottom.totalScore).toBeLessThan(top.totalScore);
|
||||
});
|
||||
|
||||
it('penalizes recipes eaten recently (Variety score)', async () => {
|
||||
const recipeX = {
|
||||
_id: 'recipeX',
|
||||
name: 'Recipe X',
|
||||
ingredients: [],
|
||||
perServingNutrition: { calories: 100, protein: 5, carbs: 10, fat: 2 },
|
||||
};
|
||||
|
||||
mockRecipesRepo.findByHousehold.mockResolvedValue({ data: [recipeX] });
|
||||
mockPantryRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
mockNutritionRepo.findByUser.mockResolvedValue(null);
|
||||
|
||||
// Fake history: Recipe X was eaten 7 days ago
|
||||
const date7DaysAgo = new Date();
|
||||
date7DaysAgo.setDate(date7DaysAgo.getDate() - 7);
|
||||
const dateStr = date7DaysAgo.toISOString().split('T')[0];
|
||||
|
||||
mockMealPlanRepo.findByHousehold.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
days: [
|
||||
{
|
||||
date: dateStr,
|
||||
meals: [{ recipeId: 'recipeX' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const suggestions = await service.getSuggestions('hh1', 'user1');
|
||||
|
||||
// Variety calculation: 7 days ago / 14 days = 0.5
|
||||
expect(suggestions[0]!.scores.variety).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
291
packages/api/src/modules/meal-plans/suggestion-engine.service.ts
Normal file
291
packages/api/src/modules/meal-plans/suggestion-engine.service.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import type { RecipesRepository } from '../recipes/recipes.repository.js';
|
||||
import type { PantryRepository } from '../pantry/pantry.repository.js';
|
||||
import type { MealPlanRepository } from './meal-plans.repository.js';
|
||||
import type { NutritionTargetRepository } from '../nutrition-targets/nutrition-target.repository.js';
|
||||
|
||||
interface Deps {
|
||||
recipesRepository: RecipesRepository;
|
||||
pantryRepository: PantryRepository;
|
||||
mealPlanRepository: MealPlanRepository;
|
||||
nutritionTargetRepository: NutritionTargetRepository;
|
||||
}
|
||||
|
||||
export interface ScoredRecipe {
|
||||
recipeId: string;
|
||||
recipeName: string;
|
||||
totalScore: number;
|
||||
scores: {
|
||||
coverage: number;
|
||||
urgency: number;
|
||||
nutrition: number;
|
||||
variety: number;
|
||||
};
|
||||
reasoning: string[];
|
||||
}
|
||||
|
||||
export class SuggestionEngineService {
|
||||
private readonly recipesRepository: RecipesRepository;
|
||||
private readonly pantryRepository: PantryRepository;
|
||||
private readonly mealPlanRepository: MealPlanRepository;
|
||||
private readonly nutritionTargetRepository: NutritionTargetRepository;
|
||||
|
||||
public constructor({
|
||||
recipesRepository,
|
||||
pantryRepository,
|
||||
mealPlanRepository,
|
||||
nutritionTargetRepository,
|
||||
}: Deps) {
|
||||
this.recipesRepository = recipesRepository;
|
||||
this.pantryRepository = pantryRepository;
|
||||
this.mealPlanRepository = mealPlanRepository;
|
||||
this.nutritionTargetRepository = nutritionTargetRepository;
|
||||
}
|
||||
|
||||
public async getSuggestions(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
options: { limit?: number } = {}
|
||||
): Promise<ScoredRecipe[]> {
|
||||
const limit = options.limit ?? 5;
|
||||
|
||||
// 1. Parallel fetch state
|
||||
const [recipesResult, pantryItems, activeTarget, recentPlansResult] = await Promise.all([
|
||||
this.recipesRepository.findByHousehold(householdId, { limit: 1000 }),
|
||||
this.pantryRepository.findActiveByHousehold(householdId),
|
||||
this.nutritionTargetRepository.findByUser(userId, householdId),
|
||||
this.mealPlanRepository.findByHousehold(householdId, { limit: 5 }), // scan last ~5 weeks to cover 14 days
|
||||
]);
|
||||
|
||||
const recipes = recipesResult.data;
|
||||
|
||||
// 2. Process Pantry inventory for fast O(1) access
|
||||
// Map of productId -> { totalQty, minDaysRemaining, maxUrgencyWeight }
|
||||
const pantryInventory = new Map<
|
||||
string,
|
||||
{ qty: number; minDays: number; maxUrgencyWeight: number }
|
||||
>();
|
||||
|
||||
for (const item of pantryItems) {
|
||||
const rec = item as Record<string, unknown>;
|
||||
const productId = rec.productId as string;
|
||||
const quantity = (rec.quantity as number) ?? 0;
|
||||
const fresh = (rec.freshnessEstimate ?? {}) as Record<string, unknown>;
|
||||
const daysRemaining = (fresh.daysRemaining as number) ?? 999;
|
||||
const urgencyStr = (fresh.urgency as string) || 'normal';
|
||||
|
||||
const urgencyWeight = this.getUrgencyWeight(urgencyStr);
|
||||
|
||||
const existing = pantryInventory.get(productId);
|
||||
if (existing) {
|
||||
existing.qty += quantity;
|
||||
existing.minDays = Math.min(existing.minDays, daysRemaining);
|
||||
existing.maxUrgencyWeight = Math.max(existing.maxUrgencyWeight, urgencyWeight);
|
||||
} else {
|
||||
pantryInventory.set(productId, {
|
||||
qty: quantity,
|
||||
minDays: daysRemaining,
|
||||
maxUrgencyWeight: urgencyWeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build History Map for Variety scoring (last 14 days)
|
||||
const lastEaten = new Map<string, number>(); // recipeId -> daysAgo
|
||||
const now = new Date();
|
||||
|
||||
for (const plan of recentPlansResult.data) {
|
||||
const recPlan = plan as Record<string, unknown>;
|
||||
const days = (recPlan.days as any[]) || [];
|
||||
for (const day of days) {
|
||||
const dateStr = day.date as string;
|
||||
const dayDate = new Date(dateStr);
|
||||
const diffTime = Math.abs(now.getTime() - dayDate.getTime());
|
||||
const daysAgo = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysAgo <= 14) {
|
||||
const meals = (day.meals as any[]) || [];
|
||||
for (const meal of meals) {
|
||||
const recipeId = meal.recipeId as string;
|
||||
if (!recipeId) continue;
|
||||
|
||||
const currentMin = lastEaten.get(recipeId);
|
||||
if (currentMin === undefined || daysAgo < currentMin) {
|
||||
lastEaten.set(recipeId, daysAgo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Score each recipe
|
||||
const scoredList: ScoredRecipe[] = [];
|
||||
|
||||
for (const recipe of recipes) {
|
||||
const rec = recipe as Record<string, unknown>;
|
||||
const recipeId = (rec._id as { toString(): string }).toString();
|
||||
const ingredients = (rec.ingredients as any[]) || [];
|
||||
const recipeName = (rec.name as string) || 'Unknown Recipe';
|
||||
|
||||
const scoreBreakdown = this.scoreRecipe(
|
||||
ingredients,
|
||||
rec.perServingNutrition as Record<string, number> | undefined,
|
||||
recipeId,
|
||||
pantryInventory,
|
||||
activeTarget as Record<string, unknown> | null,
|
||||
lastEaten
|
||||
);
|
||||
|
||||
const totalScore =
|
||||
scoreBreakdown.coverage * 0.4 +
|
||||
scoreBreakdown.urgency * 0.3 +
|
||||
scoreBreakdown.nutrition * 0.2 +
|
||||
scoreBreakdown.variety * 0.1;
|
||||
|
||||
const roundedScore = Math.round(totalScore * 1000) / 1000;
|
||||
|
||||
scoredList.push({
|
||||
recipeId,
|
||||
recipeName,
|
||||
totalScore: roundedScore,
|
||||
scores: scoreBreakdown,
|
||||
reasoning: this.generateReasoning(scoreBreakdown, recipeName),
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Sort descending and limit
|
||||
return scoredList
|
||||
.sort((a, b) => b.totalScore - a.totalScore)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
private scoreRecipe(
|
||||
ingredients: any[],
|
||||
nutrition: Record<string, number> | undefined,
|
||||
recipeId: string,
|
||||
inventory: Map<string, { qty: number; minDays: number; maxUrgencyWeight: number }>,
|
||||
target: Record<string, unknown> | null,
|
||||
lastEaten: Map<string, number>
|
||||
) {
|
||||
// Filter non-optional ingredients
|
||||
const requiredIngs = ingredients.filter((ing) => !ing.isOptional);
|
||||
|
||||
// -- COVERAGE & URGENCY --
|
||||
let coverageScore = 1.0;
|
||||
let urgencyScore = 0.0;
|
||||
|
||||
if (requiredIngs.length > 0) {
|
||||
let totalMatches = 0;
|
||||
let weightedUrgencySum = 0;
|
||||
|
||||
for (const ing of requiredIngs) {
|
||||
const inv = inventory.get(ing.productId);
|
||||
if (inv && inv.qty > 0) {
|
||||
// If we have full quantity, 1.0, else partial
|
||||
const coverageFraction = Math.min(1.0, inv.qty / ing.quantity);
|
||||
totalMatches += coverageFraction;
|
||||
weightedUrgencySum += inv.maxUrgencyWeight * coverageFraction;
|
||||
}
|
||||
}
|
||||
|
||||
coverageScore = totalMatches / requiredIngs.length;
|
||||
// Normalize urgency as average urgency over ingredients used
|
||||
urgencyScore = totalMatches > 0 ? weightedUrgencySum / totalMatches : 0.0;
|
||||
}
|
||||
|
||||
// -- NUTRITION --
|
||||
let nutritionScore = 1.0; // Neutral by default
|
||||
if (target && nutrition && nutrition.calories > 0) {
|
||||
const tCals = (target.dailyCalories as number) || 2000;
|
||||
const tP = (target.proteinG as number) || 0;
|
||||
const tC = (target.carbsG as number) || 0;
|
||||
const tF = (target.fatG as number) || 0;
|
||||
|
||||
const rCals = nutrition.calories;
|
||||
const rP = nutrition.protein || 0;
|
||||
const rC = nutrition.carbs || 0;
|
||||
const rF = nutrition.fat || 0;
|
||||
|
||||
if (tP || tC || tF) {
|
||||
// Target macro percentages
|
||||
const targetProtPct = (tP * 4) / tCals;
|
||||
const targetCarbPct = (tC * 4) / tCals;
|
||||
const targetFatPct = (tF * 9) / tCals;
|
||||
|
||||
// Recipe macro percentages
|
||||
const recProtPct = (rP * 4) / rCals;
|
||||
const recCarbPct = (rC * 4) / rCals;
|
||||
const recFatPct = (rF * 9) / rCals;
|
||||
|
||||
// Sum of absolute diffs
|
||||
const diff =
|
||||
Math.abs(targetProtPct - recProtPct) +
|
||||
Math.abs(targetCarbPct - recCarbPct) +
|
||||
Math.abs(targetFatPct - recFatPct);
|
||||
|
||||
// Lower diff means higher score. Max diff is 2.0 theoretically.
|
||||
nutritionScore = Math.max(0, 1.0 - diff / 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
// -- VARIETY --
|
||||
let varietyScore = 1.0;
|
||||
const daysAgo = lastEaten.get(recipeId);
|
||||
if (daysAgo !== undefined) {
|
||||
// Linear scaling over 14 days
|
||||
varietyScore = daysAgo / 14;
|
||||
}
|
||||
|
||||
return {
|
||||
coverage: Math.round(coverageScore * 100) / 100,
|
||||
urgency: Math.round(urgencyScore * 100) / 100,
|
||||
nutrition: Math.round(nutritionScore * 100) / 100,
|
||||
variety: Math.round(varietyScore * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
private getUrgencyWeight(urgency: string): number {
|
||||
switch (urgency) {
|
||||
case 'expired':
|
||||
case 'urgent':
|
||||
return 1.0;
|
||||
case 'soon':
|
||||
case 'expiringSoon':
|
||||
return 0.7;
|
||||
case 'normal':
|
||||
return 0.3;
|
||||
case 'fresh':
|
||||
return 0.1;
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
private generateReasoning(
|
||||
scores: { coverage: number; urgency: number; nutrition: number; variety: number },
|
||||
recipeName: string
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (scores.coverage > 0.9) {
|
||||
reasons.push(`You have almost all the ingredients for ${recipeName} right now.`);
|
||||
} else if (scores.coverage > 0.5) {
|
||||
reasons.push(`Uses several ingredients already stocked in your pantry.`);
|
||||
}
|
||||
|
||||
if (scores.urgency > 0.7) {
|
||||
reasons.push(`High priority: Saves expiring pantry items from going to waste!`);
|
||||
} else if (scores.urgency > 0.4) {
|
||||
reasons.push(`Helps use up items that should be consumed soon.`);
|
||||
}
|
||||
|
||||
if (scores.nutrition > 0.85) {
|
||||
reasons.push(`Matches your daily nutritional macro goals very closely.`);
|
||||
}
|
||||
|
||||
if (scores.variety > 0.95 && (scores.coverage > 0.5 || scores.urgency > 0.5)) {
|
||||
reasons.push(`You haven't had this in over two weeks, adding good variety.`);
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NutritionTargetRepository } from './nutrition-target.repository.js';
|
||||
|
||||
const { mockSave, MockTargetModel } = vi.hoisted(() => {
|
||||
const mockSave = vi.fn();
|
||||
function MockModel(this: { save: typeof mockSave }, data: unknown) {
|
||||
Object.assign(this, data);
|
||||
this.save = mockSave;
|
||||
}
|
||||
Object.assign(MockModel, {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOneAndUpdate: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
});
|
||||
return { mockSave, MockTargetModel: MockModel };
|
||||
});
|
||||
|
||||
vi.mock('../../schemas/nutrition-target.schema.js', () => ({
|
||||
NutritionTargetModel: MockTargetModel,
|
||||
}));
|
||||
|
||||
const { NutritionTargetModel } = await import('../../schemas/nutrition-target.schema.js');
|
||||
|
||||
function makeChain(result: unknown = null) {
|
||||
return {
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
describe(NutritionTargetRepository.name, () => {
|
||||
let repo: NutritionTargetRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new NutritionTargetRepository();
|
||||
});
|
||||
|
||||
describe('findByUser', () => {
|
||||
it('queries by userId, householdId, and isActive: true', async () => {
|
||||
const mockTarget = { _id: 't1', dailyCalories: 2000 };
|
||||
vi.mocked(NutritionTargetModel.findOne).mockReturnValue(makeChain(mockTarget) as never);
|
||||
|
||||
const result = await repo.findByUser('user1', 'hh1');
|
||||
expect(NutritionTargetModel.findOne).toHaveBeenCalledWith({
|
||||
userId: 'user1',
|
||||
householdId: 'hh1',
|
||||
isActive: true,
|
||||
});
|
||||
expect(result).toEqual(mockTarget);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllByUser', () => {
|
||||
it('returns all targets sorted by newest first', async () => {
|
||||
const chain = makeChain([]);
|
||||
vi.mocked(NutritionTargetModel.find).mockReturnValue(chain as never);
|
||||
|
||||
await repo.findAllByUser('user1', 'hh1');
|
||||
expect(NutritionTargetModel.find).toHaveBeenCalledWith({
|
||||
userId: 'user1',
|
||||
householdId: 'hh1',
|
||||
});
|
||||
expect(chain.sort).toHaveBeenCalledWith({ createdAt: -1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves and returns new document', async () => {
|
||||
const plainDoc = { _id: 'new-id', dailyCalories: 2000 };
|
||||
mockSave.mockResolvedValue({ toObject: () => plainDoc });
|
||||
|
||||
const result = await repo.create({ dailyCalories: 2000 });
|
||||
expect(mockSave).toHaveBeenCalled();
|
||||
expect(result).toEqual(plainDoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivateAllForUser', () => {
|
||||
it('updates all active targets for the user to inactive', async () => {
|
||||
vi.mocked(NutritionTargetModel.updateMany).mockReturnValue({
|
||||
exec: vi.fn().mockResolvedValue({ modifiedCount: 1 }),
|
||||
} as never);
|
||||
|
||||
await repo.deactivateAllForUser('user1', 'hh1');
|
||||
expect(NutritionTargetModel.updateMany).toHaveBeenCalledWith(
|
||||
{ userId: 'user1', householdId: 'hh1', isActive: true },
|
||||
{ $set: { isActive: false } },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { NutritionTargetModel } from '../../schemas/nutrition-target.schema.js';
|
||||
|
||||
export class NutritionTargetRepository {
|
||||
public async findByUser(userId: string, householdId: string) {
|
||||
return NutritionTargetModel.findOne({ userId, householdId, isActive: true })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async findAllByUser(userId: string, householdId: string) {
|
||||
return NutritionTargetModel.find({ userId, householdId })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async create(data: Record<string, unknown>) {
|
||||
const doc = new NutritionTargetModel(data);
|
||||
const saved = await doc.save();
|
||||
return saved.toObject();
|
||||
}
|
||||
|
||||
public async deactivateAllForUser(userId: string, householdId: string) {
|
||||
return NutritionTargetModel.updateMany(
|
||||
{ userId, householdId, isActive: true },
|
||||
{ $set: { isActive: false } }
|
||||
).exec();
|
||||
}
|
||||
|
||||
public async update(id: string, userId: string, householdId: string, data: Record<string, unknown>) {
|
||||
return NutritionTargetModel.findOneAndUpdate(
|
||||
{ _id: id, userId, householdId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true }
|
||||
).exec();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { fastifyAwilixPlugin } from '@fastify/awilix';
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
|
||||
jwtVerify: vi.fn().mockResolvedValue({
|
||||
payload: {
|
||||
sub: 'kc-1',
|
||||
email: 'test@example.com',
|
||||
preferred_username: 'testuser',
|
||||
realm_access: { roles: ['member'] },
|
||||
householdIds: ['hh1'],
|
||||
},
|
||||
protectedHeader: { alg: 'RS256' },
|
||||
key: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockFindByUser,
|
||||
mockFindAllByUser,
|
||||
mockDeactivateAllForUser,
|
||||
mockCreate,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindByUser: vi.fn(),
|
||||
mockFindAllByUser: vi.fn(),
|
||||
mockDeactivateAllForUser: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./nutrition-target.repository.js', () => ({
|
||||
NutritionTargetRepository: class {
|
||||
findByUser = mockFindByUser;
|
||||
findAllByUser = mockFindAllByUser;
|
||||
deactivateAllForUser = mockDeactivateAllForUser;
|
||||
create = mockCreate;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../users/users.repository.js', () => ({
|
||||
UsersRepository: class {
|
||||
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
|
||||
},
|
||||
}));
|
||||
|
||||
import authPlugin from '../../plugins/auth.plugin.js';
|
||||
import householdPlugin from '../../plugins/household.plugin.js';
|
||||
import usersRoutes from '../users/users.routes.js';
|
||||
import nutritionTargetRoutes from './nutrition-target.routes.js';
|
||||
|
||||
function makeTarget(overrides = {}) {
|
||||
return {
|
||||
_id: 'target-1',
|
||||
userId: 'kc-1',
|
||||
householdId: 'hh1',
|
||||
dailyCalories: 2000,
|
||||
proteinG: 150,
|
||||
carbsG: 200,
|
||||
fatG: 67,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('nutrition-target.routes', () => {
|
||||
let app: any;
|
||||
|
||||
async function buildTestApp() {
|
||||
const instance = Fastify({ logger: false });
|
||||
instance.setValidatorCompiler(validatorCompiler);
|
||||
instance.setSerializerCompiler(serializerCompiler);
|
||||
await instance.register(fastifyAwilixPlugin, {
|
||||
disposeOnClose: true,
|
||||
disposeOnResponse: true,
|
||||
strictBooleanEnforced: true,
|
||||
});
|
||||
await instance.register(authPlugin);
|
||||
await instance.register(householdPlugin);
|
||||
await instance.register(usersRoutes);
|
||||
await instance.register(nutritionTargetRoutes);
|
||||
await instance.ready();
|
||||
return instance;
|
||||
}
|
||||
|
||||
const authHeaders = { authorization: 'Bearer valid-token' };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
app = await buildTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/nutrition-targets', () => {
|
||||
it('returns the active target if found', async () => {
|
||||
mockFindByUser.mockResolvedValue(makeTarget());
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/nutrition-targets',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.dailyCalories).toBe(2000);
|
||||
expect(body.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a message object if no target is set', async () => {
|
||||
mockFindByUser.mockResolvedValue(null);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/nutrition-targets',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().message).toBe('No active targets defined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/households/:householdId/nutrition-targets/history', () => {
|
||||
it('returns historical targets', async () => {
|
||||
mockFindAllByUser.mockResolvedValue([makeTarget({ isActive: false }), makeTarget()]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/hh1/nutrition-targets/history',
|
||||
headers: authHeaders,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/nutrition-targets', () => {
|
||||
it('creates target and returns it', async () => {
|
||||
mockCreate.mockImplementation((data) => Promise.resolve({ ...data, _id: 'new-id', createdAt: new Date(), updatedAt: new Date() }));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/nutrition-targets',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dailyCalories: 1800,
|
||||
proteinG: 135,
|
||||
carbsG: 180,
|
||||
fatG: 60,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.dailyCalories).toBe(1800);
|
||||
expect(body.isActive).toBe(true); // auto activated
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/households/:householdId/nutrition-targets/presets', () => {
|
||||
it('returns calculated splits', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/hh1/nutrition-targets/presets',
|
||||
headers: { ...authHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
calories: 2000,
|
||||
strategy: 'loss',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
// Loss is 40% protein (200g), 30% carbs (150g), 30% fat (67g)
|
||||
expect(body.dailyCalories).toBe(2000);
|
||||
expect(body.proteinG).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
NutritionTargetSchema,
|
||||
NutritionTargetResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
import { NutritionTargetRepository } from './nutrition-target.repository.js';
|
||||
import { NutritionTargetService } from './nutrition-target.service.js';
|
||||
|
||||
type AnyTargetDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
userId: string;
|
||||
householdId: string;
|
||||
dailyCalories: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG?: number | null;
|
||||
sugarG?: number | null;
|
||||
sodiumMg?: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | Date): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
|
||||
function toNutritionTargetResponse(doc: AnyTargetDoc): z.infer<typeof NutritionTargetResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
userId: doc.userId,
|
||||
householdId: doc.householdId,
|
||||
dailyCalories: doc.dailyCalories,
|
||||
proteinG: doc.proteinG,
|
||||
carbsG: doc.carbsG,
|
||||
fatG: doc.fatG,
|
||||
...(doc.fiberG != null ? { fiberG: doc.fiberG } : {}),
|
||||
...(doc.sugarG != null ? { sugarG: doc.sugarG } : {}),
|
||||
...(doc.sodiumMg != null ? { sodiumMg: doc.sodiumMg } : {}),
|
||||
isActive: doc.isActive,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
nutritionTargetRepository: NutritionTargetRepository;
|
||||
nutritionTargetService: NutritionTargetService;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
fastify.diContainer.register({
|
||||
nutritionTargetRepository: asClass(NutritionTargetRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
nutritionTargetService: asClass(NutritionTargetService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
const paramsSchema = z.object({ householdId: z.string() });
|
||||
|
||||
// GET /api/v1/households/:householdId/nutrition-targets — get current active targets
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/nutrition-targets',
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: z.union([
|
||||
NutritionTargetResponseSchema,
|
||||
z.object({ message: z.literal('No active targets defined') }),
|
||||
]),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const userId = request.user.keycloakId;
|
||||
const target = await service.getActiveByUser(userId, request.params.householdId);
|
||||
|
||||
if (!target) {
|
||||
return reply.status(200).send({ message: 'No active targets defined' });
|
||||
}
|
||||
|
||||
return reply.send(toNutritionTargetResponse(target as AnyTargetDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// GET /api/v1/households/:householdId/nutrition-targets/history — get all targets historically
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/households/:householdId/nutrition-targets/history',
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: z.array(NutritionTargetResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const userId = request.user.keycloakId;
|
||||
const targets = await service.getAllByUser(userId, request.params.householdId);
|
||||
return reply.send(targets.map((t) => toNutritionTargetResponse(t as AnyTargetDoc)));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/nutrition-targets — upsert active targets
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/nutrition-targets',
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
body: NutritionTargetSchema,
|
||||
response: { 201: NutritionTargetResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const userId = request.user.keycloakId;
|
||||
const target = await service.setTarget(
|
||||
userId,
|
||||
request.params.householdId,
|
||||
request.body
|
||||
);
|
||||
return reply.status(201).send(toNutritionTargetResponse(target as AnyTargetDoc));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/v1/households/:householdId/nutrition-targets/presets — generate macros via preset strategy
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/households/:householdId/nutrition-targets/presets',
|
||||
schema: {
|
||||
params: paramsSchema,
|
||||
body: z.object({
|
||||
calories: z.number().positive(),
|
||||
strategy: z.enum(['maintenance', 'loss', 'gain']),
|
||||
}),
|
||||
response: { 200: NutritionTargetSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const calculated = service.calculatePreset(request.body.calories, request.body.strategy);
|
||||
return reply.send(calculated);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'nutrition-targets-routes',
|
||||
dependencies: ['auth-plugin'],
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NutritionTargetService } from './nutrition-target.service.js';
|
||||
import type { NutritionTargetRepository } from './nutrition-target.repository.js';
|
||||
|
||||
describe(NutritionTargetService.name, () => {
|
||||
let service: NutritionTargetService;
|
||||
let mockRepo: { [K in keyof NutritionTargetRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findByUser: vi.fn(),
|
||||
findAllByUser: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deactivateAllForUser: vi.fn(),
|
||||
update: vi.fn(),
|
||||
} as never;
|
||||
|
||||
service = new NutritionTargetService({
|
||||
nutritionTargetRepository: mockRepo as unknown as NutritionTargetRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveByUser', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const mockTarget = { dailyCalories: 2000, isActive: true };
|
||||
mockRepo.findByUser.mockResolvedValue(mockTarget);
|
||||
|
||||
const result = await service.getActiveByUser('u1', 'h1');
|
||||
expect(mockRepo.findByUser).toHaveBeenCalledWith('u1', 'h1');
|
||||
expect(result).toEqual(mockTarget);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllByUser', () => {
|
||||
it('delegates to repository', async () => {
|
||||
const mockTargets = [{ dailyCalories: 2000 }, { dailyCalories: 1800 }];
|
||||
mockRepo.findAllByUser.mockResolvedValue(mockTargets);
|
||||
|
||||
const result = await service.getAllByUser('u1', 'h1');
|
||||
expect(mockRepo.findAllByUser).toHaveBeenCalledWith('u1', 'h1');
|
||||
expect(result).toEqual(mockTargets);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTarget', () => {
|
||||
it('deactivates existing targets before creating an active one', async () => {
|
||||
const mockInput = { dailyCalories: 2000, proteinG: 100, carbsG: 200, fatG: 50, isActive: true };
|
||||
const createdTarget = { ...mockInput, _id: 'new-id', userId: 'u1', householdId: 'h1' };
|
||||
mockRepo.create.mockResolvedValue(createdTarget);
|
||||
|
||||
const result = await service.setTarget('u1', 'h1', mockInput);
|
||||
|
||||
expect(mockRepo.deactivateAllForUser).toHaveBeenCalledWith('u1', 'h1');
|
||||
expect(mockRepo.create).toHaveBeenCalledWith({
|
||||
...mockInput,
|
||||
userId: 'u1',
|
||||
householdId: 'h1',
|
||||
});
|
||||
expect(result).toEqual(createdTarget);
|
||||
});
|
||||
|
||||
it('does NOT deactivate others if isActive is explicitly false', async () => {
|
||||
const mockInput = { dailyCalories: 2000, proteinG: 100, carbsG: 200, fatG: 50, isActive: false };
|
||||
await service.setTarget('u1', 'h1', mockInput);
|
||||
|
||||
expect(mockRepo.deactivateAllForUser).not.toHaveBeenCalled();
|
||||
expect(mockRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculatePreset', () => {
|
||||
it('calculates macros correctly for maintenance (30p / 40c / 30f)', () => {
|
||||
const result = service.calculatePreset(2000, 'maintenance');
|
||||
|
||||
// Math:
|
||||
// Protein: (2000 * 0.3) / 4 = 600 / 4 = 150
|
||||
// Carbs: (2000 * 0.4) / 4 = 800 / 4 = 200
|
||||
// Fat: (2000 * 0.3) / 9 = 600 / 9 = 66.66 => 67
|
||||
expect(result).toEqual({
|
||||
dailyCalories: 2000,
|
||||
proteinG: 150,
|
||||
carbsG: 200,
|
||||
fatG: 67,
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates macros correctly for loss (40p / 30c / 30f)', () => {
|
||||
const result = service.calculatePreset(2000, 'loss');
|
||||
|
||||
// Math:
|
||||
// Protein: (2000 * 0.4) / 4 = 800 / 4 = 200
|
||||
// Carbs: (2000 * 0.3) / 4 = 600 / 4 = 150
|
||||
// Fat: (2000 * 0.3) / 9 = 600 / 9 = 66.66 => 67
|
||||
expect(result).toEqual({
|
||||
dailyCalories: 2000,
|
||||
proteinG: 200,
|
||||
carbsG: 150,
|
||||
fatG: 67,
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates macros correctly for gain (25p / 50c / 25f)', () => {
|
||||
const result = service.calculatePreset(2000, 'gain');
|
||||
|
||||
// Math:
|
||||
// Protein: (2000 * 0.25) / 4 = 500 / 4 = 125
|
||||
// Carbs: (2000 * 0.5) / 4 = 1000 / 4 = 250
|
||||
// Fat: (2000 * 0.25) / 9 = 500 / 9 = 55.55 => 56
|
||||
expect(result).toEqual({
|
||||
dailyCalories: 2000,
|
||||
proteinG: 125,
|
||||
carbsG: 250,
|
||||
fatG: 56,
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { NutritionTargetRepository } from './nutrition-target.repository.js';
|
||||
import type { SetNutritionTargetInput } from '@meshitrack/shared';
|
||||
|
||||
interface Deps {
|
||||
nutritionTargetRepository: NutritionTargetRepository;
|
||||
}
|
||||
|
||||
export type PresetType = 'maintenance' | 'loss' | 'gain';
|
||||
|
||||
export class NutritionTargetService {
|
||||
private readonly nutritionTargetRepository: NutritionTargetRepository;
|
||||
|
||||
public constructor({ nutritionTargetRepository }: Deps) {
|
||||
this.nutritionTargetRepository = nutritionTargetRepository;
|
||||
}
|
||||
|
||||
public async getActiveByUser(userId: string, householdId: string) {
|
||||
return this.nutritionTargetRepository.findByUser(userId, householdId);
|
||||
}
|
||||
|
||||
public async getAllByUser(userId: string, householdId: string) {
|
||||
return this.nutritionTargetRepository.findAllByUser(userId, householdId);
|
||||
}
|
||||
|
||||
public async setTarget(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
input: SetNutritionTargetInput
|
||||
) {
|
||||
// Maintain invariant: only one target is active per user per household
|
||||
if (input.isActive !== false) {
|
||||
await this.nutritionTargetRepository.deactivateAllForUser(userId, householdId);
|
||||
}
|
||||
|
||||
return this.nutritionTargetRepository.create({
|
||||
...input,
|
||||
userId,
|
||||
householdId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate macros based on calorie goal and strategy preset.
|
||||
* - Maintenance: 30% Protein, 40% Carbs, 30% Fat
|
||||
* - Loss: 40% Protein, 30% Carbs, 30% Fat
|
||||
* - Gain: 25% Protein, 50% Carbs, 25% Fat
|
||||
*/
|
||||
public calculatePreset(calories: number, preset: PresetType): SetNutritionTargetInput {
|
||||
let proteinPct: number;
|
||||
let carbsPct: number;
|
||||
let fatPct: number;
|
||||
|
||||
switch (preset) {
|
||||
case 'loss':
|
||||
proteinPct = 0.40;
|
||||
carbsPct = 0.30;
|
||||
fatPct = 0.30;
|
||||
break;
|
||||
case 'gain':
|
||||
proteinPct = 0.25;
|
||||
carbsPct = 0.50;
|
||||
fatPct = 0.25;
|
||||
break;
|
||||
case 'maintenance':
|
||||
default:
|
||||
proteinPct = 0.30;
|
||||
carbsPct = 0.40;
|
||||
fatPct = 0.30;
|
||||
break;
|
||||
}
|
||||
|
||||
// 4 kcal per gram of protein/carb, 9 kcal per gram of fat
|
||||
const proteinG = Math.round((calories * proteinPct) / 4);
|
||||
const carbsG = Math.round((calories * carbsPct) / 4);
|
||||
const fatG = Math.round((calories * fatPct) / 9);
|
||||
|
||||
return {
|
||||
dailyCalories: calories,
|
||||
proteinG,
|
||||
carbsG,
|
||||
fatG,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
72
packages/api/src/schemas/meal-plan.schema.ts
Normal file
72
packages/api/src/schemas/meal-plan.schema.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MealType, MealPlanStatus } from '@meshitrack/shared';
|
||||
|
||||
const nutritionInfoSchema = new mongoose.Schema(
|
||||
{
|
||||
calories: { type: Number, required: true, min: 0 },
|
||||
protein: { type: Number, required: true, min: 0 },
|
||||
carbs: { type: Number, required: true, min: 0 },
|
||||
fat: { type: Number, required: true, min: 0 },
|
||||
fiber: { type: Number, min: 0 },
|
||||
sugar: { type: Number, min: 0 },
|
||||
sodium: { type: Number, min: 0 },
|
||||
saturatedFat: { type: Number, min: 0 },
|
||||
cholesterol: { type: Number, min: 0 },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const plannedMealSchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: String, required: true }, // UUID for drag-and-drop reference
|
||||
type: { type: String, enum: Object.values(MealType), required: true },
|
||||
recipeId: { type: String },
|
||||
recipeName: { type: String, required: true },
|
||||
servings: { type: Number, required: true, min: 0.1 },
|
||||
customName: { type: String },
|
||||
customNutrition: { type: nutritionInfoSchema },
|
||||
perServingNutrition: { type: nutritionInfoSchema, required: true },
|
||||
notes: { type: String },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const mealPlanDaySchema = new mongoose.Schema(
|
||||
{
|
||||
date: { type: String, required: true }, // YYYY-MM-DD
|
||||
meals: { type: [plannedMealSchema], default: [] },
|
||||
dailyNutritionTotal: { type: nutritionInfoSchema, required: true },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
const mealPlanSchema = new mongoose.Schema(
|
||||
{
|
||||
householdId: { type: String, required: true },
|
||||
weekStartDate: { type: String, required: true }, // Monday of the week YYYY-MM-DD
|
||||
days: { type: [mealPlanDaySchema], required: true },
|
||||
status: {
|
||||
type: String,
|
||||
enum: Object.values(MealPlanStatus),
|
||||
required: true,
|
||||
default: MealPlanStatus.DRAFT,
|
||||
},
|
||||
shoppingListId: { type: String },
|
||||
createdBy: { type: String, required: true },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
// Unique week per household
|
||||
mealPlanSchema.index({ householdId: 1, weekStartDate: 1 }, { unique: true });
|
||||
mealPlanSchema.index({ householdId: 1, status: 1 });
|
||||
mealPlanSchema.index({ householdId: 1, 'days.meals.recipeId': 1 });
|
||||
|
||||
export const MealPlanModel = mongoose.model('MealPlan', mealPlanSchema);
|
||||
export type MealPlanDocument = mongoose.InferSchemaType<typeof mealPlanSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
29
packages/api/src/schemas/nutrition-target.schema.ts
Normal file
29
packages/api/src/schemas/nutrition-target.schema.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
const nutritionTargetSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: { type: String, required: true },
|
||||
householdId: { type: String, required: true },
|
||||
dailyCalories: { type: Number, required: true, min: 0 },
|
||||
proteinG: { type: Number, required: true, min: 0 },
|
||||
carbsG: { type: Number, required: true, min: 0 },
|
||||
fatG: { type: Number, required: true, min: 0 },
|
||||
fiberG: { type: Number, min: 0 },
|
||||
sodiumMg: { type: Number, min: 0 },
|
||||
sugarG: { type: Number, min: 0 },
|
||||
isActive: { type: Boolean, required: true, default: true },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
},
|
||||
);
|
||||
|
||||
// One active target per user per household
|
||||
nutritionTargetSchema.index({ userId: 1, householdId: 1, isActive: 1 });
|
||||
|
||||
export const NutritionTargetModel = mongoose.model('NutritionTarget', nutritionTargetSchema);
|
||||
export type NutritionTargetDocument = mongoose.InferSchemaType<typeof nutritionTargetSchema> & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue