Sieht gut aus und geht (noch keine Bildeingabe)

This commit is contained in:
rxf
2025-09-22 09:41:01 +02:00
parent 6f93db4a12
commit 6d04ab93c0
79 changed files with 16233 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
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
app.use(cors({
origin: config.cors.origin,
credentials: true,
}));
// Additional CORS headers for all requests
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:5173');
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);
// 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;

View 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_klaus',
},
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;

View 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 }),
});
};

View 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();
};

View 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 Klaus 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;

View File

@@ -0,0 +1,109 @@
import { Router, Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
import path from 'path';
import fs from 'fs';
const router = Router();
const prisma = new PrismaClient();
// 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(process.cwd(), '../../uploads', 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
res.set({
'Access-Control-Allow-Origin': 'http://localhost:5173',
'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;

View 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;

View File

@@ -0,0 +1,257 @@
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 recipe = await prisma.recipe.findUnique({
where: { id: parseInt(id) },
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;