servcraft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +45 -0
- package/.env.example +46 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +11 -0
- package/Dockerfile +76 -0
- package/Dockerfile.dev +31 -0
- package/README.md +232 -0
- package/commitlint.config.js +24 -0
- package/dist/cli/index.cjs +3968 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +3945 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +2458 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +828 -0
- package/dist/index.d.ts +828 -0
- package/dist/index.js +2332 -0
- package/dist/index.js.map +1 -0
- package/docker-compose.prod.yml +118 -0
- package/docker-compose.yml +147 -0
- package/eslint.config.js +27 -0
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +5 -0
- package/npm-cache/_update-notifier-last-checked +0 -0
- package/package.json +112 -0
- package/prisma/schema.prisma +157 -0
- package/src/cli/commands/add-module.ts +422 -0
- package/src/cli/commands/db.ts +137 -0
- package/src/cli/commands/docs.ts +16 -0
- package/src/cli/commands/generate.ts +459 -0
- package/src/cli/commands/init.ts +640 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/templates/controller.ts +67 -0
- package/src/cli/templates/dynamic-prisma.ts +89 -0
- package/src/cli/templates/dynamic-schemas.ts +232 -0
- package/src/cli/templates/dynamic-types.ts +60 -0
- package/src/cli/templates/module-index.ts +33 -0
- package/src/cli/templates/prisma-model.ts +17 -0
- package/src/cli/templates/repository.ts +104 -0
- package/src/cli/templates/routes.ts +70 -0
- package/src/cli/templates/schemas.ts +26 -0
- package/src/cli/templates/service.ts +58 -0
- package/src/cli/templates/types.ts +27 -0
- package/src/cli/utils/docs-generator.ts +47 -0
- package/src/cli/utils/field-parser.ts +315 -0
- package/src/cli/utils/helpers.ts +89 -0
- package/src/config/env.ts +80 -0
- package/src/config/index.ts +97 -0
- package/src/core/index.ts +5 -0
- package/src/core/logger.ts +43 -0
- package/src/core/server.ts +132 -0
- package/src/database/index.ts +7 -0
- package/src/database/prisma.ts +54 -0
- package/src/database/seed.ts +59 -0
- package/src/index.ts +63 -0
- package/src/middleware/error-handler.ts +73 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/security.ts +116 -0
- package/src/modules/audit/audit.service.ts +192 -0
- package/src/modules/audit/index.ts +2 -0
- package/src/modules/audit/types.ts +37 -0
- package/src/modules/auth/auth.controller.ts +182 -0
- package/src/modules/auth/auth.middleware.ts +87 -0
- package/src/modules/auth/auth.routes.ts +123 -0
- package/src/modules/auth/auth.service.ts +142 -0
- package/src/modules/auth/index.ts +49 -0
- package/src/modules/auth/schemas.ts +52 -0
- package/src/modules/auth/types.ts +69 -0
- package/src/modules/email/email.service.ts +212 -0
- package/src/modules/email/index.ts +10 -0
- package/src/modules/email/templates.ts +213 -0
- package/src/modules/email/types.ts +57 -0
- package/src/modules/swagger/index.ts +3 -0
- package/src/modules/swagger/schema-builder.ts +263 -0
- package/src/modules/swagger/swagger.service.ts +169 -0
- package/src/modules/swagger/types.ts +68 -0
- package/src/modules/user/index.ts +30 -0
- package/src/modules/user/schemas.ts +49 -0
- package/src/modules/user/types.ts +78 -0
- package/src/modules/user/user.controller.ts +139 -0
- package/src/modules/user/user.repository.ts +156 -0
- package/src/modules/user/user.routes.ts +199 -0
- package/src/modules/user/user.service.ts +145 -0
- package/src/modules/validation/index.ts +18 -0
- package/src/modules/validation/validator.ts +104 -0
- package/src/types/common.ts +61 -0
- package/src/types/index.ts +10 -0
- package/src/utils/errors.ts +66 -0
- package/src/utils/index.ts +33 -0
- package/src/utils/pagination.ts +38 -0
- package/src/utils/response.ts +63 -0
- package/tests/integration/auth.test.ts +59 -0
- package/tests/setup.ts +17 -0
- package/tests/unit/modules/validation.test.ts +88 -0
- package/tests/unit/utils/errors.test.ts +113 -0
- package/tests/unit/utils/pagination.test.ts +82 -0
- package/tsconfig.json +33 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,3945 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command as Command6 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/init.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import path2 from "path";
|
|
9
|
+
import fs2 from "fs/promises";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
import inquirer from "inquirer";
|
|
12
|
+
import chalk2 from "chalk";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
|
|
15
|
+
// src/cli/utils/helpers.ts
|
|
16
|
+
import fs from "fs/promises";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import chalk from "chalk";
|
|
19
|
+
function toPascalCase(str) {
|
|
20
|
+
return str.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
21
|
+
}
|
|
22
|
+
function toCamelCase(str) {
|
|
23
|
+
const pascal = toPascalCase(str);
|
|
24
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
25
|
+
}
|
|
26
|
+
function toKebabCase(str) {
|
|
27
|
+
return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
function pluralize(str) {
|
|
30
|
+
if (str.endsWith("y")) {
|
|
31
|
+
return str.slice(0, -1) + "ies";
|
|
32
|
+
}
|
|
33
|
+
if (str.endsWith("s") || str.endsWith("x") || str.endsWith("ch") || str.endsWith("sh")) {
|
|
34
|
+
return str + "es";
|
|
35
|
+
}
|
|
36
|
+
return str + "s";
|
|
37
|
+
}
|
|
38
|
+
async function fileExists(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
await fs.access(filePath);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function ensureDir(dirPath) {
|
|
47
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
async function writeFile(filePath, content) {
|
|
50
|
+
await ensureDir(path.dirname(filePath));
|
|
51
|
+
await fs.writeFile(filePath, content, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
function success(message) {
|
|
54
|
+
console.log(chalk.green("\u2713"), message);
|
|
55
|
+
}
|
|
56
|
+
function error(message) {
|
|
57
|
+
console.error(chalk.red("\u2717"), message);
|
|
58
|
+
}
|
|
59
|
+
function warn(message) {
|
|
60
|
+
console.log(chalk.yellow("\u26A0"), message);
|
|
61
|
+
}
|
|
62
|
+
function info(message) {
|
|
63
|
+
console.log(chalk.blue("\u2139"), message);
|
|
64
|
+
}
|
|
65
|
+
function getProjectRoot() {
|
|
66
|
+
return process.cwd();
|
|
67
|
+
}
|
|
68
|
+
function getSourceDir() {
|
|
69
|
+
return path.join(getProjectRoot(), "src");
|
|
70
|
+
}
|
|
71
|
+
function getModulesDir() {
|
|
72
|
+
return path.join(getSourceDir(), "modules");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/cli/commands/init.ts
|
|
76
|
+
var initCommand = new 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(async (name, cmdOptions) => {
|
|
77
|
+
console.log(chalk2.blue(`
|
|
78
|
+
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
79
|
+
\u2551 \u2551
|
|
80
|
+
\u2551 ${chalk2.bold("\u{1F680} Servcraft Project Generator")} \u2551
|
|
81
|
+
\u2551 \u2551
|
|
82
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
83
|
+
`));
|
|
84
|
+
let options;
|
|
85
|
+
if (cmdOptions?.yes) {
|
|
86
|
+
options = {
|
|
87
|
+
name: name || "my-servcraft-app",
|
|
88
|
+
language: cmdOptions.javascript ? "javascript" : "typescript",
|
|
89
|
+
database: cmdOptions.db || "postgresql",
|
|
90
|
+
validator: "zod",
|
|
91
|
+
features: ["auth", "users", "email"]
|
|
92
|
+
};
|
|
93
|
+
} else {
|
|
94
|
+
const answers = await inquirer.prompt([
|
|
95
|
+
{
|
|
96
|
+
type: "input",
|
|
97
|
+
name: "name",
|
|
98
|
+
message: "Project name:",
|
|
99
|
+
default: name || "my-servcraft-app",
|
|
100
|
+
validate: (input) => {
|
|
101
|
+
if (!/^[a-z0-9-_]+$/i.test(input)) {
|
|
102
|
+
return "Project name can only contain letters, numbers, hyphens, and underscores";
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: "list",
|
|
109
|
+
name: "language",
|
|
110
|
+
message: "Select language:",
|
|
111
|
+
choices: [
|
|
112
|
+
{ name: "TypeScript (Recommended)", value: "typescript" },
|
|
113
|
+
{ name: "JavaScript", value: "javascript" }
|
|
114
|
+
],
|
|
115
|
+
default: "typescript"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
type: "list",
|
|
119
|
+
name: "database",
|
|
120
|
+
message: "Select database:",
|
|
121
|
+
choices: [
|
|
122
|
+
{ name: "PostgreSQL (Recommended)", value: "postgresql" },
|
|
123
|
+
{ name: "MySQL", value: "mysql" },
|
|
124
|
+
{ name: "SQLite (Development)", value: "sqlite" },
|
|
125
|
+
{ name: "MongoDB", value: "mongodb" },
|
|
126
|
+
{ name: "None (Add later)", value: "none" }
|
|
127
|
+
],
|
|
128
|
+
default: "postgresql"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
type: "list",
|
|
132
|
+
name: "validator",
|
|
133
|
+
message: "Select validation library:",
|
|
134
|
+
choices: [
|
|
135
|
+
{ name: "Zod (Recommended - TypeScript-first)", value: "zod" },
|
|
136
|
+
{ name: "Joi (Battle-tested, feature-rich)", value: "joi" },
|
|
137
|
+
{ name: "Yup (Inspired by Joi, lighter)", value: "yup" }
|
|
138
|
+
],
|
|
139
|
+
default: "zod"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: "checkbox",
|
|
143
|
+
name: "features",
|
|
144
|
+
message: "Select features to include:",
|
|
145
|
+
choices: [
|
|
146
|
+
{ name: "Authentication (JWT)", value: "auth", checked: true },
|
|
147
|
+
{ name: "User Management", value: "users", checked: true },
|
|
148
|
+
{ name: "Email Service", value: "email", checked: true },
|
|
149
|
+
{ name: "Audit Logs", value: "audit", checked: false },
|
|
150
|
+
{ name: "File Upload", value: "upload", checked: false },
|
|
151
|
+
{ name: "Redis Cache", value: "redis", checked: false }
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
]);
|
|
155
|
+
options = answers;
|
|
156
|
+
}
|
|
157
|
+
const projectDir = path2.resolve(process.cwd(), options.name);
|
|
158
|
+
const spinner = ora("Creating project...").start();
|
|
159
|
+
try {
|
|
160
|
+
try {
|
|
161
|
+
await fs2.access(projectDir);
|
|
162
|
+
spinner.stop();
|
|
163
|
+
error(`Directory "${options.name}" already exists`);
|
|
164
|
+
return;
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
await ensureDir(projectDir);
|
|
168
|
+
spinner.text = "Generating project files...";
|
|
169
|
+
const packageJson = generatePackageJson(options);
|
|
170
|
+
await writeFile(path2.join(projectDir, "package.json"), JSON.stringify(packageJson, null, 2));
|
|
171
|
+
if (options.language === "typescript") {
|
|
172
|
+
await writeFile(path2.join(projectDir, "tsconfig.json"), generateTsConfig());
|
|
173
|
+
await writeFile(path2.join(projectDir, "tsup.config.ts"), generateTsupConfig());
|
|
174
|
+
} else {
|
|
175
|
+
await writeFile(path2.join(projectDir, "jsconfig.json"), generateJsConfig());
|
|
176
|
+
}
|
|
177
|
+
await writeFile(path2.join(projectDir, ".env.example"), generateEnvExample(options));
|
|
178
|
+
await writeFile(path2.join(projectDir, ".env"), generateEnvExample(options));
|
|
179
|
+
await writeFile(path2.join(projectDir, ".gitignore"), generateGitignore());
|
|
180
|
+
await writeFile(path2.join(projectDir, "Dockerfile"), generateDockerfile(options));
|
|
181
|
+
await writeFile(path2.join(projectDir, "docker-compose.yml"), generateDockerCompose(options));
|
|
182
|
+
const ext = options.language === "typescript" ? "ts" : "js";
|
|
183
|
+
const dirs = [
|
|
184
|
+
"src/core",
|
|
185
|
+
"src/config",
|
|
186
|
+
"src/modules",
|
|
187
|
+
"src/middleware",
|
|
188
|
+
"src/utils",
|
|
189
|
+
"src/types",
|
|
190
|
+
"tests/unit",
|
|
191
|
+
"tests/integration"
|
|
192
|
+
];
|
|
193
|
+
if (options.database !== "none" && options.database !== "mongodb") {
|
|
194
|
+
dirs.push("prisma");
|
|
195
|
+
}
|
|
196
|
+
for (const dir of dirs) {
|
|
197
|
+
await ensureDir(path2.join(projectDir, dir));
|
|
198
|
+
}
|
|
199
|
+
await writeFile(
|
|
200
|
+
path2.join(projectDir, `src/index.${ext}`),
|
|
201
|
+
generateEntryFile(options)
|
|
202
|
+
);
|
|
203
|
+
await writeFile(
|
|
204
|
+
path2.join(projectDir, `src/core/server.${ext}`),
|
|
205
|
+
generateServerFile(options)
|
|
206
|
+
);
|
|
207
|
+
await writeFile(
|
|
208
|
+
path2.join(projectDir, `src/core/logger.${ext}`),
|
|
209
|
+
generateLoggerFile(options)
|
|
210
|
+
);
|
|
211
|
+
if (options.database !== "none" && options.database !== "mongodb") {
|
|
212
|
+
await writeFile(
|
|
213
|
+
path2.join(projectDir, "prisma/schema.prisma"),
|
|
214
|
+
generatePrismaSchema(options)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
spinner.succeed("Project files generated!");
|
|
218
|
+
const installSpinner = ora("Installing dependencies...").start();
|
|
219
|
+
try {
|
|
220
|
+
execSync("npm install", { cwd: projectDir, stdio: "pipe" });
|
|
221
|
+
installSpinner.succeed("Dependencies installed!");
|
|
222
|
+
} catch {
|
|
223
|
+
installSpinner.warn("Failed to install dependencies automatically");
|
|
224
|
+
warn(' Run "npm install" manually in the project directory');
|
|
225
|
+
}
|
|
226
|
+
console.log("\n" + chalk2.green("\u2728 Project created successfully!"));
|
|
227
|
+
console.log("\n" + chalk2.bold("\u{1F4C1} Project structure:"));
|
|
228
|
+
console.log(`
|
|
229
|
+
${options.name}/
|
|
230
|
+
\u251C\u2500\u2500 src/
|
|
231
|
+
\u2502 \u251C\u2500\u2500 core/ # Core server, logger
|
|
232
|
+
\u2502 \u251C\u2500\u2500 config/ # Configuration
|
|
233
|
+
\u2502 \u251C\u2500\u2500 modules/ # Feature modules
|
|
234
|
+
\u2502 \u251C\u2500\u2500 middleware/ # Middlewares
|
|
235
|
+
\u2502 \u251C\u2500\u2500 utils/ # Utilities
|
|
236
|
+
\u2502 \u2514\u2500\u2500 index.${ext} # Entry point
|
|
237
|
+
\u251C\u2500\u2500 tests/ # Tests
|
|
238
|
+
\u251C\u2500\u2500 prisma/ # Database schema
|
|
239
|
+
\u251C\u2500\u2500 docker-compose.yml
|
|
240
|
+
\u2514\u2500\u2500 package.json
|
|
241
|
+
`);
|
|
242
|
+
console.log(chalk2.bold("\u{1F680} Get started:"));
|
|
243
|
+
console.log(`
|
|
244
|
+
${chalk2.cyan(`cd ${options.name}`)}
|
|
245
|
+
${options.database !== "none" ? chalk2.cyan("npm run db:push # Setup database") : ""}
|
|
246
|
+
${chalk2.cyan("npm run dev # Start development server")}
|
|
247
|
+
`);
|
|
248
|
+
console.log(chalk2.bold("\u{1F4DA} Available commands:"));
|
|
249
|
+
console.log(`
|
|
250
|
+
${chalk2.yellow("servcraft generate module <name>")} Generate a new module
|
|
251
|
+
${chalk2.yellow("servcraft generate controller <name>")} Generate a controller
|
|
252
|
+
${chalk2.yellow("servcraft generate service <name>")} Generate a service
|
|
253
|
+
${chalk2.yellow("servcraft add auth")} Add authentication module
|
|
254
|
+
`);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
spinner.fail("Failed to create project");
|
|
257
|
+
error(err instanceof Error ? err.message : String(err));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
function generatePackageJson(options) {
|
|
261
|
+
const isTS = options.language === "typescript";
|
|
262
|
+
const pkg = {
|
|
263
|
+
name: options.name,
|
|
264
|
+
version: "0.1.0",
|
|
265
|
+
description: "A Servcraft application",
|
|
266
|
+
main: isTS ? "dist/index.js" : "src/index.js",
|
|
267
|
+
type: "module",
|
|
268
|
+
scripts: {
|
|
269
|
+
dev: isTS ? "tsx watch src/index.ts" : "node --watch src/index.js",
|
|
270
|
+
build: isTS ? "tsup" : 'echo "No build needed for JS"',
|
|
271
|
+
start: isTS ? "node dist/index.js" : "node src/index.js",
|
|
272
|
+
test: "vitest",
|
|
273
|
+
lint: isTS ? "eslint src --ext .ts" : "eslint src --ext .js"
|
|
274
|
+
},
|
|
275
|
+
dependencies: {
|
|
276
|
+
fastify: "^4.28.1",
|
|
277
|
+
"@fastify/cors": "^9.0.1",
|
|
278
|
+
"@fastify/helmet": "^11.1.1",
|
|
279
|
+
"@fastify/jwt": "^8.0.1",
|
|
280
|
+
"@fastify/rate-limit": "^9.1.0",
|
|
281
|
+
"@fastify/cookie": "^9.3.1",
|
|
282
|
+
pino: "^9.5.0",
|
|
283
|
+
"pino-pretty": "^11.3.0",
|
|
284
|
+
bcryptjs: "^2.4.3",
|
|
285
|
+
dotenv: "^16.4.5"
|
|
286
|
+
},
|
|
287
|
+
devDependencies: {
|
|
288
|
+
vitest: "^2.1.8"
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
switch (options.validator) {
|
|
292
|
+
case "zod":
|
|
293
|
+
pkg.dependencies.zod = "^3.23.8";
|
|
294
|
+
break;
|
|
295
|
+
case "joi":
|
|
296
|
+
pkg.dependencies.joi = "^17.13.3";
|
|
297
|
+
break;
|
|
298
|
+
case "yup":
|
|
299
|
+
pkg.dependencies.yup = "^1.4.0";
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (isTS) {
|
|
303
|
+
pkg.devDependencies.typescript = "^5.7.2";
|
|
304
|
+
pkg.devDependencies.tsx = "^4.19.2";
|
|
305
|
+
pkg.devDependencies.tsup = "^8.3.5";
|
|
306
|
+
pkg.devDependencies["@types/node"] = "^22.10.1";
|
|
307
|
+
pkg.devDependencies["@types/bcryptjs"] = "^2.4.6";
|
|
308
|
+
}
|
|
309
|
+
if (options.database !== "none" && options.database !== "mongodb") {
|
|
310
|
+
pkg.dependencies["@prisma/client"] = "^5.22.0";
|
|
311
|
+
pkg.devDependencies.prisma = "^5.22.0";
|
|
312
|
+
pkg.scripts["db:generate"] = "prisma generate";
|
|
313
|
+
pkg.scripts["db:migrate"] = "prisma migrate dev";
|
|
314
|
+
pkg.scripts["db:push"] = "prisma db push";
|
|
315
|
+
pkg.scripts["db:studio"] = "prisma studio";
|
|
316
|
+
}
|
|
317
|
+
if (options.database === "mongodb") {
|
|
318
|
+
pkg.dependencies.mongoose = "^8.8.4";
|
|
319
|
+
}
|
|
320
|
+
if (options.features.includes("email")) {
|
|
321
|
+
pkg.dependencies.nodemailer = "^6.9.15";
|
|
322
|
+
pkg.dependencies.handlebars = "^4.7.8";
|
|
323
|
+
if (isTS) {
|
|
324
|
+
pkg.devDependencies["@types/nodemailer"] = "^6.4.17";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (options.features.includes("redis")) {
|
|
328
|
+
pkg.dependencies.ioredis = "^5.4.1";
|
|
329
|
+
}
|
|
330
|
+
return pkg;
|
|
331
|
+
}
|
|
332
|
+
function generateTsConfig() {
|
|
333
|
+
return JSON.stringify({
|
|
334
|
+
compilerOptions: {
|
|
335
|
+
target: "ES2022",
|
|
336
|
+
module: "NodeNext",
|
|
337
|
+
moduleResolution: "NodeNext",
|
|
338
|
+
lib: ["ES2022"],
|
|
339
|
+
outDir: "./dist",
|
|
340
|
+
rootDir: "./src",
|
|
341
|
+
strict: true,
|
|
342
|
+
esModuleInterop: true,
|
|
343
|
+
skipLibCheck: true,
|
|
344
|
+
forceConsistentCasingInFileNames: true,
|
|
345
|
+
resolveJsonModule: true,
|
|
346
|
+
declaration: true,
|
|
347
|
+
sourceMap: true
|
|
348
|
+
},
|
|
349
|
+
include: ["src/**/*"],
|
|
350
|
+
exclude: ["node_modules", "dist"]
|
|
351
|
+
}, null, 2);
|
|
352
|
+
}
|
|
353
|
+
function generateJsConfig() {
|
|
354
|
+
return JSON.stringify({
|
|
355
|
+
compilerOptions: {
|
|
356
|
+
module: "NodeNext",
|
|
357
|
+
moduleResolution: "NodeNext",
|
|
358
|
+
target: "ES2022",
|
|
359
|
+
checkJs: true
|
|
360
|
+
},
|
|
361
|
+
include: ["src/**/*"],
|
|
362
|
+
exclude: ["node_modules"]
|
|
363
|
+
}, null, 2);
|
|
364
|
+
}
|
|
365
|
+
function generateTsupConfig() {
|
|
366
|
+
return `import { defineConfig } from 'tsup';
|
|
367
|
+
|
|
368
|
+
export default defineConfig({
|
|
369
|
+
entry: ['src/index.ts'],
|
|
370
|
+
format: ['esm'],
|
|
371
|
+
dts: true,
|
|
372
|
+
clean: true,
|
|
373
|
+
sourcemap: true,
|
|
374
|
+
target: 'node18',
|
|
375
|
+
});
|
|
376
|
+
`;
|
|
377
|
+
}
|
|
378
|
+
function generateEnvExample(options) {
|
|
379
|
+
let env2 = `# Server
|
|
380
|
+
NODE_ENV=development
|
|
381
|
+
PORT=3000
|
|
382
|
+
HOST=0.0.0.0
|
|
383
|
+
|
|
384
|
+
# JWT
|
|
385
|
+
JWT_SECRET=your-super-secret-key-min-32-characters
|
|
386
|
+
JWT_ACCESS_EXPIRES_IN=15m
|
|
387
|
+
JWT_REFRESH_EXPIRES_IN=7d
|
|
388
|
+
|
|
389
|
+
# Security
|
|
390
|
+
CORS_ORIGIN=http://localhost:3000
|
|
391
|
+
RATE_LIMIT_MAX=100
|
|
392
|
+
|
|
393
|
+
# Logging
|
|
394
|
+
LOG_LEVEL=info
|
|
395
|
+
`;
|
|
396
|
+
if (options.database === "postgresql") {
|
|
397
|
+
env2 += `
|
|
398
|
+
# Database (PostgreSQL)
|
|
399
|
+
DATABASE_PROVIDER=postgresql
|
|
400
|
+
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
|
|
401
|
+
`;
|
|
402
|
+
} else if (options.database === "mysql") {
|
|
403
|
+
env2 += `
|
|
404
|
+
# Database (MySQL)
|
|
405
|
+
DATABASE_PROVIDER=mysql
|
|
406
|
+
DATABASE_URL="mysql://user:password@localhost:3306/mydb"
|
|
407
|
+
`;
|
|
408
|
+
} else if (options.database === "sqlite") {
|
|
409
|
+
env2 += `
|
|
410
|
+
# Database (SQLite)
|
|
411
|
+
DATABASE_PROVIDER=sqlite
|
|
412
|
+
DATABASE_URL="file:./dev.db"
|
|
413
|
+
`;
|
|
414
|
+
} else if (options.database === "mongodb") {
|
|
415
|
+
env2 += `
|
|
416
|
+
# Database (MongoDB)
|
|
417
|
+
MONGODB_URI="mongodb://localhost:27017/mydb"
|
|
418
|
+
`;
|
|
419
|
+
}
|
|
420
|
+
if (options.features.includes("email")) {
|
|
421
|
+
env2 += `
|
|
422
|
+
# Email
|
|
423
|
+
SMTP_HOST=smtp.example.com
|
|
424
|
+
SMTP_PORT=587
|
|
425
|
+
SMTP_USER=
|
|
426
|
+
SMTP_PASS=
|
|
427
|
+
SMTP_FROM="App <noreply@example.com>"
|
|
428
|
+
`;
|
|
429
|
+
}
|
|
430
|
+
if (options.features.includes("redis")) {
|
|
431
|
+
env2 += `
|
|
432
|
+
# Redis
|
|
433
|
+
REDIS_URL=redis://localhost:6379
|
|
434
|
+
`;
|
|
435
|
+
}
|
|
436
|
+
return env2;
|
|
437
|
+
}
|
|
438
|
+
function generateGitignore() {
|
|
439
|
+
return `node_modules/
|
|
440
|
+
dist/
|
|
441
|
+
.env
|
|
442
|
+
.env.local
|
|
443
|
+
*.log
|
|
444
|
+
coverage/
|
|
445
|
+
.DS_Store
|
|
446
|
+
*.db
|
|
447
|
+
`;
|
|
448
|
+
}
|
|
449
|
+
function generateDockerfile(options) {
|
|
450
|
+
const isTS = options.language === "typescript";
|
|
451
|
+
return `FROM node:20-alpine
|
|
452
|
+
WORKDIR /app
|
|
453
|
+
COPY package*.json ./
|
|
454
|
+
RUN npm ci --only=production
|
|
455
|
+
COPY ${isTS ? "dist" : "src"} ./${isTS ? "dist" : "src"}
|
|
456
|
+
${options.database !== "none" && options.database !== "mongodb" ? "COPY prisma ./prisma\nRUN npx prisma generate" : ""}
|
|
457
|
+
EXPOSE 3000
|
|
458
|
+
CMD ["node", "${isTS ? "dist" : "src"}/index.js"]
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
function generateDockerCompose(options) {
|
|
462
|
+
let compose = `version: '3.8'
|
|
463
|
+
|
|
464
|
+
services:
|
|
465
|
+
app:
|
|
466
|
+
build: .
|
|
467
|
+
ports:
|
|
468
|
+
- "\${PORT:-3000}:3000"
|
|
469
|
+
environment:
|
|
470
|
+
- NODE_ENV=development
|
|
471
|
+
`;
|
|
472
|
+
if (options.database === "postgresql") {
|
|
473
|
+
compose += ` - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/mydb
|
|
474
|
+
depends_on:
|
|
475
|
+
- postgres
|
|
476
|
+
|
|
477
|
+
postgres:
|
|
478
|
+
image: postgres:16-alpine
|
|
479
|
+
environment:
|
|
480
|
+
POSTGRES_USER: postgres
|
|
481
|
+
POSTGRES_PASSWORD: postgres
|
|
482
|
+
POSTGRES_DB: mydb
|
|
483
|
+
ports:
|
|
484
|
+
- "5432:5432"
|
|
485
|
+
volumes:
|
|
486
|
+
- postgres-data:/var/lib/postgresql/data
|
|
487
|
+
|
|
488
|
+
volumes:
|
|
489
|
+
postgres-data:
|
|
490
|
+
`;
|
|
491
|
+
} else if (options.database === "mysql") {
|
|
492
|
+
compose += ` - DATABASE_URL=mysql://root:root@mysql:3306/mydb
|
|
493
|
+
depends_on:
|
|
494
|
+
- mysql
|
|
495
|
+
|
|
496
|
+
mysql:
|
|
497
|
+
image: mysql:8.0
|
|
498
|
+
environment:
|
|
499
|
+
MYSQL_ROOT_PASSWORD: root
|
|
500
|
+
MYSQL_DATABASE: mydb
|
|
501
|
+
ports:
|
|
502
|
+
- "3306:3306"
|
|
503
|
+
volumes:
|
|
504
|
+
- mysql-data:/var/lib/mysql
|
|
505
|
+
|
|
506
|
+
volumes:
|
|
507
|
+
mysql-data:
|
|
508
|
+
`;
|
|
509
|
+
}
|
|
510
|
+
if (options.features.includes("redis")) {
|
|
511
|
+
compose += `
|
|
512
|
+
redis:
|
|
513
|
+
image: redis:7-alpine
|
|
514
|
+
ports:
|
|
515
|
+
- "6379:6379"
|
|
516
|
+
`;
|
|
517
|
+
}
|
|
518
|
+
return compose;
|
|
519
|
+
}
|
|
520
|
+
function generatePrismaSchema(options) {
|
|
521
|
+
const provider = options.database === "sqlite" ? "sqlite" : options.database;
|
|
522
|
+
return `generator client {
|
|
523
|
+
provider = "prisma-client-js"
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
datasource db {
|
|
527
|
+
provider = "${provider}"
|
|
528
|
+
url = env("DATABASE_URL")
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
model User {
|
|
532
|
+
id String @id @default(uuid())
|
|
533
|
+
email String @unique
|
|
534
|
+
password String
|
|
535
|
+
name String?
|
|
536
|
+
role String @default("user")
|
|
537
|
+
status String @default("active")
|
|
538
|
+
emailVerified Boolean @default(false)
|
|
539
|
+
createdAt DateTime @default(now())
|
|
540
|
+
updatedAt DateTime @updatedAt
|
|
541
|
+
|
|
542
|
+
@@map("users")
|
|
543
|
+
}
|
|
544
|
+
`;
|
|
545
|
+
}
|
|
546
|
+
function generateEntryFile(options) {
|
|
547
|
+
const isTS = options.language === "typescript";
|
|
548
|
+
return `${isTS ? "import { createServer } from './core/server.js';\nimport { logger } from './core/logger.js';" : "const { createServer } = require('./core/server.js');\nconst { logger } = require('./core/logger.js');"}
|
|
549
|
+
|
|
550
|
+
async function main()${isTS ? ": Promise<void>" : ""} {
|
|
551
|
+
const server = createServer();
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
await server.start();
|
|
555
|
+
} catch (error) {
|
|
556
|
+
logger.error({ err: error }, 'Failed to start server');
|
|
557
|
+
process.exit(1);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
main();
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
function generateServerFile(options) {
|
|
565
|
+
const isTS = options.language === "typescript";
|
|
566
|
+
return `${isTS ? `import Fastify from 'fastify';
|
|
567
|
+
import type { FastifyInstance } from 'fastify';
|
|
568
|
+
import { logger } from './logger.js';` : `const Fastify = require('fastify');
|
|
569
|
+
const { logger } = require('./logger.js');`}
|
|
570
|
+
|
|
571
|
+
${isTS ? "export function createServer(): { instance: FastifyInstance; start: () => Promise<void> }" : "function createServer()"} {
|
|
572
|
+
const app = Fastify({ logger });
|
|
573
|
+
|
|
574
|
+
// Health check
|
|
575
|
+
app.get('/health', async () => ({
|
|
576
|
+
status: 'ok',
|
|
577
|
+
timestamp: new Date().toISOString(),
|
|
578
|
+
}));
|
|
579
|
+
|
|
580
|
+
// Graceful shutdown
|
|
581
|
+
const signals${isTS ? ": NodeJS.Signals[]" : ""} = ['SIGINT', 'SIGTERM'];
|
|
582
|
+
signals.forEach((signal) => {
|
|
583
|
+
process.on(signal, async () => {
|
|
584
|
+
logger.info(\`Received \${signal}, shutting down...\`);
|
|
585
|
+
await app.close();
|
|
586
|
+
process.exit(0);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
instance: app,
|
|
592
|
+
start: async ()${isTS ? ": Promise<void>" : ""} => {
|
|
593
|
+
const port = parseInt(process.env.PORT || '3000', 10);
|
|
594
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
595
|
+
await app.listen({ port, host });
|
|
596
|
+
logger.info(\`Server listening on \${host}:\${port}\`);
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
${isTS ? "" : "module.exports = { createServer };"}
|
|
602
|
+
`;
|
|
603
|
+
}
|
|
604
|
+
function generateLoggerFile(options) {
|
|
605
|
+
const isTS = options.language === "typescript";
|
|
606
|
+
return `${isTS ? "import pino from 'pino';\nimport type { Logger } from 'pino';" : "const pino = require('pino');"}
|
|
607
|
+
|
|
608
|
+
${isTS ? "export const logger: Logger" : "const logger"} = pino({
|
|
609
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
610
|
+
transport: process.env.NODE_ENV !== 'production' ? {
|
|
611
|
+
target: 'pino-pretty',
|
|
612
|
+
options: { colorize: true },
|
|
613
|
+
} : undefined,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
${isTS ? "" : "module.exports = { logger };"}
|
|
617
|
+
`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/cli/commands/generate.ts
|
|
621
|
+
import { Command as Command2 } from "commander";
|
|
622
|
+
import path4 from "path";
|
|
623
|
+
import ora3 from "ora";
|
|
624
|
+
import inquirer2 from "inquirer";
|
|
625
|
+
|
|
626
|
+
// src/cli/utils/field-parser.ts
|
|
627
|
+
var tsTypeMap = {
|
|
628
|
+
string: "string",
|
|
629
|
+
number: "number",
|
|
630
|
+
boolean: "boolean",
|
|
631
|
+
date: "Date",
|
|
632
|
+
datetime: "Date",
|
|
633
|
+
text: "string",
|
|
634
|
+
json: "Record<string, unknown>",
|
|
635
|
+
email: "string",
|
|
636
|
+
url: "string",
|
|
637
|
+
uuid: "string",
|
|
638
|
+
int: "number",
|
|
639
|
+
float: "number",
|
|
640
|
+
decimal: "number",
|
|
641
|
+
enum: "string"
|
|
642
|
+
};
|
|
643
|
+
var prismaTypeMap = {
|
|
644
|
+
string: "String",
|
|
645
|
+
number: "Int",
|
|
646
|
+
boolean: "Boolean",
|
|
647
|
+
date: "DateTime",
|
|
648
|
+
datetime: "DateTime",
|
|
649
|
+
text: "String",
|
|
650
|
+
json: "Json",
|
|
651
|
+
email: "String",
|
|
652
|
+
url: "String",
|
|
653
|
+
uuid: "String",
|
|
654
|
+
int: "Int",
|
|
655
|
+
float: "Float",
|
|
656
|
+
decimal: "Decimal",
|
|
657
|
+
enum: "String"
|
|
658
|
+
};
|
|
659
|
+
var zodTypeMap = {
|
|
660
|
+
string: "z.string()",
|
|
661
|
+
number: "z.number()",
|
|
662
|
+
boolean: "z.boolean()",
|
|
663
|
+
date: "z.coerce.date()",
|
|
664
|
+
datetime: "z.coerce.date()",
|
|
665
|
+
text: "z.string()",
|
|
666
|
+
json: "z.record(z.unknown())",
|
|
667
|
+
email: "z.string().email()",
|
|
668
|
+
url: "z.string().url()",
|
|
669
|
+
uuid: "z.string().uuid()",
|
|
670
|
+
int: "z.number().int()",
|
|
671
|
+
float: "z.number()",
|
|
672
|
+
decimal: "z.number()",
|
|
673
|
+
enum: "z.string()"
|
|
674
|
+
};
|
|
675
|
+
var joiTypeMap = {
|
|
676
|
+
string: "Joi.string()",
|
|
677
|
+
number: "Joi.number()",
|
|
678
|
+
boolean: "Joi.boolean()",
|
|
679
|
+
date: "Joi.date()",
|
|
680
|
+
datetime: "Joi.date()",
|
|
681
|
+
text: "Joi.string()",
|
|
682
|
+
json: "Joi.object()",
|
|
683
|
+
email: "Joi.string().email()",
|
|
684
|
+
url: "Joi.string().uri()",
|
|
685
|
+
uuid: "Joi.string().uuid()",
|
|
686
|
+
int: "Joi.number().integer()",
|
|
687
|
+
float: "Joi.number()",
|
|
688
|
+
decimal: "Joi.number()",
|
|
689
|
+
enum: "Joi.string()"
|
|
690
|
+
};
|
|
691
|
+
var yupTypeMap = {
|
|
692
|
+
string: "yup.string()",
|
|
693
|
+
number: "yup.number()",
|
|
694
|
+
boolean: "yup.boolean()",
|
|
695
|
+
date: "yup.date()",
|
|
696
|
+
datetime: "yup.date()",
|
|
697
|
+
text: "yup.string()",
|
|
698
|
+
json: "yup.object()",
|
|
699
|
+
email: "yup.string().email()",
|
|
700
|
+
url: "yup.string().url()",
|
|
701
|
+
uuid: "yup.string().uuid()",
|
|
702
|
+
int: "yup.number().integer()",
|
|
703
|
+
float: "yup.number()",
|
|
704
|
+
decimal: "yup.number()",
|
|
705
|
+
enum: "yup.string()"
|
|
706
|
+
};
|
|
707
|
+
function parseField(fieldStr) {
|
|
708
|
+
const parts = fieldStr.split(":");
|
|
709
|
+
let name = parts[0] || "";
|
|
710
|
+
let typeStr = parts[1] || "string";
|
|
711
|
+
const modifiers = parts.slice(2);
|
|
712
|
+
const isOptional = name.endsWith("?") || typeStr.endsWith("?");
|
|
713
|
+
name = name.replace("?", "");
|
|
714
|
+
typeStr = typeStr.replace("?", "");
|
|
715
|
+
const isArray = typeStr.endsWith("[]");
|
|
716
|
+
typeStr = typeStr.replace("[]", "");
|
|
717
|
+
const validTypes = [
|
|
718
|
+
"string",
|
|
719
|
+
"number",
|
|
720
|
+
"boolean",
|
|
721
|
+
"date",
|
|
722
|
+
"datetime",
|
|
723
|
+
"text",
|
|
724
|
+
"json",
|
|
725
|
+
"email",
|
|
726
|
+
"url",
|
|
727
|
+
"uuid",
|
|
728
|
+
"int",
|
|
729
|
+
"float",
|
|
730
|
+
"decimal",
|
|
731
|
+
"enum"
|
|
732
|
+
];
|
|
733
|
+
let type = "string";
|
|
734
|
+
if (validTypes.includes(typeStr)) {
|
|
735
|
+
type = typeStr;
|
|
736
|
+
}
|
|
737
|
+
let isUnique = false;
|
|
738
|
+
let defaultValue;
|
|
739
|
+
let relation;
|
|
740
|
+
for (const mod of modifiers) {
|
|
741
|
+
if (mod === "unique") {
|
|
742
|
+
isUnique = true;
|
|
743
|
+
} else if (mod.startsWith("default=")) {
|
|
744
|
+
defaultValue = mod.replace("default=", "");
|
|
745
|
+
} else if (typeStr === "relation") {
|
|
746
|
+
relation = {
|
|
747
|
+
model: mod,
|
|
748
|
+
type: "many-to-one"
|
|
749
|
+
};
|
|
750
|
+
type = "string";
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
name,
|
|
755
|
+
type,
|
|
756
|
+
isOptional,
|
|
757
|
+
isArray,
|
|
758
|
+
isUnique,
|
|
759
|
+
defaultValue,
|
|
760
|
+
relation
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function parseFields(fieldsStr) {
|
|
764
|
+
if (!fieldsStr) return [];
|
|
765
|
+
return fieldsStr.split(/\s+/).filter(Boolean).map(parseField);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/cli/templates/controller.ts
|
|
769
|
+
function controllerTemplate(name, pascalName, camelName) {
|
|
770
|
+
return `import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
771
|
+
import type { ${pascalName}Service } from './${name}.service.js';
|
|
772
|
+
import { create${pascalName}Schema, update${pascalName}Schema, ${camelName}QuerySchema } from './${name}.schemas.js';
|
|
773
|
+
import { success, created, noContent } from '../../utils/response.js';
|
|
774
|
+
import { parsePaginationParams } from '../../utils/pagination.js';
|
|
775
|
+
import { validateBody, validateQuery } from '../validation/validator.js';
|
|
776
|
+
|
|
777
|
+
export class ${pascalName}Controller {
|
|
778
|
+
constructor(private ${camelName}Service: ${pascalName}Service) {}
|
|
779
|
+
|
|
780
|
+
async list(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
781
|
+
const query = validateQuery(${camelName}QuerySchema, request.query);
|
|
782
|
+
const pagination = parsePaginationParams(query);
|
|
783
|
+
const filters = {
|
|
784
|
+
search: query.search,
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const result = await this.${camelName}Service.findMany(pagination, filters);
|
|
788
|
+
success(reply, result);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async getById(
|
|
792
|
+
request: FastifyRequest<{ Params: { id: string } }>,
|
|
793
|
+
reply: FastifyReply
|
|
794
|
+
): Promise<void> {
|
|
795
|
+
const item = await this.${camelName}Service.findById(request.params.id);
|
|
796
|
+
|
|
797
|
+
if (!item) {
|
|
798
|
+
return reply.status(404).send({
|
|
799
|
+
success: false,
|
|
800
|
+
message: '${pascalName} not found',
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
success(reply, item);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async create(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
808
|
+
const data = validateBody(create${pascalName}Schema, request.body);
|
|
809
|
+
const item = await this.${camelName}Service.create(data);
|
|
810
|
+
created(reply, item);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async update(
|
|
814
|
+
request: FastifyRequest<{ Params: { id: string } }>,
|
|
815
|
+
reply: FastifyReply
|
|
816
|
+
): Promise<void> {
|
|
817
|
+
const data = validateBody(update${pascalName}Schema, request.body);
|
|
818
|
+
const item = await this.${camelName}Service.update(request.params.id, data);
|
|
819
|
+
success(reply, item);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async delete(
|
|
823
|
+
request: FastifyRequest<{ Params: { id: string } }>,
|
|
824
|
+
reply: FastifyReply
|
|
825
|
+
): Promise<void> {
|
|
826
|
+
await this.${camelName}Service.delete(request.params.id);
|
|
827
|
+
noContent(reply);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function create${pascalName}Controller(${camelName}Service: ${pascalName}Service): ${pascalName}Controller {
|
|
832
|
+
return new ${pascalName}Controller(${camelName}Service);
|
|
833
|
+
}
|
|
834
|
+
`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/cli/templates/service.ts
|
|
838
|
+
function serviceTemplate(name, pascalName, camelName) {
|
|
839
|
+
return `import type { PaginatedResult, PaginationParams } from '../../types/index.js';
|
|
840
|
+
import { NotFoundError, ConflictError } from '../../utils/errors.js';
|
|
841
|
+
import { ${pascalName}Repository, create${pascalName}Repository } from './${name}.repository.js';
|
|
842
|
+
import type { ${pascalName}, Create${pascalName}Data, Update${pascalName}Data, ${pascalName}Filters } from './${name}.types.js';
|
|
843
|
+
import { logger } from '../../core/logger.js';
|
|
844
|
+
|
|
845
|
+
export class ${pascalName}Service {
|
|
846
|
+
constructor(private repository: ${pascalName}Repository) {}
|
|
847
|
+
|
|
848
|
+
async findById(id: string): Promise<${pascalName} | null> {
|
|
849
|
+
return this.repository.findById(id);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async findMany(
|
|
853
|
+
params: PaginationParams,
|
|
854
|
+
filters?: ${pascalName}Filters
|
|
855
|
+
): Promise<PaginatedResult<${pascalName}>> {
|
|
856
|
+
return this.repository.findMany(params, filters);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async create(data: Create${pascalName}Data): Promise<${pascalName}> {
|
|
860
|
+
const item = await this.repository.create(data);
|
|
861
|
+
logger.info({ ${camelName}Id: item.id }, '${pascalName} created');
|
|
862
|
+
return item;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async update(id: string, data: Update${pascalName}Data): Promise<${pascalName}> {
|
|
866
|
+
const existing = await this.repository.findById(id);
|
|
867
|
+
if (!existing) {
|
|
868
|
+
throw new NotFoundError('${pascalName}');
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const updated = await this.repository.update(id, data);
|
|
872
|
+
if (!updated) {
|
|
873
|
+
throw new NotFoundError('${pascalName}');
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
logger.info({ ${camelName}Id: id }, '${pascalName} updated');
|
|
877
|
+
return updated;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async delete(id: string): Promise<void> {
|
|
881
|
+
const existing = await this.repository.findById(id);
|
|
882
|
+
if (!existing) {
|
|
883
|
+
throw new NotFoundError('${pascalName}');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
await this.repository.delete(id);
|
|
887
|
+
logger.info({ ${camelName}Id: id }, '${pascalName} deleted');
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function create${pascalName}Service(repository?: ${pascalName}Repository): ${pascalName}Service {
|
|
892
|
+
return new ${pascalName}Service(repository || create${pascalName}Repository());
|
|
893
|
+
}
|
|
894
|
+
`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/cli/templates/repository.ts
|
|
898
|
+
function repositoryTemplate(name, pascalName, camelName, pluralName) {
|
|
899
|
+
return `import { randomUUID } from 'crypto';
|
|
900
|
+
import type { PaginatedResult, PaginationParams } from '../../types/index.js';
|
|
901
|
+
import { createPaginatedResult, getSkip } from '../../utils/pagination.js';
|
|
902
|
+
import type { ${pascalName}, Create${pascalName}Data, Update${pascalName}Data, ${pascalName}Filters } from './${name}.types.js';
|
|
903
|
+
|
|
904
|
+
// In-memory storage (will be replaced by Prisma in production)
|
|
905
|
+
const ${pluralName} = new Map<string, ${pascalName}>();
|
|
906
|
+
|
|
907
|
+
export class ${pascalName}Repository {
|
|
908
|
+
async findById(id: string): Promise<${pascalName} | null> {
|
|
909
|
+
return ${pluralName}.get(id) || null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async findMany(
|
|
913
|
+
params: PaginationParams,
|
|
914
|
+
filters?: ${pascalName}Filters
|
|
915
|
+
): Promise<PaginatedResult<${pascalName}>> {
|
|
916
|
+
let items = Array.from(${pluralName}.values());
|
|
917
|
+
|
|
918
|
+
// Apply filters
|
|
919
|
+
if (filters?.search) {
|
|
920
|
+
const search = filters.search.toLowerCase();
|
|
921
|
+
items = items.filter((item) =>
|
|
922
|
+
JSON.stringify(item).toLowerCase().includes(search)
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Sort
|
|
927
|
+
if (params.sortBy) {
|
|
928
|
+
const sortKey = params.sortBy as keyof ${pascalName};
|
|
929
|
+
items.sort((a, b) => {
|
|
930
|
+
const aVal = a[sortKey];
|
|
931
|
+
const bVal = b[sortKey];
|
|
932
|
+
if (aVal === undefined || bVal === undefined) return 0;
|
|
933
|
+
if (aVal < bVal) return params.sortOrder === 'desc' ? 1 : -1;
|
|
934
|
+
if (aVal > bVal) return params.sortOrder === 'desc' ? -1 : 1;
|
|
935
|
+
return 0;
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const total = items.length;
|
|
940
|
+
const skip = getSkip(params);
|
|
941
|
+
const data = items.slice(skip, skip + params.limit);
|
|
942
|
+
|
|
943
|
+
return createPaginatedResult(data, total, params);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async create(data: Create${pascalName}Data): Promise<${pascalName}> {
|
|
947
|
+
const now = new Date();
|
|
948
|
+
const item: ${pascalName} = {
|
|
949
|
+
id: randomUUID(),
|
|
950
|
+
...data,
|
|
951
|
+
createdAt: now,
|
|
952
|
+
updatedAt: now,
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
${pluralName}.set(item.id, item);
|
|
956
|
+
return item;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async update(id: string, data: Update${pascalName}Data): Promise<${pascalName} | null> {
|
|
960
|
+
const item = ${pluralName}.get(id);
|
|
961
|
+
if (!item) return null;
|
|
962
|
+
|
|
963
|
+
const updated: ${pascalName} = {
|
|
964
|
+
...item,
|
|
965
|
+
...data,
|
|
966
|
+
updatedAt: new Date(),
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
${pluralName}.set(id, updated);
|
|
970
|
+
return updated;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async delete(id: string): Promise<boolean> {
|
|
974
|
+
return ${pluralName}.delete(id);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async count(filters?: ${pascalName}Filters): Promise<number> {
|
|
978
|
+
if (!filters) return ${pluralName}.size;
|
|
979
|
+
|
|
980
|
+
let count = 0;
|
|
981
|
+
for (const item of ${pluralName}.values()) {
|
|
982
|
+
if (filters.search) {
|
|
983
|
+
const search = filters.search.toLowerCase();
|
|
984
|
+
if (!JSON.stringify(item).toLowerCase().includes(search)) continue;
|
|
985
|
+
}
|
|
986
|
+
count++;
|
|
987
|
+
}
|
|
988
|
+
return count;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Clear all (for testing)
|
|
992
|
+
async clear(): Promise<void> {
|
|
993
|
+
${pluralName}.clear();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export function create${pascalName}Repository(): ${pascalName}Repository {
|
|
998
|
+
return new ${pascalName}Repository();
|
|
999
|
+
}
|
|
1000
|
+
`;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/cli/templates/types.ts
|
|
1004
|
+
function typesTemplate(name, pascalName) {
|
|
1005
|
+
return `import type { BaseEntity } from '../../types/index.js';
|
|
1006
|
+
|
|
1007
|
+
export interface ${pascalName} extends BaseEntity {
|
|
1008
|
+
// Add your ${pascalName} specific fields here
|
|
1009
|
+
name: string;
|
|
1010
|
+
description?: string;
|
|
1011
|
+
// status?: string;
|
|
1012
|
+
// metadata?: Record<string, unknown>;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export interface Create${pascalName}Data {
|
|
1016
|
+
name: string;
|
|
1017
|
+
description?: string;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export interface Update${pascalName}Data {
|
|
1021
|
+
name?: string;
|
|
1022
|
+
description?: string;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export interface ${pascalName}Filters {
|
|
1026
|
+
search?: string;
|
|
1027
|
+
// Add more filters as needed
|
|
1028
|
+
}
|
|
1029
|
+
`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/cli/templates/schemas.ts
|
|
1033
|
+
function schemasTemplate(name, pascalName, camelName) {
|
|
1034
|
+
return `import { z } from 'zod';
|
|
1035
|
+
|
|
1036
|
+
export const create${pascalName}Schema = z.object({
|
|
1037
|
+
name: z.string().min(1, 'Name is required').max(255),
|
|
1038
|
+
description: z.string().max(1000).optional(),
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
export const update${pascalName}Schema = z.object({
|
|
1042
|
+
name: z.string().min(1).max(255).optional(),
|
|
1043
|
+
description: z.string().max(1000).optional(),
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
export const ${camelName}QuerySchema = z.object({
|
|
1047
|
+
page: z.string().transform(Number).optional(),
|
|
1048
|
+
limit: z.string().transform(Number).optional(),
|
|
1049
|
+
sortBy: z.string().optional(),
|
|
1050
|
+
sortOrder: z.enum(['asc', 'desc']).optional(),
|
|
1051
|
+
search: z.string().optional(),
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
export type Create${pascalName}Input = z.infer<typeof create${pascalName}Schema>;
|
|
1055
|
+
export type Update${pascalName}Input = z.infer<typeof update${pascalName}Schema>;
|
|
1056
|
+
export type ${pascalName}QueryInput = z.infer<typeof ${camelName}QuerySchema>;
|
|
1057
|
+
`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/cli/templates/routes.ts
|
|
1061
|
+
function routesTemplate(name, pascalName, camelName, pluralName, fields = []) {
|
|
1062
|
+
const serializedFields = JSON.stringify(fields, null, 2);
|
|
1063
|
+
return `import type { FastifyInstance } from 'fastify';
|
|
1064
|
+
import type { ${pascalName}Controller } from './${name}.controller.js';
|
|
1065
|
+
import type { AuthService } from '../auth/auth.service.js';
|
|
1066
|
+
import { createAuthMiddleware, createRoleMiddleware } from '../auth/auth.middleware.js';
|
|
1067
|
+
import { generateRouteSchema } from '../swagger/schema-builder.js';
|
|
1068
|
+
import type { FieldDefinition } from '../cli/utils/field-parser.js';
|
|
1069
|
+
|
|
1070
|
+
const ${camelName}Fields: FieldDefinition[] = ${serializedFields};
|
|
1071
|
+
const ${camelName}Schemas = {
|
|
1072
|
+
list: generateRouteSchema('${pascalName}', ${camelName}Fields, 'list'),
|
|
1073
|
+
get: generateRouteSchema('${pascalName}', ${camelName}Fields, 'get'),
|
|
1074
|
+
create: generateRouteSchema('${pascalName}', ${camelName}Fields, 'create'),
|
|
1075
|
+
update: generateRouteSchema('${pascalName}', ${camelName}Fields, 'update'),
|
|
1076
|
+
delete: generateRouteSchema('${pascalName}', ${camelName}Fields, 'delete'),
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
export function register${pascalName}Routes(
|
|
1080
|
+
app: FastifyInstance,
|
|
1081
|
+
controller: ${pascalName}Controller,
|
|
1082
|
+
authService: AuthService
|
|
1083
|
+
): void {
|
|
1084
|
+
const authenticate = createAuthMiddleware(authService);
|
|
1085
|
+
const isAdmin = createRoleMiddleware(['admin', 'super_admin']);
|
|
1086
|
+
|
|
1087
|
+
// Public routes (if any)
|
|
1088
|
+
// app.get('/${pluralName}/public', controller.publicList.bind(controller));
|
|
1089
|
+
|
|
1090
|
+
// Protected routes
|
|
1091
|
+
app.get(
|
|
1092
|
+
'/${pluralName}',
|
|
1093
|
+
{ preHandler: [authenticate], ...${camelName}Schemas.list },
|
|
1094
|
+
controller.list.bind(controller)
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
app.get(
|
|
1098
|
+
'/${pluralName}/:id',
|
|
1099
|
+
{ preHandler: [authenticate], ...${camelName}Schemas.get },
|
|
1100
|
+
controller.getById.bind(controller)
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
app.post(
|
|
1104
|
+
'/${pluralName}',
|
|
1105
|
+
{ preHandler: [authenticate], ...${camelName}Schemas.create },
|
|
1106
|
+
controller.create.bind(controller)
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
app.patch(
|
|
1110
|
+
'/${pluralName}/:id',
|
|
1111
|
+
{ preHandler: [authenticate], ...${camelName}Schemas.update },
|
|
1112
|
+
controller.update.bind(controller)
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
app.delete(
|
|
1116
|
+
'/${pluralName}/:id',
|
|
1117
|
+
{ preHandler: [authenticate, isAdmin], ...${camelName}Schemas.delete },
|
|
1118
|
+
controller.delete.bind(controller)
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/cli/templates/module-index.ts
|
|
1125
|
+
function moduleIndexTemplate(name, pascalName, camelName) {
|
|
1126
|
+
return `import type { FastifyInstance } from 'fastify';
|
|
1127
|
+
import { logger } from '../../core/logger.js';
|
|
1128
|
+
import { ${pascalName}Service, create${pascalName}Service } from './${name}.service.js';
|
|
1129
|
+
import { ${pascalName}Controller, create${pascalName}Controller } from './${name}.controller.js';
|
|
1130
|
+
import { ${pascalName}Repository, create${pascalName}Repository } from './${name}.repository.js';
|
|
1131
|
+
import { register${pascalName}Routes } from './${name}.routes.js';
|
|
1132
|
+
import type { AuthService } from '../auth/auth.service.js';
|
|
1133
|
+
|
|
1134
|
+
export async function register${pascalName}Module(
|
|
1135
|
+
app: FastifyInstance,
|
|
1136
|
+
authService: AuthService
|
|
1137
|
+
): Promise<void> {
|
|
1138
|
+
// Create repository and service
|
|
1139
|
+
const repository = create${pascalName}Repository();
|
|
1140
|
+
const ${camelName}Service = create${pascalName}Service(repository);
|
|
1141
|
+
|
|
1142
|
+
// Create controller
|
|
1143
|
+
const ${camelName}Controller = create${pascalName}Controller(${camelName}Service);
|
|
1144
|
+
|
|
1145
|
+
// Register routes
|
|
1146
|
+
register${pascalName}Routes(app, ${camelName}Controller, authService);
|
|
1147
|
+
|
|
1148
|
+
logger.info('${pascalName} module registered');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
export { ${pascalName}Service, create${pascalName}Service } from './${name}.service.js';
|
|
1152
|
+
export { ${pascalName}Controller, create${pascalName}Controller } from './${name}.controller.js';
|
|
1153
|
+
export { ${pascalName}Repository, create${pascalName}Repository } from './${name}.repository.js';
|
|
1154
|
+
export * from './${name}.types.js';
|
|
1155
|
+
export * from './${name}.schemas.js';
|
|
1156
|
+
`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// src/cli/templates/prisma-model.ts
|
|
1160
|
+
function prismaModelTemplate(name, pascalName, tableName) {
|
|
1161
|
+
return `
|
|
1162
|
+
// Add this model to your prisma/schema.prisma file
|
|
1163
|
+
|
|
1164
|
+
model ${pascalName} {
|
|
1165
|
+
id String @id @default(uuid())
|
|
1166
|
+
name String
|
|
1167
|
+
description String?
|
|
1168
|
+
|
|
1169
|
+
createdAt DateTime @default(now())
|
|
1170
|
+
updatedAt DateTime @updatedAt
|
|
1171
|
+
|
|
1172
|
+
@@index([name])
|
|
1173
|
+
@@map("${tableName}")
|
|
1174
|
+
}
|
|
1175
|
+
`;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/cli/templates/dynamic-types.ts
|
|
1179
|
+
function dynamicTypesTemplate(name, pascalName, fields) {
|
|
1180
|
+
const fieldLines = fields.map((field) => {
|
|
1181
|
+
const tsType = tsTypeMap[field.type];
|
|
1182
|
+
const arrayMark = field.isArray ? "[]" : "";
|
|
1183
|
+
const optionalMark = field.isOptional ? "?" : "";
|
|
1184
|
+
return ` ${field.name}${optionalMark}: ${tsType}${arrayMark};`;
|
|
1185
|
+
});
|
|
1186
|
+
const createFieldLines = fields.filter((f) => !f.isOptional).map((field) => {
|
|
1187
|
+
const tsType = tsTypeMap[field.type];
|
|
1188
|
+
const arrayMark = field.isArray ? "[]" : "";
|
|
1189
|
+
return ` ${field.name}: ${tsType}${arrayMark};`;
|
|
1190
|
+
});
|
|
1191
|
+
const createOptionalLines = fields.filter((f) => f.isOptional).map((field) => {
|
|
1192
|
+
const tsType = tsTypeMap[field.type];
|
|
1193
|
+
const arrayMark = field.isArray ? "[]" : "";
|
|
1194
|
+
return ` ${field.name}?: ${tsType}${arrayMark};`;
|
|
1195
|
+
});
|
|
1196
|
+
const updateFieldLines = fields.map((field) => {
|
|
1197
|
+
const tsType = tsTypeMap[field.type];
|
|
1198
|
+
const arrayMark = field.isArray ? "[]" : "";
|
|
1199
|
+
return ` ${field.name}?: ${tsType}${arrayMark};`;
|
|
1200
|
+
});
|
|
1201
|
+
return `import type { BaseEntity } from '../../types/index.js';
|
|
1202
|
+
|
|
1203
|
+
export interface ${pascalName} extends BaseEntity {
|
|
1204
|
+
${fieldLines.join("\n")}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export interface Create${pascalName}Data {
|
|
1208
|
+
${[...createFieldLines, ...createOptionalLines].join("\n")}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
export interface Update${pascalName}Data {
|
|
1212
|
+
${updateFieldLines.join("\n")}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export interface ${pascalName}Filters {
|
|
1216
|
+
search?: string;
|
|
1217
|
+
${fields.filter((f) => ["string", "enum", "boolean"].includes(f.type)).map((f) => ` ${f.name}?: ${tsTypeMap[f.type]};`).join("\n")}
|
|
1218
|
+
}
|
|
1219
|
+
`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/cli/templates/dynamic-schemas.ts
|
|
1223
|
+
function dynamicSchemasTemplate(name, pascalName, camelName, fields, validator = "zod") {
|
|
1224
|
+
switch (validator) {
|
|
1225
|
+
case "joi":
|
|
1226
|
+
return generateJoiSchemas(pascalName, camelName, fields);
|
|
1227
|
+
case "yup":
|
|
1228
|
+
return generateYupSchemas(pascalName, camelName, fields);
|
|
1229
|
+
default:
|
|
1230
|
+
return generateZodSchemas(pascalName, camelName, fields);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
function generateZodSchemas(pascalName, camelName, fields) {
|
|
1234
|
+
const createFields = fields.map((field) => {
|
|
1235
|
+
let validator = zodTypeMap[field.type];
|
|
1236
|
+
if (field.isArray) {
|
|
1237
|
+
validator = `z.array(${validator})`;
|
|
1238
|
+
}
|
|
1239
|
+
if (field.isOptional) {
|
|
1240
|
+
validator += ".optional()";
|
|
1241
|
+
}
|
|
1242
|
+
if (field.defaultValue) {
|
|
1243
|
+
validator += `.default(${field.defaultValue})`;
|
|
1244
|
+
}
|
|
1245
|
+
if (field.type === "string" && !field.isOptional) {
|
|
1246
|
+
validator = validator.replace("z.string()", "z.string().min(1)");
|
|
1247
|
+
}
|
|
1248
|
+
return ` ${field.name}: ${validator},`;
|
|
1249
|
+
});
|
|
1250
|
+
const updateFields = fields.map((field) => {
|
|
1251
|
+
let validator = zodTypeMap[field.type];
|
|
1252
|
+
if (field.isArray) {
|
|
1253
|
+
validator = `z.array(${validator})`;
|
|
1254
|
+
}
|
|
1255
|
+
validator += ".optional()";
|
|
1256
|
+
return ` ${field.name}: ${validator},`;
|
|
1257
|
+
});
|
|
1258
|
+
return `import { z } from 'zod';
|
|
1259
|
+
|
|
1260
|
+
export const create${pascalName}Schema = z.object({
|
|
1261
|
+
${createFields.join("\n")}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
export const update${pascalName}Schema = z.object({
|
|
1265
|
+
${updateFields.join("\n")}
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
export const ${camelName}QuerySchema = z.object({
|
|
1269
|
+
page: z.string().transform(Number).optional(),
|
|
1270
|
+
limit: z.string().transform(Number).optional(),
|
|
1271
|
+
sortBy: z.string().optional(),
|
|
1272
|
+
sortOrder: z.enum(['asc', 'desc']).optional(),
|
|
1273
|
+
search: z.string().optional(),
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
export type Create${pascalName}Input = z.infer<typeof create${pascalName}Schema>;
|
|
1277
|
+
export type Update${pascalName}Input = z.infer<typeof update${pascalName}Schema>;
|
|
1278
|
+
export type ${pascalName}QueryInput = z.infer<typeof ${camelName}QuerySchema>;
|
|
1279
|
+
`;
|
|
1280
|
+
}
|
|
1281
|
+
function generateJoiSchemas(pascalName, camelName, fields) {
|
|
1282
|
+
const createFields = fields.map((field) => {
|
|
1283
|
+
let validator = joiTypeMap[field.type];
|
|
1284
|
+
if (field.isArray) {
|
|
1285
|
+
validator = `Joi.array().items(${validator})`;
|
|
1286
|
+
}
|
|
1287
|
+
if (!field.isOptional) {
|
|
1288
|
+
validator += ".required()";
|
|
1289
|
+
}
|
|
1290
|
+
if (field.defaultValue) {
|
|
1291
|
+
validator += `.default(${field.defaultValue})`;
|
|
1292
|
+
}
|
|
1293
|
+
return ` ${field.name}: ${validator},`;
|
|
1294
|
+
});
|
|
1295
|
+
const updateFields = fields.map((field) => {
|
|
1296
|
+
let validator = joiTypeMap[field.type];
|
|
1297
|
+
if (field.isArray) {
|
|
1298
|
+
validator = `Joi.array().items(${validator})`;
|
|
1299
|
+
}
|
|
1300
|
+
return ` ${field.name}: ${validator},`;
|
|
1301
|
+
});
|
|
1302
|
+
return `import Joi from 'joi';
|
|
1303
|
+
|
|
1304
|
+
export const create${pascalName}Schema = Joi.object({
|
|
1305
|
+
${createFields.join("\n")}
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
export const update${pascalName}Schema = Joi.object({
|
|
1309
|
+
${updateFields.join("\n")}
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
export const ${camelName}QuerySchema = Joi.object({
|
|
1313
|
+
page: Joi.number().integer().min(1),
|
|
1314
|
+
limit: Joi.number().integer().min(1).max(100),
|
|
1315
|
+
sortBy: Joi.string(),
|
|
1316
|
+
sortOrder: Joi.string().valid('asc', 'desc'),
|
|
1317
|
+
search: Joi.string(),
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
export type Create${pascalName}Input = {
|
|
1321
|
+
${fields.map((f) => ` ${f.name}${f.isOptional ? "?" : ""}: ${getJsType(f)};`).join("\n")}
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
export type Update${pascalName}Input = Partial<Create${pascalName}Input>;
|
|
1325
|
+
export type ${pascalName}QueryInput = {
|
|
1326
|
+
page?: number;
|
|
1327
|
+
limit?: number;
|
|
1328
|
+
sortBy?: string;
|
|
1329
|
+
sortOrder?: 'asc' | 'desc';
|
|
1330
|
+
search?: string;
|
|
1331
|
+
};
|
|
1332
|
+
`;
|
|
1333
|
+
}
|
|
1334
|
+
function generateYupSchemas(pascalName, camelName, fields) {
|
|
1335
|
+
const createFields = fields.map((field) => {
|
|
1336
|
+
let validator = yupTypeMap[field.type];
|
|
1337
|
+
if (field.isArray) {
|
|
1338
|
+
validator = `yup.array().of(${validator})`;
|
|
1339
|
+
}
|
|
1340
|
+
if (!field.isOptional) {
|
|
1341
|
+
validator += ".required()";
|
|
1342
|
+
}
|
|
1343
|
+
if (field.defaultValue) {
|
|
1344
|
+
validator += `.default(${field.defaultValue})`;
|
|
1345
|
+
}
|
|
1346
|
+
return ` ${field.name}: ${validator},`;
|
|
1347
|
+
});
|
|
1348
|
+
const updateFields = fields.map((field) => {
|
|
1349
|
+
let validator = yupTypeMap[field.type];
|
|
1350
|
+
if (field.isArray) {
|
|
1351
|
+
validator = `yup.array().of(${validator})`;
|
|
1352
|
+
}
|
|
1353
|
+
validator += ".optional()";
|
|
1354
|
+
return ` ${field.name}: ${validator},`;
|
|
1355
|
+
});
|
|
1356
|
+
return `import * as yup from 'yup';
|
|
1357
|
+
|
|
1358
|
+
export const create${pascalName}Schema = yup.object({
|
|
1359
|
+
${createFields.join("\n")}
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
export const update${pascalName}Schema = yup.object({
|
|
1363
|
+
${updateFields.join("\n")}
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
export const ${camelName}QuerySchema = yup.object({
|
|
1367
|
+
page: yup.number().integer().min(1),
|
|
1368
|
+
limit: yup.number().integer().min(1).max(100),
|
|
1369
|
+
sortBy: yup.string(),
|
|
1370
|
+
sortOrder: yup.string().oneOf(['asc', 'desc']),
|
|
1371
|
+
search: yup.string(),
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
export type Create${pascalName}Input = yup.InferType<typeof create${pascalName}Schema>;
|
|
1375
|
+
export type Update${pascalName}Input = yup.InferType<typeof update${pascalName}Schema>;
|
|
1376
|
+
export type ${pascalName}QueryInput = yup.InferType<typeof ${camelName}QuerySchema>;
|
|
1377
|
+
`;
|
|
1378
|
+
}
|
|
1379
|
+
function getJsType(field) {
|
|
1380
|
+
const typeMap = {
|
|
1381
|
+
string: "string",
|
|
1382
|
+
number: "number",
|
|
1383
|
+
boolean: "boolean",
|
|
1384
|
+
date: "Date",
|
|
1385
|
+
datetime: "Date",
|
|
1386
|
+
text: "string",
|
|
1387
|
+
json: "Record<string, unknown>",
|
|
1388
|
+
email: "string",
|
|
1389
|
+
url: "string",
|
|
1390
|
+
uuid: "string",
|
|
1391
|
+
int: "number",
|
|
1392
|
+
float: "number",
|
|
1393
|
+
decimal: "number",
|
|
1394
|
+
enum: "string"
|
|
1395
|
+
};
|
|
1396
|
+
const baseType = typeMap[field.type] || "unknown";
|
|
1397
|
+
return field.isArray ? `${baseType}[]` : baseType;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// src/cli/templates/dynamic-prisma.ts
|
|
1401
|
+
function dynamicPrismaTemplate(modelName, tableName, fields) {
|
|
1402
|
+
const fieldLines = [];
|
|
1403
|
+
for (const field of fields) {
|
|
1404
|
+
const prismaType = prismaTypeMap[field.type];
|
|
1405
|
+
const optionalMark = field.isOptional ? "?" : "";
|
|
1406
|
+
const arrayMark = field.isArray ? "[]" : "";
|
|
1407
|
+
const annotations = [];
|
|
1408
|
+
if (field.isUnique) {
|
|
1409
|
+
annotations.push("@unique");
|
|
1410
|
+
}
|
|
1411
|
+
if (field.defaultValue !== void 0) {
|
|
1412
|
+
if (field.type === "boolean") {
|
|
1413
|
+
annotations.push(`@default(${field.defaultValue})`);
|
|
1414
|
+
} else if (field.type === "number" || field.type === "int" || field.type === "float") {
|
|
1415
|
+
annotations.push(`@default(${field.defaultValue})`);
|
|
1416
|
+
} else {
|
|
1417
|
+
annotations.push(`@default("${field.defaultValue}")`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (field.type === "text") {
|
|
1421
|
+
annotations.push("@db.Text");
|
|
1422
|
+
}
|
|
1423
|
+
if (field.type === "decimal") {
|
|
1424
|
+
annotations.push("@db.Decimal(10, 2)");
|
|
1425
|
+
}
|
|
1426
|
+
const annotationStr = annotations.length > 0 ? " " + annotations.join(" ") : "";
|
|
1427
|
+
const typePart = `${prismaType}${optionalMark}${arrayMark}`;
|
|
1428
|
+
fieldLines.push(` ${field.name.padEnd(15)} ${typePart.padEnd(12)}${annotationStr}`);
|
|
1429
|
+
}
|
|
1430
|
+
const indexLines = [];
|
|
1431
|
+
const uniqueFields = fields.filter((f) => f.isUnique);
|
|
1432
|
+
for (const field of uniqueFields) {
|
|
1433
|
+
indexLines.push(` @@index([${field.name}])`);
|
|
1434
|
+
}
|
|
1435
|
+
const searchableFields = fields.filter(
|
|
1436
|
+
(f) => ["string", "email"].includes(f.type) && !f.isUnique
|
|
1437
|
+
);
|
|
1438
|
+
if (searchableFields.length > 0) {
|
|
1439
|
+
const firstSearchable = searchableFields[0];
|
|
1440
|
+
if (firstSearchable) {
|
|
1441
|
+
indexLines.push(` @@index([${firstSearchable.name}])`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return `
|
|
1445
|
+
// ==========================================
|
|
1446
|
+
// Add this model to your prisma/schema.prisma file
|
|
1447
|
+
// ==========================================
|
|
1448
|
+
|
|
1449
|
+
model ${modelName} {
|
|
1450
|
+
id String @id @default(uuid())
|
|
1451
|
+
|
|
1452
|
+
${fieldLines.join("\n")}
|
|
1453
|
+
|
|
1454
|
+
createdAt DateTime @default(now())
|
|
1455
|
+
updatedAt DateTime @updatedAt
|
|
1456
|
+
|
|
1457
|
+
${indexLines.join("\n")}
|
|
1458
|
+
@@map("${tableName}")
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ==========================================
|
|
1462
|
+
// After adding the model, run:
|
|
1463
|
+
// npm run db:migrate -- --name add_${tableName}
|
|
1464
|
+
// ==========================================
|
|
1465
|
+
`;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// src/cli/utils/docs-generator.ts
|
|
1469
|
+
import fs3 from "fs/promises";
|
|
1470
|
+
import path3 from "path";
|
|
1471
|
+
import ora2 from "ora";
|
|
1472
|
+
|
|
1473
|
+
// src/core/server.ts
|
|
1474
|
+
import Fastify from "fastify";
|
|
1475
|
+
|
|
1476
|
+
// src/core/logger.ts
|
|
1477
|
+
import pino from "pino";
|
|
1478
|
+
var defaultConfig = {
|
|
1479
|
+
level: process.env.LOG_LEVEL || "info",
|
|
1480
|
+
pretty: process.env.NODE_ENV !== "production",
|
|
1481
|
+
name: "servcraft"
|
|
1482
|
+
};
|
|
1483
|
+
function createLogger(config2 = {}) {
|
|
1484
|
+
const mergedConfig = { ...defaultConfig, ...config2 };
|
|
1485
|
+
const transport = mergedConfig.pretty ? {
|
|
1486
|
+
target: "pino-pretty",
|
|
1487
|
+
options: {
|
|
1488
|
+
colorize: true,
|
|
1489
|
+
translateTime: "SYS:standard",
|
|
1490
|
+
ignore: "pid,hostname"
|
|
1491
|
+
}
|
|
1492
|
+
} : void 0;
|
|
1493
|
+
return pino({
|
|
1494
|
+
name: mergedConfig.name,
|
|
1495
|
+
level: mergedConfig.level,
|
|
1496
|
+
transport,
|
|
1497
|
+
formatters: {
|
|
1498
|
+
level: (label) => ({ level: label })
|
|
1499
|
+
},
|
|
1500
|
+
timestamp: pino.stdTimeFunctions.isoTime
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
var logger = createLogger();
|
|
1504
|
+
|
|
1505
|
+
// src/core/server.ts
|
|
1506
|
+
var defaultConfig2 = {
|
|
1507
|
+
port: parseInt(process.env.PORT || "3000", 10),
|
|
1508
|
+
host: process.env.HOST || "0.0.0.0",
|
|
1509
|
+
trustProxy: true,
|
|
1510
|
+
bodyLimit: 1048576,
|
|
1511
|
+
// 1MB
|
|
1512
|
+
requestTimeout: 3e4
|
|
1513
|
+
// 30s
|
|
1514
|
+
};
|
|
1515
|
+
var Server = class {
|
|
1516
|
+
app;
|
|
1517
|
+
config;
|
|
1518
|
+
logger;
|
|
1519
|
+
isShuttingDown = false;
|
|
1520
|
+
constructor(config2 = {}) {
|
|
1521
|
+
this.config = { ...defaultConfig2, ...config2 };
|
|
1522
|
+
this.logger = this.config.logger || logger;
|
|
1523
|
+
const fastifyOptions = {
|
|
1524
|
+
logger: this.logger,
|
|
1525
|
+
trustProxy: this.config.trustProxy,
|
|
1526
|
+
bodyLimit: this.config.bodyLimit,
|
|
1527
|
+
requestTimeout: this.config.requestTimeout
|
|
1528
|
+
};
|
|
1529
|
+
this.app = Fastify(fastifyOptions);
|
|
1530
|
+
this.setupHealthCheck();
|
|
1531
|
+
this.setupGracefulShutdown();
|
|
1532
|
+
}
|
|
1533
|
+
get instance() {
|
|
1534
|
+
return this.app;
|
|
1535
|
+
}
|
|
1536
|
+
setupHealthCheck() {
|
|
1537
|
+
this.app.get("/health", async (_request, reply) => {
|
|
1538
|
+
const healthcheck = {
|
|
1539
|
+
status: "ok",
|
|
1540
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1541
|
+
uptime: process.uptime(),
|
|
1542
|
+
memory: process.memoryUsage(),
|
|
1543
|
+
version: process.env.npm_package_version || "0.1.0"
|
|
1544
|
+
};
|
|
1545
|
+
return reply.status(200).send(healthcheck);
|
|
1546
|
+
});
|
|
1547
|
+
this.app.get("/ready", async (_request, reply) => {
|
|
1548
|
+
if (this.isShuttingDown) {
|
|
1549
|
+
return reply.status(503).send({ status: "shutting_down" });
|
|
1550
|
+
}
|
|
1551
|
+
return reply.status(200).send({ status: "ready" });
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
setupGracefulShutdown() {
|
|
1555
|
+
const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
|
|
1556
|
+
signals.forEach((signal) => {
|
|
1557
|
+
process.on(signal, async () => {
|
|
1558
|
+
this.logger.info(`Received ${signal}, starting graceful shutdown...`);
|
|
1559
|
+
await this.shutdown();
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
process.on("uncaughtException", async (error2) => {
|
|
1563
|
+
this.logger.error({ err: error2 }, "Uncaught exception");
|
|
1564
|
+
await this.shutdown(1);
|
|
1565
|
+
});
|
|
1566
|
+
process.on("unhandledRejection", async (reason) => {
|
|
1567
|
+
this.logger.error({ err: reason }, "Unhandled rejection");
|
|
1568
|
+
await this.shutdown(1);
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
async shutdown(exitCode = 0) {
|
|
1572
|
+
if (this.isShuttingDown) {
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
this.isShuttingDown = true;
|
|
1576
|
+
this.logger.info("Graceful shutdown initiated...");
|
|
1577
|
+
const shutdownTimeout = setTimeout(() => {
|
|
1578
|
+
this.logger.error("Graceful shutdown timeout, forcing exit");
|
|
1579
|
+
process.exit(1);
|
|
1580
|
+
}, 3e4);
|
|
1581
|
+
try {
|
|
1582
|
+
await this.app.close();
|
|
1583
|
+
this.logger.info("Server closed successfully");
|
|
1584
|
+
clearTimeout(shutdownTimeout);
|
|
1585
|
+
process.exit(exitCode);
|
|
1586
|
+
} catch (error2) {
|
|
1587
|
+
this.logger.error({ err: error2 }, "Error during shutdown");
|
|
1588
|
+
clearTimeout(shutdownTimeout);
|
|
1589
|
+
process.exit(1);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
async start() {
|
|
1593
|
+
try {
|
|
1594
|
+
await this.app.listen({
|
|
1595
|
+
port: this.config.port,
|
|
1596
|
+
host: this.config.host
|
|
1597
|
+
});
|
|
1598
|
+
this.logger.info(`Server listening on ${this.config.host}:${this.config.port}`);
|
|
1599
|
+
} catch (error2) {
|
|
1600
|
+
this.logger.error({ err: error2 }, "Failed to start server");
|
|
1601
|
+
throw error2;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
function createServer(config2 = {}) {
|
|
1606
|
+
return new Server(config2);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/utils/errors.ts
|
|
1610
|
+
var AppError = class _AppError extends Error {
|
|
1611
|
+
statusCode;
|
|
1612
|
+
isOperational;
|
|
1613
|
+
errors;
|
|
1614
|
+
constructor(message, statusCode = 500, isOperational = true, errors) {
|
|
1615
|
+
super(message);
|
|
1616
|
+
this.statusCode = statusCode;
|
|
1617
|
+
this.isOperational = isOperational;
|
|
1618
|
+
this.errors = errors;
|
|
1619
|
+
Object.setPrototypeOf(this, _AppError.prototype);
|
|
1620
|
+
Error.captureStackTrace(this, this.constructor);
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
var NotFoundError = class extends AppError {
|
|
1624
|
+
constructor(resource = "Resource") {
|
|
1625
|
+
super(`${resource} not found`, 404);
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
var UnauthorizedError = class extends AppError {
|
|
1629
|
+
constructor(message = "Unauthorized") {
|
|
1630
|
+
super(message, 401);
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
var ForbiddenError = class extends AppError {
|
|
1634
|
+
constructor(message = "Forbidden") {
|
|
1635
|
+
super(message, 403);
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
var BadRequestError = class extends AppError {
|
|
1639
|
+
constructor(message = "Bad request", errors) {
|
|
1640
|
+
super(message, 400, true, errors);
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
var ConflictError = class extends AppError {
|
|
1644
|
+
constructor(message = "Resource already exists") {
|
|
1645
|
+
super(message, 409);
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
var ValidationError = class extends AppError {
|
|
1649
|
+
constructor(errors) {
|
|
1650
|
+
super("Validation failed", 422, true, errors);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
function isAppError(error2) {
|
|
1654
|
+
return error2 instanceof AppError;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/config/env.ts
|
|
1658
|
+
import { z } from "zod";
|
|
1659
|
+
import dotenv from "dotenv";
|
|
1660
|
+
dotenv.config();
|
|
1661
|
+
var envSchema = z.object({
|
|
1662
|
+
// Server
|
|
1663
|
+
NODE_ENV: z.enum(["development", "staging", "production", "test"]).default("development"),
|
|
1664
|
+
PORT: z.string().transform(Number).default("3000"),
|
|
1665
|
+
HOST: z.string().default("0.0.0.0"),
|
|
1666
|
+
// Database
|
|
1667
|
+
DATABASE_URL: z.string().optional(),
|
|
1668
|
+
// JWT
|
|
1669
|
+
JWT_SECRET: z.string().min(32).optional(),
|
|
1670
|
+
JWT_ACCESS_EXPIRES_IN: z.string().default("15m"),
|
|
1671
|
+
JWT_REFRESH_EXPIRES_IN: z.string().default("7d"),
|
|
1672
|
+
// Security
|
|
1673
|
+
CORS_ORIGIN: z.string().default("*"),
|
|
1674
|
+
RATE_LIMIT_MAX: z.string().transform(Number).default("100"),
|
|
1675
|
+
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default("60000"),
|
|
1676
|
+
// Email
|
|
1677
|
+
SMTP_HOST: z.string().optional(),
|
|
1678
|
+
SMTP_PORT: z.string().transform(Number).optional(),
|
|
1679
|
+
SMTP_USER: z.string().optional(),
|
|
1680
|
+
SMTP_PASS: z.string().optional(),
|
|
1681
|
+
SMTP_FROM: z.string().optional(),
|
|
1682
|
+
// Redis (optional)
|
|
1683
|
+
REDIS_URL: z.string().optional(),
|
|
1684
|
+
// Swagger/OpenAPI
|
|
1685
|
+
SWAGGER_ENABLED: z.union([z.literal("true"), z.literal("false")]).default("true").transform((val) => val === "true"),
|
|
1686
|
+
SWAGGER_ROUTE: z.string().default("/docs"),
|
|
1687
|
+
SWAGGER_TITLE: z.string().default("Servcraft API"),
|
|
1688
|
+
SWAGGER_DESCRIPTION: z.string().default("API documentation"),
|
|
1689
|
+
SWAGGER_VERSION: z.string().default("1.0.0"),
|
|
1690
|
+
// Logging
|
|
1691
|
+
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info")
|
|
1692
|
+
});
|
|
1693
|
+
function validateEnv() {
|
|
1694
|
+
const parsed = envSchema.safeParse(process.env);
|
|
1695
|
+
if (!parsed.success) {
|
|
1696
|
+
logger.error({ errors: parsed.error.flatten().fieldErrors }, "Invalid environment variables");
|
|
1697
|
+
throw new Error("Invalid environment variables");
|
|
1698
|
+
}
|
|
1699
|
+
return parsed.data;
|
|
1700
|
+
}
|
|
1701
|
+
var env = validateEnv();
|
|
1702
|
+
function isProduction() {
|
|
1703
|
+
return env.NODE_ENV === "production";
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// src/config/index.ts
|
|
1707
|
+
function parseCorsOrigin(origin) {
|
|
1708
|
+
if (origin === "*") return "*";
|
|
1709
|
+
if (origin.includes(",")) {
|
|
1710
|
+
return origin.split(",").map((o) => o.trim());
|
|
1711
|
+
}
|
|
1712
|
+
return origin;
|
|
1713
|
+
}
|
|
1714
|
+
function createConfig() {
|
|
1715
|
+
return {
|
|
1716
|
+
env,
|
|
1717
|
+
server: {
|
|
1718
|
+
port: env.PORT,
|
|
1719
|
+
host: env.HOST
|
|
1720
|
+
},
|
|
1721
|
+
jwt: {
|
|
1722
|
+
secret: env.JWT_SECRET || "change-me-in-production-please-32chars",
|
|
1723
|
+
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
|
1724
|
+
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN
|
|
1725
|
+
},
|
|
1726
|
+
security: {
|
|
1727
|
+
corsOrigin: parseCorsOrigin(env.CORS_ORIGIN),
|
|
1728
|
+
rateLimit: {
|
|
1729
|
+
max: env.RATE_LIMIT_MAX,
|
|
1730
|
+
windowMs: env.RATE_LIMIT_WINDOW_MS
|
|
1731
|
+
}
|
|
1732
|
+
},
|
|
1733
|
+
email: {
|
|
1734
|
+
host: env.SMTP_HOST,
|
|
1735
|
+
port: env.SMTP_PORT,
|
|
1736
|
+
user: env.SMTP_USER,
|
|
1737
|
+
pass: env.SMTP_PASS,
|
|
1738
|
+
from: env.SMTP_FROM
|
|
1739
|
+
},
|
|
1740
|
+
database: {
|
|
1741
|
+
url: env.DATABASE_URL
|
|
1742
|
+
},
|
|
1743
|
+
redis: {
|
|
1744
|
+
url: env.REDIS_URL
|
|
1745
|
+
},
|
|
1746
|
+
swagger: {
|
|
1747
|
+
enabled: env.SWAGGER_ENABLED,
|
|
1748
|
+
route: env.SWAGGER_ROUTE,
|
|
1749
|
+
title: env.SWAGGER_TITLE,
|
|
1750
|
+
description: env.SWAGGER_DESCRIPTION,
|
|
1751
|
+
version: env.SWAGGER_VERSION
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
var config = createConfig();
|
|
1756
|
+
|
|
1757
|
+
// src/middleware/error-handler.ts
|
|
1758
|
+
function registerErrorHandler(app) {
|
|
1759
|
+
app.setErrorHandler(
|
|
1760
|
+
(error2, request, reply) => {
|
|
1761
|
+
logger.error(
|
|
1762
|
+
{
|
|
1763
|
+
err: error2,
|
|
1764
|
+
requestId: request.id,
|
|
1765
|
+
method: request.method,
|
|
1766
|
+
url: request.url
|
|
1767
|
+
},
|
|
1768
|
+
"Request error"
|
|
1769
|
+
);
|
|
1770
|
+
if (isAppError(error2)) {
|
|
1771
|
+
return reply.status(error2.statusCode).send({
|
|
1772
|
+
success: false,
|
|
1773
|
+
message: error2.message,
|
|
1774
|
+
errors: error2.errors,
|
|
1775
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
if ("validation" in error2 && error2.validation) {
|
|
1779
|
+
const errors = {};
|
|
1780
|
+
for (const err of error2.validation) {
|
|
1781
|
+
const field = err.instancePath?.replace("/", "") || "body";
|
|
1782
|
+
if (!errors[field]) {
|
|
1783
|
+
errors[field] = [];
|
|
1784
|
+
}
|
|
1785
|
+
errors[field].push(err.message || "Invalid value");
|
|
1786
|
+
}
|
|
1787
|
+
return reply.status(400).send({
|
|
1788
|
+
success: false,
|
|
1789
|
+
message: "Validation failed",
|
|
1790
|
+
errors
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
if ("statusCode" in error2 && typeof error2.statusCode === "number") {
|
|
1794
|
+
return reply.status(error2.statusCode).send({
|
|
1795
|
+
success: false,
|
|
1796
|
+
message: error2.message,
|
|
1797
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
return reply.status(500).send({
|
|
1801
|
+
success: false,
|
|
1802
|
+
message: isProduction() ? "Internal server error" : error2.message,
|
|
1803
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
);
|
|
1807
|
+
app.setNotFoundHandler((request, reply) => {
|
|
1808
|
+
return reply.status(404).send({
|
|
1809
|
+
success: false,
|
|
1810
|
+
message: `Route ${request.method} ${request.url} not found`
|
|
1811
|
+
});
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// src/middleware/security.ts
|
|
1816
|
+
import helmet from "@fastify/helmet";
|
|
1817
|
+
import cors from "@fastify/cors";
|
|
1818
|
+
import rateLimit from "@fastify/rate-limit";
|
|
1819
|
+
var defaultOptions = {
|
|
1820
|
+
helmet: true,
|
|
1821
|
+
cors: true,
|
|
1822
|
+
rateLimit: true
|
|
1823
|
+
};
|
|
1824
|
+
async function registerSecurity(app, options = {}) {
|
|
1825
|
+
const opts = { ...defaultOptions, ...options };
|
|
1826
|
+
if (opts.helmet) {
|
|
1827
|
+
await app.register(helmet, {
|
|
1828
|
+
contentSecurityPolicy: {
|
|
1829
|
+
directives: {
|
|
1830
|
+
defaultSrc: ["'self'"],
|
|
1831
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
1832
|
+
scriptSrc: ["'self'"],
|
|
1833
|
+
imgSrc: ["'self'", "data:", "https:"]
|
|
1834
|
+
}
|
|
1835
|
+
},
|
|
1836
|
+
crossOriginEmbedderPolicy: false
|
|
1837
|
+
});
|
|
1838
|
+
logger.debug("Helmet security headers enabled");
|
|
1839
|
+
}
|
|
1840
|
+
if (opts.cors) {
|
|
1841
|
+
await app.register(cors, {
|
|
1842
|
+
origin: config.security.corsOrigin,
|
|
1843
|
+
credentials: true,
|
|
1844
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
1845
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
1846
|
+
exposedHeaders: ["X-Total-Count", "X-Page", "X-Limit"],
|
|
1847
|
+
maxAge: 86400
|
|
1848
|
+
// 24 hours
|
|
1849
|
+
});
|
|
1850
|
+
logger.debug({ origin: config.security.corsOrigin }, "CORS enabled");
|
|
1851
|
+
}
|
|
1852
|
+
if (opts.rateLimit) {
|
|
1853
|
+
await app.register(rateLimit, {
|
|
1854
|
+
max: config.security.rateLimit.max,
|
|
1855
|
+
timeWindow: config.security.rateLimit.windowMs,
|
|
1856
|
+
errorResponseBuilder: (_request, context) => ({
|
|
1857
|
+
success: false,
|
|
1858
|
+
message: "Too many requests, please try again later",
|
|
1859
|
+
retryAfter: context.after
|
|
1860
|
+
}),
|
|
1861
|
+
keyGenerator: (request) => {
|
|
1862
|
+
return request.headers["x-forwarded-for"]?.toString().split(",")[0] || request.ip || "unknown";
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
logger.debug(
|
|
1866
|
+
{
|
|
1867
|
+
max: config.security.rateLimit.max,
|
|
1868
|
+
windowMs: config.security.rateLimit.windowMs
|
|
1869
|
+
},
|
|
1870
|
+
"Rate limiting enabled"
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// src/modules/swagger/swagger.service.ts
|
|
1876
|
+
import swagger from "@fastify/swagger";
|
|
1877
|
+
import swaggerUi from "@fastify/swagger-ui";
|
|
1878
|
+
var defaultConfig3 = {
|
|
1879
|
+
enabled: true,
|
|
1880
|
+
route: "/docs",
|
|
1881
|
+
title: "Servcraft API",
|
|
1882
|
+
description: "API documentation generated by Servcraft",
|
|
1883
|
+
version: "1.0.0",
|
|
1884
|
+
tags: [
|
|
1885
|
+
{ name: "Auth", description: "Authentication endpoints" },
|
|
1886
|
+
{ name: "Users", description: "User management endpoints" },
|
|
1887
|
+
{ name: "Health", description: "Health check endpoints" }
|
|
1888
|
+
]
|
|
1889
|
+
};
|
|
1890
|
+
async function registerSwagger(app, customConfig) {
|
|
1891
|
+
const swaggerConfig = { ...defaultConfig3, ...customConfig };
|
|
1892
|
+
if (swaggerConfig.enabled === false) {
|
|
1893
|
+
logger.info("Swagger documentation disabled");
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
await app.register(swagger, {
|
|
1897
|
+
openapi: {
|
|
1898
|
+
openapi: "3.0.3",
|
|
1899
|
+
info: {
|
|
1900
|
+
title: swaggerConfig.title,
|
|
1901
|
+
description: swaggerConfig.description,
|
|
1902
|
+
version: swaggerConfig.version,
|
|
1903
|
+
contact: swaggerConfig.contact,
|
|
1904
|
+
license: swaggerConfig.license
|
|
1905
|
+
},
|
|
1906
|
+
servers: swaggerConfig.servers || [
|
|
1907
|
+
{
|
|
1908
|
+
url: `http://localhost:${config.server.port}`,
|
|
1909
|
+
description: "Development server"
|
|
1910
|
+
}
|
|
1911
|
+
],
|
|
1912
|
+
tags: swaggerConfig.tags,
|
|
1913
|
+
components: {
|
|
1914
|
+
securitySchemes: {
|
|
1915
|
+
bearerAuth: {
|
|
1916
|
+
type: "http",
|
|
1917
|
+
scheme: "bearer",
|
|
1918
|
+
bearerFormat: "JWT",
|
|
1919
|
+
description: "Enter your JWT token"
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
await app.register(swaggerUi, {
|
|
1926
|
+
routePrefix: swaggerConfig.route || "/docs",
|
|
1927
|
+
uiConfig: {
|
|
1928
|
+
docExpansion: "list",
|
|
1929
|
+
deepLinking: true,
|
|
1930
|
+
displayRequestDuration: true,
|
|
1931
|
+
filter: true,
|
|
1932
|
+
showExtensions: true,
|
|
1933
|
+
showCommonExtensions: true
|
|
1934
|
+
},
|
|
1935
|
+
staticCSP: true,
|
|
1936
|
+
transformStaticCSP: (header) => header
|
|
1937
|
+
});
|
|
1938
|
+
logger.info("Swagger documentation registered at /docs");
|
|
1939
|
+
}
|
|
1940
|
+
var commonResponses = {
|
|
1941
|
+
success: {
|
|
1942
|
+
type: "object",
|
|
1943
|
+
properties: {
|
|
1944
|
+
success: { type: "boolean", example: true },
|
|
1945
|
+
data: { type: "object" }
|
|
1946
|
+
}
|
|
1947
|
+
},
|
|
1948
|
+
error: {
|
|
1949
|
+
type: "object",
|
|
1950
|
+
properties: {
|
|
1951
|
+
success: { type: "boolean", example: false },
|
|
1952
|
+
message: { type: "string" },
|
|
1953
|
+
errors: {
|
|
1954
|
+
type: "object",
|
|
1955
|
+
additionalProperties: {
|
|
1956
|
+
type: "array",
|
|
1957
|
+
items: { type: "string" }
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
},
|
|
1962
|
+
unauthorized: {
|
|
1963
|
+
type: "object",
|
|
1964
|
+
properties: {
|
|
1965
|
+
success: { type: "boolean", example: false },
|
|
1966
|
+
message: { type: "string", example: "Unauthorized" }
|
|
1967
|
+
}
|
|
1968
|
+
},
|
|
1969
|
+
notFound: {
|
|
1970
|
+
type: "object",
|
|
1971
|
+
properties: {
|
|
1972
|
+
success: { type: "boolean", example: false },
|
|
1973
|
+
message: { type: "string", example: "Resource not found" }
|
|
1974
|
+
}
|
|
1975
|
+
},
|
|
1976
|
+
paginated: {
|
|
1977
|
+
type: "object",
|
|
1978
|
+
properties: {
|
|
1979
|
+
success: { type: "boolean", example: true },
|
|
1980
|
+
data: {
|
|
1981
|
+
type: "object",
|
|
1982
|
+
properties: {
|
|
1983
|
+
data: { type: "array", items: { type: "object" } },
|
|
1984
|
+
meta: {
|
|
1985
|
+
type: "object",
|
|
1986
|
+
properties: {
|
|
1987
|
+
total: { type: "number" },
|
|
1988
|
+
page: { type: "number" },
|
|
1989
|
+
limit: { type: "number" },
|
|
1990
|
+
totalPages: { type: "number" },
|
|
1991
|
+
hasNextPage: { type: "boolean" },
|
|
1992
|
+
hasPrevPage: { type: "boolean" }
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
};
|
|
2000
|
+
var paginationQuery = {
|
|
2001
|
+
type: "object",
|
|
2002
|
+
properties: {
|
|
2003
|
+
page: { type: "integer", minimum: 1, default: 1, description: "Page number" },
|
|
2004
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 20, description: "Items per page" },
|
|
2005
|
+
sortBy: { type: "string", description: "Field to sort by" },
|
|
2006
|
+
sortOrder: { type: "string", enum: ["asc", "desc"], default: "asc", description: "Sort order" },
|
|
2007
|
+
search: { type: "string", description: "Search query" }
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
var idParam = {
|
|
2011
|
+
type: "object",
|
|
2012
|
+
properties: {
|
|
2013
|
+
id: { type: "string", format: "uuid", description: "Resource ID" }
|
|
2014
|
+
},
|
|
2015
|
+
required: ["id"]
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
// src/modules/auth/index.ts
|
|
2019
|
+
import jwt from "@fastify/jwt";
|
|
2020
|
+
import cookie from "@fastify/cookie";
|
|
2021
|
+
|
|
2022
|
+
// src/modules/auth/auth.service.ts
|
|
2023
|
+
import bcrypt from "bcryptjs";
|
|
2024
|
+
var tokenBlacklist = /* @__PURE__ */ new Set();
|
|
2025
|
+
var AuthService = class {
|
|
2026
|
+
app;
|
|
2027
|
+
SALT_ROUNDS = 12;
|
|
2028
|
+
constructor(app) {
|
|
2029
|
+
this.app = app;
|
|
2030
|
+
}
|
|
2031
|
+
async hashPassword(password) {
|
|
2032
|
+
return bcrypt.hash(password, this.SALT_ROUNDS);
|
|
2033
|
+
}
|
|
2034
|
+
async verifyPassword(password, hash) {
|
|
2035
|
+
return bcrypt.compare(password, hash);
|
|
2036
|
+
}
|
|
2037
|
+
generateTokenPair(user) {
|
|
2038
|
+
const accessPayload = {
|
|
2039
|
+
sub: user.id,
|
|
2040
|
+
email: user.email,
|
|
2041
|
+
role: user.role,
|
|
2042
|
+
type: "access"
|
|
2043
|
+
};
|
|
2044
|
+
const refreshPayload = {
|
|
2045
|
+
sub: user.id,
|
|
2046
|
+
email: user.email,
|
|
2047
|
+
role: user.role,
|
|
2048
|
+
type: "refresh"
|
|
2049
|
+
};
|
|
2050
|
+
const accessToken = this.app.jwt.sign(accessPayload, {
|
|
2051
|
+
expiresIn: config.jwt.accessExpiresIn
|
|
2052
|
+
});
|
|
2053
|
+
const refreshToken = this.app.jwt.sign(refreshPayload, {
|
|
2054
|
+
expiresIn: config.jwt.refreshExpiresIn
|
|
2055
|
+
});
|
|
2056
|
+
const expiresIn = this.parseExpiration(config.jwt.accessExpiresIn);
|
|
2057
|
+
return { accessToken, refreshToken, expiresIn };
|
|
2058
|
+
}
|
|
2059
|
+
parseExpiration(expiration) {
|
|
2060
|
+
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
2061
|
+
if (!match) return 900;
|
|
2062
|
+
const value = parseInt(match[1] || "0", 10);
|
|
2063
|
+
const unit = match[2];
|
|
2064
|
+
switch (unit) {
|
|
2065
|
+
case "s":
|
|
2066
|
+
return value;
|
|
2067
|
+
case "m":
|
|
2068
|
+
return value * 60;
|
|
2069
|
+
case "h":
|
|
2070
|
+
return value * 3600;
|
|
2071
|
+
case "d":
|
|
2072
|
+
return value * 86400;
|
|
2073
|
+
default:
|
|
2074
|
+
return 900;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
async verifyAccessToken(token) {
|
|
2078
|
+
try {
|
|
2079
|
+
if (this.isTokenBlacklisted(token)) {
|
|
2080
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
2081
|
+
}
|
|
2082
|
+
const payload = this.app.jwt.verify(token);
|
|
2083
|
+
if (payload.type !== "access") {
|
|
2084
|
+
throw new UnauthorizedError("Invalid token type");
|
|
2085
|
+
}
|
|
2086
|
+
return payload;
|
|
2087
|
+
} catch (error2) {
|
|
2088
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
2089
|
+
logger.debug({ err: error2 }, "Token verification failed");
|
|
2090
|
+
throw new UnauthorizedError("Invalid or expired token");
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
async verifyRefreshToken(token) {
|
|
2094
|
+
try {
|
|
2095
|
+
if (this.isTokenBlacklisted(token)) {
|
|
2096
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
2097
|
+
}
|
|
2098
|
+
const payload = this.app.jwt.verify(token);
|
|
2099
|
+
if (payload.type !== "refresh") {
|
|
2100
|
+
throw new UnauthorizedError("Invalid token type");
|
|
2101
|
+
}
|
|
2102
|
+
return payload;
|
|
2103
|
+
} catch (error2) {
|
|
2104
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
2105
|
+
logger.debug({ err: error2 }, "Refresh token verification failed");
|
|
2106
|
+
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
blacklistToken(token) {
|
|
2110
|
+
tokenBlacklist.add(token);
|
|
2111
|
+
logger.debug("Token blacklisted");
|
|
2112
|
+
}
|
|
2113
|
+
isTokenBlacklisted(token) {
|
|
2114
|
+
return tokenBlacklist.has(token);
|
|
2115
|
+
}
|
|
2116
|
+
// Clear expired tokens from blacklist periodically
|
|
2117
|
+
cleanupBlacklist() {
|
|
2118
|
+
tokenBlacklist.clear();
|
|
2119
|
+
logger.debug("Token blacklist cleared");
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
function createAuthService(app) {
|
|
2123
|
+
return new AuthService(app);
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// src/modules/auth/schemas.ts
|
|
2127
|
+
import { z as z2 } from "zod";
|
|
2128
|
+
var loginSchema = z2.object({
|
|
2129
|
+
email: z2.string().email("Invalid email address"),
|
|
2130
|
+
password: z2.string().min(1, "Password is required")
|
|
2131
|
+
});
|
|
2132
|
+
var registerSchema = z2.object({
|
|
2133
|
+
email: z2.string().email("Invalid email address"),
|
|
2134
|
+
password: z2.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"),
|
|
2135
|
+
name: z2.string().min(2, "Name must be at least 2 characters").optional()
|
|
2136
|
+
});
|
|
2137
|
+
var refreshTokenSchema = z2.object({
|
|
2138
|
+
refreshToken: z2.string().min(1, "Refresh token is required")
|
|
2139
|
+
});
|
|
2140
|
+
var passwordResetRequestSchema = z2.object({
|
|
2141
|
+
email: z2.string().email("Invalid email address")
|
|
2142
|
+
});
|
|
2143
|
+
var passwordResetConfirmSchema = z2.object({
|
|
2144
|
+
token: z2.string().min(1, "Token is required"),
|
|
2145
|
+
password: z2.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")
|
|
2146
|
+
});
|
|
2147
|
+
var changePasswordSchema = z2.object({
|
|
2148
|
+
currentPassword: z2.string().min(1, "Current password is required"),
|
|
2149
|
+
newPassword: z2.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")
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
// src/utils/response.ts
|
|
2153
|
+
function success3(reply, data, statusCode = 200) {
|
|
2154
|
+
const response = {
|
|
2155
|
+
success: true,
|
|
2156
|
+
data
|
|
2157
|
+
};
|
|
2158
|
+
return reply.status(statusCode).send(response);
|
|
2159
|
+
}
|
|
2160
|
+
function created(reply, data) {
|
|
2161
|
+
return success3(reply, data, 201);
|
|
2162
|
+
}
|
|
2163
|
+
function noContent(reply) {
|
|
2164
|
+
return reply.status(204).send();
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// src/modules/validation/validator.ts
|
|
2168
|
+
import { z as z3 } from "zod";
|
|
2169
|
+
function validateBody(schema, data) {
|
|
2170
|
+
const result = schema.safeParse(data);
|
|
2171
|
+
if (!result.success) {
|
|
2172
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
2173
|
+
}
|
|
2174
|
+
return result.data;
|
|
2175
|
+
}
|
|
2176
|
+
function validateQuery(schema, data) {
|
|
2177
|
+
const result = schema.safeParse(data);
|
|
2178
|
+
if (!result.success) {
|
|
2179
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
2180
|
+
}
|
|
2181
|
+
return result.data;
|
|
2182
|
+
}
|
|
2183
|
+
function formatZodErrors(error2) {
|
|
2184
|
+
const errors = {};
|
|
2185
|
+
for (const issue of error2.issues) {
|
|
2186
|
+
const path6 = issue.path.join(".") || "root";
|
|
2187
|
+
if (!errors[path6]) {
|
|
2188
|
+
errors[path6] = [];
|
|
2189
|
+
}
|
|
2190
|
+
errors[path6].push(issue.message);
|
|
2191
|
+
}
|
|
2192
|
+
return errors;
|
|
2193
|
+
}
|
|
2194
|
+
var idParamSchema = z3.object({
|
|
2195
|
+
id: z3.string().uuid("Invalid ID format")
|
|
2196
|
+
});
|
|
2197
|
+
var paginationSchema = z3.object({
|
|
2198
|
+
page: z3.string().transform(Number).optional().default("1"),
|
|
2199
|
+
limit: z3.string().transform(Number).optional().default("20"),
|
|
2200
|
+
sortBy: z3.string().optional(),
|
|
2201
|
+
sortOrder: z3.enum(["asc", "desc"]).optional().default("asc")
|
|
2202
|
+
});
|
|
2203
|
+
var searchSchema = z3.object({
|
|
2204
|
+
q: z3.string().min(1, "Search query is required").optional(),
|
|
2205
|
+
search: z3.string().min(1).optional()
|
|
2206
|
+
});
|
|
2207
|
+
var emailSchema = z3.string().email("Invalid email address");
|
|
2208
|
+
var passwordSchema = z3.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");
|
|
2209
|
+
var urlSchema = z3.string().url("Invalid URL format");
|
|
2210
|
+
var phoneSchema = z3.string().regex(
|
|
2211
|
+
/^\+?[1-9]\d{1,14}$/,
|
|
2212
|
+
"Invalid phone number format"
|
|
2213
|
+
);
|
|
2214
|
+
var dateSchema = z3.coerce.date();
|
|
2215
|
+
var futureDateSchema = z3.coerce.date().refine(
|
|
2216
|
+
(date) => date > /* @__PURE__ */ new Date(),
|
|
2217
|
+
"Date must be in the future"
|
|
2218
|
+
);
|
|
2219
|
+
var pastDateSchema = z3.coerce.date().refine(
|
|
2220
|
+
(date) => date < /* @__PURE__ */ new Date(),
|
|
2221
|
+
"Date must be in the past"
|
|
2222
|
+
);
|
|
2223
|
+
|
|
2224
|
+
// src/modules/auth/auth.controller.ts
|
|
2225
|
+
var AuthController = class {
|
|
2226
|
+
constructor(authService, userService) {
|
|
2227
|
+
this.authService = authService;
|
|
2228
|
+
this.userService = userService;
|
|
2229
|
+
}
|
|
2230
|
+
async register(request, reply) {
|
|
2231
|
+
const data = validateBody(registerSchema, request.body);
|
|
2232
|
+
const existingUser = await this.userService.findByEmail(data.email);
|
|
2233
|
+
if (existingUser) {
|
|
2234
|
+
throw new BadRequestError("Email already registered");
|
|
2235
|
+
}
|
|
2236
|
+
const hashedPassword = await this.authService.hashPassword(data.password);
|
|
2237
|
+
const user = await this.userService.create({
|
|
2238
|
+
email: data.email,
|
|
2239
|
+
password: hashedPassword,
|
|
2240
|
+
name: data.name
|
|
2241
|
+
});
|
|
2242
|
+
const tokens = this.authService.generateTokenPair({
|
|
2243
|
+
id: user.id,
|
|
2244
|
+
email: user.email,
|
|
2245
|
+
role: user.role
|
|
2246
|
+
});
|
|
2247
|
+
created(reply, {
|
|
2248
|
+
user: {
|
|
2249
|
+
id: user.id,
|
|
2250
|
+
email: user.email,
|
|
2251
|
+
name: user.name,
|
|
2252
|
+
role: user.role
|
|
2253
|
+
},
|
|
2254
|
+
...tokens
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
async login(request, reply) {
|
|
2258
|
+
const data = validateBody(loginSchema, request.body);
|
|
2259
|
+
const user = await this.userService.findByEmail(data.email);
|
|
2260
|
+
if (!user) {
|
|
2261
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
2262
|
+
}
|
|
2263
|
+
if (user.status !== "active") {
|
|
2264
|
+
throw new UnauthorizedError("Account is not active");
|
|
2265
|
+
}
|
|
2266
|
+
const isValidPassword = await this.authService.verifyPassword(data.password, user.password);
|
|
2267
|
+
if (!isValidPassword) {
|
|
2268
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
2269
|
+
}
|
|
2270
|
+
await this.userService.updateLastLogin(user.id);
|
|
2271
|
+
const tokens = this.authService.generateTokenPair({
|
|
2272
|
+
id: user.id,
|
|
2273
|
+
email: user.email,
|
|
2274
|
+
role: user.role
|
|
2275
|
+
});
|
|
2276
|
+
success3(reply, {
|
|
2277
|
+
user: {
|
|
2278
|
+
id: user.id,
|
|
2279
|
+
email: user.email,
|
|
2280
|
+
name: user.name,
|
|
2281
|
+
role: user.role
|
|
2282
|
+
},
|
|
2283
|
+
...tokens
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
async refresh(request, reply) {
|
|
2287
|
+
const data = validateBody(refreshTokenSchema, request.body);
|
|
2288
|
+
const payload = await this.authService.verifyRefreshToken(data.refreshToken);
|
|
2289
|
+
const user = await this.userService.findById(payload.sub);
|
|
2290
|
+
if (!user || user.status !== "active") {
|
|
2291
|
+
throw new UnauthorizedError("User not found or inactive");
|
|
2292
|
+
}
|
|
2293
|
+
this.authService.blacklistToken(data.refreshToken);
|
|
2294
|
+
const tokens = this.authService.generateTokenPair({
|
|
2295
|
+
id: user.id,
|
|
2296
|
+
email: user.email,
|
|
2297
|
+
role: user.role
|
|
2298
|
+
});
|
|
2299
|
+
success3(reply, tokens);
|
|
2300
|
+
}
|
|
2301
|
+
async logout(request, reply) {
|
|
2302
|
+
const authHeader = request.headers.authorization;
|
|
2303
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
2304
|
+
const token = authHeader.substring(7);
|
|
2305
|
+
this.authService.blacklistToken(token);
|
|
2306
|
+
}
|
|
2307
|
+
success3(reply, { message: "Logged out successfully" });
|
|
2308
|
+
}
|
|
2309
|
+
async me(request, reply) {
|
|
2310
|
+
const authRequest = request;
|
|
2311
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
2312
|
+
if (!user) {
|
|
2313
|
+
throw new UnauthorizedError("User not found");
|
|
2314
|
+
}
|
|
2315
|
+
success3(reply, {
|
|
2316
|
+
id: user.id,
|
|
2317
|
+
email: user.email,
|
|
2318
|
+
name: user.name,
|
|
2319
|
+
role: user.role,
|
|
2320
|
+
status: user.status,
|
|
2321
|
+
createdAt: user.createdAt
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
async changePassword(request, reply) {
|
|
2325
|
+
const authRequest = request;
|
|
2326
|
+
const data = validateBody(changePasswordSchema, request.body);
|
|
2327
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
2328
|
+
if (!user) {
|
|
2329
|
+
throw new UnauthorizedError("User not found");
|
|
2330
|
+
}
|
|
2331
|
+
const isValidPassword = await this.authService.verifyPassword(
|
|
2332
|
+
data.currentPassword,
|
|
2333
|
+
user.password
|
|
2334
|
+
);
|
|
2335
|
+
if (!isValidPassword) {
|
|
2336
|
+
throw new BadRequestError("Current password is incorrect");
|
|
2337
|
+
}
|
|
2338
|
+
const hashedPassword = await this.authService.hashPassword(data.newPassword);
|
|
2339
|
+
await this.userService.updatePassword(user.id, hashedPassword);
|
|
2340
|
+
success3(reply, { message: "Password changed successfully" });
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
function createAuthController(authService, userService) {
|
|
2344
|
+
return new AuthController(authService, userService);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/modules/auth/auth.middleware.ts
|
|
2348
|
+
function createAuthMiddleware(authService) {
|
|
2349
|
+
return async function authenticate(request, reply) {
|
|
2350
|
+
const authHeader = request.headers.authorization;
|
|
2351
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
2352
|
+
throw new UnauthorizedError("Missing or invalid authorization header");
|
|
2353
|
+
}
|
|
2354
|
+
const token = authHeader.substring(7);
|
|
2355
|
+
const payload = await authService.verifyAccessToken(token);
|
|
2356
|
+
request.user = {
|
|
2357
|
+
id: payload.sub,
|
|
2358
|
+
email: payload.email,
|
|
2359
|
+
role: payload.role
|
|
2360
|
+
};
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
function createRoleMiddleware(allowedRoles) {
|
|
2364
|
+
return async function authorize(request, _reply) {
|
|
2365
|
+
const user = request.user;
|
|
2366
|
+
if (!user) {
|
|
2367
|
+
throw new UnauthorizedError("Authentication required");
|
|
2368
|
+
}
|
|
2369
|
+
if (!allowedRoles.includes(user.role)) {
|
|
2370
|
+
throw new ForbiddenError("Insufficient permissions");
|
|
2371
|
+
}
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// src/modules/auth/auth.routes.ts
|
|
2376
|
+
var credentialsBody = {
|
|
2377
|
+
type: "object",
|
|
2378
|
+
required: ["email", "password"],
|
|
2379
|
+
properties: {
|
|
2380
|
+
email: { type: "string", format: "email" },
|
|
2381
|
+
password: { type: "string", minLength: 8 }
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
var changePasswordBody = {
|
|
2385
|
+
type: "object",
|
|
2386
|
+
required: ["currentPassword", "newPassword"],
|
|
2387
|
+
properties: {
|
|
2388
|
+
currentPassword: { type: "string", minLength: 8 },
|
|
2389
|
+
newPassword: { type: "string", minLength: 8 }
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
function registerAuthRoutes(app, controller, authService) {
|
|
2393
|
+
const authenticate = createAuthMiddleware(authService);
|
|
2394
|
+
app.post("/auth/register", {
|
|
2395
|
+
schema: {
|
|
2396
|
+
tags: ["Auth"],
|
|
2397
|
+
summary: "Register a new user",
|
|
2398
|
+
body: credentialsBody,
|
|
2399
|
+
response: {
|
|
2400
|
+
201: commonResponses.success,
|
|
2401
|
+
400: commonResponses.error,
|
|
2402
|
+
409: commonResponses.error
|
|
2403
|
+
}
|
|
2404
|
+
},
|
|
2405
|
+
handler: controller.register.bind(controller)
|
|
2406
|
+
});
|
|
2407
|
+
app.post("/auth/login", {
|
|
2408
|
+
schema: {
|
|
2409
|
+
tags: ["Auth"],
|
|
2410
|
+
summary: "Login and obtain tokens",
|
|
2411
|
+
body: credentialsBody,
|
|
2412
|
+
response: {
|
|
2413
|
+
200: commonResponses.success,
|
|
2414
|
+
400: commonResponses.error,
|
|
2415
|
+
401: commonResponses.unauthorized
|
|
2416
|
+
}
|
|
2417
|
+
},
|
|
2418
|
+
handler: controller.login.bind(controller)
|
|
2419
|
+
});
|
|
2420
|
+
app.post("/auth/refresh", {
|
|
2421
|
+
schema: {
|
|
2422
|
+
tags: ["Auth"],
|
|
2423
|
+
summary: "Refresh access token",
|
|
2424
|
+
body: {
|
|
2425
|
+
type: "object",
|
|
2426
|
+
required: ["refreshToken"],
|
|
2427
|
+
properties: {
|
|
2428
|
+
refreshToken: { type: "string" }
|
|
2429
|
+
}
|
|
2430
|
+
},
|
|
2431
|
+
response: {
|
|
2432
|
+
200: commonResponses.success,
|
|
2433
|
+
401: commonResponses.unauthorized
|
|
2434
|
+
}
|
|
2435
|
+
},
|
|
2436
|
+
handler: controller.refresh.bind(controller)
|
|
2437
|
+
});
|
|
2438
|
+
app.post("/auth/logout", {
|
|
2439
|
+
preHandler: [authenticate],
|
|
2440
|
+
schema: {
|
|
2441
|
+
tags: ["Auth"],
|
|
2442
|
+
summary: "Logout current user",
|
|
2443
|
+
security: [{ bearerAuth: [] }],
|
|
2444
|
+
response: {
|
|
2445
|
+
200: commonResponses.success,
|
|
2446
|
+
401: commonResponses.unauthorized
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
handler: controller.logout.bind(controller)
|
|
2450
|
+
});
|
|
2451
|
+
app.get("/auth/me", {
|
|
2452
|
+
preHandler: [authenticate],
|
|
2453
|
+
schema: {
|
|
2454
|
+
tags: ["Auth"],
|
|
2455
|
+
summary: "Get current user profile",
|
|
2456
|
+
security: [{ bearerAuth: [] }],
|
|
2457
|
+
response: {
|
|
2458
|
+
200: commonResponses.success,
|
|
2459
|
+
401: commonResponses.unauthorized
|
|
2460
|
+
}
|
|
2461
|
+
},
|
|
2462
|
+
handler: controller.me.bind(controller)
|
|
2463
|
+
});
|
|
2464
|
+
app.post("/auth/change-password", {
|
|
2465
|
+
preHandler: [authenticate],
|
|
2466
|
+
schema: {
|
|
2467
|
+
tags: ["Auth"],
|
|
2468
|
+
summary: "Change current user password",
|
|
2469
|
+
security: [{ bearerAuth: [] }],
|
|
2470
|
+
body: changePasswordBody,
|
|
2471
|
+
response: {
|
|
2472
|
+
200: commonResponses.success,
|
|
2473
|
+
400: commonResponses.error,
|
|
2474
|
+
401: commonResponses.unauthorized
|
|
2475
|
+
}
|
|
2476
|
+
},
|
|
2477
|
+
handler: controller.changePassword.bind(controller)
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// src/modules/user/user.repository.ts
|
|
2482
|
+
import { randomUUID } from "crypto";
|
|
2483
|
+
|
|
2484
|
+
// src/utils/pagination.ts
|
|
2485
|
+
var DEFAULT_PAGE = 1;
|
|
2486
|
+
var DEFAULT_LIMIT = 20;
|
|
2487
|
+
var MAX_LIMIT = 100;
|
|
2488
|
+
function parsePaginationParams(query) {
|
|
2489
|
+
const page = Math.max(1, parseInt(String(query.page || DEFAULT_PAGE), 10));
|
|
2490
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(String(query.limit || DEFAULT_LIMIT), 10)));
|
|
2491
|
+
const sortBy = typeof query.sortBy === "string" ? query.sortBy : void 0;
|
|
2492
|
+
const sortOrder = query.sortOrder === "desc" ? "desc" : "asc";
|
|
2493
|
+
return { page, limit, sortBy, sortOrder };
|
|
2494
|
+
}
|
|
2495
|
+
function createPaginatedResult(data, total, params) {
|
|
2496
|
+
const totalPages = Math.ceil(total / params.limit);
|
|
2497
|
+
return {
|
|
2498
|
+
data,
|
|
2499
|
+
meta: {
|
|
2500
|
+
total,
|
|
2501
|
+
page: params.page,
|
|
2502
|
+
limit: params.limit,
|
|
2503
|
+
totalPages,
|
|
2504
|
+
hasNextPage: params.page < totalPages,
|
|
2505
|
+
hasPrevPage: params.page > 1
|
|
2506
|
+
}
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
function getSkip(params) {
|
|
2510
|
+
return (params.page - 1) * params.limit;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
// src/modules/user/user.repository.ts
|
|
2514
|
+
var users = /* @__PURE__ */ new Map();
|
|
2515
|
+
var UserRepository = class {
|
|
2516
|
+
async findById(id) {
|
|
2517
|
+
return users.get(id) || null;
|
|
2518
|
+
}
|
|
2519
|
+
async findByEmail(email) {
|
|
2520
|
+
for (const user of users.values()) {
|
|
2521
|
+
if (user.email.toLowerCase() === email.toLowerCase()) {
|
|
2522
|
+
return user;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
return null;
|
|
2526
|
+
}
|
|
2527
|
+
async findMany(params, filters) {
|
|
2528
|
+
let filteredUsers = Array.from(users.values());
|
|
2529
|
+
if (filters) {
|
|
2530
|
+
if (filters.status) {
|
|
2531
|
+
filteredUsers = filteredUsers.filter((u) => u.status === filters.status);
|
|
2532
|
+
}
|
|
2533
|
+
if (filters.role) {
|
|
2534
|
+
filteredUsers = filteredUsers.filter((u) => u.role === filters.role);
|
|
2535
|
+
}
|
|
2536
|
+
if (filters.emailVerified !== void 0) {
|
|
2537
|
+
filteredUsers = filteredUsers.filter((u) => u.emailVerified === filters.emailVerified);
|
|
2538
|
+
}
|
|
2539
|
+
if (filters.search) {
|
|
2540
|
+
const search = filters.search.toLowerCase();
|
|
2541
|
+
filteredUsers = filteredUsers.filter(
|
|
2542
|
+
(u) => u.email.toLowerCase().includes(search) || u.name?.toLowerCase().includes(search)
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (params.sortBy) {
|
|
2547
|
+
const sortKey = params.sortBy;
|
|
2548
|
+
filteredUsers.sort((a, b) => {
|
|
2549
|
+
const aVal = a[sortKey];
|
|
2550
|
+
const bVal = b[sortKey];
|
|
2551
|
+
if (aVal === void 0 || bVal === void 0) return 0;
|
|
2552
|
+
if (aVal < bVal) return params.sortOrder === "desc" ? 1 : -1;
|
|
2553
|
+
if (aVal > bVal) return params.sortOrder === "desc" ? -1 : 1;
|
|
2554
|
+
return 0;
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
const total = filteredUsers.length;
|
|
2558
|
+
const skip = getSkip(params);
|
|
2559
|
+
const data = filteredUsers.slice(skip, skip + params.limit);
|
|
2560
|
+
return createPaginatedResult(data, total, params);
|
|
2561
|
+
}
|
|
2562
|
+
async create(data) {
|
|
2563
|
+
const now = /* @__PURE__ */ new Date();
|
|
2564
|
+
const user = {
|
|
2565
|
+
id: randomUUID(),
|
|
2566
|
+
email: data.email,
|
|
2567
|
+
password: data.password,
|
|
2568
|
+
name: data.name,
|
|
2569
|
+
role: data.role || "user",
|
|
2570
|
+
status: "active",
|
|
2571
|
+
emailVerified: false,
|
|
2572
|
+
createdAt: now,
|
|
2573
|
+
updatedAt: now
|
|
2574
|
+
};
|
|
2575
|
+
users.set(user.id, user);
|
|
2576
|
+
return user;
|
|
2577
|
+
}
|
|
2578
|
+
async update(id, data) {
|
|
2579
|
+
const user = users.get(id);
|
|
2580
|
+
if (!user) return null;
|
|
2581
|
+
const updatedUser = {
|
|
2582
|
+
...user,
|
|
2583
|
+
...data,
|
|
2584
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2585
|
+
};
|
|
2586
|
+
users.set(id, updatedUser);
|
|
2587
|
+
return updatedUser;
|
|
2588
|
+
}
|
|
2589
|
+
async updatePassword(id, password) {
|
|
2590
|
+
const user = users.get(id);
|
|
2591
|
+
if (!user) return null;
|
|
2592
|
+
const updatedUser = {
|
|
2593
|
+
...user,
|
|
2594
|
+
password,
|
|
2595
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2596
|
+
};
|
|
2597
|
+
users.set(id, updatedUser);
|
|
2598
|
+
return updatedUser;
|
|
2599
|
+
}
|
|
2600
|
+
async updateLastLogin(id) {
|
|
2601
|
+
const user = users.get(id);
|
|
2602
|
+
if (!user) return null;
|
|
2603
|
+
const updatedUser = {
|
|
2604
|
+
...user,
|
|
2605
|
+
lastLoginAt: /* @__PURE__ */ new Date(),
|
|
2606
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2607
|
+
};
|
|
2608
|
+
users.set(id, updatedUser);
|
|
2609
|
+
return updatedUser;
|
|
2610
|
+
}
|
|
2611
|
+
async delete(id) {
|
|
2612
|
+
return users.delete(id);
|
|
2613
|
+
}
|
|
2614
|
+
async count(filters) {
|
|
2615
|
+
let count = 0;
|
|
2616
|
+
for (const user of users.values()) {
|
|
2617
|
+
if (filters) {
|
|
2618
|
+
if (filters.status && user.status !== filters.status) continue;
|
|
2619
|
+
if (filters.role && user.role !== filters.role) continue;
|
|
2620
|
+
if (filters.emailVerified !== void 0 && user.emailVerified !== filters.emailVerified)
|
|
2621
|
+
continue;
|
|
2622
|
+
}
|
|
2623
|
+
count++;
|
|
2624
|
+
}
|
|
2625
|
+
return count;
|
|
2626
|
+
}
|
|
2627
|
+
// Helper to clear all users (for testing)
|
|
2628
|
+
async clear() {
|
|
2629
|
+
users.clear();
|
|
2630
|
+
}
|
|
2631
|
+
};
|
|
2632
|
+
function createUserRepository() {
|
|
2633
|
+
return new UserRepository();
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// src/modules/user/types.ts
|
|
2637
|
+
var DEFAULT_ROLE_PERMISSIONS = {
|
|
2638
|
+
user: ["profile:read", "profile:update"],
|
|
2639
|
+
moderator: [
|
|
2640
|
+
"profile:read",
|
|
2641
|
+
"profile:update",
|
|
2642
|
+
"users:read",
|
|
2643
|
+
"content:read",
|
|
2644
|
+
"content:update",
|
|
2645
|
+
"content:delete"
|
|
2646
|
+
],
|
|
2647
|
+
admin: [
|
|
2648
|
+
"profile:read",
|
|
2649
|
+
"profile:update",
|
|
2650
|
+
"users:read",
|
|
2651
|
+
"users:update",
|
|
2652
|
+
"users:delete",
|
|
2653
|
+
"content:manage",
|
|
2654
|
+
"settings:read"
|
|
2655
|
+
],
|
|
2656
|
+
super_admin: ["*:manage"]
|
|
2657
|
+
// All permissions
|
|
2658
|
+
};
|
|
2659
|
+
|
|
2660
|
+
// src/modules/user/user.service.ts
|
|
2661
|
+
var UserService = class {
|
|
2662
|
+
constructor(repository) {
|
|
2663
|
+
this.repository = repository;
|
|
2664
|
+
}
|
|
2665
|
+
async findById(id) {
|
|
2666
|
+
return this.repository.findById(id);
|
|
2667
|
+
}
|
|
2668
|
+
async findByEmail(email) {
|
|
2669
|
+
return this.repository.findByEmail(email);
|
|
2670
|
+
}
|
|
2671
|
+
async findMany(params, filters) {
|
|
2672
|
+
const result = await this.repository.findMany(params, filters);
|
|
2673
|
+
return {
|
|
2674
|
+
...result,
|
|
2675
|
+
data: result.data.map(({ password, ...user }) => user)
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
async create(data) {
|
|
2679
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
2680
|
+
if (existing) {
|
|
2681
|
+
throw new ConflictError("User with this email already exists");
|
|
2682
|
+
}
|
|
2683
|
+
const user = await this.repository.create(data);
|
|
2684
|
+
logger.info({ userId: user.id, email: user.email }, "User created");
|
|
2685
|
+
return user;
|
|
2686
|
+
}
|
|
2687
|
+
async update(id, data) {
|
|
2688
|
+
const user = await this.repository.findById(id);
|
|
2689
|
+
if (!user) {
|
|
2690
|
+
throw new NotFoundError("User");
|
|
2691
|
+
}
|
|
2692
|
+
if (data.email && data.email !== user.email) {
|
|
2693
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
2694
|
+
if (existing) {
|
|
2695
|
+
throw new ConflictError("Email already in use");
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
const updatedUser = await this.repository.update(id, data);
|
|
2699
|
+
if (!updatedUser) {
|
|
2700
|
+
throw new NotFoundError("User");
|
|
2701
|
+
}
|
|
2702
|
+
logger.info({ userId: id }, "User updated");
|
|
2703
|
+
return updatedUser;
|
|
2704
|
+
}
|
|
2705
|
+
async updatePassword(id, hashedPassword) {
|
|
2706
|
+
const user = await this.repository.updatePassword(id, hashedPassword);
|
|
2707
|
+
if (!user) {
|
|
2708
|
+
throw new NotFoundError("User");
|
|
2709
|
+
}
|
|
2710
|
+
logger.info({ userId: id }, "User password updated");
|
|
2711
|
+
return user;
|
|
2712
|
+
}
|
|
2713
|
+
async updateLastLogin(id) {
|
|
2714
|
+
const user = await this.repository.updateLastLogin(id);
|
|
2715
|
+
if (!user) {
|
|
2716
|
+
throw new NotFoundError("User");
|
|
2717
|
+
}
|
|
2718
|
+
return user;
|
|
2719
|
+
}
|
|
2720
|
+
async delete(id) {
|
|
2721
|
+
const user = await this.repository.findById(id);
|
|
2722
|
+
if (!user) {
|
|
2723
|
+
throw new NotFoundError("User");
|
|
2724
|
+
}
|
|
2725
|
+
await this.repository.delete(id);
|
|
2726
|
+
logger.info({ userId: id }, "User deleted");
|
|
2727
|
+
}
|
|
2728
|
+
async suspend(id) {
|
|
2729
|
+
return this.update(id, { status: "suspended" });
|
|
2730
|
+
}
|
|
2731
|
+
async ban(id) {
|
|
2732
|
+
return this.update(id, { status: "banned" });
|
|
2733
|
+
}
|
|
2734
|
+
async activate(id) {
|
|
2735
|
+
return this.update(id, { status: "active" });
|
|
2736
|
+
}
|
|
2737
|
+
async verifyEmail(id) {
|
|
2738
|
+
return this.update(id, { emailVerified: true });
|
|
2739
|
+
}
|
|
2740
|
+
async changeRole(id, role) {
|
|
2741
|
+
return this.update(id, { role });
|
|
2742
|
+
}
|
|
2743
|
+
// RBAC helpers
|
|
2744
|
+
hasPermission(role, permission) {
|
|
2745
|
+
const permissions = DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
2746
|
+
if (permissions.includes("*:manage")) {
|
|
2747
|
+
return true;
|
|
2748
|
+
}
|
|
2749
|
+
if (permissions.includes(permission)) {
|
|
2750
|
+
return true;
|
|
2751
|
+
}
|
|
2752
|
+
const [resource, action] = permission.split(":");
|
|
2753
|
+
const managePermission = `${resource}:manage`;
|
|
2754
|
+
if (permissions.includes(managePermission)) {
|
|
2755
|
+
return true;
|
|
2756
|
+
}
|
|
2757
|
+
return false;
|
|
2758
|
+
}
|
|
2759
|
+
getPermissions(role) {
|
|
2760
|
+
return DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
2761
|
+
}
|
|
2762
|
+
};
|
|
2763
|
+
function createUserService(repository) {
|
|
2764
|
+
return new UserService(repository || createUserRepository());
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// src/modules/auth/index.ts
|
|
2768
|
+
async function registerAuthModule(app) {
|
|
2769
|
+
await app.register(jwt, {
|
|
2770
|
+
secret: config.jwt.secret,
|
|
2771
|
+
sign: {
|
|
2772
|
+
algorithm: "HS256"
|
|
2773
|
+
}
|
|
2774
|
+
});
|
|
2775
|
+
await app.register(cookie, {
|
|
2776
|
+
secret: config.jwt.secret,
|
|
2777
|
+
hook: "onRequest"
|
|
2778
|
+
});
|
|
2779
|
+
const authService = createAuthService(app);
|
|
2780
|
+
const userService = createUserService();
|
|
2781
|
+
const authController = createAuthController(authService, userService);
|
|
2782
|
+
registerAuthRoutes(app, authController, authService);
|
|
2783
|
+
logger.info("Auth module registered");
|
|
2784
|
+
return authService;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// src/modules/user/schemas.ts
|
|
2788
|
+
import { z as z4 } from "zod";
|
|
2789
|
+
var userStatusEnum = z4.enum(["active", "inactive", "suspended", "banned"]);
|
|
2790
|
+
var userRoleEnum = z4.enum(["user", "admin", "moderator", "super_admin"]);
|
|
2791
|
+
var createUserSchema = z4.object({
|
|
2792
|
+
email: z4.string().email("Invalid email address"),
|
|
2793
|
+
password: z4.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"),
|
|
2794
|
+
name: z4.string().min(2, "Name must be at least 2 characters").optional(),
|
|
2795
|
+
role: userRoleEnum.optional().default("user")
|
|
2796
|
+
});
|
|
2797
|
+
var updateUserSchema = z4.object({
|
|
2798
|
+
email: z4.string().email("Invalid email address").optional(),
|
|
2799
|
+
name: z4.string().min(2, "Name must be at least 2 characters").optional(),
|
|
2800
|
+
role: userRoleEnum.optional(),
|
|
2801
|
+
status: userStatusEnum.optional(),
|
|
2802
|
+
emailVerified: z4.boolean().optional(),
|
|
2803
|
+
metadata: z4.record(z4.unknown()).optional()
|
|
2804
|
+
});
|
|
2805
|
+
var updateProfileSchema = z4.object({
|
|
2806
|
+
name: z4.string().min(2, "Name must be at least 2 characters").optional(),
|
|
2807
|
+
metadata: z4.record(z4.unknown()).optional()
|
|
2808
|
+
});
|
|
2809
|
+
var userQuerySchema = z4.object({
|
|
2810
|
+
page: z4.string().transform(Number).optional(),
|
|
2811
|
+
limit: z4.string().transform(Number).optional(),
|
|
2812
|
+
sortBy: z4.string().optional(),
|
|
2813
|
+
sortOrder: z4.enum(["asc", "desc"]).optional(),
|
|
2814
|
+
status: userStatusEnum.optional(),
|
|
2815
|
+
role: userRoleEnum.optional(),
|
|
2816
|
+
search: z4.string().optional(),
|
|
2817
|
+
emailVerified: z4.string().transform((val) => val === "true").optional()
|
|
2818
|
+
});
|
|
2819
|
+
|
|
2820
|
+
// src/modules/user/user.controller.ts
|
|
2821
|
+
var UserController = class {
|
|
2822
|
+
constructor(userService) {
|
|
2823
|
+
this.userService = userService;
|
|
2824
|
+
}
|
|
2825
|
+
async list(request, reply) {
|
|
2826
|
+
const query = validateQuery(userQuerySchema, request.query);
|
|
2827
|
+
const pagination = parsePaginationParams(query);
|
|
2828
|
+
const filters = {
|
|
2829
|
+
status: query.status,
|
|
2830
|
+
role: query.role,
|
|
2831
|
+
search: query.search,
|
|
2832
|
+
emailVerified: query.emailVerified
|
|
2833
|
+
};
|
|
2834
|
+
const result = await this.userService.findMany(pagination, filters);
|
|
2835
|
+
success3(reply, result);
|
|
2836
|
+
}
|
|
2837
|
+
async getById(request, reply) {
|
|
2838
|
+
const user = await this.userService.findById(request.params.id);
|
|
2839
|
+
if (!user) {
|
|
2840
|
+
return reply.status(404).send({
|
|
2841
|
+
success: false,
|
|
2842
|
+
message: "User not found"
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
const { password, ...userData } = user;
|
|
2846
|
+
success3(reply, userData);
|
|
2847
|
+
}
|
|
2848
|
+
async update(request, reply) {
|
|
2849
|
+
const data = validateBody(updateUserSchema, request.body);
|
|
2850
|
+
const user = await this.userService.update(request.params.id, data);
|
|
2851
|
+
const { password, ...userData } = user;
|
|
2852
|
+
success3(reply, userData);
|
|
2853
|
+
}
|
|
2854
|
+
async delete(request, reply) {
|
|
2855
|
+
const authRequest = request;
|
|
2856
|
+
if (authRequest.user.id === request.params.id) {
|
|
2857
|
+
throw new ForbiddenError("Cannot delete your own account");
|
|
2858
|
+
}
|
|
2859
|
+
await this.userService.delete(request.params.id);
|
|
2860
|
+
noContent(reply);
|
|
2861
|
+
}
|
|
2862
|
+
async suspend(request, reply) {
|
|
2863
|
+
const authRequest = request;
|
|
2864
|
+
if (authRequest.user.id === request.params.id) {
|
|
2865
|
+
throw new ForbiddenError("Cannot suspend your own account");
|
|
2866
|
+
}
|
|
2867
|
+
const user = await this.userService.suspend(request.params.id);
|
|
2868
|
+
const { password, ...userData } = user;
|
|
2869
|
+
success3(reply, userData);
|
|
2870
|
+
}
|
|
2871
|
+
async ban(request, reply) {
|
|
2872
|
+
const authRequest = request;
|
|
2873
|
+
if (authRequest.user.id === request.params.id) {
|
|
2874
|
+
throw new ForbiddenError("Cannot ban your own account");
|
|
2875
|
+
}
|
|
2876
|
+
const user = await this.userService.ban(request.params.id);
|
|
2877
|
+
const { password, ...userData } = user;
|
|
2878
|
+
success3(reply, userData);
|
|
2879
|
+
}
|
|
2880
|
+
async activate(request, reply) {
|
|
2881
|
+
const user = await this.userService.activate(request.params.id);
|
|
2882
|
+
const { password, ...userData } = user;
|
|
2883
|
+
success3(reply, userData);
|
|
2884
|
+
}
|
|
2885
|
+
// Profile routes (for authenticated user)
|
|
2886
|
+
async getProfile(request, reply) {
|
|
2887
|
+
const authRequest = request;
|
|
2888
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
2889
|
+
if (!user) {
|
|
2890
|
+
return reply.status(404).send({
|
|
2891
|
+
success: false,
|
|
2892
|
+
message: "User not found"
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
const { password, ...userData } = user;
|
|
2896
|
+
success3(reply, userData);
|
|
2897
|
+
}
|
|
2898
|
+
async updateProfile(request, reply) {
|
|
2899
|
+
const authRequest = request;
|
|
2900
|
+
const data = validateBody(updateProfileSchema, request.body);
|
|
2901
|
+
const user = await this.userService.update(authRequest.user.id, data);
|
|
2902
|
+
const { password, ...userData } = user;
|
|
2903
|
+
success3(reply, userData);
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
function createUserController(userService) {
|
|
2907
|
+
return new UserController(userService);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// src/modules/user/user.routes.ts
|
|
2911
|
+
var userTag = "Users";
|
|
2912
|
+
var userResponse = {
|
|
2913
|
+
type: "object",
|
|
2914
|
+
properties: {
|
|
2915
|
+
success: { type: "boolean", example: true },
|
|
2916
|
+
data: { type: "object" }
|
|
2917
|
+
}
|
|
2918
|
+
};
|
|
2919
|
+
function registerUserRoutes(app, controller, authService) {
|
|
2920
|
+
const authenticate = createAuthMiddleware(authService);
|
|
2921
|
+
const isAdmin = createRoleMiddleware(["admin", "super_admin"]);
|
|
2922
|
+
const isModerator = createRoleMiddleware(["moderator", "admin", "super_admin"]);
|
|
2923
|
+
app.get(
|
|
2924
|
+
"/profile",
|
|
2925
|
+
{
|
|
2926
|
+
preHandler: [authenticate],
|
|
2927
|
+
schema: {
|
|
2928
|
+
tags: [userTag],
|
|
2929
|
+
summary: "Get current user profile",
|
|
2930
|
+
security: [{ bearerAuth: [] }],
|
|
2931
|
+
response: {
|
|
2932
|
+
200: userResponse,
|
|
2933
|
+
401: commonResponses.unauthorized
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
},
|
|
2937
|
+
controller.getProfile.bind(controller)
|
|
2938
|
+
);
|
|
2939
|
+
app.patch(
|
|
2940
|
+
"/profile",
|
|
2941
|
+
{
|
|
2942
|
+
preHandler: [authenticate],
|
|
2943
|
+
schema: {
|
|
2944
|
+
tags: [userTag],
|
|
2945
|
+
summary: "Update current user profile",
|
|
2946
|
+
security: [{ bearerAuth: [] }],
|
|
2947
|
+
body: { type: "object" },
|
|
2948
|
+
response: {
|
|
2949
|
+
200: userResponse,
|
|
2950
|
+
401: commonResponses.unauthorized,
|
|
2951
|
+
400: commonResponses.error
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
},
|
|
2955
|
+
controller.updateProfile.bind(controller)
|
|
2956
|
+
);
|
|
2957
|
+
app.get(
|
|
2958
|
+
"/users",
|
|
2959
|
+
{
|
|
2960
|
+
preHandler: [authenticate, isModerator],
|
|
2961
|
+
schema: {
|
|
2962
|
+
tags: [userTag],
|
|
2963
|
+
summary: "List users",
|
|
2964
|
+
security: [{ bearerAuth: [] }],
|
|
2965
|
+
querystring: {
|
|
2966
|
+
...paginationQuery,
|
|
2967
|
+
properties: {
|
|
2968
|
+
...paginationQuery.properties,
|
|
2969
|
+
status: { type: "string", enum: ["active", "inactive", "suspended", "banned"] },
|
|
2970
|
+
role: { type: "string", enum: ["user", "admin", "moderator", "super_admin"] },
|
|
2971
|
+
search: { type: "string" },
|
|
2972
|
+
emailVerified: { type: "boolean" }
|
|
2973
|
+
}
|
|
2974
|
+
},
|
|
2975
|
+
response: {
|
|
2976
|
+
200: commonResponses.paginated,
|
|
2977
|
+
401: commonResponses.unauthorized
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
},
|
|
2981
|
+
controller.list.bind(controller)
|
|
2982
|
+
);
|
|
2983
|
+
app.get(
|
|
2984
|
+
"/users/:id",
|
|
2985
|
+
{
|
|
2986
|
+
preHandler: [authenticate, isModerator],
|
|
2987
|
+
schema: {
|
|
2988
|
+
tags: [userTag],
|
|
2989
|
+
summary: "Get user by id",
|
|
2990
|
+
security: [{ bearerAuth: [] }],
|
|
2991
|
+
params: idParam,
|
|
2992
|
+
response: {
|
|
2993
|
+
200: userResponse,
|
|
2994
|
+
401: commonResponses.unauthorized,
|
|
2995
|
+
404: commonResponses.notFound
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
},
|
|
2999
|
+
controller.getById.bind(controller)
|
|
3000
|
+
);
|
|
3001
|
+
app.patch(
|
|
3002
|
+
"/users/:id",
|
|
3003
|
+
{
|
|
3004
|
+
preHandler: [authenticate, isAdmin],
|
|
3005
|
+
schema: {
|
|
3006
|
+
tags: [userTag],
|
|
3007
|
+
summary: "Update user",
|
|
3008
|
+
security: [{ bearerAuth: [] }],
|
|
3009
|
+
params: idParam,
|
|
3010
|
+
body: { type: "object" },
|
|
3011
|
+
response: {
|
|
3012
|
+
200: userResponse,
|
|
3013
|
+
401: commonResponses.unauthorized,
|
|
3014
|
+
404: commonResponses.notFound
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
},
|
|
3018
|
+
controller.update.bind(controller)
|
|
3019
|
+
);
|
|
3020
|
+
app.delete(
|
|
3021
|
+
"/users/:id",
|
|
3022
|
+
{
|
|
3023
|
+
preHandler: [authenticate, isAdmin],
|
|
3024
|
+
schema: {
|
|
3025
|
+
tags: [userTag],
|
|
3026
|
+
summary: "Delete user",
|
|
3027
|
+
security: [{ bearerAuth: [] }],
|
|
3028
|
+
params: idParam,
|
|
3029
|
+
response: {
|
|
3030
|
+
204: { description: "User deleted" },
|
|
3031
|
+
401: commonResponses.unauthorized,
|
|
3032
|
+
404: commonResponses.notFound
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
},
|
|
3036
|
+
controller.delete.bind(controller)
|
|
3037
|
+
);
|
|
3038
|
+
app.post(
|
|
3039
|
+
"/users/:id/suspend",
|
|
3040
|
+
{
|
|
3041
|
+
preHandler: [authenticate, isAdmin],
|
|
3042
|
+
schema: {
|
|
3043
|
+
tags: [userTag],
|
|
3044
|
+
summary: "Suspend user",
|
|
3045
|
+
security: [{ bearerAuth: [] }],
|
|
3046
|
+
params: idParam,
|
|
3047
|
+
response: {
|
|
3048
|
+
200: userResponse,
|
|
3049
|
+
401: commonResponses.unauthorized,
|
|
3050
|
+
404: commonResponses.notFound
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
},
|
|
3054
|
+
controller.suspend.bind(controller)
|
|
3055
|
+
);
|
|
3056
|
+
app.post(
|
|
3057
|
+
"/users/:id/ban",
|
|
3058
|
+
{
|
|
3059
|
+
preHandler: [authenticate, isAdmin],
|
|
3060
|
+
schema: {
|
|
3061
|
+
tags: [userTag],
|
|
3062
|
+
summary: "Ban user",
|
|
3063
|
+
security: [{ bearerAuth: [] }],
|
|
3064
|
+
params: idParam,
|
|
3065
|
+
response: {
|
|
3066
|
+
200: userResponse,
|
|
3067
|
+
401: commonResponses.unauthorized,
|
|
3068
|
+
404: commonResponses.notFound
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
},
|
|
3072
|
+
controller.ban.bind(controller)
|
|
3073
|
+
);
|
|
3074
|
+
app.post(
|
|
3075
|
+
"/users/:id/activate",
|
|
3076
|
+
{
|
|
3077
|
+
preHandler: [authenticate, isAdmin],
|
|
3078
|
+
schema: {
|
|
3079
|
+
tags: [userTag],
|
|
3080
|
+
summary: "Activate user",
|
|
3081
|
+
security: [{ bearerAuth: [] }],
|
|
3082
|
+
params: idParam,
|
|
3083
|
+
response: {
|
|
3084
|
+
200: userResponse,
|
|
3085
|
+
401: commonResponses.unauthorized,
|
|
3086
|
+
404: commonResponses.notFound
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
},
|
|
3090
|
+
controller.activate.bind(controller)
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// src/modules/user/index.ts
|
|
3095
|
+
async function registerUserModule(app, authService) {
|
|
3096
|
+
const repository = createUserRepository();
|
|
3097
|
+
const userService = createUserService(repository);
|
|
3098
|
+
const userController = createUserController(userService);
|
|
3099
|
+
registerUserRoutes(app, userController, authService);
|
|
3100
|
+
logger.info("User module registered");
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// src/cli/utils/docs-generator.ts
|
|
3104
|
+
async function generateDocs(outputPath = "openapi.json", silent = false) {
|
|
3105
|
+
const spinner = silent ? null : ora2("Generating OpenAPI documentation...").start();
|
|
3106
|
+
try {
|
|
3107
|
+
const server = createServer({
|
|
3108
|
+
port: config.server.port,
|
|
3109
|
+
host: config.server.host
|
|
3110
|
+
});
|
|
3111
|
+
const app = server.instance;
|
|
3112
|
+
registerErrorHandler(app);
|
|
3113
|
+
await registerSecurity(app);
|
|
3114
|
+
await registerSwagger(app, {
|
|
3115
|
+
enabled: true,
|
|
3116
|
+
route: config.swagger.route,
|
|
3117
|
+
title: config.swagger.title,
|
|
3118
|
+
description: config.swagger.description,
|
|
3119
|
+
version: config.swagger.version
|
|
3120
|
+
});
|
|
3121
|
+
const authService = await registerAuthModule(app);
|
|
3122
|
+
await registerUserModule(app, authService);
|
|
3123
|
+
await app.ready();
|
|
3124
|
+
const spec = app.swagger();
|
|
3125
|
+
const absoluteOutput = path3.resolve(outputPath);
|
|
3126
|
+
await fs3.mkdir(path3.dirname(absoluteOutput), { recursive: true });
|
|
3127
|
+
await fs3.writeFile(absoluteOutput, JSON.stringify(spec, null, 2), "utf8");
|
|
3128
|
+
spinner?.succeed(`OpenAPI spec generated at ${absoluteOutput}`);
|
|
3129
|
+
await app.close();
|
|
3130
|
+
return absoluteOutput;
|
|
3131
|
+
} catch (error2) {
|
|
3132
|
+
spinner?.fail("Failed to generate OpenAPI documentation");
|
|
3133
|
+
throw error2;
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
// src/cli/commands/generate.ts
|
|
3138
|
+
var generateCommand = new Command2("generate").alias("g").description("Generate resources (module, controller, service, etc.)");
|
|
3139
|
+
generateCommand.command("module <name> [fields...]").alias("m").description("Generate a complete module with controller, service, repository, types, schemas, and routes").option("--no-routes", "Skip routes generation").option("--no-repository", "Skip repository generation").option("--prisma", "Generate Prisma model suggestion").option("--validator <type>", "Validator type: zod, joi, yup", "zod").option("-i, --interactive", "Interactive mode to define fields").action(async (name, fieldsArgs, options) => {
|
|
3140
|
+
let fields = [];
|
|
3141
|
+
if (options.interactive) {
|
|
3142
|
+
fields = await promptForFields();
|
|
3143
|
+
} else if (fieldsArgs.length > 0) {
|
|
3144
|
+
fields = parseFields(fieldsArgs.join(" "));
|
|
3145
|
+
}
|
|
3146
|
+
const spinner = ora3("Generating module...").start();
|
|
3147
|
+
try {
|
|
3148
|
+
const kebabName = toKebabCase(name);
|
|
3149
|
+
const pascalName = toPascalCase(name);
|
|
3150
|
+
const camelName = toCamelCase(name);
|
|
3151
|
+
const pluralName = pluralize(kebabName);
|
|
3152
|
+
const tableName = pluralize(kebabName.replace(/-/g, "_"));
|
|
3153
|
+
const validatorType = options.validator || "zod";
|
|
3154
|
+
const moduleDir = path4.join(getModulesDir(), kebabName);
|
|
3155
|
+
if (await fileExists(moduleDir)) {
|
|
3156
|
+
spinner.stop();
|
|
3157
|
+
error(`Module "${kebabName}" already exists`);
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
const hasFields = fields.length > 0;
|
|
3161
|
+
const files = [
|
|
3162
|
+
{
|
|
3163
|
+
name: `${kebabName}.types.ts`,
|
|
3164
|
+
content: hasFields ? dynamicTypesTemplate(kebabName, pascalName, fields) : typesTemplate(kebabName, pascalName)
|
|
3165
|
+
},
|
|
3166
|
+
{
|
|
3167
|
+
name: `${kebabName}.schemas.ts`,
|
|
3168
|
+
content: hasFields ? dynamicSchemasTemplate(kebabName, pascalName, camelName, fields, validatorType) : schemasTemplate(kebabName, pascalName, camelName)
|
|
3169
|
+
},
|
|
3170
|
+
{ name: `${kebabName}.service.ts`, content: serviceTemplate(kebabName, pascalName, camelName) },
|
|
3171
|
+
{ name: `${kebabName}.controller.ts`, content: controllerTemplate(kebabName, pascalName, camelName) },
|
|
3172
|
+
{ name: "index.ts", content: moduleIndexTemplate(kebabName, pascalName, camelName) }
|
|
3173
|
+
];
|
|
3174
|
+
if (options.repository !== false) {
|
|
3175
|
+
files.push({
|
|
3176
|
+
name: `${kebabName}.repository.ts`,
|
|
3177
|
+
content: repositoryTemplate(kebabName, pascalName, camelName, pluralName)
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
if (options.routes !== false) {
|
|
3181
|
+
files.push({
|
|
3182
|
+
name: `${kebabName}.routes.ts`,
|
|
3183
|
+
content: routesTemplate(kebabName, pascalName, camelName, pluralName, fields)
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
for (const file of files) {
|
|
3187
|
+
await writeFile(path4.join(moduleDir, file.name), file.content);
|
|
3188
|
+
}
|
|
3189
|
+
spinner.succeed(`Module "${pascalName}" generated successfully!`);
|
|
3190
|
+
if (options.prisma || hasFields) {
|
|
3191
|
+
console.log("\n" + "\u2500".repeat(50));
|
|
3192
|
+
info("Prisma model suggestion:");
|
|
3193
|
+
if (hasFields) {
|
|
3194
|
+
console.log(dynamicPrismaTemplate(pascalName, tableName, fields));
|
|
3195
|
+
} else {
|
|
3196
|
+
console.log(prismaModelTemplate(kebabName, pascalName, tableName));
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
if (hasFields) {
|
|
3200
|
+
console.log("\n\u{1F4CB} Fields defined:");
|
|
3201
|
+
fields.forEach((f) => {
|
|
3202
|
+
const opts = [];
|
|
3203
|
+
if (f.isOptional) opts.push("optional");
|
|
3204
|
+
if (f.isArray) opts.push("array");
|
|
3205
|
+
if (f.isUnique) opts.push("unique");
|
|
3206
|
+
const optsStr = opts.length > 0 ? ` (${opts.join(", ")})` : "";
|
|
3207
|
+
success(` ${f.name}: ${f.type}${optsStr}`);
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
console.log("\n\u{1F4C1} Files created:");
|
|
3211
|
+
files.forEach((f) => success(` src/modules/${kebabName}/${f.name}`));
|
|
3212
|
+
console.log("\n\u{1F4CC} Next steps:");
|
|
3213
|
+
if (!hasFields) {
|
|
3214
|
+
info(` 1. Update the types in ${kebabName}.types.ts`);
|
|
3215
|
+
info(` 2. Update the schemas in ${kebabName}.schemas.ts`);
|
|
3216
|
+
info(" 3. Register the module in your app");
|
|
3217
|
+
} else {
|
|
3218
|
+
info(" 1. Review generated types and schemas");
|
|
3219
|
+
info(" 2. Register the module in your app");
|
|
3220
|
+
}
|
|
3221
|
+
if (options.prisma || hasFields) {
|
|
3222
|
+
info(` ${hasFields ? "3" : "4"}. Add the Prisma model to schema.prisma`);
|
|
3223
|
+
info(` ${hasFields ? "4" : "5"}. Run: npm run db:migrate`);
|
|
3224
|
+
}
|
|
3225
|
+
const { generateDocsNow } = await inquirer2.prompt([
|
|
3226
|
+
{
|
|
3227
|
+
type: "confirm",
|
|
3228
|
+
name: "generateDocsNow",
|
|
3229
|
+
message: "Generate Swagger/OpenAPI documentation now?",
|
|
3230
|
+
default: true
|
|
3231
|
+
}
|
|
3232
|
+
]);
|
|
3233
|
+
if (generateDocsNow) {
|
|
3234
|
+
await generateDocs("openapi.json", true);
|
|
3235
|
+
}
|
|
3236
|
+
} catch (err) {
|
|
3237
|
+
spinner.fail("Failed to generate module");
|
|
3238
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3239
|
+
}
|
|
3240
|
+
});
|
|
3241
|
+
generateCommand.command("controller <name>").alias("c").description("Generate a controller").option("-m, --module <module>", "Target module name").action(async (name, options) => {
|
|
3242
|
+
const spinner = ora3("Generating controller...").start();
|
|
3243
|
+
try {
|
|
3244
|
+
const kebabName = toKebabCase(name);
|
|
3245
|
+
const pascalName = toPascalCase(name);
|
|
3246
|
+
const camelName = toCamelCase(name);
|
|
3247
|
+
const moduleName = options.module ? toKebabCase(options.module) : kebabName;
|
|
3248
|
+
const moduleDir = path4.join(getModulesDir(), moduleName);
|
|
3249
|
+
const filePath = path4.join(moduleDir, `${kebabName}.controller.ts`);
|
|
3250
|
+
if (await fileExists(filePath)) {
|
|
3251
|
+
spinner.stop();
|
|
3252
|
+
error(`Controller "${kebabName}" already exists`);
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
await writeFile(filePath, controllerTemplate(kebabName, pascalName, camelName));
|
|
3256
|
+
spinner.succeed(`Controller "${pascalName}Controller" generated!`);
|
|
3257
|
+
success(` src/modules/${moduleName}/${kebabName}.controller.ts`);
|
|
3258
|
+
} catch (err) {
|
|
3259
|
+
spinner.fail("Failed to generate controller");
|
|
3260
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3261
|
+
}
|
|
3262
|
+
});
|
|
3263
|
+
generateCommand.command("service <name>").alias("s").description("Generate a service").option("-m, --module <module>", "Target module name").action(async (name, options) => {
|
|
3264
|
+
const spinner = ora3("Generating service...").start();
|
|
3265
|
+
try {
|
|
3266
|
+
const kebabName = toKebabCase(name);
|
|
3267
|
+
const pascalName = toPascalCase(name);
|
|
3268
|
+
const camelName = toCamelCase(name);
|
|
3269
|
+
const moduleName = options.module ? toKebabCase(options.module) : kebabName;
|
|
3270
|
+
const moduleDir = path4.join(getModulesDir(), moduleName);
|
|
3271
|
+
const filePath = path4.join(moduleDir, `${kebabName}.service.ts`);
|
|
3272
|
+
if (await fileExists(filePath)) {
|
|
3273
|
+
spinner.stop();
|
|
3274
|
+
error(`Service "${kebabName}" already exists`);
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
await writeFile(filePath, serviceTemplate(kebabName, pascalName, camelName));
|
|
3278
|
+
spinner.succeed(`Service "${pascalName}Service" generated!`);
|
|
3279
|
+
success(` src/modules/${moduleName}/${kebabName}.service.ts`);
|
|
3280
|
+
} catch (err) {
|
|
3281
|
+
spinner.fail("Failed to generate service");
|
|
3282
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3283
|
+
}
|
|
3284
|
+
});
|
|
3285
|
+
generateCommand.command("repository <name>").alias("r").description("Generate a repository").option("-m, --module <module>", "Target module name").action(async (name, options) => {
|
|
3286
|
+
const spinner = ora3("Generating repository...").start();
|
|
3287
|
+
try {
|
|
3288
|
+
const kebabName = toKebabCase(name);
|
|
3289
|
+
const pascalName = toPascalCase(name);
|
|
3290
|
+
const camelName = toCamelCase(name);
|
|
3291
|
+
const pluralName = pluralize(kebabName);
|
|
3292
|
+
const moduleName = options.module ? toKebabCase(options.module) : kebabName;
|
|
3293
|
+
const moduleDir = path4.join(getModulesDir(), moduleName);
|
|
3294
|
+
const filePath = path4.join(moduleDir, `${kebabName}.repository.ts`);
|
|
3295
|
+
if (await fileExists(filePath)) {
|
|
3296
|
+
spinner.stop();
|
|
3297
|
+
error(`Repository "${kebabName}" already exists`);
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
await writeFile(filePath, repositoryTemplate(kebabName, pascalName, camelName, pluralName));
|
|
3301
|
+
spinner.succeed(`Repository "${pascalName}Repository" generated!`);
|
|
3302
|
+
success(` src/modules/${moduleName}/${kebabName}.repository.ts`);
|
|
3303
|
+
} catch (err) {
|
|
3304
|
+
spinner.fail("Failed to generate repository");
|
|
3305
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3306
|
+
}
|
|
3307
|
+
});
|
|
3308
|
+
generateCommand.command("types <name>").alias("t").description("Generate types/interfaces").option("-m, --module <module>", "Target module name").action(async (name, options) => {
|
|
3309
|
+
const spinner = ora3("Generating types...").start();
|
|
3310
|
+
try {
|
|
3311
|
+
const kebabName = toKebabCase(name);
|
|
3312
|
+
const pascalName = toPascalCase(name);
|
|
3313
|
+
const moduleName = options.module ? toKebabCase(options.module) : kebabName;
|
|
3314
|
+
const moduleDir = path4.join(getModulesDir(), moduleName);
|
|
3315
|
+
const filePath = path4.join(moduleDir, `${kebabName}.types.ts`);
|
|
3316
|
+
if (await fileExists(filePath)) {
|
|
3317
|
+
spinner.stop();
|
|
3318
|
+
error(`Types file "${kebabName}.types.ts" already exists`);
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
await writeFile(filePath, typesTemplate(kebabName, pascalName));
|
|
3322
|
+
spinner.succeed(`Types for "${pascalName}" generated!`);
|
|
3323
|
+
success(` src/modules/${moduleName}/${kebabName}.types.ts`);
|
|
3324
|
+
} catch (err) {
|
|
3325
|
+
spinner.fail("Failed to generate types");
|
|
3326
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3327
|
+
}
|
|
3328
|
+
});
|
|
3329
|
+
generateCommand.command("schema <name>").alias("v").description("Generate validation schemas").option("-m, --module <module>", "Target module name").action(async (name, options) => {
|
|
3330
|
+
const spinner = ora3("Generating schemas...").start();
|
|
3331
|
+
try {
|
|
3332
|
+
const kebabName = toKebabCase(name);
|
|
3333
|
+
const pascalName = toPascalCase(name);
|
|
3334
|
+
const camelName = toCamelCase(name);
|
|
3335
|
+
const moduleName = options.module ? toKebabCase(options.module) : kebabName;
|
|
3336
|
+
const moduleDir = path4.join(getModulesDir(), moduleName);
|
|
3337
|
+
const filePath = path4.join(moduleDir, `${kebabName}.schemas.ts`);
|
|
3338
|
+
if (await fileExists(filePath)) {
|
|
3339
|
+
spinner.stop();
|
|
3340
|
+
error(`Schemas file "${kebabName}.schemas.ts" already exists`);
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
await writeFile(filePath, schemasTemplate(kebabName, pascalName, camelName));
|
|
3344
|
+
spinner.succeed(`Schemas for "${pascalName}" generated!`);
|
|
3345
|
+
success(` src/modules/${moduleName}/${kebabName}.schemas.ts`);
|
|
3346
|
+
} catch (err) {
|
|
3347
|
+
spinner.fail("Failed to generate schemas");
|
|
3348
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3349
|
+
}
|
|
3350
|
+
});
|
|
3351
|
+
generateCommand.command("routes <name>").description("Generate routes").option("-m, --module <module>", "Target module name").action(async (name, options) => {
|
|
3352
|
+
const spinner = ora3("Generating routes...").start();
|
|
3353
|
+
try {
|
|
3354
|
+
const kebabName = toKebabCase(name);
|
|
3355
|
+
const pascalName = toPascalCase(name);
|
|
3356
|
+
const camelName = toCamelCase(name);
|
|
3357
|
+
const pluralName = pluralize(kebabName);
|
|
3358
|
+
const moduleName = options.module ? toKebabCase(options.module) : kebabName;
|
|
3359
|
+
const moduleDir = path4.join(getModulesDir(), moduleName);
|
|
3360
|
+
const filePath = path4.join(moduleDir, `${kebabName}.routes.ts`);
|
|
3361
|
+
if (await fileExists(filePath)) {
|
|
3362
|
+
spinner.stop();
|
|
3363
|
+
error(`Routes file "${kebabName}.routes.ts" already exists`);
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
await writeFile(filePath, routesTemplate(kebabName, pascalName, camelName, pluralName));
|
|
3367
|
+
spinner.succeed(`Routes for "${pascalName}" generated!`);
|
|
3368
|
+
success(` src/modules/${moduleName}/${kebabName}.routes.ts`);
|
|
3369
|
+
} catch (err) {
|
|
3370
|
+
spinner.fail("Failed to generate routes");
|
|
3371
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3372
|
+
}
|
|
3373
|
+
});
|
|
3374
|
+
async function promptForFields() {
|
|
3375
|
+
const fields = [];
|
|
3376
|
+
console.log("\n\u{1F4DD} Define your model fields (press Enter with empty name to finish)\n");
|
|
3377
|
+
const fieldTypes = [
|
|
3378
|
+
"string",
|
|
3379
|
+
"number",
|
|
3380
|
+
"boolean",
|
|
3381
|
+
"date",
|
|
3382
|
+
"datetime",
|
|
3383
|
+
"text",
|
|
3384
|
+
"email",
|
|
3385
|
+
"url",
|
|
3386
|
+
"uuid",
|
|
3387
|
+
"int",
|
|
3388
|
+
"float",
|
|
3389
|
+
"decimal",
|
|
3390
|
+
"json"
|
|
3391
|
+
];
|
|
3392
|
+
let addMore = true;
|
|
3393
|
+
while (addMore) {
|
|
3394
|
+
const answers = await inquirer2.prompt([
|
|
3395
|
+
{
|
|
3396
|
+
type: "input",
|
|
3397
|
+
name: "name",
|
|
3398
|
+
message: "Field name (empty to finish):"
|
|
3399
|
+
}
|
|
3400
|
+
]);
|
|
3401
|
+
if (!answers.name) {
|
|
3402
|
+
addMore = false;
|
|
3403
|
+
continue;
|
|
3404
|
+
}
|
|
3405
|
+
const fieldDetails = await inquirer2.prompt([
|
|
3406
|
+
{
|
|
3407
|
+
type: "list",
|
|
3408
|
+
name: "type",
|
|
3409
|
+
message: `Type for "${answers.name}":`,
|
|
3410
|
+
choices: fieldTypes,
|
|
3411
|
+
default: "string"
|
|
3412
|
+
},
|
|
3413
|
+
{
|
|
3414
|
+
type: "confirm",
|
|
3415
|
+
name: "isOptional",
|
|
3416
|
+
message: "Is optional?",
|
|
3417
|
+
default: false
|
|
3418
|
+
},
|
|
3419
|
+
{
|
|
3420
|
+
type: "confirm",
|
|
3421
|
+
name: "isUnique",
|
|
3422
|
+
message: "Is unique?",
|
|
3423
|
+
default: false
|
|
3424
|
+
},
|
|
3425
|
+
{
|
|
3426
|
+
type: "confirm",
|
|
3427
|
+
name: "isArray",
|
|
3428
|
+
message: "Is array?",
|
|
3429
|
+
default: false
|
|
3430
|
+
}
|
|
3431
|
+
]);
|
|
3432
|
+
fields.push({
|
|
3433
|
+
name: answers.name,
|
|
3434
|
+
type: fieldDetails.type,
|
|
3435
|
+
isOptional: fieldDetails.isOptional,
|
|
3436
|
+
isUnique: fieldDetails.isUnique,
|
|
3437
|
+
isArray: fieldDetails.isArray
|
|
3438
|
+
});
|
|
3439
|
+
console.log(` \u2713 Added: ${answers.name}: ${fieldDetails.type}
|
|
3440
|
+
`);
|
|
3441
|
+
}
|
|
3442
|
+
return fields;
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
// src/cli/commands/add-module.ts
|
|
3446
|
+
import { Command as Command3 } from "commander";
|
|
3447
|
+
import path5 from "path";
|
|
3448
|
+
import ora4 from "ora";
|
|
3449
|
+
import chalk3 from "chalk";
|
|
3450
|
+
var AVAILABLE_MODULES = {
|
|
3451
|
+
auth: {
|
|
3452
|
+
name: "Authentication",
|
|
3453
|
+
description: "JWT authentication with access/refresh tokens",
|
|
3454
|
+
files: ["auth.service", "auth.controller", "auth.routes", "auth.middleware", "auth.schemas", "auth.types", "index"]
|
|
3455
|
+
},
|
|
3456
|
+
users: {
|
|
3457
|
+
name: "User Management",
|
|
3458
|
+
description: "User CRUD with RBAC (roles & permissions)",
|
|
3459
|
+
files: ["user.service", "user.controller", "user.repository", "user.routes", "user.schemas", "user.types", "index"]
|
|
3460
|
+
},
|
|
3461
|
+
email: {
|
|
3462
|
+
name: "Email Service",
|
|
3463
|
+
description: "SMTP email with templates (Handlebars)",
|
|
3464
|
+
files: ["email.service", "email.templates", "email.types", "index"]
|
|
3465
|
+
},
|
|
3466
|
+
audit: {
|
|
3467
|
+
name: "Audit Logs",
|
|
3468
|
+
description: "Activity logging and audit trail",
|
|
3469
|
+
files: ["audit.service", "audit.types", "index"]
|
|
3470
|
+
},
|
|
3471
|
+
upload: {
|
|
3472
|
+
name: "File Upload",
|
|
3473
|
+
description: "File upload with local/S3 storage",
|
|
3474
|
+
files: ["upload.service", "upload.controller", "upload.routes", "upload.types", "index"]
|
|
3475
|
+
},
|
|
3476
|
+
cache: {
|
|
3477
|
+
name: "Redis Cache",
|
|
3478
|
+
description: "Redis caching service",
|
|
3479
|
+
files: ["cache.service", "cache.types", "index"]
|
|
3480
|
+
},
|
|
3481
|
+
notifications: {
|
|
3482
|
+
name: "Notifications",
|
|
3483
|
+
description: "In-app and push notifications",
|
|
3484
|
+
files: ["notification.service", "notification.types", "index"]
|
|
3485
|
+
},
|
|
3486
|
+
settings: {
|
|
3487
|
+
name: "Settings",
|
|
3488
|
+
description: "Application settings management",
|
|
3489
|
+
files: ["settings.service", "settings.controller", "settings.routes", "settings.types", "index"]
|
|
3490
|
+
}
|
|
3491
|
+
};
|
|
3492
|
+
var addModuleCommand = new Command3("add").description("Add a pre-built module to your project").argument("[module]", "Module to add (auth, users, email, audit, upload, cache, notifications, settings)").option("-l, --list", "List available modules").action(async (moduleName, options) => {
|
|
3493
|
+
if (options?.list || !moduleName) {
|
|
3494
|
+
console.log(chalk3.bold("\n\u{1F4E6} Available Modules:\n"));
|
|
3495
|
+
for (const [key, mod] of Object.entries(AVAILABLE_MODULES)) {
|
|
3496
|
+
console.log(` ${chalk3.cyan(key.padEnd(15))} ${mod.name}`);
|
|
3497
|
+
console.log(` ${" ".repeat(15)} ${chalk3.gray(mod.description)}
|
|
3498
|
+
`);
|
|
3499
|
+
}
|
|
3500
|
+
console.log(chalk3.bold("Usage:"));
|
|
3501
|
+
console.log(` ${chalk3.yellow("servcraft add auth")} Add authentication module`);
|
|
3502
|
+
console.log(` ${chalk3.yellow("servcraft add users")} Add user management module`);
|
|
3503
|
+
console.log(` ${chalk3.yellow("servcraft add email")} Add email service module
|
|
3504
|
+
`);
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
const module = AVAILABLE_MODULES[moduleName];
|
|
3508
|
+
if (!module) {
|
|
3509
|
+
error(`Unknown module: ${moduleName}`);
|
|
3510
|
+
info('Run "servcraft add --list" to see available modules');
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
const spinner = ora4(`Adding ${module.name} module...`).start();
|
|
3514
|
+
try {
|
|
3515
|
+
const moduleDir = path5.join(getModulesDir(), moduleName);
|
|
3516
|
+
if (await fileExists(moduleDir)) {
|
|
3517
|
+
spinner.stop();
|
|
3518
|
+
warn(`Module "${moduleName}" already exists`);
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
await ensureDir(moduleDir);
|
|
3522
|
+
switch (moduleName) {
|
|
3523
|
+
case "auth":
|
|
3524
|
+
await generateAuthModule(moduleDir);
|
|
3525
|
+
break;
|
|
3526
|
+
case "users":
|
|
3527
|
+
await generateUsersModule(moduleDir);
|
|
3528
|
+
break;
|
|
3529
|
+
case "email":
|
|
3530
|
+
await generateEmailModule(moduleDir);
|
|
3531
|
+
break;
|
|
3532
|
+
case "audit":
|
|
3533
|
+
await generateAuditModule(moduleDir);
|
|
3534
|
+
break;
|
|
3535
|
+
case "upload":
|
|
3536
|
+
await generateUploadModule(moduleDir);
|
|
3537
|
+
break;
|
|
3538
|
+
case "cache":
|
|
3539
|
+
await generateCacheModule(moduleDir);
|
|
3540
|
+
break;
|
|
3541
|
+
default:
|
|
3542
|
+
await generateGenericModule(moduleDir, moduleName);
|
|
3543
|
+
}
|
|
3544
|
+
spinner.succeed(`${module.name} module added successfully!`);
|
|
3545
|
+
console.log("\n\u{1F4C1} Files created:");
|
|
3546
|
+
module.files.forEach((f) => success(` src/modules/${moduleName}/${f}.ts`));
|
|
3547
|
+
console.log("\n\u{1F4CC} Next steps:");
|
|
3548
|
+
info(" 1. Register the module in your main app file");
|
|
3549
|
+
info(" 2. Configure any required environment variables");
|
|
3550
|
+
info(" 3. Run database migrations if needed");
|
|
3551
|
+
} catch (err) {
|
|
3552
|
+
spinner.fail("Failed to add module");
|
|
3553
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3554
|
+
}
|
|
3555
|
+
});
|
|
3556
|
+
async function generateAuthModule(dir) {
|
|
3557
|
+
const files = {
|
|
3558
|
+
"auth.types.ts": `export interface JwtPayload {
|
|
3559
|
+
sub: string;
|
|
3560
|
+
email: string;
|
|
3561
|
+
role: string;
|
|
3562
|
+
type: 'access' | 'refresh';
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
export interface TokenPair {
|
|
3566
|
+
accessToken: string;
|
|
3567
|
+
refreshToken: string;
|
|
3568
|
+
expiresIn: number;
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
export interface AuthUser {
|
|
3572
|
+
id: string;
|
|
3573
|
+
email: string;
|
|
3574
|
+
role: string;
|
|
3575
|
+
}
|
|
3576
|
+
`,
|
|
3577
|
+
"auth.schemas.ts": `import { z } from 'zod';
|
|
3578
|
+
|
|
3579
|
+
export const loginSchema = z.object({
|
|
3580
|
+
email: z.string().email(),
|
|
3581
|
+
password: z.string().min(1),
|
|
3582
|
+
});
|
|
3583
|
+
|
|
3584
|
+
export const registerSchema = z.object({
|
|
3585
|
+
email: z.string().email(),
|
|
3586
|
+
password: z.string().min(8),
|
|
3587
|
+
name: z.string().min(2).optional(),
|
|
3588
|
+
});
|
|
3589
|
+
|
|
3590
|
+
export const refreshTokenSchema = z.object({
|
|
3591
|
+
refreshToken: z.string().min(1),
|
|
3592
|
+
});
|
|
3593
|
+
`,
|
|
3594
|
+
"index.ts": `export * from './auth.types.js';
|
|
3595
|
+
export * from './auth.schemas.js';
|
|
3596
|
+
// Export services, controllers, etc.
|
|
3597
|
+
`
|
|
3598
|
+
};
|
|
3599
|
+
for (const [name, content] of Object.entries(files)) {
|
|
3600
|
+
await writeFile(path5.join(dir, name), content);
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
async function generateUsersModule(dir) {
|
|
3604
|
+
const files = {
|
|
3605
|
+
"user.types.ts": `export type UserStatus = 'active' | 'inactive' | 'suspended' | 'banned';
|
|
3606
|
+
export type UserRole = 'user' | 'admin' | 'moderator' | 'super_admin';
|
|
3607
|
+
|
|
3608
|
+
export interface User {
|
|
3609
|
+
id: string;
|
|
3610
|
+
email: string;
|
|
3611
|
+
password: string;
|
|
3612
|
+
name?: string;
|
|
3613
|
+
role: UserRole;
|
|
3614
|
+
status: UserStatus;
|
|
3615
|
+
createdAt: Date;
|
|
3616
|
+
updatedAt: Date;
|
|
3617
|
+
}
|
|
3618
|
+
`,
|
|
3619
|
+
"user.schemas.ts": `import { z } from 'zod';
|
|
3620
|
+
|
|
3621
|
+
export const createUserSchema = z.object({
|
|
3622
|
+
email: z.string().email(),
|
|
3623
|
+
password: z.string().min(8),
|
|
3624
|
+
name: z.string().min(2).optional(),
|
|
3625
|
+
role: z.enum(['user', 'admin', 'moderator']).optional(),
|
|
3626
|
+
});
|
|
3627
|
+
|
|
3628
|
+
export const updateUserSchema = z.object({
|
|
3629
|
+
email: z.string().email().optional(),
|
|
3630
|
+
name: z.string().min(2).optional(),
|
|
3631
|
+
role: z.enum(['user', 'admin', 'moderator', 'super_admin']).optional(),
|
|
3632
|
+
status: z.enum(['active', 'inactive', 'suspended', 'banned']).optional(),
|
|
3633
|
+
});
|
|
3634
|
+
`,
|
|
3635
|
+
"index.ts": `export * from './user.types.js';
|
|
3636
|
+
export * from './user.schemas.js';
|
|
3637
|
+
`
|
|
3638
|
+
};
|
|
3639
|
+
for (const [name, content] of Object.entries(files)) {
|
|
3640
|
+
await writeFile(path5.join(dir, name), content);
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
async function generateEmailModule(dir) {
|
|
3644
|
+
const files = {
|
|
3645
|
+
"email.types.ts": `export interface EmailOptions {
|
|
3646
|
+
to: string | string[];
|
|
3647
|
+
subject: string;
|
|
3648
|
+
html?: string;
|
|
3649
|
+
text?: string;
|
|
3650
|
+
template?: string;
|
|
3651
|
+
data?: Record<string, unknown>;
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
export interface EmailResult {
|
|
3655
|
+
success: boolean;
|
|
3656
|
+
messageId?: string;
|
|
3657
|
+
error?: string;
|
|
3658
|
+
}
|
|
3659
|
+
`,
|
|
3660
|
+
"email.service.ts": `import nodemailer from 'nodemailer';
|
|
3661
|
+
import type { EmailOptions, EmailResult } from './email.types.js';
|
|
3662
|
+
|
|
3663
|
+
export class EmailService {
|
|
3664
|
+
private transporter;
|
|
3665
|
+
|
|
3666
|
+
constructor() {
|
|
3667
|
+
this.transporter = nodemailer.createTransport({
|
|
3668
|
+
host: process.env.SMTP_HOST,
|
|
3669
|
+
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
|
3670
|
+
auth: {
|
|
3671
|
+
user: process.env.SMTP_USER,
|
|
3672
|
+
pass: process.env.SMTP_PASS,
|
|
3673
|
+
},
|
|
3674
|
+
});
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
async send(options: EmailOptions): Promise<EmailResult> {
|
|
3678
|
+
try {
|
|
3679
|
+
const result = await this.transporter.sendMail({
|
|
3680
|
+
from: process.env.SMTP_FROM,
|
|
3681
|
+
...options,
|
|
3682
|
+
});
|
|
3683
|
+
return { success: true, messageId: result.messageId };
|
|
3684
|
+
} catch (error) {
|
|
3685
|
+
return { success: false, error: String(error) };
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
export const emailService = new EmailService();
|
|
3691
|
+
`,
|
|
3692
|
+
"index.ts": `export * from './email.types.js';
|
|
3693
|
+
export { EmailService, emailService } from './email.service.js';
|
|
3694
|
+
`
|
|
3695
|
+
};
|
|
3696
|
+
for (const [name, content] of Object.entries(files)) {
|
|
3697
|
+
await writeFile(path5.join(dir, name), content);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
async function generateAuditModule(dir) {
|
|
3701
|
+
const files = {
|
|
3702
|
+
"audit.types.ts": `export interface AuditLogEntry {
|
|
3703
|
+
userId?: string;
|
|
3704
|
+
action: string;
|
|
3705
|
+
resource: string;
|
|
3706
|
+
resourceId?: string;
|
|
3707
|
+
oldValue?: Record<string, unknown>;
|
|
3708
|
+
newValue?: Record<string, unknown>;
|
|
3709
|
+
ipAddress?: string;
|
|
3710
|
+
userAgent?: string;
|
|
3711
|
+
createdAt: Date;
|
|
3712
|
+
}
|
|
3713
|
+
`,
|
|
3714
|
+
"audit.service.ts": `import type { AuditLogEntry } from './audit.types.js';
|
|
3715
|
+
|
|
3716
|
+
const logs: AuditLogEntry[] = [];
|
|
3717
|
+
|
|
3718
|
+
export class AuditService {
|
|
3719
|
+
async log(entry: Omit<AuditLogEntry, 'createdAt'>): Promise<void> {
|
|
3720
|
+
logs.push({ ...entry, createdAt: new Date() });
|
|
3721
|
+
console.log('[AUDIT]', entry.action, entry.resource);
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
async query(filters: Partial<AuditLogEntry>): Promise<AuditLogEntry[]> {
|
|
3725
|
+
return logs.filter((log) => {
|
|
3726
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
3727
|
+
if (log[key as keyof AuditLogEntry] !== value) return false;
|
|
3728
|
+
}
|
|
3729
|
+
return true;
|
|
3730
|
+
});
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
export const auditService = new AuditService();
|
|
3735
|
+
`,
|
|
3736
|
+
"index.ts": `export * from './audit.types.js';
|
|
3737
|
+
export { AuditService, auditService } from './audit.service.js';
|
|
3738
|
+
`
|
|
3739
|
+
};
|
|
3740
|
+
for (const [name, content] of Object.entries(files)) {
|
|
3741
|
+
await writeFile(path5.join(dir, name), content);
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
async function generateUploadModule(dir) {
|
|
3745
|
+
const files = {
|
|
3746
|
+
"upload.types.ts": `export interface UploadedFile {
|
|
3747
|
+
id: string;
|
|
3748
|
+
filename: string;
|
|
3749
|
+
originalName: string;
|
|
3750
|
+
mimetype: string;
|
|
3751
|
+
size: number;
|
|
3752
|
+
path: string;
|
|
3753
|
+
url: string;
|
|
3754
|
+
createdAt: Date;
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
export interface UploadOptions {
|
|
3758
|
+
maxSize?: number;
|
|
3759
|
+
allowedTypes?: string[];
|
|
3760
|
+
destination?: string;
|
|
3761
|
+
}
|
|
3762
|
+
`,
|
|
3763
|
+
"index.ts": `export * from './upload.types.js';
|
|
3764
|
+
`
|
|
3765
|
+
};
|
|
3766
|
+
for (const [name, content] of Object.entries(files)) {
|
|
3767
|
+
await writeFile(path5.join(dir, name), content);
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
async function generateCacheModule(dir) {
|
|
3771
|
+
const files = {
|
|
3772
|
+
"cache.types.ts": `export interface CacheOptions {
|
|
3773
|
+
ttl?: number;
|
|
3774
|
+
prefix?: string;
|
|
3775
|
+
}
|
|
3776
|
+
`,
|
|
3777
|
+
"cache.service.ts": `import type { CacheOptions } from './cache.types.js';
|
|
3778
|
+
|
|
3779
|
+
// In-memory cache (replace with Redis in production)
|
|
3780
|
+
const cache = new Map<string, { value: unknown; expiry: number }>();
|
|
3781
|
+
|
|
3782
|
+
export class CacheService {
|
|
3783
|
+
async get<T>(key: string): Promise<T | null> {
|
|
3784
|
+
const item = cache.get(key);
|
|
3785
|
+
if (!item) return null;
|
|
3786
|
+
if (Date.now() > item.expiry) {
|
|
3787
|
+
cache.delete(key);
|
|
3788
|
+
return null;
|
|
3789
|
+
}
|
|
3790
|
+
return item.value as T;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
async set(key: string, value: unknown, ttl = 3600): Promise<void> {
|
|
3794
|
+
cache.set(key, { value, expiry: Date.now() + ttl * 1000 });
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
async del(key: string): Promise<void> {
|
|
3798
|
+
cache.delete(key);
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
async clear(): Promise<void> {
|
|
3802
|
+
cache.clear();
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
export const cacheService = new CacheService();
|
|
3807
|
+
`,
|
|
3808
|
+
"index.ts": `export * from './cache.types.js';
|
|
3809
|
+
export { CacheService, cacheService } from './cache.service.js';
|
|
3810
|
+
`
|
|
3811
|
+
};
|
|
3812
|
+
for (const [name, content] of Object.entries(files)) {
|
|
3813
|
+
await writeFile(path5.join(dir, name), content);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
async function generateGenericModule(dir, name) {
|
|
3817
|
+
const files = {
|
|
3818
|
+
[`${name}.types.ts`]: `// ${name} types
|
|
3819
|
+
export interface ${name.charAt(0).toUpperCase() + name.slice(1)}Data {
|
|
3820
|
+
// Define your types here
|
|
3821
|
+
}
|
|
3822
|
+
`,
|
|
3823
|
+
"index.ts": `export * from './${name}.types.js';
|
|
3824
|
+
`
|
|
3825
|
+
};
|
|
3826
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
3827
|
+
await writeFile(path5.join(dir, fileName), content);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
// src/cli/commands/db.ts
|
|
3832
|
+
import { Command as Command4 } from "commander";
|
|
3833
|
+
import { execSync as execSync2, spawn } from "child_process";
|
|
3834
|
+
import ora5 from "ora";
|
|
3835
|
+
import chalk4 from "chalk";
|
|
3836
|
+
var dbCommand = new Command4("db").description("Database management commands");
|
|
3837
|
+
dbCommand.command("migrate").description("Run database migrations").option("-n, --name <name>", "Migration name").action(async (options) => {
|
|
3838
|
+
const spinner = ora5("Running migrations...").start();
|
|
3839
|
+
try {
|
|
3840
|
+
const cmd = options.name ? `npx prisma migrate dev --name ${options.name}` : "npx prisma migrate dev";
|
|
3841
|
+
execSync2(cmd, { stdio: "inherit" });
|
|
3842
|
+
spinner.succeed("Migrations completed!");
|
|
3843
|
+
} catch (err) {
|
|
3844
|
+
spinner.fail("Migration failed");
|
|
3845
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3846
|
+
}
|
|
3847
|
+
});
|
|
3848
|
+
dbCommand.command("push").description("Push schema changes to database (no migration)").action(async () => {
|
|
3849
|
+
const spinner = ora5("Pushing schema...").start();
|
|
3850
|
+
try {
|
|
3851
|
+
execSync2("npx prisma db push", { stdio: "inherit" });
|
|
3852
|
+
spinner.succeed("Schema pushed successfully!");
|
|
3853
|
+
} catch (err) {
|
|
3854
|
+
spinner.fail("Push failed");
|
|
3855
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3856
|
+
}
|
|
3857
|
+
});
|
|
3858
|
+
dbCommand.command("generate").description("Generate Prisma client").action(async () => {
|
|
3859
|
+
const spinner = ora5("Generating Prisma client...").start();
|
|
3860
|
+
try {
|
|
3861
|
+
execSync2("npx prisma generate", { stdio: "inherit" });
|
|
3862
|
+
spinner.succeed("Prisma client generated!");
|
|
3863
|
+
} catch (err) {
|
|
3864
|
+
spinner.fail("Generation failed");
|
|
3865
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3866
|
+
}
|
|
3867
|
+
});
|
|
3868
|
+
dbCommand.command("studio").description("Open Prisma Studio").action(async () => {
|
|
3869
|
+
info("Opening Prisma Studio...");
|
|
3870
|
+
const studio = spawn("npx", ["prisma", "studio"], {
|
|
3871
|
+
stdio: "inherit",
|
|
3872
|
+
shell: true
|
|
3873
|
+
});
|
|
3874
|
+
studio.on("close", (code) => {
|
|
3875
|
+
if (code !== 0) {
|
|
3876
|
+
error("Prisma Studio closed with error");
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
});
|
|
3880
|
+
dbCommand.command("seed").description("Run database seed").action(async () => {
|
|
3881
|
+
const spinner = ora5("Seeding database...").start();
|
|
3882
|
+
try {
|
|
3883
|
+
execSync2("npx prisma db seed", { stdio: "inherit" });
|
|
3884
|
+
spinner.succeed("Database seeded!");
|
|
3885
|
+
} catch (err) {
|
|
3886
|
+
spinner.fail("Seeding failed");
|
|
3887
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3888
|
+
}
|
|
3889
|
+
});
|
|
3890
|
+
dbCommand.command("reset").description("Reset database (drop all data and re-run migrations)").option("-f, --force", "Skip confirmation").action(async (options) => {
|
|
3891
|
+
if (!options.force) {
|
|
3892
|
+
console.log(chalk4.yellow("\n\u26A0\uFE0F WARNING: This will delete all data in your database!\n"));
|
|
3893
|
+
const readline = await import("readline");
|
|
3894
|
+
const rl = readline.createInterface({
|
|
3895
|
+
input: process.stdin,
|
|
3896
|
+
output: process.stdout
|
|
3897
|
+
});
|
|
3898
|
+
const answer = await new Promise((resolve) => {
|
|
3899
|
+
rl.question("Are you sure you want to continue? (y/N) ", resolve);
|
|
3900
|
+
});
|
|
3901
|
+
rl.close();
|
|
3902
|
+
if (answer.toLowerCase() !== "y") {
|
|
3903
|
+
info("Operation cancelled");
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
const spinner = ora5("Resetting database...").start();
|
|
3908
|
+
try {
|
|
3909
|
+
execSync2("npx prisma migrate reset --force", { stdio: "inherit" });
|
|
3910
|
+
spinner.succeed("Database reset completed!");
|
|
3911
|
+
} catch (err) {
|
|
3912
|
+
spinner.fail("Reset failed");
|
|
3913
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
dbCommand.command("status").description("Show migration status").action(async () => {
|
|
3917
|
+
try {
|
|
3918
|
+
execSync2("npx prisma migrate status", { stdio: "inherit" });
|
|
3919
|
+
} catch (err) {
|
|
3920
|
+
error("Failed to get migration status");
|
|
3921
|
+
}
|
|
3922
|
+
});
|
|
3923
|
+
|
|
3924
|
+
// src/cli/commands/docs.ts
|
|
3925
|
+
import { Command as Command5 } from "commander";
|
|
3926
|
+
var docsCommand = new Command5("docs").description("Generate Swagger/OpenAPI documentation").option("-o, --output <path>", "Output file path", "openapi.json").action(async (options) => {
|
|
3927
|
+
try {
|
|
3928
|
+
const outputPath = await generateDocs(options.output);
|
|
3929
|
+
success(`Documentation written to ${outputPath}`);
|
|
3930
|
+
} catch (err) {
|
|
3931
|
+
error(err instanceof Error ? err.message : String(err));
|
|
3932
|
+
process.exitCode = 1;
|
|
3933
|
+
}
|
|
3934
|
+
});
|
|
3935
|
+
|
|
3936
|
+
// src/cli/index.ts
|
|
3937
|
+
var program = new Command6();
|
|
3938
|
+
program.name("servcraft").description("Servcraft - A modular Node.js backend framework CLI").version("0.1.0");
|
|
3939
|
+
program.addCommand(initCommand);
|
|
3940
|
+
program.addCommand(generateCommand);
|
|
3941
|
+
program.addCommand(addModuleCommand);
|
|
3942
|
+
program.addCommand(dbCommand);
|
|
3943
|
+
program.addCommand(docsCommand);
|
|
3944
|
+
program.parse();
|
|
3945
|
+
//# sourceMappingURL=index.js.map
|