import express from 'express'; import cors from 'cors'; import passport from 'passport'; import path from 'path'; import fs from 'fs'; import swaggerUI from 'swagger-ui-express'; import swaggerJsDoc from 'swagger-jsdoc'; import config from '@/shared/config'; import csrfOrigin from '@/middlewares/csrf-origin'; import requestLogger from '@/middlewares/request-logger'; import { resolveActiveScope } from '@/middlewares/resolve-active-scope'; import { AUTH_COOKIE_NAME, AUTH_REFRESH_COOKIE_NAME, } from '@/shared/constants/auth'; import ForbiddenError from '@/shared/errors/forbidden'; import { errorHandler, notFoundHandler, } from '@/middlewares/error-handler'; import logger from '@/shared/logger'; import '@/auth/auth'; // Global error handlers to prevent server crashes from unhandled errors process.on('uncaughtException', (error: Error) => { logger.error('Uncaught Exception - server continues running:', error); }); process.on('unhandledRejection', (reason: unknown) => { const error = reason instanceof Error ? reason : new Error(String(reason)); logger.error('Unhandled Promise Rejection - server continues running:', error); }); import authRoutes from '@/routes/auth'; import fileRoutes from '@/routes/file'; import searchRoutes from '@/routes/search'; import publicCampusesRoutes from '@/routes/public_campuses'; import contentCatalogRoutes from '@/routes/content_catalog'; import usersRoutes from '@/routes/users'; import rolesRoutes from '@/routes/roles'; import permissionsRoutes from '@/routes/permissions'; import organizationsRoutes from '@/routes/organizations'; import iamCapabilitiesRoutes from '@/routes/iam_capabilities'; import scopeRoutes from '@/routes/scope'; import platformRoutes from '@/routes/platform'; import directMessagesRoutes from '@/routes/direct_messages'; import schoolsRoutes from '@/routes/schools'; import campusesRoutes from '@/routes/campuses'; import academicYearsRoutes from '@/routes/academic_years'; import gradesRoutes from '@/routes/grades'; import subjectsRoutes from '@/routes/subjects'; import guardianStudentsRoutes from '@/routes/guardian_students'; import classesRoutes from '@/routes/classes'; import classEnrollmentsRoutes from '@/routes/class_enrollments'; import classSubjectsRoutes from '@/routes/class_subjects'; import timetablesRoutes from '@/routes/timetables'; import timetablePeriodsRoutes from '@/routes/timetable_periods'; import attendanceSessionsRoutes from '@/routes/attendance_sessions'; import attendanceRecordsRoutes from '@/routes/attendance_records'; import assessmentsRoutes from '@/routes/assessments'; import assessmentResultsRoutes from '@/routes/assessment_results'; import messagesRoutes from '@/routes/messages'; import messageRecipientsRoutes from '@/routes/message_recipients'; import frameEntriesRoutes from '@/routes/frame_entries'; import userProgressRoutes from '@/routes/user_progress'; import safetyQuizResultsRoutes from '@/routes/safety_quiz_results'; import walkthroughCheckinsRoutes from '@/routes/walkthrough_checkins'; import communicationsRoutes from '@/routes/communications'; import personalityQuizResultsRoutes from '@/routes/personality_quiz_results'; import campusAttendanceRoutes from '@/routes/campus_attendance'; import classAttendanceRoutes from '@/routes/class_attendance'; import staffAttendanceRoutes from '@/routes/staff_attendance'; import policyDocumentsRoutes from '@/routes/policy_documents'; import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments'; import audioFilesRoutes from '@/routes/audio_files'; import zoneCheckinsRoutes from '@/routes/zone_checkins'; const app = express(); // JWT auth, then resolve any drill-down active-tenant header into // `req.currentUser.activeScope` (validated against the user's scope). const authenticated = [ passport.authenticate('jwt', { session: false }), resolveActiveScope, ]; function getBaseUrl(url: string | undefined): string { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; } const swaggerOptions: swaggerJsDoc.Options = { definition: { openapi: '3.0.0', info: { version: '1.0.0', title: 'School Chain Manager API', description: [ 'REST API for the School Chain Manager backend.', '', '**Authentication is cookie-based.** Sign-in sets two HttpOnly cookies:', `a short-lived access cookie (\`${AUTH_COOKIE_NAME}\`, a signed JWT) and a`, `long-lived opaque refresh cookie (\`${AUTH_REFRESH_COOKIE_NAME}\`). The`, 'browser never reads or sends tokens manually; they travel automatically', 'as cookies. The Swagger "Authorize" cookie field is for tooling only.', '', '**Authorization is by permission.** Most routes require a', '`${METHOD}_${ENTITY}` CRUD permission (e.g. `READ_USERS`); product', 'feature routes require a product-feature permission (e.g. `READ_FRAME`,', '`FILL_ATTENDANCE`, `TAKE_QUIZ`). `system_admin` keeps global scope,', 'while only `super_admin` bypasses standard per-permission checks.', '', '**Generic-CRUD convention.** Entity routers (`users`, `roles`,', '`campuses`, …) expose the same shape: `GET /` (list → `ListResponse`),', '`GET /count`, `GET /autocomplete`, `GET /:id`, `POST /`,', '`POST /bulk-import`, `PUT /:id`, `DELETE /:id`, `POST /deleteByIds`.', 'Errors share the `Error` schema.', ].join('\n'), }, servers: [ { url: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, description: 'Development server', }, ], components: { securitySchemes: { cookieAuth: { type: 'apiKey', in: 'cookie', name: AUTH_COOKIE_NAME, description: 'HttpOnly access cookie set on sign-in/refresh. Sent automatically by the browser.', }, }, schemas: { Error: { type: 'object', description: 'Standard error body produced by the terminal error handler.', properties: { message: { type: 'string' }, code: { type: 'string', nullable: true }, details: { nullable: true }, }, required: ['message'], }, ListResponse: { type: 'object', description: 'Standard paginated list payload.', properties: { rows: { type: 'array', items: { type: 'object' } }, count: { type: 'integer' }, }, required: ['rows', 'count'], }, UserProfile: { type: 'object', description: 'The authenticated user profile returned by sign-in, refresh, and `/me`.', properties: { id: { type: 'string', format: 'uuid' }, email: { type: 'string' }, name_prefix: { type: 'string', nullable: true }, firstName: { type: 'string', nullable: true }, lastName: { type: 'string', nullable: true }, organizationId: { type: 'string', format: 'uuid', nullable: true }, app_role: { type: 'object', nullable: true, properties: { id: { type: 'string', format: 'uuid' }, name: { type: 'string' }, scope: { type: 'string' }, globalAccess: { type: 'boolean' }, }, }, campusId: { type: 'string', format: 'uuid', nullable: true }, permissions: { type: 'array', items: { type: 'string' } }, }, }, }, responses: { UnauthorizedError: { description: 'No valid session cookie (401).', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, }, }, }, ForbiddenError: { description: 'Authenticated but lacks the required permission (403).', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, }, }, }, ValidationError: { description: 'Invalid input or not found (400).', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' }, }, }, }, }, }, security: [{ cookieAuth: [] }], }, apis: ['./src/routes/*.ts'], }; const specs = swaggerJsDoc(swaggerOptions); app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs)); app.use( cors({ credentials: true, origin(origin, callback) { if ( !origin || config.auth.allowAllOrigins || config.auth.allowedOrigins.includes(origin) ) { callback(null, origin || true); return; } callback(new ForbiddenError()); }, }), ); app.use(express.json()); app.use('/api', csrfOrigin); app.use('/api', requestLogger); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); app.use('/api/public/campuses', publicCampusesRoutes); app.enable('trust proxy'); app.use('/api/users', authenticated, usersRoutes); app.use('/api/roles', authenticated, rolesRoutes); app.use('/api/permissions', authenticated, permissionsRoutes); app.use('/api/organizations', authenticated, organizationsRoutes); app.use('/api/iam', authenticated, iamCapabilitiesRoutes); app.use('/api/scope', authenticated, scopeRoutes); app.use('/api/platform', authenticated, platformRoutes); app.use('/api/schools', authenticated, schoolsRoutes); app.use('/api/campuses', authenticated, campusesRoutes); app.use('/api/academic_years', authenticated, academicYearsRoutes); app.use('/api/grades', authenticated, gradesRoutes); app.use('/api/subjects', authenticated, subjectsRoutes); app.use('/api/guardian_students', authenticated, guardianStudentsRoutes); app.use('/api/classes', authenticated, classesRoutes); app.use('/api/class_enrollments', authenticated, classEnrollmentsRoutes); app.use('/api/class_subjects', authenticated, classSubjectsRoutes); app.use('/api/timetables', authenticated, timetablesRoutes); app.use('/api/timetable_periods', authenticated, timetablePeriodsRoutes); app.use('/api/attendance_sessions', authenticated, attendanceSessionsRoutes); app.use('/api/attendance_records', authenticated, attendanceRecordsRoutes); app.use('/api/assessments', authenticated, assessmentsRoutes); app.use('/api/assessment_results', authenticated, assessmentResultsRoutes); app.use('/api/messages', authenticated, messagesRoutes); app.use('/api/message_recipients', authenticated, messageRecipientsRoutes); app.use('/api/frame_entries', authenticated, frameEntriesRoutes); app.use('/api/user_progress', authenticated, userProgressRoutes); app.use('/api/safety_quiz_results', authenticated, safetyQuizResultsRoutes); app.use('/api/walkthrough_checkins', authenticated, walkthroughCheckinsRoutes); app.use('/api/communications', authenticated, communicationsRoutes); app.use('/api/direct_messages', authenticated, directMessagesRoutes); app.use( '/api/personality_quiz_results', authenticated, personalityQuizResultsRoutes, ); app.use('/api/campus_attendance', authenticated, campusAttendanceRoutes); app.use('/api/class_attendance', authenticated, classAttendanceRoutes); app.use('/api/staff_attendance', authenticated, staffAttendanceRoutes); app.use('/api/content-catalog', authenticated, contentCatalogRoutes); app.use('/api/policy_documents', authenticated, policyDocumentsRoutes); app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes); app.use('/api/audio_files', authenticated, audioFilesRoutes); app.use('/api/zone_checkins', authenticated, zoneCheckinsRoutes); app.use('/api/search', authenticated, searchRoutes); // Unmatched API routes → centralized 404 (the SPA fallback below handles the rest). app.use('/api', notFoundHandler); const __dirname = import.meta.dirname; const publicDir = path.join(__dirname, '../public'); if (fs.existsSync(publicDir)) { app.use('/', express.static(publicDir)); app.get('/*splat', (_request, response) => { response.sendFile(path.resolve(publicDir, 'index.html')); }); } // Terminal error middleware — must be registered after all routes/middleware. app.use(errorHandler); const PORT = config.serverPort; app.listen(PORT, () => { logger.info(`Listening on port ${PORT}`); }); export default app;