@flusys/nestjs-shared 1.1.0 → 1.2.0-rc.1
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 +139 -1103
- package/cjs/classes/api-controller.class.js +320 -29
- package/cjs/classes/api-service.class.js +219 -64
- package/cjs/classes/handle-custome-db.js +37 -0
- package/cjs/classes/index.js +1 -2
- package/cjs/classes/winston.logger.class.js +103 -70
- package/cjs/constants/index.js +1 -4
- package/cjs/constants/message-keys.js +68 -0
- package/cjs/constants/permissions.js +114 -11
- package/cjs/decorators/api-response.decorator.js +1 -2
- package/cjs/decorators/index.js +1 -1
- package/cjs/decorators/log-action.decorator.js +149 -0
- package/cjs/decorators/require-permission.decorator.js +5 -5
- package/cjs/dtos/get-by-ids.dto.js +65 -0
- package/cjs/dtos/identity-response.dto.js +4 -8
- package/cjs/dtos/index.js +1 -0
- package/cjs/dtos/response-payload.dto.js +72 -0
- package/cjs/entities/identity.js +8 -2
- package/cjs/entities/index.js +1 -0
- package/cjs/entities/raw-type.js +4 -0
- package/cjs/entities/user-root.js +2 -1
- package/cjs/enums/index.js +20 -0
- package/cjs/enums/notification-type.enum.js +17 -0
- package/cjs/enums/participant-status.enum.js +17 -0
- package/cjs/enums/recurrence-type.enum.js +18 -0
- package/cjs/exceptions/base-app.exception.js +151 -0
- package/cjs/exceptions/index.js +1 -0
- package/cjs/exceptions/permission.exception.js +13 -9
- package/cjs/filters/global-exception.filter.js +279 -0
- package/cjs/filters/index.js +18 -0
- package/cjs/guards/jwt-auth.guard.js +4 -1
- package/cjs/guards/permission.guard.js +71 -77
- package/cjs/index.js +2 -0
- package/cjs/interceptors/idempotency.interceptor.js +1 -1
- package/cjs/interceptors/index.js +0 -1
- package/cjs/interfaces/event-manager-adapter.interface.js +11 -0
- package/cjs/interfaces/index.js +2 -1
- package/cjs/interfaces/notification-adapter.interface.js +11 -0
- package/cjs/interfaces/permission.interface.js +1 -1
- package/cjs/middlewares/logger.middleware.js +83 -26
- package/cjs/modules/datasource/index.js +0 -1
- package/cjs/modules/datasource/multi-tenant-datasource.service.js +46 -85
- package/cjs/modules/utils/utils.service.js +4 -20
- package/cjs/utils/date-time.util.js +94 -0
- package/cjs/utils/index.js +1 -1
- package/cjs/utils/query-helpers.util.js +8 -1
- package/cjs/utils/request.util.js +25 -0
- package/classes/api-controller.class.d.ts +14 -6
- package/classes/api-service.class.d.ts +25 -22
- package/classes/handle-custome-db.d.ts +3 -0
- package/classes/index.d.ts +1 -2
- package/classes/winston.logger.class.d.ts +1 -0
- package/constants/index.d.ts +1 -1
- package/constants/message-keys.d.ts +45 -0
- package/constants/permissions.d.ts +127 -0
- package/decorators/api-response.decorator.d.ts +1 -1
- package/decorators/current-user.decorator.d.ts +1 -1
- package/decorators/index.d.ts +1 -1
- package/decorators/log-action.decorator.d.ts +8 -0
- package/decorators/require-permission.decorator.d.ts +2 -2
- package/dtos/get-by-ids.dto.d.ts +4 -0
- package/dtos/index.d.ts +1 -0
- package/dtos/response-payload.dto.d.ts +8 -0
- package/entities/index.d.ts +1 -0
- package/entities/raw-type.d.ts +1 -0
- package/enums/index.d.ts +3 -0
- package/enums/notification-type.enum.d.ts +6 -0
- package/enums/participant-status.enum.d.ts +6 -0
- package/enums/recurrence-type.enum.d.ts +7 -0
- package/exceptions/base-app.exception.d.ts +41 -0
- package/exceptions/index.d.ts +1 -0
- package/exceptions/permission.exception.d.ts +2 -2
- package/fesm/classes/api-controller.class.js +321 -30
- package/fesm/classes/api-service.class.js +222 -67
- package/fesm/classes/handle-custome-db.js +19 -0
- package/fesm/classes/index.js +1 -2
- package/fesm/classes/winston.logger.class.js +100 -68
- package/fesm/constants/index.js +2 -1
- package/fesm/constants/message-keys.js +49 -0
- package/fesm/constants/permissions.js +86 -14
- package/fesm/decorators/api-response.decorator.js +2 -20
- package/fesm/decorators/index.js +1 -1
- package/fesm/decorators/log-action.decorator.js +139 -0
- package/fesm/decorators/require-permission.decorator.js +17 -65
- package/fesm/dtos/get-by-ids.dto.js +55 -0
- package/fesm/dtos/identity-response.dto.js +4 -8
- package/fesm/dtos/index.js +1 -0
- package/fesm/dtos/response-payload.dto.js +72 -0
- package/fesm/entities/identity.js +8 -2
- package/fesm/entities/index.js +1 -0
- package/fesm/entities/raw-type.js +1 -0
- package/fesm/entities/user-root.js +2 -1
- package/fesm/enums/index.js +3 -0
- package/fesm/enums/notification-type.enum.js +7 -0
- package/fesm/enums/participant-status.enum.js +7 -0
- package/fesm/enums/recurrence-type.enum.js +8 -0
- package/fesm/exceptions/base-app.exception.js +115 -0
- package/fesm/exceptions/index.js +1 -0
- package/fesm/exceptions/permission.exception.js +16 -18
- package/fesm/filters/global-exception.filter.js +269 -0
- package/fesm/filters/index.js +1 -0
- package/fesm/guards/jwt-auth.guard.js +5 -2
- package/fesm/guards/permission.guard.js +73 -79
- package/fesm/index.js +2 -0
- package/fesm/interceptors/idempotency.interceptor.js +2 -2
- package/fesm/interceptors/index.js +0 -1
- package/fesm/interfaces/datasource.interface.js +1 -20
- package/fesm/interfaces/event-manager-adapter.interface.js +1 -0
- package/fesm/interfaces/index.js +2 -1
- package/fesm/interfaces/notification-adapter.interface.js +1 -0
- package/fesm/interfaces/permission.interface.js +1 -3
- package/fesm/middlewares/logger.middleware.js +83 -26
- package/fesm/modules/datasource/index.js +0 -1
- package/fesm/modules/datasource/multi-tenant-datasource.service.js +47 -86
- package/fesm/modules/utils/utils.service.js +5 -21
- package/fesm/utils/date-time.util.js +70 -0
- package/fesm/utils/index.js +1 -1
- package/fesm/utils/query-helpers.util.js +8 -1
- package/fesm/utils/request.util.js +22 -0
- package/filters/global-exception.filter.d.ts +9 -0
- package/filters/index.d.ts +1 -0
- package/guards/permission.guard.d.ts +3 -7
- package/index.d.ts +2 -0
- package/interceptors/idempotency.interceptor.d.ts +1 -1
- package/interceptors/index.d.ts +0 -1
- package/interfaces/api.interface.d.ts +2 -0
- package/interfaces/event-manager-adapter.interface.d.ts +43 -0
- package/interfaces/index.d.ts +2 -1
- package/interfaces/notification-adapter.interface.d.ts +21 -0
- package/interfaces/permission.interface.d.ts +13 -8
- package/modules/datasource/index.d.ts +0 -1
- package/modules/datasource/multi-tenant-datasource.service.d.ts +5 -15
- package/modules/utils/utils.service.d.ts +0 -1
- package/package.json +21 -14
- package/utils/date-time.util.d.ts +8 -0
- package/utils/index.d.ts +1 -1
- package/utils/request.util.d.ts +2 -0
- package/cjs/classes/request-scoped-api.service.js +0 -61
- package/cjs/classes/winston-logger-adapter.class.js +0 -104
- package/cjs/decorators/sanitize-html.decorator.js +0 -36
- package/cjs/interceptors/query-performance.interceptor.js +0 -66
- package/cjs/interfaces/logger.interface.js +0 -7
- package/cjs/modules/datasource/datasource.module.js +0 -117
- package/cjs/utils/error-handler.util.js +0 -90
- package/classes/request-scoped-api.service.d.ts +0 -14
- package/classes/winston-logger-adapter.class.d.ts +0 -22
- package/decorators/sanitize-html.decorator.d.ts +0 -2
- package/fesm/classes/request-scoped-api.service.js +0 -51
- package/fesm/classes/winston-logger-adapter.class.js +0 -107
- package/fesm/decorators/sanitize-html.decorator.js +0 -45
- package/fesm/interceptors/query-performance.interceptor.js +0 -56
- package/fesm/interfaces/logger.interface.js +0 -4
- package/fesm/modules/datasource/datasource.module.js +0 -107
- package/fesm/utils/error-handler.util.js +0 -82
- package/interceptors/query-performance.interceptor.d.ts +0 -8
- package/interfaces/logger.interface.d.ts +0 -7
- package/modules/datasource/datasource.module.d.ts +0 -19
- package/utils/error-handler.util.d.ts +0 -19
|
@@ -24,18 +24,15 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
24
24
|
import { instance as winstonLogger } from '../classes/winston.logger.class';
|
|
25
25
|
import { REQUEST_ID_HEADER } from '../constants';
|
|
26
26
|
// Configuration
|
|
27
|
-
const
|
|
27
|
+
const logConfig = envConfig.getLogConfig();
|
|
28
|
+
const IS_DEBUG = logConfig.level === 'debug';
|
|
29
|
+
const DISABLE_HTTP_LOGGING = logConfig.disableHttpLogging;
|
|
28
30
|
const TENANT_ID_HEADER = 'x-tenant-id';
|
|
29
31
|
const EXCLUDED_PATHS = [
|
|
30
32
|
'/health',
|
|
31
33
|
'/metrics',
|
|
32
34
|
'/favicon.ico'
|
|
33
35
|
];
|
|
34
|
-
const EXCLUDED_HEADERS = [
|
|
35
|
-
'authorization',
|
|
36
|
-
'cookie',
|
|
37
|
-
'x-api-key'
|
|
38
|
-
];
|
|
39
36
|
const MAX_BODY_LOG_SIZE = 1000;
|
|
40
37
|
export const requestContext = new AsyncLocalStorage();
|
|
41
38
|
// Context accessors
|
|
@@ -44,17 +41,71 @@ export const getTenantId = ()=>requestContext.getStore()?.tenantId;
|
|
|
44
41
|
export const getUserId = ()=>requestContext.getStore()?.userId;
|
|
45
42
|
export const getCompanyId = ()=>requestContext.getStore()?.companyId;
|
|
46
43
|
// Helper Functions
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
function summarizeRequestBody(body) {
|
|
45
|
+
if (!body) return undefined;
|
|
46
|
+
const parsed = typeof body === 'string' ? tryParseJson(body) : body;
|
|
47
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
48
|
+
const str = String(body);
|
|
49
|
+
return str.length > MAX_BODY_LOG_SIZE ? str.substring(0, MAX_BODY_LOG_SIZE) + '...' : str;
|
|
50
|
+
}
|
|
51
|
+
// For API request bodies, provide a concise summary
|
|
52
|
+
const summary = {};
|
|
53
|
+
// Pagination info
|
|
54
|
+
if (parsed.pagination) {
|
|
55
|
+
summary.page = parsed.pagination.currentPage;
|
|
56
|
+
summary.pageSize = parsed.pagination.pageSize;
|
|
57
|
+
}
|
|
58
|
+
// Filter keys only (not values - may contain sensitive data)
|
|
59
|
+
if (parsed.filter && typeof parsed.filter === 'object') {
|
|
60
|
+
const filterKeys = Object.keys(parsed.filter);
|
|
61
|
+
if (filterKeys.length > 0) summary.filterKeys = filterKeys;
|
|
62
|
+
}
|
|
63
|
+
// Select fields count
|
|
64
|
+
if (Array.isArray(parsed.select) && parsed.select.length > 0) {
|
|
65
|
+
summary.selectCount = parsed.select.length;
|
|
66
|
+
}
|
|
67
|
+
// Sort keys
|
|
68
|
+
if (parsed.sort && typeof parsed.sort === 'object') {
|
|
69
|
+
const sortKeys = Object.keys(parsed.sort);
|
|
70
|
+
if (sortKeys.length > 0) summary.sortKeys = sortKeys;
|
|
51
71
|
}
|
|
52
|
-
|
|
72
|
+
// ID for single item operations
|
|
73
|
+
if (parsed.id) summary.id = parsed.id;
|
|
74
|
+
// IDs for bulk operations
|
|
75
|
+
if (Array.isArray(parsed.ids)) summary.idsCount = parsed.ids.length;
|
|
76
|
+
return Object.keys(summary).length > 0 ? JSON.stringify(summary) : '{}';
|
|
53
77
|
}
|
|
54
|
-
function
|
|
78
|
+
function summarizeResponseBody(body) {
|
|
55
79
|
if (!body) return undefined;
|
|
56
|
-
const
|
|
57
|
-
|
|
80
|
+
const parsed = typeof body === 'string' ? tryParseJson(body) : body;
|
|
81
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
82
|
+
const str = String(body);
|
|
83
|
+
return str.length > MAX_BODY_LOG_SIZE ? str.substring(0, MAX_BODY_LOG_SIZE) + '...' : str;
|
|
84
|
+
}
|
|
85
|
+
const summary = {};
|
|
86
|
+
if ('success' in parsed) summary.success = parsed.success;
|
|
87
|
+
if ('message' in parsed) summary.message = parsed.message;
|
|
88
|
+
if ('code' in parsed) summary.code = parsed.code;
|
|
89
|
+
if (Array.isArray(parsed.data)) {
|
|
90
|
+
summary.dataCount = parsed.data.length;
|
|
91
|
+
} else if (parsed.data && typeof parsed.data === 'object') {
|
|
92
|
+
summary.hasData = true;
|
|
93
|
+
}
|
|
94
|
+
if (parsed.meta) {
|
|
95
|
+
summary.total = parsed.meta.total;
|
|
96
|
+
summary.page = parsed.meta.page;
|
|
97
|
+
}
|
|
98
|
+
if (parsed.errors) {
|
|
99
|
+
summary.errorsCount = Array.isArray(parsed.errors) ? parsed.errors.length : 1;
|
|
100
|
+
}
|
|
101
|
+
return JSON.stringify(summary);
|
|
102
|
+
}
|
|
103
|
+
function tryParseJson(str) {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(str);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
58
109
|
}
|
|
59
110
|
function getClientIp(req) {
|
|
60
111
|
return req.headers['x-forwarded-for']?.split(',')[0] || req.socket?.remoteAddress || 'unknown';
|
|
@@ -71,7 +122,7 @@ export class LoggerMiddleware {
|
|
|
71
122
|
startTime
|
|
72
123
|
};
|
|
73
124
|
requestContext.run(context, ()=>{
|
|
74
|
-
const shouldSkipLogging = EXCLUDED_PATHS.some((path)=>req.originalUrl.startsWith(path));
|
|
125
|
+
const shouldSkipLogging = DISABLE_HTTP_LOGGING || EXCLUDED_PATHS.some((path)=>req.originalUrl.startsWith(path));
|
|
75
126
|
if (!shouldSkipLogging) {
|
|
76
127
|
this.logRequest(req, requestId, tenantId);
|
|
77
128
|
this.setupResponseLogging(req, res, startTime, requestId, tenantId);
|
|
@@ -112,20 +163,22 @@ export class LoggerMiddleware {
|
|
|
112
163
|
});
|
|
113
164
|
}
|
|
114
165
|
logRequest(req, requestId, tenantId) {
|
|
166
|
+
const ip = getClientIp(req);
|
|
167
|
+
const contentType = req.headers['content-type'] || 'none';
|
|
168
|
+
// Build descriptive message like: Started processing request "POST" "/api/users" from "192.168.1.1"
|
|
169
|
+
const message = `Started processing request "${req.method}" "${req.originalUrl}" from "${ip}"`;
|
|
115
170
|
const logData = {
|
|
116
171
|
...this.buildBaseLogData(req, requestId, tenantId),
|
|
117
172
|
query: Object.keys(req.query).length > 0 ? req.query : undefined,
|
|
118
|
-
ip
|
|
173
|
+
ip,
|
|
119
174
|
userAgent: req.headers['user-agent'],
|
|
120
|
-
contentType
|
|
175
|
+
contentType,
|
|
121
176
|
contentLength: req.headers['content-length']
|
|
122
177
|
};
|
|
123
178
|
if (IS_DEBUG) {
|
|
124
|
-
logData.
|
|
125
|
-
logData.body = truncateBody(req.body);
|
|
126
|
-
logData.params = Object.keys(req.params).length > 0 ? req.params : undefined;
|
|
179
|
+
logData.body = summarizeRequestBody(req.body);
|
|
127
180
|
}
|
|
128
|
-
this.logger.info(
|
|
181
|
+
this.logger.info(message, logData);
|
|
129
182
|
}
|
|
130
183
|
logResponse(req, res, startTime, body, requestId, tenantId) {
|
|
131
184
|
const duration = Date.now() - startTime;
|
|
@@ -133,6 +186,10 @@ export class LoggerMiddleware {
|
|
|
133
186
|
const level = statusCode >= 500 ? 'error' : statusCode >= 400 ? 'warn' : 'info';
|
|
134
187
|
const userId = getUserId();
|
|
135
188
|
const companyId = getCompanyId();
|
|
189
|
+
const contentType = res.getHeader('content-type') || 'none';
|
|
190
|
+
const contentLength = res.getHeader('content-length') || 'null';
|
|
191
|
+
// Build descriptive message like: Request finished "HTTP/1.1" "POST" "/api/users" - 200 "application/json" 534.29ms
|
|
192
|
+
const message = `Request finished "HTTP/1.1" "${req.method}" "${req.originalUrl}" - ${statusCode} ${contentLength} "${contentType}" ${duration.toFixed(2)}ms`;
|
|
136
193
|
const logData = {
|
|
137
194
|
...this.buildBaseLogData(req, requestId, tenantId),
|
|
138
195
|
userId,
|
|
@@ -141,15 +198,15 @@ export class LoggerMiddleware {
|
|
|
141
198
|
statusMessage: res.statusMessage,
|
|
142
199
|
duration: `${duration}ms`,
|
|
143
200
|
durationMs: duration,
|
|
144
|
-
contentType
|
|
145
|
-
contentLength
|
|
201
|
+
contentType,
|
|
202
|
+
contentLength
|
|
146
203
|
};
|
|
147
204
|
if (statusCode >= 400 || IS_DEBUG) {
|
|
148
|
-
logData.responseBody =
|
|
205
|
+
logData.responseBody = summarizeResponseBody(body);
|
|
149
206
|
}
|
|
150
|
-
this.logger.log(level,
|
|
207
|
+
this.logger.log(level, message, logData);
|
|
151
208
|
if (duration > 3000 && statusCode < 400) {
|
|
152
|
-
this.logger.warn(
|
|
209
|
+
this.logger.warn(`Slow request detected: "${req.method}" "${req.originalUrl}" took ${duration}ms (threshold: 3000ms)`, {
|
|
153
210
|
...this.buildBaseLogData(req, requestId, tenantId),
|
|
154
211
|
userId,
|
|
155
212
|
companyId,
|
|
@@ -26,27 +26,28 @@ function _ts_param(paramIndex, decorator) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
import { DEFAULT_TENANT_HEADER, IDataSourceServiceOptions, MODULE_OPTIONS } from '@flusys/nestjs-core';
|
|
29
|
-
import { BadRequestException, Inject, Injectable,
|
|
29
|
+
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Optional, Scope } from '@nestjs/common';
|
|
30
|
+
import { SYSTEM_MESSAGES } from '../../constants';
|
|
30
31
|
import { REQUEST } from '@nestjs/core';
|
|
31
32
|
import { Request } from 'express';
|
|
32
33
|
import { DataSource } from 'typeorm';
|
|
33
34
|
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
|
|
34
35
|
export class MultiTenantDataSourceService {
|
|
36
|
+
get self() {
|
|
37
|
+
return this.constructor;
|
|
38
|
+
}
|
|
35
39
|
initializeFromOptions() {
|
|
36
40
|
if (!this.options) return;
|
|
37
|
-
if (!
|
|
41
|
+
if (!this.self.initialized) {
|
|
38
42
|
this.registerTenants();
|
|
39
|
-
|
|
43
|
+
this.self.initialized = true;
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
registerTenants() {
|
|
43
47
|
this.options?.tenants?.forEach((tenant)=>{
|
|
44
|
-
|
|
48
|
+
this.self.tenantsRegistry.set(tenant.id, tenant);
|
|
45
49
|
});
|
|
46
50
|
}
|
|
47
|
-
setTenantHeader(header) {
|
|
48
|
-
this.tenantHeader = header;
|
|
49
|
-
}
|
|
50
51
|
getDatabaseMode() {
|
|
51
52
|
return this.options?.bootstrapAppConfig?.databaseMode ?? 'single';
|
|
52
53
|
}
|
|
@@ -58,7 +59,10 @@ export class MultiTenantDataSourceService {
|
|
|
58
59
|
const tenantId = this.request.headers[this.tenantHeader];
|
|
59
60
|
if (!tenantId) return null;
|
|
60
61
|
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId)) {
|
|
61
|
-
throw new BadRequestException(
|
|
62
|
+
throw new BadRequestException({
|
|
63
|
+
message: 'Invalid tenant ID format',
|
|
64
|
+
messageKey: SYSTEM_MESSAGES.INVALID_TENANT_ID
|
|
65
|
+
});
|
|
62
66
|
}
|
|
63
67
|
return tenantId;
|
|
64
68
|
}
|
|
@@ -67,124 +71,78 @@ export class MultiTenantDataSourceService {
|
|
|
67
71
|
return tenantId ? this.getTenant(tenantId) : null;
|
|
68
72
|
}
|
|
69
73
|
getTenant(tenantId) {
|
|
70
|
-
return
|
|
71
|
-
}
|
|
72
|
-
getAllTenants() {
|
|
73
|
-
return Array.from(MultiTenantDataSourceService.tenantsRegistry.values());
|
|
74
|
-
}
|
|
75
|
-
getActiveTenants() {
|
|
76
|
-
return this.getAllTenants();
|
|
74
|
+
return this.self.tenantsRegistry.get(tenantId) ?? null;
|
|
77
75
|
}
|
|
78
76
|
async getDataSource() {
|
|
79
77
|
return this.isMultiTenant() ? this.getTenantDataSource() : this.getSingleDataSource();
|
|
80
78
|
}
|
|
81
|
-
async getDataSourceForTenant(tenantId) {
|
|
82
|
-
const tenant = this.getTenant(tenantId);
|
|
83
|
-
if (!tenant) throw new Error(`Tenant '${tenantId}' not found`);
|
|
84
|
-
return this.getOrCreateTenantConnection(tenant);
|
|
85
|
-
}
|
|
86
|
-
setDataSource(dataSource) {
|
|
87
|
-
MultiTenantDataSourceService.singleDataSource = dataSource;
|
|
88
|
-
}
|
|
89
79
|
async getRepository(entity) {
|
|
90
80
|
const dataSource = await this.getDataSource();
|
|
91
81
|
return dataSource.getRepository(entity);
|
|
92
82
|
}
|
|
93
|
-
async withTenant(tenantId, callback) {
|
|
94
|
-
const dataSource = await this.getDataSourceForTenant(tenantId);
|
|
95
|
-
return callback(dataSource);
|
|
96
|
-
}
|
|
97
|
-
async forAllTenants(callback) {
|
|
98
|
-
const results = new Map();
|
|
99
|
-
for (const tenant of this.getActiveTenants()){
|
|
100
|
-
try {
|
|
101
|
-
const dataSource = await this.getOrCreateTenantConnection(tenant);
|
|
102
|
-
results.set(tenant.id, await callback(tenant, dataSource));
|
|
103
|
-
} catch (error) {
|
|
104
|
-
this.logger.error(`Error processing tenant ${tenant.id}: ${error}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return results;
|
|
108
|
-
}
|
|
109
|
-
registerTenant(tenant) {
|
|
110
|
-
MultiTenantDataSourceService.tenantsRegistry.set(tenant.id, tenant);
|
|
111
|
-
}
|
|
112
|
-
async removeTenant(tenantId) {
|
|
113
|
-
await this.closeTenantConnection(tenantId);
|
|
114
|
-
MultiTenantDataSourceService.tenantsRegistry.delete(tenantId);
|
|
115
|
-
}
|
|
116
83
|
async closeTenantConnection(tenantId) {
|
|
117
|
-
const connection =
|
|
84
|
+
const connection = this.self.tenantConnections.get(tenantId);
|
|
118
85
|
if (connection?.isInitialized) {
|
|
119
86
|
await connection.destroy();
|
|
120
|
-
|
|
121
|
-
this.logger.log(`Closed connection for tenant: ${tenantId}`);
|
|
87
|
+
this.self.tenantConnections.delete(tenantId);
|
|
122
88
|
}
|
|
123
89
|
}
|
|
124
90
|
async onModuleDestroy() {
|
|
125
|
-
for (const [tenantId] of
|
|
91
|
+
for (const [tenantId] of this.self.tenantConnections){
|
|
126
92
|
await this.closeTenantConnection(tenantId);
|
|
127
93
|
}
|
|
128
94
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
MultiTenantDataSourceService.singleConnectionLock = null;
|
|
133
|
-
MultiTenantDataSourceService.tenantConnections.clear();
|
|
134
|
-
MultiTenantDataSourceService.tenantsRegistry.clear();
|
|
135
|
-
MultiTenantDataSourceService.connectionLocks.clear();
|
|
136
|
-
}
|
|
137
|
-
// Protected Methods (for subclasses)
|
|
138
|
-
/**
|
|
139
|
-
* Get single database DataSource with connection locking to prevent race conditions
|
|
140
|
-
*/ async getSingleDataSource() {
|
|
141
|
-
// Return existing initialized connection
|
|
142
|
-
if (MultiTenantDataSourceService.singleDataSource?.isInitialized) {
|
|
143
|
-
return MultiTenantDataSourceService.singleDataSource;
|
|
95
|
+
async getSingleDataSource() {
|
|
96
|
+
if (this.self.singleDataSource?.isInitialized) {
|
|
97
|
+
return this.self.singleDataSource;
|
|
144
98
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return MultiTenantDataSourceService.singleConnectionLock;
|
|
99
|
+
if (this.self.singleConnectionLock) {
|
|
100
|
+
return this.self.singleConnectionLock;
|
|
148
101
|
}
|
|
149
102
|
const config = this.getDefaultDatabaseConfig();
|
|
150
103
|
if (!config) {
|
|
151
|
-
throw new
|
|
104
|
+
throw new InternalServerErrorException({
|
|
105
|
+
message: 'No database config available. Provide defaultDatabaseConfig or tenantDefaultDatabaseConfig.',
|
|
106
|
+
messageKey: SYSTEM_MESSAGES.DATABASE_CONFIG_NOT_AVAILABLE
|
|
107
|
+
});
|
|
152
108
|
}
|
|
153
|
-
// Create connection with lock to prevent race conditions
|
|
154
109
|
const connectionPromise = this.createDataSourceFromConfig(config);
|
|
155
|
-
|
|
110
|
+
this.self.singleConnectionLock = connectionPromise;
|
|
156
111
|
try {
|
|
157
112
|
const dataSource = await connectionPromise;
|
|
158
|
-
|
|
113
|
+
this.self.singleDataSource = dataSource;
|
|
159
114
|
return dataSource;
|
|
160
115
|
} finally{
|
|
161
|
-
|
|
116
|
+
this.self.singleConnectionLock = null;
|
|
162
117
|
}
|
|
163
118
|
}
|
|
164
|
-
|
|
165
|
-
* Get tenant-specific DataSource
|
|
166
|
-
*/ async getTenantDataSource() {
|
|
119
|
+
async getTenantDataSource() {
|
|
167
120
|
const tenant = this.getCurrentTenant();
|
|
168
121
|
if (!tenant) {
|
|
169
|
-
throw new
|
|
122
|
+
throw new BadRequestException({
|
|
123
|
+
message: `Tenant not found. Ensure '${this.tenantHeader}' header is set.`,
|
|
124
|
+
messageKey: SYSTEM_MESSAGES.TENANT_HEADER_REQUIRED,
|
|
125
|
+
messageVariables: {
|
|
126
|
+
header: this.tenantHeader
|
|
127
|
+
}
|
|
128
|
+
});
|
|
170
129
|
}
|
|
171
130
|
return this.getOrCreateTenantConnection(tenant);
|
|
172
131
|
}
|
|
173
132
|
async getOrCreateTenantConnection(tenant) {
|
|
174
|
-
const existing =
|
|
133
|
+
const existing = this.self.tenantConnections.get(tenant.id);
|
|
175
134
|
if (existing?.isInitialized) return existing;
|
|
176
|
-
const pendingConnection =
|
|
135
|
+
const pendingConnection = this.self.connectionLocks.get(tenant.id);
|
|
177
136
|
if (pendingConnection) return pendingConnection;
|
|
178
137
|
const config = this.buildTenantDatabaseConfig(tenant);
|
|
179
138
|
const connectionPromise = this.createDataSourceFromConfig(config);
|
|
180
|
-
|
|
139
|
+
this.self.connectionLocks.set(tenant.id, connectionPromise);
|
|
181
140
|
try {
|
|
182
141
|
const dataSource = await connectionPromise;
|
|
183
|
-
|
|
184
|
-
this.logger.log(`Created connection for tenant: ${tenant.id}`);
|
|
142
|
+
this.self.tenantConnections.set(tenant.id, dataSource);
|
|
185
143
|
return dataSource;
|
|
186
144
|
} finally{
|
|
187
|
-
|
|
145
|
+
this.self.connectionLocks.delete(tenant.id);
|
|
188
146
|
}
|
|
189
147
|
}
|
|
190
148
|
getDefaultDatabaseConfig() {
|
|
@@ -192,7 +150,12 @@ export class MultiTenantDataSourceService {
|
|
|
192
150
|
}
|
|
193
151
|
buildTenantDatabaseConfig(tenant) {
|
|
194
152
|
const defaultConfig = this.getDefaultDatabaseConfig();
|
|
195
|
-
if (!defaultConfig)
|
|
153
|
+
if (!defaultConfig) {
|
|
154
|
+
throw new InternalServerErrorException({
|
|
155
|
+
message: 'No default database config for multi-tenant mode.',
|
|
156
|
+
messageKey: SYSTEM_MESSAGES.DATABASE_CONFIG_NOT_AVAILABLE
|
|
157
|
+
});
|
|
158
|
+
}
|
|
196
159
|
return {
|
|
197
160
|
type: defaultConfig.type,
|
|
198
161
|
host: tenant.host ?? defaultConfig.host,
|
|
@@ -220,11 +183,9 @@ export class MultiTenantDataSourceService {
|
|
|
220
183
|
constructor(options, request){
|
|
221
184
|
_define_property(this, "options", void 0);
|
|
222
185
|
_define_property(this, "request", void 0);
|
|
223
|
-
_define_property(this, "logger", void 0);
|
|
224
186
|
_define_property(this, "tenantHeader", void 0);
|
|
225
187
|
this.options = options;
|
|
226
188
|
this.request = request;
|
|
227
|
-
this.logger = new Logger(this.constructor.name);
|
|
228
189
|
this.tenantHeader = DEFAULT_TENANT_HEADER;
|
|
229
190
|
this.initializeFromOptions();
|
|
230
191
|
}
|
|
@@ -1,23 +1,10 @@
|
|
|
1
|
-
function _define_property(obj, key, value) {
|
|
2
|
-
if (key in obj) {
|
|
3
|
-
Object.defineProperty(obj, key, {
|
|
4
|
-
value: value,
|
|
5
|
-
enumerable: true,
|
|
6
|
-
configurable: true,
|
|
7
|
-
writable: true
|
|
8
|
-
});
|
|
9
|
-
} else {
|
|
10
|
-
obj[key] = value;
|
|
11
|
-
}
|
|
12
|
-
return obj;
|
|
13
|
-
}
|
|
14
1
|
function _ts_decorate(decorators, target, key, desc) {
|
|
15
2
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
16
3
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
17
4
|
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
18
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
19
6
|
}
|
|
20
|
-
import { Injectable
|
|
7
|
+
import { Injectable } from '@nestjs/common';
|
|
21
8
|
export class UtilsService {
|
|
22
9
|
// ---------------- CACHE HELPERS ----------------
|
|
23
10
|
/**
|
|
@@ -37,8 +24,8 @@ export class UtilsService {
|
|
|
37
24
|
const keys = await cacheManager.get(trackingKey) || [];
|
|
38
25
|
if (!keys.includes(cacheKey)) keys.push(cacheKey);
|
|
39
26
|
await cacheManager.set(trackingKey, keys);
|
|
40
|
-
} catch
|
|
41
|
-
|
|
27
|
+
} catch {
|
|
28
|
+
// Silent failure - cache tracking is non-critical
|
|
42
29
|
}
|
|
43
30
|
}
|
|
44
31
|
/**
|
|
@@ -49,8 +36,8 @@ export class UtilsService {
|
|
|
49
36
|
const keys = await cacheManager.get(trackingKey) || [];
|
|
50
37
|
await Promise.allSettled(keys.map((key)=>cacheManager.del(key)));
|
|
51
38
|
await cacheManager.del(trackingKey);
|
|
52
|
-
} catch
|
|
53
|
-
|
|
39
|
+
} catch {
|
|
40
|
+
// Silent failure - cache invalidation is non-critical
|
|
54
41
|
}
|
|
55
42
|
}
|
|
56
43
|
// ---------------- STRING HELPERS ----------------
|
|
@@ -73,9 +60,6 @@ export class UtilsService {
|
|
|
73
60
|
const prefix = this.buildTenantPrefix(tenantId);
|
|
74
61
|
return entityId ? `${prefix}entity_${entityName}_id_${entityId}_keys` : `${prefix}entity_${entityName}_keys`;
|
|
75
62
|
}
|
|
76
|
-
constructor(){
|
|
77
|
-
_define_property(this, "logger", new Logger(UtilsService.name));
|
|
78
|
-
}
|
|
79
63
|
}
|
|
80
64
|
UtilsService = _ts_decorate([
|
|
81
65
|
Injectable()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function getOffsetMinutes(utcDate, timezone) {
|
|
2
|
+
const extract = (tz)=>{
|
|
3
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
4
|
+
timeZone: tz,
|
|
5
|
+
year: 'numeric',
|
|
6
|
+
month: '2-digit',
|
|
7
|
+
day: '2-digit',
|
|
8
|
+
hour: '2-digit',
|
|
9
|
+
minute: '2-digit',
|
|
10
|
+
second: '2-digit',
|
|
11
|
+
hour12: false
|
|
12
|
+
}).formatToParts(utcDate);
|
|
13
|
+
const p = Object.fromEntries(parts.filter((x)=>x.type !== 'literal').map((x)=>[
|
|
14
|
+
x.type,
|
|
15
|
+
parseInt(x.value)
|
|
16
|
+
]));
|
|
17
|
+
return Date.UTC(p['year'], p['month'] - 1, p['day'], p['hour'], p['minute'], p['second']);
|
|
18
|
+
};
|
|
19
|
+
return (extract(timezone) - extract('UTC')) / 60_000;
|
|
20
|
+
}
|
|
21
|
+
export function localToUtc(dateStr, timeStr, timezone) {
|
|
22
|
+
const [year, month, day] = dateStr.split('-').map(Number);
|
|
23
|
+
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
24
|
+
const approx = new Date(Date.UTC(year, month - 1, day, hours, minutes, 0));
|
|
25
|
+
const offset = getOffsetMinutes(approx, timezone);
|
|
26
|
+
const utcDate = new Date(approx.getTime() - offset * 60_000);
|
|
27
|
+
// DST boundary guard: re-check offset after adjustment
|
|
28
|
+
const finalOffset = getOffsetMinutes(utcDate, timezone);
|
|
29
|
+
return finalOffset !== offset ? new Date(approx.getTime() - finalOffset * 60_000) : utcDate;
|
|
30
|
+
}
|
|
31
|
+
export function utcToLocal(utcDate, timezone) {
|
|
32
|
+
const d = typeof utcDate === 'string' ? new Date(utcDate) : utcDate;
|
|
33
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
34
|
+
timeZone: timezone,
|
|
35
|
+
year: 'numeric',
|
|
36
|
+
month: '2-digit',
|
|
37
|
+
day: '2-digit',
|
|
38
|
+
hour: '2-digit',
|
|
39
|
+
minute: '2-digit',
|
|
40
|
+
hour12: false
|
|
41
|
+
}).formatToParts(d);
|
|
42
|
+
const p = Object.fromEntries(parts.filter((x)=>x.type !== 'literal').map((x)=>[
|
|
43
|
+
x.type,
|
|
44
|
+
x.value
|
|
45
|
+
]));
|
|
46
|
+
// en-CA formats midnight hour as '24' — normalize to '00'
|
|
47
|
+
const hour = p['hour'] === '24' ? '00' : p['hour'];
|
|
48
|
+
return {
|
|
49
|
+
date: `${p['year']}-${p['month']}-${p['day']}`,
|
|
50
|
+
time: `${hour}:${p['minute']}`,
|
|
51
|
+
display: new Intl.DateTimeFormat('en-US', {
|
|
52
|
+
timeZone: timezone,
|
|
53
|
+
dateStyle: 'medium',
|
|
54
|
+
timeStyle: 'short'
|
|
55
|
+
}).format(d)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function formatInTimezone(utcDate, timezone, options = {
|
|
59
|
+
dateStyle: 'medium',
|
|
60
|
+
timeStyle: 'short'
|
|
61
|
+
}) {
|
|
62
|
+
const d = typeof utcDate === 'string' ? new Date(utcDate) : utcDate;
|
|
63
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
64
|
+
...options,
|
|
65
|
+
timeZone: timezone
|
|
66
|
+
}).format(d);
|
|
67
|
+
}
|
|
68
|
+
export function getTodayInTimezone(timezone) {
|
|
69
|
+
return utcToLocal(new Date(), timezone).date;
|
|
70
|
+
}
|
package/fesm/utils/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BadRequestException } from '@nestjs/common';
|
|
2
|
+
import { AUTH_MESSAGES } from '../constants';
|
|
2
3
|
/**
|
|
3
4
|
* Apply company filter to a query builder if company feature is enabled.
|
|
4
5
|
* Centralizes the common pattern of filtering by user's company.
|
|
@@ -72,7 +73,13 @@ import { BadRequestException } from '@nestjs/common';
|
|
|
72
73
|
*/ export function validateCompanyOwnership(entity, user, isCompanyFeatureEnabled, entityName) {
|
|
73
74
|
if (isCompanyFeatureEnabled && user?.companyId && hasCompanyId(entity)) {
|
|
74
75
|
if (entity.companyId && entity.companyId !== user.companyId) {
|
|
75
|
-
throw new BadRequestException(
|
|
76
|
+
throw new BadRequestException({
|
|
77
|
+
message: `${entityName} belongs to another company`,
|
|
78
|
+
messageKey: AUTH_MESSAGES.ENTITY_BELOG_ANOTHER_COMPANY,
|
|
79
|
+
messageVariables: {
|
|
80
|
+
entity: entityName
|
|
81
|
+
}
|
|
82
|
+
});
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
}
|
|
@@ -57,3 +57,25 @@ import { CLIENT_TYPE_HEADER } from '../constants';
|
|
|
57
57
|
const unit = match[2];
|
|
58
58
|
return value * (TIME_UNIT_MS[unit] ?? TIME_UNIT_MS.d);
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Determine the frontend base URL for auth-related links.
|
|
62
|
+
* Uses allowed origins from envConfig first, then falls back to request host.
|
|
63
|
+
*/ export function extractFrontendUrl(req) {
|
|
64
|
+
const allowedOrigins = envConfig.getOrigins();
|
|
65
|
+
const origin = (req.get('origin') ?? req.get('referer') ?? '').trim();
|
|
66
|
+
if (origin && allowedOrigins.length) {
|
|
67
|
+
const matched = allowedOrigins.find((u)=>origin.startsWith(u));
|
|
68
|
+
if (matched) {
|
|
69
|
+
return matched;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const host = req.get('host');
|
|
73
|
+
if (!host) {
|
|
74
|
+
throw new Error('Unable to determine frontend URL from request');
|
|
75
|
+
}
|
|
76
|
+
return `${req.protocol}://${host}`;
|
|
77
|
+
}
|
|
78
|
+
export function buildFrontendUrl(req, path) {
|
|
79
|
+
const baseUrl = extractFrontendUrl(req);
|
|
80
|
+
return `${baseUrl}${path}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
|
|
2
|
+
export declare class GlobalExceptionFilter implements ExceptionFilter {
|
|
3
|
+
private readonly logger;
|
|
4
|
+
catch(exception: unknown, host: ArgumentsHost): void;
|
|
5
|
+
private resolveException;
|
|
6
|
+
private resolveHttpException;
|
|
7
|
+
private resolveQueryFailedError;
|
|
8
|
+
private toHumanReadable;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './global-exception.filter';
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
2
|
import { Reflector } from '@nestjs/core';
|
|
3
3
|
import { HybridCache } from '../classes/hybrid-cache.class';
|
|
4
|
-
import { ILogger } from '../interfaces/logger.interface';
|
|
5
4
|
import { PermissionGuardConfig } from '../interfaces/permission.interface';
|
|
6
5
|
export declare class PermissionGuard implements CanActivate {
|
|
7
6
|
private readonly reflector;
|
|
8
7
|
private readonly cache?;
|
|
9
8
|
private readonly config;
|
|
10
|
-
|
|
11
|
-
constructor(reflector: Reflector, cache?: HybridCache, config?: PermissionGuardConfig, logger?: ILogger);
|
|
9
|
+
constructor(reflector: Reflector, cache?: HybridCache | undefined, config?: PermissionGuardConfig);
|
|
12
10
|
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
13
|
-
private
|
|
14
|
-
private
|
|
15
|
-
private isNestedCondition;
|
|
16
|
-
private evaluateCondition;
|
|
11
|
+
private normalizeToLogicNode;
|
|
12
|
+
private evaluateLogicNode;
|
|
17
13
|
private getUserPermissions;
|
|
18
14
|
private buildPermissionCacheKey;
|
|
19
15
|
private hasPermission;
|
package/index.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ export * from './constants';
|
|
|
3
3
|
export * from './decorators';
|
|
4
4
|
export * from './dtos';
|
|
5
5
|
export * from './entities';
|
|
6
|
+
export * from './enums';
|
|
6
7
|
export * from './exceptions';
|
|
8
|
+
export * from './filters';
|
|
7
9
|
export * from './guards';
|
|
8
10
|
export * from './interceptors';
|
|
9
11
|
export * from './interfaces';
|
|
@@ -5,6 +5,6 @@ export declare class IdempotencyInterceptor implements NestInterceptor {
|
|
|
5
5
|
private readonly cache?;
|
|
6
6
|
private readonly CACHE_TTL;
|
|
7
7
|
private readonly PROCESSING_MARKER;
|
|
8
|
-
constructor(cache?: HybridCache);
|
|
8
|
+
constructor(cache?: HybridCache | undefined);
|
|
9
9
|
intercept<T = any>(context: ExecutionContext, next: CallHandler<T>): Promise<Observable<T>>;
|
|
10
10
|
}
|
package/interceptors/index.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
export * from './delete-empty-id-from-body.interceptor';
|
|
2
2
|
export * from './idempotency.interceptor';
|
|
3
|
-
export * from './query-performance.interceptor';
|
|
4
3
|
export * from './response-meta.interceptor';
|
|
5
4
|
export * from './set-user-field-on-body.interceptor';
|
|
6
5
|
export * from './slug.interceptor';
|