Datei-Struktur ordentlich bereinigt
This commit is contained in:
12
backend/.env
Normal file
12
backend/.env
Normal file
@@ -0,0 +1,12 @@
|
||||
# Database
|
||||
DATABASE_URL="mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# Prisma
|
||||
# DATABASE_URL="file:./dev.db"
|
||||
16
backend/.env.example
Normal file
16
backend/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Environment variables
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Database
|
||||
DATABASE_URL="mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte"
|
||||
|
||||
# JWT Secret (change in production!)
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
|
||||
# Upload settings
|
||||
UPLOAD_PATH=./uploads
|
||||
MAX_FILE_SIZE=5242880
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
89
backend/Dockerfile
Normal file
89
backend/Dockerfile
Normal file
@@ -0,0 +1,89 @@
|
||||
# Backend Dockerfile
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Install OpenSSL for Prisma compatibility
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies for build)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
# Install required system dependencies for Prisma and health checks
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
libc6-compat \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Create app user
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S backend -u 1001
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copy prisma schema for runtime
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads legacy-uploads && chown -R backend:nodejs uploads legacy-uploads
|
||||
|
||||
# Create migration script for legacy uploads (via volumes)
|
||||
COPY <<EOF ./migrate-uploads.sh
|
||||
#!/bin/sh
|
||||
# This will be handled via volume mounts in docker-compose
|
||||
# The legacy upload/ directory will be mounted to /app/legacy-uploads
|
||||
if [ -d "/app/legacy-uploads" ] && [ "$(ls -A /app/legacy-uploads)" ]; then
|
||||
echo "Migrating legacy uploads from volume..."
|
||||
cp -r /app/legacy-uploads/* /app/uploads/ 2>/dev/null || true
|
||||
chown -R backend:nodejs /app/uploads
|
||||
echo "Upload migration completed."
|
||||
else
|
||||
echo "No legacy uploads found to migrate."
|
||||
fi
|
||||
EOF
|
||||
|
||||
RUN chmod +x ./migrate-uploads.sh
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Switch to non-root user
|
||||
USER backend
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3001/api/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["sh", "-c", "./migrate-uploads.sh && node dist/app.js"]
|
||||
3
backend/dist/app.d.ts
vendored
Normal file
3
backend/dist/app.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const app: import("express-serve-static-core").Express;
|
||||
export default app;
|
||||
//# sourceMappingURL=app.d.ts.map
|
||||
1
backend/dist/app.d.ts.map
vendored
Normal file
1
backend/dist/app.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAgBA,QAAA,MAAM,GAAG,6CAAY,CAAC;AA2GtB,eAAe,GAAG,CAAC"}
|
||||
94
backend/dist/app.js
vendored
Normal file
94
backend/dist/app.js
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const helmet_1 = __importDefault(require("helmet"));
|
||||
const compression_1 = __importDefault(require("compression"));
|
||||
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const config_1 = require("./config/config");
|
||||
const errorHandler_1 = require("./middleware/errorHandler");
|
||||
const requestLogger_1 = require("./middleware/requestLogger");
|
||||
const recipes_1 = __importDefault(require("./routes/recipes"));
|
||||
const ingredients_1 = __importDefault(require("./routes/ingredients"));
|
||||
const images_1 = __importDefault(require("./routes/images"));
|
||||
const health_1 = __importDefault(require("./routes/health"));
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, helmet_1.default)({
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
}));
|
||||
app.use((0, compression_1.default)());
|
||||
const limiter = (0, express_rate_limit_1.default)({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use(limiter);
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3000',
|
||||
config_1.config.cors.origin
|
||||
].filter(Boolean);
|
||||
app.use((0, cors_1.default)({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
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();
|
||||
});
|
||||
app.use(express_1.default.json({ limit: '10mb' }));
|
||||
app.use(express_1.default.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(requestLogger_1.requestLogger);
|
||||
app.use('/api/health', health_1.default);
|
||||
app.use('/api/recipes', recipes_1.default);
|
||||
app.use('/api/ingredients', ingredients_1.default);
|
||||
app.use('/api/images', images_1.default);
|
||||
app.get('/serve/*', (req, res, next) => {
|
||||
const imagePath = req.params[0];
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path_1.default.join(process.cwd(), '../../uploads', cleanPath);
|
||||
console.log(`Direct serve request: ${req.originalUrl} -> ${fullPath}`);
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
requestedPath: req.originalUrl,
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
});
|
||||
res.sendFile(path_1.default.resolve(fullPath));
|
||||
});
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: `Route ${req.originalUrl} not found`,
|
||||
});
|
||||
});
|
||||
app.use(errorHandler_1.errorHandler);
|
||||
const PORT = config_1.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`);
|
||||
});
|
||||
exports.default = app;
|
||||
//# sourceMappingURL=app.js.map
|
||||
1
backend/dist/app.js.map
vendored
Normal file
1
backend/dist/app.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
||||
20
backend/dist/config/config.d.ts
vendored
Normal file
20
backend/dist/config/config.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
export declare const config: {
|
||||
readonly port: string | 3001;
|
||||
readonly nodeEnv: string;
|
||||
readonly database: {
|
||||
readonly url: string;
|
||||
};
|
||||
readonly jwt: {
|
||||
readonly secret: string;
|
||||
readonly expiresIn: "24h";
|
||||
};
|
||||
readonly upload: {
|
||||
readonly path: string;
|
||||
readonly maxFileSize: number;
|
||||
readonly allowedTypes: readonly ["image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
};
|
||||
readonly cors: {
|
||||
readonly origin: string;
|
||||
};
|
||||
};
|
||||
//# sourceMappingURL=config.d.ts.map
|
||||
1
backend/dist/config/config.d.ts.map
vendored
Normal file
1
backend/dist/config/config.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;CAsBT,CAAC"}
|
||||
28
backend/dist/config/config.js
vendored
Normal file
28
backend/dist/config/config.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.config = void 0;
|
||||
const dotenv_1 = __importDefault(require("dotenv"));
|
||||
dotenv_1.default.config();
|
||||
exports.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'),
|
||||
allowedTypes: ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'],
|
||||
},
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
},
|
||||
};
|
||||
//# sourceMappingURL=config.js.map
|
||||
1
backend/dist/config/config.js.map
vendored
Normal file
1
backend/dist/config/config.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":";;;;;;AAAA,oDAA4B;AAE5B,gBAAM,CAAC,MAAM,EAAE,CAAC;AAEH,QAAA,MAAM,GAAG;IACpB,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI;IAC9B,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,aAAa;IAE9C,QAAQ,EAAE;QACR,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,gEAAgE;KAClG;IAED,GAAG,EAAE;QACH,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,2BAA2B;QAC7D,SAAS,EAAE,KAAK;KACjB;IAED,MAAM,EAAE;QACN,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,WAAW;QAC5C,WAAW,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,SAAS,CAAC;QAC7D,YAAY,EAAE,CAAC,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,CAAC;KACrE;IAED,IAAI,EAAE;QACJ,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,uBAAuB;KAC3D;CACO,CAAC"}
|
||||
7
backend/dist/middleware/errorHandler.d.ts
vendored
Normal file
7
backend/dist/middleware/errorHandler.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
export interface ErrorWithStatus extends Error {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
}
|
||||
export declare const errorHandler: (err: ErrorWithStatus, req: Request, res: Response, next: NextFunction) => void;
|
||||
//# sourceMappingURL=errorHandler.d.ts.map
|
||||
1
backend/dist/middleware/errorHandler.d.ts.map
vendored
Normal file
1
backend/dist/middleware/errorHandler.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"errorHandler.d.ts","sourceRoot":"","sources":["../../src/middleware/errorHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,MAAM,WAAW,eAAgB,SAAQ,KAAK;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,YAAY,GACvB,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,QAAQ,EACb,MAAM,YAAY,KACjB,IAoBF,CAAC"}
|
||||
24
backend/dist/middleware/errorHandler.js
vendored
Normal file
24
backend/dist/middleware/errorHandler.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.errorHandler = void 0;
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
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 }),
|
||||
});
|
||||
};
|
||||
exports.errorHandler = errorHandler;
|
||||
//# sourceMappingURL=errorHandler.js.map
|
||||
1
backend/dist/middleware/errorHandler.js.map
vendored
Normal file
1
backend/dist/middleware/errorHandler.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"errorHandler.js","sourceRoot":"","sources":["../../src/middleware/errorHandler.ts"],"names":[],"mappings":";;;AAOO,MAAM,YAAY,GAAG,CAC1B,GAAoB,EACpB,GAAY,EACZ,GAAa,EACb,IAAkB,EACZ,EAAE;IACR,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC;IACnD,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,uBAAuB,CAAC;IAEvD,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE;QACtB,MAAM;QACN,OAAO;QACP,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,GAAG,EAAE,GAAG,CAAC,GAAG;QACZ,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,EAAE,EAAE,GAAG,CAAC,EAAE;KACX,CAAC,CAAC;IAEH,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC;QACtB,OAAO,EAAE,KAAK;QACd,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;YAC5C,CAAC,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,OAAO,CAAC;YACtD,CAAC,CAAC,OAAO;QACX,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,aAAa,IAAI,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC;KACpE,CAAC,CAAC;AACL,CAAC,CAAC;AAzBW,QAAA,YAAY,gBAyBvB"}
|
||||
3
backend/dist/middleware/requestLogger.d.ts
vendored
Normal file
3
backend/dist/middleware/requestLogger.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
export declare const requestLogger: (req: Request, res: Response, next: NextFunction) => void;
|
||||
//# sourceMappingURL=requestLogger.d.ts.map
|
||||
1
backend/dist/middleware/requestLogger.d.ts.map
vendored
Normal file
1
backend/dist/middleware/requestLogger.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"requestLogger.d.ts","sourceRoot":"","sources":["../../src/middleware/requestLogger.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,eAAO,MAAM,aAAa,GAAI,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,KAAG,IAY/E,CAAC"}
|
||||
15
backend/dist/middleware/requestLogger.js
vendored
Normal file
15
backend/dist/middleware/requestLogger.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.requestLogger = void 0;
|
||||
const requestLogger = (req, res, next) => {
|
||||
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();
|
||||
};
|
||||
exports.requestLogger = requestLogger;
|
||||
//# sourceMappingURL=requestLogger.js.map
|
||||
1
backend/dist/middleware/requestLogger.js.map
vendored
Normal file
1
backend/dist/middleware/requestLogger.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"requestLogger.js","sourceRoot":"","sources":["../../src/middleware/requestLogger.ts"],"names":[],"mappings":";;;AAEO,MAAM,aAAa,GAAG,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;IACrF,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEzB,GAAG,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE;QACpB,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC;QACpC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,GAAG,CAAC;QAChC,MAAM,EAAE,UAAU,EAAE,GAAG,GAAG,CAAC;QAE3B,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,IAAI,GAAG,MAAM,UAAU,MAAM,QAAQ,QAAQ,EAAE,EAAE,CAAC,CAAC;IAC1E,CAAC,CAAC,CAAC;IAEH,IAAI,EAAE,CAAC;AACT,CAAC,CAAC;AAZW,QAAA,aAAa,iBAYxB"}
|
||||
3
backend/dist/routes/health.d.ts
vendored
Normal file
3
backend/dist/routes/health.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
//# sourceMappingURL=health.d.ts.map
|
||||
1
backend/dist/routes/health.d.ts.map
vendored
Normal file
1
backend/dist/routes/health.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":"AAEA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8BxB,eAAe,MAAM,CAAC"}
|
||||
30
backend/dist/routes/health.js
vendored
Normal file
30
backend/dist/routes/health.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const router = (0, express_1.Router)();
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rezepte API is running!',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
});
|
||||
router.get('/db', async (req, res) => {
|
||||
try {
|
||||
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',
|
||||
});
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=health.js.map
|
||||
1
backend/dist/routes/health.js.map
vendored
Normal file
1
backend/dist/routes/health.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AAEpD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAGxB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,GAAG,CAAC,IAAI,CAAC;QACP,OAAO,EAAE,IAAI;QACb,OAAO,EAAE,+BAA+B;QACxC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ;KAClC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAGH,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtD,IAAI,CAAC;QAEH,GAAG,CAAC,IAAI,CAAC;YACP,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,gCAAgC;YACzC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACpC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,4BAA4B;YACrC,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe;SAChE,CAAC,CAAC;IACL,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}
|
||||
3
backend/dist/routes/images.d.ts
vendored
Normal file
3
backend/dist/routes/images.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
//# sourceMappingURL=images.d.ts.map
|
||||
1
backend/dist/routes/images.d.ts.map
vendored
Normal file
1
backend/dist/routes/images.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../src/routes/images.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAqQxB,eAAe,MAAM,CAAC"}
|
||||
225
backend/dist/routes/images.js
vendored
Normal file
225
backend/dist/routes/images.js
vendored
Normal file
@@ -0,0 +1,225 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const client_1 = require("@prisma/client");
|
||||
const multer_1 = __importDefault(require("multer"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const config_1 = require("../config/config");
|
||||
const router = (0, express_1.Router)();
|
||||
const prisma = new client_1.PrismaClient();
|
||||
const storage = multer_1.default.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 = path_1.default.join(process.cwd(), '../../uploads', recipeNumber);
|
||||
if (!fs_1.default.existsSync(uploadDir)) {
|
||||
fs_1.default.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'), '');
|
||||
}
|
||||
const uploadDir = path_1.default.join(process.cwd(), '../../uploads', recipeNumber);
|
||||
const existingFiles = fs_1.default.existsSync(uploadDir)
|
||||
? fs_1.default.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 = (0, multer_1.default)({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: config_1.config.upload.maxFileSize,
|
||||
},
|
||||
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.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
router.post('/upload/:recipeId', upload.array('images', 10), async (req, res, next) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const files = req.files;
|
||||
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',
|
||||
});
|
||||
}
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: parseInt(recipeId) }
|
||||
});
|
||||
if (!recipe) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Recipe not found',
|
||||
});
|
||||
}
|
||||
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) {
|
||||
if (req.files) {
|
||||
const files = req.files;
|
||||
files.forEach(file => {
|
||||
if (fs_1.default.existsSync(file.path)) {
|
||||
fs_1.default.unlinkSync(file.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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',
|
||||
});
|
||||
}
|
||||
const fullPath = path_1.default.join(process.cwd(), '../..', image.filePath);
|
||||
if (fs_1.default.existsSync(fullPath)) {
|
||||
fs_1.default.unlinkSync(fullPath);
|
||||
}
|
||||
await prisma.recipeImage.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get('/recipe/:recipeId', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.get('/serve/:imagePath(*)', (req, res, next) => {
|
||||
try {
|
||||
const imagePath = req.params.imagePath;
|
||||
if (!imagePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image path is required',
|
||||
});
|
||||
}
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path_1.default.join(process.cwd(), '../../uploads', cleanPath);
|
||||
console.log(`Serving image: ${imagePath} -> ${fullPath}`);
|
||||
if (!fs_1.default.existsSync(fullPath)) {
|
||||
console.log(`Image not found: ${fullPath}`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
requestedPath: imagePath,
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
});
|
||||
return res.sendFile(path_1.default.resolve(fullPath));
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error serving image:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=images.js.map
|
||||
1
backend/dist/routes/images.js.map
vendored
Normal file
1
backend/dist/routes/images.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
3
backend/dist/routes/ingredients.d.ts
vendored
Normal file
3
backend/dist/routes/ingredients.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
//# sourceMappingURL=ingredients.d.ts.map
|
||||
1
backend/dist/routes/ingredients.d.ts.map
vendored
Normal file
1
backend/dist/routes/ingredients.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ingredients.d.ts","sourceRoot":"","sources":["../../src/routes/ingredients.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA0LxB,eAAe,MAAM,CAAC"}
|
||||
159
backend/dist/routes/ingredients.js
vendored
Normal file
159
backend/dist/routes/ingredients.js
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const client_1 = require("@prisma/client");
|
||||
const joi_1 = __importDefault(require("joi"));
|
||||
const router = (0, express_1.Router)();
|
||||
const prisma = new client_1.PrismaClient();
|
||||
const ingredientSchema = joi_1.default.object({
|
||||
recipeNumber: joi_1.default.string().required().max(20),
|
||||
ingredients: joi_1.default.string().required(),
|
||||
});
|
||||
const updateIngredientSchema = ingredientSchema.fork(['recipeNumber'], (schema) => schema.optional());
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { search = '', category = '', page = '1', limit = '10', sortBy = 'recipeNumber', sortOrder = 'asc' } = req.query;
|
||||
const pageNum = parseInt(page);
|
||||
const limitNum = parseInt(limit);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
const where = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ recipeNumber: { contains: search } },
|
||||
{ ingredients: { contains: search } },
|
||||
];
|
||||
}
|
||||
if (category) {
|
||||
where.recipeNumber = { contains: category };
|
||||
}
|
||||
const [ingredients, total] = await Promise.all([
|
||||
prisma.ingredient.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.post('/', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=ingredients.js.map
|
||||
1
backend/dist/routes/ingredients.js.map
vendored
Normal file
1
backend/dist/routes/ingredients.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ingredients.js","sourceRoot":"","sources":["../../src/routes/ingredients.ts"],"names":[],"mappings":"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"}
|
||||
3
backend/dist/routes/recipes.d.ts
vendored
Normal file
3
backend/dist/routes/recipes.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
//# sourceMappingURL=recipes.d.ts.map
|
||||
1
backend/dist/routes/recipes.d.ts.map
vendored
Normal file
1
backend/dist/routes/recipes.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoQxB,eAAe,MAAM,CAAC"}
|
||||
219
backend/dist/routes/recipes.js
vendored
Normal file
219
backend/dist/routes/recipes.js
vendored
Normal file
@@ -0,0 +1,219 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const client_1 = require("@prisma/client");
|
||||
const joi_1 = __importDefault(require("joi"));
|
||||
const router = (0, express_1.Router)();
|
||||
const prisma = new client_1.PrismaClient();
|
||||
const recipeSchema = joi_1.default.object({
|
||||
recipeNumber: joi_1.default.string().optional().allow(''),
|
||||
title: joi_1.default.string().required().min(1).max(255),
|
||||
description: joi_1.default.string().optional().allow(''),
|
||||
category: joi_1.default.string().optional().allow(''),
|
||||
preparation: joi_1.default.string().optional().allow(''),
|
||||
servings: joi_1.default.number().integer().min(1).default(1),
|
||||
ingredients: joi_1.default.string().optional().allow(''),
|
||||
instructions: joi_1.default.string().optional().allow(''),
|
||||
comment: joi_1.default.string().optional().allow(''),
|
||||
});
|
||||
const updateRecipeSchema = recipeSchema;
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { search = '', category = '', page = '1', limit = '10', sortBy = 'title', sortOrder = 'asc' } = req.query;
|
||||
const pageNum = parseInt(page);
|
||||
const limitNum = parseInt(limit);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
const where = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
{ ingredients: { contains: search } },
|
||||
];
|
||||
}
|
||||
if (category) {
|
||||
where.category = { contains: category };
|
||||
}
|
||||
const [recipes, total] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
where,
|
||||
include: {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
},
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
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',
|
||||
});
|
||||
}
|
||||
if ((!recipe.ingredients || recipe.ingredients.trim() === '' || recipe.ingredients.length < 10) && recipe.recipeNumber) {
|
||||
try {
|
||||
const paddedNumber = recipe.recipeNumber.padStart(3, '0');
|
||||
const recipeNumberWithR = `R${paddedNumber}`;
|
||||
const separateIngredients = await prisma.ingredient.findFirst({
|
||||
where: { recipeNumber: recipeNumberWithR }
|
||||
});
|
||||
if (separateIngredients && separateIngredients.ingredients) {
|
||||
recipe.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);
|
||||
}
|
||||
});
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { error, value } = recipeSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
if (!value.recipeNumber || value.recipeNumber.trim() === '') {
|
||||
const lastRecipe = await prisma.recipe.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { recipeNumber: true }
|
||||
});
|
||||
let nextNumber = 1;
|
||||
if (lastRecipe?.recipeNumber) {
|
||||
const match = lastRecipe.recipeNumber.match(/\d+/);
|
||||
if (match) {
|
||||
nextNumber = parseInt(match[0]) + 1;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=recipes.js.map
|
||||
1
backend/dist/routes/recipes.js.map
vendored
Normal file
1
backend/dist/routes/recipes.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7362
backend/package-lock.json
generated
Normal file
7362
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
backend/package.json
Normal file
58
backend/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "rezepte-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Rezepte - Node.js Backend",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.8",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.8.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.9.1",
|
||||
"@typescript-eslint/parser": "^6.9.1",
|
||||
"eslint": "^8.53.0",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.6.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.1.4",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"keywords": [
|
||||
"recipes",
|
||||
"cooking",
|
||||
"node.js",
|
||||
"typescript",
|
||||
"express"
|
||||
],
|
||||
"author": "Recipe Admin",
|
||||
"license": "MIT"
|
||||
}
|
||||
53
backend/prisma/schema.prisma
Normal file
53
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,53 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Recipe {
|
||||
id Int @id @default(autoincrement())
|
||||
recipeNumber String @unique @map("Rezeptnummer") @db.VarChar(50)
|
||||
title String @map("Bezeichnung") @db.Text
|
||||
description String? @map("Beschreibung") @db.Text
|
||||
category String? @map("Kategorie") @db.VarChar(100)
|
||||
filePath String? @map("datei_pfad") @db.VarChar(255)
|
||||
preparation String? @map("Vorbereitung") @db.Text
|
||||
servings Int @map("Anzahl") @default(1)
|
||||
ingredients String? @map("Zutaten") @db.Text
|
||||
instructions String? @map("Zubereitung") @db.Text
|
||||
comment String? @map("Kommentar") @db.Text
|
||||
|
||||
// Relations
|
||||
images RecipeImage[]
|
||||
ingredientsList Ingredient[]
|
||||
|
||||
@@map("Rezepte")
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
id Int @id @default(autoincrement())
|
||||
recipeNumber String @map("rezeptnr") @db.VarChar(20)
|
||||
ingredients String @map("ingr") @db.Text
|
||||
|
||||
// Relations
|
||||
recipe Recipe? @relation(fields: [recipeNumber], references: [recipeNumber])
|
||||
|
||||
@@map("ingredients")
|
||||
}
|
||||
|
||||
model RecipeImage {
|
||||
id Int @id @default(autoincrement())
|
||||
recipeId Int @map("rezepte_id")
|
||||
filePath String @map("datei_pfad") @db.VarChar(255)
|
||||
|
||||
// Relations
|
||||
recipe Recipe? @relation(fields: [recipeId], references: [id])
|
||||
|
||||
@@map("rezepte_bilder")
|
||||
}
|
||||
26
backend/server.log
Normal file
26
backend/server.log
Normal file
@@ -0,0 +1,26 @@
|
||||
node:events:497
|
||||
throw er; // Unhandled 'error' event
|
||||
^
|
||||
|
||||
Error: listen EADDRINUSE: address already in use :::3001
|
||||
at Server.setupListenHandle [as _listen2] (node:net:1908:16)
|
||||
at listenInCluster (node:net:1965:12)
|
||||
at Server.listen (node:net:2067:7)
|
||||
at Function.listen (/Users/rxf/Projekte/Rezepte_Klaus/nodejs-version/backend/node_modules/express/lib/application.js:635:24)
|
||||
at Object.<anonymous> (/Users/rxf/Projekte/Rezepte_Klaus/nodejs-version/backend/dist/app.js:70:5)
|
||||
at Module._compile (node:internal/modules/cjs/loader:1546:14)
|
||||
at Module._extensions..js (node:internal/modules/cjs/loader:1691:10)
|
||||
at Module.load (node:internal/modules/cjs/loader:1317:32)
|
||||
at Module._load (node:internal/modules/cjs/loader:1127:12)
|
||||
at TracingChannel.traceSync (node:diagnostics_channel:315:14)
|
||||
Emitted 'error' event on Server instance at:
|
||||
at emitErrorNT (node:net:1944:8)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -48,
|
||||
syscall: 'listen',
|
||||
address: '::',
|
||||
port: 3001
|
||||
}
|
||||
|
||||
Node.js v22.9.0
|
||||
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;
|
||||
25
backend/tsconfig.json
Normal file
25
backend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user