feat: add cookbooks, multiple categories, and image management
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled

Major features added:
- Cookbook management with CRUD operations
- Auto-filter cookbooks by categories and tags
- Multiple categories per recipe (changed from single category)
- Image upload and URL download for cookbooks
- Improved image management UI

Database changes:
- Changed Recipe.category (string) to Recipe.categories (string array)
- Added Cookbook and CookbookRecipe models
- Added Tag and RecipeTag models for recipe tagging

Backend changes:
- Added cookbooks API routes with image upload
- Added tags API routes
- Added auto-filter functionality to add recipes to cookbooks automatically
- Added downloadAndSaveImage() to StorageService for URL downloads
- Updated recipes routes to support multiple categories

Frontend changes:
- Added Cookbooks page with grid view
- Added CookbookDetail page with filtering
- Added EditCookbook page with image upload/download
- Updated recipe forms to use chip-based UI for multiple categories
- Improved image upload UX with separate file upload and URL download
- Added remove image functionality with immediate save

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 05:19:34 +00:00
parent d6fceccba5
commit 6d6abd7729
26 changed files with 4380 additions and 146 deletions

View File

@@ -20,7 +20,7 @@ model Recipe {
sourceUrl String? // For imported recipes
author String?
cuisine String?
category String?
categories String[] @default([]) // Changed from single category to array
rating Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -30,10 +30,10 @@ model Recipe {
instructions Instruction[]
images RecipeImage[]
tags RecipeTag[]
cookbooks CookbookRecipe[]
@@index([title])
@@index([cuisine])
@@index([category])
}
model RecipeSection {
@@ -127,3 +127,32 @@ model RecipeTag {
@@index([recipeId])
@@index([tagId])
}
model Cookbook {
id String @id @default(cuid())
name String
description String?
coverImageUrl String?
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipes CookbookRecipe[]
@@index([name])
}
model CookbookRecipe {
id String @id @default(cuid())
cookbookId String
recipeId String
addedAt DateTime @default(now())
cookbook Cookbook @relation(fields: [cookbookId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@unique([cookbookId, recipeId])
@@index([cookbookId])
@@index([recipeId])
}

View File

@@ -3,6 +3,8 @@ import cors from 'cors';
import dotenv from 'dotenv';
import path from 'path';
import recipesRoutes from './routes/recipes.routes';
import cookbooksRoutes from './routes/cookbooks.routes';
import tagsRoutes from './routes/tags.routes';
dotenv.config();
@@ -22,6 +24,8 @@ app.use('/uploads', express.static(uploadsPath));
// Routes
app.use('/api/recipes', recipesRoutes);
app.use('/api/cookbooks', cookbooksRoutes);
app.use('/api/tags', tagsRoutes);
// Health check
app.get('/health', (req, res) => {

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import cookbooksRouter from './cookbooks.routes';
import tagsRouter from './tags.routes';
// Mock the database
vi.mock('../config/database', () => ({
default: {
cookbook: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
cookbookRecipe: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
tag: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
describe('Cookbook & Tags - Integration Tests', () => {
let app: express.Application;
beforeEach(() => {
vi.clearAllMocks();
app = express();
app.use(express.json());
app.use('/cookbooks', cookbooksRouter);
app.use('/tags', tagsRouter);
});
describe('Complete Cookbook Workflow', () => {
it('should create a cookbook, add recipes, and retrieve it', async () => {
const prisma = await import('../config/database');
// Step 1: Create a cookbook
const newCookbook = {
name: 'Summer BBQ',
description: 'Perfect recipes for outdoor grilling',
};
const createdCookbook = {
id: 'cb-summer',
...newCookbook,
coverImageUrl: null,
createdAt: new Date('2025-06-01'),
updatedAt: new Date('2025-06-01'),
};
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
const createResponse = await request(app).post('/cookbooks').send(newCookbook).expect(201);
expect(createResponse.body.data.id).toBe('cb-summer');
expect(createResponse.body.data.name).toBe('Summer BBQ');
// Step 2: Add a recipe to the cookbook
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue(null);
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
id: 'cbr1',
cookbookId: 'cb-summer',
recipeId: 'recipe-bbq-ribs',
addedAt: new Date(),
} as any);
const addRecipeResponse = await request(app)
.post('/cookbooks/cb-summer/recipes/recipe-bbq-ribs')
.expect(201);
expect(addRecipeResponse.body.data.cookbookId).toBe('cb-summer');
expect(addRecipeResponse.body.data.recipeId).toBe('recipe-bbq-ribs');
// Step 3: Retrieve the cookbook with its recipes
const cookbookWithRecipes = {
...createdCookbook,
recipes: [
{
recipe: {
id: 'recipe-bbq-ribs',
title: 'BBQ Ribs',
description: 'Tender, fall-off-the-bone ribs',
images: [],
tags: [
{ tag: { id: 't1', name: 'BBQ' } },
{ tag: { id: 't2', name: 'Summer' } },
],
},
},
],
};
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(cookbookWithRecipes as any);
const getResponse = await request(app).get('/cookbooks/cb-summer').expect(200);
expect(getResponse.body.data.name).toBe('Summer BBQ');
expect(getResponse.body.data.recipes).toHaveLength(1);
expect(getResponse.body.data.recipes[0].title).toBe('BBQ Ribs');
expect(getResponse.body.data.recipes[0].tags).toEqual(['BBQ', 'Summer']);
});
it('should prevent adding the same recipe twice to a cookbook', async () => {
const prisma = await import('../config/database');
// First addition succeeds
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
id: 'cbr1',
cookbookId: 'cb1',
recipeId: 'r1',
addedAt: new Date(),
} as any);
await request(app).post('/cookbooks/cb1/recipes/r1').expect(201);
// Second addition fails
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValueOnce({
id: 'cbr1',
cookbookId: 'cb1',
recipeId: 'r1',
addedAt: new Date(),
} as any);
await request(app).post('/cookbooks/cb1/recipes/r1').expect(400);
});
it('should remove a recipe from a cookbook', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookRecipe.delete).mockResolvedValue({} as any);
const response = await request(app).delete('/cookbooks/cb1/recipes/r1').expect(200);
expect(response.body.message).toBe('Recipe removed from cookbook');
});
});
describe('Complete Tags Workflow', () => {
it('should create tags and handle case-insensitive duplicates', async () => {
const prisma = await import('../config/database');
// Create first tag
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.default.tag.create).mockResolvedValue({
id: 't1',
name: 'Italian',
} as any);
const firstResponse = await request(app).post('/tags').send({ name: 'Italian' }).expect(200);
expect(firstResponse.body.data.name).toBe('Italian');
// Try to create duplicate with different case - should return existing
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce({
id: 't1',
name: 'Italian',
} as any);
const duplicateResponse = await request(app).post('/tags').send({ name: 'italian' }).expect(200);
expect(duplicateResponse.body.data.id).toBe('t1');
expect(duplicateResponse.body.data.name).toBe('Italian'); // Original casing preserved
});
it('should list all tags sorted alphabetically', async () => {
const prisma = await import('../config/database');
const mockTags = [
{ id: 't1', name: 'BBQ', _count: { recipes: 5 } },
{ id: 't2', name: 'Dessert', _count: { recipes: 10 } },
{ id: 't3', name: 'Italian', _count: { recipes: 15 } },
];
vi.mocked(prisma.default.tag.findMany).mockResolvedValue(mockTags as any);
const response = await request(app).get('/tags').expect(200);
expect(response.body.data).toHaveLength(3);
expect(response.body.data[0].name).toBe('BBQ');
expect(response.body.data[1].name).toBe('Dessert');
expect(response.body.data[2].name).toBe('Italian');
});
it('should only allow deletion of unused tags', async () => {
const prisma = await import('../config/database');
// Try to delete used tag
vi.mocked(prisma.default.tag.findUnique).mockResolvedValueOnce({
id: 't1',
name: 'Italian',
_count: { recipes: 5 },
} as any);
const usedTagResponse = await request(app).delete('/tags/t1').expect(400);
expect(usedTagResponse.body.error).toContain('used by 5 recipe(s)');
// Delete unused tag
vi.mocked(prisma.default.tag.findUnique).mockResolvedValueOnce({
id: 't2',
name: 'Unused',
_count: { recipes: 0 },
} as any);
vi.mocked(prisma.default.tag.delete).mockResolvedValue({} as any);
const unusedTagResponse = await request(app).delete('/tags/t2').expect(200);
expect(unusedTagResponse.body.message).toBe('Tag deleted successfully');
});
});
describe('Cookbook and Tags Combined Workflow', () => {
it('should create cookbook, create tags, and retrieve cookbook with tagged recipes', async () => {
const prisma = await import('../config/database');
// Create tags
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.default.tag.create).mockResolvedValueOnce({
id: 't1',
name: 'Quick',
} as any);
await request(app).post('/tags').send({ name: 'Quick' }).expect(200);
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.default.tag.create).mockResolvedValueOnce({
id: 't2',
name: 'Healthy',
} as any);
await request(app).post('/tags').send({ name: 'Healthy' }).expect(200);
// Create cookbook
vi.mocked(prisma.default.cookbook.create).mockResolvedValue({
id: 'cb1',
name: 'Weeknight Dinners',
description: 'Quick and healthy meals',
coverImageUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
await request(app)
.post('/cookbooks')
.send({ name: 'Weeknight Dinners', description: 'Quick and healthy meals' })
.expect(201);
// Retrieve cookbook with tagged recipes
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({
id: 'cb1',
name: 'Weeknight Dinners',
description: 'Quick and healthy meals',
coverImageUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
recipes: [
{
recipe: {
id: 'r1',
title: 'Stir Fry',
tags: [
{ tag: { id: 't1', name: 'Quick' } },
{ tag: { id: 't2', name: 'Healthy' } },
],
images: [],
},
},
],
} as any);
const response = await request(app).get('/cookbooks/cb1').expect(200);
expect(response.body.data.recipes[0].tags).toEqual(['Quick', 'Healthy']);
});
});
});

View File

@@ -0,0 +1,270 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import cookbooksRouter from './cookbooks.routes';
// Mock the database
vi.mock('../config/database', () => ({
default: {
cookbook: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
cookbookRecipe: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
describe('Cookbooks Routes - Unit Tests', () => {
let app: express.Application;
beforeEach(() => {
vi.clearAllMocks();
app = express();
app.use(express.json());
app.use('/cookbooks', cookbooksRouter);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('GET /cookbooks', () => {
it('should return all cookbooks with recipe counts', async () => {
const mockCookbooks = [
{
id: 'cb1',
name: 'Family Favorites',
description: 'Our favorite family recipes',
coverImageUrl: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
_count: { recipes: 5 },
},
{
id: 'cb2',
name: 'Holiday Recipes',
description: 'Recipes for holidays',
coverImageUrl: '/uploads/holiday.jpg',
createdAt: new Date('2025-01-02'),
updatedAt: new Date('2025-01-02'),
_count: { recipes: 3 },
},
];
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
const response = await request(app).get('/cookbooks').expect(200);
expect(response.body.data).toHaveLength(2);
expect(response.body.data[0]).toEqual({
id: 'cb1',
name: 'Family Favorites',
description: 'Our favorite family recipes',
coverImageUrl: null,
recipeCount: 5,
createdAt: mockCookbooks[0].createdAt.toISOString(),
updatedAt: mockCookbooks[0].updatedAt.toISOString(),
});
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith({
include: {
_count: {
select: { recipes: true },
},
},
orderBy: { updatedAt: 'desc' },
});
});
it('should handle errors gracefully', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.findMany).mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/cookbooks').expect(500);
expect(response.body.error).toBe('Failed to fetch cookbooks');
});
});
describe('GET /cookbooks/:id', () => {
it('should return a cookbook with its recipes', async () => {
const mockCookbook = {
id: 'cb1',
name: 'Family Favorites',
description: 'Our favorite family recipes',
coverImageUrl: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
recipes: [
{
recipe: {
id: 'r1',
title: 'Pasta Carbonara',
description: 'Classic Italian pasta',
images: [],
tags: [
{ tag: { id: 't1', name: 'Italian' } },
{ tag: { id: 't2', name: 'Pasta' } },
],
},
},
],
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(mockCookbook as any);
const response = await request(app).get('/cookbooks/cb1').expect(200);
expect(response.body.data.id).toBe('cb1');
expect(response.body.data.recipes).toHaveLength(1);
expect(response.body.data.recipes[0].tags).toEqual(['Italian', 'Pasta']);
});
it('should return 404 if cookbook not found', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(null);
const response = await request(app).get('/cookbooks/nonexistent').expect(404);
expect(response.body.error).toBe('Cookbook not found');
});
});
describe('POST /cookbooks', () => {
it('should create a new cookbook', async () => {
const newCookbook = {
name: 'Quick Meals',
description: 'Fast recipes for busy weeknights',
coverImageUrl: '/uploads/quick-meals.jpg',
};
const createdCookbook = {
id: 'cb-new',
...newCookbook,
createdAt: new Date(),
updatedAt: new Date(),
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
const response = await request(app).post('/cookbooks').send(newCookbook).expect(201);
expect(response.body.data.id).toBe('cb-new');
expect(response.body.data.name).toBe('Quick Meals');
expect(prisma.default.cookbook.create).toHaveBeenCalledWith({
data: newCookbook,
});
});
it('should return 400 if name is missing', async () => {
const response = await request(app)
.post('/cookbooks')
.send({ description: 'Missing name' })
.expect(400);
expect(response.body.error).toBe('Name is required');
});
});
describe('PUT /cookbooks/:id', () => {
it('should update a cookbook', async () => {
const updates = {
name: 'Updated Name',
description: 'Updated description',
};
const updatedCookbook = {
id: 'cb1',
...updates,
coverImageUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.update).mockResolvedValue(updatedCookbook as any);
const response = await request(app).put('/cookbooks/cb1').send(updates).expect(200);
expect(response.body.data.name).toBe('Updated Name');
expect(prisma.default.cookbook.update).toHaveBeenCalledWith({
where: { id: 'cb1' },
data: updates,
});
});
});
describe('DELETE /cookbooks/:id', () => {
it('should delete a cookbook', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.delete).mockResolvedValue({} as any);
const response = await request(app).delete('/cookbooks/cb1').expect(200);
expect(response.body.message).toBe('Cookbook deleted successfully');
expect(prisma.default.cookbook.delete).toHaveBeenCalledWith({
where: { id: 'cb1' },
});
});
});
describe('POST /cookbooks/:id/recipes/:recipeId', () => {
it('should add a recipe to a cookbook', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue(null);
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
id: 'cbr1',
cookbookId: 'cb1',
recipeId: 'r1',
addedAt: new Date(),
} as any);
const response = await request(app).post('/cookbooks/cb1/recipes/r1').expect(201);
expect(response.body.data.cookbookId).toBe('cb1');
expect(response.body.data.recipeId).toBe('r1');
});
it('should return 400 if recipe is already in cookbook', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue({
id: 'cbr1',
cookbookId: 'cb1',
recipeId: 'r1',
addedAt: new Date(),
} as any);
const response = await request(app).post('/cookbooks/cb1/recipes/r1').expect(400);
expect(response.body.error).toBe('Recipe already in cookbook');
});
});
describe('DELETE /cookbooks/:id/recipes/:recipeId', () => {
it('should remove a recipe from a cookbook', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookRecipe.delete).mockResolvedValue({} as any);
const response = await request(app).delete('/cookbooks/cb1/recipes/r1').expect(200);
expect(response.body.message).toBe('Recipe removed from cookbook');
expect(prisma.default.cookbookRecipe.delete).toHaveBeenCalledWith({
where: {
cookbookId_recipeId: {
cookbookId: 'cb1',
recipeId: 'r1',
},
},
});
});
});
});

View File

@@ -0,0 +1,370 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import prisma from '../config/database';
import { StorageService } from '../services/storage.service';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 20 * 1024 * 1024, // 20MB limit
},
fileFilter: (req, file, cb) => {
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();
// Helper function to apply cookbook filters to existing recipes
async function applyFiltersToExistingRecipes(cookbookId: string) {
try {
const cookbook = await prisma.cookbook.findUnique({
where: { id: cookbookId }
});
if (!cookbook) return;
// If no filters are set, nothing to do
if (cookbook.autoFilterCategories.length === 0 && cookbook.autoFilterTags.length === 0) {
return;
}
// Build query to find matching recipes
const whereConditions: any[] = [];
if (cookbook.autoFilterCategories.length > 0) {
whereConditions.push({
categories: {
hasSome: cookbook.autoFilterCategories
}
});
}
if (cookbook.autoFilterTags.length > 0) {
whereConditions.push({
tags: {
some: {
tag: {
name: { in: cookbook.autoFilterTags }
}
}
}
});
}
// Find matching recipes
const matchingRecipes = await prisma.recipe.findMany({
where: {
OR: whereConditions
},
select: { id: true }
});
// Add each matching recipe to the cookbook
for (const recipe of matchingRecipes) {
try {
await prisma.cookbookRecipe.create({
data: {
cookbookId: cookbookId,
recipeId: recipe.id
}
});
} catch (error: any) {
// Ignore unique constraint violations (recipe already in cookbook)
if (error.code !== 'P2002') {
console.error(`Error adding recipe ${recipe.id} to cookbook:`, error);
}
}
}
console.log(`Applied filters to cookbook ${cookbook.name}: added ${matchingRecipes.length} recipes`);
} catch (error) {
console.error('Error in applyFiltersToExistingRecipes:', error);
}
}
// Get all cookbooks with recipe count
router.get('/', async (req: Request, res: Response) => {
try {
const cookbooks = await prisma.cookbook.findMany({
include: {
_count: {
select: { recipes: true }
}
},
orderBy: { updatedAt: 'desc' }
});
const response = cookbooks.map(cookbook => ({
id: cookbook.id,
name: cookbook.name,
description: cookbook.description,
coverImageUrl: cookbook.coverImageUrl,
autoFilterCategories: cookbook.autoFilterCategories,
autoFilterTags: cookbook.autoFilterTags,
recipeCount: cookbook._count.recipes,
createdAt: cookbook.createdAt,
updatedAt: cookbook.updatedAt
}));
res.json({ data: response });
} catch (error) {
console.error('Error fetching cookbooks:', error);
res.status(500).json({ error: 'Failed to fetch cookbooks' });
}
});
// Get a single cookbook with all its recipes
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const cookbook = await prisma.cookbook.findUnique({
where: { id },
include: {
recipes: {
include: {
recipe: {
include: {
images: true,
tags: {
include: {
tag: true
}
}
}
}
},
orderBy: { addedAt: 'desc' }
}
}
});
if (!cookbook) {
return res.status(404).json({ error: 'Cookbook not found' });
}
const response = {
id: cookbook.id,
name: cookbook.name,
description: cookbook.description,
coverImageUrl: cookbook.coverImageUrl,
autoFilterCategories: cookbook.autoFilterCategories,
autoFilterTags: cookbook.autoFilterTags,
createdAt: cookbook.createdAt,
updatedAt: cookbook.updatedAt,
recipes: cookbook.recipes.map(cr => ({
...cr.recipe,
tags: cr.recipe.tags.map(rt => rt.tag.name)
}))
};
res.json({ data: response });
} catch (error) {
console.error('Error fetching cookbook:', error);
res.status(500).json({ error: 'Failed to fetch cookbook' });
}
});
// Create a new cookbook
router.post('/', async (req: Request, res: Response) => {
try {
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const cookbook = await prisma.cookbook.create({
data: {
name,
description,
coverImageUrl,
autoFilterCategories: autoFilterCategories || [],
autoFilterTags: autoFilterTags || []
}
});
// Apply filters to existing recipes
await applyFiltersToExistingRecipes(cookbook.id);
res.status(201).json({ data: cookbook });
} catch (error) {
console.error('Error creating cookbook:', error);
res.status(500).json({ error: 'Failed to create cookbook' });
}
});
// Update a cookbook
router.put('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (coverImageUrl !== undefined) updateData.coverImageUrl = coverImageUrl;
if (autoFilterCategories !== undefined) updateData.autoFilterCategories = autoFilterCategories;
if (autoFilterTags !== undefined) updateData.autoFilterTags = autoFilterTags;
const cookbook = await prisma.cookbook.update({
where: { id },
data: updateData
});
// Apply filters to existing recipes if filters were updated
if (autoFilterCategories !== undefined || autoFilterTags !== undefined) {
await applyFiltersToExistingRecipes(id);
}
res.json({ data: cookbook });
} catch (error) {
console.error('Error updating cookbook:', error);
res.status(500).json({ error: 'Failed to update cookbook' });
}
});
// Delete a cookbook
router.delete('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
await prisma.cookbook.delete({
where: { id }
});
res.json({ message: 'Cookbook deleted successfully' });
} catch (error) {
console.error('Error deleting cookbook:', error);
res.status(500).json({ error: 'Failed to delete cookbook' });
}
});
// Add a recipe to a cookbook
router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
try {
const { id, recipeId } = req.params;
// Check if recipe is already in cookbook
const existing = await prisma.cookbookRecipe.findUnique({
where: {
cookbookId_recipeId: {
cookbookId: id,
recipeId
}
}
});
if (existing) {
return res.status(400).json({ error: 'Recipe already in cookbook' });
}
const cookbookRecipe = await prisma.cookbookRecipe.create({
data: {
cookbookId: id,
recipeId
}
});
res.status(201).json({ data: cookbookRecipe });
} catch (error) {
console.error('Error adding recipe to cookbook:', error);
res.status(500).json({ error: 'Failed to add recipe to cookbook' });
}
});
// Remove a recipe from a cookbook
router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
try {
const { id, recipeId } = req.params;
await prisma.cookbookRecipe.delete({
where: {
cookbookId_recipeId: {
cookbookId: id,
recipeId
}
}
});
res.json({ message: 'Recipe removed from cookbook' });
} catch (error) {
console.error('Error removing recipe from cookbook:', error);
res.status(500).json({ error: 'Failed to remove recipe from cookbook' });
}
});
// Upload cookbook cover image
router.post('/:id/image', upload.single('image'), async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!req.file) {
return res.status(400).json({ error: 'No image provided' });
}
// Delete old cover image if it exists
const cookbook = await prisma.cookbook.findUnique({
where: { id }
});
if (cookbook?.coverImageUrl) {
await storageService.deleteFile(cookbook.coverImageUrl);
}
// Save new image
const imageUrl = await storageService.saveFile(req.file, 'cookbooks');
// Update cookbook with new image URL
const updated = await prisma.cookbook.update({
where: { id },
data: { coverImageUrl: imageUrl }
});
res.json({ data: { url: imageUrl }, message: 'Image uploaded successfully' });
} catch (error) {
console.error('Error uploading cookbook image:', error);
res.status(500).json({ error: 'Failed to upload image' });
}
});
// Download and save image from URL
router.post('/:id/image-from-url', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
// Delete old cover image if it exists
const cookbook = await prisma.cookbook.findUnique({
where: { id }
});
if (cookbook?.coverImageUrl) {
await storageService.deleteFile(cookbook.coverImageUrl);
}
// Download and save image from URL
const imageUrl = await storageService.downloadAndSaveImage(url, 'cookbooks');
// Update cookbook with new image URL
await prisma.cookbook.update({
where: { id },
data: { coverImageUrl: imageUrl }
});
res.json({ data: { url: imageUrl }, message: 'Image downloaded and saved successfully' });
} catch (error) {
console.error('Error downloading cookbook image:', error);
res.status(500).json({ error: 'Failed to download image from URL' });
}
});
export default router;

View File

@@ -23,6 +23,82 @@ const upload = multer({
const storageService = StorageService.getInstance();
const scraperService = new ScraperService();
// Helper function to auto-add recipe to cookbooks based on their filters
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 => rt.tag.name);
const recipeCategories = recipe.categories || [];
// Get all cookbooks with auto-filters
const cookbooks = await prisma.cookbook.findMany({
where: {
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 =>
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 =>
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 {
@@ -39,7 +115,11 @@ router.get('/', async (req, res) => {
];
}
if (cuisine) where.cuisine = cuisine;
if (category) where.category = category;
if (category) {
where.categories = {
has: category as string
};
}
const [recipes, total] = await Promise.all([
prisma.recipe.findMany({
@@ -250,6 +330,9 @@ router.post('/', async (req, res) => {
// 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);
@@ -343,6 +426,9 @@ router.put('/:id', async (req, res) => {
// 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);

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import tagsRouter from './tags.routes';
// Mock the database
vi.mock('../config/database', () => ({
default: {
tag: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
describe('Tags Routes - Unit Tests', () => {
let app: express.Application;
beforeEach(() => {
vi.clearAllMocks();
app = express();
app.use(express.json());
app.use('/tags', tagsRouter);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('GET /tags', () => {
it('should return all tags with recipe counts', async () => {
const mockTags = [
{
id: 't1',
name: 'Italian',
_count: { recipes: 12 },
},
{
id: 't2',
name: 'Quick',
_count: { recipes: 8 },
},
{
id: 't3',
name: 'Vegetarian',
_count: { recipes: 15 },
},
];
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findMany).mockResolvedValue(mockTags as any);
const response = await request(app).get('/tags').expect(200);
expect(response.body.data).toHaveLength(3);
expect(response.body.data[0]).toEqual({
id: 't1',
name: 'Italian',
recipeCount: 12,
});
expect(prisma.default.tag.findMany).toHaveBeenCalledWith({
include: {
_count: {
select: { recipes: true },
},
},
orderBy: { name: 'asc' },
});
});
it('should handle errors gracefully', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findMany).mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/tags').expect(500);
expect(response.body.error).toBe('Failed to fetch tags');
});
});
describe('POST /tags', () => {
it('should create a new tag if it does not exist', async () => {
const newTag = { name: 'Dessert' };
const createdTag = {
id: 't-new',
name: 'Dessert',
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findFirst).mockResolvedValue(null);
vi.mocked(prisma.default.tag.create).mockResolvedValue(createdTag as any);
const response = await request(app).post('/tags').send(newTag).expect(200);
expect(response.body.data.id).toBe('t-new');
expect(response.body.data.name).toBe('Dessert');
expect(prisma.default.tag.findFirst).toHaveBeenCalledWith({
where: {
name: {
equals: 'Dessert',
mode: 'insensitive',
},
},
});
expect(prisma.default.tag.create).toHaveBeenCalledWith({
data: { name: 'Dessert' },
});
});
it('should return existing tag if it already exists (case-insensitive)', async () => {
const newTag = { name: 'dessert' };
const existingTag = {
id: 't-existing',
name: 'Dessert',
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findFirst).mockResolvedValue(existingTag as any);
const response = await request(app).post('/tags').send(newTag).expect(200);
expect(response.body.data.id).toBe('t-existing');
expect(response.body.data.name).toBe('Dessert');
expect(prisma.default.tag.create).not.toHaveBeenCalled();
});
it('should return 400 if tag name is missing', async () => {
const response = await request(app).post('/tags').send({}).expect(400);
expect(response.body.error).toBe('Tag name is required');
});
});
describe('DELETE /tags/:id', () => {
it('should delete a tag that is not used by any recipes', async () => {
const mockTag = {
id: 't1',
name: 'Unused',
_count: { recipes: 0 },
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(mockTag as any);
vi.mocked(prisma.default.tag.delete).mockResolvedValue({} as any);
const response = await request(app).delete('/tags/t1').expect(200);
expect(response.body.message).toBe('Tag deleted successfully');
expect(prisma.default.tag.delete).toHaveBeenCalledWith({
where: { id: 't1' },
});
});
it('should return 404 if tag does not exist', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(null);
const response = await request(app).delete('/tags/nonexistent').expect(404);
expect(response.body.error).toBe('Tag not found');
});
it('should return 400 if tag is used by recipes', async () => {
const mockTag = {
id: 't1',
name: 'Italian',
_count: { recipes: 5 },
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(mockTag as any);
const response = await request(app).delete('/tags/t1').expect(400);
expect(response.body.error).toBe('Cannot delete tag "Italian" as it is used by 5 recipe(s)');
expect(prisma.default.tag.delete).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,100 @@
import { Router, Request, Response } from 'express';
import prisma from '../config/database';
const router = Router();
// Get all tags
router.get('/', async (req: Request, res: Response) => {
try {
const tags = await prisma.tag.findMany({
include: {
_count: {
select: { recipes: true }
}
},
orderBy: { name: 'asc' }
});
const response = tags.map(tag => ({
id: tag.id,
name: tag.name,
recipeCount: tag._count.recipes
}));
res.json({ data: response });
} catch (error) {
console.error('Error fetching tags:', error);
res.status(500).json({ error: 'Failed to fetch tags' });
}
});
// Get or create a tag by name
router.post('/', async (req: Request, res: Response) => {
try {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Tag name is required' });
}
// Try to find existing tag (case-insensitive)
let tag = await prisma.tag.findFirst({
where: {
name: {
equals: name,
mode: 'insensitive'
}
}
});
// If not found, create it
if (!tag) {
tag = await prisma.tag.create({
data: { name }
});
}
res.json({ data: tag });
} catch (error) {
console.error('Error creating/finding tag:', error);
res.status(500).json({ error: 'Failed to create/find tag' });
}
});
// Delete a tag (only if not used by any recipe)
router.delete('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// Check if tag is used by any recipes
const tag = await prisma.tag.findUnique({
where: { id },
include: {
_count: {
select: { recipes: true }
}
}
});
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
}
if (tag._count.recipes > 0) {
return res.status(400).json({
error: `Cannot delete tag "${tag.name}" as it is used by ${tag._count.recipes} recipe(s)`
});
}
await prisma.tag.delete({
where: { id }
});
res.json({ message: 'Tag deleted successfully' });
} catch (error) {
console.error('Error deleting tag:', error);
res.status(500).json({ error: 'Failed to delete tag' });
}
});
export default router;

View File

@@ -1,5 +1,6 @@
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
import { storageConfig } from '../config/storage';
export class StorageService {
@@ -63,4 +64,55 @@ export class StorageService {
throw new Error('S3 storage not yet implemented');
}
}
async downloadAndSaveImage(url: string, folder: string = 'images'): Promise<string> {
try {
// Download the image
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
maxContentLength: 20 * 1024 * 1024, // 20MB limit
});
// Get the file extension from content-type or URL
let extension = 'jpg';
const contentType = response.headers['content-type'];
if (contentType) {
const match = contentType.match(/image\/(jpeg|jpg|png|gif|webp)/);
if (match) {
extension = match[1] === 'jpeg' ? 'jpg' : match[1];
}
} else {
// Try to get extension from URL
const urlMatch = url.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
if (urlMatch) {
extension = urlMatch[1].toLowerCase();
}
}
// Create a file object similar to multer's format
const buffer = Buffer.from(response.data);
const filename = `${Date.now()}.${extension}`;
// Save using local storage
if (storageConfig.type === 'local') {
const basePath = storageConfig.localPath || './uploads';
const folderPath = path.join(basePath, folder);
await fs.mkdir(folderPath, { recursive: true });
const filePath = path.join(folderPath, filename);
await fs.writeFile(filePath, buffer);
return `/uploads/${folder}/${filename}`;
} else if (storageConfig.type === 's3') {
// TODO: Implement S3 upload
throw new Error('S3 storage not yet implemented');
}
throw new Error('Invalid storage type');
} catch (error) {
console.error('Error downloading image from URL:', error);
throw new Error('Failed to download image from URL');
}
}
}