create-lumina-project 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/cli.js +106 -28
  2. package/package.json +3 -2
  3. package/template/.env.example +1 -0
  4. package/template/package.json +61 -55
  5. package/template/public/js/status.js +53 -0
  6. package/template/scripts/create-migration.ts +457 -16
  7. package/template/server.ts +50 -8
  8. package/template/src/config/database.ts +11 -13
  9. package/template/src/config/env.ts +54 -0
  10. package/template/src/controllers/AuthController.ts +30 -0
  11. package/template/src/database/migrations/20260222151300-create_refresh_tokens.js +63 -0
  12. package/template/src/database/migrations/20260420050854-create_users.js +68 -0
  13. package/template/src/database/migrations/20260420050855-create_refresh_tokens.js +60 -0
  14. package/template/src/exceptions/Handler.ts +10 -3
  15. package/template/src/middlewares/Authentication.ts +2 -5
  16. package/template/src/middlewares/Csrf.ts +52 -0
  17. package/template/src/middlewares/Maintenance.ts +2 -4
  18. package/template/src/middlewares/RequestLogger.ts +31 -0
  19. package/template/src/models/RefreshToken.ts +69 -0
  20. package/template/src/models/index.ts +4 -6
  21. package/template/src/routes/api.ts +5 -0
  22. package/template/src/routes/web.ts +34 -5
  23. package/template/src/services/AuthService.ts +101 -9
  24. package/template/src/services/StorageService.ts +36 -9
  25. package/template/src/tests/apiResponse.test.ts +66 -0
  26. package/template/src/tests/env.test.ts +99 -0
  27. package/template/src/tests/hash.test.ts +30 -0
  28. package/template/src/utils/Logger.ts +16 -6
  29. package/template/views/404.html +233 -0
  30. package/template/views/maintenance.html +445 -23
  31. package/template/views/status.html +545 -0
  32. package/template/views/welcome.html +472 -39
  33. package/template/vitest.config.ts +10 -0
@@ -2,6 +2,18 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import Logger from '../src/utils/Logger.js';
4
4
 
5
+ interface ColumnDefinition {
6
+ type: string;
7
+ allowNull?: boolean;
8
+ primaryKey?: boolean;
9
+ autoIncrement?: boolean;
10
+ unique?: boolean;
11
+ defaultValue?: string;
12
+ references?: { model: string; key: string };
13
+ onUpdate?: string;
14
+ onDelete?: string;
15
+ }
16
+
5
17
  class MigrationGenerator {
6
18
  private nameArgument: string;
7
19
  private migrationName: string;
@@ -9,12 +21,14 @@ class MigrationGenerator {
9
21
  private tableName: string;
10
22
  private migrationsDir: string;
11
23
  private stubPath: string;
24
+ private modelsDir: string;
12
25
 
13
26
  constructor() {
14
27
  this.nameArgument = process.argv[2];
15
28
  this.migrationsDir = path.join(process.cwd(), 'src', 'database', 'migrations');
16
29
  this.stubPath = path.join(process.cwd(), 'scripts', 'stubs', 'migration.stub');
17
-
30
+ this.modelsDir = path.join(process.cwd(), 'src', 'models');
31
+
18
32
  // Initialize properties safely
19
33
  this.migrationName = '';
20
34
  this.className = '';
@@ -24,15 +38,19 @@ class MigrationGenerator {
24
38
  public run(): void {
25
39
  if (!this.validateInput()) return;
26
40
 
27
- this.parseNameArgument();
28
- this.createMigrationFile();
41
+ if (this.nameArgument.toLowerCase() === 'all') {
42
+ this.generateAllMigrations();
43
+ } else {
44
+ this.parseNameArgument();
45
+ this.createMigrationFile();
46
+ }
29
47
  }
30
48
 
31
49
  private validateInput(): boolean {
32
50
  if (!this.nameArgument) {
33
51
  Logger.error('Please provide a migration name.');
34
- console.log('\nUsage: npm run make:migration <name>');
35
- console.log('Example: npm run make:migration create_products_table\n');
52
+ console.log('\nUsage: npm run create:migration <name> | all');
53
+ console.log('Example: npm run create:migration create_products_table\n');
36
54
  process.exit(1);
37
55
  }
38
56
  return true;
@@ -57,16 +75,348 @@ class MigrationGenerator {
57
75
  if (match && match[1]) {
58
76
  this.tableName = match[1];
59
77
  } else {
60
- this.tableName = this.migrationName;
78
+ // Also try without _table suffix: "create_users" -> "users"
79
+ const simpleMatch = this.migrationName.match(/create_(.+)/);
80
+ if (simpleMatch && simpleMatch[1]) {
81
+ this.tableName = simpleMatch[1];
82
+ } else {
83
+ this.tableName = this.migrationName;
84
+ }
61
85
  }
62
86
  }
63
87
 
64
88
  /**
65
89
  * Generates YYYYMMDDHHMMSS format
66
90
  */
67
- private getTimestamp(): string {
68
- const now = new Date();
69
- return now.toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
91
+ private getTimestamp(date: Date = new Date()): string {
92
+ return date.toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
93
+ }
94
+
95
+ /**
96
+ * Attempts to find a matching model file based on the table name.
97
+ * Tries multiple strategies: PascalCase singularization, direct tableName match,
98
+ * and pluralized tableName match to handle inputs like "user", "users", "refresh_tokens".
99
+ * When a model is found, updates this.tableName to match the model's actual tableName.
100
+ */
101
+ private findModelFile(): string | null {
102
+ if (!fs.existsSync(this.modelsDir)) return null;
103
+
104
+ const files = fs.readdirSync(this.modelsDir).filter(
105
+ f => f.endsWith('.ts') && f !== 'index.ts'
106
+ );
107
+
108
+ // Strategy 1: Singularize table name and PascalCase it -> Model file
109
+ // e.g. "users" -> "User.ts", "refresh_tokens" -> "RefreshToken.ts"
110
+ const singularized = this.singularize(this.tableName);
111
+ const expectedModelName = this.toPascalCase(singularized);
112
+
113
+ const directMatch = files.find(f => f === `${expectedModelName}.ts`);
114
+ if (directMatch) {
115
+ const filePath = path.join(this.modelsDir, directMatch);
116
+ this.syncTableName(filePath);
117
+ return filePath;
118
+ }
119
+
120
+ // Strategy 2: Try PascalCase without singularizing (in case table name is already singular)
121
+ const rawPascal = this.toPascalCase(this.tableName);
122
+ const rawMatch = files.find(f => f === `${rawPascal}.ts`);
123
+ if (rawMatch) {
124
+ const filePath = path.join(this.modelsDir, rawMatch);
125
+ this.syncTableName(filePath);
126
+ return filePath;
127
+ }
128
+
129
+ // Strategy 3: Scan all model files for a matching tableName (try both singular and plural)
130
+ const possibleTableNames = new Set([this.tableName, this.pluralize(this.tableName), singularized]);
131
+ for (const file of files) {
132
+ const filePath = path.join(this.modelsDir, file);
133
+ const content = fs.readFileSync(filePath, 'utf-8');
134
+ for (const name of possibleTableNames) {
135
+ if (content.includes(`tableName: '${name}'`)) {
136
+ // Update tableName to match what the model actually uses
137
+ this.tableName = name;
138
+ return filePath;
139
+ }
140
+ }
141
+ }
142
+
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Reads a model file and updates this.tableName to match the model's actual tableName.
148
+ */
149
+ private syncTableName(filePath: string): void {
150
+ const content = fs.readFileSync(filePath, 'utf-8');
151
+ const tableNameMatch = content.match(/tableName:\s*'(\w+)'/);
152
+ if (tableNameMatch) {
153
+ this.tableName = tableNameMatch[1];
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Converts a snake_case string to PascalCase.
159
+ */
160
+ private toPascalCase(str: string): string {
161
+ return str
162
+ .split('_')
163
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
164
+ .join('');
165
+ }
166
+
167
+ /**
168
+ * Basic pluralization: adds trailing 's'.
169
+ */
170
+ private pluralize(word: string): string {
171
+ if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z') || word.endsWith('ch') || word.endsWith('sh')) {
172
+ return word + 'es';
173
+ }
174
+ if (word.endsWith('y') && !['a','e','i','o','u'].includes(word[word.length - 2])) {
175
+ return word.slice(0, -1) + 'ies';
176
+ }
177
+ return word + 's';
178
+ }
179
+
180
+ /**
181
+ * Basic singularization: removes trailing 's'.
182
+ * Handles common patterns like "ies" -> "y".
183
+ */
184
+ private singularize(word: string): string {
185
+ if (word.endsWith('ies')) {
186
+ return word.slice(0, -3) + 'y';
187
+ }
188
+ if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
189
+ return word.slice(0, -2);
190
+ }
191
+ if (word.endsWith('s') && !word.endsWith('ss')) {
192
+ return word.slice(0, -1);
193
+ }
194
+ return word;
195
+ }
196
+
197
+ /**
198
+ * Parses a Sequelize model file to extract column definitions from the init() call
199
+ * and model options (paranoid, timestamps).
200
+ */
201
+ private parseModelFile(filePath: string): { columns: Map<string, ColumnDefinition>; paranoid: boolean; timestamps: boolean; associations: string[] } {
202
+ const content = fs.readFileSync(filePath, 'utf-8');
203
+ const columns = new Map<string, ColumnDefinition>();
204
+ const associations: string[] = [];
205
+
206
+ // Detect paranoid & timestamps from model options
207
+ const paranoid = /paranoid:\s*true/.test(content);
208
+ const timestamps = /timestamps:\s*true/.test(content) || !/timestamps:\s*false/.test(content);
209
+
210
+ // Extract the init() block - find the column definitions object
211
+ // We look for the pattern: ModelName.init({ ... }, { ... })
212
+ const initMatch = content.match(/\.init\s*\(\s*\{([\s\S]*?)\}\s*,\s*\{/);
213
+ if (!initMatch) {
214
+ return { columns, paranoid, timestamps, associations };
215
+ }
216
+
217
+ const columnsBlock = initMatch[1];
218
+
219
+ // Parse individual column definitions using a state-machine approach
220
+ this.parseColumnsBlock(columnsBlock, columns);
221
+
222
+ // Parse associations for foreign key references
223
+ this.parseAssociations(content, associations);
224
+
225
+ return { columns, paranoid, timestamps, associations };
226
+ }
227
+
228
+ /**
229
+ * Parses the columns block from Model.init() to extract column definitions.
230
+ */
231
+ private parseColumnsBlock(block: string, columns: Map<string, ColumnDefinition>): void {
232
+ // Match each column: `columnName: { ... }`
233
+ const columnRegex = /(\w+)\s*:\s*\{([^}]+)\}/g;
234
+ let match;
235
+
236
+ while ((match = columnRegex.exec(block)) !== null) {
237
+ const columnName = match[1];
238
+ const columnBody = match[2];
239
+
240
+ const colDef: ColumnDefinition = {
241
+ type: this.extractType(columnBody),
242
+ };
243
+
244
+ // Extract allowNull
245
+ const allowNullMatch = columnBody.match(/allowNull:\s*(true|false)/);
246
+ if (allowNullMatch) {
247
+ colDef.allowNull = allowNullMatch[1] === 'true';
248
+ }
249
+
250
+ // Extract primaryKey
251
+ if (/primaryKey:\s*true/.test(columnBody)) {
252
+ colDef.primaryKey = true;
253
+ }
254
+
255
+ // Extract autoIncrement
256
+ if (/autoIncrement:\s*true/.test(columnBody)) {
257
+ colDef.autoIncrement = true;
258
+ }
259
+
260
+ // Extract unique
261
+ if (/unique:\s*true/.test(columnBody)) {
262
+ colDef.unique = true;
263
+ }
264
+
265
+ // Extract defaultValue
266
+ const defaultMatch = columnBody.match(/defaultValue:\s*(.+?)(?:,|\s*$)/);
267
+ if (defaultMatch) {
268
+ const val = defaultMatch[1].trim();
269
+ // Clean trailing commas or spaces
270
+ colDef.defaultValue = val.replace(/,\s*$/, '');
271
+ }
272
+
273
+ columns.set(columnName, colDef);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Extracts the DataTypes.XXX type string from a column definition body.
279
+ */
280
+ private extractType(body: string): string {
281
+ // Match patterns like: DataTypes.STRING, DataTypes.STRING(50), DataTypes.BIGINT
282
+ const typeMatch = body.match(/type:\s*(DataTypes\.\w+(?:\([^)]*\))?)/);
283
+ if (typeMatch) {
284
+ return typeMatch[1];
285
+ }
286
+ return 'DataTypes.STRING';
287
+ }
288
+
289
+ /**
290
+ * Parses association methods (belongsTo, hasMany, etc.) for foreign key info.
291
+ */
292
+ private parseAssociations(content: string, associations: string[]): void {
293
+ // Match belongsTo associations to detect foreign keys
294
+ const belongsToRegex = /belongsTo\s*\(\s*models\.(\w+)\s*,\s*\{[^}]*foreignKey:\s*'(\w+)'/g;
295
+ let match;
296
+ while ((match = belongsToRegex.exec(content)) !== null) {
297
+ associations.push(`${match[2]}:${match[1]}`);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Generates the migration file content by scanning the model.
303
+ */
304
+ private generateFromModel(modelPath: string): string {
305
+ const { columns, paranoid, timestamps, associations } = this.parseModelFile(modelPath);
306
+
307
+ // Map of foreignKey -> tableName
308
+ const fkMap = new Map<string, string>();
309
+ for (const assoc of associations) {
310
+ const [fk, modelName] = assoc.split(':');
311
+ // Pluralize the lowercased model name to guess target table
312
+ const targetTable = this.pluralize(modelName.toLowerCase());
313
+ fkMap.set(fk, targetTable);
314
+ }
315
+
316
+ // Build column definitions string
317
+ const lines: string[] = [];
318
+ const indent = ' ';
319
+
320
+ for (const [name, col] of columns) {
321
+ // Skip id — we'll always add it manually with BIGINT for migration
322
+ if (name === 'id') {
323
+ lines.push(`${indent}id: {`);
324
+ lines.push(`${indent} allowNull: false,`);
325
+ lines.push(`${indent} autoIncrement: true,`);
326
+ lines.push(`${indent} primaryKey: true,`);
327
+ lines.push(`${indent} type: DataTypes.BIGINT,`);
328
+ lines.push(`${indent}},`);
329
+ continue;
330
+ }
331
+
332
+ lines.push(`${indent}${name}: {`);
333
+ lines.push(`${indent} type: ${this.migrationDataType(col.type)},`);
334
+
335
+ if (col.allowNull !== undefined) {
336
+ lines.push(`${indent} allowNull: ${col.allowNull},`);
337
+ }
338
+ if (col.unique) {
339
+ lines.push(`${indent} unique: true,`);
340
+ }
341
+ if (col.defaultValue !== undefined) {
342
+ lines.push(`${indent} defaultValue: ${col.defaultValue},`);
343
+ }
344
+
345
+ if (fkMap.has(name)) {
346
+ lines.push(`${indent} references: {`);
347
+ lines.push(`${indent} model: '${fkMap.get(name)}',`);
348
+ lines.push(`${indent} key: 'id',`);
349
+ lines.push(`${indent} },`);
350
+ lines.push(`${indent} onUpdate: 'CASCADE',`);
351
+ lines.push(`${indent} onDelete: 'CASCADE',`);
352
+ }
353
+
354
+ lines.push(`${indent}},`);
355
+ }
356
+
357
+ // Add timestamps
358
+ if (timestamps) {
359
+ lines.push(`${indent}created_at: {`);
360
+ lines.push(`${indent} type: DataTypes.DATE,`);
361
+ lines.push(`${indent} allowNull: false,`);
362
+ lines.push(`${indent} defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),`);
363
+ lines.push(`${indent}},`);
364
+ lines.push(`${indent}updated_at: {`);
365
+ lines.push(`${indent} type: DataTypes.DATE,`);
366
+ lines.push(`${indent} allowNull: false,`);
367
+ lines.push(`${indent} defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),`);
368
+ lines.push(`${indent}},`);
369
+ }
370
+
371
+ // Add deleted_at for paranoid models
372
+ if (paranoid) {
373
+ lines.push(`${indent}deleted_at: {`);
374
+ lines.push(`${indent} type: DataTypes.DATE,`);
375
+ lines.push(`${indent} allowNull: true,`);
376
+ lines.push(`${indent} defaultValue: null,`);
377
+ lines.push(`${indent}},`);
378
+ }
379
+
380
+ const columnsStr = lines.join('\n');
381
+
382
+ // Build class name from table name: "users" -> "CreateUsersTable"
383
+ const migrationClassName = `Create${this.toPascalCase(this.tableName)}Table`;
384
+
385
+ return `import { DataTypes } from 'sequelize';
386
+
387
+ class ${migrationClassName} {
388
+ /**
389
+ * Run the migrations.
390
+ */
391
+ async up(queryInterface, Sequelize) {
392
+ await queryInterface.createTable('${this.tableName}', {
393
+ ${columnsStr}
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Reverse the migrations.
399
+ */
400
+ async down(queryInterface, Sequelize) {
401
+ await queryInterface.dropTable('${this.tableName}');
402
+ }
403
+ }
404
+
405
+ export default new ${migrationClassName}();
406
+ `;
407
+ }
408
+
409
+ /**
410
+ * Converts model DataTypes to migration-appropriate DataTypes.
411
+ * e.g., DataTypes.INTEGER for id -> DataTypes.BIGINT in migrations.
412
+ * Keeps explicit types as-is.
413
+ */
414
+ private migrationDataType(type: string): string {
415
+ // Models often use DataTypes.INTEGER for IDs/Foreign Keys, but migrations use BIGINT typically.
416
+ if (type === 'DataTypes.INTEGER') {
417
+ return 'DataTypes.BIGINT';
418
+ }
419
+ return type;
70
420
  }
71
421
 
72
422
  private createMigrationFile(): void {
@@ -76,23 +426,114 @@ class MigrationGenerator {
76
426
  const fileName = `${timestamp}-create_${this.migrationName}.js`;
77
427
  const targetPath = path.join(this.migrationsDir, fileName);
78
428
 
79
- // 2. Read Stub
80
- let content = fs.readFileSync(this.stubPath, 'utf-8');
429
+ // 2. Try to find and scan the corresponding model
430
+ const modelPath = this.findModelFile();
431
+ let content: string;
81
432
 
82
- // 3. Replace Placeholders
83
- content = content.replace(/{{ClassName}}/g, this.className);
84
- content = content.replace(/{{TableName}}/g, this.tableName);
433
+ if (modelPath) {
434
+ const modelName = path.basename(modelPath, '.ts');
435
+ Logger.info(`Model found: src/models/${modelName}.ts — scanning columns...`);
436
+ content = this.generateFromModel(modelPath);
437
+ } else {
438
+ // Fall back to stub template
439
+ Logger.info('No matching model found. Using stub template.');
440
+ content = fs.readFileSync(this.stubPath, 'utf-8');
441
+ content = content.replace(/{{ClassName}}/g, this.className);
442
+ content = content.replace(/{{TableName}}/g, this.tableName);
443
+ }
85
444
 
86
- // 4. Write File
445
+ // 3. Write File
87
446
  fs.writeFileSync(targetPath, content);
88
447
 
89
448
  Logger.info(`Migration Created: src/database/migrations/${fileName}`);
90
-
449
+
91
450
  } catch (error) {
92
451
  Logger.error('Failed to create migration:', error);
93
452
  process.exit(1);
94
453
  }
95
454
  }
455
+
456
+ /**
457
+ * Generates migrations for all models in the models directory, considering dependencies (foreign keys).
458
+ */
459
+ private generateAllMigrations(): void {
460
+ if (!fs.existsSync(this.modelsDir)) {
461
+ Logger.error("Models directory not found.");
462
+ return;
463
+ }
464
+
465
+ // 1. Find all model files
466
+ const files = fs.readdirSync(this.modelsDir).filter(
467
+ f => f.endsWith('.ts') && f !== 'index.ts'
468
+ );
469
+
470
+ // 2. Parse all models to gather dependencies
471
+ const modelNodes: { file: string, tableName: string, dependencies: string[] }[] = [];
472
+ for (const file of files) {
473
+ const fullPath = path.join(this.modelsDir, file);
474
+ // temporarily set tableName to singularized file name to allow syncTableName to update it
475
+ this.tableName = file.replace('.ts', '').toLowerCase();
476
+ this.syncTableName(fullPath);
477
+ const currentTableName = this.tableName;
478
+
479
+ const { associations } = this.parseModelFile(fullPath);
480
+ const dependencies: string[] = [];
481
+ for (const assoc of associations) {
482
+ const [fk, modelName] = assoc.split(':');
483
+ const targetTable = this.pluralize(modelName.toLowerCase());
484
+ if (targetTable !== currentTableName) { // avoid self-dependencies
485
+ dependencies.push(targetTable);
486
+ }
487
+ }
488
+
489
+ modelNodes.push({
490
+ file: fullPath,
491
+ tableName: currentTableName,
492
+ dependencies
493
+ });
494
+ }
495
+
496
+ // 3. Topological Sort (DFS)
497
+ const sorted: typeof modelNodes = [];
498
+ const visited = new Set<string>();
499
+ const visiting = new Set<string>();
500
+
501
+ const visit = (node: typeof modelNodes[0]) => {
502
+ if (visited.has(node.tableName)) return;
503
+ if (visiting.has(node.tableName)) {
504
+ Logger.warn(`Circular dependency detected involving model: ${node.tableName}. Output order may not be perfect.`);
505
+ return;
506
+ }
507
+ visiting.add(node.tableName);
508
+ for (const depTable of node.dependencies) {
509
+ const depNode = modelNodes.find(n => n.tableName === depTable);
510
+ if (depNode) visit(depNode);
511
+ }
512
+ visiting.delete(node.tableName);
513
+ visited.add(node.tableName);
514
+ sorted.push(node);
515
+ };
516
+
517
+ for (const node of modelNodes) visit(node);
518
+
519
+ // 4. Generate them with incremental timestamps
520
+ let currentTimestamp = new Date();
521
+ for (const node of sorted) {
522
+ this.tableName = node.tableName;
523
+ const timestampStr = this.getTimestamp(currentTimestamp);
524
+
525
+ const fileName = `${timestampStr}-create_${node.tableName}.js`;
526
+ const targetPath = path.join(this.migrationsDir, fileName);
527
+
528
+ Logger.info(`Model found: src/models/${path.basename(node.file)} — scanning columns...`);
529
+ const content = this.generateFromModel(node.file);
530
+ fs.writeFileSync(targetPath, content);
531
+ Logger.info(`Migration Created: src/database/migrations/${fileName}`);
532
+
533
+ // Add 1 second for next file so they execute in order
534
+ currentTimestamp.setSeconds(currentTimestamp.getSeconds() + 1);
535
+ }
536
+ }
96
537
  }
97
538
 
98
539
  new MigrationGenerator().run();
@@ -1,6 +1,8 @@
1
1
  import express, { Application } from 'express';
2
2
  import cors from 'cors';
3
3
  import helmet from 'helmet';
4
+ import compression from 'compression';
5
+ import cookieParser from 'cookie-parser';
4
6
  import path from 'path';
5
7
  import db from './src/models/index.js';
6
8
  import RouteService from './src/services/RouteService.js';
@@ -8,23 +10,28 @@ import ExceptionHandler from './src/exceptions/Handler.js';
8
10
  import Logger from './src/utils/Logger.js';
9
11
  import Limiter from './src/middlewares/Limiter.js';
10
12
  import Maintenance from './src/middlewares/Maintenance.js';
11
- import dotenv from 'dotenv';
12
-
13
- dotenv.config();
13
+ import RequestLogger from './src/middlewares/RequestLogger.js';
14
+ import env from './src/config/env.js';
14
15
 
15
16
  const app: Application = express();
16
- const PORT = process.env.APP_PORT || 3000;
17
+ const PORT = env.APP_PORT;
17
18
 
18
19
  // ==========================
19
20
  // Global Middleware
20
21
  // ==========================
21
22
  app.use(Maintenance.handle);
22
23
  app.use(helmet({ crossOriginResourcePolicy: false }));
23
- app.use(cors());
24
- app.use(express.json());
25
- app.use(express.urlencoded({ extended: true }));
24
+ app.use(cors({
25
+ origin: env.CORS_ORIGIN,
26
+ credentials: true,
27
+ }));
28
+ app.use(compression());
29
+ app.use(cookieParser());
30
+ app.use(express.json({ limit: '10kb' }));
31
+ app.use(express.urlencoded({ extended: true, limit: '10kb' }));
26
32
  app.use(express.static(path.join(process.cwd(), 'public')));
27
33
  app.use(Limiter.global);
34
+ app.use(RequestLogger.handle);
28
35
 
29
36
  // ==========================
30
37
  // Register Routes
@@ -45,13 +52,15 @@ app.use(ExceptionHandler.handle);
45
52
  // ==========================
46
53
  // Start Server
47
54
  // ==========================
55
+ let server: ReturnType<typeof app.listen>;
56
+
48
57
  const start = async () => {
49
58
  try {
50
59
  // Test Database Connection
51
60
  await db.connect();
52
61
 
53
62
  // Start Listening
54
- app.listen(PORT, () => {
63
+ server = app.listen(PORT, () => {
55
64
  Logger.info(`Server running on http://localhost:${PORT}`);
56
65
  });
57
66
  } catch (error) {
@@ -60,4 +69,37 @@ const start = async () => {
60
69
  }
61
70
  };
62
71
 
72
+ // ==========================
73
+ // Graceful Shutdown
74
+ // ==========================
75
+ const shutdown = async (signal: string) => {
76
+ Logger.info(`${signal} received. Shutting down gracefully...`);
77
+
78
+ if (server) {
79
+ server.close(async () => {
80
+ Logger.info('HTTP server closed.');
81
+
82
+ try {
83
+ await db.sequelize.close();
84
+ Logger.info('Database connection closed.');
85
+ } catch (error) {
86
+ Logger.error('Error closing database connection:', error);
87
+ }
88
+
89
+ process.exit(0);
90
+ });
91
+
92
+ // Force shutdown after 10 seconds
93
+ setTimeout(() => {
94
+ Logger.error('Forced shutdown after timeout.');
95
+ process.exit(1);
96
+ }, 10000);
97
+ } else {
98
+ process.exit(0);
99
+ }
100
+ };
101
+
102
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
103
+ process.on('SIGINT', () => shutdown('SIGINT'));
104
+
63
105
  start();
@@ -1,8 +1,6 @@
1
1
  import { Options } from 'sequelize';
2
2
  import Logger from '../utils/Logger.js';
3
- import dotenv from 'dotenv';
4
-
5
- dotenv.config();
3
+ import env from './env.js';
6
4
 
7
5
  class DatabaseConfig {
8
6
  public development: Options;
@@ -19,18 +17,18 @@ class DatabaseConfig {
19
17
  * Helper to generate config based on the environment.
20
18
  * This reduces repetition.
21
19
  */
22
- private getEnvironmentConfig(env: 'development' | 'test' | 'production' = 'development'): Options {
23
- const isTest = env === 'test';
24
- const isProd = env === 'production';
25
- const useSSL = process.env.DB_SSL === 'true';
20
+ private getEnvironmentConfig(envName: 'development' | 'test' | 'production' = 'development'): Options {
21
+ const isTest = envName === 'test';
22
+ const isProd = envName === 'production';
23
+ const useSSL = env.DB_SSL === 'true';
26
24
 
27
25
  return {
28
- username: process.env.DB_USERNAME,
29
- password: process.env.DB_PASSWORD,
30
- database: isTest ? process.env.DB_DATABASE_TEST : process.env.DB_DATABASE,
31
- host: process.env.DB_HOST,
32
- port: Number(process.env.DB_PORT) || 3306,
33
- dialect: (process.env.DB_DIALECT as any) || 'mysql',
26
+ username: env.DB_USERNAME,
27
+ password: env.DB_PASSWORD,
28
+ database: isTest ? env.DB_DATABASE_TEST : env.DB_DATABASE,
29
+ host: env.DB_HOST,
30
+ port: env.DB_PORT,
31
+ dialect: env.DB_DIALECT as any,
34
32
  dialectOptions: useSSL ? {
35
33
  ssl: {
36
34
  require: true,