Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 3m18s
Basil CI/CD Pipeline / Web Tests (push) Successful in 3m31s
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Failing after 3m56s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 3m11s
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Introduces Family as the tenant boundary so recipes and cookbooks can be scoped per household instead of every user seeing everything. Adds a centralized access filter, an invite/membership UI, a first-login prompt to create a family, and locks down the previously unauthenticated backup routes to admin only. - Family and FamilyMember models with OWNER/MEMBER roles; familyId on Recipe and Cookbook (ON DELETE SET NULL so deleting a family orphans content rather than destroying it). - access.service.ts composes a single WhereInput covering owner, family, PUBLIC visibility, and direct share; admins short-circuit to full access. - recipes/cookbooks routes now require auth, strip client-supplied userId/familyId on create, and gate mutations with canMutate checks. Auto-filter helpers scoped to the same family to prevent cross-tenant leakage via shared tag names. - families.routes.ts exposes list/create/get/rename/delete plus add/remove member, with last-owner protection on removal. - FamilyGate component blocks the authenticated UI with a modal if the user has zero memberships, prompting them to create their first family; Family page provides ongoing management. - backup.routes.ts now requires admin; it had no auth at all before. - Bumps version to 2026.04.008 and documents the monotonic PPP counter in CLAUDE.md. Migration SQL is generated locally but not tracked (per existing .gitignore); apply 20260416010000_add_family_tenant to prod during deploy. Run backfill-family-tenant.ts once post-migration to assign existing content to a default owner's family. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
693 lines
20 KiB
TypeScript
693 lines
20 KiB
TypeScript
import { Router } from 'express';
|
|
import multer from 'multer';
|
|
import prisma from '../config/database';
|
|
import { StorageService } from '../services/storage.service';
|
|
import { ScraperService } from '../services/scraper.service';
|
|
import { autoMapIngredients, saveIngredientMappings } from '../services/ingredientMatcher.service';
|
|
import {
|
|
getAccessContext,
|
|
buildRecipeAccessFilter,
|
|
canMutateRecipe,
|
|
getPrimaryFamilyId,
|
|
} from '../services/access.service';
|
|
import { requireAuth } from '../middleware/auth.middleware';
|
|
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
|
|
|
const router = Router();
|
|
router.use(requireAuth);
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: {
|
|
fileSize: 20 * 1024 * 1024, // 20MB limit
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// Accept images only
|
|
if (!file.originalname.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
|
return cb(new Error('Only image files are allowed!'));
|
|
}
|
|
cb(null, true);
|
|
},
|
|
});
|
|
const storageService = StorageService.getInstance();
|
|
const scraperService = new ScraperService();
|
|
|
|
// Helper function to auto-add recipe to cookbooks based on their filters.
|
|
// Scoped to same family to prevent cross-tenant leakage via shared tag names.
|
|
async function autoAddToCookbooks(recipeId: string) {
|
|
try {
|
|
// Get the recipe with its category and tags
|
|
const recipe = await prisma.recipe.findUnique({
|
|
where: { id: recipeId },
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!recipe) return;
|
|
|
|
const recipeTags = recipe.tags.map((rt: any) => rt.tag.name);
|
|
const recipeCategories = recipe.categories || [];
|
|
|
|
// Get cookbooks in the same family with auto-filters. Skip unscoped recipes.
|
|
if (!recipe.familyId) return;
|
|
const cookbooks = await prisma.cookbook.findMany({
|
|
where: {
|
|
familyId: recipe.familyId,
|
|
OR: [
|
|
{ autoFilterCategories: { isEmpty: false } },
|
|
{ autoFilterTags: { isEmpty: false } }
|
|
]
|
|
}
|
|
});
|
|
|
|
// Check each cookbook to see if recipe matches
|
|
for (const cookbook of cookbooks) {
|
|
let shouldAdd = false;
|
|
|
|
// Check if any recipe category matches any of the cookbook's filter categories
|
|
if (cookbook.autoFilterCategories.length > 0 && recipeCategories.length > 0) {
|
|
const hasMatchingCategory = recipeCategories.some((cat: any) =>
|
|
cookbook.autoFilterCategories.includes(cat)
|
|
);
|
|
if (hasMatchingCategory) {
|
|
shouldAdd = true;
|
|
}
|
|
}
|
|
|
|
// Check if recipe has any of the cookbook's filter tags
|
|
if (cookbook.autoFilterTags.length > 0 && recipeTags.length > 0) {
|
|
const hasMatchingTag = cookbook.autoFilterTags.some((filterTag: any) =>
|
|
recipeTags.includes(filterTag)
|
|
);
|
|
if (hasMatchingTag) {
|
|
shouldAdd = true;
|
|
}
|
|
}
|
|
|
|
// Add recipe to cookbook if it matches and isn't already added
|
|
if (shouldAdd) {
|
|
try {
|
|
await prisma.cookbookRecipe.create({
|
|
data: {
|
|
cookbookId: cookbook.id,
|
|
recipeId: recipeId
|
|
}
|
|
});
|
|
} catch (error: any) {
|
|
// Ignore unique constraint violations (recipe already in cookbook)
|
|
if (error.code !== 'P2002') {
|
|
console.error(`Error auto-adding recipe to cookbook ${cookbook.name}:`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in autoAddToCookbooks:', error);
|
|
}
|
|
}
|
|
|
|
// Get all recipes
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const { page = '1', limit = '20', search, cuisine, category, tag } = req.query;
|
|
const pageNum = parseInt(page as string);
|
|
const limitNum = parseInt(limit as string);
|
|
const skip = (pageNum - 1) * limitNum;
|
|
|
|
const ctx = await getAccessContext(req.user!);
|
|
const where: any = { AND: [buildRecipeAccessFilter(ctx)] };
|
|
if (search) {
|
|
where.AND.push({
|
|
OR: [
|
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
|
{ description: { contains: search as string, mode: 'insensitive' } },
|
|
{
|
|
tags: {
|
|
some: {
|
|
tag: {
|
|
name: { contains: search as string, mode: 'insensitive' }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (cuisine) where.AND.push({ cuisine });
|
|
if (category) where.AND.push({ categories: { has: category as string } });
|
|
if (tag) {
|
|
where.AND.push({
|
|
tags: {
|
|
some: {
|
|
tag: { name: { equals: tag as string, mode: 'insensitive' } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const [recipes, total] = await Promise.all([
|
|
prisma.recipe.findMany({
|
|
where,
|
|
skip,
|
|
take: limitNum,
|
|
include: {
|
|
sections: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
instructions: {
|
|
include: {
|
|
instruction: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
instructions: {
|
|
orderBy: { step: 'asc' },
|
|
include: {
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ingredient: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
instructions: {
|
|
include: {
|
|
instruction: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
instructions: {
|
|
orderBy: { step: 'asc' },
|
|
include: {
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ingredient: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
images: { orderBy: { order: 'asc' } },
|
|
tags: { include: { tag: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
prisma.recipe.count({ where }),
|
|
]);
|
|
|
|
res.json({
|
|
data: recipes,
|
|
total,
|
|
page: pageNum,
|
|
pageSize: limitNum,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching recipes:', error);
|
|
res.status(500).json({ error: 'Failed to fetch recipes' });
|
|
}
|
|
});
|
|
|
|
// Get single recipe
|
|
router.get('/:id', async (req, res) => {
|
|
try {
|
|
const ctx = await getAccessContext(req.user!);
|
|
const recipe = await prisma.recipe.findFirst({
|
|
where: { AND: [{ id: req.params.id }, buildRecipeAccessFilter(ctx)] },
|
|
include: {
|
|
sections: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
instructions: {
|
|
include: {
|
|
instruction: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
instructions: {
|
|
orderBy: { step: 'asc' },
|
|
include: {
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ingredient: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
instructions: {
|
|
include: {
|
|
instruction: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
instructions: {
|
|
orderBy: { step: 'asc' },
|
|
include: {
|
|
ingredients: {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ingredient: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
images: { orderBy: { order: 'asc' } },
|
|
tags: { include: { tag: true } },
|
|
},
|
|
});
|
|
|
|
if (!recipe) {
|
|
return res.status(404).json({ error: 'Recipe not found' });
|
|
}
|
|
|
|
res.json({ data: recipe });
|
|
} catch (error) {
|
|
console.error('Error fetching recipe:', error);
|
|
res.status(500).json({ error: 'Failed to fetch recipe' });
|
|
}
|
|
});
|
|
|
|
// Create recipe
|
|
router.post('/', async (req, res) => {
|
|
try {
|
|
const { title, description, sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
|
// Strip any client-supplied ownership — always derive server-side.
|
|
delete recipeData.userId;
|
|
delete recipeData.familyId;
|
|
const familyId = await getPrimaryFamilyId(req.user!.id);
|
|
|
|
const recipe = await prisma.recipe.create({
|
|
data: {
|
|
title,
|
|
description,
|
|
userId: req.user!.id,
|
|
familyId,
|
|
...recipeData,
|
|
sections: sections
|
|
? {
|
|
create: sections.map((section: any) => ({
|
|
name: section.name,
|
|
order: section.order,
|
|
timing: section.timing,
|
|
ingredients: {
|
|
create: section.ingredients?.map((ing: any, index: number) => ({
|
|
...ing,
|
|
order: ing.order ?? index,
|
|
})),
|
|
},
|
|
instructions: {
|
|
create: section.instructions?.map((inst: any) => inst),
|
|
},
|
|
})),
|
|
}
|
|
: undefined,
|
|
ingredients: {
|
|
create: ingredients?.map((ing: any, index: number) => ({
|
|
...ing,
|
|
order: ing.order ?? index,
|
|
})),
|
|
},
|
|
instructions: {
|
|
create: instructions?.map((inst: any) => inst),
|
|
},
|
|
tags: tags
|
|
? {
|
|
create: tags.map((tagName: string) => ({
|
|
tag: {
|
|
connectOrCreate: {
|
|
where: { name: tagName },
|
|
create: { name: tagName },
|
|
},
|
|
},
|
|
})),
|
|
}
|
|
: undefined,
|
|
},
|
|
include: {
|
|
sections: {
|
|
include: {
|
|
ingredients: true,
|
|
instructions: true,
|
|
},
|
|
},
|
|
ingredients: true,
|
|
instructions: true,
|
|
images: true,
|
|
tags: { include: { tag: true } },
|
|
},
|
|
});
|
|
|
|
// Automatically generate ingredient-instruction mappings
|
|
await autoMapIngredients(recipe.id);
|
|
|
|
// Auto-add to cookbooks based on filters
|
|
await autoAddToCookbooks(recipe.id);
|
|
|
|
res.status(201).json({ data: recipe });
|
|
} catch (error) {
|
|
console.error('Error creating recipe:', error);
|
|
res.status(500).json({ error: 'Failed to create recipe' });
|
|
}
|
|
});
|
|
|
|
// Update recipe
|
|
router.put('/:id', async (req, res) => {
|
|
try {
|
|
const ctx = await getAccessContext(req.user!);
|
|
const existing = await prisma.recipe.findUnique({
|
|
where: { id: req.params.id },
|
|
select: { userId: true, familyId: true, visibility: true },
|
|
});
|
|
if (!existing) return res.status(404).json({ error: 'Recipe not found' });
|
|
if (!canMutateRecipe(ctx, existing)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
const { sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
|
// Block client from reassigning ownership via update.
|
|
delete recipeData.userId;
|
|
delete recipeData.familyId;
|
|
|
|
// Only delete relations that are being updated (not undefined)
|
|
if (sections !== undefined) {
|
|
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
|
|
}
|
|
if (ingredients !== undefined) {
|
|
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
|
|
}
|
|
if (instructions !== undefined) {
|
|
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
|
|
}
|
|
if (tags !== undefined) {
|
|
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
|
|
}
|
|
|
|
// Helper to clean IDs from nested data
|
|
const cleanIngredient = (ing: any, index: number) => ({
|
|
name: ing.name,
|
|
amount: ing.amount,
|
|
unit: ing.unit,
|
|
notes: ing.notes,
|
|
order: ing.order ?? index,
|
|
});
|
|
|
|
const cleanInstruction = (inst: any) => ({
|
|
step: inst.step,
|
|
text: inst.text,
|
|
imageUrl: inst.imageUrl,
|
|
timing: inst.timing,
|
|
});
|
|
|
|
const recipe = await prisma.recipe.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
...recipeData,
|
|
sections: sections
|
|
? {
|
|
create: sections.map((section: any) => ({
|
|
name: section.name,
|
|
order: section.order,
|
|
timing: section.timing,
|
|
ingredients: {
|
|
create: section.ingredients?.map(cleanIngredient) || [],
|
|
},
|
|
instructions: {
|
|
create: section.instructions?.map(cleanInstruction) || [],
|
|
},
|
|
})),
|
|
}
|
|
: undefined,
|
|
ingredients: ingredients
|
|
? {
|
|
create: ingredients.map(cleanIngredient),
|
|
}
|
|
: undefined,
|
|
instructions: instructions
|
|
? {
|
|
create: instructions.map(cleanInstruction),
|
|
}
|
|
: undefined,
|
|
tags: tags
|
|
? {
|
|
create: tags.map((tagName: string) => ({
|
|
tag: {
|
|
connectOrCreate: {
|
|
where: { name: tagName },
|
|
create: { name: tagName },
|
|
},
|
|
},
|
|
})),
|
|
}
|
|
: undefined,
|
|
},
|
|
include: {
|
|
sections: {
|
|
include: {
|
|
ingredients: true,
|
|
instructions: true,
|
|
},
|
|
},
|
|
ingredients: true,
|
|
instructions: true,
|
|
images: true,
|
|
tags: { include: { tag: true } },
|
|
},
|
|
});
|
|
|
|
// Regenerate ingredient-instruction mappings
|
|
await autoMapIngredients(req.params.id);
|
|
|
|
// Auto-add to cookbooks based on filters
|
|
await autoAddToCookbooks(req.params.id);
|
|
|
|
res.json({ data: recipe });
|
|
} catch (error) {
|
|
console.error('Error updating recipe:', error);
|
|
res.status(500).json({ error: 'Failed to update recipe' });
|
|
}
|
|
});
|
|
|
|
// Delete recipe
|
|
router.delete('/:id', async (req, res) => {
|
|
try {
|
|
const ctx = await getAccessContext(req.user!);
|
|
// Get recipe to delete associated images
|
|
const recipe = await prisma.recipe.findUnique({
|
|
where: { id: req.params.id },
|
|
include: { images: true },
|
|
});
|
|
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
|
if (!canMutateRecipe(ctx, recipe)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
// Delete images from storage
|
|
if (recipe.imageUrl) {
|
|
await storageService.deleteFile(recipe.imageUrl);
|
|
}
|
|
for (const image of recipe.images) {
|
|
await storageService.deleteFile(image.url);
|
|
}
|
|
|
|
await prisma.recipe.delete({ where: { id: req.params.id } });
|
|
|
|
res.json({ message: 'Recipe deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Error deleting recipe:', error);
|
|
res.status(500).json({ error: 'Failed to delete recipe' });
|
|
}
|
|
});
|
|
|
|
// Upload image
|
|
router.post('/:id/images', upload.single('image'), async (req, res) => {
|
|
try {
|
|
console.log('Image upload request received for recipe:', req.params.id);
|
|
console.log('File info:', req.file ? {
|
|
originalname: req.file.originalname,
|
|
mimetype: req.file.mimetype,
|
|
size: req.file.size,
|
|
} : 'No file');
|
|
|
|
if (!req.file) {
|
|
console.error('No file in request');
|
|
return res.status(400).json({ error: 'No image provided' });
|
|
}
|
|
|
|
const ctx = await getAccessContext(req.user!);
|
|
const existingRecipe = await prisma.recipe.findUnique({
|
|
where: { id: req.params.id },
|
|
select: { imageUrl: true, userId: true, familyId: true, visibility: true },
|
|
});
|
|
if (!existingRecipe) return res.status(404).json({ error: 'Recipe not found' });
|
|
if (!canMutateRecipe(ctx, existingRecipe)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
console.log('Saving file to storage...');
|
|
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
|
console.log('File saved, URL:', imageUrl);
|
|
|
|
// Delete old image from storage if it exists
|
|
if (existingRecipe?.imageUrl) {
|
|
console.log('Deleting old image:', existingRecipe.imageUrl);
|
|
await storageService.deleteFile(existingRecipe.imageUrl);
|
|
}
|
|
|
|
console.log('Updating database...');
|
|
// Add to recipe images and update main imageUrl
|
|
const [image, recipe] = await Promise.all([
|
|
prisma.recipeImage.create({
|
|
data: {
|
|
recipeId: req.params.id,
|
|
url: imageUrl,
|
|
order: 0,
|
|
},
|
|
}),
|
|
prisma.recipe.update({
|
|
where: { id: req.params.id },
|
|
data: { imageUrl },
|
|
}),
|
|
]);
|
|
|
|
console.log('Image upload successful');
|
|
res.json({ data: { image, imageUrl } });
|
|
} catch (error) {
|
|
console.error('Error uploading image:', error);
|
|
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to upload image';
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// Delete recipe image
|
|
router.delete('/:id/image', async (req, res) => {
|
|
try {
|
|
const ctx = await getAccessContext(req.user!);
|
|
const recipe = await prisma.recipe.findUnique({
|
|
where: { id: req.params.id },
|
|
select: { imageUrl: true, userId: true, familyId: true, visibility: true },
|
|
});
|
|
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
|
if (!canMutateRecipe(ctx, recipe)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
if (!recipe.imageUrl) {
|
|
return res.status(404).json({ error: 'No image to delete' });
|
|
}
|
|
|
|
// Delete image from storage
|
|
await storageService.deleteFile(recipe.imageUrl);
|
|
|
|
// Update recipe to remove imageUrl
|
|
await prisma.recipe.update({
|
|
where: { id: req.params.id },
|
|
data: { imageUrl: null },
|
|
});
|
|
|
|
res.json({ message: 'Image deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Error deleting image:', error);
|
|
res.status(500).json({ error: 'Failed to delete image' });
|
|
}
|
|
});
|
|
|
|
// Import recipe from URL
|
|
router.post('/import', async (req, res) => {
|
|
try {
|
|
const { url }: RecipeImportRequest = req.body;
|
|
|
|
if (!url) {
|
|
return res.status(400).json({ error: 'URL is required' });
|
|
}
|
|
|
|
const result = await scraperService.scrapeRecipe(url);
|
|
|
|
if (!result.success) {
|
|
return res.status(400).json({ error: result.error });
|
|
}
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('Error importing recipe:', error);
|
|
res.status(500).json({ error: 'Failed to import recipe' });
|
|
}
|
|
});
|
|
|
|
// Update ingredient-instruction mappings
|
|
router.post('/:id/ingredient-mappings', async (req, res) => {
|
|
try {
|
|
const { mappings } = req.body;
|
|
|
|
if (!Array.isArray(mappings)) {
|
|
return res.status(400).json({ error: 'Mappings must be an array' });
|
|
}
|
|
|
|
const ctx = await getAccessContext(req.user!);
|
|
const recipe = await prisma.recipe.findUnique({
|
|
where: { id: req.params.id },
|
|
select: { userId: true, familyId: true, visibility: true },
|
|
});
|
|
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
|
if (!canMutateRecipe(ctx, recipe)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
await saveIngredientMappings(mappings);
|
|
|
|
res.json({ message: 'Mappings updated successfully' });
|
|
} catch (error) {
|
|
console.error('Error updating ingredient mappings:', error);
|
|
res.status(500).json({ error: 'Failed to update ingredient mappings' });
|
|
}
|
|
});
|
|
|
|
// Regenerate ingredient-instruction mappings
|
|
router.post('/:id/regenerate-mappings', async (req, res) => {
|
|
try {
|
|
const ctx = await getAccessContext(req.user!);
|
|
const recipe = await prisma.recipe.findUnique({
|
|
where: { id: req.params.id },
|
|
select: { userId: true, familyId: true, visibility: true },
|
|
});
|
|
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
|
if (!canMutateRecipe(ctx, recipe)) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
|
|
await autoMapIngredients(req.params.id);
|
|
|
|
res.json({ message: 'Mappings regenerated successfully' });
|
|
} catch (error) {
|
|
console.error('Error regenerating ingredient mappings:', error);
|
|
res.status(500).json({ error: 'Failed to regenerate ingredient mappings' });
|
|
}
|
|
});
|
|
|
|
export default router;
|