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
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2458 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
AppError: () => AppError,
|
|
34
|
+
AuditService: () => AuditService,
|
|
35
|
+
AuthController: () => AuthController,
|
|
36
|
+
AuthService: () => AuthService,
|
|
37
|
+
BadRequestError: () => BadRequestError,
|
|
38
|
+
ConflictError: () => ConflictError,
|
|
39
|
+
DEFAULT_LIMIT: () => DEFAULT_LIMIT,
|
|
40
|
+
DEFAULT_PAGE: () => DEFAULT_PAGE,
|
|
41
|
+
DEFAULT_ROLE_PERMISSIONS: () => DEFAULT_ROLE_PERMISSIONS,
|
|
42
|
+
EmailService: () => EmailService,
|
|
43
|
+
ForbiddenError: () => ForbiddenError,
|
|
44
|
+
MAX_LIMIT: () => MAX_LIMIT,
|
|
45
|
+
NotFoundError: () => NotFoundError,
|
|
46
|
+
Server: () => Server,
|
|
47
|
+
TooManyRequestsError: () => TooManyRequestsError,
|
|
48
|
+
UnauthorizedError: () => UnauthorizedError,
|
|
49
|
+
UserController: () => UserController,
|
|
50
|
+
UserRepository: () => UserRepository,
|
|
51
|
+
UserService: () => UserService,
|
|
52
|
+
ValidationError: () => ValidationError,
|
|
53
|
+
badRequest: () => badRequest,
|
|
54
|
+
changePasswordSchema: () => changePasswordSchema,
|
|
55
|
+
config: () => config,
|
|
56
|
+
conflict: () => conflict,
|
|
57
|
+
createAuditService: () => createAuditService,
|
|
58
|
+
createAuthController: () => createAuthController,
|
|
59
|
+
createAuthMiddleware: () => createAuthMiddleware,
|
|
60
|
+
createAuthService: () => createAuthService,
|
|
61
|
+
createConfig: () => createConfig,
|
|
62
|
+
createEmailService: () => createEmailService,
|
|
63
|
+
createLogger: () => createLogger,
|
|
64
|
+
createOptionalAuthMiddleware: () => createOptionalAuthMiddleware,
|
|
65
|
+
createPaginatedResult: () => createPaginatedResult,
|
|
66
|
+
createPermissionMiddleware: () => createPermissionMiddleware,
|
|
67
|
+
createRoleMiddleware: () => createRoleMiddleware,
|
|
68
|
+
createServer: () => createServer,
|
|
69
|
+
createUserController: () => createUserController,
|
|
70
|
+
createUserRepository: () => createUserRepository,
|
|
71
|
+
createUserSchema: () => createUserSchema,
|
|
72
|
+
createUserService: () => createUserService,
|
|
73
|
+
created: () => created,
|
|
74
|
+
dateSchema: () => dateSchema,
|
|
75
|
+
emailSchema: () => emailSchema,
|
|
76
|
+
env: () => env,
|
|
77
|
+
error: () => error,
|
|
78
|
+
forbidden: () => forbidden,
|
|
79
|
+
futureDateSchema: () => futureDateSchema,
|
|
80
|
+
getAuditService: () => getAuditService,
|
|
81
|
+
getEmailService: () => getEmailService,
|
|
82
|
+
getSkip: () => getSkip,
|
|
83
|
+
idParamSchema: () => idParamSchema,
|
|
84
|
+
internalError: () => internalError,
|
|
85
|
+
isAppError: () => isAppError,
|
|
86
|
+
isDevelopment: () => isDevelopment,
|
|
87
|
+
isProduction: () => isProduction,
|
|
88
|
+
isStaging: () => isStaging,
|
|
89
|
+
isTest: () => isTest,
|
|
90
|
+
logger: () => logger,
|
|
91
|
+
loginSchema: () => loginSchema,
|
|
92
|
+
noContent: () => noContent,
|
|
93
|
+
notFound: () => notFound,
|
|
94
|
+
paginationSchema: () => paginationSchema,
|
|
95
|
+
parsePaginationParams: () => parsePaginationParams,
|
|
96
|
+
passwordResetConfirmSchema: () => passwordResetConfirmSchema,
|
|
97
|
+
passwordResetRequestSchema: () => passwordResetRequestSchema,
|
|
98
|
+
passwordSchema: () => passwordSchema,
|
|
99
|
+
pastDateSchema: () => pastDateSchema,
|
|
100
|
+
phoneSchema: () => phoneSchema,
|
|
101
|
+
refreshTokenSchema: () => refreshTokenSchema,
|
|
102
|
+
registerAuthModule: () => registerAuthModule,
|
|
103
|
+
registerBruteForceProtection: () => registerBruteForceProtection,
|
|
104
|
+
registerErrorHandler: () => registerErrorHandler,
|
|
105
|
+
registerSchema: () => registerSchema,
|
|
106
|
+
registerSecurity: () => registerSecurity,
|
|
107
|
+
registerUserModule: () => registerUserModule,
|
|
108
|
+
renderCustomTemplate: () => renderCustomTemplate,
|
|
109
|
+
renderTemplate: () => renderTemplate,
|
|
110
|
+
searchSchema: () => searchSchema,
|
|
111
|
+
success: () => success,
|
|
112
|
+
unauthorized: () => unauthorized,
|
|
113
|
+
updateProfileSchema: () => updateProfileSchema,
|
|
114
|
+
updateUserSchema: () => updateUserSchema,
|
|
115
|
+
urlSchema: () => urlSchema,
|
|
116
|
+
userQuerySchema: () => userQuerySchema,
|
|
117
|
+
userRoleEnum: () => userRoleEnum,
|
|
118
|
+
userStatusEnum: () => userStatusEnum,
|
|
119
|
+
validate: () => validate,
|
|
120
|
+
validateBody: () => validateBody,
|
|
121
|
+
validateParams: () => validateParams,
|
|
122
|
+
validateQuery: () => validateQuery
|
|
123
|
+
});
|
|
124
|
+
module.exports = __toCommonJS(index_exports);
|
|
125
|
+
|
|
126
|
+
// src/core/server.ts
|
|
127
|
+
var import_fastify = __toESM(require("fastify"), 1);
|
|
128
|
+
|
|
129
|
+
// src/core/logger.ts
|
|
130
|
+
var import_pino = __toESM(require("pino"), 1);
|
|
131
|
+
var defaultConfig = {
|
|
132
|
+
level: process.env.LOG_LEVEL || "info",
|
|
133
|
+
pretty: process.env.NODE_ENV !== "production",
|
|
134
|
+
name: "servcraft"
|
|
135
|
+
};
|
|
136
|
+
function createLogger(config2 = {}) {
|
|
137
|
+
const mergedConfig = { ...defaultConfig, ...config2 };
|
|
138
|
+
const transport = mergedConfig.pretty ? {
|
|
139
|
+
target: "pino-pretty",
|
|
140
|
+
options: {
|
|
141
|
+
colorize: true,
|
|
142
|
+
translateTime: "SYS:standard",
|
|
143
|
+
ignore: "pid,hostname"
|
|
144
|
+
}
|
|
145
|
+
} : void 0;
|
|
146
|
+
return (0, import_pino.default)({
|
|
147
|
+
name: mergedConfig.name,
|
|
148
|
+
level: mergedConfig.level,
|
|
149
|
+
transport,
|
|
150
|
+
formatters: {
|
|
151
|
+
level: (label) => ({ level: label })
|
|
152
|
+
},
|
|
153
|
+
timestamp: import_pino.default.stdTimeFunctions.isoTime
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
var logger = createLogger();
|
|
157
|
+
|
|
158
|
+
// src/core/server.ts
|
|
159
|
+
var defaultConfig2 = {
|
|
160
|
+
port: parseInt(process.env.PORT || "3000", 10),
|
|
161
|
+
host: process.env.HOST || "0.0.0.0",
|
|
162
|
+
trustProxy: true,
|
|
163
|
+
bodyLimit: 1048576,
|
|
164
|
+
// 1MB
|
|
165
|
+
requestTimeout: 3e4
|
|
166
|
+
// 30s
|
|
167
|
+
};
|
|
168
|
+
var Server = class {
|
|
169
|
+
app;
|
|
170
|
+
config;
|
|
171
|
+
logger;
|
|
172
|
+
isShuttingDown = false;
|
|
173
|
+
constructor(config2 = {}) {
|
|
174
|
+
this.config = { ...defaultConfig2, ...config2 };
|
|
175
|
+
this.logger = this.config.logger || logger;
|
|
176
|
+
const fastifyOptions = {
|
|
177
|
+
logger: this.logger,
|
|
178
|
+
trustProxy: this.config.trustProxy,
|
|
179
|
+
bodyLimit: this.config.bodyLimit,
|
|
180
|
+
requestTimeout: this.config.requestTimeout
|
|
181
|
+
};
|
|
182
|
+
this.app = (0, import_fastify.default)(fastifyOptions);
|
|
183
|
+
this.setupHealthCheck();
|
|
184
|
+
this.setupGracefulShutdown();
|
|
185
|
+
}
|
|
186
|
+
get instance() {
|
|
187
|
+
return this.app;
|
|
188
|
+
}
|
|
189
|
+
setupHealthCheck() {
|
|
190
|
+
this.app.get("/health", async (_request, reply) => {
|
|
191
|
+
const healthcheck = {
|
|
192
|
+
status: "ok",
|
|
193
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
194
|
+
uptime: process.uptime(),
|
|
195
|
+
memory: process.memoryUsage(),
|
|
196
|
+
version: process.env.npm_package_version || "0.1.0"
|
|
197
|
+
};
|
|
198
|
+
return reply.status(200).send(healthcheck);
|
|
199
|
+
});
|
|
200
|
+
this.app.get("/ready", async (_request, reply) => {
|
|
201
|
+
if (this.isShuttingDown) {
|
|
202
|
+
return reply.status(503).send({ status: "shutting_down" });
|
|
203
|
+
}
|
|
204
|
+
return reply.status(200).send({ status: "ready" });
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
setupGracefulShutdown() {
|
|
208
|
+
const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
|
|
209
|
+
signals.forEach((signal) => {
|
|
210
|
+
process.on(signal, async () => {
|
|
211
|
+
this.logger.info(`Received ${signal}, starting graceful shutdown...`);
|
|
212
|
+
await this.shutdown();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
process.on("uncaughtException", async (error2) => {
|
|
216
|
+
this.logger.error({ err: error2 }, "Uncaught exception");
|
|
217
|
+
await this.shutdown(1);
|
|
218
|
+
});
|
|
219
|
+
process.on("unhandledRejection", async (reason) => {
|
|
220
|
+
this.logger.error({ err: reason }, "Unhandled rejection");
|
|
221
|
+
await this.shutdown(1);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async shutdown(exitCode = 0) {
|
|
225
|
+
if (this.isShuttingDown) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.isShuttingDown = true;
|
|
229
|
+
this.logger.info("Graceful shutdown initiated...");
|
|
230
|
+
const shutdownTimeout = setTimeout(() => {
|
|
231
|
+
this.logger.error("Graceful shutdown timeout, forcing exit");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}, 3e4);
|
|
234
|
+
try {
|
|
235
|
+
await this.app.close();
|
|
236
|
+
this.logger.info("Server closed successfully");
|
|
237
|
+
clearTimeout(shutdownTimeout);
|
|
238
|
+
process.exit(exitCode);
|
|
239
|
+
} catch (error2) {
|
|
240
|
+
this.logger.error({ err: error2 }, "Error during shutdown");
|
|
241
|
+
clearTimeout(shutdownTimeout);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async start() {
|
|
246
|
+
try {
|
|
247
|
+
await this.app.listen({
|
|
248
|
+
port: this.config.port,
|
|
249
|
+
host: this.config.host
|
|
250
|
+
});
|
|
251
|
+
this.logger.info(`Server listening on ${this.config.host}:${this.config.port}`);
|
|
252
|
+
} catch (error2) {
|
|
253
|
+
this.logger.error({ err: error2 }, "Failed to start server");
|
|
254
|
+
throw error2;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function createServer(config2 = {}) {
|
|
259
|
+
return new Server(config2);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/config/env.ts
|
|
263
|
+
var import_zod = require("zod");
|
|
264
|
+
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
265
|
+
import_dotenv.default.config();
|
|
266
|
+
var envSchema = import_zod.z.object({
|
|
267
|
+
// Server
|
|
268
|
+
NODE_ENV: import_zod.z.enum(["development", "staging", "production", "test"]).default("development"),
|
|
269
|
+
PORT: import_zod.z.string().transform(Number).default("3000"),
|
|
270
|
+
HOST: import_zod.z.string().default("0.0.0.0"),
|
|
271
|
+
// Database
|
|
272
|
+
DATABASE_URL: import_zod.z.string().optional(),
|
|
273
|
+
// JWT
|
|
274
|
+
JWT_SECRET: import_zod.z.string().min(32).optional(),
|
|
275
|
+
JWT_ACCESS_EXPIRES_IN: import_zod.z.string().default("15m"),
|
|
276
|
+
JWT_REFRESH_EXPIRES_IN: import_zod.z.string().default("7d"),
|
|
277
|
+
// Security
|
|
278
|
+
CORS_ORIGIN: import_zod.z.string().default("*"),
|
|
279
|
+
RATE_LIMIT_MAX: import_zod.z.string().transform(Number).default("100"),
|
|
280
|
+
RATE_LIMIT_WINDOW_MS: import_zod.z.string().transform(Number).default("60000"),
|
|
281
|
+
// Email
|
|
282
|
+
SMTP_HOST: import_zod.z.string().optional(),
|
|
283
|
+
SMTP_PORT: import_zod.z.string().transform(Number).optional(),
|
|
284
|
+
SMTP_USER: import_zod.z.string().optional(),
|
|
285
|
+
SMTP_PASS: import_zod.z.string().optional(),
|
|
286
|
+
SMTP_FROM: import_zod.z.string().optional(),
|
|
287
|
+
// Redis (optional)
|
|
288
|
+
REDIS_URL: import_zod.z.string().optional(),
|
|
289
|
+
// Swagger/OpenAPI
|
|
290
|
+
SWAGGER_ENABLED: import_zod.z.union([import_zod.z.literal("true"), import_zod.z.literal("false")]).default("true").transform((val) => val === "true"),
|
|
291
|
+
SWAGGER_ROUTE: import_zod.z.string().default("/docs"),
|
|
292
|
+
SWAGGER_TITLE: import_zod.z.string().default("Servcraft API"),
|
|
293
|
+
SWAGGER_DESCRIPTION: import_zod.z.string().default("API documentation"),
|
|
294
|
+
SWAGGER_VERSION: import_zod.z.string().default("1.0.0"),
|
|
295
|
+
// Logging
|
|
296
|
+
LOG_LEVEL: import_zod.z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info")
|
|
297
|
+
});
|
|
298
|
+
function validateEnv() {
|
|
299
|
+
const parsed = envSchema.safeParse(process.env);
|
|
300
|
+
if (!parsed.success) {
|
|
301
|
+
logger.error({ errors: parsed.error.flatten().fieldErrors }, "Invalid environment variables");
|
|
302
|
+
throw new Error("Invalid environment variables");
|
|
303
|
+
}
|
|
304
|
+
return parsed.data;
|
|
305
|
+
}
|
|
306
|
+
var env = validateEnv();
|
|
307
|
+
function isDevelopment() {
|
|
308
|
+
return env.NODE_ENV === "development";
|
|
309
|
+
}
|
|
310
|
+
function isProduction() {
|
|
311
|
+
return env.NODE_ENV === "production";
|
|
312
|
+
}
|
|
313
|
+
function isTest() {
|
|
314
|
+
return env.NODE_ENV === "test";
|
|
315
|
+
}
|
|
316
|
+
function isStaging() {
|
|
317
|
+
return env.NODE_ENV === "staging";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/config/index.ts
|
|
321
|
+
function parseCorsOrigin(origin) {
|
|
322
|
+
if (origin === "*") return "*";
|
|
323
|
+
if (origin.includes(",")) {
|
|
324
|
+
return origin.split(",").map((o) => o.trim());
|
|
325
|
+
}
|
|
326
|
+
return origin;
|
|
327
|
+
}
|
|
328
|
+
function createConfig() {
|
|
329
|
+
return {
|
|
330
|
+
env,
|
|
331
|
+
server: {
|
|
332
|
+
port: env.PORT,
|
|
333
|
+
host: env.HOST
|
|
334
|
+
},
|
|
335
|
+
jwt: {
|
|
336
|
+
secret: env.JWT_SECRET || "change-me-in-production-please-32chars",
|
|
337
|
+
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
|
338
|
+
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN
|
|
339
|
+
},
|
|
340
|
+
security: {
|
|
341
|
+
corsOrigin: parseCorsOrigin(env.CORS_ORIGIN),
|
|
342
|
+
rateLimit: {
|
|
343
|
+
max: env.RATE_LIMIT_MAX,
|
|
344
|
+
windowMs: env.RATE_LIMIT_WINDOW_MS
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
email: {
|
|
348
|
+
host: env.SMTP_HOST,
|
|
349
|
+
port: env.SMTP_PORT,
|
|
350
|
+
user: env.SMTP_USER,
|
|
351
|
+
pass: env.SMTP_PASS,
|
|
352
|
+
from: env.SMTP_FROM
|
|
353
|
+
},
|
|
354
|
+
database: {
|
|
355
|
+
url: env.DATABASE_URL
|
|
356
|
+
},
|
|
357
|
+
redis: {
|
|
358
|
+
url: env.REDIS_URL
|
|
359
|
+
},
|
|
360
|
+
swagger: {
|
|
361
|
+
enabled: env.SWAGGER_ENABLED,
|
|
362
|
+
route: env.SWAGGER_ROUTE,
|
|
363
|
+
title: env.SWAGGER_TITLE,
|
|
364
|
+
description: env.SWAGGER_DESCRIPTION,
|
|
365
|
+
version: env.SWAGGER_VERSION
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
var config = createConfig();
|
|
370
|
+
|
|
371
|
+
// src/utils/errors.ts
|
|
372
|
+
var AppError = class _AppError extends Error {
|
|
373
|
+
statusCode;
|
|
374
|
+
isOperational;
|
|
375
|
+
errors;
|
|
376
|
+
constructor(message, statusCode = 500, isOperational = true, errors) {
|
|
377
|
+
super(message);
|
|
378
|
+
this.statusCode = statusCode;
|
|
379
|
+
this.isOperational = isOperational;
|
|
380
|
+
this.errors = errors;
|
|
381
|
+
Object.setPrototypeOf(this, _AppError.prototype);
|
|
382
|
+
Error.captureStackTrace(this, this.constructor);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
var NotFoundError = class extends AppError {
|
|
386
|
+
constructor(resource = "Resource") {
|
|
387
|
+
super(`${resource} not found`, 404);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
var UnauthorizedError = class extends AppError {
|
|
391
|
+
constructor(message = "Unauthorized") {
|
|
392
|
+
super(message, 401);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
var ForbiddenError = class extends AppError {
|
|
396
|
+
constructor(message = "Forbidden") {
|
|
397
|
+
super(message, 403);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
var BadRequestError = class extends AppError {
|
|
401
|
+
constructor(message = "Bad request", errors) {
|
|
402
|
+
super(message, 400, true, errors);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
var ConflictError = class extends AppError {
|
|
406
|
+
constructor(message = "Resource already exists") {
|
|
407
|
+
super(message, 409);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
var ValidationError = class extends AppError {
|
|
411
|
+
constructor(errors) {
|
|
412
|
+
super("Validation failed", 422, true, errors);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
var TooManyRequestsError = class extends AppError {
|
|
416
|
+
constructor(message = "Too many requests") {
|
|
417
|
+
super(message, 429);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
function isAppError(error2) {
|
|
421
|
+
return error2 instanceof AppError;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/middleware/error-handler.ts
|
|
425
|
+
function registerErrorHandler(app) {
|
|
426
|
+
app.setErrorHandler(
|
|
427
|
+
(error2, request, reply) => {
|
|
428
|
+
logger.error(
|
|
429
|
+
{
|
|
430
|
+
err: error2,
|
|
431
|
+
requestId: request.id,
|
|
432
|
+
method: request.method,
|
|
433
|
+
url: request.url
|
|
434
|
+
},
|
|
435
|
+
"Request error"
|
|
436
|
+
);
|
|
437
|
+
if (isAppError(error2)) {
|
|
438
|
+
return reply.status(error2.statusCode).send({
|
|
439
|
+
success: false,
|
|
440
|
+
message: error2.message,
|
|
441
|
+
errors: error2.errors,
|
|
442
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if ("validation" in error2 && error2.validation) {
|
|
446
|
+
const errors = {};
|
|
447
|
+
for (const err of error2.validation) {
|
|
448
|
+
const field = err.instancePath?.replace("/", "") || "body";
|
|
449
|
+
if (!errors[field]) {
|
|
450
|
+
errors[field] = [];
|
|
451
|
+
}
|
|
452
|
+
errors[field].push(err.message || "Invalid value");
|
|
453
|
+
}
|
|
454
|
+
return reply.status(400).send({
|
|
455
|
+
success: false,
|
|
456
|
+
message: "Validation failed",
|
|
457
|
+
errors
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if ("statusCode" in error2 && typeof error2.statusCode === "number") {
|
|
461
|
+
return reply.status(error2.statusCode).send({
|
|
462
|
+
success: false,
|
|
463
|
+
message: error2.message,
|
|
464
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return reply.status(500).send({
|
|
468
|
+
success: false,
|
|
469
|
+
message: isProduction() ? "Internal server error" : error2.message,
|
|
470
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
app.setNotFoundHandler((request, reply) => {
|
|
475
|
+
return reply.status(404).send({
|
|
476
|
+
success: false,
|
|
477
|
+
message: `Route ${request.method} ${request.url} not found`
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/middleware/security.ts
|
|
483
|
+
var import_helmet = __toESM(require("@fastify/helmet"), 1);
|
|
484
|
+
var import_cors = __toESM(require("@fastify/cors"), 1);
|
|
485
|
+
var import_rate_limit = __toESM(require("@fastify/rate-limit"), 1);
|
|
486
|
+
var defaultOptions = {
|
|
487
|
+
helmet: true,
|
|
488
|
+
cors: true,
|
|
489
|
+
rateLimit: true
|
|
490
|
+
};
|
|
491
|
+
async function registerSecurity(app, options = {}) {
|
|
492
|
+
const opts = { ...defaultOptions, ...options };
|
|
493
|
+
if (opts.helmet) {
|
|
494
|
+
await app.register(import_helmet.default, {
|
|
495
|
+
contentSecurityPolicy: {
|
|
496
|
+
directives: {
|
|
497
|
+
defaultSrc: ["'self'"],
|
|
498
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
499
|
+
scriptSrc: ["'self'"],
|
|
500
|
+
imgSrc: ["'self'", "data:", "https:"]
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
crossOriginEmbedderPolicy: false
|
|
504
|
+
});
|
|
505
|
+
logger.debug("Helmet security headers enabled");
|
|
506
|
+
}
|
|
507
|
+
if (opts.cors) {
|
|
508
|
+
await app.register(import_cors.default, {
|
|
509
|
+
origin: config.security.corsOrigin,
|
|
510
|
+
credentials: true,
|
|
511
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
512
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
513
|
+
exposedHeaders: ["X-Total-Count", "X-Page", "X-Limit"],
|
|
514
|
+
maxAge: 86400
|
|
515
|
+
// 24 hours
|
|
516
|
+
});
|
|
517
|
+
logger.debug({ origin: config.security.corsOrigin }, "CORS enabled");
|
|
518
|
+
}
|
|
519
|
+
if (opts.rateLimit) {
|
|
520
|
+
await app.register(import_rate_limit.default, {
|
|
521
|
+
max: config.security.rateLimit.max,
|
|
522
|
+
timeWindow: config.security.rateLimit.windowMs,
|
|
523
|
+
errorResponseBuilder: (_request, context) => ({
|
|
524
|
+
success: false,
|
|
525
|
+
message: "Too many requests, please try again later",
|
|
526
|
+
retryAfter: context.after
|
|
527
|
+
}),
|
|
528
|
+
keyGenerator: (request) => {
|
|
529
|
+
return request.headers["x-forwarded-for"]?.toString().split(",")[0] || request.ip || "unknown";
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
logger.debug(
|
|
533
|
+
{
|
|
534
|
+
max: config.security.rateLimit.max,
|
|
535
|
+
windowMs: config.security.rateLimit.windowMs
|
|
536
|
+
},
|
|
537
|
+
"Rate limiting enabled"
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async function registerBruteForceProtection(app, routePrefix, options = {}) {
|
|
542
|
+
const { max = 5, timeWindow = 3e5 } = options;
|
|
543
|
+
await app.register(import_rate_limit.default, {
|
|
544
|
+
max,
|
|
545
|
+
timeWindow,
|
|
546
|
+
keyGenerator: (request) => {
|
|
547
|
+
const ip = request.headers["x-forwarded-for"]?.toString().split(",")[0] || request.ip || "unknown";
|
|
548
|
+
return `brute:${routePrefix}:${ip}`;
|
|
549
|
+
},
|
|
550
|
+
errorResponseBuilder: () => ({
|
|
551
|
+
success: false,
|
|
552
|
+
message: "Too many attempts. Please try again later."
|
|
553
|
+
}),
|
|
554
|
+
onExceeded: (request) => {
|
|
555
|
+
logger.warn(
|
|
556
|
+
{
|
|
557
|
+
ip: request.ip,
|
|
558
|
+
route: routePrefix
|
|
559
|
+
},
|
|
560
|
+
"Brute force protection triggered"
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/modules/auth/index.ts
|
|
567
|
+
var import_jwt = __toESM(require("@fastify/jwt"), 1);
|
|
568
|
+
var import_cookie = __toESM(require("@fastify/cookie"), 1);
|
|
569
|
+
|
|
570
|
+
// src/modules/auth/auth.service.ts
|
|
571
|
+
var import_bcryptjs = __toESM(require("bcryptjs"), 1);
|
|
572
|
+
var tokenBlacklist = /* @__PURE__ */ new Set();
|
|
573
|
+
var AuthService = class {
|
|
574
|
+
app;
|
|
575
|
+
SALT_ROUNDS = 12;
|
|
576
|
+
constructor(app) {
|
|
577
|
+
this.app = app;
|
|
578
|
+
}
|
|
579
|
+
async hashPassword(password) {
|
|
580
|
+
return import_bcryptjs.default.hash(password, this.SALT_ROUNDS);
|
|
581
|
+
}
|
|
582
|
+
async verifyPassword(password, hash) {
|
|
583
|
+
return import_bcryptjs.default.compare(password, hash);
|
|
584
|
+
}
|
|
585
|
+
generateTokenPair(user) {
|
|
586
|
+
const accessPayload = {
|
|
587
|
+
sub: user.id,
|
|
588
|
+
email: user.email,
|
|
589
|
+
role: user.role,
|
|
590
|
+
type: "access"
|
|
591
|
+
};
|
|
592
|
+
const refreshPayload = {
|
|
593
|
+
sub: user.id,
|
|
594
|
+
email: user.email,
|
|
595
|
+
role: user.role,
|
|
596
|
+
type: "refresh"
|
|
597
|
+
};
|
|
598
|
+
const accessToken = this.app.jwt.sign(accessPayload, {
|
|
599
|
+
expiresIn: config.jwt.accessExpiresIn
|
|
600
|
+
});
|
|
601
|
+
const refreshToken = this.app.jwt.sign(refreshPayload, {
|
|
602
|
+
expiresIn: config.jwt.refreshExpiresIn
|
|
603
|
+
});
|
|
604
|
+
const expiresIn = this.parseExpiration(config.jwt.accessExpiresIn);
|
|
605
|
+
return { accessToken, refreshToken, expiresIn };
|
|
606
|
+
}
|
|
607
|
+
parseExpiration(expiration) {
|
|
608
|
+
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
609
|
+
if (!match) return 900;
|
|
610
|
+
const value = parseInt(match[1] || "0", 10);
|
|
611
|
+
const unit = match[2];
|
|
612
|
+
switch (unit) {
|
|
613
|
+
case "s":
|
|
614
|
+
return value;
|
|
615
|
+
case "m":
|
|
616
|
+
return value * 60;
|
|
617
|
+
case "h":
|
|
618
|
+
return value * 3600;
|
|
619
|
+
case "d":
|
|
620
|
+
return value * 86400;
|
|
621
|
+
default:
|
|
622
|
+
return 900;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async verifyAccessToken(token) {
|
|
626
|
+
try {
|
|
627
|
+
if (this.isTokenBlacklisted(token)) {
|
|
628
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
629
|
+
}
|
|
630
|
+
const payload = this.app.jwt.verify(token);
|
|
631
|
+
if (payload.type !== "access") {
|
|
632
|
+
throw new UnauthorizedError("Invalid token type");
|
|
633
|
+
}
|
|
634
|
+
return payload;
|
|
635
|
+
} catch (error2) {
|
|
636
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
637
|
+
logger.debug({ err: error2 }, "Token verification failed");
|
|
638
|
+
throw new UnauthorizedError("Invalid or expired token");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async verifyRefreshToken(token) {
|
|
642
|
+
try {
|
|
643
|
+
if (this.isTokenBlacklisted(token)) {
|
|
644
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
645
|
+
}
|
|
646
|
+
const payload = this.app.jwt.verify(token);
|
|
647
|
+
if (payload.type !== "refresh") {
|
|
648
|
+
throw new UnauthorizedError("Invalid token type");
|
|
649
|
+
}
|
|
650
|
+
return payload;
|
|
651
|
+
} catch (error2) {
|
|
652
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
653
|
+
logger.debug({ err: error2 }, "Refresh token verification failed");
|
|
654
|
+
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
blacklistToken(token) {
|
|
658
|
+
tokenBlacklist.add(token);
|
|
659
|
+
logger.debug("Token blacklisted");
|
|
660
|
+
}
|
|
661
|
+
isTokenBlacklisted(token) {
|
|
662
|
+
return tokenBlacklist.has(token);
|
|
663
|
+
}
|
|
664
|
+
// Clear expired tokens from blacklist periodically
|
|
665
|
+
cleanupBlacklist() {
|
|
666
|
+
tokenBlacklist.clear();
|
|
667
|
+
logger.debug("Token blacklist cleared");
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
function createAuthService(app) {
|
|
671
|
+
return new AuthService(app);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/modules/auth/schemas.ts
|
|
675
|
+
var import_zod2 = require("zod");
|
|
676
|
+
var loginSchema = import_zod2.z.object({
|
|
677
|
+
email: import_zod2.z.string().email("Invalid email address"),
|
|
678
|
+
password: import_zod2.z.string().min(1, "Password is required")
|
|
679
|
+
});
|
|
680
|
+
var registerSchema = import_zod2.z.object({
|
|
681
|
+
email: import_zod2.z.string().email("Invalid email address"),
|
|
682
|
+
password: import_zod2.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number"),
|
|
683
|
+
name: import_zod2.z.string().min(2, "Name must be at least 2 characters").optional()
|
|
684
|
+
});
|
|
685
|
+
var refreshTokenSchema = import_zod2.z.object({
|
|
686
|
+
refreshToken: import_zod2.z.string().min(1, "Refresh token is required")
|
|
687
|
+
});
|
|
688
|
+
var passwordResetRequestSchema = import_zod2.z.object({
|
|
689
|
+
email: import_zod2.z.string().email("Invalid email address")
|
|
690
|
+
});
|
|
691
|
+
var passwordResetConfirmSchema = import_zod2.z.object({
|
|
692
|
+
token: import_zod2.z.string().min(1, "Token is required"),
|
|
693
|
+
password: import_zod2.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number")
|
|
694
|
+
});
|
|
695
|
+
var changePasswordSchema = import_zod2.z.object({
|
|
696
|
+
currentPassword: import_zod2.z.string().min(1, "Current password is required"),
|
|
697
|
+
newPassword: import_zod2.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number")
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// src/utils/response.ts
|
|
701
|
+
function success(reply, data, statusCode = 200) {
|
|
702
|
+
const response = {
|
|
703
|
+
success: true,
|
|
704
|
+
data
|
|
705
|
+
};
|
|
706
|
+
return reply.status(statusCode).send(response);
|
|
707
|
+
}
|
|
708
|
+
function created(reply, data) {
|
|
709
|
+
return success(reply, data, 201);
|
|
710
|
+
}
|
|
711
|
+
function noContent(reply) {
|
|
712
|
+
return reply.status(204).send();
|
|
713
|
+
}
|
|
714
|
+
function error(reply, message, statusCode = 400, errors) {
|
|
715
|
+
const response = {
|
|
716
|
+
success: false,
|
|
717
|
+
message,
|
|
718
|
+
errors
|
|
719
|
+
};
|
|
720
|
+
return reply.status(statusCode).send(response);
|
|
721
|
+
}
|
|
722
|
+
function notFound(reply, message = "Resource not found") {
|
|
723
|
+
return error(reply, message, 404);
|
|
724
|
+
}
|
|
725
|
+
function unauthorized(reply, message = "Unauthorized") {
|
|
726
|
+
return error(reply, message, 401);
|
|
727
|
+
}
|
|
728
|
+
function forbidden(reply, message = "Forbidden") {
|
|
729
|
+
return error(reply, message, 403);
|
|
730
|
+
}
|
|
731
|
+
function badRequest(reply, message = "Bad request", errors) {
|
|
732
|
+
return error(reply, message, 400, errors);
|
|
733
|
+
}
|
|
734
|
+
function conflict(reply, message = "Resource already exists") {
|
|
735
|
+
return error(reply, message, 409);
|
|
736
|
+
}
|
|
737
|
+
function internalError(reply, message = "Internal server error") {
|
|
738
|
+
return error(reply, message, 500);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/modules/validation/validator.ts
|
|
742
|
+
var import_zod3 = require("zod");
|
|
743
|
+
function validateBody(schema, data) {
|
|
744
|
+
const result = schema.safeParse(data);
|
|
745
|
+
if (!result.success) {
|
|
746
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
747
|
+
}
|
|
748
|
+
return result.data;
|
|
749
|
+
}
|
|
750
|
+
function validateQuery(schema, data) {
|
|
751
|
+
const result = schema.safeParse(data);
|
|
752
|
+
if (!result.success) {
|
|
753
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
754
|
+
}
|
|
755
|
+
return result.data;
|
|
756
|
+
}
|
|
757
|
+
function validateParams(schema, data) {
|
|
758
|
+
const result = schema.safeParse(data);
|
|
759
|
+
if (!result.success) {
|
|
760
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
761
|
+
}
|
|
762
|
+
return result.data;
|
|
763
|
+
}
|
|
764
|
+
function validate(schema, data) {
|
|
765
|
+
return validateBody(schema, data);
|
|
766
|
+
}
|
|
767
|
+
function formatZodErrors(error2) {
|
|
768
|
+
const errors = {};
|
|
769
|
+
for (const issue of error2.issues) {
|
|
770
|
+
const path = issue.path.join(".") || "root";
|
|
771
|
+
if (!errors[path]) {
|
|
772
|
+
errors[path] = [];
|
|
773
|
+
}
|
|
774
|
+
errors[path].push(issue.message);
|
|
775
|
+
}
|
|
776
|
+
return errors;
|
|
777
|
+
}
|
|
778
|
+
var idParamSchema = import_zod3.z.object({
|
|
779
|
+
id: import_zod3.z.string().uuid("Invalid ID format")
|
|
780
|
+
});
|
|
781
|
+
var paginationSchema = import_zod3.z.object({
|
|
782
|
+
page: import_zod3.z.string().transform(Number).optional().default("1"),
|
|
783
|
+
limit: import_zod3.z.string().transform(Number).optional().default("20"),
|
|
784
|
+
sortBy: import_zod3.z.string().optional(),
|
|
785
|
+
sortOrder: import_zod3.z.enum(["asc", "desc"]).optional().default("asc")
|
|
786
|
+
});
|
|
787
|
+
var searchSchema = import_zod3.z.object({
|
|
788
|
+
q: import_zod3.z.string().min(1, "Search query is required").optional(),
|
|
789
|
+
search: import_zod3.z.string().min(1).optional()
|
|
790
|
+
});
|
|
791
|
+
var emailSchema = import_zod3.z.string().email("Invalid email address");
|
|
792
|
+
var passwordSchema = import_zod3.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number").regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
|
|
793
|
+
var urlSchema = import_zod3.z.string().url("Invalid URL format");
|
|
794
|
+
var phoneSchema = import_zod3.z.string().regex(
|
|
795
|
+
/^\+?[1-9]\d{1,14}$/,
|
|
796
|
+
"Invalid phone number format"
|
|
797
|
+
);
|
|
798
|
+
var dateSchema = import_zod3.z.coerce.date();
|
|
799
|
+
var futureDateSchema = import_zod3.z.coerce.date().refine(
|
|
800
|
+
(date) => date > /* @__PURE__ */ new Date(),
|
|
801
|
+
"Date must be in the future"
|
|
802
|
+
);
|
|
803
|
+
var pastDateSchema = import_zod3.z.coerce.date().refine(
|
|
804
|
+
(date) => date < /* @__PURE__ */ new Date(),
|
|
805
|
+
"Date must be in the past"
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// src/modules/auth/auth.controller.ts
|
|
809
|
+
var AuthController = class {
|
|
810
|
+
constructor(authService, userService) {
|
|
811
|
+
this.authService = authService;
|
|
812
|
+
this.userService = userService;
|
|
813
|
+
}
|
|
814
|
+
async register(request, reply) {
|
|
815
|
+
const data = validateBody(registerSchema, request.body);
|
|
816
|
+
const existingUser = await this.userService.findByEmail(data.email);
|
|
817
|
+
if (existingUser) {
|
|
818
|
+
throw new BadRequestError("Email already registered");
|
|
819
|
+
}
|
|
820
|
+
const hashedPassword = await this.authService.hashPassword(data.password);
|
|
821
|
+
const user = await this.userService.create({
|
|
822
|
+
email: data.email,
|
|
823
|
+
password: hashedPassword,
|
|
824
|
+
name: data.name
|
|
825
|
+
});
|
|
826
|
+
const tokens = this.authService.generateTokenPair({
|
|
827
|
+
id: user.id,
|
|
828
|
+
email: user.email,
|
|
829
|
+
role: user.role
|
|
830
|
+
});
|
|
831
|
+
created(reply, {
|
|
832
|
+
user: {
|
|
833
|
+
id: user.id,
|
|
834
|
+
email: user.email,
|
|
835
|
+
name: user.name,
|
|
836
|
+
role: user.role
|
|
837
|
+
},
|
|
838
|
+
...tokens
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
async login(request, reply) {
|
|
842
|
+
const data = validateBody(loginSchema, request.body);
|
|
843
|
+
const user = await this.userService.findByEmail(data.email);
|
|
844
|
+
if (!user) {
|
|
845
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
846
|
+
}
|
|
847
|
+
if (user.status !== "active") {
|
|
848
|
+
throw new UnauthorizedError("Account is not active");
|
|
849
|
+
}
|
|
850
|
+
const isValidPassword = await this.authService.verifyPassword(data.password, user.password);
|
|
851
|
+
if (!isValidPassword) {
|
|
852
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
853
|
+
}
|
|
854
|
+
await this.userService.updateLastLogin(user.id);
|
|
855
|
+
const tokens = this.authService.generateTokenPair({
|
|
856
|
+
id: user.id,
|
|
857
|
+
email: user.email,
|
|
858
|
+
role: user.role
|
|
859
|
+
});
|
|
860
|
+
success(reply, {
|
|
861
|
+
user: {
|
|
862
|
+
id: user.id,
|
|
863
|
+
email: user.email,
|
|
864
|
+
name: user.name,
|
|
865
|
+
role: user.role
|
|
866
|
+
},
|
|
867
|
+
...tokens
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
async refresh(request, reply) {
|
|
871
|
+
const data = validateBody(refreshTokenSchema, request.body);
|
|
872
|
+
const payload = await this.authService.verifyRefreshToken(data.refreshToken);
|
|
873
|
+
const user = await this.userService.findById(payload.sub);
|
|
874
|
+
if (!user || user.status !== "active") {
|
|
875
|
+
throw new UnauthorizedError("User not found or inactive");
|
|
876
|
+
}
|
|
877
|
+
this.authService.blacklistToken(data.refreshToken);
|
|
878
|
+
const tokens = this.authService.generateTokenPair({
|
|
879
|
+
id: user.id,
|
|
880
|
+
email: user.email,
|
|
881
|
+
role: user.role
|
|
882
|
+
});
|
|
883
|
+
success(reply, tokens);
|
|
884
|
+
}
|
|
885
|
+
async logout(request, reply) {
|
|
886
|
+
const authHeader = request.headers.authorization;
|
|
887
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
888
|
+
const token = authHeader.substring(7);
|
|
889
|
+
this.authService.blacklistToken(token);
|
|
890
|
+
}
|
|
891
|
+
success(reply, { message: "Logged out successfully" });
|
|
892
|
+
}
|
|
893
|
+
async me(request, reply) {
|
|
894
|
+
const authRequest = request;
|
|
895
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
896
|
+
if (!user) {
|
|
897
|
+
throw new UnauthorizedError("User not found");
|
|
898
|
+
}
|
|
899
|
+
success(reply, {
|
|
900
|
+
id: user.id,
|
|
901
|
+
email: user.email,
|
|
902
|
+
name: user.name,
|
|
903
|
+
role: user.role,
|
|
904
|
+
status: user.status,
|
|
905
|
+
createdAt: user.createdAt
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
async changePassword(request, reply) {
|
|
909
|
+
const authRequest = request;
|
|
910
|
+
const data = validateBody(changePasswordSchema, request.body);
|
|
911
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
912
|
+
if (!user) {
|
|
913
|
+
throw new UnauthorizedError("User not found");
|
|
914
|
+
}
|
|
915
|
+
const isValidPassword = await this.authService.verifyPassword(
|
|
916
|
+
data.currentPassword,
|
|
917
|
+
user.password
|
|
918
|
+
);
|
|
919
|
+
if (!isValidPassword) {
|
|
920
|
+
throw new BadRequestError("Current password is incorrect");
|
|
921
|
+
}
|
|
922
|
+
const hashedPassword = await this.authService.hashPassword(data.newPassword);
|
|
923
|
+
await this.userService.updatePassword(user.id, hashedPassword);
|
|
924
|
+
success(reply, { message: "Password changed successfully" });
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
function createAuthController(authService, userService) {
|
|
928
|
+
return new AuthController(authService, userService);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// src/modules/auth/auth.middleware.ts
|
|
932
|
+
function createAuthMiddleware(authService) {
|
|
933
|
+
return async function authenticate(request, reply) {
|
|
934
|
+
const authHeader = request.headers.authorization;
|
|
935
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
936
|
+
throw new UnauthorizedError("Missing or invalid authorization header");
|
|
937
|
+
}
|
|
938
|
+
const token = authHeader.substring(7);
|
|
939
|
+
const payload = await authService.verifyAccessToken(token);
|
|
940
|
+
request.user = {
|
|
941
|
+
id: payload.sub,
|
|
942
|
+
email: payload.email,
|
|
943
|
+
role: payload.role
|
|
944
|
+
};
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function createRoleMiddleware(allowedRoles) {
|
|
948
|
+
return async function authorize(request, _reply) {
|
|
949
|
+
const user = request.user;
|
|
950
|
+
if (!user) {
|
|
951
|
+
throw new UnauthorizedError("Authentication required");
|
|
952
|
+
}
|
|
953
|
+
if (!allowedRoles.includes(user.role)) {
|
|
954
|
+
throw new ForbiddenError("Insufficient permissions");
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function createPermissionMiddleware(requiredPermissions) {
|
|
959
|
+
return async function checkPermissions(request, _reply) {
|
|
960
|
+
const user = request.user;
|
|
961
|
+
if (!user) {
|
|
962
|
+
throw new UnauthorizedError("Authentication required");
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
function createOptionalAuthMiddleware(authService) {
|
|
967
|
+
return async function optionalAuthenticate(request, _reply) {
|
|
968
|
+
const authHeader = request.headers.authorization;
|
|
969
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
const token = authHeader.substring(7);
|
|
974
|
+
const payload = await authService.verifyAccessToken(token);
|
|
975
|
+
request.user = {
|
|
976
|
+
id: payload.sub,
|
|
977
|
+
email: payload.email,
|
|
978
|
+
role: payload.role
|
|
979
|
+
};
|
|
980
|
+
} catch {
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/modules/swagger/swagger.service.ts
|
|
986
|
+
var import_swagger = __toESM(require("@fastify/swagger"), 1);
|
|
987
|
+
var import_swagger_ui = __toESM(require("@fastify/swagger-ui"), 1);
|
|
988
|
+
var defaultConfig3 = {
|
|
989
|
+
enabled: true,
|
|
990
|
+
route: "/docs",
|
|
991
|
+
title: "Servcraft API",
|
|
992
|
+
description: "API documentation generated by Servcraft",
|
|
993
|
+
version: "1.0.0",
|
|
994
|
+
tags: [
|
|
995
|
+
{ name: "Auth", description: "Authentication endpoints" },
|
|
996
|
+
{ name: "Users", description: "User management endpoints" },
|
|
997
|
+
{ name: "Health", description: "Health check endpoints" }
|
|
998
|
+
]
|
|
999
|
+
};
|
|
1000
|
+
async function registerSwagger(app, customConfig) {
|
|
1001
|
+
const swaggerConfig = { ...defaultConfig3, ...customConfig };
|
|
1002
|
+
if (swaggerConfig.enabled === false) {
|
|
1003
|
+
logger.info("Swagger documentation disabled");
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
await app.register(import_swagger.default, {
|
|
1007
|
+
openapi: {
|
|
1008
|
+
openapi: "3.0.3",
|
|
1009
|
+
info: {
|
|
1010
|
+
title: swaggerConfig.title,
|
|
1011
|
+
description: swaggerConfig.description,
|
|
1012
|
+
version: swaggerConfig.version,
|
|
1013
|
+
contact: swaggerConfig.contact,
|
|
1014
|
+
license: swaggerConfig.license
|
|
1015
|
+
},
|
|
1016
|
+
servers: swaggerConfig.servers || [
|
|
1017
|
+
{
|
|
1018
|
+
url: `http://localhost:${config.server.port}`,
|
|
1019
|
+
description: "Development server"
|
|
1020
|
+
}
|
|
1021
|
+
],
|
|
1022
|
+
tags: swaggerConfig.tags,
|
|
1023
|
+
components: {
|
|
1024
|
+
securitySchemes: {
|
|
1025
|
+
bearerAuth: {
|
|
1026
|
+
type: "http",
|
|
1027
|
+
scheme: "bearer",
|
|
1028
|
+
bearerFormat: "JWT",
|
|
1029
|
+
description: "Enter your JWT token"
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
await app.register(import_swagger_ui.default, {
|
|
1036
|
+
routePrefix: swaggerConfig.route || "/docs",
|
|
1037
|
+
uiConfig: {
|
|
1038
|
+
docExpansion: "list",
|
|
1039
|
+
deepLinking: true,
|
|
1040
|
+
displayRequestDuration: true,
|
|
1041
|
+
filter: true,
|
|
1042
|
+
showExtensions: true,
|
|
1043
|
+
showCommonExtensions: true
|
|
1044
|
+
},
|
|
1045
|
+
staticCSP: true,
|
|
1046
|
+
transformStaticCSP: (header) => header
|
|
1047
|
+
});
|
|
1048
|
+
logger.info("Swagger documentation registered at /docs");
|
|
1049
|
+
}
|
|
1050
|
+
var commonResponses = {
|
|
1051
|
+
success: {
|
|
1052
|
+
type: "object",
|
|
1053
|
+
properties: {
|
|
1054
|
+
success: { type: "boolean", example: true },
|
|
1055
|
+
data: { type: "object" }
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
error: {
|
|
1059
|
+
type: "object",
|
|
1060
|
+
properties: {
|
|
1061
|
+
success: { type: "boolean", example: false },
|
|
1062
|
+
message: { type: "string" },
|
|
1063
|
+
errors: {
|
|
1064
|
+
type: "object",
|
|
1065
|
+
additionalProperties: {
|
|
1066
|
+
type: "array",
|
|
1067
|
+
items: { type: "string" }
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
unauthorized: {
|
|
1073
|
+
type: "object",
|
|
1074
|
+
properties: {
|
|
1075
|
+
success: { type: "boolean", example: false },
|
|
1076
|
+
message: { type: "string", example: "Unauthorized" }
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
notFound: {
|
|
1080
|
+
type: "object",
|
|
1081
|
+
properties: {
|
|
1082
|
+
success: { type: "boolean", example: false },
|
|
1083
|
+
message: { type: "string", example: "Resource not found" }
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
paginated: {
|
|
1087
|
+
type: "object",
|
|
1088
|
+
properties: {
|
|
1089
|
+
success: { type: "boolean", example: true },
|
|
1090
|
+
data: {
|
|
1091
|
+
type: "object",
|
|
1092
|
+
properties: {
|
|
1093
|
+
data: { type: "array", items: { type: "object" } },
|
|
1094
|
+
meta: {
|
|
1095
|
+
type: "object",
|
|
1096
|
+
properties: {
|
|
1097
|
+
total: { type: "number" },
|
|
1098
|
+
page: { type: "number" },
|
|
1099
|
+
limit: { type: "number" },
|
|
1100
|
+
totalPages: { type: "number" },
|
|
1101
|
+
hasNextPage: { type: "boolean" },
|
|
1102
|
+
hasPrevPage: { type: "boolean" }
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
var paginationQuery = {
|
|
1111
|
+
type: "object",
|
|
1112
|
+
properties: {
|
|
1113
|
+
page: { type: "integer", minimum: 1, default: 1, description: "Page number" },
|
|
1114
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 20, description: "Items per page" },
|
|
1115
|
+
sortBy: { type: "string", description: "Field to sort by" },
|
|
1116
|
+
sortOrder: { type: "string", enum: ["asc", "desc"], default: "asc", description: "Sort order" },
|
|
1117
|
+
search: { type: "string", description: "Search query" }
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
var idParam = {
|
|
1121
|
+
type: "object",
|
|
1122
|
+
properties: {
|
|
1123
|
+
id: { type: "string", format: "uuid", description: "Resource ID" }
|
|
1124
|
+
},
|
|
1125
|
+
required: ["id"]
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
// src/modules/auth/auth.routes.ts
|
|
1129
|
+
var credentialsBody = {
|
|
1130
|
+
type: "object",
|
|
1131
|
+
required: ["email", "password"],
|
|
1132
|
+
properties: {
|
|
1133
|
+
email: { type: "string", format: "email" },
|
|
1134
|
+
password: { type: "string", minLength: 8 }
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
var changePasswordBody = {
|
|
1138
|
+
type: "object",
|
|
1139
|
+
required: ["currentPassword", "newPassword"],
|
|
1140
|
+
properties: {
|
|
1141
|
+
currentPassword: { type: "string", minLength: 8 },
|
|
1142
|
+
newPassword: { type: "string", minLength: 8 }
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
function registerAuthRoutes(app, controller, authService) {
|
|
1146
|
+
const authenticate = createAuthMiddleware(authService);
|
|
1147
|
+
app.post("/auth/register", {
|
|
1148
|
+
schema: {
|
|
1149
|
+
tags: ["Auth"],
|
|
1150
|
+
summary: "Register a new user",
|
|
1151
|
+
body: credentialsBody,
|
|
1152
|
+
response: {
|
|
1153
|
+
201: commonResponses.success,
|
|
1154
|
+
400: commonResponses.error,
|
|
1155
|
+
409: commonResponses.error
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
handler: controller.register.bind(controller)
|
|
1159
|
+
});
|
|
1160
|
+
app.post("/auth/login", {
|
|
1161
|
+
schema: {
|
|
1162
|
+
tags: ["Auth"],
|
|
1163
|
+
summary: "Login and obtain tokens",
|
|
1164
|
+
body: credentialsBody,
|
|
1165
|
+
response: {
|
|
1166
|
+
200: commonResponses.success,
|
|
1167
|
+
400: commonResponses.error,
|
|
1168
|
+
401: commonResponses.unauthorized
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
handler: controller.login.bind(controller)
|
|
1172
|
+
});
|
|
1173
|
+
app.post("/auth/refresh", {
|
|
1174
|
+
schema: {
|
|
1175
|
+
tags: ["Auth"],
|
|
1176
|
+
summary: "Refresh access token",
|
|
1177
|
+
body: {
|
|
1178
|
+
type: "object",
|
|
1179
|
+
required: ["refreshToken"],
|
|
1180
|
+
properties: {
|
|
1181
|
+
refreshToken: { type: "string" }
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
response: {
|
|
1185
|
+
200: commonResponses.success,
|
|
1186
|
+
401: commonResponses.unauthorized
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
handler: controller.refresh.bind(controller)
|
|
1190
|
+
});
|
|
1191
|
+
app.post("/auth/logout", {
|
|
1192
|
+
preHandler: [authenticate],
|
|
1193
|
+
schema: {
|
|
1194
|
+
tags: ["Auth"],
|
|
1195
|
+
summary: "Logout current user",
|
|
1196
|
+
security: [{ bearerAuth: [] }],
|
|
1197
|
+
response: {
|
|
1198
|
+
200: commonResponses.success,
|
|
1199
|
+
401: commonResponses.unauthorized
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
handler: controller.logout.bind(controller)
|
|
1203
|
+
});
|
|
1204
|
+
app.get("/auth/me", {
|
|
1205
|
+
preHandler: [authenticate],
|
|
1206
|
+
schema: {
|
|
1207
|
+
tags: ["Auth"],
|
|
1208
|
+
summary: "Get current user profile",
|
|
1209
|
+
security: [{ bearerAuth: [] }],
|
|
1210
|
+
response: {
|
|
1211
|
+
200: commonResponses.success,
|
|
1212
|
+
401: commonResponses.unauthorized
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
handler: controller.me.bind(controller)
|
|
1216
|
+
});
|
|
1217
|
+
app.post("/auth/change-password", {
|
|
1218
|
+
preHandler: [authenticate],
|
|
1219
|
+
schema: {
|
|
1220
|
+
tags: ["Auth"],
|
|
1221
|
+
summary: "Change current user password",
|
|
1222
|
+
security: [{ bearerAuth: [] }],
|
|
1223
|
+
body: changePasswordBody,
|
|
1224
|
+
response: {
|
|
1225
|
+
200: commonResponses.success,
|
|
1226
|
+
400: commonResponses.error,
|
|
1227
|
+
401: commonResponses.unauthorized
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
handler: controller.changePassword.bind(controller)
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/modules/user/user.repository.ts
|
|
1235
|
+
var import_crypto = require("crypto");
|
|
1236
|
+
|
|
1237
|
+
// src/utils/pagination.ts
|
|
1238
|
+
var DEFAULT_PAGE = 1;
|
|
1239
|
+
var DEFAULT_LIMIT = 20;
|
|
1240
|
+
var MAX_LIMIT = 100;
|
|
1241
|
+
function parsePaginationParams(query) {
|
|
1242
|
+
const page = Math.max(1, parseInt(String(query.page || DEFAULT_PAGE), 10));
|
|
1243
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(String(query.limit || DEFAULT_LIMIT), 10)));
|
|
1244
|
+
const sortBy = typeof query.sortBy === "string" ? query.sortBy : void 0;
|
|
1245
|
+
const sortOrder = query.sortOrder === "desc" ? "desc" : "asc";
|
|
1246
|
+
return { page, limit, sortBy, sortOrder };
|
|
1247
|
+
}
|
|
1248
|
+
function createPaginatedResult(data, total, params) {
|
|
1249
|
+
const totalPages = Math.ceil(total / params.limit);
|
|
1250
|
+
return {
|
|
1251
|
+
data,
|
|
1252
|
+
meta: {
|
|
1253
|
+
total,
|
|
1254
|
+
page: params.page,
|
|
1255
|
+
limit: params.limit,
|
|
1256
|
+
totalPages,
|
|
1257
|
+
hasNextPage: params.page < totalPages,
|
|
1258
|
+
hasPrevPage: params.page > 1
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
function getSkip(params) {
|
|
1263
|
+
return (params.page - 1) * params.limit;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/modules/user/user.repository.ts
|
|
1267
|
+
var users = /* @__PURE__ */ new Map();
|
|
1268
|
+
var UserRepository = class {
|
|
1269
|
+
async findById(id) {
|
|
1270
|
+
return users.get(id) || null;
|
|
1271
|
+
}
|
|
1272
|
+
async findByEmail(email) {
|
|
1273
|
+
for (const user of users.values()) {
|
|
1274
|
+
if (user.email.toLowerCase() === email.toLowerCase()) {
|
|
1275
|
+
return user;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
async findMany(params, filters) {
|
|
1281
|
+
let filteredUsers = Array.from(users.values());
|
|
1282
|
+
if (filters) {
|
|
1283
|
+
if (filters.status) {
|
|
1284
|
+
filteredUsers = filteredUsers.filter((u) => u.status === filters.status);
|
|
1285
|
+
}
|
|
1286
|
+
if (filters.role) {
|
|
1287
|
+
filteredUsers = filteredUsers.filter((u) => u.role === filters.role);
|
|
1288
|
+
}
|
|
1289
|
+
if (filters.emailVerified !== void 0) {
|
|
1290
|
+
filteredUsers = filteredUsers.filter((u) => u.emailVerified === filters.emailVerified);
|
|
1291
|
+
}
|
|
1292
|
+
if (filters.search) {
|
|
1293
|
+
const search = filters.search.toLowerCase();
|
|
1294
|
+
filteredUsers = filteredUsers.filter(
|
|
1295
|
+
(u) => u.email.toLowerCase().includes(search) || u.name?.toLowerCase().includes(search)
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (params.sortBy) {
|
|
1300
|
+
const sortKey = params.sortBy;
|
|
1301
|
+
filteredUsers.sort((a, b) => {
|
|
1302
|
+
const aVal = a[sortKey];
|
|
1303
|
+
const bVal = b[sortKey];
|
|
1304
|
+
if (aVal === void 0 || bVal === void 0) return 0;
|
|
1305
|
+
if (aVal < bVal) return params.sortOrder === "desc" ? 1 : -1;
|
|
1306
|
+
if (aVal > bVal) return params.sortOrder === "desc" ? -1 : 1;
|
|
1307
|
+
return 0;
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
const total = filteredUsers.length;
|
|
1311
|
+
const skip = getSkip(params);
|
|
1312
|
+
const data = filteredUsers.slice(skip, skip + params.limit);
|
|
1313
|
+
return createPaginatedResult(data, total, params);
|
|
1314
|
+
}
|
|
1315
|
+
async create(data) {
|
|
1316
|
+
const now = /* @__PURE__ */ new Date();
|
|
1317
|
+
const user = {
|
|
1318
|
+
id: (0, import_crypto.randomUUID)(),
|
|
1319
|
+
email: data.email,
|
|
1320
|
+
password: data.password,
|
|
1321
|
+
name: data.name,
|
|
1322
|
+
role: data.role || "user",
|
|
1323
|
+
status: "active",
|
|
1324
|
+
emailVerified: false,
|
|
1325
|
+
createdAt: now,
|
|
1326
|
+
updatedAt: now
|
|
1327
|
+
};
|
|
1328
|
+
users.set(user.id, user);
|
|
1329
|
+
return user;
|
|
1330
|
+
}
|
|
1331
|
+
async update(id, data) {
|
|
1332
|
+
const user = users.get(id);
|
|
1333
|
+
if (!user) return null;
|
|
1334
|
+
const updatedUser = {
|
|
1335
|
+
...user,
|
|
1336
|
+
...data,
|
|
1337
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1338
|
+
};
|
|
1339
|
+
users.set(id, updatedUser);
|
|
1340
|
+
return updatedUser;
|
|
1341
|
+
}
|
|
1342
|
+
async updatePassword(id, password) {
|
|
1343
|
+
const user = users.get(id);
|
|
1344
|
+
if (!user) return null;
|
|
1345
|
+
const updatedUser = {
|
|
1346
|
+
...user,
|
|
1347
|
+
password,
|
|
1348
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1349
|
+
};
|
|
1350
|
+
users.set(id, updatedUser);
|
|
1351
|
+
return updatedUser;
|
|
1352
|
+
}
|
|
1353
|
+
async updateLastLogin(id) {
|
|
1354
|
+
const user = users.get(id);
|
|
1355
|
+
if (!user) return null;
|
|
1356
|
+
const updatedUser = {
|
|
1357
|
+
...user,
|
|
1358
|
+
lastLoginAt: /* @__PURE__ */ new Date(),
|
|
1359
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1360
|
+
};
|
|
1361
|
+
users.set(id, updatedUser);
|
|
1362
|
+
return updatedUser;
|
|
1363
|
+
}
|
|
1364
|
+
async delete(id) {
|
|
1365
|
+
return users.delete(id);
|
|
1366
|
+
}
|
|
1367
|
+
async count(filters) {
|
|
1368
|
+
let count = 0;
|
|
1369
|
+
for (const user of users.values()) {
|
|
1370
|
+
if (filters) {
|
|
1371
|
+
if (filters.status && user.status !== filters.status) continue;
|
|
1372
|
+
if (filters.role && user.role !== filters.role) continue;
|
|
1373
|
+
if (filters.emailVerified !== void 0 && user.emailVerified !== filters.emailVerified)
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
count++;
|
|
1377
|
+
}
|
|
1378
|
+
return count;
|
|
1379
|
+
}
|
|
1380
|
+
// Helper to clear all users (for testing)
|
|
1381
|
+
async clear() {
|
|
1382
|
+
users.clear();
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
function createUserRepository() {
|
|
1386
|
+
return new UserRepository();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/modules/user/types.ts
|
|
1390
|
+
var DEFAULT_ROLE_PERMISSIONS = {
|
|
1391
|
+
user: ["profile:read", "profile:update"],
|
|
1392
|
+
moderator: [
|
|
1393
|
+
"profile:read",
|
|
1394
|
+
"profile:update",
|
|
1395
|
+
"users:read",
|
|
1396
|
+
"content:read",
|
|
1397
|
+
"content:update",
|
|
1398
|
+
"content:delete"
|
|
1399
|
+
],
|
|
1400
|
+
admin: [
|
|
1401
|
+
"profile:read",
|
|
1402
|
+
"profile:update",
|
|
1403
|
+
"users:read",
|
|
1404
|
+
"users:update",
|
|
1405
|
+
"users:delete",
|
|
1406
|
+
"content:manage",
|
|
1407
|
+
"settings:read"
|
|
1408
|
+
],
|
|
1409
|
+
super_admin: ["*:manage"]
|
|
1410
|
+
// All permissions
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
// src/modules/user/user.service.ts
|
|
1414
|
+
var UserService = class {
|
|
1415
|
+
constructor(repository) {
|
|
1416
|
+
this.repository = repository;
|
|
1417
|
+
}
|
|
1418
|
+
async findById(id) {
|
|
1419
|
+
return this.repository.findById(id);
|
|
1420
|
+
}
|
|
1421
|
+
async findByEmail(email) {
|
|
1422
|
+
return this.repository.findByEmail(email);
|
|
1423
|
+
}
|
|
1424
|
+
async findMany(params, filters) {
|
|
1425
|
+
const result = await this.repository.findMany(params, filters);
|
|
1426
|
+
return {
|
|
1427
|
+
...result,
|
|
1428
|
+
data: result.data.map(({ password, ...user }) => user)
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
async create(data) {
|
|
1432
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
1433
|
+
if (existing) {
|
|
1434
|
+
throw new ConflictError("User with this email already exists");
|
|
1435
|
+
}
|
|
1436
|
+
const user = await this.repository.create(data);
|
|
1437
|
+
logger.info({ userId: user.id, email: user.email }, "User created");
|
|
1438
|
+
return user;
|
|
1439
|
+
}
|
|
1440
|
+
async update(id, data) {
|
|
1441
|
+
const user = await this.repository.findById(id);
|
|
1442
|
+
if (!user) {
|
|
1443
|
+
throw new NotFoundError("User");
|
|
1444
|
+
}
|
|
1445
|
+
if (data.email && data.email !== user.email) {
|
|
1446
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
1447
|
+
if (existing) {
|
|
1448
|
+
throw new ConflictError("Email already in use");
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const updatedUser = await this.repository.update(id, data);
|
|
1452
|
+
if (!updatedUser) {
|
|
1453
|
+
throw new NotFoundError("User");
|
|
1454
|
+
}
|
|
1455
|
+
logger.info({ userId: id }, "User updated");
|
|
1456
|
+
return updatedUser;
|
|
1457
|
+
}
|
|
1458
|
+
async updatePassword(id, hashedPassword) {
|
|
1459
|
+
const user = await this.repository.updatePassword(id, hashedPassword);
|
|
1460
|
+
if (!user) {
|
|
1461
|
+
throw new NotFoundError("User");
|
|
1462
|
+
}
|
|
1463
|
+
logger.info({ userId: id }, "User password updated");
|
|
1464
|
+
return user;
|
|
1465
|
+
}
|
|
1466
|
+
async updateLastLogin(id) {
|
|
1467
|
+
const user = await this.repository.updateLastLogin(id);
|
|
1468
|
+
if (!user) {
|
|
1469
|
+
throw new NotFoundError("User");
|
|
1470
|
+
}
|
|
1471
|
+
return user;
|
|
1472
|
+
}
|
|
1473
|
+
async delete(id) {
|
|
1474
|
+
const user = await this.repository.findById(id);
|
|
1475
|
+
if (!user) {
|
|
1476
|
+
throw new NotFoundError("User");
|
|
1477
|
+
}
|
|
1478
|
+
await this.repository.delete(id);
|
|
1479
|
+
logger.info({ userId: id }, "User deleted");
|
|
1480
|
+
}
|
|
1481
|
+
async suspend(id) {
|
|
1482
|
+
return this.update(id, { status: "suspended" });
|
|
1483
|
+
}
|
|
1484
|
+
async ban(id) {
|
|
1485
|
+
return this.update(id, { status: "banned" });
|
|
1486
|
+
}
|
|
1487
|
+
async activate(id) {
|
|
1488
|
+
return this.update(id, { status: "active" });
|
|
1489
|
+
}
|
|
1490
|
+
async verifyEmail(id) {
|
|
1491
|
+
return this.update(id, { emailVerified: true });
|
|
1492
|
+
}
|
|
1493
|
+
async changeRole(id, role) {
|
|
1494
|
+
return this.update(id, { role });
|
|
1495
|
+
}
|
|
1496
|
+
// RBAC helpers
|
|
1497
|
+
hasPermission(role, permission) {
|
|
1498
|
+
const permissions = DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
1499
|
+
if (permissions.includes("*:manage")) {
|
|
1500
|
+
return true;
|
|
1501
|
+
}
|
|
1502
|
+
if (permissions.includes(permission)) {
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
const [resource, action] = permission.split(":");
|
|
1506
|
+
const managePermission = `${resource}:manage`;
|
|
1507
|
+
if (permissions.includes(managePermission)) {
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
getPermissions(role) {
|
|
1513
|
+
return DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
function createUserService(repository) {
|
|
1517
|
+
return new UserService(repository || createUserRepository());
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// src/modules/auth/index.ts
|
|
1521
|
+
async function registerAuthModule(app) {
|
|
1522
|
+
await app.register(import_jwt.default, {
|
|
1523
|
+
secret: config.jwt.secret,
|
|
1524
|
+
sign: {
|
|
1525
|
+
algorithm: "HS256"
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
await app.register(import_cookie.default, {
|
|
1529
|
+
secret: config.jwt.secret,
|
|
1530
|
+
hook: "onRequest"
|
|
1531
|
+
});
|
|
1532
|
+
const authService = createAuthService(app);
|
|
1533
|
+
const userService = createUserService();
|
|
1534
|
+
const authController = createAuthController(authService, userService);
|
|
1535
|
+
registerAuthRoutes(app, authController, authService);
|
|
1536
|
+
logger.info("Auth module registered");
|
|
1537
|
+
return authService;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// src/modules/user/schemas.ts
|
|
1541
|
+
var import_zod4 = require("zod");
|
|
1542
|
+
var userStatusEnum = import_zod4.z.enum(["active", "inactive", "suspended", "banned"]);
|
|
1543
|
+
var userRoleEnum = import_zod4.z.enum(["user", "admin", "moderator", "super_admin"]);
|
|
1544
|
+
var createUserSchema = import_zod4.z.object({
|
|
1545
|
+
email: import_zod4.z.string().email("Invalid email address"),
|
|
1546
|
+
password: import_zod4.z.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number"),
|
|
1547
|
+
name: import_zod4.z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
1548
|
+
role: userRoleEnum.optional().default("user")
|
|
1549
|
+
});
|
|
1550
|
+
var updateUserSchema = import_zod4.z.object({
|
|
1551
|
+
email: import_zod4.z.string().email("Invalid email address").optional(),
|
|
1552
|
+
name: import_zod4.z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
1553
|
+
role: userRoleEnum.optional(),
|
|
1554
|
+
status: userStatusEnum.optional(),
|
|
1555
|
+
emailVerified: import_zod4.z.boolean().optional(),
|
|
1556
|
+
metadata: import_zod4.z.record(import_zod4.z.unknown()).optional()
|
|
1557
|
+
});
|
|
1558
|
+
var updateProfileSchema = import_zod4.z.object({
|
|
1559
|
+
name: import_zod4.z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
1560
|
+
metadata: import_zod4.z.record(import_zod4.z.unknown()).optional()
|
|
1561
|
+
});
|
|
1562
|
+
var userQuerySchema = import_zod4.z.object({
|
|
1563
|
+
page: import_zod4.z.string().transform(Number).optional(),
|
|
1564
|
+
limit: import_zod4.z.string().transform(Number).optional(),
|
|
1565
|
+
sortBy: import_zod4.z.string().optional(),
|
|
1566
|
+
sortOrder: import_zod4.z.enum(["asc", "desc"]).optional(),
|
|
1567
|
+
status: userStatusEnum.optional(),
|
|
1568
|
+
role: userRoleEnum.optional(),
|
|
1569
|
+
search: import_zod4.z.string().optional(),
|
|
1570
|
+
emailVerified: import_zod4.z.string().transform((val) => val === "true").optional()
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
// src/modules/user/user.controller.ts
|
|
1574
|
+
var UserController = class {
|
|
1575
|
+
constructor(userService) {
|
|
1576
|
+
this.userService = userService;
|
|
1577
|
+
}
|
|
1578
|
+
async list(request, reply) {
|
|
1579
|
+
const query = validateQuery(userQuerySchema, request.query);
|
|
1580
|
+
const pagination = parsePaginationParams(query);
|
|
1581
|
+
const filters = {
|
|
1582
|
+
status: query.status,
|
|
1583
|
+
role: query.role,
|
|
1584
|
+
search: query.search,
|
|
1585
|
+
emailVerified: query.emailVerified
|
|
1586
|
+
};
|
|
1587
|
+
const result = await this.userService.findMany(pagination, filters);
|
|
1588
|
+
success(reply, result);
|
|
1589
|
+
}
|
|
1590
|
+
async getById(request, reply) {
|
|
1591
|
+
const user = await this.userService.findById(request.params.id);
|
|
1592
|
+
if (!user) {
|
|
1593
|
+
return reply.status(404).send({
|
|
1594
|
+
success: false,
|
|
1595
|
+
message: "User not found"
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
const { password, ...userData } = user;
|
|
1599
|
+
success(reply, userData);
|
|
1600
|
+
}
|
|
1601
|
+
async update(request, reply) {
|
|
1602
|
+
const data = validateBody(updateUserSchema, request.body);
|
|
1603
|
+
const user = await this.userService.update(request.params.id, data);
|
|
1604
|
+
const { password, ...userData } = user;
|
|
1605
|
+
success(reply, userData);
|
|
1606
|
+
}
|
|
1607
|
+
async delete(request, reply) {
|
|
1608
|
+
const authRequest = request;
|
|
1609
|
+
if (authRequest.user.id === request.params.id) {
|
|
1610
|
+
throw new ForbiddenError("Cannot delete your own account");
|
|
1611
|
+
}
|
|
1612
|
+
await this.userService.delete(request.params.id);
|
|
1613
|
+
noContent(reply);
|
|
1614
|
+
}
|
|
1615
|
+
async suspend(request, reply) {
|
|
1616
|
+
const authRequest = request;
|
|
1617
|
+
if (authRequest.user.id === request.params.id) {
|
|
1618
|
+
throw new ForbiddenError("Cannot suspend your own account");
|
|
1619
|
+
}
|
|
1620
|
+
const user = await this.userService.suspend(request.params.id);
|
|
1621
|
+
const { password, ...userData } = user;
|
|
1622
|
+
success(reply, userData);
|
|
1623
|
+
}
|
|
1624
|
+
async ban(request, reply) {
|
|
1625
|
+
const authRequest = request;
|
|
1626
|
+
if (authRequest.user.id === request.params.id) {
|
|
1627
|
+
throw new ForbiddenError("Cannot ban your own account");
|
|
1628
|
+
}
|
|
1629
|
+
const user = await this.userService.ban(request.params.id);
|
|
1630
|
+
const { password, ...userData } = user;
|
|
1631
|
+
success(reply, userData);
|
|
1632
|
+
}
|
|
1633
|
+
async activate(request, reply) {
|
|
1634
|
+
const user = await this.userService.activate(request.params.id);
|
|
1635
|
+
const { password, ...userData } = user;
|
|
1636
|
+
success(reply, userData);
|
|
1637
|
+
}
|
|
1638
|
+
// Profile routes (for authenticated user)
|
|
1639
|
+
async getProfile(request, reply) {
|
|
1640
|
+
const authRequest = request;
|
|
1641
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
1642
|
+
if (!user) {
|
|
1643
|
+
return reply.status(404).send({
|
|
1644
|
+
success: false,
|
|
1645
|
+
message: "User not found"
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
const { password, ...userData } = user;
|
|
1649
|
+
success(reply, userData);
|
|
1650
|
+
}
|
|
1651
|
+
async updateProfile(request, reply) {
|
|
1652
|
+
const authRequest = request;
|
|
1653
|
+
const data = validateBody(updateProfileSchema, request.body);
|
|
1654
|
+
const user = await this.userService.update(authRequest.user.id, data);
|
|
1655
|
+
const { password, ...userData } = user;
|
|
1656
|
+
success(reply, userData);
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
function createUserController(userService) {
|
|
1660
|
+
return new UserController(userService);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// src/modules/user/user.routes.ts
|
|
1664
|
+
var userTag = "Users";
|
|
1665
|
+
var userResponse = {
|
|
1666
|
+
type: "object",
|
|
1667
|
+
properties: {
|
|
1668
|
+
success: { type: "boolean", example: true },
|
|
1669
|
+
data: { type: "object" }
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
function registerUserRoutes(app, controller, authService) {
|
|
1673
|
+
const authenticate = createAuthMiddleware(authService);
|
|
1674
|
+
const isAdmin = createRoleMiddleware(["admin", "super_admin"]);
|
|
1675
|
+
const isModerator = createRoleMiddleware(["moderator", "admin", "super_admin"]);
|
|
1676
|
+
app.get(
|
|
1677
|
+
"/profile",
|
|
1678
|
+
{
|
|
1679
|
+
preHandler: [authenticate],
|
|
1680
|
+
schema: {
|
|
1681
|
+
tags: [userTag],
|
|
1682
|
+
summary: "Get current user profile",
|
|
1683
|
+
security: [{ bearerAuth: [] }],
|
|
1684
|
+
response: {
|
|
1685
|
+
200: userResponse,
|
|
1686
|
+
401: commonResponses.unauthorized
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
},
|
|
1690
|
+
controller.getProfile.bind(controller)
|
|
1691
|
+
);
|
|
1692
|
+
app.patch(
|
|
1693
|
+
"/profile",
|
|
1694
|
+
{
|
|
1695
|
+
preHandler: [authenticate],
|
|
1696
|
+
schema: {
|
|
1697
|
+
tags: [userTag],
|
|
1698
|
+
summary: "Update current user profile",
|
|
1699
|
+
security: [{ bearerAuth: [] }],
|
|
1700
|
+
body: { type: "object" },
|
|
1701
|
+
response: {
|
|
1702
|
+
200: userResponse,
|
|
1703
|
+
401: commonResponses.unauthorized,
|
|
1704
|
+
400: commonResponses.error
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
},
|
|
1708
|
+
controller.updateProfile.bind(controller)
|
|
1709
|
+
);
|
|
1710
|
+
app.get(
|
|
1711
|
+
"/users",
|
|
1712
|
+
{
|
|
1713
|
+
preHandler: [authenticate, isModerator],
|
|
1714
|
+
schema: {
|
|
1715
|
+
tags: [userTag],
|
|
1716
|
+
summary: "List users",
|
|
1717
|
+
security: [{ bearerAuth: [] }],
|
|
1718
|
+
querystring: {
|
|
1719
|
+
...paginationQuery,
|
|
1720
|
+
properties: {
|
|
1721
|
+
...paginationQuery.properties,
|
|
1722
|
+
status: { type: "string", enum: ["active", "inactive", "suspended", "banned"] },
|
|
1723
|
+
role: { type: "string", enum: ["user", "admin", "moderator", "super_admin"] },
|
|
1724
|
+
search: { type: "string" },
|
|
1725
|
+
emailVerified: { type: "boolean" }
|
|
1726
|
+
}
|
|
1727
|
+
},
|
|
1728
|
+
response: {
|
|
1729
|
+
200: commonResponses.paginated,
|
|
1730
|
+
401: commonResponses.unauthorized
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
},
|
|
1734
|
+
controller.list.bind(controller)
|
|
1735
|
+
);
|
|
1736
|
+
app.get(
|
|
1737
|
+
"/users/:id",
|
|
1738
|
+
{
|
|
1739
|
+
preHandler: [authenticate, isModerator],
|
|
1740
|
+
schema: {
|
|
1741
|
+
tags: [userTag],
|
|
1742
|
+
summary: "Get user by id",
|
|
1743
|
+
security: [{ bearerAuth: [] }],
|
|
1744
|
+
params: idParam,
|
|
1745
|
+
response: {
|
|
1746
|
+
200: userResponse,
|
|
1747
|
+
401: commonResponses.unauthorized,
|
|
1748
|
+
404: commonResponses.notFound
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
},
|
|
1752
|
+
controller.getById.bind(controller)
|
|
1753
|
+
);
|
|
1754
|
+
app.patch(
|
|
1755
|
+
"/users/:id",
|
|
1756
|
+
{
|
|
1757
|
+
preHandler: [authenticate, isAdmin],
|
|
1758
|
+
schema: {
|
|
1759
|
+
tags: [userTag],
|
|
1760
|
+
summary: "Update user",
|
|
1761
|
+
security: [{ bearerAuth: [] }],
|
|
1762
|
+
params: idParam,
|
|
1763
|
+
body: { type: "object" },
|
|
1764
|
+
response: {
|
|
1765
|
+
200: userResponse,
|
|
1766
|
+
401: commonResponses.unauthorized,
|
|
1767
|
+
404: commonResponses.notFound
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
},
|
|
1771
|
+
controller.update.bind(controller)
|
|
1772
|
+
);
|
|
1773
|
+
app.delete(
|
|
1774
|
+
"/users/:id",
|
|
1775
|
+
{
|
|
1776
|
+
preHandler: [authenticate, isAdmin],
|
|
1777
|
+
schema: {
|
|
1778
|
+
tags: [userTag],
|
|
1779
|
+
summary: "Delete user",
|
|
1780
|
+
security: [{ bearerAuth: [] }],
|
|
1781
|
+
params: idParam,
|
|
1782
|
+
response: {
|
|
1783
|
+
204: { description: "User deleted" },
|
|
1784
|
+
401: commonResponses.unauthorized,
|
|
1785
|
+
404: commonResponses.notFound
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
},
|
|
1789
|
+
controller.delete.bind(controller)
|
|
1790
|
+
);
|
|
1791
|
+
app.post(
|
|
1792
|
+
"/users/:id/suspend",
|
|
1793
|
+
{
|
|
1794
|
+
preHandler: [authenticate, isAdmin],
|
|
1795
|
+
schema: {
|
|
1796
|
+
tags: [userTag],
|
|
1797
|
+
summary: "Suspend user",
|
|
1798
|
+
security: [{ bearerAuth: [] }],
|
|
1799
|
+
params: idParam,
|
|
1800
|
+
response: {
|
|
1801
|
+
200: userResponse,
|
|
1802
|
+
401: commonResponses.unauthorized,
|
|
1803
|
+
404: commonResponses.notFound
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
},
|
|
1807
|
+
controller.suspend.bind(controller)
|
|
1808
|
+
);
|
|
1809
|
+
app.post(
|
|
1810
|
+
"/users/:id/ban",
|
|
1811
|
+
{
|
|
1812
|
+
preHandler: [authenticate, isAdmin],
|
|
1813
|
+
schema: {
|
|
1814
|
+
tags: [userTag],
|
|
1815
|
+
summary: "Ban user",
|
|
1816
|
+
security: [{ bearerAuth: [] }],
|
|
1817
|
+
params: idParam,
|
|
1818
|
+
response: {
|
|
1819
|
+
200: userResponse,
|
|
1820
|
+
401: commonResponses.unauthorized,
|
|
1821
|
+
404: commonResponses.notFound
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
},
|
|
1825
|
+
controller.ban.bind(controller)
|
|
1826
|
+
);
|
|
1827
|
+
app.post(
|
|
1828
|
+
"/users/:id/activate",
|
|
1829
|
+
{
|
|
1830
|
+
preHandler: [authenticate, isAdmin],
|
|
1831
|
+
schema: {
|
|
1832
|
+
tags: [userTag],
|
|
1833
|
+
summary: "Activate user",
|
|
1834
|
+
security: [{ bearerAuth: [] }],
|
|
1835
|
+
params: idParam,
|
|
1836
|
+
response: {
|
|
1837
|
+
200: userResponse,
|
|
1838
|
+
401: commonResponses.unauthorized,
|
|
1839
|
+
404: commonResponses.notFound
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
},
|
|
1843
|
+
controller.activate.bind(controller)
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// src/modules/user/index.ts
|
|
1848
|
+
async function registerUserModule(app, authService) {
|
|
1849
|
+
const repository = createUserRepository();
|
|
1850
|
+
const userService = createUserService(repository);
|
|
1851
|
+
const userController = createUserController(userService);
|
|
1852
|
+
registerUserRoutes(app, userController, authService);
|
|
1853
|
+
logger.info("User module registered");
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// src/modules/email/email.service.ts
|
|
1857
|
+
var import_nodemailer = __toESM(require("nodemailer"), 1);
|
|
1858
|
+
|
|
1859
|
+
// src/modules/email/templates.ts
|
|
1860
|
+
var import_handlebars = __toESM(require("handlebars"), 1);
|
|
1861
|
+
var baseLayout = `
|
|
1862
|
+
<!DOCTYPE html>
|
|
1863
|
+
<html lang="en">
|
|
1864
|
+
<head>
|
|
1865
|
+
<meta charset="UTF-8">
|
|
1866
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1867
|
+
<title>{{subject}}</title>
|
|
1868
|
+
<style>
|
|
1869
|
+
body {
|
|
1870
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
1871
|
+
line-height: 1.6;
|
|
1872
|
+
color: #333;
|
|
1873
|
+
max-width: 600px;
|
|
1874
|
+
margin: 0 auto;
|
|
1875
|
+
padding: 20px;
|
|
1876
|
+
background-color: #f5f5f5;
|
|
1877
|
+
}
|
|
1878
|
+
.container {
|
|
1879
|
+
background-color: #ffffff;
|
|
1880
|
+
border-radius: 8px;
|
|
1881
|
+
padding: 40px;
|
|
1882
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
1883
|
+
}
|
|
1884
|
+
.header {
|
|
1885
|
+
text-align: center;
|
|
1886
|
+
margin-bottom: 30px;
|
|
1887
|
+
}
|
|
1888
|
+
.logo {
|
|
1889
|
+
font-size: 24px;
|
|
1890
|
+
font-weight: bold;
|
|
1891
|
+
color: #2563eb;
|
|
1892
|
+
}
|
|
1893
|
+
.content {
|
|
1894
|
+
margin-bottom: 30px;
|
|
1895
|
+
}
|
|
1896
|
+
.button {
|
|
1897
|
+
display: inline-block;
|
|
1898
|
+
padding: 12px 24px;
|
|
1899
|
+
background-color: #2563eb;
|
|
1900
|
+
color: #ffffff !important;
|
|
1901
|
+
text-decoration: none;
|
|
1902
|
+
border-radius: 6px;
|
|
1903
|
+
font-weight: 500;
|
|
1904
|
+
}
|
|
1905
|
+
.button:hover {
|
|
1906
|
+
background-color: #1d4ed8;
|
|
1907
|
+
}
|
|
1908
|
+
.footer {
|
|
1909
|
+
text-align: center;
|
|
1910
|
+
font-size: 12px;
|
|
1911
|
+
color: #666;
|
|
1912
|
+
margin-top: 30px;
|
|
1913
|
+
padding-top: 20px;
|
|
1914
|
+
border-top: 1px solid #eee;
|
|
1915
|
+
}
|
|
1916
|
+
.warning {
|
|
1917
|
+
background-color: #fef3c7;
|
|
1918
|
+
border: 1px solid #f59e0b;
|
|
1919
|
+
border-radius: 6px;
|
|
1920
|
+
padding: 12px;
|
|
1921
|
+
margin: 20px 0;
|
|
1922
|
+
}
|
|
1923
|
+
</style>
|
|
1924
|
+
</head>
|
|
1925
|
+
<body>
|
|
1926
|
+
<div class="container">
|
|
1927
|
+
<div class="header">
|
|
1928
|
+
<div class="logo">{{appName}}</div>
|
|
1929
|
+
</div>
|
|
1930
|
+
<div class="content">
|
|
1931
|
+
{{{body}}}
|
|
1932
|
+
</div>
|
|
1933
|
+
<div class="footer">
|
|
1934
|
+
<p>© {{year}} {{appName}}. All rights reserved.</p>
|
|
1935
|
+
<p>This email was sent to {{userEmail}}</p>
|
|
1936
|
+
</div>
|
|
1937
|
+
</div>
|
|
1938
|
+
</body>
|
|
1939
|
+
</html>
|
|
1940
|
+
`;
|
|
1941
|
+
var templates = {
|
|
1942
|
+
welcome: `
|
|
1943
|
+
<h2>Welcome to {{appName}}!</h2>
|
|
1944
|
+
<p>Hi {{userName}},</p>
|
|
1945
|
+
<p>Thank you for joining {{appName}}. We're excited to have you on board!</p>
|
|
1946
|
+
<p>To get started, please verify your email address by clicking the button below:</p>
|
|
1947
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
1948
|
+
<a href="{{actionUrl}}" class="button">Verify Email</a>
|
|
1949
|
+
</p>
|
|
1950
|
+
<p>If you didn't create an account with us, you can safely ignore this email.</p>
|
|
1951
|
+
`,
|
|
1952
|
+
"verify-email": `
|
|
1953
|
+
<h2>Verify Your Email</h2>
|
|
1954
|
+
<p>Hi {{userName}},</p>
|
|
1955
|
+
<p>Please verify your email address by clicking the button below:</p>
|
|
1956
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
1957
|
+
<a href="{{actionUrl}}" class="button">Verify Email</a>
|
|
1958
|
+
</p>
|
|
1959
|
+
<p>This link will expire in {{expiresIn}}.</p>
|
|
1960
|
+
<p>If you didn't request this verification, you can safely ignore this email.</p>
|
|
1961
|
+
`,
|
|
1962
|
+
"password-reset": `
|
|
1963
|
+
<h2>Reset Your Password</h2>
|
|
1964
|
+
<p>Hi {{userName}},</p>
|
|
1965
|
+
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
|
1966
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
1967
|
+
<a href="{{actionUrl}}" class="button">Reset Password</a>
|
|
1968
|
+
</p>
|
|
1969
|
+
<p>This link will expire in {{expiresIn}}.</p>
|
|
1970
|
+
<div class="warning">
|
|
1971
|
+
<strong>Security Notice:</strong> If you didn't request this password reset, please ignore this email and your password will remain unchanged.
|
|
1972
|
+
</div>
|
|
1973
|
+
`,
|
|
1974
|
+
"password-changed": `
|
|
1975
|
+
<h2>Password Changed</h2>
|
|
1976
|
+
<p>Hi {{userName}},</p>
|
|
1977
|
+
<p>Your password has been successfully changed.</p>
|
|
1978
|
+
<p>If you didn't make this change, please contact our support team immediately and secure your account.</p>
|
|
1979
|
+
<div class="warning">
|
|
1980
|
+
<strong>Details:</strong><br>
|
|
1981
|
+
Time: {{timestamp}}<br>
|
|
1982
|
+
IP Address: {{ipAddress}}<br>
|
|
1983
|
+
Device: {{userAgent}}
|
|
1984
|
+
</div>
|
|
1985
|
+
`,
|
|
1986
|
+
"login-alert": `
|
|
1987
|
+
<h2>New Login Detected</h2>
|
|
1988
|
+
<p>Hi {{userName}},</p>
|
|
1989
|
+
<p>We detected a new login to your account.</p>
|
|
1990
|
+
<div class="warning">
|
|
1991
|
+
<strong>Login Details:</strong><br>
|
|
1992
|
+
Time: {{timestamp}}<br>
|
|
1993
|
+
IP Address: {{ipAddress}}<br>
|
|
1994
|
+
Device: {{userAgent}}<br>
|
|
1995
|
+
Location: {{location}}
|
|
1996
|
+
</div>
|
|
1997
|
+
<p>If this was you, you can safely ignore this email.</p>
|
|
1998
|
+
<p>If you didn't log in, please change your password immediately and contact support.</p>
|
|
1999
|
+
`,
|
|
2000
|
+
"account-suspended": `
|
|
2001
|
+
<h2>Account Suspended</h2>
|
|
2002
|
+
<p>Hi {{userName}},</p>
|
|
2003
|
+
<p>Your account has been suspended due to: {{reason}}</p>
|
|
2004
|
+
<p>If you believe this is a mistake, please contact our support team.</p>
|
|
2005
|
+
`
|
|
2006
|
+
};
|
|
2007
|
+
var compiledLayout = import_handlebars.default.compile(baseLayout);
|
|
2008
|
+
var compiledTemplates = {};
|
|
2009
|
+
for (const [name, template] of Object.entries(templates)) {
|
|
2010
|
+
compiledTemplates[name] = import_handlebars.default.compile(template);
|
|
2011
|
+
}
|
|
2012
|
+
function renderTemplate(templateName, data) {
|
|
2013
|
+
const template = compiledTemplates[templateName];
|
|
2014
|
+
if (!template) {
|
|
2015
|
+
throw new Error(`Template "${templateName}" not found`);
|
|
2016
|
+
}
|
|
2017
|
+
const body = template(data);
|
|
2018
|
+
return compiledLayout({
|
|
2019
|
+
...data,
|
|
2020
|
+
body,
|
|
2021
|
+
year: (/* @__PURE__ */ new Date()).getFullYear(),
|
|
2022
|
+
appName: data.appName || "Servcraft"
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
function renderCustomTemplate(htmlTemplate, data) {
|
|
2026
|
+
const template = import_handlebars.default.compile(htmlTemplate);
|
|
2027
|
+
const body = template(data);
|
|
2028
|
+
return compiledLayout({
|
|
2029
|
+
...data,
|
|
2030
|
+
body,
|
|
2031
|
+
year: (/* @__PURE__ */ new Date()).getFullYear(),
|
|
2032
|
+
appName: data.appName || "Servcraft"
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
import_handlebars.default.registerHelper("formatDate", (date) => {
|
|
2036
|
+
return new Date(date).toLocaleDateString("en-US", {
|
|
2037
|
+
year: "numeric",
|
|
2038
|
+
month: "long",
|
|
2039
|
+
day: "numeric",
|
|
2040
|
+
hour: "2-digit",
|
|
2041
|
+
minute: "2-digit"
|
|
2042
|
+
});
|
|
2043
|
+
});
|
|
2044
|
+
import_handlebars.default.registerHelper("eq", (a, b) => a === b);
|
|
2045
|
+
import_handlebars.default.registerHelper("ne", (a, b) => a !== b);
|
|
2046
|
+
|
|
2047
|
+
// src/modules/email/email.service.ts
|
|
2048
|
+
var EmailService = class {
|
|
2049
|
+
transporter = null;
|
|
2050
|
+
config = null;
|
|
2051
|
+
constructor(emailConfig) {
|
|
2052
|
+
if (emailConfig?.host || config.email.host) {
|
|
2053
|
+
this.config = {
|
|
2054
|
+
host: emailConfig?.host || config.email.host || "",
|
|
2055
|
+
port: emailConfig?.port || config.email.port || 587,
|
|
2056
|
+
secure: (emailConfig?.port || config.email.port || 587) === 465,
|
|
2057
|
+
auth: {
|
|
2058
|
+
user: emailConfig?.auth?.user || config.email.user || "",
|
|
2059
|
+
pass: emailConfig?.auth?.pass || config.email.pass || ""
|
|
2060
|
+
},
|
|
2061
|
+
from: emailConfig?.from || config.email.from || "noreply@localhost"
|
|
2062
|
+
};
|
|
2063
|
+
this.transporter = import_nodemailer.default.createTransport({
|
|
2064
|
+
host: this.config.host,
|
|
2065
|
+
port: this.config.port,
|
|
2066
|
+
secure: this.config.secure,
|
|
2067
|
+
auth: {
|
|
2068
|
+
user: this.config.auth.user,
|
|
2069
|
+
pass: this.config.auth.pass
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
logger.info("Email service initialized");
|
|
2073
|
+
} else {
|
|
2074
|
+
logger.warn("Email service not configured - emails will be logged only");
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
async send(options) {
|
|
2078
|
+
try {
|
|
2079
|
+
let html = options.html;
|
|
2080
|
+
let text = options.text;
|
|
2081
|
+
if (options.template && options.data) {
|
|
2082
|
+
html = renderTemplate(options.template, options.data);
|
|
2083
|
+
}
|
|
2084
|
+
if (html && !text) {
|
|
2085
|
+
text = this.htmlToText(html);
|
|
2086
|
+
}
|
|
2087
|
+
const mailOptions = {
|
|
2088
|
+
from: this.config?.from || "noreply@localhost",
|
|
2089
|
+
to: Array.isArray(options.to) ? options.to.join(", ") : options.to,
|
|
2090
|
+
subject: options.subject,
|
|
2091
|
+
html,
|
|
2092
|
+
text,
|
|
2093
|
+
replyTo: options.replyTo,
|
|
2094
|
+
cc: options.cc,
|
|
2095
|
+
bcc: options.bcc,
|
|
2096
|
+
attachments: options.attachments
|
|
2097
|
+
};
|
|
2098
|
+
if (!this.transporter) {
|
|
2099
|
+
logger.info({ email: mailOptions }, "Email would be sent (no transporter configured)");
|
|
2100
|
+
return { success: true, messageId: "dev-mode" };
|
|
2101
|
+
}
|
|
2102
|
+
const result = await this.transporter.sendMail(mailOptions);
|
|
2103
|
+
logger.info(
|
|
2104
|
+
{ messageId: result.messageId, to: options.to },
|
|
2105
|
+
"Email sent successfully"
|
|
2106
|
+
);
|
|
2107
|
+
return {
|
|
2108
|
+
success: true,
|
|
2109
|
+
messageId: result.messageId
|
|
2110
|
+
};
|
|
2111
|
+
} catch (error2) {
|
|
2112
|
+
const errorMessage = error2 instanceof Error ? error2.message : "Unknown error";
|
|
2113
|
+
logger.error({ err: error2, to: options.to }, "Failed to send email");
|
|
2114
|
+
return {
|
|
2115
|
+
success: false,
|
|
2116
|
+
error: errorMessage
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
async sendTemplate(to, template, data) {
|
|
2121
|
+
const subjects = {
|
|
2122
|
+
welcome: `Welcome to ${data.appName || "Servcraft"}!`,
|
|
2123
|
+
"verify-email": "Verify Your Email",
|
|
2124
|
+
"password-reset": "Reset Your Password",
|
|
2125
|
+
"password-changed": "Password Changed Successfully",
|
|
2126
|
+
"login-alert": "New Login Detected",
|
|
2127
|
+
"account-suspended": "Account Suspended"
|
|
2128
|
+
};
|
|
2129
|
+
return this.send({
|
|
2130
|
+
to,
|
|
2131
|
+
subject: subjects[template] || "Notification",
|
|
2132
|
+
template,
|
|
2133
|
+
data
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
async sendWelcome(email, name, verifyUrl) {
|
|
2137
|
+
return this.sendTemplate(email, "welcome", {
|
|
2138
|
+
userName: name,
|
|
2139
|
+
userEmail: email,
|
|
2140
|
+
actionUrl: verifyUrl
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
async sendVerifyEmail(email, name, verifyUrl) {
|
|
2144
|
+
return this.sendTemplate(email, "verify-email", {
|
|
2145
|
+
userName: name,
|
|
2146
|
+
userEmail: email,
|
|
2147
|
+
actionUrl: verifyUrl,
|
|
2148
|
+
expiresIn: "24 hours"
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
async sendPasswordReset(email, name, resetUrl) {
|
|
2152
|
+
return this.sendTemplate(email, "password-reset", {
|
|
2153
|
+
userName: name,
|
|
2154
|
+
userEmail: email,
|
|
2155
|
+
actionUrl: resetUrl,
|
|
2156
|
+
expiresIn: "1 hour"
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
async sendPasswordChanged(email, name, ipAddress, userAgent) {
|
|
2160
|
+
return this.sendTemplate(email, "password-changed", {
|
|
2161
|
+
userName: name,
|
|
2162
|
+
userEmail: email,
|
|
2163
|
+
ipAddress: ipAddress || "Unknown",
|
|
2164
|
+
userAgent: userAgent || "Unknown",
|
|
2165
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
async sendLoginAlert(email, name, ipAddress, userAgent, location) {
|
|
2169
|
+
return this.sendTemplate(email, "login-alert", {
|
|
2170
|
+
userName: name,
|
|
2171
|
+
userEmail: email,
|
|
2172
|
+
ipAddress,
|
|
2173
|
+
userAgent,
|
|
2174
|
+
location: location || "Unknown",
|
|
2175
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
async verify() {
|
|
2179
|
+
if (!this.transporter) {
|
|
2180
|
+
return false;
|
|
2181
|
+
}
|
|
2182
|
+
try {
|
|
2183
|
+
await this.transporter.verify();
|
|
2184
|
+
logger.info("Email service connection verified");
|
|
2185
|
+
return true;
|
|
2186
|
+
} catch (error2) {
|
|
2187
|
+
logger.error({ err: error2 }, "Email service connection failed");
|
|
2188
|
+
return false;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
htmlToText(html) {
|
|
2192
|
+
return html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
2193
|
+
}
|
|
2194
|
+
};
|
|
2195
|
+
var emailService = null;
|
|
2196
|
+
function getEmailService() {
|
|
2197
|
+
if (!emailService) {
|
|
2198
|
+
emailService = new EmailService();
|
|
2199
|
+
}
|
|
2200
|
+
return emailService;
|
|
2201
|
+
}
|
|
2202
|
+
function createEmailService(config2) {
|
|
2203
|
+
return new EmailService(config2);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// src/modules/audit/audit.service.ts
|
|
2207
|
+
var import_crypto2 = require("crypto");
|
|
2208
|
+
var auditLogs = /* @__PURE__ */ new Map();
|
|
2209
|
+
var AuditService = class {
|
|
2210
|
+
async log(entry) {
|
|
2211
|
+
const id = (0, import_crypto2.randomUUID)();
|
|
2212
|
+
const auditEntry = {
|
|
2213
|
+
...entry,
|
|
2214
|
+
id,
|
|
2215
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
2216
|
+
};
|
|
2217
|
+
auditLogs.set(id, auditEntry);
|
|
2218
|
+
logger.info(
|
|
2219
|
+
{
|
|
2220
|
+
audit: true,
|
|
2221
|
+
userId: entry.userId,
|
|
2222
|
+
action: entry.action,
|
|
2223
|
+
resource: entry.resource,
|
|
2224
|
+
resourceId: entry.resourceId,
|
|
2225
|
+
ipAddress: entry.ipAddress
|
|
2226
|
+
},
|
|
2227
|
+
`Audit: ${entry.action} on ${entry.resource}`
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
async query(params) {
|
|
2231
|
+
const { page = 1, limit = 20 } = params;
|
|
2232
|
+
let logs = Array.from(auditLogs.values());
|
|
2233
|
+
if (params.userId) {
|
|
2234
|
+
logs = logs.filter((log) => log.userId === params.userId);
|
|
2235
|
+
}
|
|
2236
|
+
if (params.action) {
|
|
2237
|
+
logs = logs.filter((log) => log.action === params.action);
|
|
2238
|
+
}
|
|
2239
|
+
if (params.resource) {
|
|
2240
|
+
logs = logs.filter((log) => log.resource === params.resource);
|
|
2241
|
+
}
|
|
2242
|
+
if (params.resourceId) {
|
|
2243
|
+
logs = logs.filter((log) => log.resourceId === params.resourceId);
|
|
2244
|
+
}
|
|
2245
|
+
if (params.startDate) {
|
|
2246
|
+
logs = logs.filter((log) => log.createdAt >= params.startDate);
|
|
2247
|
+
}
|
|
2248
|
+
if (params.endDate) {
|
|
2249
|
+
logs = logs.filter((log) => log.createdAt <= params.endDate);
|
|
2250
|
+
}
|
|
2251
|
+
logs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
2252
|
+
const total = logs.length;
|
|
2253
|
+
const skip = (page - 1) * limit;
|
|
2254
|
+
const data = logs.slice(skip, skip + limit);
|
|
2255
|
+
return createPaginatedResult(data, total, { page, limit });
|
|
2256
|
+
}
|
|
2257
|
+
async findByUser(userId, limit = 50) {
|
|
2258
|
+
const result = await this.query({ userId, limit });
|
|
2259
|
+
return result.data;
|
|
2260
|
+
}
|
|
2261
|
+
async findByResource(resource, resourceId, limit = 50) {
|
|
2262
|
+
const result = await this.query({ resource, resourceId, limit });
|
|
2263
|
+
return result.data;
|
|
2264
|
+
}
|
|
2265
|
+
// Shortcut methods for common audit events
|
|
2266
|
+
async logCreate(resource, resourceId, userId, newValue, meta) {
|
|
2267
|
+
await this.log({
|
|
2268
|
+
action: "create",
|
|
2269
|
+
resource,
|
|
2270
|
+
resourceId,
|
|
2271
|
+
userId,
|
|
2272
|
+
newValue,
|
|
2273
|
+
...meta
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
async logUpdate(resource, resourceId, userId, oldValue, newValue, meta) {
|
|
2277
|
+
await this.log({
|
|
2278
|
+
action: "update",
|
|
2279
|
+
resource,
|
|
2280
|
+
resourceId,
|
|
2281
|
+
userId,
|
|
2282
|
+
oldValue,
|
|
2283
|
+
newValue,
|
|
2284
|
+
...meta
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
async logDelete(resource, resourceId, userId, oldValue, meta) {
|
|
2288
|
+
await this.log({
|
|
2289
|
+
action: "delete",
|
|
2290
|
+
resource,
|
|
2291
|
+
resourceId,
|
|
2292
|
+
userId,
|
|
2293
|
+
oldValue,
|
|
2294
|
+
...meta
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
async logLogin(userId, meta) {
|
|
2298
|
+
await this.log({
|
|
2299
|
+
action: "login",
|
|
2300
|
+
resource: "auth",
|
|
2301
|
+
userId,
|
|
2302
|
+
...meta
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
async logLogout(userId, meta) {
|
|
2306
|
+
await this.log({
|
|
2307
|
+
action: "logout",
|
|
2308
|
+
resource: "auth",
|
|
2309
|
+
userId,
|
|
2310
|
+
...meta
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
async logPasswordChange(userId, meta) {
|
|
2314
|
+
await this.log({
|
|
2315
|
+
action: "password_change",
|
|
2316
|
+
resource: "auth",
|
|
2317
|
+
userId,
|
|
2318
|
+
...meta
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
// Clear all logs (for testing)
|
|
2322
|
+
async clear() {
|
|
2323
|
+
auditLogs.clear();
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
var auditService = null;
|
|
2327
|
+
function getAuditService() {
|
|
2328
|
+
if (!auditService) {
|
|
2329
|
+
auditService = new AuditService();
|
|
2330
|
+
}
|
|
2331
|
+
return auditService;
|
|
2332
|
+
}
|
|
2333
|
+
function createAuditService() {
|
|
2334
|
+
return new AuditService();
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// src/index.ts
|
|
2338
|
+
async function bootstrap() {
|
|
2339
|
+
const server = createServer({
|
|
2340
|
+
port: config.server.port,
|
|
2341
|
+
host: config.server.host
|
|
2342
|
+
});
|
|
2343
|
+
const app = server.instance;
|
|
2344
|
+
registerErrorHandler(app);
|
|
2345
|
+
await registerSecurity(app);
|
|
2346
|
+
await registerSwagger(app, {
|
|
2347
|
+
enabled: config.swagger.enabled,
|
|
2348
|
+
route: config.swagger.route,
|
|
2349
|
+
title: config.swagger.title,
|
|
2350
|
+
description: config.swagger.description,
|
|
2351
|
+
version: config.swagger.version
|
|
2352
|
+
});
|
|
2353
|
+
const authService = await registerAuthModule(app);
|
|
2354
|
+
await registerUserModule(app, authService);
|
|
2355
|
+
await server.start();
|
|
2356
|
+
logger.info({
|
|
2357
|
+
env: config.env.NODE_ENV,
|
|
2358
|
+
port: config.server.port
|
|
2359
|
+
}, "Servcraft server started");
|
|
2360
|
+
}
|
|
2361
|
+
bootstrap().catch((err) => {
|
|
2362
|
+
logger.error({ err }, "Failed to start server");
|
|
2363
|
+
process.exit(1);
|
|
2364
|
+
});
|
|
2365
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2366
|
+
0 && (module.exports = {
|
|
2367
|
+
AppError,
|
|
2368
|
+
AuditService,
|
|
2369
|
+
AuthController,
|
|
2370
|
+
AuthService,
|
|
2371
|
+
BadRequestError,
|
|
2372
|
+
ConflictError,
|
|
2373
|
+
DEFAULT_LIMIT,
|
|
2374
|
+
DEFAULT_PAGE,
|
|
2375
|
+
DEFAULT_ROLE_PERMISSIONS,
|
|
2376
|
+
EmailService,
|
|
2377
|
+
ForbiddenError,
|
|
2378
|
+
MAX_LIMIT,
|
|
2379
|
+
NotFoundError,
|
|
2380
|
+
Server,
|
|
2381
|
+
TooManyRequestsError,
|
|
2382
|
+
UnauthorizedError,
|
|
2383
|
+
UserController,
|
|
2384
|
+
UserRepository,
|
|
2385
|
+
UserService,
|
|
2386
|
+
ValidationError,
|
|
2387
|
+
badRequest,
|
|
2388
|
+
changePasswordSchema,
|
|
2389
|
+
config,
|
|
2390
|
+
conflict,
|
|
2391
|
+
createAuditService,
|
|
2392
|
+
createAuthController,
|
|
2393
|
+
createAuthMiddleware,
|
|
2394
|
+
createAuthService,
|
|
2395
|
+
createConfig,
|
|
2396
|
+
createEmailService,
|
|
2397
|
+
createLogger,
|
|
2398
|
+
createOptionalAuthMiddleware,
|
|
2399
|
+
createPaginatedResult,
|
|
2400
|
+
createPermissionMiddleware,
|
|
2401
|
+
createRoleMiddleware,
|
|
2402
|
+
createServer,
|
|
2403
|
+
createUserController,
|
|
2404
|
+
createUserRepository,
|
|
2405
|
+
createUserSchema,
|
|
2406
|
+
createUserService,
|
|
2407
|
+
created,
|
|
2408
|
+
dateSchema,
|
|
2409
|
+
emailSchema,
|
|
2410
|
+
env,
|
|
2411
|
+
error,
|
|
2412
|
+
forbidden,
|
|
2413
|
+
futureDateSchema,
|
|
2414
|
+
getAuditService,
|
|
2415
|
+
getEmailService,
|
|
2416
|
+
getSkip,
|
|
2417
|
+
idParamSchema,
|
|
2418
|
+
internalError,
|
|
2419
|
+
isAppError,
|
|
2420
|
+
isDevelopment,
|
|
2421
|
+
isProduction,
|
|
2422
|
+
isStaging,
|
|
2423
|
+
isTest,
|
|
2424
|
+
logger,
|
|
2425
|
+
loginSchema,
|
|
2426
|
+
noContent,
|
|
2427
|
+
notFound,
|
|
2428
|
+
paginationSchema,
|
|
2429
|
+
parsePaginationParams,
|
|
2430
|
+
passwordResetConfirmSchema,
|
|
2431
|
+
passwordResetRequestSchema,
|
|
2432
|
+
passwordSchema,
|
|
2433
|
+
pastDateSchema,
|
|
2434
|
+
phoneSchema,
|
|
2435
|
+
refreshTokenSchema,
|
|
2436
|
+
registerAuthModule,
|
|
2437
|
+
registerBruteForceProtection,
|
|
2438
|
+
registerErrorHandler,
|
|
2439
|
+
registerSchema,
|
|
2440
|
+
registerSecurity,
|
|
2441
|
+
registerUserModule,
|
|
2442
|
+
renderCustomTemplate,
|
|
2443
|
+
renderTemplate,
|
|
2444
|
+
searchSchema,
|
|
2445
|
+
success,
|
|
2446
|
+
unauthorized,
|
|
2447
|
+
updateProfileSchema,
|
|
2448
|
+
updateUserSchema,
|
|
2449
|
+
urlSchema,
|
|
2450
|
+
userQuerySchema,
|
|
2451
|
+
userRoleEnum,
|
|
2452
|
+
userStatusEnum,
|
|
2453
|
+
validate,
|
|
2454
|
+
validateBody,
|
|
2455
|
+
validateParams,
|
|
2456
|
+
validateQuery
|
|
2457
|
+
});
|
|
2458
|
+
//# sourceMappingURL=index.cjs.map
|