Files
flyer-crawler.projectium.com/server.ts
Torben Sorensen 2d2cd52011
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3m58s
Massive Dependency Modernization Project
2026-02-13 00:34:22 -08:00

348 lines
13 KiB
TypeScript

// server.ts
/**
* IMPORTANT: Sentry initialization MUST happen before any other imports
* to ensure all errors are captured, including those in imported modules.
* See ADR-015: Application Performance Monitoring and Error Tracking.
*/
import { initSentry, getSentryMiddleware } from './src/services/sentry.server';
initSentry();
import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import helmet from 'helmet';
import timeout from 'connect-timeout';
import cookieParser from 'cookie-parser';
import listEndpoints from 'express-list-endpoints';
import { getPool } from './src/services/db/connection.db';
import passport from './src/config/passport';
import { logger } from './src/services/logger.server';
// Import the versioned API router factory (ADR-008 Phase 2)
import { createApiRouter } from './src/routes/versioned';
import { errorHandler } from './src/middleware/errorHandler';
import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService';
import { websocketService } from './src/services/websocketService.server';
import type { UserProfile } from './src/types';
// API Documentation (ADR-018) - tsoa-generated OpenAPI spec
import swaggerUi from 'swagger-ui-express';
import tsoaSpec from './src/config/tsoa-spec.json' with { type: 'json' };
// tsoa-generated routes
import { RegisterRoutes } from './src/routes/tsoa-generated';
import {
analyticsQueue,
weeklyAnalyticsQueue,
gracefulShutdown,
tokenCleanupQueue,
} from './src/services/queueService.server';
import { monitoringService } from './src/services/monitoringService.server';
// --- START DEBUG LOGGING ---
// Log the database connection details as seen by the SERVER PROCESS.
// This will confirm if the `--env-file` flag is working as expected.
logger.info('--- [SERVER PROCESS LOG] DATABASE CONNECTION ---');
logger.info(` NODE_ENV: ${process.env.NODE_ENV}`);
logger.info(` Host: ${process.env.DB_HOST}`);
logger.info(` Port: ${process.env.DB_PORT}`);
logger.info(` User: ${process.env.DB_USER}`);
logger.info(` Database: ${process.env.DB_NAME}`);
// Query the users table to see what the server process sees on startup.
// Corrected the query to be unambiguous by specifying the table alias for each column.
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
getPool()
.query(
'SELECT u.user_id, u.email, p.role FROM public.users u JOIN public.profiles p ON u.user_id = p.user_id',
)
.then((res) => {
logger.debug('[SERVER PROCESS] Users found in DB on startup:');
console.table(res.rows);
})
.catch((err) => {
logger.error({ err }, '[SERVER PROCESS] Could not query users table on startup.');
});
logger.info('-----------------------------------------------\n');
const app = express();
// --- Security Headers Middleware (ADR-016) ---
// Helmet sets various HTTP headers to help protect the app from common web vulnerabilities.
// Must be applied early in the middleware chain, before any routes.
app.use(
helmet({
// Content Security Policy - configured for API + SPA frontend
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for React
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for Tailwind
imgSrc: ["'self'", 'data:', 'blob:', 'https:'], // Allow images from various sources
fontSrc: ["'self'", 'https:', 'data:'],
connectSrc: ["'self'", 'https:', 'wss:'], // Allow API and WebSocket connections
frameSrc: ["'none'"], // Disallow iframes
objectSrc: ["'none'"], // Disallow plugins
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null,
},
},
// Cross-Origin settings for API
crossOriginEmbedderPolicy: false, // Disabled to allow loading external images
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allow cross-origin resource loading
// Additional security headers
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}),
);
// --- Core Middleware ---
// Increase the limit for JSON and URL-encoded bodies. This is crucial for handling large file uploads
// that are part of multipart/form-data requests, as the overall request size is checked.
// Setting a 50MB limit to accommodate large flyer images.
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ limit: '100mb', extended: true }));
app.use(cookieParser()); // Middleware to parse cookies
app.use(passport.initialize()); // Initialize Passport
// --- Sentry Request Handler (ADR-015) ---
// Must be the first middleware after body parsers to capture request data for errors.
const sentryMiddleware = getSentryMiddleware();
app.use(sentryMiddleware.requestHandler);
// --- MOCK AUTH FOR TESTING ---
// This MUST come after passport.initialize() and BEFORE any of the API routes.
import { mockAuth } from './src/config/passport';
app.use(mockAuth);
// Add a request timeout middleware. This will help prevent requests from hanging indefinitely.
// We set a generous 5-minute timeout to accommodate slow AI processing for large flyers.
app.use(timeout('5m'));
// --- Logging Middleware ---
const getDurationInMilliseconds = (start: [number, number]): number => {
const NS_PER_SEC = 1e9;
const NS_TO_MS = 1e6;
const diff = process.hrtime(start);
return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS;
};
/**
* Defines the structure for the detailed log object created for each request.
* This ensures type safety and consistency in our structured logs.
*/
interface RequestLogDetails {
user_id?: string;
method: string;
originalUrl: string;
statusCode: number;
statusMessage: string;
duration: string;
// The 'req' property is added conditionally for client/server errors.
req?: { headers: express.Request['headers']; body: express.Request['body'] };
}
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
const requestId = randomUUID();
const user = req.user as UserProfile | undefined;
const start = process.hrtime();
const { method, originalUrl } = req;
// Create a request-scoped logger instance as per ADR-004
// This attaches contextual info to every log message generated for this request.
req.log = logger.child({
request_id: requestId,
user_id: user?.user.user_id, // This will be undefined until the auth middleware runs, but the logger will hold the reference.
ip_address: req.ip,
});
req.log.debug({ method, originalUrl }, `[Request Logger] INCOMING`);
res.on('finish', () => {
const durationInMilliseconds = getDurationInMilliseconds(start);
const { statusCode, statusMessage } = res;
const finalUser = req.user as UserProfile | undefined;
// The base log object includes details relevant for all status codes.
const logDetails: RequestLogDetails = {
user_id: finalUser?.user.user_id,
method,
originalUrl,
statusCode,
statusMessage,
duration: durationInMilliseconds.toFixed(2),
};
// For failed requests, add the full request details for better debugging.
// Pino's `redact` config will automatically sanitize sensitive headers and body fields.
if (statusCode >= 400) {
logDetails.req = { headers: req.headers, body: req.body };
}
if (statusCode >= 500) req.log.error(logDetails, 'Request completed with server error');
else if (statusCode >= 400) req.log.warn(logDetails, 'Request completed with client error');
else req.log.info(logDetails, 'Request completed successfully');
});
next();
};
app.use(requestLogger); // Use the logging middleware for all requests
// --- Security Warning ---
if (!process.env.JWT_SECRET) {
logger.error('CRITICAL: JWT_SECRET is not set. The application cannot start securely.');
process.exit(1);
}
// --- API Documentation (ADR-018) ---
// Only serve Swagger UI in non-production environments to prevent information disclosure.
// Uses tsoa-generated OpenAPI specification.
if (process.env.NODE_ENV !== 'production') {
// Serve tsoa-generated OpenAPI documentation
app.use(
'/docs/api-docs',
swaggerUi.serve,
swaggerUi.setup(tsoaSpec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Flyer Crawler API Documentation',
}),
);
// Expose raw OpenAPI JSON spec for tooling (SDK generation, testing, etc.)
app.get('/docs/api-docs.json', (_req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(tsoaSpec);
});
logger.info('API Documentation available at /docs/api-docs');
}
// --- API Routes (ADR-008: API Versioning Strategy - Phase 1) ---
// ADR-053: Worker Health Checks
// Expose queue metrics for monitoring at versioned endpoint.
app.get('/api/v1/health/queues', async (req, res) => {
try {
const statuses = await monitoringService.getQueueStatuses();
res.json(statuses);
} catch (error) {
logger.error({ err: error }, 'Failed to fetch queue statuses');
res.status(503).json({ error: 'Failed to fetch queue statuses' });
}
});
// --- tsoa-generated Routes ---
// Register routes generated by tsoa from controllers.
// These routes run in parallel with existing routes during migration.
// tsoa routes are mounted directly on the app (basePath in tsoa.json is '/api').
// The RegisterRoutes function adds routes at /api/health/*, /api/_tsoa/*, etc.
//
// IMPORTANT: tsoa routes are registered BEFORE the backwards compatibility redirect
// middleware so that tsoa routes (like /api/health/ping, /api/_tsoa/verify) are
// matched directly without being redirected to /api/v1/*.
// During migration, both tsoa routes and versioned routes coexist:
// - /api/health/ping -> handled by tsoa HealthController
// - /api/v1/health/ping -> handled by versioned health.routes.ts
// As controllers are migrated, the versioned routes will be removed.
RegisterRoutes(app);
// --- Backwards Compatibility Redirect (ADR-008: API Versioning Strategy) ---
// Redirect old /api/* paths to /api/v1/* for backwards compatibility.
// This allows clients to gradually migrate to the versioned API.
// IMPORTANT: This middleware MUST be mounted:
// - AFTER tsoa routes (so tsoa routes are matched directly)
// - BEFORE createApiRouter() (so unversioned paths are redirected to /api/v1/*)
app.use('/api', (req, res, next) => {
// Check if the path starts with a version-like prefix (/v followed by digits).
// This includes both supported versions (v1, v2) and unsupported ones (v99).
// Unsupported versions will be handled by detectApiVersion middleware which returns 404.
// This redirect only handles legacy unversioned paths like /api/users -> /api/v1/users.
const versionPattern = /^\/v\d+/;
const startsWithVersionPattern = versionPattern.test(req.path);
if (!startsWithVersionPattern) {
const newPath = `/api/v1${req.path}`;
logger.info({ oldPath: `/api${req.path}`, newPath }, 'Redirecting to versioned API');
return res.redirect(301, newPath);
}
next();
});
// Mount the versioned API router (ADR-008 Phase 2).
// The createApiRouter() factory handles:
// - Version detection and validation via detectApiVersion middleware
// - Route registration in correct precedence order
// - Version-specific route availability
// - Deprecation headers via addDeprecationHeaders middleware
// - X-API-Version response headers
// All domain routers are registered in versioned.ts with proper ordering.
app.use('/api', createApiRouter());
// --- Error Handling and Server Startup ---
// Catch-all 404 handler for unmatched routes.
// Returns JSON instead of HTML for API consistency.
app.use((req: Request, res: Response) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `Cannot ${req.method} ${req.path}`,
},
});
});
// Sentry Error Handler (ADR-015) - captures errors and sends to Bugsink.
// Must come BEFORE the custom error handler but AFTER all routes.
app.use(sentryMiddleware.errorHandler);
// Global error handling middleware. This must be the last `app.use()` call.
app.use(errorHandler);
// --- Server Startup ---
// Only start the server and background jobs if the file is run directly,
// not when it's imported by another module (like the integration test setup).
// This prevents the server from trying to listen on a port during tests.
if (process.env.NODE_ENV !== 'test') {
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
logger.info(`Authentication server started on port ${PORT}`);
console.log('--- REGISTERED API ROUTES ---');
console.table(listEndpoints(app));
console.log('-----------------------------');
});
// Initialize WebSocket server (ADR-022)
websocketService.initialize(server);
logger.info('WebSocket server initialized for real-time notifications');
// Start the scheduled background jobs
startBackgroundJobs(
backgroundJobService,
analyticsQueue,
weeklyAnalyticsQueue,
tokenCleanupQueue,
logger,
);
// --- Graceful Shutdown Handling ---
const handleShutdown = (signal: string) => {
logger.info(`${signal} received, starting graceful shutdown...`);
// Shutdown WebSocket server
websocketService.shutdown();
// Shutdown queues and workers
gracefulShutdown(signal);
};
process.on('SIGINT', () => handleShutdown('SIGINT'));
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
}
// Export the app for integration testing
export default app;