Files
pantree/src/services/recipeService.ts
Azriel e633d693da feat: implement backend API for Pantree Phase 1 MVP
- 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
2026-05-10 15:00:15 +00:00

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