feat: add notification microservices and transactional outbox
هذا الالتزام موجود في:
2
libs/common/src/index.ts
Normal file
2
libs/common/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './retry';
|
||||
export * from './rabbitmq';
|
||||
39
libs/common/src/rabbitmq.spec.ts
Normal file
39
libs/common/src/rabbitmq.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { resolveRabbitMqConfig } from './rabbitmq';
|
||||
|
||||
const source = (values: Record<string, string>) => ({ get: (key: string) => values[key] });
|
||||
|
||||
describe('RabbitMQ production configuration', () => {
|
||||
it('rejects guest credentials and plaintext in production microservices', () => {
|
||||
expect(() => resolveRabbitMqConfig(source({ NODE_ENV: 'production', RABBITMQ_URL: 'amqps://guest:guest@broker/vhost' }), 'gateway', true)).toThrow('non-guest');
|
||||
expect(() => resolveRabbitMqConfig(source({ NODE_ENV: 'production', RABBITMQ_URL: 'amqp://service:strong-password@broker/vhost' }), 'gateway', true)).toThrow('TLS/amqps');
|
||||
});
|
||||
|
||||
it('supports secure URLs and separate TLS credentials without exposing userinfo', () => {
|
||||
const direct = resolveRabbitMqConfig(source({
|
||||
NODE_ENV: 'production', RABBITMQ_URL: 'amqps://service:strong-password@broker.example/vhost',
|
||||
RABBITMQ_SERVERNAME: 'broker.example', RABBITMQ_TLS_REJECT_UNAUTHORIZED: 'true',
|
||||
}), 'notification-service', true);
|
||||
expect(direct.sanitizedEndpoint).toBe('amqps://broker.example:5671/vhost');
|
||||
expect(direct.sanitizedEndpoint).not.toContain('strong-password');
|
||||
expect(direct.socketOptions.connectionOptions).toEqual(expect.objectContaining({ rejectUnauthorized: true, servername: 'broker.example' }));
|
||||
|
||||
const split = resolveRabbitMqConfig(source({
|
||||
RABBITMQ_HOST: 'rabbit', RABBITMQ_PORT: '5671', RABBITMQ_USERNAME: 'service',
|
||||
RABBITMQ_PASSWORD: 'password', RABBITMQ_VHOST: 'oudelaa', RABBITMQ_TLS_ENABLED: 'true',
|
||||
}), 'gateway', false);
|
||||
expect(split.url).toBe('amqps://service:password@rabbit:5671/oudelaa');
|
||||
|
||||
const urlWithSeparateCredentials = resolveRabbitMqConfig(source({
|
||||
RABBITMQ_URL: 'amqps://rabbit:5671/oudelaa', RABBITMQ_USERNAME: 'service',
|
||||
RABBITMQ_PASSWORD: 'password',
|
||||
}), 'gateway', false);
|
||||
expect(urlWithSeparateCredentials.url).toBe('amqps://service:password@rabbit:5671/oudelaa');
|
||||
});
|
||||
|
||||
it('requires client certificate and key as a pair', () => {
|
||||
expect(() => resolveRabbitMqConfig(source({
|
||||
RABBITMQ_URL: 'amqps://service:password@rabbit/vhost',
|
||||
RABBITMQ_CLIENT_CERT_BASE64: Buffer.from('cert').toString('base64'),
|
||||
}), 'gateway', false)).toThrow('configured together');
|
||||
});
|
||||
});
|
||||
95
libs/common/src/rabbitmq.ts
Normal file
95
libs/common/src/rabbitmq.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
export interface RabbitConfigSource {
|
||||
get(key: string): string | undefined;
|
||||
}
|
||||
|
||||
export interface ResolvedRabbitMqConfig {
|
||||
url: string;
|
||||
sanitizedEndpoint: string;
|
||||
socketOptions: {
|
||||
heartbeatIntervalInSeconds: number;
|
||||
reconnectTimeInSeconds: number;
|
||||
connectionOptions: Record<string, unknown>;
|
||||
};
|
||||
tlsEnabled: boolean;
|
||||
}
|
||||
|
||||
const bool = (value: string | undefined, fallback = false) => value === undefined ? fallback : value === 'true';
|
||||
|
||||
function material(source: RabbitConfigSource, base64Key: string, pathKey: string): Buffer | undefined {
|
||||
const encoded = source.get(base64Key);
|
||||
if (encoded) return Buffer.from(encoded, 'base64');
|
||||
const path = source.get(pathKey);
|
||||
return path ? readFileSync(path) : undefined;
|
||||
}
|
||||
|
||||
export function resolveRabbitMqConfig(
|
||||
source: RabbitConfigSource,
|
||||
serviceName: string,
|
||||
requireProductionSafety: boolean,
|
||||
): ResolvedRabbitMqConfig {
|
||||
const explicitUrl = source.get('RABBITMQ_URL');
|
||||
const tlsEnabled = bool(source.get('RABBITMQ_TLS_ENABLED'), explicitUrl?.startsWith('amqps://'));
|
||||
const protocol = tlsEnabled ? 'amqps' : 'amqp';
|
||||
const host = source.get('RABBITMQ_HOST') || '127.0.0.1';
|
||||
const port = source.get('RABBITMQ_PORT') || (tlsEnabled ? '5671' : '5672');
|
||||
const username = source.get('RABBITMQ_USERNAME') || 'guest';
|
||||
const password = source.get('RABBITMQ_PASSWORD') || 'guest';
|
||||
const vhost = source.get('RABBITMQ_VHOST') || '/';
|
||||
let url = explicitUrl
|
||||
? (tlsEnabled ? explicitUrl.replace(/^amqp:\/\//, 'amqps://') : explicitUrl)
|
||||
: `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}/${vhost === '/' ? '' : encodeURIComponent(vhost.replace(/^\//, ''))}`;
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); }
|
||||
catch { throw new Error('RabbitMQ configuration contains an invalid URL'); }
|
||||
if (explicitUrl && !parsed.username && source.get('RABBITMQ_USERNAME')) {
|
||||
parsed.username = username;
|
||||
parsed.password = password;
|
||||
url = parsed.toString();
|
||||
}
|
||||
const configuredUser = decodeURIComponent(parsed.username || username);
|
||||
const configuredPassword = decodeURIComponent(parsed.password || password);
|
||||
const production = source.get('NODE_ENV') === 'production';
|
||||
const tlsRequired = bool(source.get('RABBITMQ_TLS_REQUIRED'), true);
|
||||
if (production && requireProductionSafety) {
|
||||
if (!configuredUser || !configuredPassword || configuredUser === 'guest' || configuredPassword === 'guest') {
|
||||
throw new Error('RabbitMQ production credentials must be non-guest and non-empty');
|
||||
}
|
||||
if (tlsRequired && parsed.protocol !== 'amqps:' && !tlsEnabled) {
|
||||
throw new Error('RabbitMQ TLS/amqps is required for production microservices');
|
||||
}
|
||||
}
|
||||
|
||||
const connectionOptions: Record<string, unknown> = {
|
||||
timeout: Number(source.get('RABBITMQ_CONNECTION_TIMEOUT_MS') || 10_000),
|
||||
clientProperties: { connection_name: `${serviceName}:${source.get('HOSTNAME') || process.pid}` },
|
||||
};
|
||||
if (tlsEnabled || parsed.protocol === 'amqps:') {
|
||||
connectionOptions.rejectUnauthorized = bool(source.get('RABBITMQ_TLS_REJECT_UNAUTHORIZED'), true);
|
||||
const servername = source.get('RABBITMQ_SERVERNAME');
|
||||
if (servername) connectionOptions.servername = servername;
|
||||
const ca = material(source, 'RABBITMQ_CA_CERT_BASE64', 'RABBITMQ_CA_CERT_PATH');
|
||||
const cert = material(source, 'RABBITMQ_CLIENT_CERT_BASE64', 'RABBITMQ_CLIENT_CERT_PATH');
|
||||
const key = material(source, 'RABBITMQ_CLIENT_KEY_BASE64', 'RABBITMQ_CLIENT_KEY_PATH');
|
||||
if (ca) connectionOptions.ca = [ca];
|
||||
if (cert) connectionOptions.cert = cert;
|
||||
if (key) connectionOptions.key = key;
|
||||
if ((cert && !key) || (!cert && key)) throw new Error('RabbitMQ client certificate and key must be configured together');
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sanitizedEndpoint: `${parsed.protocol}//${parsed.hostname}:${parsed.port || (parsed.protocol === 'amqps:' ? '5671' : '5672')}${parsed.pathname}`,
|
||||
tlsEnabled: tlsEnabled || parsed.protocol === 'amqps:',
|
||||
socketOptions: {
|
||||
heartbeatIntervalInSeconds: Number(source.get('RABBITMQ_HEARTBEAT_SECONDS') || 10),
|
||||
reconnectTimeInSeconds: Number(source.get('RABBITMQ_RECONNECT_SECONDS') || 5),
|
||||
connectionOptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function processEnvSource(): RabbitConfigSource {
|
||||
return { get: (key) => process.env[key] };
|
||||
}
|
||||
18
libs/common/src/retry.ts
Normal file
18
libs/common/src/retry.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function withLimitedRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxAttempts: number,
|
||||
backoffMs = 25,
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
5
libs/common/tsconfig.lib.json
Normal file
5
libs/common/tsconfig.lib.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "declaration": true, "outDir": "../../dist/libs/common" },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
1
libs/contracts/src/index.ts
Normal file
1
libs/contracts/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './notifications.contract';
|
||||
45
libs/contracts/src/notifications.contract.ts
Normal file
45
libs/contracts/src/notifications.contract.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const NOTIFICATION_RMQ_QUEUE = 'oudelaa.notifications';
|
||||
export const NOTIFICATION_RMQ_DLQ = 'oudelaa.notifications.dlq';
|
||||
|
||||
export const NOTIFICATION_RPC = {
|
||||
list: 'notifications.list',
|
||||
unreadCount: 'notifications.unread-count',
|
||||
markRead: 'notifications.mark-read',
|
||||
markAllRead: 'notifications.mark-all-read',
|
||||
} as const;
|
||||
|
||||
export interface MessageContext {
|
||||
correlationId: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface NotificationListRequest {
|
||||
recipientId: string;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NotificationUnreadCountRequest {
|
||||
recipientId: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface NotificationMarkReadRequest {
|
||||
recipientId: string;
|
||||
notificationId: string;
|
||||
}
|
||||
|
||||
export interface NotificationMarkAllReadRequest {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export interface NotificationRealtimeUpdate {
|
||||
recipientId: string;
|
||||
notification?: unknown;
|
||||
unreadCount: number;
|
||||
unreadCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface NotificationRpcResponse<T> {
|
||||
result: T;
|
||||
realtime?: NotificationRealtimeUpdate;
|
||||
}
|
||||
5
libs/contracts/tsconfig.lib.json
Normal file
5
libs/contracts/tsconfig.lib.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "declaration": true, "outDir": "../../dist/libs/contracts" },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
34
libs/events/src/follow-request-approved.event.ts
Normal file
34
libs/events/src/follow-request-approved.event.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const FOLLOW_REQUEST_APPROVED_EVENT = 'follow.request.approved' as const;
|
||||
|
||||
export interface FollowRequestApprovedEvent {
|
||||
eventId: string;
|
||||
eventType: typeof FOLLOW_REQUEST_APPROVED_EVENT;
|
||||
occurredAt: string;
|
||||
payload: {
|
||||
requestId: string;
|
||||
actorId: string;
|
||||
recipientId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function isFollowRequestApprovedEvent(value: unknown): value is FollowRequestApprovedEvent {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const event = value as Partial<FollowRequestApprovedEvent>;
|
||||
const payload = event.payload as Partial<FollowRequestApprovedEvent['payload']> | undefined;
|
||||
return event.eventType === FOLLOW_REQUEST_APPROVED_EVENT
|
||||
&& isNonEmptyString(event.eventId)
|
||||
&& isIsoDateString(event.occurredAt)
|
||||
&& isNonEmptyString(payload?.requestId)
|
||||
&& isNonEmptyString(payload.actorId)
|
||||
&& isNonEmptyString(payload.recipientId);
|
||||
}
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
const isIsoDateString = (value: unknown): value is string => {
|
||||
if (typeof value !== 'string') return false;
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isNaN(timestamp)) return false;
|
||||
return new Date(timestamp).toISOString() === value;
|
||||
};
|
||||
1
libs/events/src/index.ts
Normal file
1
libs/events/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './follow-request-approved.event';
|
||||
5
libs/events/tsconfig.lib.json
Normal file
5
libs/events/tsconfig.lib.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": { "declaration": true, "outDir": "../../dist/libs/events" },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم