Files
basil/packages/api/src/routes/recipes.routes.ts
Paul R Kartchner c3e3d66fef
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
feat: add family-based multi-tenant access control
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>
2026-04-17 08:08:10 -06:00

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;