diff --git a/backend/.env.example b/backend/.env.example index 5347d622..0bfe3c4a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,7 @@ DATABASE_URL="postgresql://tim:tim@localhost:5432/tim" # DATABASE_URL="postgresql://tim:tim@db:5432/tim" # JWT secret for authentication +# ⚠️ MUST be changed in production — server refuses to start with default value JWT_SECRET="change-me" # Socket.IO path (leave default) @@ -20,15 +21,32 @@ SOCKET_IO_PATH="/socket.io" # Example: SOCKET_IO_CORS_ORIGIN="https://app.example.com,https://admin.example.com" SOCKET_IO_CORS_ORIGIN="http://localhost:5173,http://localhost:3000" +# REST API CORS origins (comma-separated). +# Omit or leave empty to allow all origins in development. +# CORS_ORIGIN="https://app.example.com,https://admin.example.com" +CORS_ORIGIN= + # Express port PORT=4000 # Node environment: development | production | test NODE_ENV=development -# Optional: log level (info, warn, error) +# Logging level: trace | debug | info | warn | error | fatal LOG_LEVEL=info +# Rate limiting (requests per 1-minute window per IP) +RATE_LIMIT_GLOBAL=100 +RATE_LIMIT_SAP=30 +RATE_LIMIT_AUTH=10 + +# HTTPS redirect — set to "true" behind a TLS-terminating proxy (ALB, nginx, etc.) +DISABLE_HTTPS_REDIRECT=true + +# Maximum request body size (Express body-parser limit). Default: 1mb. +# Accepts formats: '100kb', '1mb', '10mb' +REQUEST_BODY_LIMIT=1mb + # Olympus provenance service (optional — leave empty to disable) # Set to your Olympus instance URL to enable cryptographic provenance OLYMPUS_URL= diff --git a/backend/package-lock.json b/backend/package-lock.json index b5faadf0..3fe57bcf 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,17 +11,23 @@ "dependencies": { "@prisma/client": "^6.19.3", "bcryptjs": "^3.0.3", + "compression": "^1.8.1", + "cors": "^2.8.6", "dotenv": "^17.2.3", "express": "5.0.0", "express-rate-limit": "^8.3.2", + "helmet": "^8.1.0", "jose": "^5.9.3", "pino": "^10.3.1", + "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "socket.io": "^4.8.0", "zod": "^3.23.8" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.8.1", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.7.4", @@ -2006,6 +2012,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3231,6 +3248,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3309,9 +3380,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -3319,6 +3390,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/create-jest": { @@ -4526,7 +4601,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4793,6 +4867,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", @@ -6235,6 +6318,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6473,6 +6565,18 @@ "split2": "^4.0.0" } }, + "node_modules/pino-http": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", + "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^10.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, "node_modules/pino-pretty": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", diff --git a/backend/package.json b/backend/package.json index 162c75c8..a0ada371 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,17 +16,23 @@ "dependencies": { "@prisma/client": "^6.19.3", "bcryptjs": "^3.0.3", + "compression": "^1.8.1", + "cors": "^2.8.6", "dotenv": "^17.2.3", "express": "5.0.0", "express-rate-limit": "^8.3.2", + "helmet": "^8.1.0", "jose": "^5.9.3", "pino": "^10.3.1", + "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "socket.io": "^4.8.0", "zod": "^3.23.8" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.8.1", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.7.4", diff --git a/backend/src/app.ts b/backend/src/app.ts index c2375788..205f0815 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -5,6 +5,8 @@ import { Server } from 'socket.io'; import { createServer } from 'http'; import { jwtVerify } from 'jose'; import path from 'path'; +import helmet from 'helmet'; +import compression from 'compression'; import workOrderRoutes from './routes/workOrders.js'; import healthRoutes from './routes/health.js'; import queueRoutes from './routes/queue.js'; @@ -25,6 +27,12 @@ import bomRoutes from './routes/bom.js'; import materialRoutes from './routes/materials.js'; import { globalLimiter, sapLimiter } from './middleware/rateLimiter.js'; import { httpsRedirect } from './middleware/httpsRedirect.js'; +import { requestId } from './middleware/requestId.js'; +import { requestLogger } from './middleware/requestLogger.js'; +import { corsMiddleware } from './middleware/cors.js'; +import { AppError } from './errors/AppError.js'; +import { registerGracefulShutdown } from './lifecycle/shutdown.js'; +import { logger } from './services/logger.js'; import type { Role } from './middleware/auth.js'; import { getJwtSecret, validateJwtSecret } from './config/jwt.js'; @@ -62,14 +70,38 @@ export const io = new Server(httpServer, { } }); -// Security middleware +// ─── Enterprise Middleware Stack ───────────────────────────────────────────── + +// 1. Request ID — generate/propagate X-Request-ID for every request +app.use(requestId); + +// 2. Security headers (X-Content-Type-Options, X-Frame-Options, CSP, etc.) +// CSP is disabled here because the SPA loads dynamic UI5 web components and +// inline styles. In production, enforce CSP at the CDN / reverse-proxy layer. +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, // Allow cross-origin resources (SAP UI5) +})); + +// 3. HTTPS redirect + HSTS (production only) app.use(httpsRedirect); -// Rate limiting (recommended by CodeQL) +// 4. CORS — allow REST API access from configured origins +app.use(corsMiddleware); + +// 5. Response compression (gzip / deflate) +app.use(compression()); + +// 6. Rate limiting app.use(globalLimiter); -// Middleware -app.use(express.json()); +// 7. Body parsing with size limits to prevent payload abuse +const bodyLimit = process.env.REQUEST_BODY_LIMIT || '1mb'; +app.use(express.json({ limit: bodyLimit })); +app.use(express.urlencoded({ extended: true, limit: bodyLimit })); + +// 8. Structured request logging (pino-http) +app.use(requestLogger); // Socket.IO authentication middleware io.use(async (socket, next) => { @@ -130,24 +162,38 @@ if (staticFilesPath) { const indexPath = path.join(staticFilesPath, 'index.html'); res.sendFile(indexPath, (err) => { if (err) { - console.error(`Failed to serve index.html: ${err.message}`); + logger.error({ err, path: indexPath }, 'Failed to serve index.html'); res.status(500).send('Application files not found. Please reinstall the application.'); } }); }); - console.log(`📦 Desktop mode: serving frontend from ${staticFilesPath}`); + logger.info({ staticFilesPath }, 'Desktop mode: serving frontend from static path'); } +// ─── Global Error Handler ──────────────────────────────────────────────────── const errorHandler: ErrorRequestHandler = (err, req, res, next) => { if (res.headersSent) { return next(err); } + // Structured AppError instances carry their own status/code + if (err instanceof AppError) { + if (err.status >= 500) { + logger.error({ err, requestId: req.id, context: err.context }, err.message); + } + return res.status(err.status).json({ + message: err.message, + code: err.code, + ...(req.id && { requestId: req.id }), + }); + } + const isInvalidJson = err instanceof SyntaxError && 'status' in err && req.is('application/json'); + const status = isInvalidJson ? 400 @@ -159,6 +205,7 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => { err.status < 600 ? err.status : 500; + const message = status >= 500 ? 'Internal server error' @@ -169,10 +216,15 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => { : 'Unexpected error'; if (status >= 500) { - console.error(err); + logger.error({ err, requestId: req.id }, message); + } else { + logger.warn({ err, requestId: req.id }, message); } - return res.status(status).json({ message }); + return res.status(status).json({ + message, + ...(req.id && { requestId: req.id }), + }); }; app.use(errorHandler); @@ -211,8 +263,11 @@ const PORT = process.env.PORT || 4000; // during tests causes EADDRINUSE when multiple test files import this module. if (process.env.NODE_ENV !== 'test') { httpServer.listen(PORT, () => { - console.log(`🚀 Server running on port ${PORT}`); + logger.info({ port: PORT, nodeEnv: process.env.NODE_ENV || 'development' }, `TiM server running on port ${PORT}`); }); + + // Register graceful shutdown handlers (SIGTERM / SIGINT) + registerGracefulShutdown(httpServer, io); } export const server = httpServer; diff --git a/backend/src/errors/AppError.ts b/backend/src/errors/AppError.ts new file mode 100644 index 00000000..397e5adc --- /dev/null +++ b/backend/src/errors/AppError.ts @@ -0,0 +1,72 @@ +/** + * Structured application error with HTTP status code, error code, and + * optional context for downstream logging / client responses. + * + * Throwing an AppError from any route handler or service will be caught + * by the global error handler which serialises it into a consistent + * JSON envelope: + * + * ```json + * { + * "message": "Work order not found", + * "code": "WORK_ORDER_NOT_FOUND", + * "requestId": "abc-123" + * } + * ``` + */ +export class AppError extends Error { + /** HTTP status code (e.g. 400, 404, 409, 500) */ + public readonly status: number; + + /** Machine-readable error code for client consumption */ + public readonly code: string; + + /** Whether this error represents an operational (expected) failure */ + public readonly isOperational: boolean; + + /** Optional extra context (never leaked to clients in production) */ + public readonly context?: Record; + + constructor( + message: string, + status = 500, + code = 'INTERNAL_ERROR', + options?: { isOperational?: boolean; context?: Record }, + ) { + super(message); + this.name = 'AppError'; + this.status = status; + this.code = code; + this.isOperational = options?.isOperational ?? (status < 500); + this.context = options?.context; + + // Maintains proper stack trace for V8 (only available in V8 engines) + Error.captureStackTrace?.(this, AppError); + } + + /* ── Convenience factory methods ───────────────────────────── */ + + static badRequest(message: string, code = 'BAD_REQUEST') { + return new AppError(message, 400, code, { isOperational: true }); + } + + static unauthorized(message = 'Unauthorized', code = 'UNAUTHORIZED') { + return new AppError(message, 401, code, { isOperational: true }); + } + + static forbidden(message = 'Forbidden', code = 'FORBIDDEN') { + return new AppError(message, 403, code, { isOperational: true }); + } + + static notFound(message = 'Not found', code = 'NOT_FOUND') { + return new AppError(message, 404, code, { isOperational: true }); + } + + static conflict(message: string, code = 'CONFLICT') { + return new AppError(message, 409, code, { isOperational: true }); + } + + static internal(message = 'Internal server error', code = 'INTERNAL_ERROR') { + return new AppError(message, 500, code, { isOperational: false }); + } +} diff --git a/backend/src/lifecycle/shutdown.ts b/backend/src/lifecycle/shutdown.ts new file mode 100644 index 00000000..730e36b9 --- /dev/null +++ b/backend/src/lifecycle/shutdown.ts @@ -0,0 +1,72 @@ +import { Server } from 'http'; +import { Server as SocketIOServer } from 'socket.io'; +import { prisma } from '../prisma/client.js'; +import { logger } from '../services/logger.js'; + +/** + * Registers SIGTERM / SIGINT handlers that perform an orderly shutdown: + * + * 1. Stop accepting new connections. + * 2. Disconnect all Socket.IO clients gracefully. + * 3. Close the Prisma database connection pool. + * 4. Exit with code 0. + * + * A hard-kill timeout ensures the process never hangs indefinitely + * (e.g. when a TCP connection is stuck in CLOSE_WAIT). + */ +export function registerGracefulShutdown( + httpServer: Server, + io: SocketIOServer, + timeoutMs = 15_000, +) { + let shuttingDown = false; // Prevent duplicate handling + + async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + + logger.info({ signal }, 'Graceful shutdown initiated'); + + // Hard-kill safety net + const forceExit = setTimeout(() => { + logger.error('Graceful shutdown timed out — forcing exit'); + process.exit(1); + }, timeoutMs); + forceExit.unref(); // Don't keep the event loop open for the timer + + try { + // 1. Close Socket.IO (disconnects all clients) + await new Promise((resolve) => { + io.close(() => { + logger.info('Socket.IO connections closed'); + resolve(); + }); + }); + + // 2. Stop accepting new HTTP connections and drain existing ones + await new Promise((resolve, reject) => { + httpServer.close((err) => { + if (err) { + logger.error({ err }, 'Error closing HTTP server'); + return reject(err); + } + logger.info('HTTP server closed'); + resolve(); + }); + }); + + // 3. Disconnect Prisma + await prisma.$disconnect(); + logger.info('Database connection closed'); + + logger.info('Shutdown complete'); + process.exit(0); + } catch (err) { + logger.error({ err }, 'Error during graceful shutdown'); + process.exit(1); + } + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts new file mode 100644 index 00000000..0e603852 --- /dev/null +++ b/backend/src/middleware/cors.ts @@ -0,0 +1,33 @@ +import cors from 'cors'; + +/** + * CORS configuration for the REST API. + * + * In production the CORS_ORIGIN env var must be set to a comma-separated + * list of allowed origins (e.g. "https://tim.example.com,https://admin.example.com"). + * + * In development / test any origin is permitted. + */ +const allowedOrigins = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(',').map(o => o.trim()) + : []; + +export const corsMiddleware = cors({ + origin: (origin, callback) => { + // Allow requests with no origin (mobile apps, Postman, server-to-server) + if (!origin) return callback(null, true); + + // In non-production environments with no explicit config, allow everything + if (process.env.NODE_ENV !== 'production' && allowedOrigins.length === 0) { + return callback(null, true); + } + + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + callback(new Error(`Origin ${origin} not allowed by CORS`)); + }, + credentials: true, + exposedHeaders: ['X-Request-ID'], +}); diff --git a/backend/src/middleware/requestId.ts b/backend/src/middleware/requestId.ts new file mode 100644 index 00000000..67d4f46b --- /dev/null +++ b/backend/src/middleware/requestId.ts @@ -0,0 +1,28 @@ +import { Request, Response, NextFunction } from 'express'; +import { randomUUID } from 'crypto'; + +/** + * Generates or propagates a unique request ID for every HTTP request. + * If the client sends an X-Request-ID header, it is re-used for correlation. + * Otherwise a new UUID v4 is generated. + * + * The ID is attached to `req.id` and echoed back in the response header. + */ +export function requestId(req: Request, res: Response, next: NextFunction) { + const id = + (typeof req.headers['x-request-id'] === 'string' && req.headers['x-request-id']) || + randomUUID(); + + (req as Request & { id: string }).id = id; + res.setHeader('X-Request-ID', id); + next(); +} + +// Extend Express Request type +declare global { + namespace Express { + interface Request { + id?: string; + } + } +} diff --git a/backend/src/middleware/requestLogger.ts b/backend/src/middleware/requestLogger.ts new file mode 100644 index 00000000..f6acaafa --- /dev/null +++ b/backend/src/middleware/requestLogger.ts @@ -0,0 +1,36 @@ +import pinoHttp from 'pino-http'; +import { logger } from '../services/logger.js'; + +/** + * Structured HTTP request logging via pino-http. + * + * Every request logs: method, url, status, response time, request ID. + * In production, output is JSON for log aggregators (ELK, Datadog, Splunk). + * In development, inherits the pretty-printed pino-pretty transport. + * + * Health-check endpoints are logged at 'silent' level to avoid noise. + */ +export const requestLogger = pinoHttp({ + logger, + // Use the request ID already set by the requestId middleware. + // pino-http types genReqId with IncomingMessage, but Express extends it with `id`. + genReqId: (req) => (req as unknown as { id?: string }).id ?? 'unknown', + // Quiet health checks and readiness probes + autoLogging: { + ignore: (req) => { + const url = req.url ?? ''; + return url === '/health' || url.startsWith('/health/'); + }, + }, + customLogLevel: (_req, res, err) => { + if (err || (res.statusCode >= 500)) return 'error'; + if (res.statusCode >= 400) return 'warn'; + return 'info'; + }, + customSuccessMessage: (req, res) => { + return `${req.method} ${req.url} → ${res.statusCode}`; + }, + customErrorMessage: (req, _res, err) => { + return `${req.method} ${req.url} failed: ${err.message}`; + }, +}); diff --git a/backend/src/services/labReportService.ts b/backend/src/services/labReportService.ts index fbe91d58..c4dcc3f2 100644 --- a/backend/src/services/labReportService.ts +++ b/backend/src/services/labReportService.ts @@ -1,5 +1,6 @@ import { prisma } from '../prisma/client.js'; import { commitToOlympus } from './olympusBridge.js'; +import { logger } from './logger.js'; export async function submitLabReport(opts: { tenantId: string; @@ -54,11 +55,11 @@ export async function submitLabReport(opts: { where: { id: report.id }, data: { olympusCommitId: commitId }, }).catch((err) => { - console.error('[LabReport] Failed to update olympusCommitId:', err); + logger.error({ err, reportId: report.id }, '[LabReport] Failed to update olympusCommitId'); }); } }).catch((err) => { - console.error('[LabReport] Failed to commit to Olympus:', err); + logger.error({ err }, '[LabReport] Failed to commit to Olympus'); }); return { status: 201, body: { report } }; @@ -105,11 +106,11 @@ export async function signoffLabReport(opts: { where: { id: signoff.id }, data: { olympusCommitId: commitId }, }).catch((err) => { - console.error('[Signoff] Failed to update olympusCommitId:', err); + logger.error({ err, signoffId: signoff.id }, '[Signoff] Failed to update olympusCommitId'); }); } }).catch((err) => { - console.error('[Signoff] Failed to commit to Olympus:', err); + logger.error({ err }, '[Signoff] Failed to commit to Olympus'); }); return { status: 201, body: { signoff } }; diff --git a/backend/src/services/movementService.ts b/backend/src/services/movementService.ts index 85573074..a6059bf5 100644 --- a/backend/src/services/movementService.ts +++ b/backend/src/services/movementService.ts @@ -1,6 +1,7 @@ import { prisma } from '../prisma/client.js'; import { commitToOlympus } from './olympusBridge.js'; import { emitQueueUpdated } from '../sockets/workOrderSocket.js'; +import { logger } from './logger.js'; export async function logMovement(opts: { tenantId: string; @@ -57,11 +58,11 @@ export async function logMovement(opts: { where: { id: movement.id }, data: { olympusCommitId: commitId }, }).catch((err) => { - console.error('[Movement] Failed to update olympusCommitId:', err); + logger.error({ err, movementId: movement.id }, '[Movement] Failed to update olympusCommitId'); }); } }).catch((err) => { - console.error('[Movement] Failed to commit to Olympus:', err); + logger.error({ err }, '[Movement] Failed to commit to Olympus'); }); emitQueueUpdated(opts.tenantId); diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts index 15193686..6442c65c 100644 --- a/backend/tests/auth.test.ts +++ b/backend/tests/auth.test.ts @@ -140,7 +140,11 @@ describe('POST /api/v1/auth/login', () => { .send({ email: 'super@test.com', password: testPassword }); expect(res.status).toBe(500); - expect(res.body).toEqual({ message: 'Internal server error' }); + expect(res.body.message).toEqual('Internal server error'); + // Enterprise error handler also includes a requestId for traceability + if (res.body.requestId) { + expect(typeof res.body.requestId).toBe('string'); + } consoleErrorSpy.mockRestore(); }); diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..65f0ffa1 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,132 @@ +import React from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + /** Optional fallback UI to render when an error is caught */ + fallback?: React.ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * Enterprise-grade React Error Boundary. + * + * Catches JavaScript errors anywhere in the child component tree, + * logs them, and renders a recovery-friendly fallback UI instead + * of crashing the entire application. + * + * Usage: + * ```tsx + * + * + * + * ``` + */ +export class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + // Log to structured console — in production this would go to Sentry / Datadog / etc. + console.error('[ErrorBoundary] Uncaught error:', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
⚠️
+

+ Something went wrong +

+

+ An unexpected error occurred. Your work has been preserved. + {this.state.error && ( + + {this.state.error.message} + + )} +

+
+ + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts new file mode 100644 index 00000000..84679a1a --- /dev/null +++ b/frontend/src/config/env.ts @@ -0,0 +1,37 @@ +/** + * Environment configuration for TiM frontend. + * + * Centralises access to build-time and runtime configuration. + * Values come from Vite's import.meta.env or sensible defaults. + * + * Usage: + * import { env } from '../config/env'; + * console.log(env.API_BASE); + */ + +interface ImportMetaEnvTiM { + readonly VITE_API_BASE?: string; + readonly VITE_APP_VERSION?: string; + readonly MODE?: string; + readonly PROD?: boolean; + readonly DEV?: boolean; +} + +const meta: ImportMetaEnvTiM = (import.meta as { env?: ImportMetaEnvTiM }).env ?? {}; + +export const env = { + /** Base URL for API requests (default: '/api/v1') */ + API_BASE: meta.VITE_API_BASE ?? '/api/v1', + + /** Current environment mode */ + MODE: meta.MODE ?? 'development', + + /** Whether we are running in production */ + IS_PROD: meta.PROD === true || meta.MODE === 'production', + + /** Whether we are running in development */ + IS_DEV: meta.DEV === true || meta.MODE === 'development', + + /** Application version from package.json (injected at build time) */ + APP_VERSION: meta.VITE_APP_VERSION ?? '1.0.0', +} as const; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7523519f..79ac0d1b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,19 +1,21 @@ -import React from 'react'; +import React, { Suspense, lazy } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import FioriDashboard from './components/FioriDashboard'; -import CompleteStepForm from './components/CompleteStepForm'; -import StationDashboard from './components/StationDashboard'; -import AndonBoard from './components/AndonBoard'; -import SupervisorDashboard from './components/SupervisorDashboard'; -import ManagementDashboard from './components/ManagementDashboard'; -import AdminPanel from './components/AdminPanel'; -import LoginPage from './components/LoginPage'; -import ShiftReportViewer from './components/ShiftReportViewer'; +import { ErrorBoundary } from './components/ErrorBoundary'; import InstallPromptBanner from './components/InstallPromptBanner'; import OfflineBanner from './components/OfflineBanner'; import './styles/fiori.css'; +// ─── Lazy-loaded route components (code splitting) ────────────────────────── +const FioriDashboard = lazy(() => import('./components/FioriDashboard')); +const StationDashboard = lazy(() => import('./components/StationDashboard')); +const AndonBoard = lazy(() => import('./components/AndonBoard')); +const SupervisorDashboard = lazy(() => import('./components/SupervisorDashboard')); +const ManagementDashboard = lazy(() => import('./components/ManagementDashboard')); +const AdminPanel = lazy(() => import('./components/AdminPanel')); +const LoginPage = lazy(() => import('./components/LoginPage')); +const ShiftReportViewer = lazy(() => import('./components/ShiftReportViewer')); + // Set SAP Fiori theme import '@ui5/webcomponents/dist/Assets.js'; import '@ui5/webcomponents-fiori/dist/Assets.js'; @@ -49,76 +51,99 @@ function RequireRole({ allowed, children }: { allowed: string[]; children: React return <>{children}; } +/** Loading spinner for lazy-loaded route transitions */ +function RouteLoadingFallback() { + return ( +
+
+
+
Loading…
+
+
+ ); +} + function App() { return ( - - - {/* Public: Login page for supervisors/managers */} - } /> - - {/* Public: Andon board — no auth, read-only for wall-mounted TVs */} - } /> - - {/* Operator: Station dashboard — Tech, Supervisor, Admin */} - } /> - } /> - - {/* Supervisor: Shift lead dashboard */} - - - - } - /> - - {/* Management: Plant-wide analytics dashboard (Admin only) */} - - - - } - /> - - {/* Shift Reports: Auto-generated shift summaries */} - - - - } - /> - - {/* Admin: Operator management panel */} - - - - } - /> - - {/* Dashboard: Fiori overview */} - } /> - - {/* Home: redirect to dashboard */} - } /> - - {/* Fallback */} - } /> - - - {/* Global offline indicator — shown on all routes */} - - - {/* PWA install prompt — shown on all routes */} - - + + + }> + + {/* Public: Login page for supervisors/managers */} + } /> + + {/* Public: Andon board — no auth, read-only for wall-mounted TVs */} + } /> + + {/* Operator: Station dashboard — Tech, Supervisor, Admin */} + } /> + } /> + + {/* Supervisor: Shift lead dashboard */} + + + + } + /> + + {/* Management: Plant-wide analytics dashboard (Admin only) */} + + + + } + /> + + {/* Shift Reports: Auto-generated shift summaries */} + + + + } + /> + + {/* Admin: Operator management panel */} + + + + } + /> + + {/* Dashboard: Fiori overview */} + } /> + + {/* Home: redirect to dashboard */} + } /> + + {/* Fallback */} + } /> + + + + {/* Global offline indicator — shown on all routes */} + + + {/* PWA install prompt — shown on all routes */} + + + ); } diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 00000000..f047460d --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,136 @@ +/** + * Centralised API client for TiM frontend. + * + * Provides a single point of configuration for every HTTP call: + * • Automatic Bearer-token injection from localStorage + * • Consistent JSON error handling with structured error objects + * • Configurable request timeout (default 30 s) + * • Base URL resolution from environment or relative path + * + * Usage: + * import { apiClient } from '@/services/apiClient'; + * const data = await apiClient.get('/equipment'); + * await apiClient.post('/movements', { batchId, quantity }); + */ + +// ─── Configuration ────────────────────────────────────────────────────────── + +const API_BASE = '/api/v1'; +const DEFAULT_TIMEOUT_MS = 30_000; + +// ─── Error type ───────────────────────────────────────────────────────────── + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: string, + public readonly requestId?: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +function getAuthHeaders(): Record { + const token = localStorage.getItem('token'); + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; +} + +async function handleResponse(res: Response): Promise { + const requestId = res.headers.get('X-Request-ID') ?? undefined; + + if (!res.ok) { + let body: { message?: string; code?: string } = {}; + try { + body = await res.json(); + } catch { + // Non-JSON error body — use status text + } + throw new ApiError( + body.message || res.statusText || `Request failed (${res.status})`, + res.status, + body.code, + requestId, + ); + } + + // 204 No Content — return undefined + if (res.status === 204) return undefined as unknown as T; + + return res.json() as Promise; +} + +// ─── Core request function ────────────────────────────────────────────────── + +interface RequestOptions extends Omit { + /** Request body — automatically serialised to JSON */ + body?: unknown; + /** Timeout in milliseconds (default: 30 000) */ + timeout?: number; + /** Skip automatic auth header injection */ + skipAuth?: boolean; +} + +async function request( + path: string, + { body, timeout = DEFAULT_TIMEOUT_MS, skipAuth = false, ...init }: RequestOptions = {}, +): Promise { + const url = path.startsWith('http') ? path : `${API_BASE}${path}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + try { + const res = await fetch(url, { + ...init, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...(skipAuth ? {} : getAuthHeaders()), + ...(init.headers as Record | undefined), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + return handleResponse(res); + } catch (err) { + if (err instanceof ApiError) throw err; + if (err instanceof DOMException && err.name === 'AbortError') { + throw new ApiError('Request timed out', 408, 'TIMEOUT'); + } + throw new ApiError( + err instanceof Error ? err.message : 'Network error', + 0, + 'NETWORK_ERROR', + ); + } finally { + clearTimeout(timer); + } +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +export const apiClient = { + get(path: string, opts?: Omit) { + return request(path, { ...opts, method: 'GET' }); + }, + + post(path: string, body?: unknown, opts?: RequestOptions) { + return request(path, { ...opts, body, method: 'POST' }); + }, + + put(path: string, body?: unknown, opts?: RequestOptions) { + return request(path, { ...opts, body, method: 'PUT' }); + }, + + patch(path: string, body?: unknown, opts?: RequestOptions) { + return request(path, { ...opts, body, method: 'PATCH' }); + }, + + delete(path: string, opts?: RequestOptions) { + return request(path, { ...opts, method: 'DELETE' }); + }, +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e7089299..801d75dc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; +import path from 'path'; export default defineConfig({ plugins: [ @@ -12,6 +13,7 @@ export default defineConfig({ strategies: 'injectManifest', injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, // 15 MB — UI5 bundle is large }, devOptions: { enabled: false, @@ -19,6 +21,11 @@ export default defineConfig({ manifest: false, // Using existing manifest.json in public/ }), ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, // Production build configuration build: { outDir: 'dist', @@ -27,10 +34,16 @@ export default defineConfig({ minify: 'terser', rollupOptions: { output: { - manualChunks: { - vendor: ['react', 'react-dom', 'react-router-dom'], - ui5: ['@ui5/webcomponents', '@ui5/webcomponents-fiori', '@ui5/webcomponents-react'], - charts: ['recharts'], + manualChunks(id) { + if (id.includes('node_modules/react-dom/') || id.includes('node_modules/react/') || id.includes('node_modules/react-router/')) { + return 'vendor'; + } + if (id.includes('node_modules/@ui5/')) { + return 'ui5'; + } + if (id.includes('node_modules/recharts/') || id.includes('node_modules/d3-')) { + return 'charts'; + } }, }, },