@heavybit/pendoadmin-shared-lib 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/dist/common/index.d.ts +6 -0
- package/dist/common/index.d.ts.map +1 -0
- package/dist/common/index.js +6 -0
- package/dist/common/index.js.map +1 -0
- package/dist/common/jwt.utils.d.ts +7 -0
- package/dist/common/jwt.utils.d.ts.map +1 -0
- package/dist/common/jwt.utils.js +31 -0
- package/dist/common/jwt.utils.js.map +1 -0
- package/dist/common/password.utils.d.ts +4 -0
- package/dist/common/password.utils.d.ts.map +1 -0
- package/dist/common/password.utils.js +17 -0
- package/dist/common/password.utils.js.map +1 -0
- package/dist/common/phone.utils.d.ts +4 -0
- package/dist/common/phone.utils.d.ts.map +1 -0
- package/dist/common/phone.utils.js +36 -0
- package/dist/common/phone.utils.js.map +1 -0
- package/dist/common/response.utils.d.ts +6 -0
- package/dist/common/response.utils.d.ts.map +1 -0
- package/dist/common/response.utils.js +33 -0
- package/dist/common/response.utils.js.map +1 -0
- package/dist/common/uuid.utils.d.ts +5 -0
- package/dist/common/uuid.utils.d.ts.map +1 -0
- package/dist/common/uuid.utils.js +10 -0
- package/dist/common/uuid.utils.js.map +1 -0
- package/dist/config/index.d.ts +95 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +71 -0
- package/dist/config/index.js.map +1 -0
- package/dist/database/index.d.ts +25 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +62 -0
- package/dist/database/index.js.map +1 -0
- package/dist/express/auth.middleware.d.ts +20 -0
- package/dist/express/auth.middleware.d.ts.map +1 -0
- package/dist/express/auth.middleware.js +83 -0
- package/dist/express/auth.middleware.js.map +1 -0
- package/dist/express/correlation.middleware.d.ts +6 -0
- package/dist/express/correlation.middleware.d.ts.map +1 -0
- package/dist/express/correlation.middleware.js +15 -0
- package/dist/express/correlation.middleware.js.map +1 -0
- package/dist/express/error-handler.d.ts +8 -0
- package/dist/express/error-handler.d.ts.map +1 -0
- package/dist/express/error-handler.js +18 -0
- package/dist/express/error-handler.js.map +1 -0
- package/dist/express/index.d.ts +6 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +6 -0
- package/dist/express/index.js.map +1 -0
- package/dist/express/permission.guard.d.ts +14 -0
- package/dist/express/permission.guard.d.ts.map +1 -0
- package/dist/express/permission.guard.js +66 -0
- package/dist/express/permission.guard.js.map +1 -0
- package/dist/express/validation.middleware.d.ts +14 -0
- package/dist/express/validation.middleware.d.ts.map +1 -0
- package/dist/express/validation.middleware.js +80 -0
- package/dist/express/validation.middleware.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/index.d.ts +4 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/logging/index.js +24 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/messaging/index.d.ts +58 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +132 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/nestjs/auth.guard.d.ts +12 -0
- package/dist/nestjs/auth.guard.d.ts.map +1 -0
- package/dist/nestjs/auth.guard.js +75 -0
- package/dist/nestjs/auth.guard.js.map +1 -0
- package/dist/nestjs/correlation.interceptor.d.ts +9 -0
- package/dist/nestjs/correlation.interceptor.d.ts.map +1 -0
- package/dist/nestjs/correlation.interceptor.js +29 -0
- package/dist/nestjs/correlation.interceptor.js.map +1 -0
- package/dist/nestjs/exception.filter.d.ts +8 -0
- package/dist/nestjs/exception.filter.d.ts.map +1 -0
- package/dist/nestjs/exception.filter.js +54 -0
- package/dist/nestjs/exception.filter.js.map +1 -0
- package/dist/nestjs/index.d.ts +7 -0
- package/dist/nestjs/index.d.ts.map +1 -0
- package/dist/nestjs/index.js +7 -0
- package/dist/nestjs/index.js.map +1 -0
- package/dist/nestjs/permission.decorator.d.ts +22 -0
- package/dist/nestjs/permission.decorator.d.ts.map +1 -0
- package/dist/nestjs/permission.decorator.js +30 -0
- package/dist/nestjs/permission.decorator.js.map +1 -0
- package/dist/nestjs/permission.guard.d.ts +12 -0
- package/dist/nestjs/permission.guard.d.ts.map +1 -0
- package/dist/nestjs/permission.guard.js +66 -0
- package/dist/nestjs/permission.guard.js.map +1 -0
- package/dist/nestjs/user.decorator.d.ts +12 -0
- package/dist/nestjs/user.decorator.d.ts.map +1 -0
- package/dist/nestjs/user.decorator.js +16 -0
- package/dist/nestjs/user.decorator.js.map +1 -0
- package/dist/types/index.d.ts +193 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +17 -0
- package/dist/types/index.js.map +1 -0
- package/dist/validation/index.d.ts +32 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +21 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +100 -0
- package/src/common/index.ts +5 -0
- package/src/common/jwt.utils.ts +43 -0
- package/src/common/password.utils.ts +20 -0
- package/src/common/phone.utils.ts +36 -0
- package/src/common/response.utils.ts +48 -0
- package/src/common/uuid.utils.ts +11 -0
- package/src/config/index.ts +73 -0
- package/src/database/index.ts +77 -0
- package/src/express/auth.middleware.ts +93 -0
- package/src/express/correlation.middleware.ts +16 -0
- package/src/express/error-handler.ts +28 -0
- package/src/express/index.ts +5 -0
- package/src/express/permission.guard.ts +72 -0
- package/src/express/validation.middleware.ts +76 -0
- package/src/index.ts +23 -0
- package/src/logging/index.ts +31 -0
- package/src/messaging/index.ts +161 -0
- package/src/nestjs/auth.guard.ts +69 -0
- package/src/nestjs/correlation.interceptor.ts +30 -0
- package/src/nestjs/exception.filter.ts +53 -0
- package/src/nestjs/index.ts +11 -0
- package/src/nestjs/permission.decorator.ts +36 -0
- package/src/nestjs/permission.guard.ts +76 -0
- package/src/nestjs/user.decorator.ts +19 -0
- package/src/types/index.ts +239 -0
- package/src/validation/index.ts +26 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Require a specific permission. Superadmins bypass all checks.
|
|
5
|
+
*/
|
|
6
|
+
export function requirePermission(...permissions: string[]) {
|
|
7
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
8
|
+
if (!req.user) {
|
|
9
|
+
res.status(401).json({ success: false, message: 'Authentication required' });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (req.user.isSuperAdmin) return next();
|
|
14
|
+
|
|
15
|
+
const hasPermission = permissions.some(p => req.user!.permissions.includes(p));
|
|
16
|
+
if (!hasPermission) {
|
|
17
|
+
res.status(403).json({
|
|
18
|
+
success: false,
|
|
19
|
+
message: 'Insufficient permissions',
|
|
20
|
+
errors: { required: permissions },
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
next();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Require ALL of the listed permissions.
|
|
30
|
+
*/
|
|
31
|
+
export function requireAllPermissions(...permissions: string[]) {
|
|
32
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
33
|
+
if (!req.user) {
|
|
34
|
+
res.status(401).json({ success: false, message: 'Authentication required' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (req.user.isSuperAdmin) return next();
|
|
39
|
+
|
|
40
|
+
const hasAll = permissions.every(p => req.user!.permissions.includes(p));
|
|
41
|
+
if (!hasAll) {
|
|
42
|
+
res.status(403).json({
|
|
43
|
+
success: false,
|
|
44
|
+
message: 'Insufficient permissions',
|
|
45
|
+
errors: { required: permissions },
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
next();
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Require a specific role.
|
|
55
|
+
*/
|
|
56
|
+
export function requireRole(...roles: string[]) {
|
|
57
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
58
|
+
if (!req.user) {
|
|
59
|
+
res.status(401).json({ success: false, message: 'Authentication required' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (req.user.isSuperAdmin) return next();
|
|
64
|
+
|
|
65
|
+
const hasRole = roles.some(r => req.user!.roles.includes(r));
|
|
66
|
+
if (!hasRole) {
|
|
67
|
+
res.status(403).json({ success: false, message: 'Insufficient role' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import type { ZodSchema, ZodObject, ZodRawShape } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Zod validation middleware for Express routes.
|
|
6
|
+
* @param schema Zod schema to validate against
|
|
7
|
+
* @param property Which part of the request to validate ('body' | 'query' | 'params')
|
|
8
|
+
*/
|
|
9
|
+
export function validate(schema: ZodSchema, property: 'body' | 'query' | 'params' = 'body') {
|
|
10
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
11
|
+
const result = schema.safeParse(req[property]);
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
const errors: Record<string, string[]> = {};
|
|
14
|
+
for (const issue of result.error.issues) {
|
|
15
|
+
const key = issue.path.join('.');
|
|
16
|
+
if (!errors[key]) errors[key] = [];
|
|
17
|
+
errors[key].push(issue.message);
|
|
18
|
+
}
|
|
19
|
+
res.status(422).json({ success: false, message: 'Validation failed', errors });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
req[property] = result.data;
|
|
23
|
+
next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Zod validation middleware – accepts a compound schema with body/query/params
|
|
29
|
+
* keys, or a simple schema validated against body.
|
|
30
|
+
*/
|
|
31
|
+
export function validateRequest(schema: ZodSchema) {
|
|
32
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
33
|
+
// Check if this is a compound schema with body/query/params keys
|
|
34
|
+
const shape = (schema as ZodObject<ZodRawShape>)?.shape;
|
|
35
|
+
const isCompound = shape && ('body' in shape || 'query' in shape || 'params' in shape);
|
|
36
|
+
|
|
37
|
+
if (isCompound) {
|
|
38
|
+
const data: Record<string, unknown> = {};
|
|
39
|
+
if (shape.body) data.body = req.body;
|
|
40
|
+
if (shape.query) data.query = req.query;
|
|
41
|
+
if (shape.params) data.params = req.params;
|
|
42
|
+
|
|
43
|
+
const result = schema.safeParse(data);
|
|
44
|
+
if (!result.success) {
|
|
45
|
+
const errors: Record<string, string[]> = {};
|
|
46
|
+
for (const issue of result.error.issues) {
|
|
47
|
+
const key = issue.path.join('.');
|
|
48
|
+
if (!errors[key]) errors[key] = [];
|
|
49
|
+
errors[key].push(issue.message);
|
|
50
|
+
}
|
|
51
|
+
res.status(422).json({ success: false, message: 'Validation failed', errors });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const parsed = result.data as Record<string, unknown>;
|
|
55
|
+
if (parsed.body) req.body = parsed.body;
|
|
56
|
+
if (parsed.query) (req as any).query = parsed.query;
|
|
57
|
+
if (parsed.params) (req as any).params = parsed.params;
|
|
58
|
+
} else {
|
|
59
|
+
// Simple schema – validate body
|
|
60
|
+
const result = schema.safeParse(req.body);
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
const errors: Record<string, string[]> = {};
|
|
63
|
+
for (const issue of result.error.issues) {
|
|
64
|
+
const key = issue.path.join('.');
|
|
65
|
+
if (!errors[key]) errors[key] = [];
|
|
66
|
+
errors[key].push(issue.message);
|
|
67
|
+
}
|
|
68
|
+
res.status(422).json({ success: false, message: 'Validation failed', errors });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
req.body = result.data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
next();
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export * from './types/index.js';
|
|
3
|
+
|
|
4
|
+
// Common utilities
|
|
5
|
+
export * from './common/index.js';
|
|
6
|
+
|
|
7
|
+
// Validation
|
|
8
|
+
export * from './validation/index.js';
|
|
9
|
+
|
|
10
|
+
// Logging
|
|
11
|
+
export * from './logging/index.js';
|
|
12
|
+
|
|
13
|
+
// Config
|
|
14
|
+
export * from './config/index.js';
|
|
15
|
+
|
|
16
|
+
// Messaging
|
|
17
|
+
export * from './messaging/index.js';
|
|
18
|
+
|
|
19
|
+
// Database
|
|
20
|
+
export * from './database/index.js';
|
|
21
|
+
|
|
22
|
+
// Express middleware (for Express-based services)
|
|
23
|
+
export * from './express/index.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
|
|
3
|
+
const { combine, timestamp, json, errors, colorize, simple } = winston.format;
|
|
4
|
+
|
|
5
|
+
export function createLogger(service: string, level?: string): winston.Logger {
|
|
6
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
7
|
+
|
|
8
|
+
return winston.createLogger({
|
|
9
|
+
level: level || process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
|
|
10
|
+
defaultMeta: { service },
|
|
11
|
+
format: combine(
|
|
12
|
+
errors({ stack: true }),
|
|
13
|
+
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
14
|
+
isProduction ? json() : combine(colorize(), simple())
|
|
15
|
+
),
|
|
16
|
+
transports: [
|
|
17
|
+
new winston.transports.Console(),
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createAuditLogger(service: string): winston.Logger {
|
|
23
|
+
return winston.createLogger({
|
|
24
|
+
level: 'info',
|
|
25
|
+
defaultMeta: { service, type: 'audit' },
|
|
26
|
+
format: combine(timestamp(), json()),
|
|
27
|
+
transports: [
|
|
28
|
+
new winston.transports.Console(),
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import amqplib from 'amqplib';
|
|
2
|
+
import type { Channel, ChannelModel, ConsumeMessage } from 'amqplib';
|
|
3
|
+
import { createLogger } from '../logging/index.js';
|
|
4
|
+
|
|
5
|
+
const logger = createLogger('rabbitmq');
|
|
6
|
+
|
|
7
|
+
// ── Event Type Constants ──────────────────────────────
|
|
8
|
+
|
|
9
|
+
export const EVENTS = {
|
|
10
|
+
// Auth events
|
|
11
|
+
AUTH_USER_CREATED: 'auth.user.created',
|
|
12
|
+
AUTH_USER_UPDATED: 'auth.user.updated',
|
|
13
|
+
AUTH_USER_DELETED: 'auth.user.deleted',
|
|
14
|
+
AUTH_USER_REGISTERED: 'auth.user.registered',
|
|
15
|
+
AUTH_LOGIN_SUCCESS: 'auth.login.success',
|
|
16
|
+
AUTH_LOGIN_FAILED: 'auth.login.failed',
|
|
17
|
+
AUTH_ROLE_UPDATED: 'auth.role.updated',
|
|
18
|
+
AUTH_COMPANY_CREATED: 'auth.company.created',
|
|
19
|
+
AUTH_COMPANY_UPDATED: 'auth.company.updated',
|
|
20
|
+
AUTH_COMPANY_CONFIG_UPDATED: 'auth.company.config_updated',
|
|
21
|
+
AUTH_PASSWORD_RESET_REQUESTED: 'auth.password.reset_requested',
|
|
22
|
+
AUTH_OTP_REQUESTED: 'auth.otp.requested',
|
|
23
|
+
|
|
24
|
+
// SMS events
|
|
25
|
+
SMS_MESSAGE_SENT: 'sms.message.sent',
|
|
26
|
+
SMS_MESSAGE_DELIVERED: 'sms.message.delivered',
|
|
27
|
+
SMS_MESSAGE_FAILED: 'sms.message.failed',
|
|
28
|
+
SMS_DELIVERY_FAILED: 'sms.delivery.failed',
|
|
29
|
+
SMS_SCHEDULE_TRIGGERED: 'sms.schedule.triggered',
|
|
30
|
+
SMS_PROCESS_SCHEDULED: 'sms.process_scheduled',
|
|
31
|
+
SMS_BULK_QUEUED: 'sms.bulk.queued',
|
|
32
|
+
SMS_BULK_COMPLETED: 'sms.bulk.completed',
|
|
33
|
+
|
|
34
|
+
// M-Pesa events
|
|
35
|
+
MPESA_PAYMENT_RECEIVED: 'mpesa.payment.received',
|
|
36
|
+
MPESA_STK_PUSH_SUCCESS: 'mpesa.stk_push.success',
|
|
37
|
+
MPESA_STK_PUSH_FAILED: 'mpesa.stk_push.failed',
|
|
38
|
+
MPESA_STK_PUSH_COMPLETED: 'mpesa.stk_push.completed',
|
|
39
|
+
MPESA_B2C_COMPLETED: 'mpesa.b2c.completed',
|
|
40
|
+
|
|
41
|
+
// USSD events
|
|
42
|
+
USSD_SESSION_STARTED: 'ussd.session.started',
|
|
43
|
+
USSD_SESSION_ENDED: 'ussd.session.ended',
|
|
44
|
+
USSD_SUBSCRIBER_REGISTERED: 'ussd.subscriber.registered',
|
|
45
|
+
USSD_STK_PUSH_REQUEST: 'ussd.stk_push.request',
|
|
46
|
+
|
|
47
|
+
// WhatsApp events
|
|
48
|
+
WHATSAPP_MESSAGE_SENT: 'whatsapp.message.sent',
|
|
49
|
+
WHATSAPP_MESSAGE_RECEIVED: 'whatsapp.message.received',
|
|
50
|
+
WHATSAPP_MESSAGE_DELIVERED: 'whatsapp.message.delivered',
|
|
51
|
+
WHATSAPP_MESSAGE_READ: 'whatsapp.message.read',
|
|
52
|
+
|
|
53
|
+
// Notification events
|
|
54
|
+
NOTIFICATION_SEND_SMS: 'notification.send_sms',
|
|
55
|
+
NOTIFICATION_SEND_WHATSAPP: 'notification.send_whatsapp',
|
|
56
|
+
NOTIFICATION_SEND_EMAIL: 'notification.send_email',
|
|
57
|
+
|
|
58
|
+
// AI events
|
|
59
|
+
AI_COMPLETION_SUCCESS: 'ai.completion.success',
|
|
60
|
+
AI_COMPLETION_FAILED: 'ai.completion.failed',
|
|
61
|
+
AI_ANOMALY_DETECTED: 'ai.anomaly.detected',
|
|
62
|
+
|
|
63
|
+
// Gateway events
|
|
64
|
+
GATEWAY_REQUEST_COMPLETED: 'gateway.request.completed',
|
|
65
|
+
GATEWAY_REQUEST_ERROR: 'gateway.request.error',
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
export const EXCHANGES = {
|
|
69
|
+
EVENTS: 'pendoadmin.events',
|
|
70
|
+
COMMANDS: 'pendoadmin.commands',
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
// ── RabbitMQ Client ───────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export class RabbitMQClient {
|
|
76
|
+
private connection: ChannelModel | null = null;
|
|
77
|
+
private channel: Channel | null = null;
|
|
78
|
+
private url: string;
|
|
79
|
+
|
|
80
|
+
constructor(url: string) {
|
|
81
|
+
this.url = url;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async connect(): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
this.connection = await amqplib.connect(this.url);
|
|
87
|
+
this.channel = await this.connection.createChannel();
|
|
88
|
+
|
|
89
|
+
// Set up exchanges
|
|
90
|
+
await this.channel.assertExchange(EXCHANGES.EVENTS, 'topic', { durable: true });
|
|
91
|
+
await this.channel.assertExchange(EXCHANGES.COMMANDS, 'direct', { durable: true });
|
|
92
|
+
|
|
93
|
+
this.connection.on('error', (err) => {
|
|
94
|
+
logger.error('RabbitMQ connection error', { error: err.message });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this.connection.on('close', () => {
|
|
98
|
+
logger.warn('RabbitMQ connection closed, reconnecting...');
|
|
99
|
+
setTimeout(() => this.connect(), 5000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
logger.info('Connected to RabbitMQ');
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error('Failed to connect to RabbitMQ', { error: (error as Error).message });
|
|
105
|
+
setTimeout(() => this.connect(), 5000);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async publish(routingKey: string, data: Record<string, unknown>, exchange: string = EXCHANGES.EVENTS): Promise<void> {
|
|
110
|
+
if (!this.channel) {
|
|
111
|
+
logger.warn('RabbitMQ channel not ready, message dropped', { routingKey });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const message = Buffer.from(JSON.stringify({
|
|
116
|
+
routingKey,
|
|
117
|
+
data,
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
this.channel.publish(exchange, routingKey, message, {
|
|
122
|
+
persistent: true,
|
|
123
|
+
contentType: 'application/json',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async subscribe(
|
|
128
|
+
queueName: string,
|
|
129
|
+
routingKeys: string | string[],
|
|
130
|
+
handler: (data: Record<string, unknown>, routingKey: string) => Promise<void>,
|
|
131
|
+
exchange: string = EXCHANGES.EVENTS
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
if (!this.channel) throw new Error('RabbitMQ channel not ready');
|
|
134
|
+
|
|
135
|
+
const keys = Array.isArray(routingKeys) ? routingKeys : [routingKeys];
|
|
136
|
+
await this.channel.assertQueue(queueName, { durable: true });
|
|
137
|
+
|
|
138
|
+
for (const key of keys) {
|
|
139
|
+
await this.channel.bindQueue(queueName, exchange, key);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await this.channel.consume(queueName, async (msg: ConsumeMessage | null) => {
|
|
143
|
+
if (!msg) return;
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(msg.content.toString());
|
|
146
|
+
await handler(parsed.data || parsed, msg.fields.routingKey);
|
|
147
|
+
this.channel!.ack(msg);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logger.error('Error processing message', { queue: queueName, error: (error as Error).message });
|
|
150
|
+
this.channel!.nack(msg, false, false); // Dead letter
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
logger.info(`Subscribed to queue: ${queueName}`, { routingKeys });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async close(): Promise<void> {
|
|
158
|
+
await this.channel?.close();
|
|
159
|
+
await this.connection?.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
CanActivate,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Reflector } from '@nestjs/core';
|
|
8
|
+
import { verifyToken } from '../common/jwt.utils.js';
|
|
9
|
+
import { GATEWAY_HEADERS, type IAuthUser } from '../types/index.js';
|
|
10
|
+
|
|
11
|
+
export const IS_PUBLIC_KEY = 'isPublic';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* NestJS Guard: validates JWT or trusts gateway-injected headers.
|
|
15
|
+
*/
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class AuthGuard implements CanActivate {
|
|
18
|
+
constructor(private reflector: Reflector) {}
|
|
19
|
+
|
|
20
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
21
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
22
|
+
context.getHandler(),
|
|
23
|
+
context.getClass(),
|
|
24
|
+
]);
|
|
25
|
+
if (isPublic) return true;
|
|
26
|
+
|
|
27
|
+
const request = context.switchToHttp().getRequest();
|
|
28
|
+
|
|
29
|
+
// Trust gateway-injected headers first
|
|
30
|
+
const gatewayUserId = request.headers[GATEWAY_HEADERS.USER_ID];
|
|
31
|
+
if (gatewayUserId) {
|
|
32
|
+
const permissions = request.headers[GATEWAY_HEADERS.USER_PERMISSIONS];
|
|
33
|
+
request.user = {
|
|
34
|
+
id: gatewayUserId,
|
|
35
|
+
companyId: request.headers[GATEWAY_HEADERS.COMPANY_ID] || undefined,
|
|
36
|
+
email: request.headers[GATEWAY_HEADERS.USER_EMAIL] || undefined,
|
|
37
|
+
roles: request.headers[GATEWAY_HEADERS.USER_ROLES]
|
|
38
|
+
? (request.headers[GATEWAY_HEADERS.USER_ROLES] as string).split(',')
|
|
39
|
+
: [],
|
|
40
|
+
permissions: permissions ? (permissions as string).split(',') : [],
|
|
41
|
+
isSuperAdmin:
|
|
42
|
+
request.headers[GATEWAY_HEADERS.IS_SUPERADMIN] === 'true',
|
|
43
|
+
} as IAuthUser;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fallback to direct JWT validation
|
|
48
|
+
const authHeader = request.headers.authorization;
|
|
49
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
50
|
+
throw new UnauthorizedException('Missing authentication token');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const token = authHeader.slice(7);
|
|
55
|
+
const payload = verifyToken(token, process.env.JWT_SECRET!);
|
|
56
|
+
request.user = {
|
|
57
|
+
id: payload.sub,
|
|
58
|
+
companyId: payload.companyId,
|
|
59
|
+
email: payload.email,
|
|
60
|
+
roles: payload.roles || [],
|
|
61
|
+
permissions: payload.permissions || [],
|
|
62
|
+
isSuperAdmin: payload.isSuperAdmin || false,
|
|
63
|
+
} as IAuthUser;
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
throw new UnauthorizedException('Invalid or expired token');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
NestInterceptor,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
CallHandler,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Observable, tap } from 'rxjs';
|
|
8
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* NestJS Interceptor: propagates or generates x-correlation-id header.
|
|
12
|
+
*/
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class CorrelationInterceptor implements NestInterceptor {
|
|
15
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
16
|
+
const request = context.switchToHttp().getRequest();
|
|
17
|
+
const response = context.switchToHttp().getResponse();
|
|
18
|
+
|
|
19
|
+
const correlationId =
|
|
20
|
+
request.headers['x-correlation-id'] || uuidv4();
|
|
21
|
+
request.correlationId = correlationId;
|
|
22
|
+
response.setHeader('x-correlation-id', correlationId);
|
|
23
|
+
|
|
24
|
+
return next.handle().pipe(
|
|
25
|
+
tap(() => {
|
|
26
|
+
// correlation id already set on response
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExceptionFilter,
|
|
3
|
+
Catch,
|
|
4
|
+
ArgumentsHost,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { createLogger } from '../logging/index.js';
|
|
9
|
+
|
|
10
|
+
const logger = createLogger('exception-filter');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* NestJS Exception Filter: standardises error responses.
|
|
14
|
+
*/
|
|
15
|
+
@Catch()
|
|
16
|
+
export class GlobalExceptionFilter implements ExceptionFilter {
|
|
17
|
+
catch(exception: unknown, host: ArgumentsHost) {
|
|
18
|
+
const ctx = host.switchToHttp();
|
|
19
|
+
const response = ctx.getResponse();
|
|
20
|
+
const request = ctx.getRequest();
|
|
21
|
+
|
|
22
|
+
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
23
|
+
let message = 'Internal server error';
|
|
24
|
+
let errors: any = undefined;
|
|
25
|
+
|
|
26
|
+
if (exception instanceof HttpException) {
|
|
27
|
+
status = exception.getStatus();
|
|
28
|
+
const exResponse = exception.getResponse();
|
|
29
|
+
if (typeof exResponse === 'string') {
|
|
30
|
+
message = exResponse;
|
|
31
|
+
} else if (typeof exResponse === 'object') {
|
|
32
|
+
message = (exResponse as any).message || message;
|
|
33
|
+
errors = (exResponse as any).errors;
|
|
34
|
+
}
|
|
35
|
+
} else if (exception instanceof Error) {
|
|
36
|
+
message = exception.message;
|
|
37
|
+
logger.error('Unhandled exception', {
|
|
38
|
+
error: exception.message,
|
|
39
|
+
stack: exception.stack,
|
|
40
|
+
path: request.url,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
response.status(status).json({
|
|
45
|
+
success: false,
|
|
46
|
+
message,
|
|
47
|
+
...(errors ? { errors } : {}),
|
|
48
|
+
statusCode: status,
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
path: request.url,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { AuthGuard } from './auth.guard.js';
|
|
2
|
+
export {
|
|
3
|
+
RequirePermission,
|
|
4
|
+
RequireAllPermissions,
|
|
5
|
+
RequireRole,
|
|
6
|
+
Public,
|
|
7
|
+
} from './permission.decorator.js';
|
|
8
|
+
export { PermissionGuard } from './permission.guard.js';
|
|
9
|
+
export { CurrentUser } from './user.decorator.js';
|
|
10
|
+
export { CorrelationInterceptor } from './correlation.interceptor.js';
|
|
11
|
+
export { GlobalExceptionFilter } from './exception.filter.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
import { IS_PUBLIC_KEY } from './auth.guard.js';
|
|
3
|
+
|
|
4
|
+
export const PERMISSIONS_KEY = 'permissions';
|
|
5
|
+
export const REQUIRE_ALL_KEY = 'requireAll';
|
|
6
|
+
export const ROLES_KEY = 'roles';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decorator: require at least one of the listed permissions.
|
|
10
|
+
* @example @RequirePermission('sms.messages:read', 'sms.messages:list')
|
|
11
|
+
*/
|
|
12
|
+
export const RequirePermission = (...permissions: string[]) =>
|
|
13
|
+
SetMetadata(PERMISSIONS_KEY, permissions);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decorator: require ALL of the listed permissions.
|
|
17
|
+
*/
|
|
18
|
+
export const RequireAllPermissions = (...permissions: string[]) => {
|
|
19
|
+
return (target: any, key?: string | symbol, descriptor?: any) => {
|
|
20
|
+
SetMetadata(PERMISSIONS_KEY, permissions)(target, key!, descriptor);
|
|
21
|
+
SetMetadata(REQUIRE_ALL_KEY, true)(target, key!, descriptor);
|
|
22
|
+
return descriptor;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decorator: require at least one of the listed roles.
|
|
28
|
+
* @example @RequireRole('admin', 'manager')
|
|
29
|
+
*/
|
|
30
|
+
export const RequireRole = (...roles: string[]) =>
|
|
31
|
+
SetMetadata(ROLES_KEY, roles);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decorator: mark route as public (skip auth guard).
|
|
35
|
+
*/
|
|
36
|
+
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
CanActivate,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
ForbiddenException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Reflector } from '@nestjs/core';
|
|
8
|
+
import {
|
|
9
|
+
PERMISSIONS_KEY,
|
|
10
|
+
REQUIRE_ALL_KEY,
|
|
11
|
+
ROLES_KEY,
|
|
12
|
+
} from './permission.decorator.js';
|
|
13
|
+
import { IS_PUBLIC_KEY } from './auth.guard.js';
|
|
14
|
+
import type { IAuthUser } from '../types/index.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* NestJS Guard: checks permissions/roles from decorators.
|
|
18
|
+
* Must be used after AuthGuard so that request.user is populated.
|
|
19
|
+
*/
|
|
20
|
+
@Injectable()
|
|
21
|
+
export class PermissionGuard implements CanActivate {
|
|
22
|
+
constructor(private reflector: Reflector) {}
|
|
23
|
+
|
|
24
|
+
canActivate(context: ExecutionContext): boolean {
|
|
25
|
+
// Skip permission check for public routes
|
|
26
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
27
|
+
context.getHandler(),
|
|
28
|
+
context.getClass(),
|
|
29
|
+
]);
|
|
30
|
+
if (isPublic) return true;
|
|
31
|
+
|
|
32
|
+
const request = context.switchToHttp().getRequest();
|
|
33
|
+
const user: IAuthUser | undefined = request.user;
|
|
34
|
+
|
|
35
|
+
if (!user) {
|
|
36
|
+
throw new ForbiddenException('User context not found');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Super admins bypass all permission checks
|
|
40
|
+
if (user.isSuperAdmin) return true;
|
|
41
|
+
|
|
42
|
+
// Check roles
|
|
43
|
+
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
|
44
|
+
ROLES_KEY,
|
|
45
|
+
[context.getHandler(), context.getClass()]
|
|
46
|
+
);
|
|
47
|
+
if (requiredRoles?.length) {
|
|
48
|
+
const hasRole = requiredRoles.some((role: string) => user.roles.includes(role));
|
|
49
|
+
if (!hasRole) {
|
|
50
|
+
throw new ForbiddenException('Insufficient role');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check permissions
|
|
55
|
+
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
|
56
|
+
PERMISSIONS_KEY,
|
|
57
|
+
[context.getHandler(), context.getClass()]
|
|
58
|
+
);
|
|
59
|
+
if (!requiredPermissions?.length) return true;
|
|
60
|
+
|
|
61
|
+
const requireAll = this.reflector.getAllAndOverride<boolean>(
|
|
62
|
+
REQUIRE_ALL_KEY,
|
|
63
|
+
[context.getHandler(), context.getClass()]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const hasPermission = requireAll
|
|
67
|
+
? requiredPermissions.every((p: string) => user.permissions.includes(p))
|
|
68
|
+
: requiredPermissions.some((p: string) => user.permissions.includes(p));
|
|
69
|
+
|
|
70
|
+
if (!hasPermission) {
|
|
71
|
+
throw new ForbiddenException('Insufficient permissions');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import type { IAuthUser } from '../types/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decorator: extract the authenticated user from the request.
|
|
6
|
+
* @example
|
|
7
|
+
* @Get('profile')
|
|
8
|
+
* getProfile(@CurrentUser() user: IAuthUser) { ... }
|
|
9
|
+
*
|
|
10
|
+
* @Get('id')
|
|
11
|
+
* getId(@CurrentUser('id') userId: string) { ... }
|
|
12
|
+
*/
|
|
13
|
+
export const CurrentUser = createParamDecorator(
|
|
14
|
+
(data: keyof IAuthUser | undefined, ctx: ExecutionContext) => {
|
|
15
|
+
const request = ctx.switchToHttp().getRequest();
|
|
16
|
+
const user: IAuthUser = request.user;
|
|
17
|
+
return data ? user?.[data] : user;
|
|
18
|
+
}
|
|
19
|
+
);
|