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 حذوفات

عرض الملف

@@ -0,0 +1,3 @@
import { bootstrap } from '../../../src/main';
void bootstrap();

عرض الملف

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/api-gateway"
},
"include": ["src/**/*.ts", "../../src/**/*.ts", "../../libs/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

عرض الملف

@@ -0,0 +1,14 @@
import { Schema } from 'mongoose';
// A deliberately small read model. Notifications only populate these public actor fields.
export const ActorSchema = new Schema(
{
name: String,
username: String,
stageName: String,
avatar: Schema.Types.Mixed,
isVerified: Boolean,
isDisabled: Boolean,
},
{ collection: 'users', strict: false, versionKey: false },
);

عرض الملف

@@ -0,0 +1,47 @@
import { NOTIFICATION_RMQ_QUEUE } from '@app/contracts';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { NotificationRealtimeSink } from './notification-realtime.sink';
import { NotificationConsumer } from './notification.consumer';
import { HealthController } from './health.controller';
import { NOTIFICATION_SERVICE_STATE, NotificationServiceState } from './service-state';
import { ActorSchema } from './actor.schema';
import { NotificationsGateway } from '../../../src/modules/notifications/notifications.gateway';
import { NotificationsRepository } from '../../../src/modules/notifications/notifications.repository';
import { NotificationsService } from '../../../src/modules/notifications/notifications.service';
import { Notification, NotificationSchema } from '../../../src/modules/notifications/schemas/notification.schema';
export const notificationServiceState: NotificationServiceState = {
rabbitmqReady: false,
draining: false,
};
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('NOTIFICATION_MONGODB_URI')
?? config.get<string>('MONGODB_URI')
?? 'mongodb://127.0.0.1:27017/oudelaa',
autoIndex: config.get<string>('MONGODB_AUTO_INDEX', 'true') === 'true',
}),
}),
MongooseModule.forFeature([
{ name: Notification.name, schema: NotificationSchema, collection: 'notifications' },
{ name: 'User', schema: ActorSchema, collection: 'users' },
]),
],
controllers: [HealthController, NotificationConsumer],
providers: [
NotificationsRepository,
NotificationsService,
NotificationRealtimeSink,
{ provide: NotificationsGateway, useExisting: NotificationRealtimeSink },
{ provide: NOTIFICATION_SERVICE_STATE, useValue: notificationServiceState },
{ provide: 'NOTIFICATION_QUEUE_NAME', useValue: NOTIFICATION_RMQ_QUEUE },
],
})
export class NotificationServiceModule {}

عرض الملف

@@ -0,0 +1,31 @@
import { Controller, Get, Inject, ServiceUnavailableException } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
import { NOTIFICATION_SERVICE_STATE, NotificationServiceState } from './service-state';
@Controller('health')
export class HealthController {
constructor(
@InjectConnection() private readonly connection: Connection,
@Inject(NOTIFICATION_SERVICE_STATE) private readonly state: NotificationServiceState,
) {}
@Get()
@Get('live')
live() {
return { status: this.state.draining ? 'draining' : 'ok', service: 'notification-service' };
}
@Get('ready')
async ready() {
const mongodbReady = this.connection.readyState === 1;
const ready = !this.state.draining && mongodbReady && this.state.rabbitmqReady;
const response = {
status: ready ? 'ready' : 'not-ready',
service: 'notification-service',
checks: { mongodb: mongodbReady, rabbitmq: this.state.rabbitmqReady },
};
if (!ready) throw new ServiceUnavailableException(response);
return response;
}
}

عرض الملف

@@ -0,0 +1,44 @@
import { NOTIFICATION_RMQ_QUEUE } from '@app/contracts';
import { processEnvSource, resolveRabbitMqConfig } from '@app/common';
import { Logger, ShutdownSignal, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { NotificationServiceModule, notificationServiceState } from './app.module';
import { assertNotificationTopology, notificationQueueArguments } from './rabbitmq-topology';
export async function bootstrapNotificationService(): Promise<void> {
const app = await NestFactory.create(NotificationServiceModule, { bufferLogs: true });
const logger = new Logger('NotificationServiceBootstrap');
const rabbit = resolveRabbitMqConfig(processEnvSource(), 'notification-service', true);
const port = Number(process.env.NOTIFICATION_SERVICE_PORT ?? 4001);
app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM]);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const microservice = app.connectMicroservice<MicroserviceOptions>({
transport: Transport.RMQ,
options: {
urls: [rabbit.url],
socketOptions: rabbit.socketOptions,
queue: NOTIFICATION_RMQ_QUEUE,
noAck: false,
prefetchCount: Number(process.env.NOTIFICATION_RMQ_PREFETCH ?? 10),
queueOptions: { durable: true, autoDelete: false, exclusive: false, arguments: notificationQueueArguments },
},
});
const status = (microservice as unknown as { status: { subscribe: (callback: (value: string) => void) => void } }).status;
status.subscribe((value) => {
notificationServiceState.rabbitmqReady = value === 'connected' || value === 'unblocked';
});
const markDraining = () => { notificationServiceState.draining = true; };
process.once('SIGTERM', markDraining);
process.once('SIGINT', markDraining);
await assertNotificationTopology(rabbit);
await app.startAllMicroservices();
notificationServiceState.rabbitmqReady = true;
await app.listen(port, process.env.NOTIFICATION_SERVICE_HOST ?? '0.0.0.0');
logger.log(`HTTP health endpoint listening on ${port}; consuming ${NOTIFICATION_RMQ_QUEUE}; broker=${rabbit.sanitizedEndpoint}`);
}
if (require.main === module) void bootstrapNotificationService();

عرض الملف

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
// Socket.IO remains owned by the gateway. The RMQ response carries realtime data back to it.
@Injectable()
export class NotificationRealtimeSink {
emitCreated(): void {}
emitUnreadCount(): void {}
}

عرض الملف

@@ -0,0 +1,100 @@
import { FOLLOW_REQUEST_APPROVED_EVENT, FollowRequestApprovedEvent } from '@app/events';
import { NotificationConsumer } from './notification.consumer';
const event: FollowRequestApprovedEvent = {
eventId: 'event-1',
eventType: FOLLOW_REQUEST_APPROVED_EVENT,
occurredAt: '2026-08-03T10:00:00.000Z',
payload: { requestId: 'request-1', actorId: 'actor-1', recipientId: 'recipient-1' },
};
function rmqContext() {
const channel = { ack: jest.fn(), nack: jest.fn() };
const message = {
properties: { headers: { correlationId: 'correlation-1', requestId: 'request-1' } },
};
return {
channel,
message,
context: {
getChannelRef: () => channel,
getMessage: () => message,
} as never,
};
}
describe('NotificationConsumer', () => {
const notification = { toJSON: () => ({ _id: 'notification-1' }) };
let notifications: Record<string, jest.Mock>;
let consumer: NotificationConsumer;
beforeEach(() => {
notifications = {
createFollowRequestApprovedNotification: jest.fn().mockResolvedValue(notification),
getUnreadCounts: jest.fn().mockResolvedValue({ total: 1, follows: 0, followRequests: 1 }),
};
consumer = new NotificationConsumer(notifications as never);
jest.spyOn(console, 'log').mockImplementation(() => undefined);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('consumes follow.request.approved and acknowledges only after persistence', async () => {
const rmq = rmqContext();
const result = await consumer.followRequestApproved(event, rmq.context);
expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledWith({
...event.payload,
eventId: event.eventId,
});
expect(result).toEqual(expect.objectContaining({
result: notification,
realtime: expect.objectContaining({ recipientId: 'recipient-1', unreadCount: 1 }),
}));
expect(rmq.channel.ack).toHaveBeenCalledWith(rmq.message);
expect(rmq.channel.nack).not.toHaveBeenCalled();
});
it('retries transient failures with a finite limit', async () => {
notifications.createFollowRequestApprovedNotification
.mockRejectedValueOnce(new Error('temporary-1'))
.mockRejectedValueOnce(new Error('temporary-2'))
.mockResolvedValueOnce(notification);
const rmq = rmqContext();
await consumer.followRequestApproved(event, rmq.context);
expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledTimes(3);
expect(rmq.channel.ack).toHaveBeenCalledTimes(1);
});
it('rejects to the DLQ after retry exhaustion', async () => {
notifications.createFollowRequestApprovedNotification.mockRejectedValue(new Error('mongo down'));
const rmq = rmqContext();
await expect(consumer.followRequestApproved(event, rmq.context)).rejects.toThrow('mongo down');
expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledTimes(3);
expect(rmq.channel.nack).toHaveBeenCalledWith(rmq.message, false, false);
expect(rmq.channel.ack).not.toHaveBeenCalled();
});
it('rejects an invalid contract without persistence', async () => {
const rmq = rmqContext();
await expect(consumer.followRequestApproved({ ...event, eventId: '' }, rmq.context)).rejects.toThrow(
'Invalid follow.request.approved contract',
);
expect(notifications.createFollowRequestApprovedNotification).not.toHaveBeenCalled();
expect(rmq.channel.nack).toHaveBeenCalledWith(rmq.message, false, false);
});
it('requires non-empty string IDs and a canonical ISO occurredAt value', async () => {
for (const invalid of [
{ ...event, occurredAt: 'August 3, 2026' },
{ ...event, payload: { ...event.payload, actorId: '' } },
]) {
const rmq = rmqContext();
await expect(consumer.followRequestApproved(invalid, rmq.context)).rejects.toThrow(
'Invalid follow.request.approved contract',
);
expect(rmq.channel.nack).toHaveBeenCalledWith(rmq.message, false, false);
}
expect(notifications.createFollowRequestApprovedNotification).not.toHaveBeenCalled();
});
});

عرض الملف

@@ -0,0 +1,155 @@
import { withLimitedRetry } from '@app/common';
import {
MessageContext,
NOTIFICATION_RPC,
NotificationListRequest,
NotificationMarkAllReadRequest,
NotificationMarkReadRequest,
NotificationRpcResponse,
NotificationUnreadCountRequest,
} from '@app/contracts';
import {
FOLLOW_REQUEST_APPROVED_EVENT,
FollowRequestApprovedEvent,
isFollowRequestApprovedEvent,
} from '@app/events';
import { Controller } from '@nestjs/common';
import { Ctx, MessagePattern, Payload, RmqContext, RpcException } from '@nestjs/microservices';
import { NotificationCategory, NotificationQueryDto } from '../../../src/modules/notifications/dto/notification-query.dto';
import { NotificationsService } from '../../../src/modules/notifications/notifications.service';
@Controller()
export class NotificationConsumer {
constructor(private readonly notifications: NotificationsService) {}
@MessagePattern(NOTIFICATION_RPC.list)
async list(@Payload() input: NotificationListRequest, @Ctx() context: RmqContext) {
return this.rpc(context, () => this.notifications.getMine(
input.recipientId,
input.query as unknown as NotificationQueryDto,
));
}
@MessagePattern(NOTIFICATION_RPC.unreadCount)
async unreadCount(@Payload() input: NotificationUnreadCountRequest, @Ctx() context: RmqContext) {
return this.rpc(context, () => this.notifications.getUnreadCountByCategory(
input.recipientId,
input.category as NotificationCategory | undefined,
));
}
@MessagePattern(NOTIFICATION_RPC.markRead)
async markRead(@Payload() input: NotificationMarkReadRequest, @Ctx() context: RmqContext) {
return this.rpc(context, async () => {
const result = await this.notifications.markRead(input.recipientId, input.notificationId);
const unreadCounts = await this.notifications.getUnreadCounts(input.recipientId);
return { result, realtime: {
recipientId: input.recipientId,
unreadCount: unreadCounts.total,
unreadCounts,
} };
}, true);
}
@MessagePattern(NOTIFICATION_RPC.markAllRead)
async markAllRead(@Payload() input: NotificationMarkAllReadRequest, @Ctx() context: RmqContext) {
return this.rpc(context, async () => {
const result = await this.notifications.markAllRead(input.recipientId);
const unreadCounts = await this.notifications.getUnreadCounts(input.recipientId);
return { result, realtime: {
recipientId: input.recipientId,
unreadCount: unreadCounts.total,
unreadCounts,
} };
}, true);
}
@MessagePattern(FOLLOW_REQUEST_APPROVED_EVENT)
async followRequestApproved(
@Payload() event: FollowRequestApprovedEvent,
@Ctx() context: RmqContext,
) {
if (!isFollowRequestApprovedEvent(event)) {
return this.reject(context, new Error('Invalid follow.request.approved contract'));
}
const metadata = this.metadata(context);
try {
const notification = await withLimitedRetry(
() => this.notifications.createFollowRequestApprovedNotification({
...event.payload,
eventId: event.eventId,
}),
3,
25,
);
const unreadCounts = await this.notifications.getUnreadCounts(event.payload.recipientId);
this.ack(context);
this.log('info', 'follow.request.approved consumed', metadata, { eventId: event.eventId });
return {
result: notification,
realtime: {
recipientId: event.payload.recipientId,
notification,
unreadCount: unreadCounts.total,
unreadCounts,
},
};
} catch (error) {
return this.reject(context, error, metadata, event.eventId);
}
}
private async rpc<T>(
context: RmqContext,
operation: () => Promise<T | NotificationRpcResponse<T>>,
alreadyWrapped = false,
): Promise<NotificationRpcResponse<T>> {
try {
const value = await operation();
this.ack(context);
return (alreadyWrapped ? value : { result: value }) as NotificationRpcResponse<T>;
} catch (error) {
this.ack(context);
throw new RpcException(error instanceof Error ? error.message : 'Notification operation failed');
}
}
private ack(context: RmqContext): void {
context.getChannelRef().ack(context.getMessage());
}
private reject(
context: RmqContext,
error: unknown,
metadata = this.metadata(context),
eventId?: string,
): never {
context.getChannelRef().nack(context.getMessage(), false, false);
const message = error instanceof Error ? error.message : 'Notification event failed';
this.log('error', message, metadata, { eventId });
throw new RpcException(message);
}
private metadata(context: RmqContext): MessageContext {
const message = context.getMessage() as { properties?: { headers?: Record<string, unknown>; correlationId?: string } };
const headers = message.properties?.headers ?? {};
return {
correlationId: String(headers.correlationId ?? message.properties?.correlationId ?? ''),
requestId: String(headers.requestId ?? ''),
};
}
private log(
level: 'info' | 'error',
message: string,
context: MessageContext,
extra: Record<string, unknown>,
): void {
const entry = JSON.stringify({
timestamp: new Date().toISOString(), level, service: 'notification-service', message, ...context, ...extra,
});
if (level === 'error') console.error(entry);
else console.log(entry);
}
}

عرض الملف

@@ -0,0 +1,29 @@
import { NOTIFICATION_RMQ_DLQ, NOTIFICATION_RMQ_QUEUE } from '@app/contracts';
import { assertNotificationTopology, notificationQueueArguments } from './rabbitmq-topology';
describe('RabbitMQ notification adapter', () => {
it('asserts durable main and dead-letter queues', async () => {
const channel = {
assertQueue: jest.fn().mockResolvedValue({}),
close: jest.fn().mockResolvedValue(undefined),
};
const connection = {
createChannel: jest.fn().mockResolvedValue(channel),
close: jest.fn().mockResolvedValue(undefined),
};
await assertNotificationTopology('amqp://rabbit', jest.fn().mockResolvedValue(connection) as never);
expect(channel.assertQueue).toHaveBeenNthCalledWith(1, NOTIFICATION_RMQ_DLQ, {
durable: true, autoDelete: false, exclusive: false,
});
expect(channel.assertQueue).toHaveBeenNthCalledWith(2, NOTIFICATION_RMQ_QUEUE, {
durable: true,
autoDelete: false,
exclusive: false,
arguments: notificationQueueArguments,
});
expect(channel.close).toHaveBeenCalled();
expect(connection.close).toHaveBeenCalled();
});
});

عرض الملف

@@ -0,0 +1,35 @@
import { NOTIFICATION_RMQ_DLQ, NOTIFICATION_RMQ_QUEUE } from '@app/contracts';
import { ChannelModel, connect } from 'amqplib';
import { ResolvedRabbitMqConfig } from '@app/common';
type Connect = (url: string, options?: Record<string, unknown>) => Promise<ChannelModel>;
export const notificationQueueArguments = {
'x-dead-letter-exchange': '',
'x-dead-letter-routing-key': NOTIFICATION_RMQ_DLQ,
};
export async function assertNotificationTopology(
config: string | ResolvedRabbitMqConfig,
connectToRabbit: Connect = connect as unknown as Connect,
): Promise<void> {
const url = typeof config === 'string' ? config : config.url;
const options = typeof config === 'string' ? undefined : config.socketOptions.connectionOptions;
const connection = await connectToRabbit(url, options);
try {
const channel = await connection.createChannel();
try {
await channel.assertQueue(NOTIFICATION_RMQ_DLQ, { durable: true, autoDelete: false, exclusive: false });
await channel.assertQueue(NOTIFICATION_RMQ_QUEUE, {
durable: true,
autoDelete: false,
exclusive: false,
arguments: notificationQueueArguments,
});
} finally {
await channel.close();
}
} finally {
await connection.close();
}
}

عرض الملف

@@ -0,0 +1,6 @@
export const NOTIFICATION_SERVICE_STATE = Symbol('NOTIFICATION_SERVICE_STATE');
export interface NotificationServiceState {
rabbitmqReady: boolean;
draining: boolean;
}

عرض الملف

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/notification-service"
},
"include": ["src/**/*.ts", "../../src/modules/notifications/**/*.ts", "../../src/common/**/*.ts", "../../libs/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}