- Project setup: package.json, tsconfig.json, jest.config.js, .env.example - Config: env.ts, constants.ts (ALLOWED_UNITS, bcrypt rounds, JWT, deletion windows) - DB: Knex connection, knexfile, migrations 001-006 (users, password_reset_tokens, pantry_items, recipes+recipe_ingredients, shopping_lists+items, deleted_records) - Seeds: 10 seeded recipes with full ingredient lists - Middleware: JWT authMiddleware (validates token + user existence), errorHandler - Utils: Pino logger, JWT sign helper, Zod validators for all request shapes - Services: authService (signup/signin/google/pwd-reset/soft-delete/restore), pantryService (CRUD + case-insensitive duplicate guard), recipeService (browse+filter+scale), shoppingListService (CRUD+merge logic), syncService (delta sync + tombstone cleanup), emailService (SendGrid) - Routes: /v1/auth, /v1/pantry, /v1/recipes, /v1/shopping-lists, /v1/sync - App: Express factory (createApp), server entry point - Jobs: node-cron daily hard-delete + tombstone cleanup - Tests: validators, utils, auth, pantry, recipes, shopping lists, sync
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
import db from '../db/connection';
|
|
import { createError } from '../middleware/errorHandler';
|
|
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../config/constants';
|
|
|
|
export const recipeService = {
|
|
async getRecipes(
|
|
userId: string,
|
|
filter: 'all' | 'can_make' | 'can_partially_make',
|
|
page: number,
|
|
limit: number,
|
|
search?: string
|
|
) {
|
|
const safeLimit = Math.min(limit, MAX_PAGE_LIMIT);
|
|
const offset = (page - 1) * safeLimit;
|
|
|
|
// Get user's pantry item names (lowercase)
|
|
const pantryItems = await db('pantry_items')
|
|
.where({ user_id: userId })
|
|
.pluck('item_name_lower');
|
|
|
|
const pantrySet = new Set(pantryItems);
|
|
|
|
// Base query
|
|
let query = db('recipes').select('recipes.*');
|
|
|
|
if (search) {
|
|
query = query.whereRaw('LOWER(recipes.name) LIKE ?', [`%${search.toLowerCase()}%`]);
|
|
}
|
|
|
|
const allRecipes = await query.orderBy('recipes.name', 'asc');
|
|
|
|
// Get ingredients for all recipes in one query
|
|
const recipeIds = allRecipes.map((r) => r.id);
|
|
const allIngredients = recipeIds.length
|
|
? await db('recipe_ingredients').whereIn('recipe_id', recipeIds)
|
|
: [];
|
|
|
|
const ingredientsByRecipe = new Map<string, typeof allIngredients>();
|
|
for (const ing of allIngredients) {
|
|
if (!ingredientsByRecipe.has(ing.recipe_id)) {
|
|
ingredientsByRecipe.set(ing.recipe_id, []);
|
|
}
|
|
ingredientsByRecipe.get(ing.recipe_id)!.push(ing);
|
|
}
|
|
|
|
// Compute availability for each recipe
|
|
const enriched = allRecipes.map((recipe) => {
|
|
const ingredients = ingredientsByRecipe.get(recipe.id) ?? [];
|
|
const ingredientCount = ingredients.length;
|
|
const availableCount = ingredients.filter((i) =>
|
|
pantrySet.has(i.item_name_lower)
|
|
).length;
|
|
const canMake = ingredientCount > 0 && availableCount === ingredientCount;
|
|
const canPartiallyMake = availableCount > 0 && availableCount < ingredientCount;
|
|
const availabilityPct =
|
|
ingredientCount > 0
|
|
? parseFloat(((availableCount / ingredientCount) * 100).toFixed(2))
|
|
: 0;
|
|
|
|
return {
|
|
id: recipe.id,
|
|
name: recipe.name,
|
|
servings: recipe.servings,
|
|
ingredient_count: ingredientCount,
|
|
available_ingredient_count: availableCount,
|
|
can_make: canMake,
|
|
can_partially_make: canPartiallyMake,
|
|
availability_percentage: availabilityPct,
|
|
};
|
|
});
|
|
|
|
// Apply filter
|
|
let filtered = enriched;
|
|
if (filter === 'can_make') {
|
|
filtered = enriched.filter((r) => r.can_make);
|
|
} else if (filter === 'can_partially_make') {
|
|
filtered = enriched.filter((r) => r.can_partially_make);
|
|
}
|
|
|
|
const total = filtered.length;
|
|
const totalPages = Math.ceil(total / safeLimit);
|
|
const paginated = filtered.slice(offset, offset + safeLimit);
|
|
|
|
return {
|
|
recipes: paginated,
|
|
pagination: {
|
|
page,
|
|
limit: safeLimit,
|
|
total,
|
|
total_pages: totalPages,
|
|
},
|
|
};
|
|
},
|
|
|
|
async getRecipeById(recipeId: string, userId: string, scaleFactor: number) {
|
|
const recipe = await db('recipes').where({ id: recipeId }).first();
|
|
|
|
if (!recipe) {
|
|
throw createError('Recipe not found.', 404, 'NOT_FOUND');
|
|
}
|
|
|
|
const ingredients = await db('recipe_ingredients').where({ recipe_id: recipeId });
|
|
|
|
const pantryItems = await db('pantry_items')
|
|
.where({ user_id: userId })
|
|
.pluck('item_name_lower');
|
|
|
|
const pantrySet = new Set(pantryItems);
|
|
|
|
const scaledIngredients = ingredients.map((ing) => ({
|
|
id: ing.id,
|
|
item_name: ing.item_name,
|
|
quantity: parseFloat((parseFloat(ing.quantity) * scaleFactor).toFixed(4)),
|
|
original_quantity: parseFloat(ing.quantity),
|
|
unit: ing.unit,
|
|
in_pantry: pantrySet.has(ing.item_name_lower),
|
|
}));
|
|
|
|
const availableCount = scaledIngredients.filter((i) => i.in_pantry).length;
|
|
const canMake =
|
|
scaledIngredients.length > 0 && availableCount === scaledIngredients.length;
|
|
|
|
return {
|
|
id: recipe.id,
|
|
name: recipe.name,
|
|
servings: recipe.servings,
|
|
scaled_servings: recipe.servings * scaleFactor,
|
|
scale_factor: scaleFactor,
|
|
instructions: recipe.instructions,
|
|
ingredients: scaledIngredients,
|
|
can_make: canMake,
|
|
available_ingredient_count: availableCount,
|
|
ingredient_count: scaledIngredients.length,
|
|
};
|
|
},
|
|
};
|