Datei-Struktur ordentlich bereinigt
This commit is contained in:
139
backend/src/app.ts
Normal file
139
backend/src/app.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'path';
|
||||
import { config } from './config/config';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { requestLogger } from './middleware/requestLogger';
|
||||
|
||||
// Route imports
|
||||
import recipeRoutes from './routes/recipes';
|
||||
import ingredientRoutes from './routes/ingredients';
|
||||
import imageRoutes from './routes/images';
|
||||
import healthRoutes from './routes/health';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
}));
|
||||
app.use(compression());
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// CORS configuration - Allow both development and production origins
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3000', // Docker frontend
|
||||
config.cors.origin // Environment configured origin
|
||||
].filter(Boolean);
|
||||
|
||||
// Add local network origins if CORS_ORIGIN is "*" (for local network access)
|
||||
const corsConfig = config.cors.origin === '*'
|
||||
? {
|
||||
origin: true, // Allow all origins for local network
|
||||
credentials: true,
|
||||
}
|
||||
: {
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
};
|
||||
|
||||
app.use(cors(corsConfig));
|
||||
|
||||
// Additional CORS headers for all requests
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (config.cors.origin === '*') {
|
||||
// Allow all origins for local network access
|
||||
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||
} else if (origin && allowedOrigins.includes(origin)) {
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Request logging
|
||||
app.use(requestLogger);
|
||||
|
||||
// Static file serving for uploads
|
||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||
|
||||
// API routes
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/recipes', recipeRoutes);
|
||||
app.use('/api/ingredients', ingredientRoutes);
|
||||
app.use('/api/images', imageRoutes);
|
||||
|
||||
// Direct image serving (for convenience)
|
||||
app.get('/serve/*', (req, res, next) => {
|
||||
const imagePath = (req.params as any)[0]; // Get everything after /serve/
|
||||
// Remove leading 'uploads/' if present to avoid duplication
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path.join(process.cwd(), '../../uploads', cleanPath);
|
||||
|
||||
console.log(`Direct serve request: ${req.originalUrl} -> ${fullPath}`);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
requestedPath: req.originalUrl,
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
|
||||
// Set headers for images
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
});
|
||||
|
||||
res.sendFile(path.resolve(fullPath));
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: `Route ${req.originalUrl} not found`,
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
const PORT = config.port;
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📱 Health check: http://localhost:${PORT}/api/health`);
|
||||
console.log(`🎯 API Documentation: http://localhost:${PORT}/api`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
27
backend/src/config/config.ts
Normal file
27
backend/src/config/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: process.env.PORT || 3001,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key',
|
||||
expiresIn: '24h',
|
||||
},
|
||||
|
||||
upload: {
|
||||
path: process.env.UPLOAD_PATH || './uploads',
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '5242880'), // 5MB default
|
||||
allowedTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'],
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
},
|
||||
} as const;
|
||||
33
backend/src/middleware/errorHandler.ts
Normal file
33
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export interface ErrorWithStatus extends Error {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export const errorHandler = (
|
||||
err: ErrorWithStatus,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
|
||||
console.error('Error:', {
|
||||
status,
|
||||
message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? (status === 500 ? 'Internal Server Error' : message)
|
||||
: message,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||
});
|
||||
};
|
||||
15
backend/src/middleware/requestLogger.ts
Normal file
15
backend/src/middleware/requestLogger.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const requestLogger = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const { method, url, ip } = req;
|
||||
const { statusCode } = res;
|
||||
|
||||
console.log(`${method} ${url} - ${statusCode} - ${duration}ms - ${ip}`);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
33
backend/src/routes/health.ts
Normal file
33
backend/src/routes/health.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Health check endpoint
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rezepte API is running!',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
});
|
||||
|
||||
// Database health check
|
||||
router.get('/db', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// TODO: Add database connectivity check with Prisma
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Database connection is healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Database connection failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
290
backend/src/routes/images.ts
Normal file
290
backend/src/routes/images.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { config } from '../config/config';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Utility function to get correct uploads directory path
|
||||
const getUploadsDir = (subPath?: string): string => {
|
||||
// In Docker or when uploads directory exists in current directory, use local uploads
|
||||
const localUploadsDir = path.join(process.cwd(), 'uploads');
|
||||
const legacyUploadsDir = path.join(process.cwd(), '../../uploads');
|
||||
|
||||
const baseDir = fs.existsSync(localUploadsDir)
|
||||
? localUploadsDir
|
||||
: legacyUploadsDir;
|
||||
|
||||
return subPath ? path.join(baseDir, subPath) : baseDir;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const recipeNumber = req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
|
||||
// Get existing files count to determine next index
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
const existingFiles = fs.existsSync(uploadDir)
|
||||
? fs.readdirSync(uploadDir).filter(f => f.match(new RegExp(`^${recipeNumber}_\\d+\\.jpg$`)))
|
||||
: [];
|
||||
|
||||
const nextIndex = existingFiles.length;
|
||||
const filename = `${recipeNumber}_${nextIndex}.jpg`;
|
||||
|
||||
cb(null, filename);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: config.upload.maxFileSize, // 5MB
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG and WebP are allowed.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Upload images for a recipe
|
||||
router.post('/upload/:recipeId', upload.array('images', 10), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!recipeId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No files uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Get recipe to validate it exists and get recipe number
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: parseInt(recipeId) }
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Recipe not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Create database entries for uploaded images
|
||||
const imagePromises = files.map(file => {
|
||||
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||
return prisma.recipeImage.create({
|
||||
data: {
|
||||
recipeId: parseInt(recipeId),
|
||||
filePath: relativePath,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const images = await Promise.all(imagePromises);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: images,
|
||||
message: `${files.length} images uploaded successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up uploaded files if database operation fails
|
||||
if (req.files) {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
files.forEach(file => {
|
||||
if (fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete an image
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete file from filesystem
|
||||
const fullPath = path.join(process.cwd(), '../..', image.filePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await prisma.recipeImage.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all images for a recipe by recipe ID
|
||||
router.get('/recipe/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
|
||||
if (!recipeId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const images = await prisma.recipeImage.findMany({
|
||||
where: { recipeId: parseInt(recipeId) },
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: images,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve image file
|
||||
router.get('/serve/:imagePath(*)', (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const imagePath = req.params.imagePath;
|
||||
|
||||
if (!imagePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image path is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove leading 'uploads/' if present to avoid duplication
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path.join(getUploadsDir(), cleanPath);
|
||||
|
||||
console.log(`Serving image: ${imagePath} -> ${fullPath}`);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(`Image not found: ${fullPath}`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
requestedPath: imagePath,
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
|
||||
// Set CORS headers for images - support multiple origins including local network
|
||||
const allowedOrigins = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
const origin = req.headers.origin;
|
||||
|
||||
// Check if CORS_ORIGIN is set to "*" for local network access
|
||||
const corsOrigin = process.env.CORS_ORIGIN === '*'
|
||||
? (origin || '*')
|
||||
: (origin && allowedOrigins.includes(origin)) ? origin : 'http://localhost:3000';
|
||||
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': corsOrigin,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
|
||||
});
|
||||
|
||||
return res.sendFile(path.resolve(fullPath));
|
||||
} catch (error) {
|
||||
console.error('Error serving image:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get image metadata
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
191
backend/src/routes/ingredients.ts
Normal file
191
backend/src/routes/ingredients.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Validation schemas
|
||||
const ingredientSchema = Joi.object({
|
||||
recipeNumber: Joi.string().required().max(20),
|
||||
ingredients: Joi.string().required(),
|
||||
});
|
||||
|
||||
const updateIngredientSchema = ingredientSchema.fork(['recipeNumber'], (schema) => schema.optional());
|
||||
|
||||
// Get all ingredients with search and pagination
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const {
|
||||
search = '',
|
||||
category = '',
|
||||
page = '1',
|
||||
limit = '10',
|
||||
sortBy = 'recipeNumber',
|
||||
sortOrder = 'asc'
|
||||
} = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = parseInt(limit as string);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ recipeNumber: { contains: search as string } },
|
||||
{ ingredients: { contains: search as string } },
|
||||
];
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.recipeNumber = { contains: category as string };
|
||||
}
|
||||
|
||||
const [ingredients, total] = await Promise.all([
|
||||
prisma.ingredient.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy as string]: sortOrder as 'asc' | 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
}),
|
||||
prisma.ingredient.count({ where })
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: ingredients,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
pages: Math.ceil(total / limitNum),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single ingredient by ID
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ingredient ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (!ingredient) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Ingredient not found',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: ingredient,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create new ingredient
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { error, value } = ingredientSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.create({
|
||||
data: value
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: ingredient,
|
||||
message: 'Ingredient created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update ingredient
|
||||
router.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ingredient ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const { error, value } = updateIngredientSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
const ingredient = await prisma.ingredient.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: value
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: ingredient,
|
||||
message: 'Ingredient updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete ingredient
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ingredient ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.ingredient.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Ingredient deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
265
backend/src/routes/recipes.ts
Normal file
265
backend/src/routes/recipes.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Validation schemas
|
||||
const recipeSchema = Joi.object({
|
||||
recipeNumber: Joi.string().optional().allow(''),
|
||||
title: Joi.string().required().min(1).max(255),
|
||||
description: Joi.string().optional().allow(''),
|
||||
category: Joi.string().optional().allow(''),
|
||||
preparation: Joi.string().optional().allow(''),
|
||||
servings: Joi.number().integer().min(1).default(1),
|
||||
ingredients: Joi.string().optional().allow(''),
|
||||
instructions: Joi.string().optional().allow(''),
|
||||
comment: Joi.string().optional().allow(''),
|
||||
});
|
||||
|
||||
const updateRecipeSchema = recipeSchema;
|
||||
|
||||
// Get all recipes with search and pagination
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const {
|
||||
search = '',
|
||||
category = '',
|
||||
page = '1',
|
||||
limit = '10',
|
||||
sortBy = 'title',
|
||||
sortOrder = 'asc'
|
||||
} = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = parseInt(limit as string);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search as string } },
|
||||
{ description: { contains: search as string } },
|
||||
{ ingredients: { contains: search as string } },
|
||||
];
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = { contains: category as string };
|
||||
}
|
||||
|
||||
const [recipes, total] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
where,
|
||||
include: {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
},
|
||||
orderBy: { [sortBy as string]: sortOrder as 'asc' | 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
}),
|
||||
prisma.recipe.count({ where })
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: recipes,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
pages: Math.ceil(total / limitNum),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single recipe by ID
|
||||
// Get recipe by ID
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const recipeId = parseInt(id);
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid recipe ID format',
|
||||
});
|
||||
}
|
||||
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: recipeId },
|
||||
include: {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Recipe not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Try to load ingredients from separate ingredients table if not already loaded
|
||||
if ((!recipe.ingredients || recipe.ingredients.trim() === '' || recipe.ingredients.length < 10) && recipe.recipeNumber) {
|
||||
try {
|
||||
// Try with "R" prefix (e.g., "30" -> "R030")
|
||||
const paddedNumber = recipe.recipeNumber.padStart(3, '0');
|
||||
const recipeNumberWithR = `R${paddedNumber}`;
|
||||
|
||||
const separateIngredients = await prisma.ingredient.findFirst({
|
||||
where: { recipeNumber: recipeNumberWithR }
|
||||
});
|
||||
|
||||
if (separateIngredients && separateIngredients.ingredients) {
|
||||
// Update the recipe object with the found ingredients
|
||||
(recipe as any).ingredients = separateIngredients.ingredients;
|
||||
}
|
||||
} catch (ingredientError) {
|
||||
console.log(`Could not load separate ingredients for recipe ${recipe.recipeNumber}:`, ingredientError);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create new recipe
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { error, value } = recipeSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate recipeNumber if not provided
|
||||
if (!value.recipeNumber || value.recipeNumber.trim() === '') {
|
||||
// Find the highest existing recipe number
|
||||
const lastRecipe = await prisma.recipe.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { recipeNumber: true }
|
||||
});
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastRecipe?.recipeNumber) {
|
||||
// Extract number from recipeNumber like "R030" -> 30
|
||||
const match = lastRecipe.recipeNumber.match(/\d+/);
|
||||
if (match) {
|
||||
nextNumber = parseInt(match[0]) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Format as R### (e.g., R001, R032)
|
||||
value.recipeNumber = `R${nextNumber.toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: value,
|
||||
include: {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
message: 'Recipe created successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update recipe
|
||||
router.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const { error, value } = updateRecipeSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
const recipe = await prisma.recipe.update({
|
||||
where: { id: parseInt(id) },
|
||||
data: value,
|
||||
include: {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
message: 'Recipe updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete recipe
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.recipe.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Recipe deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user