servcraft 0.1.6 → 0.1.7
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.
- package/README.md +31 -3
- package/ROADMAP.md +320 -0
- package/dist/cli/index.cjs +2162 -71
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2162 -71
- package/dist/cli/index.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/commands/add-module.ts +1 -29
- package/src/cli/commands/init.ts +441 -65
- package/src/cli/index.ts +4 -0
package/dist/cli/index.cjs
CHANGED
|
@@ -28,7 +28,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
|
|
|
28
28
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
29
29
|
|
|
30
30
|
// src/cli/index.ts
|
|
31
|
-
var
|
|
31
|
+
var import_commander6 = require("commander");
|
|
32
32
|
|
|
33
33
|
// src/cli/commands/init.ts
|
|
34
34
|
var import_commander = require("commander");
|
|
@@ -100,7 +100,7 @@ function getModulesDir() {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// src/cli/commands/init.ts
|
|
103
|
-
var initCommand = new import_commander.Command("init").alias("new").description("Initialize a new Servcraft project").argument("[name]", "Project name").option("-y, --yes", "Skip prompts and use defaults").option("--ts, --typescript", "Use TypeScript (default)").option("--js, --javascript", "Use JavaScript").option("--db <database>", "Database type (postgresql, mysql, sqlite, mongodb, none)").action(
|
|
103
|
+
var initCommand = new import_commander.Command("init").alias("new").description("Initialize a new Servcraft project").argument("[name]", "Project name").option("-y, --yes", "Skip prompts and use defaults").option("--ts, --typescript", "Use TypeScript (default)").option("--js, --javascript", "Use JavaScript").option("--esm", "Use ES Modules (import/export) - default").option("--cjs, --commonjs", "Use CommonJS (require/module.exports)").option("--db <database>", "Database type (postgresql, mysql, sqlite, mongodb, none)").action(
|
|
104
104
|
async (name, cmdOptions) => {
|
|
105
105
|
console.log(
|
|
106
106
|
import_chalk2.default.blue(`
|
|
@@ -117,6 +117,7 @@ var initCommand = new import_commander.Command("init").alias("new").description(
|
|
|
117
117
|
options = {
|
|
118
118
|
name: name || "my-servcraft-app",
|
|
119
119
|
language: cmdOptions.javascript ? "javascript" : "typescript",
|
|
120
|
+
moduleSystem: cmdOptions.commonjs ? "commonjs" : "esm",
|
|
120
121
|
database: db,
|
|
121
122
|
orm: db === "mongodb" ? "mongoose" : db === "none" ? "none" : "prisma",
|
|
122
123
|
validator: "zod",
|
|
@@ -146,6 +147,16 @@ var initCommand = new import_commander.Command("init").alias("new").description(
|
|
|
146
147
|
],
|
|
147
148
|
default: "typescript"
|
|
148
149
|
},
|
|
150
|
+
{
|
|
151
|
+
type: "list",
|
|
152
|
+
name: "moduleSystem",
|
|
153
|
+
message: "Select module system:",
|
|
154
|
+
choices: [
|
|
155
|
+
{ name: "ESM (import/export) - Recommended", value: "esm" },
|
|
156
|
+
{ name: "CommonJS (require/module.exports)", value: "commonjs" }
|
|
157
|
+
],
|
|
158
|
+
default: "esm"
|
|
159
|
+
},
|
|
149
160
|
{
|
|
150
161
|
type: "list",
|
|
151
162
|
name: "database",
|
|
@@ -208,10 +219,10 @@ var initCommand = new import_commander.Command("init").alias("new").description(
|
|
|
208
219
|
JSON.stringify(packageJson, null, 2)
|
|
209
220
|
);
|
|
210
221
|
if (options.language === "typescript") {
|
|
211
|
-
await writeFile(import_path2.default.join(projectDir, "tsconfig.json"), generateTsConfig());
|
|
212
|
-
await writeFile(import_path2.default.join(projectDir, "tsup.config.ts"), generateTsupConfig());
|
|
222
|
+
await writeFile(import_path2.default.join(projectDir, "tsconfig.json"), generateTsConfig(options));
|
|
223
|
+
await writeFile(import_path2.default.join(projectDir, "tsup.config.ts"), generateTsupConfig(options));
|
|
213
224
|
} else {
|
|
214
|
-
await writeFile(import_path2.default.join(projectDir, "jsconfig.json"), generateJsConfig());
|
|
225
|
+
await writeFile(import_path2.default.join(projectDir, "jsconfig.json"), generateJsConfig(options));
|
|
215
226
|
}
|
|
216
227
|
await writeFile(import_path2.default.join(projectDir, ".env.example"), generateEnvExample(options));
|
|
217
228
|
await writeFile(import_path2.default.join(projectDir, ".env"), generateEnvExample(options));
|
|
@@ -221,7 +232,7 @@ var initCommand = new import_commander.Command("init").alias("new").description(
|
|
|
221
232
|
import_path2.default.join(projectDir, "docker-compose.yml"),
|
|
222
233
|
generateDockerCompose(options)
|
|
223
234
|
);
|
|
224
|
-
const ext = options.language === "typescript" ? "ts" : "js";
|
|
235
|
+
const ext = options.language === "typescript" ? "ts" : options.moduleSystem === "esm" ? "js" : "cjs";
|
|
225
236
|
const dirs = [
|
|
226
237
|
"src/core",
|
|
227
238
|
"src/config",
|
|
@@ -250,6 +261,22 @@ var initCommand = new import_commander.Command("init").alias("new").description(
|
|
|
250
261
|
import_path2.default.join(projectDir, `src/core/logger.${ext}`),
|
|
251
262
|
generateLoggerFile(options)
|
|
252
263
|
);
|
|
264
|
+
await writeFile(
|
|
265
|
+
import_path2.default.join(projectDir, `src/config/index.${ext}`),
|
|
266
|
+
generateConfigFile(options)
|
|
267
|
+
);
|
|
268
|
+
await writeFile(
|
|
269
|
+
import_path2.default.join(projectDir, `src/middleware/index.${ext}`),
|
|
270
|
+
generateMiddlewareFile(options)
|
|
271
|
+
);
|
|
272
|
+
await writeFile(
|
|
273
|
+
import_path2.default.join(projectDir, `src/utils/index.${ext}`),
|
|
274
|
+
generateUtilsFile(options)
|
|
275
|
+
);
|
|
276
|
+
await writeFile(
|
|
277
|
+
import_path2.default.join(projectDir, `src/types/index.${ext}`),
|
|
278
|
+
generateTypesFile(options)
|
|
279
|
+
);
|
|
253
280
|
if (options.orm === "prisma") {
|
|
254
281
|
await writeFile(
|
|
255
282
|
import_path2.default.join(projectDir, "prisma/schema.prisma"),
|
|
@@ -311,18 +338,35 @@ var initCommand = new import_commander.Command("init").alias("new").description(
|
|
|
311
338
|
);
|
|
312
339
|
function generatePackageJson(options) {
|
|
313
340
|
const isTS = options.language === "typescript";
|
|
341
|
+
const isESM = options.moduleSystem === "esm";
|
|
342
|
+
let devCommand;
|
|
343
|
+
if (isTS) {
|
|
344
|
+
devCommand = "tsx watch src/index.ts";
|
|
345
|
+
} else if (isESM) {
|
|
346
|
+
devCommand = "node --watch src/index.js";
|
|
347
|
+
} else {
|
|
348
|
+
devCommand = "node --watch src/index.cjs";
|
|
349
|
+
}
|
|
350
|
+
let startCommand;
|
|
351
|
+
if (isTS) {
|
|
352
|
+
startCommand = isESM ? "node dist/index.js" : "node dist/index.cjs";
|
|
353
|
+
} else if (isESM) {
|
|
354
|
+
startCommand = "node src/index.js";
|
|
355
|
+
} else {
|
|
356
|
+
startCommand = "node src/index.cjs";
|
|
357
|
+
}
|
|
314
358
|
const pkg = {
|
|
315
359
|
name: options.name,
|
|
316
360
|
version: "0.1.0",
|
|
317
361
|
description: "A Servcraft application",
|
|
318
|
-
main: isTS ? "dist/index.js" : "src/index.js",
|
|
319
|
-
type: "module",
|
|
362
|
+
main: isTS ? isESM ? "dist/index.js" : "dist/index.cjs" : isESM ? "src/index.js" : "src/index.cjs",
|
|
363
|
+
...isESM && { type: "module" },
|
|
320
364
|
scripts: {
|
|
321
|
-
dev:
|
|
365
|
+
dev: devCommand,
|
|
322
366
|
build: isTS ? "tsup" : 'echo "No build needed for JS"',
|
|
323
|
-
start:
|
|
367
|
+
start: startCommand,
|
|
324
368
|
test: "vitest",
|
|
325
|
-
lint: isTS ? "eslint src --ext .ts" : "eslint src --ext .js"
|
|
369
|
+
lint: isTS ? "eslint src --ext .ts" : "eslint src --ext .js,.cjs"
|
|
326
370
|
},
|
|
327
371
|
dependencies: {
|
|
328
372
|
fastify: "^4.28.1",
|
|
@@ -384,13 +428,14 @@ function generatePackageJson(options) {
|
|
|
384
428
|
}
|
|
385
429
|
return pkg;
|
|
386
430
|
}
|
|
387
|
-
function generateTsConfig() {
|
|
431
|
+
function generateTsConfig(options) {
|
|
432
|
+
const isESM = options.moduleSystem === "esm";
|
|
388
433
|
return JSON.stringify(
|
|
389
434
|
{
|
|
390
435
|
compilerOptions: {
|
|
391
436
|
target: "ES2022",
|
|
392
|
-
module: "NodeNext",
|
|
393
|
-
moduleResolution: "NodeNext",
|
|
437
|
+
module: isESM ? "NodeNext" : "CommonJS",
|
|
438
|
+
moduleResolution: isESM ? "NodeNext" : "Node",
|
|
394
439
|
lib: ["ES2022"],
|
|
395
440
|
outDir: "./dist",
|
|
396
441
|
rootDir: "./src",
|
|
@@ -409,12 +454,13 @@ function generateTsConfig() {
|
|
|
409
454
|
2
|
|
410
455
|
);
|
|
411
456
|
}
|
|
412
|
-
function generateJsConfig() {
|
|
457
|
+
function generateJsConfig(options) {
|
|
458
|
+
const isESM = options.moduleSystem === "esm";
|
|
413
459
|
return JSON.stringify(
|
|
414
460
|
{
|
|
415
461
|
compilerOptions: {
|
|
416
|
-
module: "NodeNext",
|
|
417
|
-
moduleResolution: "NodeNext",
|
|
462
|
+
module: isESM ? "NodeNext" : "CommonJS",
|
|
463
|
+
moduleResolution: isESM ? "NodeNext" : "Node",
|
|
418
464
|
target: "ES2022",
|
|
419
465
|
checkJs: true
|
|
420
466
|
},
|
|
@@ -425,12 +471,13 @@ function generateJsConfig() {
|
|
|
425
471
|
2
|
|
426
472
|
);
|
|
427
473
|
}
|
|
428
|
-
function generateTsupConfig() {
|
|
474
|
+
function generateTsupConfig(options) {
|
|
475
|
+
const isESM = options.moduleSystem === "esm";
|
|
429
476
|
return `import { defineConfig } from 'tsup';
|
|
430
477
|
|
|
431
478
|
export default defineConfig({
|
|
432
479
|
entry: ['src/index.ts'],
|
|
433
|
-
format: ['esm'],
|
|
480
|
+
format: ['${isESM ? "esm" : "cjs"}'],
|
|
434
481
|
dts: true,
|
|
435
482
|
clean: true,
|
|
436
483
|
sourcemap: true,
|
|
@@ -439,7 +486,7 @@ export default defineConfig({
|
|
|
439
486
|
`;
|
|
440
487
|
}
|
|
441
488
|
function generateEnvExample(options) {
|
|
442
|
-
let
|
|
489
|
+
let env2 = `# Server
|
|
443
490
|
NODE_ENV=development
|
|
444
491
|
PORT=3000
|
|
445
492
|
HOST=0.0.0.0
|
|
@@ -457,31 +504,31 @@ RATE_LIMIT_MAX=100
|
|
|
457
504
|
LOG_LEVEL=info
|
|
458
505
|
`;
|
|
459
506
|
if (options.database === "postgresql") {
|
|
460
|
-
|
|
507
|
+
env2 += `
|
|
461
508
|
# Database (PostgreSQL)
|
|
462
509
|
DATABASE_PROVIDER=postgresql
|
|
463
510
|
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
|
|
464
511
|
`;
|
|
465
512
|
} else if (options.database === "mysql") {
|
|
466
|
-
|
|
513
|
+
env2 += `
|
|
467
514
|
# Database (MySQL)
|
|
468
515
|
DATABASE_PROVIDER=mysql
|
|
469
516
|
DATABASE_URL="mysql://user:password@localhost:3306/mydb"
|
|
470
517
|
`;
|
|
471
518
|
} else if (options.database === "sqlite") {
|
|
472
|
-
|
|
519
|
+
env2 += `
|
|
473
520
|
# Database (SQLite)
|
|
474
521
|
DATABASE_PROVIDER=sqlite
|
|
475
522
|
DATABASE_URL="file:./dev.db"
|
|
476
523
|
`;
|
|
477
524
|
} else if (options.database === "mongodb") {
|
|
478
|
-
|
|
525
|
+
env2 += `
|
|
479
526
|
# Database (MongoDB)
|
|
480
527
|
MONGODB_URI="mongodb://localhost:27017/mydb"
|
|
481
528
|
`;
|
|
482
529
|
}
|
|
483
530
|
if (options.features.includes("email")) {
|
|
484
|
-
|
|
531
|
+
env2 += `
|
|
485
532
|
# Email
|
|
486
533
|
SMTP_HOST=smtp.example.com
|
|
487
534
|
SMTP_PORT=587
|
|
@@ -491,12 +538,12 @@ SMTP_FROM="App <noreply@example.com>"
|
|
|
491
538
|
`;
|
|
492
539
|
}
|
|
493
540
|
if (options.features.includes("redis")) {
|
|
494
|
-
|
|
541
|
+
env2 += `
|
|
495
542
|
# Redis
|
|
496
543
|
REDIS_URL=redis://localhost:6379
|
|
497
544
|
`;
|
|
498
545
|
}
|
|
499
|
-
return
|
|
546
|
+
return env2;
|
|
500
547
|
}
|
|
501
548
|
function generateGitignore() {
|
|
502
549
|
return `node_modules/
|
|
@@ -625,7 +672,12 @@ model User {
|
|
|
625
672
|
}
|
|
626
673
|
function generateEntryFile(options) {
|
|
627
674
|
const isTS = options.language === "typescript";
|
|
628
|
-
|
|
675
|
+
const isESM = options.moduleSystem === "esm";
|
|
676
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
677
|
+
if (isESM || isTS) {
|
|
678
|
+
return `import 'dotenv/config';
|
|
679
|
+
import { createServer } from './core/server.${fileExt}';
|
|
680
|
+
import { logger } from './core/logger.${fileExt}';
|
|
629
681
|
|
|
630
682
|
async function main()${isTS ? ": Promise<void>" : ""} {
|
|
631
683
|
const server = createServer();
|
|
@@ -640,16 +692,31 @@ async function main()${isTS ? ": Promise<void>" : ""} {
|
|
|
640
692
|
|
|
641
693
|
main();
|
|
642
694
|
`;
|
|
695
|
+
} else {
|
|
696
|
+
return `require('dotenv').config();
|
|
697
|
+
const { createServer } = require('./core/server.cjs');
|
|
698
|
+
const { logger } = require('./core/logger.cjs');
|
|
699
|
+
|
|
700
|
+
async function main() {
|
|
701
|
+
const server = createServer();
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
await server.start();
|
|
705
|
+
} catch (error) {
|
|
706
|
+
logger.error({ err: error }, 'Failed to start server');
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
main();
|
|
712
|
+
`;
|
|
713
|
+
}
|
|
643
714
|
}
|
|
644
715
|
function generateServerFile(options) {
|
|
645
716
|
const isTS = options.language === "typescript";
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const { logger } = require('./logger.js');`}
|
|
650
|
-
|
|
651
|
-
${isTS ? "export function createServer(): { instance: FastifyInstance; start: () => Promise<void> }" : "function createServer()"} {
|
|
652
|
-
const app = Fastify({ logger });
|
|
717
|
+
const isESM = options.moduleSystem === "esm";
|
|
718
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
719
|
+
const serverBody = ` const app = Fastify({ logger });
|
|
653
720
|
|
|
654
721
|
// Health check
|
|
655
722
|
app.get('/health', async () => ({
|
|
@@ -676,33 +743,58 @@ ${isTS ? "export function createServer(): { instance: FastifyInstance; start: ()
|
|
|
676
743
|
logger.info(\`Server listening on \${host}:\${port}\`);
|
|
677
744
|
},
|
|
678
745
|
};
|
|
679
|
-
}
|
|
746
|
+
}`;
|
|
747
|
+
if (isESM || isTS) {
|
|
748
|
+
return `import Fastify from 'fastify';
|
|
749
|
+
${isTS ? "import type { FastifyInstance } from 'fastify';" : ""}
|
|
750
|
+
import { logger } from './logger.${fileExt}';
|
|
751
|
+
|
|
752
|
+
${isTS ? "export function createServer(): { instance: FastifyInstance; start: () => Promise<void> }" : "export function createServer()"} {
|
|
753
|
+
${serverBody}
|
|
754
|
+
`;
|
|
755
|
+
} else {
|
|
756
|
+
return `const Fastify = require('fastify');
|
|
757
|
+
const { logger } = require('./logger.cjs');
|
|
758
|
+
|
|
759
|
+
function createServer() {
|
|
760
|
+
${serverBody}
|
|
680
761
|
|
|
681
|
-
|
|
762
|
+
module.exports = { createServer };
|
|
682
763
|
`;
|
|
764
|
+
}
|
|
683
765
|
}
|
|
684
766
|
function generateLoggerFile(options) {
|
|
685
767
|
const isTS = options.language === "typescript";
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
${isTS ? "export const logger: Logger" : "const logger"} = pino({
|
|
768
|
+
const isESM = options.moduleSystem === "esm";
|
|
769
|
+
const loggerBody = `pino({
|
|
689
770
|
level: process.env.LOG_LEVEL || 'info',
|
|
690
771
|
transport: process.env.NODE_ENV !== 'production' ? {
|
|
691
772
|
target: 'pino-pretty',
|
|
692
773
|
options: { colorize: true },
|
|
693
774
|
} : undefined,
|
|
694
|
-
})
|
|
775
|
+
})`;
|
|
776
|
+
if (isESM || isTS) {
|
|
777
|
+
return `import pino from 'pino';
|
|
778
|
+
${isTS ? "import type { Logger } from 'pino';" : ""}
|
|
779
|
+
|
|
780
|
+
export const logger${isTS ? ": Logger" : ""} = ${loggerBody};
|
|
781
|
+
`;
|
|
782
|
+
} else {
|
|
783
|
+
return `const pino = require('pino');
|
|
784
|
+
|
|
785
|
+
const logger = ${loggerBody};
|
|
695
786
|
|
|
696
|
-
|
|
787
|
+
module.exports = { logger };
|
|
697
788
|
`;
|
|
789
|
+
}
|
|
698
790
|
}
|
|
699
791
|
function generateMongooseConnection(options) {
|
|
700
792
|
const isTS = options.language === "typescript";
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/mydb';
|
|
793
|
+
const isESM = options.moduleSystem === "esm";
|
|
794
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
795
|
+
const connectionBody = `const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/mydb';
|
|
704
796
|
|
|
705
|
-
${isTS ? "
|
|
797
|
+
async function connectDatabase()${isTS ? ": Promise<typeof mongoose>" : ""} {
|
|
706
798
|
try {
|
|
707
799
|
const conn = await mongoose.connect(MONGODB_URI);
|
|
708
800
|
logger.info(\`MongoDB connected: \${conn.connection.host}\`);
|
|
@@ -713,35 +805,36 @@ ${isTS ? "export async function connectDatabase(): Promise<typeof mongoose>" : "
|
|
|
713
805
|
}
|
|
714
806
|
}
|
|
715
807
|
|
|
716
|
-
${isTS ? "
|
|
808
|
+
async function disconnectDatabase()${isTS ? ": Promise<void>" : ""} {
|
|
717
809
|
try {
|
|
718
810
|
await mongoose.disconnect();
|
|
719
811
|
logger.info('MongoDB disconnected');
|
|
720
812
|
} catch (error) {
|
|
721
813
|
logger.error({ err: error }, 'MongoDB disconnect failed');
|
|
722
814
|
}
|
|
723
|
-
}
|
|
815
|
+
}`;
|
|
816
|
+
if (isESM || isTS) {
|
|
817
|
+
return `import mongoose from 'mongoose';
|
|
818
|
+
import { logger } from '../core/logger.${fileExt}';
|
|
819
|
+
|
|
820
|
+
${connectionBody}
|
|
821
|
+
|
|
822
|
+
export { connectDatabase, disconnectDatabase, mongoose };
|
|
823
|
+
`;
|
|
824
|
+
} else {
|
|
825
|
+
return `const mongoose = require('mongoose');
|
|
826
|
+
const { logger } = require('../core/logger.cjs');
|
|
724
827
|
|
|
725
|
-
${
|
|
828
|
+
${connectionBody}
|
|
829
|
+
|
|
830
|
+
module.exports = { connectDatabase, disconnectDatabase, mongoose };
|
|
726
831
|
`;
|
|
832
|
+
}
|
|
727
833
|
}
|
|
728
834
|
function generateMongooseUserModel(options) {
|
|
729
835
|
const isTS = options.language === "typescript";
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
${isTS ? `export interface IUser extends Document {
|
|
733
|
-
email: string;
|
|
734
|
-
password: string;
|
|
735
|
-
name?: string;
|
|
736
|
-
role: 'user' | 'admin';
|
|
737
|
-
status: 'active' | 'inactive' | 'suspended';
|
|
738
|
-
emailVerified: boolean;
|
|
739
|
-
createdAt: Date;
|
|
740
|
-
updatedAt: Date;
|
|
741
|
-
comparePassword(candidatePassword: string): Promise<boolean>;
|
|
742
|
-
}` : ""}
|
|
743
|
-
|
|
744
|
-
const userSchema = new Schema${isTS ? "<IUser>" : ""}({
|
|
836
|
+
const isESM = options.moduleSystem === "esm";
|
|
837
|
+
const schemaBody = `const userSchema = new Schema${isTS ? "<IUser>" : ""}({
|
|
745
838
|
email: {
|
|
746
839
|
type: String,
|
|
747
840
|
required: true,
|
|
@@ -792,9 +885,237 @@ userSchema.pre('save', async function(next) {
|
|
|
792
885
|
// Compare password method
|
|
793
886
|
userSchema.methods.comparePassword = async function(candidatePassword${isTS ? ": string" : ""})${isTS ? ": Promise<boolean>" : ""} {
|
|
794
887
|
return bcrypt.compare(candidatePassword, this.password);
|
|
795
|
-
}
|
|
888
|
+
};`;
|
|
889
|
+
const tsInterface = isTS ? `
|
|
890
|
+
export interface IUser extends Document {
|
|
891
|
+
email: string;
|
|
892
|
+
password: string;
|
|
893
|
+
name?: string;
|
|
894
|
+
role: 'user' | 'admin';
|
|
895
|
+
status: 'active' | 'inactive' | 'suspended';
|
|
896
|
+
emailVerified: boolean;
|
|
897
|
+
createdAt: Date;
|
|
898
|
+
updatedAt: Date;
|
|
899
|
+
comparePassword(candidatePassword: string): Promise<boolean>;
|
|
900
|
+
}
|
|
901
|
+
` : "";
|
|
902
|
+
if (isESM || isTS) {
|
|
903
|
+
return `import mongoose${isTS ? ", { Schema, Document }" : ""} from 'mongoose';
|
|
904
|
+
import bcrypt from 'bcryptjs';
|
|
905
|
+
${!isTS ? "const { Schema } = mongoose;" : ""}
|
|
906
|
+
${tsInterface}
|
|
907
|
+
${schemaBody}
|
|
908
|
+
|
|
909
|
+
export const User = mongoose.model${isTS ? "<IUser>" : ""}('User', userSchema);
|
|
910
|
+
`;
|
|
911
|
+
} else {
|
|
912
|
+
return `const mongoose = require('mongoose');
|
|
913
|
+
const bcrypt = require('bcryptjs');
|
|
914
|
+
const { Schema } = mongoose;
|
|
915
|
+
|
|
916
|
+
${schemaBody}
|
|
917
|
+
|
|
918
|
+
const User = mongoose.model('User', userSchema);
|
|
919
|
+
|
|
920
|
+
module.exports = { User };
|
|
921
|
+
`;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function generateConfigFile(options) {
|
|
925
|
+
const isTS = options.language === "typescript";
|
|
926
|
+
const isESM = options.moduleSystem === "esm";
|
|
927
|
+
const configBody = `{
|
|
928
|
+
env: process.env.NODE_ENV || 'development',
|
|
929
|
+
port: parseInt(process.env.PORT || '3000', 10),
|
|
930
|
+
host: process.env.HOST || '0.0.0.0',
|
|
931
|
+
|
|
932
|
+
jwt: {
|
|
933
|
+
secret: process.env.JWT_SECRET || 'your-secret-key',
|
|
934
|
+
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
|
|
935
|
+
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
cors: {
|
|
939
|
+
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
|
940
|
+
},
|
|
941
|
+
|
|
942
|
+
rateLimit: {
|
|
943
|
+
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
log: {
|
|
947
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
948
|
+
},
|
|
949
|
+
}${isTS ? " as const" : ""}`;
|
|
950
|
+
if (isESM || isTS) {
|
|
951
|
+
return `import 'dotenv/config';
|
|
952
|
+
|
|
953
|
+
export const config = ${configBody};
|
|
954
|
+
`;
|
|
955
|
+
} else {
|
|
956
|
+
return `require('dotenv').config();
|
|
957
|
+
|
|
958
|
+
const config = ${configBody};
|
|
959
|
+
|
|
960
|
+
module.exports = { config };
|
|
961
|
+
`;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function generateMiddlewareFile(options) {
|
|
965
|
+
const isTS = options.language === "typescript";
|
|
966
|
+
const isESM = options.moduleSystem === "esm";
|
|
967
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
968
|
+
const middlewareBody = `/**
|
|
969
|
+
* Error handler middleware
|
|
970
|
+
*/
|
|
971
|
+
function errorHandler(error${isTS ? ": Error" : ""}, request${isTS ? ": FastifyRequest" : ""}, reply${isTS ? ": FastifyReply" : ""})${isTS ? ": void" : ""} {
|
|
972
|
+
logger.error({ err: error, url: request.url, method: request.method }, 'Request error');
|
|
973
|
+
|
|
974
|
+
const statusCode = (error${isTS ? " as any" : ""}).statusCode || 500;
|
|
975
|
+
const message = statusCode === 500 ? 'Internal Server Error' : error.message;
|
|
976
|
+
|
|
977
|
+
reply.status(statusCode).send({
|
|
978
|
+
success: false,
|
|
979
|
+
error: message,
|
|
980
|
+
...(process.env.NODE_ENV === 'development' && { stack: error.stack }),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Request logging middleware
|
|
986
|
+
*/
|
|
987
|
+
function requestLogger(request${isTS ? ": FastifyRequest" : ""}, reply${isTS ? ": FastifyReply" : ""}, done${isTS ? ": () => void" : ""})${isTS ? ": void" : ""} {
|
|
988
|
+
logger.info({ url: request.url, method: request.method, ip: request.ip }, 'Incoming request');
|
|
989
|
+
done();
|
|
990
|
+
}`;
|
|
991
|
+
if (isESM || isTS) {
|
|
992
|
+
return `${isTS ? "import type { FastifyRequest, FastifyReply } from 'fastify';" : ""}
|
|
993
|
+
import { logger } from '../core/logger.${fileExt}';
|
|
994
|
+
|
|
995
|
+
${middlewareBody}
|
|
996
|
+
|
|
997
|
+
export { errorHandler, requestLogger };
|
|
998
|
+
`;
|
|
999
|
+
} else {
|
|
1000
|
+
return `const { logger } = require('../core/logger.cjs');
|
|
1001
|
+
|
|
1002
|
+
${middlewareBody}
|
|
1003
|
+
|
|
1004
|
+
module.exports = { errorHandler, requestLogger };
|
|
1005
|
+
`;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function generateUtilsFile(options) {
|
|
1009
|
+
const isTS = options.language === "typescript";
|
|
1010
|
+
const isESM = options.moduleSystem === "esm";
|
|
1011
|
+
const utilsBody = `/**
|
|
1012
|
+
* Standard API response helper
|
|
1013
|
+
*/
|
|
1014
|
+
function apiResponse${isTS ? "<T>" : ""}(data${isTS ? ": T" : ""}, message = "Success")${isTS ? ": { success: boolean; message: string; data: T }" : ""} {
|
|
1015
|
+
return {
|
|
1016
|
+
success: true,
|
|
1017
|
+
message,
|
|
1018
|
+
data,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Error response helper
|
|
1024
|
+
*/
|
|
1025
|
+
function errorResponse(message${isTS ? ": string" : ""}, code${isTS ? "?: string" : ""})${isTS ? ": { success: boolean; error: string; code?: string }" : ""} {
|
|
1026
|
+
return {
|
|
1027
|
+
success: false,
|
|
1028
|
+
error: message,
|
|
1029
|
+
...(code && { code }),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Pagination helper
|
|
1035
|
+
*/
|
|
1036
|
+
function paginate${isTS ? "<T>" : ""}(data${isTS ? ": T[]" : ""}, page${isTS ? ": number" : ""}, limit${isTS ? ": number" : ""}, total${isTS ? ": number" : ""})${isTS ? ": PaginationResult<T>" : ""} {
|
|
1037
|
+
const totalPages = Math.ceil(total / limit);
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
data,
|
|
1041
|
+
pagination: {
|
|
1042
|
+
page,
|
|
1043
|
+
limit,
|
|
1044
|
+
total,
|
|
1045
|
+
totalPages,
|
|
1046
|
+
hasNextPage: page < totalPages,
|
|
1047
|
+
hasPrevPage: page > 1,
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
}`;
|
|
1051
|
+
const tsInterface = isTS ? `
|
|
1052
|
+
/**
|
|
1053
|
+
* Pagination result type
|
|
1054
|
+
*/
|
|
1055
|
+
export interface PaginationResult<T> {
|
|
1056
|
+
data: T[];
|
|
1057
|
+
pagination: {
|
|
1058
|
+
page: number;
|
|
1059
|
+
limit: number;
|
|
1060
|
+
total: number;
|
|
1061
|
+
totalPages: number;
|
|
1062
|
+
hasNextPage: boolean;
|
|
1063
|
+
hasPrevPage: boolean;
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
` : "";
|
|
1067
|
+
if (isESM || isTS) {
|
|
1068
|
+
return `${tsInterface}
|
|
1069
|
+
${utilsBody}
|
|
1070
|
+
|
|
1071
|
+
export { apiResponse, errorResponse, paginate };
|
|
1072
|
+
`;
|
|
1073
|
+
} else {
|
|
1074
|
+
return `${utilsBody}
|
|
1075
|
+
|
|
1076
|
+
module.exports = { apiResponse, errorResponse, paginate };
|
|
1077
|
+
`;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function generateTypesFile(options) {
|
|
1081
|
+
if (options.language !== "typescript") {
|
|
1082
|
+
return "// Types file - not needed for JavaScript\n";
|
|
1083
|
+
}
|
|
1084
|
+
return `/**
|
|
1085
|
+
* Common type definitions
|
|
1086
|
+
*/
|
|
796
1087
|
|
|
797
|
-
|
|
1088
|
+
export interface ApiResponse<T = unknown> {
|
|
1089
|
+
success: boolean;
|
|
1090
|
+
message?: string;
|
|
1091
|
+
data?: T;
|
|
1092
|
+
error?: string;
|
|
1093
|
+
code?: string;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export interface PaginatedResponse<T> {
|
|
1097
|
+
data: T[];
|
|
1098
|
+
pagination: {
|
|
1099
|
+
page: number;
|
|
1100
|
+
limit: number;
|
|
1101
|
+
total: number;
|
|
1102
|
+
totalPages: number;
|
|
1103
|
+
hasNextPage: boolean;
|
|
1104
|
+
hasPrevPage: boolean;
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export interface RequestUser {
|
|
1109
|
+
id: string;
|
|
1110
|
+
email: string;
|
|
1111
|
+
role: string;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
declare module 'fastify' {
|
|
1115
|
+
interface FastifyRequest {
|
|
1116
|
+
user?: RequestUser;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
798
1119
|
`;
|
|
799
1120
|
}
|
|
800
1121
|
|
|
@@ -1963,12 +2284,12 @@ var EnvManager = class {
|
|
|
1963
2284
|
async addVariables(sections) {
|
|
1964
2285
|
const added = [];
|
|
1965
2286
|
const skipped = [];
|
|
1966
|
-
let
|
|
2287
|
+
let created2 = false;
|
|
1967
2288
|
let envContent = "";
|
|
1968
2289
|
if ((0, import_fs.existsSync)(this.envPath)) {
|
|
1969
2290
|
envContent = await fs3.readFile(this.envPath, "utf-8");
|
|
1970
2291
|
} else {
|
|
1971
|
-
|
|
2292
|
+
created2 = true;
|
|
1972
2293
|
}
|
|
1973
2294
|
const existingKeys = this.parseExistingKeys(envContent);
|
|
1974
2295
|
let newContent = envContent;
|
|
@@ -1999,7 +2320,7 @@ var EnvManager = class {
|
|
|
1999
2320
|
if ((0, import_fs.existsSync)(this.envExamplePath)) {
|
|
2000
2321
|
await this.updateEnvExample(sections);
|
|
2001
2322
|
}
|
|
2002
|
-
return { added, skipped, created };
|
|
2323
|
+
return { added, skipped, created: created2 };
|
|
2003
2324
|
}
|
|
2004
2325
|
/**
|
|
2005
2326
|
* Update .env.example file
|
|
@@ -3582,7 +3903,7 @@ async function findServercraftModules() {
|
|
|
3582
3903
|
}
|
|
3583
3904
|
async function generateModuleFiles(moduleName, moduleDir) {
|
|
3584
3905
|
const moduleNameMap = {
|
|
3585
|
-
|
|
3906
|
+
users: "user",
|
|
3586
3907
|
"rate-limit": "rate-limit",
|
|
3587
3908
|
"feature-flag": "feature-flag",
|
|
3588
3909
|
"api-versioning": "api-versioning",
|
|
@@ -3851,12 +4172,1782 @@ dbCommand.command("status").description("Show migration status").action(async ()
|
|
|
3851
4172
|
}
|
|
3852
4173
|
});
|
|
3853
4174
|
|
|
4175
|
+
// src/cli/commands/docs.ts
|
|
4176
|
+
var import_commander5 = require("commander");
|
|
4177
|
+
var import_path6 = __toESM(require("path"), 1);
|
|
4178
|
+
var import_promises4 = __toESM(require("fs/promises"), 1);
|
|
4179
|
+
var import_ora6 = __toESM(require("ora"), 1);
|
|
4180
|
+
var import_chalk6 = __toESM(require("chalk"), 1);
|
|
4181
|
+
|
|
4182
|
+
// src/cli/utils/docs-generator.ts
|
|
4183
|
+
var import_promises3 = __toESM(require("fs/promises"), 1);
|
|
4184
|
+
var import_path5 = __toESM(require("path"), 1);
|
|
4185
|
+
var import_ora5 = __toESM(require("ora"), 1);
|
|
4186
|
+
|
|
4187
|
+
// src/core/server.ts
|
|
4188
|
+
var import_fastify = __toESM(require("fastify"), 1);
|
|
4189
|
+
|
|
4190
|
+
// src/core/logger.ts
|
|
4191
|
+
var import_pino = __toESM(require("pino"), 1);
|
|
4192
|
+
var defaultConfig = {
|
|
4193
|
+
level: process.env.LOG_LEVEL || "info",
|
|
4194
|
+
pretty: process.env.NODE_ENV !== "production",
|
|
4195
|
+
name: "servcraft"
|
|
4196
|
+
};
|
|
4197
|
+
function createLogger(config2 = {}) {
|
|
4198
|
+
const mergedConfig = { ...defaultConfig, ...config2 };
|
|
4199
|
+
const transport = mergedConfig.pretty ? {
|
|
4200
|
+
target: "pino-pretty",
|
|
4201
|
+
options: {
|
|
4202
|
+
colorize: true,
|
|
4203
|
+
translateTime: "SYS:standard",
|
|
4204
|
+
ignore: "pid,hostname"
|
|
4205
|
+
}
|
|
4206
|
+
} : void 0;
|
|
4207
|
+
return (0, import_pino.default)({
|
|
4208
|
+
name: mergedConfig.name,
|
|
4209
|
+
level: mergedConfig.level,
|
|
4210
|
+
transport,
|
|
4211
|
+
formatters: {
|
|
4212
|
+
level: (label) => ({ level: label })
|
|
4213
|
+
},
|
|
4214
|
+
timestamp: import_pino.default.stdTimeFunctions.isoTime
|
|
4215
|
+
});
|
|
4216
|
+
}
|
|
4217
|
+
var logger = createLogger();
|
|
4218
|
+
|
|
4219
|
+
// src/core/server.ts
|
|
4220
|
+
var defaultConfig2 = {
|
|
4221
|
+
port: parseInt(process.env.PORT || "3000", 10),
|
|
4222
|
+
host: process.env.HOST || "0.0.0.0",
|
|
4223
|
+
trustProxy: true,
|
|
4224
|
+
bodyLimit: 1048576,
|
|
4225
|
+
// 1MB
|
|
4226
|
+
requestTimeout: 3e4
|
|
4227
|
+
// 30s
|
|
4228
|
+
};
|
|
4229
|
+
var Server = class {
|
|
4230
|
+
app;
|
|
4231
|
+
config;
|
|
4232
|
+
logger;
|
|
4233
|
+
isShuttingDown = false;
|
|
4234
|
+
constructor(config2 = {}) {
|
|
4235
|
+
this.config = { ...defaultConfig2, ...config2 };
|
|
4236
|
+
this.logger = this.config.logger || logger;
|
|
4237
|
+
const fastifyOptions = {
|
|
4238
|
+
logger: this.logger,
|
|
4239
|
+
trustProxy: this.config.trustProxy,
|
|
4240
|
+
bodyLimit: this.config.bodyLimit,
|
|
4241
|
+
requestTimeout: this.config.requestTimeout
|
|
4242
|
+
};
|
|
4243
|
+
this.app = (0, import_fastify.default)(fastifyOptions);
|
|
4244
|
+
this.setupHealthCheck();
|
|
4245
|
+
this.setupGracefulShutdown();
|
|
4246
|
+
}
|
|
4247
|
+
get instance() {
|
|
4248
|
+
return this.app;
|
|
4249
|
+
}
|
|
4250
|
+
setupHealthCheck() {
|
|
4251
|
+
this.app.get("/health", async (_request, reply) => {
|
|
4252
|
+
const healthcheck = {
|
|
4253
|
+
status: "ok",
|
|
4254
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4255
|
+
uptime: process.uptime(),
|
|
4256
|
+
memory: process.memoryUsage(),
|
|
4257
|
+
version: process.env.npm_package_version || "0.1.0"
|
|
4258
|
+
};
|
|
4259
|
+
return reply.status(200).send(healthcheck);
|
|
4260
|
+
});
|
|
4261
|
+
this.app.get("/ready", async (_request, reply) => {
|
|
4262
|
+
if (this.isShuttingDown) {
|
|
4263
|
+
return reply.status(503).send({ status: "shutting_down" });
|
|
4264
|
+
}
|
|
4265
|
+
return reply.status(200).send({ status: "ready" });
|
|
4266
|
+
});
|
|
4267
|
+
}
|
|
4268
|
+
setupGracefulShutdown() {
|
|
4269
|
+
const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
|
|
4270
|
+
signals.forEach((signal) => {
|
|
4271
|
+
process.on(signal, async () => {
|
|
4272
|
+
this.logger.info(`Received ${signal}, starting graceful shutdown...`);
|
|
4273
|
+
await this.shutdown();
|
|
4274
|
+
});
|
|
4275
|
+
});
|
|
4276
|
+
process.on("uncaughtException", async (error2) => {
|
|
4277
|
+
this.logger.error({ err: error2 }, "Uncaught exception");
|
|
4278
|
+
await this.shutdown(1);
|
|
4279
|
+
});
|
|
4280
|
+
process.on("unhandledRejection", async (reason) => {
|
|
4281
|
+
this.logger.error({ err: reason }, "Unhandled rejection");
|
|
4282
|
+
await this.shutdown(1);
|
|
4283
|
+
});
|
|
4284
|
+
}
|
|
4285
|
+
async shutdown(exitCode = 0) {
|
|
4286
|
+
if (this.isShuttingDown) {
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
this.isShuttingDown = true;
|
|
4290
|
+
this.logger.info("Graceful shutdown initiated...");
|
|
4291
|
+
const shutdownTimeout = setTimeout(() => {
|
|
4292
|
+
this.logger.error("Graceful shutdown timeout, forcing exit");
|
|
4293
|
+
process.exit(1);
|
|
4294
|
+
}, 3e4);
|
|
4295
|
+
try {
|
|
4296
|
+
await this.app.close();
|
|
4297
|
+
this.logger.info("Server closed successfully");
|
|
4298
|
+
clearTimeout(shutdownTimeout);
|
|
4299
|
+
process.exit(exitCode);
|
|
4300
|
+
} catch (error2) {
|
|
4301
|
+
this.logger.error({ err: error2 }, "Error during shutdown");
|
|
4302
|
+
clearTimeout(shutdownTimeout);
|
|
4303
|
+
process.exit(1);
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
async start() {
|
|
4307
|
+
try {
|
|
4308
|
+
await this.app.listen({
|
|
4309
|
+
port: this.config.port,
|
|
4310
|
+
host: this.config.host
|
|
4311
|
+
});
|
|
4312
|
+
this.logger.info(`Server listening on ${this.config.host}:${this.config.port}`);
|
|
4313
|
+
} catch (error2) {
|
|
4314
|
+
this.logger.error({ err: error2 }, "Failed to start server");
|
|
4315
|
+
throw error2;
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
};
|
|
4319
|
+
function createServer(config2 = {}) {
|
|
4320
|
+
return new Server(config2);
|
|
4321
|
+
}
|
|
4322
|
+
|
|
4323
|
+
// src/utils/errors.ts
|
|
4324
|
+
var AppError = class _AppError extends Error {
|
|
4325
|
+
statusCode;
|
|
4326
|
+
isOperational;
|
|
4327
|
+
errors;
|
|
4328
|
+
constructor(message, statusCode = 500, isOperational = true, errors) {
|
|
4329
|
+
super(message);
|
|
4330
|
+
this.statusCode = statusCode;
|
|
4331
|
+
this.isOperational = isOperational;
|
|
4332
|
+
this.errors = errors;
|
|
4333
|
+
Object.setPrototypeOf(this, _AppError.prototype);
|
|
4334
|
+
Error.captureStackTrace(this, this.constructor);
|
|
4335
|
+
}
|
|
4336
|
+
};
|
|
4337
|
+
var NotFoundError = class _NotFoundError extends AppError {
|
|
4338
|
+
constructor(resource = "Resource") {
|
|
4339
|
+
super(`${resource} not found`, 404);
|
|
4340
|
+
Object.setPrototypeOf(this, _NotFoundError.prototype);
|
|
4341
|
+
}
|
|
4342
|
+
};
|
|
4343
|
+
var UnauthorizedError = class _UnauthorizedError extends AppError {
|
|
4344
|
+
constructor(message = "Unauthorized") {
|
|
4345
|
+
super(message, 401);
|
|
4346
|
+
Object.setPrototypeOf(this, _UnauthorizedError.prototype);
|
|
4347
|
+
}
|
|
4348
|
+
};
|
|
4349
|
+
var ForbiddenError = class _ForbiddenError extends AppError {
|
|
4350
|
+
constructor(message = "Forbidden") {
|
|
4351
|
+
super(message, 403);
|
|
4352
|
+
Object.setPrototypeOf(this, _ForbiddenError.prototype);
|
|
4353
|
+
}
|
|
4354
|
+
};
|
|
4355
|
+
var BadRequestError = class _BadRequestError extends AppError {
|
|
4356
|
+
constructor(message = "Bad request", errors) {
|
|
4357
|
+
super(message, 400, true, errors);
|
|
4358
|
+
Object.setPrototypeOf(this, _BadRequestError.prototype);
|
|
4359
|
+
}
|
|
4360
|
+
};
|
|
4361
|
+
var ConflictError = class _ConflictError extends AppError {
|
|
4362
|
+
constructor(message = "Resource already exists") {
|
|
4363
|
+
super(message, 409);
|
|
4364
|
+
Object.setPrototypeOf(this, _ConflictError.prototype);
|
|
4365
|
+
}
|
|
4366
|
+
};
|
|
4367
|
+
var ValidationError = class _ValidationError extends AppError {
|
|
4368
|
+
constructor(errors) {
|
|
4369
|
+
super("Validation failed", 422, true, errors);
|
|
4370
|
+
Object.setPrototypeOf(this, _ValidationError.prototype);
|
|
4371
|
+
}
|
|
4372
|
+
};
|
|
4373
|
+
function isAppError(error2) {
|
|
4374
|
+
return error2 instanceof AppError;
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
// src/config/env.ts
|
|
4378
|
+
var import_zod = require("zod");
|
|
4379
|
+
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
4380
|
+
import_dotenv.default.config();
|
|
4381
|
+
var envSchema = import_zod.z.object({
|
|
4382
|
+
// Server
|
|
4383
|
+
NODE_ENV: import_zod.z.enum(["development", "staging", "production", "test"]).default("development"),
|
|
4384
|
+
PORT: import_zod.z.string().transform(Number).default("3000"),
|
|
4385
|
+
HOST: import_zod.z.string().default("0.0.0.0"),
|
|
4386
|
+
// Database
|
|
4387
|
+
DATABASE_URL: import_zod.z.string().optional(),
|
|
4388
|
+
// JWT
|
|
4389
|
+
JWT_SECRET: import_zod.z.string().min(32).optional(),
|
|
4390
|
+
JWT_ACCESS_EXPIRES_IN: import_zod.z.string().default("15m"),
|
|
4391
|
+
JWT_REFRESH_EXPIRES_IN: import_zod.z.string().default("7d"),
|
|
4392
|
+
// Security
|
|
4393
|
+
CORS_ORIGIN: import_zod.z.string().default("*"),
|
|
4394
|
+
RATE_LIMIT_MAX: import_zod.z.string().transform(Number).default("100"),
|
|
4395
|
+
RATE_LIMIT_WINDOW_MS: import_zod.z.string().transform(Number).default("60000"),
|
|
4396
|
+
// Email
|
|
4397
|
+
SMTP_HOST: import_zod.z.string().optional(),
|
|
4398
|
+
SMTP_PORT: import_zod.z.string().transform(Number).optional(),
|
|
4399
|
+
SMTP_USER: import_zod.z.string().optional(),
|
|
4400
|
+
SMTP_PASS: import_zod.z.string().optional(),
|
|
4401
|
+
SMTP_FROM: import_zod.z.string().optional(),
|
|
4402
|
+
// Redis (optional)
|
|
4403
|
+
REDIS_URL: import_zod.z.string().optional(),
|
|
4404
|
+
// Logging
|
|
4405
|
+
LOG_LEVEL: import_zod.z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info")
|
|
4406
|
+
});
|
|
4407
|
+
function validateEnv() {
|
|
4408
|
+
const parsed = envSchema.safeParse(process.env);
|
|
4409
|
+
if (!parsed.success) {
|
|
4410
|
+
logger.error({ errors: parsed.error.flatten().fieldErrors }, "Invalid environment variables");
|
|
4411
|
+
throw new Error("Invalid environment variables");
|
|
4412
|
+
}
|
|
4413
|
+
return parsed.data;
|
|
4414
|
+
}
|
|
4415
|
+
var env = validateEnv();
|
|
4416
|
+
function isProduction() {
|
|
4417
|
+
return env.NODE_ENV === "production";
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
// src/config/index.ts
|
|
4421
|
+
function parseCorsOrigin(origin) {
|
|
4422
|
+
if (origin === "*") return "*";
|
|
4423
|
+
if (origin.includes(",")) {
|
|
4424
|
+
return origin.split(",").map((o) => o.trim());
|
|
4425
|
+
}
|
|
4426
|
+
return origin;
|
|
4427
|
+
}
|
|
4428
|
+
function createConfig() {
|
|
4429
|
+
return {
|
|
4430
|
+
env,
|
|
4431
|
+
server: {
|
|
4432
|
+
port: env.PORT,
|
|
4433
|
+
host: env.HOST
|
|
4434
|
+
},
|
|
4435
|
+
jwt: {
|
|
4436
|
+
secret: env.JWT_SECRET || "change-me-in-production-please-32chars",
|
|
4437
|
+
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
|
4438
|
+
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN
|
|
4439
|
+
},
|
|
4440
|
+
security: {
|
|
4441
|
+
corsOrigin: parseCorsOrigin(env.CORS_ORIGIN),
|
|
4442
|
+
rateLimit: {
|
|
4443
|
+
max: env.RATE_LIMIT_MAX,
|
|
4444
|
+
windowMs: env.RATE_LIMIT_WINDOW_MS
|
|
4445
|
+
}
|
|
4446
|
+
},
|
|
4447
|
+
email: {
|
|
4448
|
+
host: env.SMTP_HOST,
|
|
4449
|
+
port: env.SMTP_PORT,
|
|
4450
|
+
user: env.SMTP_USER,
|
|
4451
|
+
pass: env.SMTP_PASS,
|
|
4452
|
+
from: env.SMTP_FROM
|
|
4453
|
+
},
|
|
4454
|
+
database: {
|
|
4455
|
+
url: env.DATABASE_URL
|
|
4456
|
+
},
|
|
4457
|
+
redis: {
|
|
4458
|
+
url: env.REDIS_URL
|
|
4459
|
+
}
|
|
4460
|
+
};
|
|
4461
|
+
}
|
|
4462
|
+
var config = createConfig();
|
|
4463
|
+
|
|
4464
|
+
// src/middleware/error-handler.ts
|
|
4465
|
+
function registerErrorHandler(app) {
|
|
4466
|
+
app.setErrorHandler(
|
|
4467
|
+
(error2, request, reply) => {
|
|
4468
|
+
logger.error(
|
|
4469
|
+
{
|
|
4470
|
+
err: error2,
|
|
4471
|
+
requestId: request.id,
|
|
4472
|
+
method: request.method,
|
|
4473
|
+
url: request.url
|
|
4474
|
+
},
|
|
4475
|
+
"Request error"
|
|
4476
|
+
);
|
|
4477
|
+
if (isAppError(error2)) {
|
|
4478
|
+
return reply.status(error2.statusCode).send({
|
|
4479
|
+
success: false,
|
|
4480
|
+
message: error2.message,
|
|
4481
|
+
errors: error2.errors,
|
|
4482
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
4483
|
+
});
|
|
4484
|
+
}
|
|
4485
|
+
if ("validation" in error2 && error2.validation) {
|
|
4486
|
+
const errors = {};
|
|
4487
|
+
for (const err of error2.validation) {
|
|
4488
|
+
const field = err.instancePath?.replace("/", "") || "body";
|
|
4489
|
+
if (!errors[field]) {
|
|
4490
|
+
errors[field] = [];
|
|
4491
|
+
}
|
|
4492
|
+
errors[field].push(err.message || "Invalid value");
|
|
4493
|
+
}
|
|
4494
|
+
return reply.status(400).send({
|
|
4495
|
+
success: false,
|
|
4496
|
+
message: "Validation failed",
|
|
4497
|
+
errors
|
|
4498
|
+
});
|
|
4499
|
+
}
|
|
4500
|
+
if ("statusCode" in error2 && typeof error2.statusCode === "number") {
|
|
4501
|
+
return reply.status(error2.statusCode).send({
|
|
4502
|
+
success: false,
|
|
4503
|
+
message: error2.message,
|
|
4504
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
4505
|
+
});
|
|
4506
|
+
}
|
|
4507
|
+
return reply.status(500).send({
|
|
4508
|
+
success: false,
|
|
4509
|
+
message: isProduction() ? "Internal server error" : error2.message,
|
|
4510
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
4511
|
+
});
|
|
4512
|
+
}
|
|
4513
|
+
);
|
|
4514
|
+
app.setNotFoundHandler((request, reply) => {
|
|
4515
|
+
return reply.status(404).send({
|
|
4516
|
+
success: false,
|
|
4517
|
+
message: `Route ${request.method} ${request.url} not found`
|
|
4518
|
+
});
|
|
4519
|
+
});
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4522
|
+
// src/middleware/security.ts
|
|
4523
|
+
var import_helmet = __toESM(require("@fastify/helmet"), 1);
|
|
4524
|
+
var import_cors = __toESM(require("@fastify/cors"), 1);
|
|
4525
|
+
var import_rate_limit = __toESM(require("@fastify/rate-limit"), 1);
|
|
4526
|
+
var defaultOptions = {
|
|
4527
|
+
helmet: true,
|
|
4528
|
+
cors: true,
|
|
4529
|
+
rateLimit: true
|
|
4530
|
+
};
|
|
4531
|
+
async function registerSecurity(app, options = {}) {
|
|
4532
|
+
const opts = { ...defaultOptions, ...options };
|
|
4533
|
+
if (opts.helmet) {
|
|
4534
|
+
await app.register(import_helmet.default, {
|
|
4535
|
+
contentSecurityPolicy: {
|
|
4536
|
+
directives: {
|
|
4537
|
+
defaultSrc: ["'self'"],
|
|
4538
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
4539
|
+
scriptSrc: ["'self'"],
|
|
4540
|
+
imgSrc: ["'self'", "data:", "https:"]
|
|
4541
|
+
}
|
|
4542
|
+
},
|
|
4543
|
+
crossOriginEmbedderPolicy: false
|
|
4544
|
+
});
|
|
4545
|
+
logger.debug("Helmet security headers enabled");
|
|
4546
|
+
}
|
|
4547
|
+
if (opts.cors) {
|
|
4548
|
+
await app.register(import_cors.default, {
|
|
4549
|
+
origin: config.security.corsOrigin,
|
|
4550
|
+
credentials: true,
|
|
4551
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
4552
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
4553
|
+
exposedHeaders: ["X-Total-Count", "X-Page", "X-Limit"],
|
|
4554
|
+
maxAge: 86400
|
|
4555
|
+
// 24 hours
|
|
4556
|
+
});
|
|
4557
|
+
logger.debug({ origin: config.security.corsOrigin }, "CORS enabled");
|
|
4558
|
+
}
|
|
4559
|
+
if (opts.rateLimit) {
|
|
4560
|
+
await app.register(import_rate_limit.default, {
|
|
4561
|
+
max: config.security.rateLimit.max,
|
|
4562
|
+
timeWindow: config.security.rateLimit.windowMs,
|
|
4563
|
+
errorResponseBuilder: (_request, context) => ({
|
|
4564
|
+
success: false,
|
|
4565
|
+
message: "Too many requests, please try again later",
|
|
4566
|
+
retryAfter: context.after
|
|
4567
|
+
}),
|
|
4568
|
+
keyGenerator: (request) => {
|
|
4569
|
+
return request.headers["x-forwarded-for"]?.toString().split(",")[0] || request.ip || "unknown";
|
|
4570
|
+
}
|
|
4571
|
+
});
|
|
4572
|
+
logger.debug(
|
|
4573
|
+
{
|
|
4574
|
+
max: config.security.rateLimit.max,
|
|
4575
|
+
windowMs: config.security.rateLimit.windowMs
|
|
4576
|
+
},
|
|
4577
|
+
"Rate limiting enabled"
|
|
4578
|
+
);
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
|
|
4582
|
+
// src/modules/swagger/swagger.service.ts
|
|
4583
|
+
var import_swagger = __toESM(require("@fastify/swagger"), 1);
|
|
4584
|
+
var import_swagger_ui = __toESM(require("@fastify/swagger-ui"), 1);
|
|
4585
|
+
var defaultConfig3 = {
|
|
4586
|
+
title: "Servcraft API",
|
|
4587
|
+
description: "API documentation generated by Servcraft",
|
|
4588
|
+
version: "1.0.0",
|
|
4589
|
+
tags: [
|
|
4590
|
+
{ name: "Auth", description: "Authentication endpoints" },
|
|
4591
|
+
{ name: "Users", description: "User management endpoints" },
|
|
4592
|
+
{ name: "Health", description: "Health check endpoints" }
|
|
4593
|
+
]
|
|
4594
|
+
};
|
|
4595
|
+
async function registerSwagger(app, customConfig) {
|
|
4596
|
+
const swaggerConfig = { ...defaultConfig3, ...customConfig };
|
|
4597
|
+
await app.register(import_swagger.default, {
|
|
4598
|
+
openapi: {
|
|
4599
|
+
openapi: "3.0.3",
|
|
4600
|
+
info: {
|
|
4601
|
+
title: swaggerConfig.title,
|
|
4602
|
+
description: swaggerConfig.description,
|
|
4603
|
+
version: swaggerConfig.version,
|
|
4604
|
+
contact: swaggerConfig.contact,
|
|
4605
|
+
license: swaggerConfig.license
|
|
4606
|
+
},
|
|
4607
|
+
servers: swaggerConfig.servers || [
|
|
4608
|
+
{
|
|
4609
|
+
url: `http://localhost:${config.server.port}`,
|
|
4610
|
+
description: "Development server"
|
|
4611
|
+
}
|
|
4612
|
+
],
|
|
4613
|
+
tags: swaggerConfig.tags,
|
|
4614
|
+
components: {
|
|
4615
|
+
securitySchemes: {
|
|
4616
|
+
bearerAuth: {
|
|
4617
|
+
type: "http",
|
|
4618
|
+
scheme: "bearer",
|
|
4619
|
+
bearerFormat: "JWT",
|
|
4620
|
+
description: "Enter your JWT token"
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
});
|
|
4626
|
+
await app.register(import_swagger_ui.default, {
|
|
4627
|
+
routePrefix: "/docs",
|
|
4628
|
+
uiConfig: {
|
|
4629
|
+
docExpansion: "list",
|
|
4630
|
+
deepLinking: true,
|
|
4631
|
+
displayRequestDuration: true,
|
|
4632
|
+
filter: true,
|
|
4633
|
+
showExtensions: true,
|
|
4634
|
+
showCommonExtensions: true
|
|
4635
|
+
},
|
|
4636
|
+
staticCSP: true,
|
|
4637
|
+
transformStaticCSP: (header) => header
|
|
4638
|
+
});
|
|
4639
|
+
logger.info("Swagger documentation registered at /docs");
|
|
4640
|
+
}
|
|
4641
|
+
|
|
4642
|
+
// src/modules/auth/index.ts
|
|
4643
|
+
var import_jwt = __toESM(require("@fastify/jwt"), 1);
|
|
4644
|
+
var import_cookie = __toESM(require("@fastify/cookie"), 1);
|
|
4645
|
+
|
|
4646
|
+
// src/modules/auth/auth.service.ts
|
|
4647
|
+
var import_bcryptjs = __toESM(require("bcryptjs"), 1);
|
|
4648
|
+
var import_ioredis = require("ioredis");
|
|
4649
|
+
var AuthService = class {
|
|
4650
|
+
app;
|
|
4651
|
+
SALT_ROUNDS = 12;
|
|
4652
|
+
redis = null;
|
|
4653
|
+
BLACKLIST_PREFIX = "auth:blacklist:";
|
|
4654
|
+
BLACKLIST_TTL = 7 * 24 * 60 * 60;
|
|
4655
|
+
// 7 days in seconds
|
|
4656
|
+
constructor(app, redisUrl) {
|
|
4657
|
+
this.app = app;
|
|
4658
|
+
if (redisUrl || process.env.REDIS_URL) {
|
|
4659
|
+
try {
|
|
4660
|
+
this.redis = new import_ioredis.Redis(redisUrl || process.env.REDIS_URL || "redis://localhost:6379");
|
|
4661
|
+
this.redis.on("connect", () => {
|
|
4662
|
+
logger.info("Auth service connected to Redis for token blacklist");
|
|
4663
|
+
});
|
|
4664
|
+
this.redis.on("error", (error2) => {
|
|
4665
|
+
logger.error({ err: error2 }, "Redis connection error in Auth service");
|
|
4666
|
+
});
|
|
4667
|
+
} catch (error2) {
|
|
4668
|
+
logger.warn({ err: error2 }, "Failed to connect to Redis, using in-memory blacklist");
|
|
4669
|
+
this.redis = null;
|
|
4670
|
+
}
|
|
4671
|
+
} else {
|
|
4672
|
+
logger.warn(
|
|
4673
|
+
"No REDIS_URL provided, using in-memory token blacklist (not recommended for production)"
|
|
4674
|
+
);
|
|
4675
|
+
}
|
|
4676
|
+
}
|
|
4677
|
+
async hashPassword(password) {
|
|
4678
|
+
return import_bcryptjs.default.hash(password, this.SALT_ROUNDS);
|
|
4679
|
+
}
|
|
4680
|
+
async verifyPassword(password, hash) {
|
|
4681
|
+
return import_bcryptjs.default.compare(password, hash);
|
|
4682
|
+
}
|
|
4683
|
+
generateTokenPair(user) {
|
|
4684
|
+
const accessPayload = {
|
|
4685
|
+
sub: user.id,
|
|
4686
|
+
email: user.email,
|
|
4687
|
+
role: user.role,
|
|
4688
|
+
type: "access"
|
|
4689
|
+
};
|
|
4690
|
+
const refreshPayload = {
|
|
4691
|
+
sub: user.id,
|
|
4692
|
+
email: user.email,
|
|
4693
|
+
role: user.role,
|
|
4694
|
+
type: "refresh"
|
|
4695
|
+
};
|
|
4696
|
+
const accessToken = this.app.jwt.sign(accessPayload, {
|
|
4697
|
+
expiresIn: config.jwt.accessExpiresIn
|
|
4698
|
+
});
|
|
4699
|
+
const refreshToken = this.app.jwt.sign(refreshPayload, {
|
|
4700
|
+
expiresIn: config.jwt.refreshExpiresIn
|
|
4701
|
+
});
|
|
4702
|
+
const expiresIn = this.parseExpiration(config.jwt.accessExpiresIn);
|
|
4703
|
+
return { accessToken, refreshToken, expiresIn };
|
|
4704
|
+
}
|
|
4705
|
+
parseExpiration(expiration) {
|
|
4706
|
+
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
4707
|
+
if (!match) return 900;
|
|
4708
|
+
const value = parseInt(match[1] || "0", 10);
|
|
4709
|
+
const unit = match[2];
|
|
4710
|
+
switch (unit) {
|
|
4711
|
+
case "s":
|
|
4712
|
+
return value;
|
|
4713
|
+
case "m":
|
|
4714
|
+
return value * 60;
|
|
4715
|
+
case "h":
|
|
4716
|
+
return value * 3600;
|
|
4717
|
+
case "d":
|
|
4718
|
+
return value * 86400;
|
|
4719
|
+
default:
|
|
4720
|
+
return 900;
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
async verifyAccessToken(token) {
|
|
4724
|
+
try {
|
|
4725
|
+
if (await this.isTokenBlacklisted(token)) {
|
|
4726
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
4727
|
+
}
|
|
4728
|
+
const payload = this.app.jwt.verify(token);
|
|
4729
|
+
if (payload.type !== "access") {
|
|
4730
|
+
throw new UnauthorizedError("Invalid token type");
|
|
4731
|
+
}
|
|
4732
|
+
return payload;
|
|
4733
|
+
} catch (error2) {
|
|
4734
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
4735
|
+
logger.debug({ err: error2 }, "Token verification failed");
|
|
4736
|
+
throw new UnauthorizedError("Invalid or expired token");
|
|
4737
|
+
}
|
|
4738
|
+
}
|
|
4739
|
+
async verifyRefreshToken(token) {
|
|
4740
|
+
try {
|
|
4741
|
+
if (await this.isTokenBlacklisted(token)) {
|
|
4742
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
4743
|
+
}
|
|
4744
|
+
const payload = this.app.jwt.verify(token);
|
|
4745
|
+
if (payload.type !== "refresh") {
|
|
4746
|
+
throw new UnauthorizedError("Invalid token type");
|
|
4747
|
+
}
|
|
4748
|
+
return payload;
|
|
4749
|
+
} catch (error2) {
|
|
4750
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
4751
|
+
logger.debug({ err: error2 }, "Refresh token verification failed");
|
|
4752
|
+
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
4753
|
+
}
|
|
4754
|
+
}
|
|
4755
|
+
/**
|
|
4756
|
+
* Blacklist a token (JWT revocation)
|
|
4757
|
+
* Uses Redis if available, falls back to in-memory Set
|
|
4758
|
+
*/
|
|
4759
|
+
async blacklistToken(token) {
|
|
4760
|
+
if (this.redis) {
|
|
4761
|
+
try {
|
|
4762
|
+
const key = `${this.BLACKLIST_PREFIX}${token}`;
|
|
4763
|
+
await this.redis.setex(key, this.BLACKLIST_TTL, "1");
|
|
4764
|
+
logger.debug("Token blacklisted in Redis");
|
|
4765
|
+
} catch (error2) {
|
|
4766
|
+
logger.error({ err: error2 }, "Failed to blacklist token in Redis");
|
|
4767
|
+
throw new Error("Failed to revoke token");
|
|
4768
|
+
}
|
|
4769
|
+
} else {
|
|
4770
|
+
logger.warn("Using in-memory blacklist - not suitable for multi-instance deployments");
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
/**
|
|
4774
|
+
* Check if a token is blacklisted
|
|
4775
|
+
* Uses Redis if available, falls back to always returning false
|
|
4776
|
+
*/
|
|
4777
|
+
async isTokenBlacklisted(token) {
|
|
4778
|
+
if (this.redis) {
|
|
4779
|
+
try {
|
|
4780
|
+
const key = `${this.BLACKLIST_PREFIX}${token}`;
|
|
4781
|
+
const result = await this.redis.exists(key);
|
|
4782
|
+
return result === 1;
|
|
4783
|
+
} catch (error2) {
|
|
4784
|
+
logger.error({ err: error2 }, "Failed to check token blacklist in Redis");
|
|
4785
|
+
return false;
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
return false;
|
|
4789
|
+
}
|
|
4790
|
+
/**
|
|
4791
|
+
* Get count of blacklisted tokens (Redis only)
|
|
4792
|
+
*/
|
|
4793
|
+
async getBlacklistCount() {
|
|
4794
|
+
if (this.redis) {
|
|
4795
|
+
try {
|
|
4796
|
+
const keys = await this.redis.keys(`${this.BLACKLIST_PREFIX}*`);
|
|
4797
|
+
return keys.length;
|
|
4798
|
+
} catch (error2) {
|
|
4799
|
+
logger.error({ err: error2 }, "Failed to get blacklist count from Redis");
|
|
4800
|
+
return 0;
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
return 0;
|
|
4804
|
+
}
|
|
4805
|
+
/**
|
|
4806
|
+
* Close Redis connection
|
|
4807
|
+
*/
|
|
4808
|
+
async close() {
|
|
4809
|
+
if (this.redis) {
|
|
4810
|
+
await this.redis.quit();
|
|
4811
|
+
logger.info("Auth service Redis connection closed");
|
|
4812
|
+
}
|
|
4813
|
+
}
|
|
4814
|
+
// OAuth support methods - to be implemented with user repository
|
|
4815
|
+
async findUserByEmail(_email) {
|
|
4816
|
+
return null;
|
|
4817
|
+
}
|
|
4818
|
+
async createUserFromOAuth(data) {
|
|
4819
|
+
const user = {
|
|
4820
|
+
id: `oauth_${Date.now()}`,
|
|
4821
|
+
email: data.email,
|
|
4822
|
+
role: "user"
|
|
4823
|
+
};
|
|
4824
|
+
logger.info({ email: data.email }, "Created user from OAuth");
|
|
4825
|
+
return user;
|
|
4826
|
+
}
|
|
4827
|
+
async generateTokensForUser(userId) {
|
|
4828
|
+
const user = {
|
|
4829
|
+
id: userId,
|
|
4830
|
+
email: "",
|
|
4831
|
+
// Would be fetched from database in production
|
|
4832
|
+
role: "user"
|
|
4833
|
+
};
|
|
4834
|
+
return this.generateTokenPair(user);
|
|
4835
|
+
}
|
|
4836
|
+
async verifyPasswordById(userId, _password) {
|
|
4837
|
+
logger.debug({ userId }, "Password verification requested");
|
|
4838
|
+
return false;
|
|
4839
|
+
}
|
|
4840
|
+
};
|
|
4841
|
+
function createAuthService(app) {
|
|
4842
|
+
return new AuthService(app);
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
// src/modules/auth/schemas.ts
|
|
4846
|
+
var import_zod2 = require("zod");
|
|
4847
|
+
var loginSchema = import_zod2.z.object({
|
|
4848
|
+
email: import_zod2.z.string().email("Invalid email address"),
|
|
4849
|
+
password: import_zod2.z.string().min(1, "Password is required")
|
|
4850
|
+
});
|
|
4851
|
+
var registerSchema = import_zod2.z.object({
|
|
4852
|
+
email: import_zod2.z.string().email("Invalid email address"),
|
|
4853
|
+
password: import_zod2.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number"),
|
|
4854
|
+
name: import_zod2.z.string().min(2, "Name must be at least 2 characters").optional()
|
|
4855
|
+
});
|
|
4856
|
+
var refreshTokenSchema = import_zod2.z.object({
|
|
4857
|
+
refreshToken: import_zod2.z.string().min(1, "Refresh token is required")
|
|
4858
|
+
});
|
|
4859
|
+
var passwordResetRequestSchema = import_zod2.z.object({
|
|
4860
|
+
email: import_zod2.z.string().email("Invalid email address")
|
|
4861
|
+
});
|
|
4862
|
+
var passwordResetConfirmSchema = import_zod2.z.object({
|
|
4863
|
+
token: import_zod2.z.string().min(1, "Token is required"),
|
|
4864
|
+
password: import_zod2.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number")
|
|
4865
|
+
});
|
|
4866
|
+
var changePasswordSchema = import_zod2.z.object({
|
|
4867
|
+
currentPassword: import_zod2.z.string().min(1, "Current password is required"),
|
|
4868
|
+
newPassword: import_zod2.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number")
|
|
4869
|
+
});
|
|
4870
|
+
|
|
4871
|
+
// src/utils/response.ts
|
|
4872
|
+
function success2(reply, data, statusCode = 200) {
|
|
4873
|
+
const response = {
|
|
4874
|
+
success: true,
|
|
4875
|
+
data
|
|
4876
|
+
};
|
|
4877
|
+
return reply.status(statusCode).send(response);
|
|
4878
|
+
}
|
|
4879
|
+
function created(reply, data) {
|
|
4880
|
+
return success2(reply, data, 201);
|
|
4881
|
+
}
|
|
4882
|
+
function noContent(reply) {
|
|
4883
|
+
return reply.status(204).send();
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
// src/modules/validation/validator.ts
|
|
4887
|
+
var import_zod3 = require("zod");
|
|
4888
|
+
function validateBody(schema, data) {
|
|
4889
|
+
const result = schema.safeParse(data);
|
|
4890
|
+
if (!result.success) {
|
|
4891
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
4892
|
+
}
|
|
4893
|
+
return result.data;
|
|
4894
|
+
}
|
|
4895
|
+
function validateQuery(schema, data) {
|
|
4896
|
+
const result = schema.safeParse(data);
|
|
4897
|
+
if (!result.success) {
|
|
4898
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
4899
|
+
}
|
|
4900
|
+
return result.data;
|
|
4901
|
+
}
|
|
4902
|
+
function formatZodErrors(error2) {
|
|
4903
|
+
const errors = {};
|
|
4904
|
+
for (const issue of error2.issues) {
|
|
4905
|
+
const path9 = issue.path.join(".") || "root";
|
|
4906
|
+
if (!errors[path9]) {
|
|
4907
|
+
errors[path9] = [];
|
|
4908
|
+
}
|
|
4909
|
+
errors[path9].push(issue.message);
|
|
4910
|
+
}
|
|
4911
|
+
return errors;
|
|
4912
|
+
}
|
|
4913
|
+
var idParamSchema = import_zod3.z.object({
|
|
4914
|
+
id: import_zod3.z.string().uuid("Invalid ID format")
|
|
4915
|
+
});
|
|
4916
|
+
var paginationSchema = import_zod3.z.object({
|
|
4917
|
+
page: import_zod3.z.string().transform(Number).optional().default("1"),
|
|
4918
|
+
limit: import_zod3.z.string().transform(Number).optional().default("20"),
|
|
4919
|
+
sortBy: import_zod3.z.string().optional(),
|
|
4920
|
+
sortOrder: import_zod3.z.enum(["asc", "desc"]).optional().default("asc")
|
|
4921
|
+
});
|
|
4922
|
+
var searchSchema = import_zod3.z.object({
|
|
4923
|
+
q: import_zod3.z.string().min(1, "Search query is required").optional(),
|
|
4924
|
+
search: import_zod3.z.string().min(1).optional()
|
|
4925
|
+
});
|
|
4926
|
+
var emailSchema = import_zod3.z.string().email("Invalid email address");
|
|
4927
|
+
var passwordSchema = import_zod3.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number").regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
|
|
4928
|
+
var urlSchema = import_zod3.z.string().url("Invalid URL format");
|
|
4929
|
+
var phoneSchema = import_zod3.z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format");
|
|
4930
|
+
var dateSchema = import_zod3.z.coerce.date();
|
|
4931
|
+
var futureDateSchema = import_zod3.z.coerce.date().refine((date) => date > /* @__PURE__ */ new Date(), "Date must be in the future");
|
|
4932
|
+
var pastDateSchema = import_zod3.z.coerce.date().refine((date) => date < /* @__PURE__ */ new Date(), "Date must be in the past");
|
|
4933
|
+
|
|
4934
|
+
// src/modules/auth/auth.controller.ts
|
|
4935
|
+
var AuthController = class {
|
|
4936
|
+
constructor(authService, userService) {
|
|
4937
|
+
this.authService = authService;
|
|
4938
|
+
this.userService = userService;
|
|
4939
|
+
}
|
|
4940
|
+
async register(request, reply) {
|
|
4941
|
+
const data = validateBody(registerSchema, request.body);
|
|
4942
|
+
const existingUser = await this.userService.findByEmail(data.email);
|
|
4943
|
+
if (existingUser) {
|
|
4944
|
+
throw new BadRequestError("Email already registered");
|
|
4945
|
+
}
|
|
4946
|
+
const hashedPassword = await this.authService.hashPassword(data.password);
|
|
4947
|
+
const user = await this.userService.create({
|
|
4948
|
+
email: data.email,
|
|
4949
|
+
password: hashedPassword,
|
|
4950
|
+
name: data.name
|
|
4951
|
+
});
|
|
4952
|
+
const tokens = this.authService.generateTokenPair({
|
|
4953
|
+
id: user.id,
|
|
4954
|
+
email: user.email,
|
|
4955
|
+
role: user.role
|
|
4956
|
+
});
|
|
4957
|
+
created(reply, {
|
|
4958
|
+
user: {
|
|
4959
|
+
id: user.id,
|
|
4960
|
+
email: user.email,
|
|
4961
|
+
name: user.name,
|
|
4962
|
+
role: user.role
|
|
4963
|
+
},
|
|
4964
|
+
...tokens
|
|
4965
|
+
});
|
|
4966
|
+
}
|
|
4967
|
+
async login(request, reply) {
|
|
4968
|
+
const data = validateBody(loginSchema, request.body);
|
|
4969
|
+
const user = await this.userService.findByEmail(data.email);
|
|
4970
|
+
if (!user) {
|
|
4971
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
4972
|
+
}
|
|
4973
|
+
if (user.status !== "active") {
|
|
4974
|
+
throw new UnauthorizedError("Account is not active");
|
|
4975
|
+
}
|
|
4976
|
+
const isValidPassword = await this.authService.verifyPassword(data.password, user.password);
|
|
4977
|
+
if (!isValidPassword) {
|
|
4978
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
4979
|
+
}
|
|
4980
|
+
await this.userService.updateLastLogin(user.id);
|
|
4981
|
+
const tokens = this.authService.generateTokenPair({
|
|
4982
|
+
id: user.id,
|
|
4983
|
+
email: user.email,
|
|
4984
|
+
role: user.role
|
|
4985
|
+
});
|
|
4986
|
+
success2(reply, {
|
|
4987
|
+
user: {
|
|
4988
|
+
id: user.id,
|
|
4989
|
+
email: user.email,
|
|
4990
|
+
name: user.name,
|
|
4991
|
+
role: user.role
|
|
4992
|
+
},
|
|
4993
|
+
...tokens
|
|
4994
|
+
});
|
|
4995
|
+
}
|
|
4996
|
+
async refresh(request, reply) {
|
|
4997
|
+
const data = validateBody(refreshTokenSchema, request.body);
|
|
4998
|
+
const payload = await this.authService.verifyRefreshToken(data.refreshToken);
|
|
4999
|
+
const user = await this.userService.findById(payload.sub);
|
|
5000
|
+
if (!user || user.status !== "active") {
|
|
5001
|
+
throw new UnauthorizedError("User not found or inactive");
|
|
5002
|
+
}
|
|
5003
|
+
await this.authService.blacklistToken(data.refreshToken);
|
|
5004
|
+
const tokens = this.authService.generateTokenPair({
|
|
5005
|
+
id: user.id,
|
|
5006
|
+
email: user.email,
|
|
5007
|
+
role: user.role
|
|
5008
|
+
});
|
|
5009
|
+
success2(reply, tokens);
|
|
5010
|
+
}
|
|
5011
|
+
async logout(request, reply) {
|
|
5012
|
+
const authHeader = request.headers.authorization;
|
|
5013
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
5014
|
+
const token = authHeader.substring(7);
|
|
5015
|
+
await this.authService.blacklistToken(token);
|
|
5016
|
+
}
|
|
5017
|
+
success2(reply, { message: "Logged out successfully" });
|
|
5018
|
+
}
|
|
5019
|
+
async me(request, reply) {
|
|
5020
|
+
const authRequest = request;
|
|
5021
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
5022
|
+
if (!user) {
|
|
5023
|
+
throw new UnauthorizedError("User not found");
|
|
5024
|
+
}
|
|
5025
|
+
success2(reply, {
|
|
5026
|
+
id: user.id,
|
|
5027
|
+
email: user.email,
|
|
5028
|
+
name: user.name,
|
|
5029
|
+
role: user.role,
|
|
5030
|
+
status: user.status,
|
|
5031
|
+
createdAt: user.createdAt
|
|
5032
|
+
});
|
|
5033
|
+
}
|
|
5034
|
+
async changePassword(request, reply) {
|
|
5035
|
+
const authRequest = request;
|
|
5036
|
+
const data = validateBody(changePasswordSchema, request.body);
|
|
5037
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
5038
|
+
if (!user) {
|
|
5039
|
+
throw new UnauthorizedError("User not found");
|
|
5040
|
+
}
|
|
5041
|
+
const isValidPassword = await this.authService.verifyPassword(
|
|
5042
|
+
data.currentPassword,
|
|
5043
|
+
user.password
|
|
5044
|
+
);
|
|
5045
|
+
if (!isValidPassword) {
|
|
5046
|
+
throw new BadRequestError("Current password is incorrect");
|
|
5047
|
+
}
|
|
5048
|
+
const hashedPassword = await this.authService.hashPassword(data.newPassword);
|
|
5049
|
+
await this.userService.updatePassword(user.id, hashedPassword);
|
|
5050
|
+
success2(reply, { message: "Password changed successfully" });
|
|
5051
|
+
}
|
|
5052
|
+
};
|
|
5053
|
+
function createAuthController(authService, userService) {
|
|
5054
|
+
return new AuthController(authService, userService);
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5057
|
+
// src/modules/auth/auth.middleware.ts
|
|
5058
|
+
function createAuthMiddleware(authService) {
|
|
5059
|
+
return async function authenticate(request, _reply) {
|
|
5060
|
+
const authHeader = request.headers.authorization;
|
|
5061
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
5062
|
+
throw new UnauthorizedError("Missing or invalid authorization header");
|
|
5063
|
+
}
|
|
5064
|
+
const token = authHeader.substring(7);
|
|
5065
|
+
const payload = await authService.verifyAccessToken(token);
|
|
5066
|
+
request.user = {
|
|
5067
|
+
id: payload.sub,
|
|
5068
|
+
email: payload.email,
|
|
5069
|
+
role: payload.role
|
|
5070
|
+
};
|
|
5071
|
+
};
|
|
5072
|
+
}
|
|
5073
|
+
function createRoleMiddleware(allowedRoles) {
|
|
5074
|
+
return async function authorize(request, _reply) {
|
|
5075
|
+
const user = request.user;
|
|
5076
|
+
if (!user) {
|
|
5077
|
+
throw new UnauthorizedError("Authentication required");
|
|
5078
|
+
}
|
|
5079
|
+
if (!allowedRoles.includes(user.role)) {
|
|
5080
|
+
throw new ForbiddenError("Insufficient permissions");
|
|
5081
|
+
}
|
|
5082
|
+
};
|
|
5083
|
+
}
|
|
5084
|
+
|
|
5085
|
+
// src/modules/auth/auth.routes.ts
|
|
5086
|
+
function registerAuthRoutes(app, controller, authService) {
|
|
5087
|
+
const authenticate = createAuthMiddleware(authService);
|
|
5088
|
+
app.post("/auth/register", controller.register.bind(controller));
|
|
5089
|
+
app.post("/auth/login", controller.login.bind(controller));
|
|
5090
|
+
app.post("/auth/refresh", controller.refresh.bind(controller));
|
|
5091
|
+
app.post("/auth/logout", { preHandler: [authenticate] }, controller.logout.bind(controller));
|
|
5092
|
+
app.get("/auth/me", { preHandler: [authenticate] }, controller.me.bind(controller));
|
|
5093
|
+
app.post(
|
|
5094
|
+
"/auth/change-password",
|
|
5095
|
+
{ preHandler: [authenticate] },
|
|
5096
|
+
controller.changePassword.bind(controller)
|
|
5097
|
+
);
|
|
5098
|
+
}
|
|
5099
|
+
|
|
5100
|
+
// src/database/prisma.ts
|
|
5101
|
+
var import_client = require("@prisma/client");
|
|
5102
|
+
var prismaClientSingleton = () => {
|
|
5103
|
+
return new import_client.PrismaClient({
|
|
5104
|
+
log: isProduction() ? ["error"] : ["query", "info", "warn", "error"],
|
|
5105
|
+
errorFormat: isProduction() ? "minimal" : "pretty"
|
|
5106
|
+
});
|
|
5107
|
+
};
|
|
5108
|
+
var prisma = globalThis.__prisma ?? prismaClientSingleton();
|
|
5109
|
+
if (!isProduction()) {
|
|
5110
|
+
globalThis.__prisma = prisma;
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
// src/utils/pagination.ts
|
|
5114
|
+
var DEFAULT_PAGE = 1;
|
|
5115
|
+
var DEFAULT_LIMIT = 20;
|
|
5116
|
+
var MAX_LIMIT = 100;
|
|
5117
|
+
function parsePaginationParams(query) {
|
|
5118
|
+
const page = Math.max(1, parseInt(String(query.page || DEFAULT_PAGE), 10));
|
|
5119
|
+
const limit = Math.min(
|
|
5120
|
+
MAX_LIMIT,
|
|
5121
|
+
Math.max(1, parseInt(String(query.limit || DEFAULT_LIMIT), 10))
|
|
5122
|
+
);
|
|
5123
|
+
const sortBy = typeof query.sortBy === "string" ? query.sortBy : void 0;
|
|
5124
|
+
const sortOrder = query.sortOrder === "desc" ? "desc" : "asc";
|
|
5125
|
+
return { page, limit, sortBy, sortOrder };
|
|
5126
|
+
}
|
|
5127
|
+
function createPaginatedResult(data, total, params) {
|
|
5128
|
+
const totalPages = Math.ceil(total / params.limit);
|
|
5129
|
+
return {
|
|
5130
|
+
data,
|
|
5131
|
+
meta: {
|
|
5132
|
+
total,
|
|
5133
|
+
page: params.page,
|
|
5134
|
+
limit: params.limit,
|
|
5135
|
+
totalPages,
|
|
5136
|
+
hasNextPage: params.page < totalPages,
|
|
5137
|
+
hasPrevPage: params.page > 1
|
|
5138
|
+
}
|
|
5139
|
+
};
|
|
5140
|
+
}
|
|
5141
|
+
function getSkip(params) {
|
|
5142
|
+
return (params.page - 1) * params.limit;
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
// src/modules/user/user.repository.ts
|
|
5146
|
+
var import_client2 = require("@prisma/client");
|
|
5147
|
+
var UserRepository = class {
|
|
5148
|
+
/**
|
|
5149
|
+
* Find user by ID
|
|
5150
|
+
*/
|
|
5151
|
+
async findById(id) {
|
|
5152
|
+
const user = await prisma.user.findUnique({
|
|
5153
|
+
where: { id }
|
|
5154
|
+
});
|
|
5155
|
+
if (!user) return null;
|
|
5156
|
+
return this.mapPrismaUserToUser(user);
|
|
5157
|
+
}
|
|
5158
|
+
/**
|
|
5159
|
+
* Find user by email (case-insensitive)
|
|
5160
|
+
*/
|
|
5161
|
+
async findByEmail(email) {
|
|
5162
|
+
const user = await prisma.user.findUnique({
|
|
5163
|
+
where: { email: email.toLowerCase() }
|
|
5164
|
+
});
|
|
5165
|
+
if (!user) return null;
|
|
5166
|
+
return this.mapPrismaUserToUser(user);
|
|
5167
|
+
}
|
|
5168
|
+
/**
|
|
5169
|
+
* Find multiple users with pagination and filters
|
|
5170
|
+
*/
|
|
5171
|
+
async findMany(params, filters) {
|
|
5172
|
+
const where = this.buildWhereClause(filters);
|
|
5173
|
+
const orderBy = this.buildOrderBy(params);
|
|
5174
|
+
const [data, total] = await Promise.all([
|
|
5175
|
+
prisma.user.findMany({
|
|
5176
|
+
where,
|
|
5177
|
+
orderBy,
|
|
5178
|
+
skip: getSkip(params),
|
|
5179
|
+
take: params.limit
|
|
5180
|
+
}),
|
|
5181
|
+
prisma.user.count({ where })
|
|
5182
|
+
]);
|
|
5183
|
+
const mappedUsers = data.map((user) => this.mapPrismaUserToUser(user));
|
|
5184
|
+
return createPaginatedResult(mappedUsers, total, params);
|
|
5185
|
+
}
|
|
5186
|
+
/**
|
|
5187
|
+
* Create a new user
|
|
5188
|
+
*/
|
|
5189
|
+
async create(data) {
|
|
5190
|
+
const user = await prisma.user.create({
|
|
5191
|
+
data: {
|
|
5192
|
+
email: data.email.toLowerCase(),
|
|
5193
|
+
password: data.password,
|
|
5194
|
+
name: data.name,
|
|
5195
|
+
role: this.mapRoleToEnum(data.role || "user"),
|
|
5196
|
+
status: import_client2.UserStatus.ACTIVE,
|
|
5197
|
+
emailVerified: false
|
|
5198
|
+
}
|
|
5199
|
+
});
|
|
5200
|
+
return this.mapPrismaUserToUser(user);
|
|
5201
|
+
}
|
|
5202
|
+
/**
|
|
5203
|
+
* Update user data
|
|
5204
|
+
*/
|
|
5205
|
+
async update(id, data) {
|
|
5206
|
+
try {
|
|
5207
|
+
const user = await prisma.user.update({
|
|
5208
|
+
where: { id },
|
|
5209
|
+
data: {
|
|
5210
|
+
...data.email && { email: data.email.toLowerCase() },
|
|
5211
|
+
...data.name !== void 0 && { name: data.name },
|
|
5212
|
+
...data.role && { role: this.mapRoleToEnum(data.role) },
|
|
5213
|
+
...data.status && { status: this.mapStatusToEnum(data.status) },
|
|
5214
|
+
...data.emailVerified !== void 0 && { emailVerified: data.emailVerified },
|
|
5215
|
+
...data.metadata && { metadata: data.metadata }
|
|
5216
|
+
}
|
|
5217
|
+
});
|
|
5218
|
+
return this.mapPrismaUserToUser(user);
|
|
5219
|
+
} catch {
|
|
5220
|
+
return null;
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5223
|
+
/**
|
|
5224
|
+
* Update user password
|
|
5225
|
+
*/
|
|
5226
|
+
async updatePassword(id, password) {
|
|
5227
|
+
try {
|
|
5228
|
+
const user = await prisma.user.update({
|
|
5229
|
+
where: { id },
|
|
5230
|
+
data: { password }
|
|
5231
|
+
});
|
|
5232
|
+
return this.mapPrismaUserToUser(user);
|
|
5233
|
+
} catch {
|
|
5234
|
+
return null;
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
/**
|
|
5238
|
+
* Update last login timestamp
|
|
5239
|
+
*/
|
|
5240
|
+
async updateLastLogin(id) {
|
|
5241
|
+
try {
|
|
5242
|
+
const user = await prisma.user.update({
|
|
5243
|
+
where: { id },
|
|
5244
|
+
data: { lastLoginAt: /* @__PURE__ */ new Date() }
|
|
5245
|
+
});
|
|
5246
|
+
return this.mapPrismaUserToUser(user);
|
|
5247
|
+
} catch {
|
|
5248
|
+
return null;
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
/**
|
|
5252
|
+
* Delete user by ID
|
|
5253
|
+
*/
|
|
5254
|
+
async delete(id) {
|
|
5255
|
+
try {
|
|
5256
|
+
await prisma.user.delete({
|
|
5257
|
+
where: { id }
|
|
5258
|
+
});
|
|
5259
|
+
return true;
|
|
5260
|
+
} catch {
|
|
5261
|
+
return false;
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
5264
|
+
/**
|
|
5265
|
+
* Count users with optional filters
|
|
5266
|
+
*/
|
|
5267
|
+
async count(filters) {
|
|
5268
|
+
const where = this.buildWhereClause(filters);
|
|
5269
|
+
return prisma.user.count({ where });
|
|
5270
|
+
}
|
|
5271
|
+
/**
|
|
5272
|
+
* Helper to clear all users (for testing only)
|
|
5273
|
+
* WARNING: This deletes all users from the database
|
|
5274
|
+
*/
|
|
5275
|
+
async clear() {
|
|
5276
|
+
await prisma.user.deleteMany();
|
|
5277
|
+
}
|
|
5278
|
+
/**
|
|
5279
|
+
* Build Prisma where clause from filters
|
|
5280
|
+
*/
|
|
5281
|
+
buildWhereClause(filters) {
|
|
5282
|
+
if (!filters) return {};
|
|
5283
|
+
return {
|
|
5284
|
+
...filters.status && { status: this.mapStatusToEnum(filters.status) },
|
|
5285
|
+
...filters.role && { role: this.mapRoleToEnum(filters.role) },
|
|
5286
|
+
...filters.emailVerified !== void 0 && { emailVerified: filters.emailVerified },
|
|
5287
|
+
...filters.search && {
|
|
5288
|
+
OR: [
|
|
5289
|
+
{ email: { contains: filters.search, mode: "insensitive" } },
|
|
5290
|
+
{ name: { contains: filters.search, mode: "insensitive" } }
|
|
5291
|
+
]
|
|
5292
|
+
}
|
|
5293
|
+
};
|
|
5294
|
+
}
|
|
5295
|
+
/**
|
|
5296
|
+
* Build Prisma orderBy clause from pagination params
|
|
5297
|
+
*/
|
|
5298
|
+
buildOrderBy(params) {
|
|
5299
|
+
if (!params.sortBy) {
|
|
5300
|
+
return { createdAt: "desc" };
|
|
5301
|
+
}
|
|
5302
|
+
return {
|
|
5303
|
+
[params.sortBy]: params.sortOrder || "asc"
|
|
5304
|
+
};
|
|
5305
|
+
}
|
|
5306
|
+
/**
|
|
5307
|
+
* Map Prisma User to application User type
|
|
5308
|
+
*/
|
|
5309
|
+
mapPrismaUserToUser(prismaUser) {
|
|
5310
|
+
return {
|
|
5311
|
+
id: prismaUser.id,
|
|
5312
|
+
email: prismaUser.email,
|
|
5313
|
+
password: prismaUser.password,
|
|
5314
|
+
name: prismaUser.name ?? void 0,
|
|
5315
|
+
role: this.mapEnumToRole(prismaUser.role),
|
|
5316
|
+
status: this.mapEnumToStatus(prismaUser.status),
|
|
5317
|
+
emailVerified: prismaUser.emailVerified,
|
|
5318
|
+
lastLoginAt: prismaUser.lastLoginAt ?? void 0,
|
|
5319
|
+
metadata: prismaUser.metadata,
|
|
5320
|
+
createdAt: prismaUser.createdAt,
|
|
5321
|
+
updatedAt: prismaUser.updatedAt
|
|
5322
|
+
};
|
|
5323
|
+
}
|
|
5324
|
+
/**
|
|
5325
|
+
* Map application role to Prisma enum
|
|
5326
|
+
*/
|
|
5327
|
+
mapRoleToEnum(role) {
|
|
5328
|
+
const roleMap = {
|
|
5329
|
+
user: import_client2.UserRole.USER,
|
|
5330
|
+
moderator: import_client2.UserRole.MODERATOR,
|
|
5331
|
+
admin: import_client2.UserRole.ADMIN,
|
|
5332
|
+
super_admin: import_client2.UserRole.SUPER_ADMIN
|
|
5333
|
+
};
|
|
5334
|
+
return roleMap[role] || import_client2.UserRole.USER;
|
|
5335
|
+
}
|
|
5336
|
+
/**
|
|
5337
|
+
* Map Prisma enum to application role
|
|
5338
|
+
*/
|
|
5339
|
+
mapEnumToRole(role) {
|
|
5340
|
+
const roleMap = {
|
|
5341
|
+
[import_client2.UserRole.USER]: "user",
|
|
5342
|
+
[import_client2.UserRole.MODERATOR]: "moderator",
|
|
5343
|
+
[import_client2.UserRole.ADMIN]: "admin",
|
|
5344
|
+
[import_client2.UserRole.SUPER_ADMIN]: "super_admin"
|
|
5345
|
+
};
|
|
5346
|
+
return roleMap[role];
|
|
5347
|
+
}
|
|
5348
|
+
/**
|
|
5349
|
+
* Map application status to Prisma enum
|
|
5350
|
+
*/
|
|
5351
|
+
mapStatusToEnum(status) {
|
|
5352
|
+
const statusMap = {
|
|
5353
|
+
active: import_client2.UserStatus.ACTIVE,
|
|
5354
|
+
inactive: import_client2.UserStatus.INACTIVE,
|
|
5355
|
+
suspended: import_client2.UserStatus.SUSPENDED,
|
|
5356
|
+
banned: import_client2.UserStatus.BANNED
|
|
5357
|
+
};
|
|
5358
|
+
return statusMap[status] || import_client2.UserStatus.ACTIVE;
|
|
5359
|
+
}
|
|
5360
|
+
/**
|
|
5361
|
+
* Map Prisma enum to application status
|
|
5362
|
+
*/
|
|
5363
|
+
mapEnumToStatus(status) {
|
|
5364
|
+
const statusMap = {
|
|
5365
|
+
[import_client2.UserStatus.ACTIVE]: "active",
|
|
5366
|
+
[import_client2.UserStatus.INACTIVE]: "inactive",
|
|
5367
|
+
[import_client2.UserStatus.SUSPENDED]: "suspended",
|
|
5368
|
+
[import_client2.UserStatus.BANNED]: "banned"
|
|
5369
|
+
};
|
|
5370
|
+
return statusMap[status];
|
|
5371
|
+
}
|
|
5372
|
+
};
|
|
5373
|
+
function createUserRepository() {
|
|
5374
|
+
return new UserRepository();
|
|
5375
|
+
}
|
|
5376
|
+
|
|
5377
|
+
// src/modules/user/types.ts
|
|
5378
|
+
var DEFAULT_ROLE_PERMISSIONS = {
|
|
5379
|
+
user: ["profile:read", "profile:update"],
|
|
5380
|
+
moderator: [
|
|
5381
|
+
"profile:read",
|
|
5382
|
+
"profile:update",
|
|
5383
|
+
"users:read",
|
|
5384
|
+
"content:read",
|
|
5385
|
+
"content:update",
|
|
5386
|
+
"content:delete"
|
|
5387
|
+
],
|
|
5388
|
+
admin: [
|
|
5389
|
+
"profile:read",
|
|
5390
|
+
"profile:update",
|
|
5391
|
+
"users:read",
|
|
5392
|
+
"users:update",
|
|
5393
|
+
"users:delete",
|
|
5394
|
+
"content:manage",
|
|
5395
|
+
"settings:read"
|
|
5396
|
+
],
|
|
5397
|
+
super_admin: ["*:manage"]
|
|
5398
|
+
// All permissions
|
|
5399
|
+
};
|
|
5400
|
+
|
|
5401
|
+
// src/modules/user/user.service.ts
|
|
5402
|
+
var UserService = class {
|
|
5403
|
+
constructor(repository) {
|
|
5404
|
+
this.repository = repository;
|
|
5405
|
+
}
|
|
5406
|
+
async findById(id) {
|
|
5407
|
+
return this.repository.findById(id);
|
|
5408
|
+
}
|
|
5409
|
+
async findByEmail(email) {
|
|
5410
|
+
return this.repository.findByEmail(email);
|
|
5411
|
+
}
|
|
5412
|
+
async findMany(params, filters) {
|
|
5413
|
+
const result = await this.repository.findMany(params, filters);
|
|
5414
|
+
return {
|
|
5415
|
+
...result,
|
|
5416
|
+
data: result.data.map(({ password: _password, ...user }) => user)
|
|
5417
|
+
};
|
|
5418
|
+
}
|
|
5419
|
+
async create(data) {
|
|
5420
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
5421
|
+
if (existing) {
|
|
5422
|
+
throw new ConflictError("User with this email already exists");
|
|
5423
|
+
}
|
|
5424
|
+
const user = await this.repository.create(data);
|
|
5425
|
+
logger.info({ userId: user.id, email: user.email }, "User created");
|
|
5426
|
+
return user;
|
|
5427
|
+
}
|
|
5428
|
+
async update(id, data) {
|
|
5429
|
+
const user = await this.repository.findById(id);
|
|
5430
|
+
if (!user) {
|
|
5431
|
+
throw new NotFoundError("User");
|
|
5432
|
+
}
|
|
5433
|
+
if (data.email && data.email !== user.email) {
|
|
5434
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
5435
|
+
if (existing) {
|
|
5436
|
+
throw new ConflictError("Email already in use");
|
|
5437
|
+
}
|
|
5438
|
+
}
|
|
5439
|
+
const updatedUser = await this.repository.update(id, data);
|
|
5440
|
+
if (!updatedUser) {
|
|
5441
|
+
throw new NotFoundError("User");
|
|
5442
|
+
}
|
|
5443
|
+
logger.info({ userId: id }, "User updated");
|
|
5444
|
+
return updatedUser;
|
|
5445
|
+
}
|
|
5446
|
+
async updatePassword(id, hashedPassword) {
|
|
5447
|
+
const user = await this.repository.updatePassword(id, hashedPassword);
|
|
5448
|
+
if (!user) {
|
|
5449
|
+
throw new NotFoundError("User");
|
|
5450
|
+
}
|
|
5451
|
+
logger.info({ userId: id }, "User password updated");
|
|
5452
|
+
return user;
|
|
5453
|
+
}
|
|
5454
|
+
async updateLastLogin(id) {
|
|
5455
|
+
const user = await this.repository.updateLastLogin(id);
|
|
5456
|
+
if (!user) {
|
|
5457
|
+
throw new NotFoundError("User");
|
|
5458
|
+
}
|
|
5459
|
+
return user;
|
|
5460
|
+
}
|
|
5461
|
+
async delete(id) {
|
|
5462
|
+
const user = await this.repository.findById(id);
|
|
5463
|
+
if (!user) {
|
|
5464
|
+
throw new NotFoundError("User");
|
|
5465
|
+
}
|
|
5466
|
+
await this.repository.delete(id);
|
|
5467
|
+
logger.info({ userId: id }, "User deleted");
|
|
5468
|
+
}
|
|
5469
|
+
async suspend(id) {
|
|
5470
|
+
return this.update(id, { status: "suspended" });
|
|
5471
|
+
}
|
|
5472
|
+
async ban(id) {
|
|
5473
|
+
return this.update(id, { status: "banned" });
|
|
5474
|
+
}
|
|
5475
|
+
async activate(id) {
|
|
5476
|
+
return this.update(id, { status: "active" });
|
|
5477
|
+
}
|
|
5478
|
+
async verifyEmail(id) {
|
|
5479
|
+
return this.update(id, { emailVerified: true });
|
|
5480
|
+
}
|
|
5481
|
+
async changeRole(id, role) {
|
|
5482
|
+
return this.update(id, { role });
|
|
5483
|
+
}
|
|
5484
|
+
// RBAC helpers
|
|
5485
|
+
hasPermission(role, permission) {
|
|
5486
|
+
const permissions = DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
5487
|
+
if (permissions.includes("*:manage")) {
|
|
5488
|
+
return true;
|
|
5489
|
+
}
|
|
5490
|
+
if (permissions.includes(permission)) {
|
|
5491
|
+
return true;
|
|
5492
|
+
}
|
|
5493
|
+
const [resource] = permission.split(":");
|
|
5494
|
+
const managePermission = `${resource}:manage`;
|
|
5495
|
+
if (permissions.includes(managePermission)) {
|
|
5496
|
+
return true;
|
|
5497
|
+
}
|
|
5498
|
+
return false;
|
|
5499
|
+
}
|
|
5500
|
+
getPermissions(role) {
|
|
5501
|
+
return DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
5502
|
+
}
|
|
5503
|
+
};
|
|
5504
|
+
function createUserService(repository) {
|
|
5505
|
+
return new UserService(repository || createUserRepository());
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
// src/modules/auth/index.ts
|
|
5509
|
+
async function registerAuthModule(app) {
|
|
5510
|
+
await app.register(import_jwt.default, {
|
|
5511
|
+
secret: config.jwt.secret,
|
|
5512
|
+
sign: {
|
|
5513
|
+
algorithm: "HS256"
|
|
5514
|
+
}
|
|
5515
|
+
});
|
|
5516
|
+
await app.register(import_cookie.default, {
|
|
5517
|
+
secret: config.jwt.secret,
|
|
5518
|
+
hook: "onRequest"
|
|
5519
|
+
});
|
|
5520
|
+
const authService = createAuthService(app);
|
|
5521
|
+
const userService = createUserService();
|
|
5522
|
+
const authController = createAuthController(authService, userService);
|
|
5523
|
+
registerAuthRoutes(app, authController, authService);
|
|
5524
|
+
logger.info("Auth module registered");
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
// src/modules/user/schemas.ts
|
|
5528
|
+
var import_zod4 = require("zod");
|
|
5529
|
+
var userStatusEnum = import_zod4.z.enum(["active", "inactive", "suspended", "banned"]);
|
|
5530
|
+
var userRoleEnum = import_zod4.z.enum(["user", "admin", "moderator", "super_admin"]);
|
|
5531
|
+
var createUserSchema = import_zod4.z.object({
|
|
5532
|
+
email: import_zod4.z.string().email("Invalid email address"),
|
|
5533
|
+
password: import_zod4.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number"),
|
|
5534
|
+
name: import_zod4.z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
5535
|
+
role: userRoleEnum.optional().default("user")
|
|
5536
|
+
});
|
|
5537
|
+
var updateUserSchema = import_zod4.z.object({
|
|
5538
|
+
email: import_zod4.z.string().email("Invalid email address").optional(),
|
|
5539
|
+
name: import_zod4.z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
5540
|
+
role: userRoleEnum.optional(),
|
|
5541
|
+
status: userStatusEnum.optional(),
|
|
5542
|
+
emailVerified: import_zod4.z.boolean().optional(),
|
|
5543
|
+
metadata: import_zod4.z.record(import_zod4.z.unknown()).optional()
|
|
5544
|
+
});
|
|
5545
|
+
var updateProfileSchema = import_zod4.z.object({
|
|
5546
|
+
name: import_zod4.z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
5547
|
+
metadata: import_zod4.z.record(import_zod4.z.unknown()).optional()
|
|
5548
|
+
});
|
|
5549
|
+
var userQuerySchema = import_zod4.z.object({
|
|
5550
|
+
page: import_zod4.z.string().transform(Number).optional(),
|
|
5551
|
+
limit: import_zod4.z.string().transform(Number).optional(),
|
|
5552
|
+
sortBy: import_zod4.z.string().optional(),
|
|
5553
|
+
sortOrder: import_zod4.z.enum(["asc", "desc"]).optional(),
|
|
5554
|
+
status: userStatusEnum.optional(),
|
|
5555
|
+
role: userRoleEnum.optional(),
|
|
5556
|
+
search: import_zod4.z.string().optional(),
|
|
5557
|
+
emailVerified: import_zod4.z.string().transform((val) => val === "true").optional()
|
|
5558
|
+
});
|
|
5559
|
+
|
|
5560
|
+
// src/modules/user/user.controller.ts
|
|
5561
|
+
function omitPassword(user) {
|
|
5562
|
+
const { password, ...userData } = user;
|
|
5563
|
+
void password;
|
|
5564
|
+
return userData;
|
|
5565
|
+
}
|
|
5566
|
+
var UserController = class {
|
|
5567
|
+
constructor(userService) {
|
|
5568
|
+
this.userService = userService;
|
|
5569
|
+
}
|
|
5570
|
+
async list(request, reply) {
|
|
5571
|
+
const query = validateQuery(userQuerySchema, request.query);
|
|
5572
|
+
const pagination = parsePaginationParams(query);
|
|
5573
|
+
const filters = {
|
|
5574
|
+
status: query.status,
|
|
5575
|
+
role: query.role,
|
|
5576
|
+
search: query.search,
|
|
5577
|
+
emailVerified: query.emailVerified
|
|
5578
|
+
};
|
|
5579
|
+
const result = await this.userService.findMany(pagination, filters);
|
|
5580
|
+
success2(reply, result);
|
|
5581
|
+
}
|
|
5582
|
+
async getById(request, reply) {
|
|
5583
|
+
const user = await this.userService.findById(request.params.id);
|
|
5584
|
+
if (!user) {
|
|
5585
|
+
return reply.status(404).send({
|
|
5586
|
+
success: false,
|
|
5587
|
+
message: "User not found"
|
|
5588
|
+
});
|
|
5589
|
+
}
|
|
5590
|
+
success2(reply, omitPassword(user));
|
|
5591
|
+
}
|
|
5592
|
+
async update(request, reply) {
|
|
5593
|
+
const data = validateBody(updateUserSchema, request.body);
|
|
5594
|
+
const user = await this.userService.update(request.params.id, data);
|
|
5595
|
+
success2(reply, omitPassword(user));
|
|
5596
|
+
}
|
|
5597
|
+
async delete(request, reply) {
|
|
5598
|
+
const authRequest = request;
|
|
5599
|
+
if (authRequest.user.id === request.params.id) {
|
|
5600
|
+
throw new ForbiddenError("Cannot delete your own account");
|
|
5601
|
+
}
|
|
5602
|
+
await this.userService.delete(request.params.id);
|
|
5603
|
+
noContent(reply);
|
|
5604
|
+
}
|
|
5605
|
+
async suspend(request, reply) {
|
|
5606
|
+
const authRequest = request;
|
|
5607
|
+
if (authRequest.user.id === request.params.id) {
|
|
5608
|
+
throw new ForbiddenError("Cannot suspend your own account");
|
|
5609
|
+
}
|
|
5610
|
+
const user = await this.userService.suspend(request.params.id);
|
|
5611
|
+
const userData = omitPassword(user);
|
|
5612
|
+
success2(reply, userData);
|
|
5613
|
+
}
|
|
5614
|
+
async ban(request, reply) {
|
|
5615
|
+
const authRequest = request;
|
|
5616
|
+
if (authRequest.user.id === request.params.id) {
|
|
5617
|
+
throw new ForbiddenError("Cannot ban your own account");
|
|
5618
|
+
}
|
|
5619
|
+
const user = await this.userService.ban(request.params.id);
|
|
5620
|
+
const userData = omitPassword(user);
|
|
5621
|
+
success2(reply, userData);
|
|
5622
|
+
}
|
|
5623
|
+
async activate(request, reply) {
|
|
5624
|
+
const user = await this.userService.activate(request.params.id);
|
|
5625
|
+
const userData = omitPassword(user);
|
|
5626
|
+
success2(reply, userData);
|
|
5627
|
+
}
|
|
5628
|
+
// Profile routes (for authenticated user)
|
|
5629
|
+
async getProfile(request, reply) {
|
|
5630
|
+
const authRequest = request;
|
|
5631
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
5632
|
+
if (!user) {
|
|
5633
|
+
return reply.status(404).send({
|
|
5634
|
+
success: false,
|
|
5635
|
+
message: "User not found"
|
|
5636
|
+
});
|
|
5637
|
+
}
|
|
5638
|
+
const userData = omitPassword(user);
|
|
5639
|
+
success2(reply, userData);
|
|
5640
|
+
}
|
|
5641
|
+
async updateProfile(request, reply) {
|
|
5642
|
+
const authRequest = request;
|
|
5643
|
+
const data = validateBody(updateProfileSchema, request.body);
|
|
5644
|
+
const user = await this.userService.update(authRequest.user.id, data);
|
|
5645
|
+
const userData = omitPassword(user);
|
|
5646
|
+
success2(reply, userData);
|
|
5647
|
+
}
|
|
5648
|
+
};
|
|
5649
|
+
function createUserController(userService) {
|
|
5650
|
+
return new UserController(userService);
|
|
5651
|
+
}
|
|
5652
|
+
|
|
5653
|
+
// src/modules/user/user.routes.ts
|
|
5654
|
+
var idParamsSchema = {
|
|
5655
|
+
type: "object",
|
|
5656
|
+
properties: {
|
|
5657
|
+
id: { type: "string" }
|
|
5658
|
+
},
|
|
5659
|
+
required: ["id"]
|
|
5660
|
+
};
|
|
5661
|
+
function registerUserRoutes(app, controller, authService) {
|
|
5662
|
+
const authenticate = createAuthMiddleware(authService);
|
|
5663
|
+
const isAdmin = createRoleMiddleware(["admin", "super_admin"]);
|
|
5664
|
+
const isModerator = createRoleMiddleware(["moderator", "admin", "super_admin"]);
|
|
5665
|
+
app.get("/profile", { preHandler: [authenticate] }, controller.getProfile.bind(controller));
|
|
5666
|
+
app.patch("/profile", { preHandler: [authenticate] }, controller.updateProfile.bind(controller));
|
|
5667
|
+
app.get("/users", { preHandler: [authenticate, isModerator] }, controller.list.bind(controller));
|
|
5668
|
+
app.get(
|
|
5669
|
+
"/users/:id",
|
|
5670
|
+
{ preHandler: [authenticate, isModerator], schema: { params: idParamsSchema } },
|
|
5671
|
+
async (request, reply) => {
|
|
5672
|
+
return controller.getById(request, reply);
|
|
5673
|
+
}
|
|
5674
|
+
);
|
|
5675
|
+
app.patch(
|
|
5676
|
+
"/users/:id",
|
|
5677
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5678
|
+
async (request, reply) => {
|
|
5679
|
+
return controller.update(request, reply);
|
|
5680
|
+
}
|
|
5681
|
+
);
|
|
5682
|
+
app.delete(
|
|
5683
|
+
"/users/:id",
|
|
5684
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5685
|
+
async (request, reply) => {
|
|
5686
|
+
return controller.delete(request, reply);
|
|
5687
|
+
}
|
|
5688
|
+
);
|
|
5689
|
+
app.post(
|
|
5690
|
+
"/users/:id/suspend",
|
|
5691
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5692
|
+
async (request, reply) => {
|
|
5693
|
+
return controller.suspend(request, reply);
|
|
5694
|
+
}
|
|
5695
|
+
);
|
|
5696
|
+
app.post(
|
|
5697
|
+
"/users/:id/ban",
|
|
5698
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5699
|
+
async (request, reply) => {
|
|
5700
|
+
return controller.ban(request, reply);
|
|
5701
|
+
}
|
|
5702
|
+
);
|
|
5703
|
+
app.post(
|
|
5704
|
+
"/users/:id/activate",
|
|
5705
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5706
|
+
async (request, reply) => {
|
|
5707
|
+
return controller.activate(request, reply);
|
|
5708
|
+
}
|
|
5709
|
+
);
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
// src/modules/user/index.ts
|
|
5713
|
+
async function registerUserModule(app, authService) {
|
|
5714
|
+
const repository = createUserRepository();
|
|
5715
|
+
const userService = createUserService(repository);
|
|
5716
|
+
const userController = createUserController(userService);
|
|
5717
|
+
registerUserRoutes(app, userController, authService);
|
|
5718
|
+
logger.info("User module registered");
|
|
5719
|
+
}
|
|
5720
|
+
|
|
5721
|
+
// src/cli/utils/docs-generator.ts
|
|
5722
|
+
async function generateDocs(outputPath = "openapi.json", silent = false) {
|
|
5723
|
+
const spinner = silent ? null : (0, import_ora5.default)("Generating OpenAPI documentation...").start();
|
|
5724
|
+
try {
|
|
5725
|
+
const server = createServer({
|
|
5726
|
+
port: config.server.port,
|
|
5727
|
+
host: config.server.host
|
|
5728
|
+
});
|
|
5729
|
+
const app = server.instance;
|
|
5730
|
+
registerErrorHandler(app);
|
|
5731
|
+
await registerSecurity(app);
|
|
5732
|
+
await registerSwagger(app, {
|
|
5733
|
+
title: "Servcraft API",
|
|
5734
|
+
description: "API documentation generated by Servcraft",
|
|
5735
|
+
version: "1.0.0"
|
|
5736
|
+
});
|
|
5737
|
+
await registerAuthModule(app);
|
|
5738
|
+
const authService = createAuthService(app);
|
|
5739
|
+
await registerUserModule(app, authService);
|
|
5740
|
+
await app.ready();
|
|
5741
|
+
const spec = app.swagger();
|
|
5742
|
+
const absoluteOutput = import_path5.default.resolve(outputPath);
|
|
5743
|
+
await import_promises3.default.mkdir(import_path5.default.dirname(absoluteOutput), { recursive: true });
|
|
5744
|
+
await import_promises3.default.writeFile(absoluteOutput, JSON.stringify(spec, null, 2), "utf8");
|
|
5745
|
+
spinner?.succeed(`OpenAPI spec generated at ${absoluteOutput}`);
|
|
5746
|
+
await app.close();
|
|
5747
|
+
return absoluteOutput;
|
|
5748
|
+
} catch (error2) {
|
|
5749
|
+
spinner?.fail("Failed to generate OpenAPI documentation");
|
|
5750
|
+
throw error2;
|
|
5751
|
+
}
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
// src/cli/commands/docs.ts
|
|
5755
|
+
var docsCommand = new import_commander5.Command("docs").description("API documentation commands");
|
|
5756
|
+
docsCommand.command("generate").alias("gen").description("Generate OpenAPI/Swagger documentation").option("-o, --output <file>", "Output file path", "openapi.json").option("-f, --format <format>", "Output format: json, yaml", "json").action(async (options) => {
|
|
5757
|
+
try {
|
|
5758
|
+
const outputPath = await generateDocs(options.output, false);
|
|
5759
|
+
if (options.format === "yaml") {
|
|
5760
|
+
const jsonContent = await import_promises4.default.readFile(outputPath, "utf-8");
|
|
5761
|
+
const spec = JSON.parse(jsonContent);
|
|
5762
|
+
const yamlPath = outputPath.replace(".json", ".yaml");
|
|
5763
|
+
await import_promises4.default.writeFile(yamlPath, jsonToYaml(spec));
|
|
5764
|
+
success(`YAML documentation generated: ${yamlPath}`);
|
|
5765
|
+
}
|
|
5766
|
+
console.log("\n\u{1F4DA} Documentation URLs:");
|
|
5767
|
+
info(" Swagger UI: http://localhost:3000/docs");
|
|
5768
|
+
info(" OpenAPI JSON: http://localhost:3000/docs/json");
|
|
5769
|
+
} catch (err) {
|
|
5770
|
+
error(err instanceof Error ? err.message : String(err));
|
|
5771
|
+
}
|
|
5772
|
+
});
|
|
5773
|
+
docsCommand.option("-o, --output <path>", "Output file path", "openapi.json").action(async (options) => {
|
|
5774
|
+
if (options.output) {
|
|
5775
|
+
try {
|
|
5776
|
+
const outputPath = await generateDocs(options.output);
|
|
5777
|
+
success(`Documentation written to ${outputPath}`);
|
|
5778
|
+
} catch (err) {
|
|
5779
|
+
error(err instanceof Error ? err.message : String(err));
|
|
5780
|
+
process.exitCode = 1;
|
|
5781
|
+
}
|
|
5782
|
+
}
|
|
5783
|
+
});
|
|
5784
|
+
docsCommand.command("export").description("Export documentation to Postman, Insomnia, or YAML").option("-f, --format <format>", "Export format: postman, insomnia, yaml", "postman").option("-o, --output <file>", "Output file path").action(async (options) => {
|
|
5785
|
+
const spinner = (0, import_ora6.default)("Exporting documentation...").start();
|
|
5786
|
+
try {
|
|
5787
|
+
const projectRoot = getProjectRoot();
|
|
5788
|
+
const specPath = import_path6.default.join(projectRoot, "openapi.json");
|
|
5789
|
+
try {
|
|
5790
|
+
await import_promises4.default.access(specPath);
|
|
5791
|
+
} catch {
|
|
5792
|
+
spinner.text = "Generating OpenAPI spec first...";
|
|
5793
|
+
await generateDocs("openapi.json", true);
|
|
5794
|
+
}
|
|
5795
|
+
const specContent = await import_promises4.default.readFile(specPath, "utf-8");
|
|
5796
|
+
const spec = JSON.parse(specContent);
|
|
5797
|
+
let output;
|
|
5798
|
+
let defaultName;
|
|
5799
|
+
switch (options.format) {
|
|
5800
|
+
case "postman":
|
|
5801
|
+
output = JSON.stringify(convertToPostman(spec), null, 2);
|
|
5802
|
+
defaultName = "postman_collection.json";
|
|
5803
|
+
break;
|
|
5804
|
+
case "insomnia":
|
|
5805
|
+
output = JSON.stringify(convertToInsomnia(spec), null, 2);
|
|
5806
|
+
defaultName = "insomnia_collection.json";
|
|
5807
|
+
break;
|
|
5808
|
+
case "yaml":
|
|
5809
|
+
output = jsonToYaml(spec);
|
|
5810
|
+
defaultName = "openapi.yaml";
|
|
5811
|
+
break;
|
|
5812
|
+
default:
|
|
5813
|
+
throw new Error(`Unknown format: ${options.format}`);
|
|
5814
|
+
}
|
|
5815
|
+
const outPath = import_path6.default.join(projectRoot, options.output || defaultName);
|
|
5816
|
+
await import_promises4.default.writeFile(outPath, output);
|
|
5817
|
+
spinner.succeed(`Exported to: ${options.output || defaultName}`);
|
|
5818
|
+
if (options.format === "postman") {
|
|
5819
|
+
info("\n Import in Postman: File > Import > Select file");
|
|
5820
|
+
}
|
|
5821
|
+
} catch (err) {
|
|
5822
|
+
spinner.fail("Export failed");
|
|
5823
|
+
error(err instanceof Error ? err.message : String(err));
|
|
5824
|
+
}
|
|
5825
|
+
});
|
|
5826
|
+
docsCommand.command("status").description("Show documentation status").action(async () => {
|
|
5827
|
+
const projectRoot = getProjectRoot();
|
|
5828
|
+
console.log(import_chalk6.default.bold("\n\u{1F4CA} Documentation Status\n"));
|
|
5829
|
+
const specPath = import_path6.default.join(projectRoot, "openapi.json");
|
|
5830
|
+
try {
|
|
5831
|
+
const stat2 = await import_promises4.default.stat(specPath);
|
|
5832
|
+
success(
|
|
5833
|
+
`openapi.json exists (${formatBytes(stat2.size)}, modified ${formatDate(stat2.mtime)})`
|
|
5834
|
+
);
|
|
5835
|
+
const content = await import_promises4.default.readFile(specPath, "utf-8");
|
|
5836
|
+
const spec = JSON.parse(content);
|
|
5837
|
+
const pathCount = Object.keys(spec.paths || {}).length;
|
|
5838
|
+
info(` ${pathCount} endpoints documented`);
|
|
5839
|
+
} catch {
|
|
5840
|
+
warn('openapi.json not found - run "servcraft docs generate"');
|
|
5841
|
+
}
|
|
5842
|
+
console.log("\n\u{1F4CC} Commands:");
|
|
5843
|
+
info(" servcraft docs generate Generate OpenAPI spec");
|
|
5844
|
+
info(" servcraft docs export Export to Postman/Insomnia");
|
|
5845
|
+
});
|
|
5846
|
+
function jsonToYaml(obj, indent = 0) {
|
|
5847
|
+
const spaces = " ".repeat(indent);
|
|
5848
|
+
if (obj === null || obj === void 0) return "null";
|
|
5849
|
+
if (typeof obj === "string") {
|
|
5850
|
+
if (obj.includes("\n") || obj.includes(":") || obj.includes("#")) {
|
|
5851
|
+
return `"${obj.replace(/"/g, '\\"')}"`;
|
|
5852
|
+
}
|
|
5853
|
+
return obj || '""';
|
|
5854
|
+
}
|
|
5855
|
+
if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
|
|
5856
|
+
if (Array.isArray(obj)) {
|
|
5857
|
+
if (obj.length === 0) return "[]";
|
|
5858
|
+
return obj.map((item) => `${spaces}- ${jsonToYaml(item, indent + 1).trimStart()}`).join("\n");
|
|
5859
|
+
}
|
|
5860
|
+
if (typeof obj === "object") {
|
|
5861
|
+
const entries = Object.entries(obj);
|
|
5862
|
+
if (entries.length === 0) return "{}";
|
|
5863
|
+
return entries.map(([key, value]) => {
|
|
5864
|
+
const valueStr = jsonToYaml(value, indent + 1);
|
|
5865
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0) {
|
|
5866
|
+
return `${spaces}${key}:
|
|
5867
|
+
${valueStr}`;
|
|
5868
|
+
}
|
|
5869
|
+
return `${spaces}${key}: ${valueStr}`;
|
|
5870
|
+
}).join("\n");
|
|
5871
|
+
}
|
|
5872
|
+
return String(obj);
|
|
5873
|
+
}
|
|
5874
|
+
function convertToPostman(spec) {
|
|
5875
|
+
const baseUrl = spec.servers?.[0]?.url || "http://localhost:3000";
|
|
5876
|
+
const items = [];
|
|
5877
|
+
for (const [pathUrl, methods] of Object.entries(spec.paths || {})) {
|
|
5878
|
+
for (const [method, details] of Object.entries(methods)) {
|
|
5879
|
+
items.push({
|
|
5880
|
+
name: details.summary || `${method.toUpperCase()} ${pathUrl}`,
|
|
5881
|
+
request: {
|
|
5882
|
+
method: method.toUpperCase(),
|
|
5883
|
+
header: [
|
|
5884
|
+
{ key: "Content-Type", value: "application/json" },
|
|
5885
|
+
{ key: "Authorization", value: "Bearer {{token}}" }
|
|
5886
|
+
],
|
|
5887
|
+
url: {
|
|
5888
|
+
raw: `{{baseUrl}}${pathUrl}`,
|
|
5889
|
+
host: ["{{baseUrl}}"],
|
|
5890
|
+
path: pathUrl.split("/").filter(Boolean)
|
|
5891
|
+
},
|
|
5892
|
+
...details.requestBody ? { body: { mode: "raw", raw: "{}" } } : {}
|
|
5893
|
+
}
|
|
5894
|
+
});
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
return {
|
|
5898
|
+
info: {
|
|
5899
|
+
name: spec.info.title,
|
|
5900
|
+
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
5901
|
+
},
|
|
5902
|
+
item: items,
|
|
5903
|
+
variable: [
|
|
5904
|
+
{ key: "baseUrl", value: baseUrl },
|
|
5905
|
+
{ key: "token", value: "" }
|
|
5906
|
+
]
|
|
5907
|
+
};
|
|
5908
|
+
}
|
|
5909
|
+
function convertToInsomnia(spec) {
|
|
5910
|
+
const baseUrl = spec.servers?.[0]?.url || "http://localhost:3000";
|
|
5911
|
+
const resources = [
|
|
5912
|
+
{ _type: "environment", name: "Base Environment", data: { baseUrl, token: "" } }
|
|
5913
|
+
];
|
|
5914
|
+
for (const [pathUrl, methods] of Object.entries(spec.paths || {})) {
|
|
5915
|
+
for (const [method, details] of Object.entries(methods)) {
|
|
5916
|
+
resources.push({
|
|
5917
|
+
_type: "request",
|
|
5918
|
+
name: details.summary || `${method.toUpperCase()} ${pathUrl}`,
|
|
5919
|
+
method: method.toUpperCase(),
|
|
5920
|
+
url: `{{ baseUrl }}${pathUrl}`,
|
|
5921
|
+
headers: [
|
|
5922
|
+
{ name: "Content-Type", value: "application/json" },
|
|
5923
|
+
{ name: "Authorization", value: "Bearer {{ token }}" }
|
|
5924
|
+
]
|
|
5925
|
+
});
|
|
5926
|
+
}
|
|
5927
|
+
}
|
|
5928
|
+
return { _type: "export", __export_format: 4, resources };
|
|
5929
|
+
}
|
|
5930
|
+
function formatBytes(bytes) {
|
|
5931
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
5932
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
5933
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
5934
|
+
}
|
|
5935
|
+
function formatDate(date) {
|
|
5936
|
+
return date.toLocaleDateString("en-US", {
|
|
5937
|
+
month: "short",
|
|
5938
|
+
day: "numeric",
|
|
5939
|
+
hour: "2-digit",
|
|
5940
|
+
minute: "2-digit"
|
|
5941
|
+
});
|
|
5942
|
+
}
|
|
5943
|
+
|
|
3854
5944
|
// src/cli/index.ts
|
|
3855
|
-
var program = new
|
|
5945
|
+
var program = new import_commander6.Command();
|
|
3856
5946
|
program.name("servcraft").description("Servcraft - A modular Node.js backend framework CLI").version("0.1.0");
|
|
3857
5947
|
program.addCommand(initCommand);
|
|
3858
5948
|
program.addCommand(generateCommand);
|
|
3859
5949
|
program.addCommand(addModuleCommand);
|
|
3860
5950
|
program.addCommand(dbCommand);
|
|
5951
|
+
program.addCommand(docsCommand);
|
|
3861
5952
|
program.parse();
|
|
3862
5953
|
//# sourceMappingURL=index.cjs.map
|