create-forgeapi 1.0.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/README.md +79 -0
- package/dist/bin/index.d.ts +2 -0
- package/dist/bin/index.js +286 -0
- package/package.json +37 -0
- package/template/.env.example +7 -0
- package/template/nodemon.json +7 -0
- package/template/package.json +21 -0
- package/template/src/App.ts +1 -0
- package/template/src/api/controllers/UserController.ts +54 -0
- package/template/src/api/middlewares/AuthMiddleware.ts +24 -0
- package/template/src/base/BaseRepository.ts +112 -0
- package/template/src/base/BaseResponse.ts +45 -0
- package/template/src/base/BaseResponseError.ts +12 -0
- package/template/src/base/BaseSchema.ts +82 -0
- package/template/src/config/DateTimeConfig.ts +4 -0
- package/template/src/config/GlobalHandlerError.ts +81 -0
- package/template/src/config/LoggerConfig.ts +157 -0
- package/template/src/config/LookupConfig.ts +68 -0
- package/template/src/config/MetaDataConfig.ts +25 -0
- package/template/src/config/RouteConfig.ts +98 -0
- package/template/src/config/SwaggerConfig.ts +147 -0
- package/template/src/config/envConfig.ts +5 -0
- package/template/src/core/Server.ts +75 -0
- package/template/src/core/exceptions/BadRequestException.ts +7 -0
- package/template/src/core/exceptions/ForbiddenException.ts +7 -0
- package/template/src/core/exceptions/NotFoundException.ts +7 -0
- package/template/src/core/exceptions/UnauthorizedException.ts +7 -0
- package/template/src/core/repositories/UserRepository.ts +53 -0
- package/template/src/database/DatabaseConnection.ts +11 -0
- package/template/src/database/builder/CustomBuilder.ts +177 -0
- package/template/src/database/builder/CustomFilter.ts +12 -0
- package/template/src/database/builder/MultipleSearchCriteria.ts +25 -0
- package/template/src/database/builder/SearchCriteria.ts +79 -0
- package/template/src/database/entity/ProfileModel.ts +54 -0
- package/template/src/database/entity/UserModel.ts +46 -0
- package/template/src/shared/decorators/ApiDecorator.ts +88 -0
- package/template/src/shared/decorators/LookupDecorator.ts +51 -0
- package/template/src/shared/decorators/MethodeDecorator.ts +38 -0
- package/template/src/shared/helpers/VirtualHelper.ts +53 -0
- package/template/src/shared/interfaces/BaseInterface.ts +14 -0
- package/template/src/shared/interfaces/DatabaseInterface.ts +41 -0
- package/template/src/shared/interfaces/HttpInterface.ts +37 -0
- package/template/src/shared/interfaces/LoggerOptionsInterface.ts +16 -0
- package/template/src/shared/interfaces/MiddlewareInterface.ts +14 -0
- package/template/src/shared/interfaces/RouteMetadataInterface.ts +10 -0
- package/template/src/shared/interfaces/SoftDeleteInterface.ts +8 -0
- package/template/src/shared/interfaces/SwaggerMetadataInterface.ts +12 -0
- package/template/src/shared/interfaces/UserInterface.ts +37 -0
- package/template/src/shared/interfaces/user/UserCreateInterface.ts +4 -0
- package/template/src/shared/models/enum/MiddlewareEnum.ts +19 -0
- package/template/src/shared/models/request/UserCreateRequest.ts +12 -0
- package/template/src/shared/models/response/ApiResponse.ts +27 -0
- package/template/src/shared/types/express.d.ts +14 -0
- package/template/src/shared/types/mongoose.d.ts +7 -0
- package/template/src/shared/utils/ColorUtil.ts +17 -0
- package/template/src/shared/utils/DatabaseUtil.ts +3 -0
- package/template/src/shared/utils/DecoratorUtil.ts +4 -0
- package/template/src/shared/utils/FolderUtil.ts +15 -0
- package/template/src/shared/utils/JwtUtil.ts +21 -0
- package/template/src/shared/utils/SwaggerUtil.ts +107 -0
- package/template/src/shared/utils/TimeUtil.ts +74 -0
- package/template/tsconfig.dev.json +9 -0
- package/template/tsconfig.json +33 -0
- package/template/tsconfig.prod.json +10 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import RouteConfig from "@config/RouteConfig";
|
|
2
|
+
import {GetControllerMetadata} from "@shared/decorators/ApiDecorator";
|
|
3
|
+
import SwaggerUtil from "@shared/utils/SwaggerUtil";
|
|
4
|
+
import {IRouteDefinition} from "@shared/interfaces/RouteMetadataInterface";
|
|
5
|
+
import {ErrorResponse, SuccessResponse} from "@shared/models/response/ApiResponse";
|
|
6
|
+
import {z} from "zod";
|
|
7
|
+
|
|
8
|
+
// Default responses yang otomatis dipakai jika @BodyResponse tidak didefinisikan
|
|
9
|
+
const DEFAULT_RESPONSES: Record<string, { description: string; schema: any }> = {
|
|
10
|
+
'200': {
|
|
11
|
+
description: 'Success',
|
|
12
|
+
schema: SwaggerUtil.generateSchema(SuccessResponse(z.object({})))
|
|
13
|
+
},
|
|
14
|
+
'400': {
|
|
15
|
+
description: 'Bad Request',
|
|
16
|
+
schema: SwaggerUtil.generateSchema(ErrorResponse())
|
|
17
|
+
},
|
|
18
|
+
'401': {
|
|
19
|
+
description: 'Unauthorized',
|
|
20
|
+
schema: SwaggerUtil.generateSchema(ErrorResponse())
|
|
21
|
+
},
|
|
22
|
+
'500': {
|
|
23
|
+
description: 'Internal Server Error',
|
|
24
|
+
schema: SwaggerUtil.generateSchema(ErrorResponse())
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export default class SwaggerConfig extends RouteConfig {
|
|
30
|
+
|
|
31
|
+
protected static MainObject = {
|
|
32
|
+
openapi: '3.0.0',
|
|
33
|
+
info: {
|
|
34
|
+
title: 'REST API Documentation',
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
description: 'Auto-generated API documentation with TypeScript decorators'
|
|
37
|
+
},
|
|
38
|
+
servers: [
|
|
39
|
+
{
|
|
40
|
+
url: 'http://localhost:3000',
|
|
41
|
+
description: 'Local development server'
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
,
|
|
45
|
+
components: {
|
|
46
|
+
securitySchemes: {
|
|
47
|
+
bearerAuth: {
|
|
48
|
+
type: 'http',
|
|
49
|
+
scheme: 'bearer',
|
|
50
|
+
bearerFormat: 'JWT',
|
|
51
|
+
description: 'Enter your JWT token'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
static generate() {
|
|
58
|
+
const paths: any = {};
|
|
59
|
+
|
|
60
|
+
RouteConfig.getControllers().forEach(ControllerClass => {
|
|
61
|
+
const metadata = GetControllerMetadata(ControllerClass);
|
|
62
|
+
|
|
63
|
+
metadata.routes.forEach((route: IRouteDefinition) => {
|
|
64
|
+
const fullPath = `${metadata.basePath}${route.path}`
|
|
65
|
+
.replace(/:/g, '{')
|
|
66
|
+
.replace(/{(\w+)}/g, '{$1}');
|
|
67
|
+
|
|
68
|
+
if (!paths[fullPath]) {
|
|
69
|
+
paths[fullPath] = {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const swagger = route.swagger || {};
|
|
73
|
+
const requireAuth = route.authenticate?.authenticate === true; // true hanya jika explisit { authenticate: true }
|
|
74
|
+
|
|
75
|
+
const operation: any = {
|
|
76
|
+
tags: metadata.swagger.tags || [],
|
|
77
|
+
summary: swagger.summary || '',
|
|
78
|
+
description: swagger.description || swagger.summary || '',
|
|
79
|
+
parameters: [],
|
|
80
|
+
responses: {},
|
|
81
|
+
// Add security if authentication required
|
|
82
|
+
...(requireAuth && { security: [{ bearerAuth: [] }] })
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (swagger.paramsType) {
|
|
86
|
+
operation.parameters.push(
|
|
87
|
+
...SwaggerUtil.generateParameters(swagger.paramsType, 'path')
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (swagger.queryType) {
|
|
92
|
+
operation.parameters.push(
|
|
93
|
+
...SwaggerUtil.generateParameters(swagger.queryType, 'query')
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (swagger.bodyType) {
|
|
98
|
+
operation.requestBody = {
|
|
99
|
+
required: true,
|
|
100
|
+
content: {
|
|
101
|
+
'application/json': {
|
|
102
|
+
schema: SwaggerUtil.generateSchema(swagger.bodyType)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Mulai dari default responses, lalu timpa dengan yang didefinisikan via @BodyResponse
|
|
109
|
+
const mergedResponses: Record<string, any> = {
|
|
110
|
+
...DEFAULT_RESPONSES,
|
|
111
|
+
...(requireAuth ? {} : { '401': undefined }) // hapus 401 default jika tidak butuh auth
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (swagger.responses) {
|
|
115
|
+
for (const [statusCode, config] of Object.entries(swagger.responses)) {
|
|
116
|
+
const responseConfig: any = config;
|
|
117
|
+
mergedResponses[statusCode] = {
|
|
118
|
+
description: responseConfig.description || 'Success',
|
|
119
|
+
schema: responseConfig.type
|
|
120
|
+
? SwaggerUtil.generateSchema(responseConfig.type)
|
|
121
|
+
: { type: 'object' }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const [statusCode, config] of Object.entries(mergedResponses)) {
|
|
127
|
+
if (!config) continue; // skip yang di-undefined-kan
|
|
128
|
+
operation.responses[statusCode] = {
|
|
129
|
+
description: config.description,
|
|
130
|
+
content: {
|
|
131
|
+
'application/json': {
|
|
132
|
+
schema: config.schema
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
paths[fullPath][route.method] = operation;
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
...this.MainObject,
|
|
144
|
+
paths
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import "@config/envConfig"
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import express, {Router} from "express";
|
|
4
|
+
import swaggerUi from 'swagger-ui-express';
|
|
5
|
+
import expressRateLimit from "express-rate-limit";
|
|
6
|
+
import {log, serverLogger} from "@config/LoggerConfig";
|
|
7
|
+
import GlobalHandlerError from "@config/GlobalHandlerError";
|
|
8
|
+
import UnauthorizedException from "@core/exceptions/UnauthorizedException";
|
|
9
|
+
import DatabaseConnection from "@database/DatabaseConnection";
|
|
10
|
+
import LookupConfig from "@config/LookupConfig";
|
|
11
|
+
import {UserLookup} from "@database/entity/UserModel";
|
|
12
|
+
import {ProfileLookup} from "@database/entity/ProfileModel";
|
|
13
|
+
import SwaggerConfig from "@config/SwaggerConfig";
|
|
14
|
+
import RouteConfig from "@config/RouteConfig";
|
|
15
|
+
import UserController from "@api/controllers/UserController";
|
|
16
|
+
|
|
17
|
+
DatabaseConnection().then(() => {
|
|
18
|
+
log.info("✅ Database connected successfully.");
|
|
19
|
+
LookupConfig.init([UserLookup, ProfileLookup])
|
|
20
|
+
log.info("🚀 Starting server...");
|
|
21
|
+
RouteConfig.register(UserController)
|
|
22
|
+
const app = express();
|
|
23
|
+
|
|
24
|
+
app.use(serverLogger({
|
|
25
|
+
enableFileLog: false,
|
|
26
|
+
enableConsoleLog: true
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
app.use(express.json({
|
|
30
|
+
limit: '20mb'
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
app.use(express.urlencoded({extended: true}));
|
|
34
|
+
app.use(cors({
|
|
35
|
+
origin: '*',
|
|
36
|
+
methods: ['GET', 'POST', 'DELETE'],
|
|
37
|
+
allowedHeaders: "*"
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Rate Limiter
|
|
41
|
+
const limiter = expressRateLimit({
|
|
42
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
43
|
+
max: 100, // limit each IP to 100 requests per windowMs
|
|
44
|
+
standardHeaders: true,
|
|
45
|
+
legacyHeaders: false,
|
|
46
|
+
message: 'Too many requests from this IP, please try again later.'
|
|
47
|
+
});
|
|
48
|
+
app.use(limiter);
|
|
49
|
+
|
|
50
|
+
// Register routes
|
|
51
|
+
const router = RouteConfig.buildRoutes(Router());
|
|
52
|
+
app.use(router);
|
|
53
|
+
|
|
54
|
+
// Test error endpoint
|
|
55
|
+
app.get("/test-error", (req, res, next) => {
|
|
56
|
+
next(new UnauthorizedException("Test unauthorized error"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const swaggerDoc = SwaggerConfig.generate();
|
|
60
|
+
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDoc));
|
|
61
|
+
|
|
62
|
+
// 404 handler harus sebelum error handler
|
|
63
|
+
app.use(GlobalHandlerError.notFound);
|
|
64
|
+
|
|
65
|
+
// Error handler harus terakhir (4 parameters)
|
|
66
|
+
app.use(GlobalHandlerError.error);
|
|
67
|
+
|
|
68
|
+
app.listen(process.env.SERVER_PORT || 3000, () => {
|
|
69
|
+
log.info('Server is running on port ' + (process.env.SERVER_PORT || 3000));
|
|
70
|
+
log.info("━".repeat(60));
|
|
71
|
+
})
|
|
72
|
+
}).catch((err) => {
|
|
73
|
+
log.error("❌ Database connection failed:", err);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import BaseRepository from "@base/BaseRepository";
|
|
2
|
+
import {IUser, IUserCreate, IUserProfileCreate} from "@shared/interfaces/UserInterface";
|
|
3
|
+
import UserEntity from "@database/entity/UserModel";
|
|
4
|
+
import ProfileEntity from "@database/entity/ProfileModel";
|
|
5
|
+
import {dbCreateSeason} from "@shared/utils/DatabaseUtil";
|
|
6
|
+
|
|
7
|
+
export default class UserRepository extends BaseRepository<IUser>{
|
|
8
|
+
constructor() {
|
|
9
|
+
super(UserEntity);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async createWithProfile(
|
|
13
|
+
userData: IUserCreate,
|
|
14
|
+
profileData: IUserProfileCreate,
|
|
15
|
+
idUser?: ObjectId
|
|
16
|
+
) {
|
|
17
|
+
const session = await dbCreateSeason();
|
|
18
|
+
session.startTransaction();
|
|
19
|
+
try {
|
|
20
|
+
// Create user
|
|
21
|
+
const user = new this.model({
|
|
22
|
+
...userData,
|
|
23
|
+
createdBy: idUser,
|
|
24
|
+
modifiedBy: idUser
|
|
25
|
+
})
|
|
26
|
+
await user.save({ session });
|
|
27
|
+
|
|
28
|
+
// Create profile linked to the user
|
|
29
|
+
const profile = new ProfileEntity({
|
|
30
|
+
...profileData,
|
|
31
|
+
idUser,
|
|
32
|
+
createdBy: idUser,
|
|
33
|
+
modifiedBy: idUser
|
|
34
|
+
})
|
|
35
|
+
await profile.save({ session });
|
|
36
|
+
|
|
37
|
+
// Link profile to user
|
|
38
|
+
user.idProfile = profile._id;
|
|
39
|
+
await user.save({ session });
|
|
40
|
+
|
|
41
|
+
// Commit transaction
|
|
42
|
+
await session.commitTransaction();
|
|
43
|
+
|
|
44
|
+
// Return the created user with profile populated
|
|
45
|
+
return user;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await session.abortTransaction();
|
|
48
|
+
throw new Error("Internal Server Error");
|
|
49
|
+
} finally {
|
|
50
|
+
await session.endSession();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import mongoose from "mongoose";
|
|
2
|
+
import {log} from "@config/LoggerConfig";
|
|
3
|
+
|
|
4
|
+
export default async () => {
|
|
5
|
+
log.info("━".repeat(60));
|
|
6
|
+
log.info("🔄️Connecting to MongoDB");
|
|
7
|
+
await mongoose.connect(process.env.MONGO_DB_URL || "http://localhost:27017/local",{
|
|
8
|
+
user: process.env.MONGO_DB_USER,
|
|
9
|
+
pass: process.env.MONGO_DB_PASSWORD
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import SearchCriteria from "@database/builder/SearchCriteria";
|
|
2
|
+
import MultipleSearchCriteria from "@database/builder/MultipleSearchCriteria";
|
|
3
|
+
import MetaDataConfig from "@config/MetaDataConfig";
|
|
4
|
+
import LookupConfig from "@config/LookupConfig";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export default class CustomBuilder<T> {
|
|
8
|
+
private criteria: Array<SearchCriteria | MultipleSearchCriteria> = [];
|
|
9
|
+
private requiredLookups: Set<string> = new Set();
|
|
10
|
+
private entityClass?: any;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param entityClass - Optional Entity class to auto-include all @Lookup decorators
|
|
14
|
+
*/
|
|
15
|
+
constructor(entityClass?: any) {
|
|
16
|
+
if (entityClass) {
|
|
17
|
+
this.entityClass = entityClass;
|
|
18
|
+
this.withLookupsFrom(entityClass);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Auto-include all lookups defined in Entity class via @Lookup decorator
|
|
24
|
+
* @param entityClass - The Entity class (e.g., UserEntity)
|
|
25
|
+
*/
|
|
26
|
+
withLookupsFrom(entityClass: any): CustomBuilder<T> {
|
|
27
|
+
// Use originalClassName from @Entity decorator, fallback to name
|
|
28
|
+
const className = entityClass.originalClassName || entityClass.name;
|
|
29
|
+
const lookupKeys = MetaDataConfig.lookupGetKeys(className);
|
|
30
|
+
lookupKeys.forEach(key => this.requiredLookups.add(key));
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
with(criterion: SearchCriteria | MultipleSearchCriteria): CustomBuilder<T> {
|
|
35
|
+
this.criteria.push(criterion);
|
|
36
|
+
|
|
37
|
+
if (criterion instanceof SearchCriteria && criterion.isLookupOperation()) {
|
|
38
|
+
const lookupInfo = criterion.getLookupInfo();
|
|
39
|
+
if (lookupInfo) {
|
|
40
|
+
this.requiredLookups.add(lookupInfo.prefix);
|
|
41
|
+
}
|
|
42
|
+
} else if (criterion instanceof MultipleSearchCriteria) {
|
|
43
|
+
criterion.criteriaList.forEach(c => {
|
|
44
|
+
if (c.isLookupOperation()) {
|
|
45
|
+
const lookupInfo = c.getLookupInfo();
|
|
46
|
+
if (lookupInfo) {
|
|
47
|
+
this.requiredLookups.add(lookupInfo.prefix);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Explicitly add a lookup to join related collection
|
|
58
|
+
* @param lookupName - The registered lookup name (e.g., 'profile', 'user')
|
|
59
|
+
*/
|
|
60
|
+
withLookup(lookupName: string): CustomBuilder<T> {
|
|
61
|
+
this.requiredLookups.add(lookupName);
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
build(): any {
|
|
66
|
+
if (this.criteria.length === 0) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.criteria.length === 1) {
|
|
71
|
+
return this.criteria[0].toMongoQuery();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const queries = this.criteria.map(c => c.toMongoQuery());
|
|
75
|
+
return { $and: queries };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
buildAggregation(): any[] {
|
|
79
|
+
const pipeline: any[] = [];
|
|
80
|
+
|
|
81
|
+
this.requiredLookups.forEach(prefix => {
|
|
82
|
+
const config = LookupConfig.get(prefix);
|
|
83
|
+
if (config) {
|
|
84
|
+
pipeline.push({
|
|
85
|
+
$lookup: {
|
|
86
|
+
from: config.from,
|
|
87
|
+
localField: config.localField,
|
|
88
|
+
foreignField: config.foreignField,
|
|
89
|
+
as: config.as
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (config.unwind) {
|
|
94
|
+
pipeline.push({
|
|
95
|
+
$unwind: {
|
|
96
|
+
path: `$${config.as}`,
|
|
97
|
+
preserveNullAndEmptyArrays: true
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Auto-apply virtual fields for the looked-up entity
|
|
103
|
+
this.applyVirtualFields(pipeline, prefix, config.from);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (this.criteria.length > 0) {
|
|
108
|
+
pipeline.push({
|
|
109
|
+
$match: this.build()
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return pipeline;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply virtual/computed fields from @Virtual decorators to aggregation pipeline
|
|
118
|
+
*/
|
|
119
|
+
private applyVirtualFields(pipeline: any[], lookupPrefix: string, collectionName: string): void {
|
|
120
|
+
// Map collection names to entity class names
|
|
121
|
+
const collectionToEntity: Record<string, string> = {
|
|
122
|
+
'profiles': 'ProfileEntity',
|
|
123
|
+
'users': 'UserEntity'
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const entityName = collectionToEntity[collectionName];
|
|
127
|
+
if (!entityName) return;
|
|
128
|
+
|
|
129
|
+
const virtualFields = MetaDataConfig.virtualGetFields(entityName);
|
|
130
|
+
if (virtualFields.length === 0) return;
|
|
131
|
+
|
|
132
|
+
const addFieldsStage: any = { $addFields: {} };
|
|
133
|
+
|
|
134
|
+
virtualFields.forEach(virtual => {
|
|
135
|
+
const fieldPath = `${lookupPrefix}.${virtual.field}`;
|
|
136
|
+
// Replace field references in expression to include lookup prefix
|
|
137
|
+
addFieldsStage.$addFields[fieldPath] = this.prefixFieldPaths(virtual.aggregationExpression, lookupPrefix);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
pipeline.push(addFieldsStage);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Prefix field paths in aggregation expression with lookup prefix
|
|
145
|
+
*/
|
|
146
|
+
private prefixFieldPaths(expression: any, prefix: string): any {
|
|
147
|
+
if (typeof expression === 'string' && expression.startsWith('$')) {
|
|
148
|
+
// Replace $fieldName with $prefix.fieldName
|
|
149
|
+
const fieldName = expression.substring(1);
|
|
150
|
+
return `$${prefix}.${fieldName}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (Array.isArray(expression)) {
|
|
154
|
+
return expression.map(item => this.prefixFieldPaths(item, prefix));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof expression === 'object' && expression !== null) {
|
|
158
|
+
const result: any = {};
|
|
159
|
+
for (const [key, value] of Object.entries(expression)) {
|
|
160
|
+
result[key] = this.prefixFieldPaths(value, prefix);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return expression;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
hasLookups(): boolean {
|
|
169
|
+
return this.requiredLookups.size > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
clear(): CustomBuilder<T> {
|
|
173
|
+
this.criteria = [];
|
|
174
|
+
this.requiredLookups.clear();
|
|
175
|
+
return this;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default class CustomFilter {
|
|
2
|
+
static readonly OPERATION_EQUAL = 'eq';
|
|
3
|
+
static readonly OPERATION_NOT_EQUAL = 'ne';
|
|
4
|
+
static readonly OPERATION_LIKE = 'like';
|
|
5
|
+
static readonly OPERATION_GREATER_THAN = 'gt';
|
|
6
|
+
static readonly OPERATION_LESS_THAN = 'lt';
|
|
7
|
+
static readonly OPERATION_GREATER_THAN_EQUAL = 'gte';
|
|
8
|
+
static readonly OPERATION_LESS_THAN_EQUAL = 'lte';
|
|
9
|
+
static readonly OPERATION_IN = 'in';
|
|
10
|
+
static readonly OPERATION_LOOKUP_EQUAL = 'lookup_eq';
|
|
11
|
+
static readonly OPERATION_LOOKUP_LIKE = 'lookup_like';
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import SearchCriteria from "@database/builder/SearchCriteria";
|
|
2
|
+
|
|
3
|
+
export default class MultipleSearchCriteria {
|
|
4
|
+
constructor(
|
|
5
|
+
public logicalOperation: string,
|
|
6
|
+
public criteriaList: SearchCriteria[]
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
static of(
|
|
10
|
+
logicalOperation: string,
|
|
11
|
+
...criteriaList: SearchCriteria[]
|
|
12
|
+
): MultipleSearchCriteria {
|
|
13
|
+
return new MultipleSearchCriteria(logicalOperation, criteriaList);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toMongoQuery(): any {
|
|
17
|
+
const queries = this.criteriaList.map(c => c.toMongoQuery());
|
|
18
|
+
|
|
19
|
+
if (this.logicalOperation === SearchCriteria.OPERATION_OR) {
|
|
20
|
+
return { $or: queries };
|
|
21
|
+
} else {
|
|
22
|
+
return { $and: queries };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import CustomFilter from "@database/builder/CustomFilter";
|
|
2
|
+
import LookupConfig from "@config/LookupConfig";
|
|
3
|
+
|
|
4
|
+
export default class SearchCriteria {
|
|
5
|
+
static readonly OPERATION_AND = 'AND';
|
|
6
|
+
static readonly OPERATION_OR = 'OR';
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
public key: string,
|
|
10
|
+
public operation: string,
|
|
11
|
+
public value: any
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
static of(key: string, operation: string, value: any): SearchCriteria {
|
|
15
|
+
return new SearchCriteria(key, operation, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
isLookupOperation(): boolean {
|
|
19
|
+
return this.operation === CustomFilter.OPERATION_LOOKUP_EQUAL ||
|
|
20
|
+
this.operation === CustomFilter.OPERATION_LOOKUP_LIKE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getLookupInfo(): { prefix: string; config: LookupConfig; field: string } | null {
|
|
24
|
+
const lookupInfo = LookupConfig.getLookupForPath(this.key);
|
|
25
|
+
if (!lookupInfo) return null;
|
|
26
|
+
|
|
27
|
+
const { prefix, config } = lookupInfo;
|
|
28
|
+
const field = this.key.substring(prefix.length + 1);
|
|
29
|
+
|
|
30
|
+
return { prefix, config, field };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
toMongoQuery(): any {
|
|
34
|
+
const operation = this.operation;
|
|
35
|
+
|
|
36
|
+
if (!this.isLookupOperation()) {
|
|
37
|
+
const fieldPath = this.key;
|
|
38
|
+
|
|
39
|
+
switch (operation) {
|
|
40
|
+
case CustomFilter.OPERATION_EQUAL:
|
|
41
|
+
return { [fieldPath]: this.value };
|
|
42
|
+
case CustomFilter.OPERATION_NOT_EQUAL:
|
|
43
|
+
return { [fieldPath]: { $ne: this.value } };
|
|
44
|
+
case CustomFilter.OPERATION_LIKE:
|
|
45
|
+
return { [fieldPath]: { $regex: this.value, $options: 'i' } };
|
|
46
|
+
case CustomFilter.OPERATION_GREATER_THAN:
|
|
47
|
+
return { [fieldPath]: { $gt: this.value } };
|
|
48
|
+
case CustomFilter.OPERATION_LESS_THAN:
|
|
49
|
+
return { [fieldPath]: { $lt: this.value } };
|
|
50
|
+
case CustomFilter.OPERATION_GREATER_THAN_EQUAL:
|
|
51
|
+
return { [fieldPath]: { $gte: this.value } };
|
|
52
|
+
case CustomFilter.OPERATION_LESS_THAN_EQUAL:
|
|
53
|
+
return { [fieldPath]: { $lte: this.value } };
|
|
54
|
+
case CustomFilter.OPERATION_IN:
|
|
55
|
+
return { [fieldPath]: { $in: this.value } };
|
|
56
|
+
default:
|
|
57
|
+
return { [fieldPath]: this.value };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lookupInfo = this.getLookupInfo();
|
|
62
|
+
if (!lookupInfo) {
|
|
63
|
+
throw new Error(`Lookup configuration not found for path: ${this.key}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { prefix, field } = lookupInfo;
|
|
67
|
+
const matchField = `${prefix}.${field}`;
|
|
68
|
+
|
|
69
|
+
if (operation === CustomFilter.OPERATION_LOOKUP_EQUAL) {
|
|
70
|
+
return { [matchField]: this.value };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (operation === CustomFilter.OPERATION_LOOKUP_LIKE) {
|
|
74
|
+
return { [matchField]: { $regex: this.value, $options: 'i' } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {ComponentLookup, Lookup, Virtual} from "@shared/decorators/LookupDecorator";
|
|
2
|
+
import {IUser, IUserProfile} from "@shared/interfaces/UserInterface";
|
|
3
|
+
import {AgeVirtual, FullNameVirtual} from "@shared/helpers/VirtualHelper";
|
|
4
|
+
import mongoose, {Schema} from "mongoose";
|
|
5
|
+
import BaseSchema from "@base/BaseSchema";
|
|
6
|
+
|
|
7
|
+
@ComponentLookup("profiles")
|
|
8
|
+
export class ProfileLookup {
|
|
9
|
+
@Lookup({
|
|
10
|
+
from: 'users',
|
|
11
|
+
localField: 'idUser',
|
|
12
|
+
foreignField: '_id',
|
|
13
|
+
as: 'user',
|
|
14
|
+
unwind: true
|
|
15
|
+
})
|
|
16
|
+
user?: IUser;
|
|
17
|
+
|
|
18
|
+
@Virtual(
|
|
19
|
+
FullNameVirtual("fullName", "firstName", "lastName")
|
|
20
|
+
)
|
|
21
|
+
fullName?: string;
|
|
22
|
+
|
|
23
|
+
@Virtual(
|
|
24
|
+
AgeVirtual("age", "dateOfBirth")
|
|
25
|
+
)
|
|
26
|
+
age?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ProfileSchema = BaseSchema.create<IUserProfile>({
|
|
30
|
+
userId: {
|
|
31
|
+
type: Schema.Types.ObjectId,
|
|
32
|
+
ref: 'users',
|
|
33
|
+
required: true,
|
|
34
|
+
unique: true
|
|
35
|
+
},
|
|
36
|
+
firstName: {
|
|
37
|
+
type: String,
|
|
38
|
+
required: true },
|
|
39
|
+
lastName: {
|
|
40
|
+
type: String,
|
|
41
|
+
required: true
|
|
42
|
+
},
|
|
43
|
+
phone: {
|
|
44
|
+
type: String
|
|
45
|
+
},
|
|
46
|
+
address: {
|
|
47
|
+
type: String
|
|
48
|
+
},
|
|
49
|
+
dateOfBirth: {
|
|
50
|
+
type: Number
|
|
51
|
+
} // Epoch timestamp
|
|
52
|
+
});
|
|
53
|
+
BaseSchema.addSoftDelete(ProfileSchema);
|
|
54
|
+
export default mongoose.model<IUserProfile>("profile", ProfileSchema);
|