Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .mocharc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
ignore: [
'test/contractTests/**',
// Jest-style unit test — run via `npx jest`, not Mocha
'test/recipeDiscoveryAndFilter.test.js',
],
};
164 changes: 107 additions & 57 deletions controller/filterController.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,96 @@
const supabase = require('../dbConnection');

/**
* Filter recipes based on dietary preferences and allergens
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* GET /api/filter
*
* Canonical server-side recipe filter endpoint. This is intentionally the
* ONLY discovery filter endpoint on the backend — UI-only refinements
* (sorting client-loaded results, toggling favourites, etc.) stay in the
* frontend. See docs/RECIPES_SCOPE.md for the full scope contract.
*
* Supported query parameters (all optional):
* - allergies comma separated list or repeated param
* - dietary single dietary name (partial match)
* - cuisine_id numeric cuisine id
* - search partial match on recipe_name (ILIKE)
* - limit page size (default 50, max 200)
* - offset pagination offset (default 0)
*
* Response shape is preserved as a JSON array of recipes to avoid breaking
* existing frontend consumers.
*/
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;

function parsePaginationParam(value, fallback, max) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
if (max && parsed > max) return max;
return parsed;
}

function parseAllergyList(allergies) {
if (!allergies) return [];
const raw = Array.isArray(allergies) ? allergies : String(allergies).split(',');
return raw
.map((allergy) => String(allergy || '').toLowerCase().trim())
.filter(Boolean);
}

const filterRecipes = async (req, res) => {
const { allergies, dietary } = req.query;
const { allergies, dietary, cuisine_id, search } = req.query;
const limit = parsePaginationParam(req.query.limit, DEFAULT_LIMIT, MAX_LIMIT);
const offset = parsePaginationParam(req.query.offset, 0);

try {
// Fetch the mapping of dietary names to IDs
const { data: dietaryMapping, error: dietaryError } = await supabase
.from('dietary_requirements')
.select('id, name');
// Resolve dietary -> id list once. We keep partial-name matching for
// backwards compatibility with the existing frontend dropdown.
let dietaryFilterIds = [];
if (dietary) {
const { data: dietaryMapping, error: dietaryError } = await supabase
.from('dietary_requirements')
.select('id, name');

if (dietaryError) throw dietaryError;

if (dietaryError) throw dietaryError;
const matches = (dietaryMapping || []).filter((d) =>
d.name.toLowerCase().includes(String(dietary).toLowerCase())
);

// Validate dietary input
if (dietary && !dietaryMapping.some(d => d.name.toLowerCase().includes(dietary.toLowerCase()))) {
return res.status(400).json({ error: "Invalid dietary requirement provided" });
if (!matches.length) {
return res.status(400).json({ error: 'Invalid dietary requirement provided' });
}

dietaryFilterIds = matches.map((d) => d.id.toString());
}

// Find dietary IDs for partial matches
const dietaryFilterIds = dietary
? dietaryMapping
.filter(d => d.name.toLowerCase().includes(dietary.toLowerCase()))
.map(d => d.id.toString())
: [];
// Validate allergens against the canonical allergies table.
const allergyList = parseAllergyList(allergies);
if (allergyList.length) {
const { data: allergensMapping, error: allergensError } = await supabase
.from('allergies')
.select('id, name');

if (allergensError) throw allergensError;

// Fetch recipes with their dietary requirements and ingredients
const { data: recipes, error: recipeError } = await supabase
const allKnown = allergyList.every((allergy) =>
(allergensMapping || []).some((a) => a.name.toLowerCase().includes(allergy))
);

if (!allKnown) {
return res.status(400).json({ error: 'Invalid allergen provided' });
}
}

// Build the base query with server-side filters where Supabase supports
// them. Allergy filtering relies on joined ingredient data, so it still
// runs in JS, but the result set is pre-narrowed by cuisine/search.
let query = supabase
.from('recipes')
.select(`
id,
recipe_name,
cuisine_id,
dietary,
dietary_requirements (
id,
Expand All @@ -49,59 +106,52 @@ const filterRecipes = async (req, res) => {
)
`);

if (recipeError) throw recipeError;
if (cuisine_id) {
const cuisineIdNum = Number.parseInt(cuisine_id, 10);
if (!Number.isFinite(cuisineIdNum)) {
return res.status(400).json({ error: 'cuisine_id must be numeric' });
}
query = query.eq('cuisine_id', cuisineIdNum);
}

// Validate allergies input
const allergyList = allergies
? (Array.isArray(allergies) ? allergies : allergies.split(',')).map(allergy =>
allergy.toLowerCase().trim()
)
: [];

const { data: allergensMapping, error: allergensError } = await supabase
.from('allergies')
.select('id, name');

if (allergensError) throw allergensError;

if (
allergyList.length &&
!allergyList.every(allergy =>
allergensMapping.some(a => a.name.toLowerCase().includes(allergy))
)
) {
return res.status(400).json({ error: "Invalid allergen provided" });
if (search) {
// Escape % and _ to keep ILIKE safe-ish; treat the rest as literal.
const safeSearch = String(search).replace(/[%_]/g, (c) => `\\${c}`);
query = query.ilike('recipe_name', `%${safeSearch}%`);
}

// Filter recipes based on dietary requirements and allergens
const filteredRecipes = recipes.filter(recipe => {
// Check if any ingredient in the recipe has an allergen matching the allergyList (partial match)
const hasAllergy = recipe.ingredients.some(ingredient => {
return (
ingredient.allergies_type &&
allergyList.some(allergy =>
ingredient.allergies_type.name
.toLowerCase()
.includes(allergy) // Check for partial match
)
);
// Pull a slightly larger window than `limit` because allergy filtering
// can shrink the page; we re-slice after filtering.
query = query.range(offset, offset + Math.max(limit * 2, limit + 25) - 1);

const { data: recipes, error: recipeError } = await query;
if (recipeError) throw recipeError;

const filteredRecipes = (recipes || []).filter((recipe) => {
const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];

const hasAllergy = ingredients.some((ingredient) => {
if (!ingredient?.allergies_type?.name) return false;
const allergenName = ingredient.allergies_type.name.toLowerCase();
return allergyList.some((allergy) => allergenName.includes(allergy));
});

// Exclude recipes with ingredients containing allergens
if (hasAllergy) return false;

// Check if recipe matches any of the dietary filter IDs
const dietaryCheck =
!dietaryFilterIds.length ||
(recipe.dietary && dietaryFilterIds.includes(recipe.dietary.toString()));

return dietaryCheck;
});

res.status(200).json(filteredRecipes);
// Apply final pagination slice after allergy filtering.
const page = filteredRecipes.slice(0, limit);

return res.status(200).json(page);
} catch (error) {
console.error('Error filtering recipes:', error.message);
res.status(400).json({ error: error.message });
return res.status(400).json({ error: error.message });
}
};

Expand Down
81 changes: 72 additions & 9 deletions controller/recipeController.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,21 +346,79 @@ const listAdminRecipes = async (req, res) => {
}
};

/**
* GET /api/recipe/community
*
* Refined to accept light discovery filters so the frontend does not need a
* parallel "community discovery" endpoint. Supported query parameters:
* - search partial match on recipe_name (ILIKE)
* - cuisine_id numeric cuisine filter
* - cooking_method_id numeric cooking method filter
* - sort "latest" (default) | "oldest" | "name"
* - limit page size (default 300, max 1000)
* - offset pagination offset (default 0)
*
* Anything beyond this (favourites, client-side reordering, etc.) stays in
* the frontend — see docs/RECIPES_SCOPE.md.
*/
const listCommunityRecipes = async (req, res) => {
try {
const limit = Math.max(1, Math.min(Number(req.query.limit) || 300, 1000));
const { data, error } = await supabase
const offset = Math.max(0, Number(req.query.offset) || 0);
const { search, cuisine_id, cooking_method_id, sort } = req.query;

let query = supabase
.from("recipes")
.select("*")
.eq("visibility", "community")
.eq("is_published", true)
.order("published_at", { ascending: false })
.limit(limit);
.eq("is_published", true);

if (search) {
const safeSearch = String(search).replace(/[%_]/g, (c) => `\\${c}`);
query = query.ilike("recipe_name", `%${safeSearch}%`);
}

if (cuisine_id) {
const cuisineIdNum = Number.parseInt(cuisine_id, 10);
if (!Number.isFinite(cuisineIdNum)) {
return res.status(400).json({ error: "cuisine_id must be numeric", statusCode: 400 });
}
query = query.eq("cuisine_id", cuisineIdNum);
}

if (cooking_method_id) {
const cookingMethodIdNum = Number.parseInt(cooking_method_id, 10);
if (!Number.isFinite(cookingMethodIdNum)) {
return res.status(400).json({ error: "cooking_method_id must be numeric", statusCode: 400 });
}
query = query.eq("cooking_method_id", cookingMethodIdNum);
}

switch (String(sort || "").toLowerCase()) {
case "oldest":
query = query.order("published_at", { ascending: true });
break;
case "name":
query = query.order("recipe_name", { ascending: true });
break;
case "latest":
default:
query = query.order("published_at", { ascending: false });
break;
}

query = query.range(offset, offset + limit - 1);

const { data, error } = await query;
if (error) throw error;

const recipes = await decorateRecipes(data || []);
return res.status(200).json({ message: "success", statusCode: 200, recipes });
return res.status(200).json({
message: "success",
statusCode: 200,
recipes,
pagination: { limit, offset, count: recipes.length },
});
} catch (error) {
console.error("Error loading community recipes:", error);
return res.status(500).json({ error: "Internal server error", statusCode: 500 });
Expand All @@ -369,11 +427,14 @@ const listCommunityRecipes = async (req, res) => {

const shareRecipeToCommunity = async (req, res) => {
const recipeId = Number(req.params.id);
const userId = Number(req.body.user_id || req.body.userId || req.user?.userId);
// Ownership is derived from the authenticated session; we deliberately
// ignore any user_id supplied in the request body so a caller cannot
// submit someone else's recipe for community review.
const userId = Number(req.user?.userId);

try {
if (!recipeId || !userId) {
return res.status(400).json({ error: "Recipe ID and User ID are required", statusCode: 400 });
return res.status(400).json({ error: "Recipe ID and authenticated user are required", statusCode: 400 });
}

const { data: recipe, error: recipeError } = await supabase
Expand Down Expand Up @@ -418,11 +479,13 @@ const shareRecipeToCommunity = async (req, res) => {

const unshareRecipeFromCommunity = async (req, res) => {
const recipeId = Number(req.params.id);
const userId = Number(req.body.user_id || req.body.userId || req.user?.userId);
// Ownership is derived from the authenticated session — see
// shareRecipeToCommunity for rationale.
const userId = Number(req.user?.userId);

try {
if (!recipeId || !userId) {
return res.status(400).json({ error: "Recipe ID and User ID are required", statusCode: 400 });
return res.status(400).json({ error: "Recipe ID and authenticated user are required", statusCode: 400 });
}

const { data: recipe, error: recipeError } = await supabase
Expand Down
Loading
Loading