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
This commit is contained in:
136
src/services/recipeService.ts
Normal file
136
src/services/recipeService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user