313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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;
|