feat: add notification microservices and transactional outbox
فشلت بعض الفحوصات
Deploy To Ghaymah / quality (push) Has been cancelled
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-08-05 14:46:12 +03:00
الأصل 2fd5322ef7
التزام 8147f3b191
62 ملفات معدلة مع 2212 إضافات و126 حذوفات

2
libs/common/src/index.ts Normal file
عرض الملف

@@ -0,0 +1,2 @@
export * from './retry';
export * from './rabbitmq';

عرض الملف

@@ -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');
});
});

عرض الملف

@@ -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
عرض الملف

@@ -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;
}

عرض الملف

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "declaration": true, "outDir": "../../dist/libs/common" },
"include": ["src/**/*.ts"]
}

عرض الملف

@@ -0,0 +1 @@
export * from './notifications.contract';

عرض الملف

@@ -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;
}

عرض الملف

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "declaration": true, "outDir": "../../dist/libs/contracts" },
"include": ["src/**/*.ts"]
}

عرض الملف

@@ -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
عرض الملف

@@ -0,0 +1 @@
export * from './follow-request-approved.event';

عرض الملف

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "declaration": true, "outDir": "../../dist/libs/events" },
"include": ["src/**/*.ts"]
}