feat: add notification microservices and transactional outbox
This commit is contained in:
@@ -15,3 +15,6 @@ npm-debug.log*
|
|||||||
.idea
|
.idea
|
||||||
postman
|
postman
|
||||||
docs
|
docs
|
||||||
|
oudelaa_dashboard
|
||||||
|
stream_0
|
||||||
|
stream_1
|
||||||
|
|||||||
@@ -81,6 +81,43 @@ QUEUE_DEFAULT_BACKOFF_MS=1000
|
|||||||
QUEUE_REMOVE_ON_COMPLETE=true
|
QUEUE_REMOVE_ON_COMPLETE=true
|
||||||
QUEUE_WORKER_CONCURRENCY=5
|
QUEUE_WORKER_CONCURRENCY=5
|
||||||
|
|
||||||
|
# Phase 1 microservice routing (false keeps the current in-process path)
|
||||||
|
NOTIFICATION_SERVICE_ENABLED=false
|
||||||
|
# The polling dispatcher is independently disabled for the current production monolith.
|
||||||
|
OUTBOX_DISPATCHER_ENABLED=false
|
||||||
|
OUTBOX_POLL_INTERVAL_MS=1000
|
||||||
|
OUTBOX_BATCH_SIZE=25
|
||||||
|
OUTBOX_MAX_ATTEMPTS=8
|
||||||
|
OUTBOX_LEASE_TIMEOUT_MS=60000
|
||||||
|
OUTBOX_RETRY_BASE_MS=1000
|
||||||
|
OUTBOX_RETRY_MAX_MS=60000
|
||||||
|
# Local development only. Production must inject non-guest credentials and TLS/amqps.
|
||||||
|
RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672
|
||||||
|
RABBITMQ_HOST=127.0.0.1
|
||||||
|
RABBITMQ_PORT=5672
|
||||||
|
RABBITMQ_USERNAME=
|
||||||
|
RABBITMQ_PASSWORD=
|
||||||
|
RABBITMQ_VHOST=/
|
||||||
|
RABBITMQ_TLS_ENABLED=false
|
||||||
|
RABBITMQ_TLS_REQUIRED=true
|
||||||
|
RABBITMQ_CA_CERT_BASE64=
|
||||||
|
RABBITMQ_CA_CERT_PATH=
|
||||||
|
RABBITMQ_CLIENT_CERT_BASE64=
|
||||||
|
RABBITMQ_CLIENT_CERT_PATH=
|
||||||
|
RABBITMQ_CLIENT_KEY_BASE64=
|
||||||
|
RABBITMQ_CLIENT_KEY_PATH=
|
||||||
|
RABBITMQ_TLS_REJECT_UNAUTHORIZED=true
|
||||||
|
RABBITMQ_SERVERNAME=
|
||||||
|
RABBITMQ_HEARTBEAT_SECONDS=10
|
||||||
|
RABBITMQ_RECONNECT_SECONDS=5
|
||||||
|
RABBITMQ_CONNECTION_TIMEOUT_MS=10000
|
||||||
|
NOTIFICATION_RPC_TIMEOUT_MS=3000
|
||||||
|
NOTIFICATION_RPC_RETRIES=1
|
||||||
|
NOTIFICATION_CIRCUIT_FAILURE_THRESHOLD=3
|
||||||
|
NOTIFICATION_CIRCUIT_RESET_MS=15000
|
||||||
|
NOTIFICATION_SERVICE_PORT=4001
|
||||||
|
NOTIFICATION_RMQ_PREFETCH=10
|
||||||
|
|
||||||
STORAGE_PROVIDER=local
|
STORAGE_PROVIDER=local
|
||||||
MEDIA_ACCESS_MODE=direct
|
MEDIA_ACCESS_MODE=direct
|
||||||
STORAGE_BASE_PATH=uploads
|
STORAGE_BASE_PATH=uploads
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:22-alpine AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM dependencies AS build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS api-gateway
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package.json ./
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
||||||
|
FROM node:22-alpine AS notification-service
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package.json ./
|
||||||
|
EXPOSE 4001
|
||||||
|
CMD ["node", "dist/apps/notification-service/main.js"]
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Local phase-1 stack only. Never reuse guest/guest or this topology as production deployment configuration.
|
||||||
|
services:
|
||||||
|
api-gateway:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.microservices
|
||||||
|
target: api-gateway
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: 4000
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
MONGODB_URI: mongodb://mongodb:27017/oudelaa?replicaSet=rs0
|
||||||
|
REDIS_ENABLED: "true"
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
REDIS_SOCKET_ADAPTER_ENABLED: "true"
|
||||||
|
QUEUE_ENABLED: "true"
|
||||||
|
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672
|
||||||
|
NOTIFICATION_SERVICE_ENABLED: "true"
|
||||||
|
OUTBOX_DISPATCHER_ENABLED: "true"
|
||||||
|
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?Set JWT_ACCESS_SECRET in the selected local env file}
|
||||||
|
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET in the selected local env file}
|
||||||
|
REFRESH_TOKEN_HASH_SECRET: ${REFRESH_TOKEN_HASH_SECRET:-}
|
||||||
|
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:?Set SUPERADMIN_EMAIL in the selected local env file}
|
||||||
|
SUPERADMIN_ACCESS_SECRET: ${SUPERADMIN_ACCESS_SECRET:?Set SUPERADMIN_ACCESS_SECRET in the selected local env file}
|
||||||
|
SUPERADMIN_REFRESH_SECRET: ${SUPERADMIN_REFRESH_SECRET:?Set SUPERADMIN_REFRESH_SECRET in the selected local env file}
|
||||||
|
STORAGE_PROVIDER: local
|
||||||
|
STORAGE_BASE_PATH: uploads
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
notification-service:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4000/api/v1/health/ready"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
volumes:
|
||||||
|
- gateway_uploads:/app/uploads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
notification-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.microservices
|
||||||
|
target: notification-service
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4001:4001"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
NOTIFICATION_SERVICE_PORT: 4001
|
||||||
|
NOTIFICATION_MONGODB_URI: mongodb://mongodb:27017/oudelaa?replicaSet=rs0
|
||||||
|
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672
|
||||||
|
NOTIFICATION_RMQ_PREFETCH: 10
|
||||||
|
MONGODB_AUTO_INDEX: "true"
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4001/health/ready"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3.13-management-alpine
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:15672:15672"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "rabbitmq-diagnostics -q check_running && rabbitmq-diagnostics -q check_port_connectivity"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
volumes:
|
||||||
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
mongodb:
|
||||||
|
image: mongo:8
|
||||||
|
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--eval", "try { if (rs.status().ok === 1) quit(0); quit(1) } catch (e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}); quit(0) }"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gateway_uploads:
|
||||||
|
rabbitmq_data:
|
||||||
|
redis_data:
|
||||||
|
mongodb_data:
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Oudelaa microservices migration
|
||||||
|
|
||||||
|
## Current architecture
|
||||||
|
|
||||||
|
The backend is a NestJS modular monolith backed by MongoDB. Redis is used for cache,
|
||||||
|
BullMQ jobs, and the optional Socket.IO adapter. Domain modules call the in-process
|
||||||
|
`NotificationsService`. Public HTTP routes use the global `/api/v1` prefix and the
|
||||||
|
notifications Socket.IO namespace is owned by the same application.
|
||||||
|
|
||||||
|
## Phase 1 boundary
|
||||||
|
|
||||||
|
- `apps/api-gateway` is the new monorepo entry point. It deliberately bootstraps the
|
||||||
|
existing `src/AppModule`, so the application remains runnable while modules are moved
|
||||||
|
incrementally.
|
||||||
|
- `apps/notification-service` owns notification persistence, queries, unread counts,
|
||||||
|
read mutations, and the `follow.request.approved` consumer.
|
||||||
|
- The four existing Flutter notification endpoints remain on the gateway. The gateway
|
||||||
|
delegates them over Nest RMQ only when `NOTIFICATION_SERVICE_ENABLED=true`; otherwise
|
||||||
|
it uses the original in-process service.
|
||||||
|
- Socket.IO authentication, namespace, rooms, and public event names remain in the
|
||||||
|
gateway. Internal RMQ responses include realtime data which the gateway emits.
|
||||||
|
- Other notification producers stay in-process in phase 1. They can migrate one event
|
||||||
|
at a time in later phases.
|
||||||
|
|
||||||
|
## Reliability and topology
|
||||||
|
|
||||||
|
The main durable queue is `oudelaa.notifications`. Rejected messages are dead-lettered
|
||||||
|
to the durable `oudelaa.notifications.dlq` queue through the default exchange. Consumers
|
||||||
|
use manual acknowledgement and a prefetch limit. Event handling retries three times
|
||||||
|
inside one delivery, then rejects without requeue. Gateway calls have a bounded timeout,
|
||||||
|
one configurable retry, and a small circuit breaker. Rabbit message headers carry
|
||||||
|
`correlationId` and `requestId`; logs are JSON records containing both.
|
||||||
|
|
||||||
|
Follow-request approval is the first true transactional-outbox flow. The request state,
|
||||||
|
follow relationship, user counters, and deterministic outbox event are committed in one
|
||||||
|
MongoDB transaction. No BullMQ or RabbitMQ call occurs inside that transaction. When both
|
||||||
|
phase-1 flags are enabled, a polling dispatcher atomically leases pending/failed events,
|
||||||
|
waits for the Notification Service RPC response, and only then marks the event processed.
|
||||||
|
Timeouts use bounded exponential backoff and eventually move the event to `dead`; expired
|
||||||
|
`processing` leases are recovered after restart. `OutboxService.listByStatuses()` and
|
||||||
|
`retryEvent()` are the intentionally non-public administrative surface in this phase.
|
||||||
|
|
||||||
|
`eventId` is stored on notifications under a unique partial index. The consumer checks
|
||||||
|
it before insert and also handles Mongo duplicate-key races. The existing unique index on
|
||||||
|
recipient/type/reference remains as an additional guard for follow approval records.
|
||||||
|
|
||||||
|
## Planned migration
|
||||||
|
|
||||||
|
1. Phase 1: monorepo shell, contracts/events/common libraries, gateway compatibility
|
||||||
|
facade, and Notification Service.
|
||||||
|
2. Phase 2: move Auth and Users behind versioned internal contracts; centralize JWT
|
||||||
|
verification primitives in `libs/auth`; add tracing/metrics in `libs/observability`.
|
||||||
|
3. Phase 3: extract Posts and Media; replace direct notification calls with outbox events.
|
||||||
|
4. Phase 4: extract Feed and Search read models, consuming post/user events.
|
||||||
|
5. Phase 5: extract Moderation, then stabilize cross-service workflows and data ownership.
|
||||||
|
6. Phase 6: remove transitional imports and local fallbacks after traffic comparison,
|
||||||
|
replay tests, and a controlled cutover.
|
||||||
|
|
||||||
|
At every phase the gateway retains public paths and response envelopes. Internal event
|
||||||
|
contracts evolve additively and are versioned before any breaking change.
|
||||||
|
|
||||||
|
## Configuration and operating modes
|
||||||
|
|
||||||
|
`NOTIFICATION_SERVICE_ENABLED=false` and `OUTBOX_DISPATCHER_ENABLED=false` remain the
|
||||||
|
safe defaults. In that mode the gateway does not instantiate or connect an RMQ client;
|
||||||
|
the in-process notification path and public Flutter contracts remain unchanged. The
|
||||||
|
standalone-Mongo fallback exists only for this disabled legacy transition mode.
|
||||||
|
|
||||||
|
The reliable phase-1 path requires both flags to be `true`. Dispatcher interval, batch,
|
||||||
|
lease, attempt limit, and bounded backoff are configured by `OUTBOX_*`. RabbitMQ accepts
|
||||||
|
an injected `RABBITMQ_URL` or separate host, port, username, password, and vhost values.
|
||||||
|
TLS supports `amqps://`, CA/client material injected as base64 or mounted paths, peer
|
||||||
|
verification, and SNI. Passwords and URL userinfo are never logged.
|
||||||
|
|
||||||
|
`docker-compose.microservices.yml` is local-only. It deliberately uses guest/guest inside
|
||||||
|
the private Compose network, exposes management only on loopback, and runs a single-node
|
||||||
|
Mongo replica set so transactions are exercised locally. It is not a production template.
|
||||||
|
Queue names remain `oudelaa.notifications` and `oudelaa.notifications.dlq` to avoid an
|
||||||
|
undeclared migration; both are durable, non-exclusive, and non-auto-delete. RPC messages
|
||||||
|
are persistent, and consumers use manual acknowledgement with a prefetch limit.
|
||||||
|
|
||||||
|
## Production prerequisites
|
||||||
|
|
||||||
|
- Managed RabbitMQ (or an operated HA cluster), a dedicated vhost, non-guest
|
||||||
|
least-privilege credentials, and TLS/amqps. Inject credentials and certificates from
|
||||||
|
the deployment secret store; never copy local Compose values.
|
||||||
|
- A transaction-capable MongoDB replica set or sharded cluster. Gateway startup rejects
|
||||||
|
dispatcher activation against standalone MongoDB.
|
||||||
|
- Production Redis, service-specific secrets, persistent storage where applicable, and
|
||||||
|
network policy that permits RMQ/Mongo/Redis access only from the services.
|
||||||
|
- Liveness probes at `/api/v1/health` and `/health/live`; readiness probes at
|
||||||
|
`/api/v1/health/ready` and `/health/ready`. A temporary RMQ outage fails readiness but
|
||||||
|
does not fail liveness. RabbitMQ management must not be publicly exposed.
|
||||||
|
- Alerts for pending/failed/dead outbox counts, oldest event age, dispatcher failures,
|
||||||
|
notification DLQ depth, RMQ connectivity, and duplicate-key activity.
|
||||||
|
|
||||||
|
### Controlled activation and rollback
|
||||||
|
|
||||||
|
1. Provision production RabbitMQ with TLS, a dedicated vhost, and least-privilege user.
|
||||||
|
2. Confirm MongoDB is a replica set/mongos and Redis is production-ready.
|
||||||
|
3. Deploy Notification Service with injected secrets and verify liveness/readiness.
|
||||||
|
4. Deploy Gateway with both phase-1 flags still `false`; verify the monolith path.
|
||||||
|
5. Inject the same RMQ endpoint/trust settings into Gateway without logging them.
|
||||||
|
6. Enable both flags on one canary Gateway replica.
|
||||||
|
7. Approve a controlled private follow and observe outbox
|
||||||
|
`pending -> processing -> processed`, one notification, and one Socket emission.
|
||||||
|
8. Monitor retries, dead events, DLQ, readiness, latency, and duplicate-key metrics.
|
||||||
|
9. Expand the flags gradually; atomic leases make multiple Gateway replicas safe.
|
||||||
|
10. Roll back by setting both flags to `false`. Do not delete pending events or queues;
|
||||||
|
diagnose or retry them before the next canary, then drain Notification Service.
|
||||||
|
|
||||||
|
Notification Service still uses the same Mongo database and the same `notifications`
|
||||||
|
collection as Gateway. This is a transitional shared-database model, not independent data
|
||||||
|
ownership; physical separation belongs to a later migration phase.
|
||||||
@@ -7,6 +7,11 @@ module.exports = {
|
|||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(t|j)s$': 'ts-jest',
|
'^.+\\.(t|j)s$': 'ts-jest',
|
||||||
},
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@app/contracts$': '<rootDir>/libs/contracts/src',
|
||||||
|
'^@app/events$': '<rootDir>/libs/events/src',
|
||||||
|
'^@app/common$': '<rootDir>/libs/common/src',
|
||||||
|
},
|
||||||
collectCoverageFrom: ['src/**/*.(t|j)s'],
|
collectCoverageFrom: ['src/**/*.(t|j)s'],
|
||||||
coverageDirectory: './coverage',
|
coverageDirectory: './coverage',
|
||||||
coverageReporters: ['text', 'json-summary', 'lcov'],
|
coverageReporters: ['text', 'json-summary', 'lcov'],
|
||||||
|
|||||||
@@ -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] };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
+52
-1
@@ -1,8 +1,59 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"monorepo": true,
|
||||||
|
"root": "apps/api-gateway",
|
||||||
|
"sourceRoot": "apps/api-gateway/src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"api-gateway": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/api-gateway",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/api-gateway/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"webpack": false,
|
||||||
|
"tsConfigPath": "apps/api-gateway/tsconfig.app.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-service": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/notification-service",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/notification-service/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"webpack": true,
|
||||||
|
"tsConfigPath": "apps/notification-service/tsconfig.app.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"type": "library",
|
||||||
|
"root": "libs/contracts",
|
||||||
|
"entryFile": "index",
|
||||||
|
"sourceRoot": "libs/contracts/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "libs/contracts/tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "library",
|
||||||
|
"root": "libs/events",
|
||||||
|
"entryFile": "index",
|
||||||
|
"sourceRoot": "libs/events/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "libs/events/tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"type": "library",
|
||||||
|
"root": "libs/common",
|
||||||
|
"entryFile": "index",
|
||||||
|
"sourceRoot": "libs/common/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "libs/common/tsconfig.lib.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+138
-3
@@ -8,9 +8,6 @@
|
|||||||
"name": "oudelaa-backend",
|
"name": "oudelaa-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
|
||||||
"node": ">=20 <25"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1041.0",
|
"@aws-sdk/client-s3": "^3.1041.0",
|
||||||
"@aws-sdk/lib-storage": "^3.1041.0",
|
"@aws-sdk/lib-storage": "^3.1041.0",
|
||||||
@@ -19,6 +16,7 @@
|
|||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.1.28",
|
"@nestjs/core": "^11.1.28",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/microservices": "^11.1.28",
|
||||||
"@nestjs/mongoose": "^11.0.4",
|
"@nestjs/mongoose": "^11.0.4",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.28",
|
"@nestjs/platform-express": "^11.1.28",
|
||||||
@@ -27,6 +25,8 @@
|
|||||||
"@nestjs/websockets": "^11.1.28",
|
"@nestjs/websockets": "^11.1.28",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
"amqp-connection-manager": "^4.1.14",
|
||||||
|
"amqplib": "^0.10.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.76.5",
|
"bullmq": "^5.76.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"@nestjs/cli": "^11.0.24",
|
"@nestjs/cli": "^11.0.24",
|
||||||
"@nestjs/schematics": "^11.1.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^11.1.28",
|
"@nestjs/testing": "^11.1.28",
|
||||||
|
"@types/amqplib": "^0.10.7",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
@@ -73,6 +74,9 @@
|
|||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"typescript-eslint": "^8.64.0"
|
"typescript-eslint": "^8.64.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20 <25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/core": {
|
"node_modules/@angular-devkit/core": {
|
||||||
@@ -3080,6 +3084,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/microservices": {
|
||||||
|
"version": "11.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.28.tgz",
|
||||||
|
"integrity": "sha512-8uRs6/UrhXvd8YCrYKcNUwWA7b8jcbYH03WBKWJ2A3bc6+WcHA6yXq7Px30yemAGBtIIMXkHeL88dO2usVI5zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iterare": "1.2.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/nest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@grpc/grpc-js": "*",
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/websockets": "^11.0.0",
|
||||||
|
"amqp-connection-manager": "*",
|
||||||
|
"amqplib": "*",
|
||||||
|
"cache-manager": "*",
|
||||||
|
"ioredis": "*",
|
||||||
|
"kafkajs": "*",
|
||||||
|
"mqtt": "*",
|
||||||
|
"nats": "*",
|
||||||
|
"reflect-metadata": "^0.1.12 || ^0.2.0",
|
||||||
|
"rxjs": "^7.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@grpc/grpc-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@nestjs/websockets": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"amqp-connection-manager": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"amqplib": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"cache-manager": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ioredis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"kafkajs": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mqtt": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/mongoose": {
|
"node_modules/@nestjs/mongoose": {
|
||||||
"version": "11.0.4",
|
"version": "11.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/mongoose/-/mongoose-11.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/mongoose/-/mongoose-11.0.4.tgz",
|
||||||
@@ -4222,6 +4284,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/amqplib": {
|
||||||
|
"version": "0.10.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.7.tgz",
|
||||||
|
"integrity": "sha512-IVj3avf9AQd2nXCx0PGk/OYq7VmHiyNxWFSb5HhU9ATh+i+gHWvVcljFTcTWQ/dyHJCTrzCixde+r/asL2ErDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -5234,6 +5306,35 @@
|
|||||||
"ajv": "^8.8.2"
|
"ajv": "^8.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/amqp-connection-manager": {
|
||||||
|
"version": "4.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/amqp-connection-manager/-/amqp-connection-manager-4.1.14.tgz",
|
||||||
|
"integrity": "sha512-1km47dIvEr0HhMUazqovSvNwIlSvDX2APdUpULaINtHpiki1O+cLRaTeXb/jav4OLtH+k6GBXx5gsKOT9kcGKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"promise-breaker": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0",
|
||||||
|
"npm": ">5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"amqplib": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/amqplib": {
|
||||||
|
"version": "0.10.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.8.tgz",
|
||||||
|
"integrity": "sha512-Tfn1O9sFgAP8DqeMEpt2IacsVTENBpblB3SqLdn0jK2AeX8iyCvbptBc8lyATT9bQ31MsjVwUSQ1g8f4jHOUfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-more-ints": "~1.0.0",
|
||||||
|
"url-parse": "~1.5.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-colors": {
|
"node_modules/ansi-colors": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
@@ -5752,6 +5853,12 @@
|
|||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-more-ints": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bullmq": {
|
"node_modules/bullmq": {
|
||||||
"version": "5.76.5",
|
"version": "5.76.5",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.5.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.5.tgz",
|
||||||
@@ -10439,6 +10546,12 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/promise-breaker": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/promise-breaker/-/promise-breaker-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-BthzO9yTPswGf7etOBiHCVuugs2N01/Q/94dIPls48z2zCmrnDptUUZzfIb+41xq0MnYZ/BzmOd6ikDR4ibNZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prompts": {
|
"node_modules/prompts": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||||
@@ -10508,6 +10621,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/querystringify": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -10618,6 +10737,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -12102,6 +12227,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url-parse": {
|
||||||
|
"version": "1.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||||
|
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"querystringify": "^2.1.1",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
+15
-6
@@ -8,12 +8,17 @@
|
|||||||
"node": ">=20 <25"
|
"node": ">=20 <25"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build api-gateway && nest build notification-service && tsc -p tsconfig.legacy.json && node scripts/copy-legacy-entry.js",
|
||||||
"start": "nest start",
|
"build:gateway": "nest build api-gateway",
|
||||||
"start:dev": "nest start --watch",
|
"build:notifications": "nest build notification-service",
|
||||||
"start:prod": "node dist/main",
|
"start": "nest start api-gateway",
|
||||||
"lint": "eslint \"src/**/*.ts\"",
|
"start:dev": "nest start api-gateway --watch",
|
||||||
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
"start:notification-service": "nest start notification-service",
|
||||||
|
"start:notification-service:dev": "nest start notification-service --watch",
|
||||||
|
"start:prod": "node dist/main.js",
|
||||||
|
"start:notification-service:prod": "node dist/apps/notification-service/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs}/**/*.ts\"",
|
||||||
|
"lint:fix": "eslint \"{src,apps,libs}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:coverage": "jest --runInBand --coverage && node scripts/check-coverage.js",
|
"test:coverage": "jest --runInBand --coverage && node scripts/check-coverage.js",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.1.28",
|
"@nestjs/core": "^11.1.28",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/microservices": "^11.1.28",
|
||||||
"@nestjs/mongoose": "^11.0.4",
|
"@nestjs/mongoose": "^11.0.4",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.28",
|
"@nestjs/platform-express": "^11.1.28",
|
||||||
@@ -41,6 +47,8 @@
|
|||||||
"@nestjs/websockets": "^11.1.28",
|
"@nestjs/websockets": "^11.1.28",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
"@types/passport-google-oauth20": "^2.0.17",
|
||||||
|
"amqp-connection-manager": "^4.1.14",
|
||||||
|
"amqplib": "^0.10.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.76.5",
|
"bullmq": "^5.76.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -64,6 +72,7 @@
|
|||||||
"@nestjs/cli": "^11.0.24",
|
"@nestjs/cli": "^11.0.24",
|
||||||
"@nestjs/schematics": "^11.1.0",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@nestjs/testing": "^11.1.28",
|
"@nestjs/testing": "^11.1.28",
|
||||||
|
"@types/amqplib": "^0.10.7",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const { copyFileSync, existsSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
const projectRoot = join(__dirname, '..');
|
||||||
|
const legacyBundle = join(projectRoot, 'dist', 'legacy', 'src', 'main.js');
|
||||||
|
const legacyShim = join(projectRoot, 'scripts', 'legacy-main.js');
|
||||||
|
const legacyEntry = join(projectRoot, 'dist', 'main.js');
|
||||||
|
|
||||||
|
if (!existsSync(legacyBundle)) {
|
||||||
|
throw new Error(`Legacy monolith bundle not found: ${legacyBundle}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyFileSync(legacyShim, legacyEntry);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const { bootstrap } = require('./legacy/src/main');
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -353,6 +353,11 @@ describe('controller and repository contracts', () => {
|
|||||||
expect(operations).toHaveLength(before);
|
expect(operations).toHaveLength(before);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (`${Repository.name}.${method}` === 'FollowsRepository.runInTransaction') {
|
||||||
|
// Session lifecycle and fallback semantics need a session-aware model mock and
|
||||||
|
// are covered in follows.repository.spec.ts.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await instance[method](...args);
|
await instance[method](...args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { MarketplaceModule } from './modules/marketplace/marketplace.module';
|
|||||||
import { MusicWorldModule } from './modules/music-world/music-world.module';
|
import { MusicWorldModule } from './modules/music-world/music-world.module';
|
||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
import { OutboxModule } from './modules/outbox/outbox.module';
|
import { OutboxModule } from './modules/outbox/outbox.module';
|
||||||
|
import { OrdersModule } from './modules/orders/orders.module';
|
||||||
import { PostsModule } from './modules/posts/posts.module';
|
import { PostsModule } from './modules/posts/posts.module';
|
||||||
import { ReportsModule } from './modules/reports/reports.module';
|
import { ReportsModule } from './modules/reports/reports.module';
|
||||||
import { SavesModule } from './modules/saves/saves.module';
|
import { SavesModule } from './modules/saves/saves.module';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('AppService readiness', () => {
|
|||||||
getActiveRequests: jest.fn().mockReturnValue(0),
|
getActiveRequests: jest.fn().mockReturnValue(0),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
service: new AppService(connection as any, redis as any, storage as any, shutdown as any),
|
service: new AppService(connection as any, redis as any, storage as any, shutdown as any, { isReady: () => true } as any),
|
||||||
shutdown,
|
shutdown,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Connection } from 'mongoose';
|
|||||||
import { ManagedStorageService } from './infrastructure/storage/managed-storage.service';
|
import { ManagedStorageService } from './infrastructure/storage/managed-storage.service';
|
||||||
import { RedisService } from './infrastructure/redis/redis.service';
|
import { RedisService } from './infrastructure/redis/redis.service';
|
||||||
import { ShutdownCoordinatorService } from './infrastructure/reliability/shutdown-coordinator.service';
|
import { ShutdownCoordinatorService } from './infrastructure/reliability/shutdown-coordinator.service';
|
||||||
|
import { NotificationServiceClient } from './modules/notifications/notification-service.client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AppService {
|
export class AppService {
|
||||||
@@ -12,6 +13,7 @@ export class AppService {
|
|||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly storage: ManagedStorageService,
|
private readonly storage: ManagedStorageService,
|
||||||
private readonly shutdownCoordinator: ShutdownCoordinatorService,
|
private readonly shutdownCoordinator: ShutdownCoordinatorService,
|
||||||
|
private readonly notificationClient: NotificationServiceClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getHealth(): { status: string; service: string } {
|
getHealth(): { status: string; service: string } {
|
||||||
@@ -51,6 +53,10 @@ export class AppService {
|
|||||||
checks.redis = { status: 'up' };
|
checks.redis = { status: 'up' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checks.rabbitmq = this.notificationClient.isReady()
|
||||||
|
? { status: 'up' }
|
||||||
|
: { status: 'down', error: 'RabbitMQ connection is unavailable' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const health = await this.withTimeout(this.storage.getHealth(), 5000, 'Storage check timeout');
|
const health = await this.withTimeout(this.storage.getHealth(), 5000, 'Storage check timeout');
|
||||||
const local = health.local as { readable?: boolean; writable?: boolean } | undefined;
|
const local = health.local as { readable?: boolean; writable?: boolean } | undefined;
|
||||||
|
|||||||
@@ -86,6 +86,37 @@ export const validationSchema = Joi.object({
|
|||||||
QUEUE_DEFAULT_BACKOFF_MS: Joi.number().min(100).max(600000).default(1000),
|
QUEUE_DEFAULT_BACKOFF_MS: Joi.number().min(100).max(600000).default(1000),
|
||||||
QUEUE_REMOVE_ON_COMPLETE: Joi.boolean().truthy('true').falsy('false').default(true),
|
QUEUE_REMOVE_ON_COMPLETE: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
QUEUE_WORKER_CONCURRENCY: Joi.number().min(1).max(100).default(5),
|
QUEUE_WORKER_CONCURRENCY: Joi.number().min(1).max(100).default(5),
|
||||||
|
NOTIFICATION_SERVICE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
OUTBOX_DISPATCHER_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
OUTBOX_POLL_INTERVAL_MS: Joi.number().integer().min(100).max(60000).default(1000),
|
||||||
|
OUTBOX_BATCH_SIZE: Joi.number().integer().min(1).max(500).default(25),
|
||||||
|
OUTBOX_MAX_ATTEMPTS: Joi.number().integer().min(1).max(100).default(8),
|
||||||
|
OUTBOX_LEASE_TIMEOUT_MS: Joi.number().integer().min(1000).max(3600000).default(60000),
|
||||||
|
OUTBOX_RETRY_BASE_MS: Joi.number().integer().min(100).max(600000).default(1000),
|
||||||
|
OUTBOX_RETRY_MAX_MS: Joi.number().integer().min(100).max(3600000).default(60000),
|
||||||
|
RABBITMQ_URL: Joi.string().uri().allow('').optional(),
|
||||||
|
RABBITMQ_HOST: Joi.string().default('127.0.0.1'),
|
||||||
|
RABBITMQ_PORT: Joi.number().integer().min(1).max(65535).default(5672),
|
||||||
|
RABBITMQ_USERNAME: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_PASSWORD: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_VHOST: Joi.string().default('/'),
|
||||||
|
RABBITMQ_TLS_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||||
|
RABBITMQ_TLS_REQUIRED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
|
RABBITMQ_CA_CERT_BASE64: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_CA_CERT_PATH: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_CLIENT_CERT_BASE64: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_CLIENT_CERT_PATH: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_CLIENT_KEY_BASE64: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_CLIENT_KEY_PATH: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_TLS_REJECT_UNAUTHORIZED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||||
|
RABBITMQ_SERVERNAME: Joi.string().allow('').optional(),
|
||||||
|
RABBITMQ_HEARTBEAT_SECONDS: Joi.number().integer().min(1).max(120).default(10),
|
||||||
|
RABBITMQ_RECONNECT_SECONDS: Joi.number().integer().min(1).max(300).default(5),
|
||||||
|
RABBITMQ_CONNECTION_TIMEOUT_MS: Joi.number().integer().min(100).max(120000).default(10000),
|
||||||
|
NOTIFICATION_RPC_TIMEOUT_MS: Joi.number().integer().min(100).max(30000).default(3000),
|
||||||
|
NOTIFICATION_RPC_RETRIES: Joi.number().integer().min(0).max(5).default(1),
|
||||||
|
NOTIFICATION_CIRCUIT_FAILURE_THRESHOLD: Joi.number().integer().min(1).max(20).default(3),
|
||||||
|
NOTIFICATION_CIRCUIT_RESET_MS: Joi.number().integer().min(1000).max(300000).default(15000),
|
||||||
STORAGE_PROVIDER: Joi.string().valid('local', 's3').default('local'),
|
STORAGE_PROVIDER: Joi.string().valid('local', 's3').default('local'),
|
||||||
MEDIA_ACCESS_MODE: Joi.string().valid('direct', 'signed').default('direct'),
|
MEDIA_ACCESS_MODE: Joi.string().valid('direct', 'signed').default('direct'),
|
||||||
SIGNED_URL_EXPIRES_SECONDS: Joi.number().integer().min(1).max(604800).default(86400),
|
SIGNED_URL_EXPIRES_SECONDS: Joi.number().integer().min(1).max(604800).default(86400),
|
||||||
@@ -156,5 +187,26 @@ export const validationSchema = Joi.object({
|
|||||||
custom: 'HTTP_SERVER_REQUEST_TIMEOUT_MS must be at least HTTP_HEADERS_TIMEOUT_MS',
|
custom: 'HTTP_SERVER_REQUEST_TIMEOUT_MS must be at least HTTP_HEADERS_TIMEOUT_MS',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (Number(value.OUTBOX_RETRY_MAX_MS) < Number(value.OUTBOX_RETRY_BASE_MS)) {
|
||||||
|
return helpers.message({ custom: 'OUTBOX_RETRY_MAX_MS must be at least OUTBOX_RETRY_BASE_MS' });
|
||||||
|
}
|
||||||
|
if (value.NOTIFICATION_SERVICE_ENABLED !== value.OUTBOX_DISPATCHER_ENABLED) {
|
||||||
|
return helpers.message({
|
||||||
|
custom: 'Phase-1 Gateway activation requires NOTIFICATION_SERVICE_ENABLED and OUTBOX_DISPATCHER_ENABLED to have the same value',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.NODE_ENV === 'production' && value.NOTIFICATION_SERVICE_ENABLED === true) {
|
||||||
|
const rawUrl = String(value.RABBITMQ_URL ?? '');
|
||||||
|
let url: URL | undefined;
|
||||||
|
try { if (rawUrl) url = new URL(rawUrl); } catch { return helpers.message({ custom: 'RABBITMQ_URL is invalid' }); }
|
||||||
|
const username = url?.username ? decodeURIComponent(url.username) : String(value.RABBITMQ_USERNAME ?? '');
|
||||||
|
const password = url?.password ? decodeURIComponent(url.password) : String(value.RABBITMQ_PASSWORD ?? '');
|
||||||
|
if (!username || !password || username === 'guest' || password === 'guest') {
|
||||||
|
return helpers.message({ custom: 'Production notification routing requires non-guest RabbitMQ credentials' });
|
||||||
|
}
|
||||||
|
if (value.RABBITMQ_TLS_REQUIRED === true && url?.protocol !== 'amqps:' && value.RABBITMQ_TLS_ENABLED !== true) {
|
||||||
|
return helpers.message({ custom: 'Production notification routing requires RabbitMQ TLS/amqps' });
|
||||||
|
}
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}, 'storage access mode validation');
|
}, 'cross-field configuration validation');
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { FollowsRepository } from './follows.repository';
|
||||||
|
|
||||||
|
describe('FollowsRepository transaction boundary', () => {
|
||||||
|
function setup(withTransaction: jest.Mock) {
|
||||||
|
const session = { withTransaction, endSession: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const followModel = { db: { startSession: jest.fn().mockResolvedValue(session) } };
|
||||||
|
const repository = new FollowsRepository(followModel as never, {} as never);
|
||||||
|
return { repository, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns only after the transaction callback commits and always closes the session', async () => {
|
||||||
|
const ctx = setup(jest.fn(async (callback: () => Promise<void>) => callback()));
|
||||||
|
const operation = jest.fn().mockResolvedValue('committed');
|
||||||
|
await expect(ctx.repository.runInTransaction(operation, true)).resolves.toBe('committed');
|
||||||
|
expect(operation).toHaveBeenCalledWith(ctx.session);
|
||||||
|
expect(ctx.session.endSession).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates transaction failures in microservices mode without an unsafe fallback', async () => {
|
||||||
|
const ctx = setup(jest.fn(async (callback: () => Promise<void>) => callback()));
|
||||||
|
const operation = jest.fn().mockRejectedValue(new Error('outbox insert failed'));
|
||||||
|
await expect(ctx.repository.runInTransaction(operation, true)).rejects.toThrow('outbox insert failed');
|
||||||
|
expect(operation).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.session.endSession).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only the documented legacy fallback when standalone Mongo rejects transactions', async () => {
|
||||||
|
const ctx = setup(jest.fn().mockRejectedValue(new Error('Transaction numbers are only allowed on a replica set member or mongos')));
|
||||||
|
const operation = jest.fn().mockResolvedValue('legacy');
|
||||||
|
await expect(ctx.repository.runInTransaction(operation, false)).resolves.toBe('legacy');
|
||||||
|
expect(operation).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,12 +12,17 @@ export class FollowsRepository {
|
|||||||
private readonly followRequestModel: Model<FollowRequestDocument>,
|
private readonly followRequestModel: Model<FollowRequestDocument>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findOne(followerId: string, followingId: string): Promise<FollowDocument | null> {
|
async findOne(
|
||||||
|
followerId: string,
|
||||||
|
followingId: string,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<FollowDocument | null> {
|
||||||
return this.followModel
|
return this.followModel
|
||||||
.findOne({
|
.findOne({
|
||||||
followerId: new Types.ObjectId(followerId),
|
followerId: new Types.ObjectId(followerId),
|
||||||
followingId: new Types.ObjectId(followingId),
|
followingId: new Types.ObjectId(followingId),
|
||||||
})
|
})
|
||||||
|
.session(session ?? null)
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +114,27 @@ export class FollowsRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runInTransaction<T>(
|
||||||
|
operation: (session?: ClientSession) => Promise<T>,
|
||||||
|
required: boolean,
|
||||||
|
): Promise<T> {
|
||||||
|
const session = await this.followModel.db.startSession();
|
||||||
|
try {
|
||||||
|
let result: T | undefined;
|
||||||
|
try {
|
||||||
|
await session.withTransaction(async () => {
|
||||||
|
result = await operation(session);
|
||||||
|
});
|
||||||
|
return result as T;
|
||||||
|
} catch (error) {
|
||||||
|
if (required || !this.isTransactionUnsupported(error)) throw error;
|
||||||
|
return operation(undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await session.endSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deletePendingRequest(requesterId: string, targetUserId: string): Promise<void> {
|
async deletePendingRequest(requesterId: string, targetUserId: string): Promise<void> {
|
||||||
await this.followRequestModel
|
await this.followRequestModel
|
||||||
.deleteOne({
|
.deleteOne({
|
||||||
@@ -162,16 +188,33 @@ export class FollowsRepository {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
status: 'approved' | 'rejected',
|
status: 'approved' | 'rejected',
|
||||||
|
session?: ClientSession,
|
||||||
): Promise<FollowRequestDocument | null> {
|
): Promise<FollowRequestDocument | null> {
|
||||||
return this.followRequestModel
|
return this.followRequestModel
|
||||||
.findOneAndUpdate(
|
.findOneAndUpdate(
|
||||||
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
|
{ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId), status: 'pending' },
|
||||||
{ status },
|
{ status },
|
||||||
{ new: true },
|
{ new: true, session },
|
||||||
)
|
)
|
||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findRequestByIdForTarget(
|
||||||
|
requestId: string,
|
||||||
|
targetUserId: string,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<FollowRequestDocument | null> {
|
||||||
|
return this.followRequestModel
|
||||||
|
.findOne({ _id: new Types.ObjectId(requestId), targetUserId: new Types.ObjectId(targetUserId) })
|
||||||
|
.session(session ?? null)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTransactionUnsupported(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return /Transaction numbers are only allowed|replica set member or mongos/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
async findPendingRequestsForTarget(
|
async findPendingRequestsForTarget(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
skip: number,
|
skip: number,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ describe('FollowsService', () => {
|
|||||||
findPendingRequestsForTarget: jest.fn().mockResolvedValue([]),
|
findPendingRequestsForTarget: jest.fn().mockResolvedValue([]),
|
||||||
countPendingRequestsForTarget: jest.fn().mockResolvedValue(0),
|
countPendingRequestsForTarget: jest.fn().mockResolvedValue(0),
|
||||||
updateRequestStatus: jest.fn().mockResolvedValue(null),
|
updateRequestStatus: jest.fn().mockResolvedValue(null),
|
||||||
|
findRequestByIdForTarget: jest.fn().mockResolvedValue(null),
|
||||||
|
runInTransaction: jest.fn().mockImplementation(async (operation: (session?: unknown) => Promise<unknown>) => operation(undefined)),
|
||||||
...overrides.followsRepository,
|
...overrides.followsRepository,
|
||||||
};
|
};
|
||||||
const usersRepository = {
|
const usersRepository = {
|
||||||
@@ -38,6 +40,8 @@ describe('FollowsService', () => {
|
|||||||
})),
|
})),
|
||||||
setFollowingCount: jest.fn().mockResolvedValue(undefined),
|
setFollowingCount: jest.fn().mockResolvedValue(undefined),
|
||||||
setFollowersCount: jest.fn().mockResolvedValue(undefined),
|
setFollowersCount: jest.fn().mockResolvedValue(undefined),
|
||||||
|
incrementFollowingCount: jest.fn().mockResolvedValue(undefined),
|
||||||
|
incrementFollowersCount: jest.fn().mockResolvedValue(undefined),
|
||||||
findManyByIds: jest.fn().mockResolvedValue([]),
|
findManyByIds: jest.fn().mockResolvedValue([]),
|
||||||
findSuggestionCandidates: jest.fn().mockResolvedValue([]),
|
findSuggestionCandidates: jest.fn().mockResolvedValue([]),
|
||||||
...overrides.usersRepository,
|
...overrides.usersRepository,
|
||||||
@@ -45,6 +49,8 @@ describe('FollowsService', () => {
|
|||||||
const outboxService = {
|
const outboxService = {
|
||||||
enqueueFollowNotification: jest.fn().mockResolvedValue(undefined),
|
enqueueFollowNotification: jest.fn().mockResolvedValue(undefined),
|
||||||
enqueueFollowRequestApprovedNotification: jest.fn().mockResolvedValue(undefined),
|
enqueueFollowRequestApprovedNotification: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createFollowRequestApprovedEvent: jest.fn().mockResolvedValue(`follow.request.approved:${requestId}`),
|
||||||
|
enqueuePersistedEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
...overrides.outboxService,
|
...overrides.outboxService,
|
||||||
};
|
};
|
||||||
const feedVersionService = {
|
const feedVersionService = {
|
||||||
@@ -413,12 +419,13 @@ describe('FollowsService', () => {
|
|||||||
following: true,
|
following: true,
|
||||||
requesterId: targetUserId,
|
requesterId: targetUserId,
|
||||||
});
|
});
|
||||||
expect(ctx.followsRepository.create).toHaveBeenCalledWith(targetUserId, currentUserId);
|
expect(ctx.followsRepository.create).toHaveBeenCalledWith(targetUserId, currentUserId, undefined);
|
||||||
expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(targetUserId);
|
expect(ctx.feedVersionService.bumpUserVersion).toHaveBeenCalledWith(targetUserId);
|
||||||
expect(ctx.outboxService.enqueueFollowRequestApprovedNotification).toHaveBeenCalledWith(
|
expect(ctx.outboxService.createFollowRequestApprovedEvent).toHaveBeenCalledWith(
|
||||||
currentUserId,
|
currentUserId,
|
||||||
targetUserId,
|
targetUserId,
|
||||||
requestId,
|
requestId,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existing = setup({
|
const existing = setup({
|
||||||
@@ -429,7 +436,7 @@ describe('FollowsService', () => {
|
|||||||
});
|
});
|
||||||
await existing.service.approveRequest(currentUserId, requestId);
|
await existing.service.approveRequest(currentUserId, requestId);
|
||||||
expect(existing.followsRepository.create).not.toHaveBeenCalled();
|
expect(existing.followsRepository.create).not.toHaveBeenCalled();
|
||||||
expect(existing.outboxService.enqueueFollowRequestApprovedNotification).toHaveBeenCalledTimes(1);
|
expect(existing.outboxService.createFollowRequestApprovedEvent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates approve and reject requests and handles missing records', async () => {
|
it('validates approve and reject requests and handles missing records', async () => {
|
||||||
@@ -439,7 +446,7 @@ describe('FollowsService', () => {
|
|||||||
await expect(setup().service.approveRequest(currentUserId, requestId)).rejects.toThrow(
|
await expect(setup().service.approveRequest(currentUserId, requestId)).rejects.toThrow(
|
||||||
'Follow request not found',
|
'Follow request not found',
|
||||||
);
|
);
|
||||||
expect(setup().outboxService.enqueueFollowRequestApprovedNotification).not.toHaveBeenCalled();
|
expect(setup().outboxService.createFollowRequestApprovedEvent).not.toHaveBeenCalled();
|
||||||
await expect(setup().service.rejectRequest(currentUserId, 'bad')).rejects.toThrow(
|
await expect(setup().service.rejectRequest(currentUserId, 'bad')).rejects.toThrow(
|
||||||
'Invalid follow request id',
|
'Invalid follow request id',
|
||||||
);
|
);
|
||||||
@@ -458,6 +465,29 @@ describe('FollowsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps duplicate approvals idempotent and propagates an outbox transaction failure', async () => {
|
||||||
|
const duplicate = setup({
|
||||||
|
followsRepository: {
|
||||||
|
updateRequestStatus: jest.fn().mockResolvedValue(null),
|
||||||
|
findRequestByIdForTarget: jest.fn().mockResolvedValue({
|
||||||
|
status: 'approved', requesterId: { toString: () => targetUserId },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(duplicate.service.approveRequest(currentUserId, requestId)).resolves.toEqual({
|
||||||
|
approved: true, following: true, requesterId: targetUserId,
|
||||||
|
});
|
||||||
|
expect(duplicate.followsRepository.create).not.toHaveBeenCalled();
|
||||||
|
expect(duplicate.outboxService.createFollowRequestApprovedEvent).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const failed = setup({
|
||||||
|
followsRepository: { updateRequestStatus: jest.fn().mockResolvedValue({ requesterId: { toString: () => targetUserId } }) },
|
||||||
|
outboxService: { createFollowRequestApprovedEvent: jest.fn().mockRejectedValue(new Error('outbox insert failed')) },
|
||||||
|
});
|
||||||
|
await expect(failed.service.approveRequest(currentUserId, requestId)).rejects.toThrow('outbox insert failed');
|
||||||
|
expect(failed.feedVersionService.bumpUserVersion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('ranks and explains personalized suggestions with stable pagination', async () => {
|
it('ranks and explains personalized suggestions with stable pagination', async () => {
|
||||||
jest.spyOn(Math, 'random').mockReturnValue(0.25);
|
jest.spyOn(Math, 'random').mockReturnValue(0.25);
|
||||||
const current = {
|
const current = {
|
||||||
|
|||||||
@@ -251,24 +251,61 @@ export class FollowsService {
|
|||||||
throw new BadRequestException('Invalid follow request id');
|
throw new BadRequestException('Invalid follow request id');
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = await this.followsRepository.updateRequestStatus(requestId, currentUserId, 'approved');
|
const microservicesEnabled = process.env.NOTIFICATION_SERVICE_ENABLED === 'true'
|
||||||
if (!request) {
|
|| process.env.OUTBOX_DISPATCHER_ENABLED === 'true';
|
||||||
throw new NotFoundException('Follow request not found');
|
const transaction = await this.followsRepository.runInTransaction(async (session) => {
|
||||||
|
const request = await this.followsRepository.updateRequestStatus(
|
||||||
|
requestId,
|
||||||
|
currentUserId,
|
||||||
|
'approved',
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
if (!request) {
|
||||||
|
const existingRequest = await this.followsRepository.findRequestByIdForTarget(
|
||||||
|
requestId,
|
||||||
|
currentUserId,
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
if (existingRequest?.status === 'approved') {
|
||||||
|
return {
|
||||||
|
requesterId: existingRequest.requesterId.toString(),
|
||||||
|
followCreated: false,
|
||||||
|
eventId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new NotFoundException('Follow request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterId = request.requesterId.toString();
|
||||||
|
const existing = await this.followsRepository.findOne(requesterId, currentUserId, session);
|
||||||
|
let followCreated = false;
|
||||||
|
if (!existing) {
|
||||||
|
await this.followsRepository.create(requesterId, currentUserId, session);
|
||||||
|
// MongoDB transactions do not support parallel operations on one session.
|
||||||
|
await this.usersRepository.incrementFollowingCount(requesterId, 1, session);
|
||||||
|
await this.usersRepository.incrementFollowersCount(currentUserId, 1, session);
|
||||||
|
followCreated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = await this.outboxService.createFollowRequestApprovedEvent(
|
||||||
|
currentUserId,
|
||||||
|
requesterId,
|
||||||
|
requestId,
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
return { requesterId, followCreated, eventId };
|
||||||
|
}, microservicesEnabled);
|
||||||
|
|
||||||
|
if (transaction.followCreated) await this.feedVersionService.bumpUserVersion(transaction.requesterId);
|
||||||
|
if (!microservicesEnabled && transaction.eventId) {
|
||||||
|
try {
|
||||||
|
await this.outboxService.enqueuePersistedEvent(transaction.eventId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Legacy outbox enqueue failed; event remains pending: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requesterId = request.requesterId.toString();
|
const requesterId = transaction.requesterId;
|
||||||
const existing = await this.followsRepository.findOne(requesterId, currentUserId);
|
|
||||||
if (!existing) {
|
|
||||||
await this.followsRepository.create(requesterId, currentUserId);
|
|
||||||
await this.syncFollowCounts(requesterId, currentUserId);
|
|
||||||
await this.feedVersionService.bumpUserVersion(requesterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.enqueueFollowRequestApprovedNotification(
|
|
||||||
currentUserId,
|
|
||||||
requesterId,
|
|
||||||
requestId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { approved: true, following: true, requesterId };
|
return { approved: true, following: true, requesterId };
|
||||||
}
|
}
|
||||||
@@ -467,26 +504,6 @@ export class FollowsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async enqueueFollowRequestApprovedNotification(
|
|
||||||
actorId: string,
|
|
||||||
recipientId: string,
|
|
||||||
requestId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.outboxService.enqueueFollowRequestApprovedNotification(
|
|
||||||
actorId,
|
|
||||||
recipientId,
|
|
||||||
requestId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Follow request approval notification failed for actor=${actorId} recipient=${recipientId}: ${
|
|
||||||
error instanceof Error ? error.message : 'unknown error'
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFollowActionResponse(
|
private buildFollowActionResponse(
|
||||||
message: string,
|
message: string,
|
||||||
isFollowing: boolean,
|
isFollowing: boolean,
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { IsEnum, IsMongoId, IsObject, IsOptional, IsString, MaxLength } from 'cl
|
|||||||
import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema';
|
import { NOTIFICATION_TYPES, NotificationType } from '../schemas/notification.schema';
|
||||||
|
|
||||||
export class CreateNotificationDto {
|
export class CreateNotificationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
eventId?: string;
|
||||||
|
|
||||||
@IsMongoId()
|
@IsMongoId()
|
||||||
recipientId!: string;
|
recipientId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NOTIFICATION_RPC } from '@app/contracts';
|
||||||
|
import { FOLLOW_REQUEST_APPROVED_EVENT, FollowRequestApprovedEvent } from '@app/events';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { NotificationConsumer } from '../../../apps/notification-service/src/notification.consumer';
|
||||||
|
import { NotificationServiceClient } from './notification-service.client';
|
||||||
|
import { NotificationsGateway } from './notifications.gateway';
|
||||||
|
|
||||||
|
const remoteConfig = () => ({
|
||||||
|
get: jest.fn((key: string, fallback?: string) => ({
|
||||||
|
NOTIFICATION_SERVICE_ENABLED: 'true',
|
||||||
|
RABBITMQ_URL: 'amqp://rabbit',
|
||||||
|
NOTIFICATION_RPC_TIMEOUT_MS: '1000',
|
||||||
|
NOTIFICATION_RPC_RETRIES: '0',
|
||||||
|
})[key] ?? fallback),
|
||||||
|
}) as unknown as ConfigService;
|
||||||
|
|
||||||
|
describe('Gateway to notification-service contract', () => {
|
||||||
|
it('keeps the gateway result identical while using the shared RMQ pattern', async () => {
|
||||||
|
const expected = { items: [{ _id: 'notification-1' }], total: 1, unreadCount: 1 };
|
||||||
|
const service = new NotificationServiceClient(
|
||||||
|
remoteConfig(),
|
||||||
|
{ getMine: jest.fn() } as never,
|
||||||
|
{ emitCreated: jest.fn(), emitUnreadCount: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
const transport = { send: jest.fn().mockReturnValue(of({ result: expected })), close: jest.fn() };
|
||||||
|
(service as unknown as { client: unknown }).client = transport;
|
||||||
|
|
||||||
|
await expect(service.getMine('user-1', { page: 1 } as never, 'request-1')).resolves.toBe(expected);
|
||||||
|
expect(transport.send).toHaveBeenCalledWith(
|
||||||
|
NOTIFICATION_RPC.list,
|
||||||
|
expect.objectContaining({ data: { recipientId: 'user-1', query: { page: 1 } } }),
|
||||||
|
);
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only the local service and never sends to RabbitMQ when the flag is false', async () => {
|
||||||
|
const expected = { items: [], total: 0, unreadCount: 0 };
|
||||||
|
const local = { getMine: jest.fn().mockResolvedValue(expected) };
|
||||||
|
const service = new NotificationServiceClient(
|
||||||
|
{ get: jest.fn((_key: string, fallback?: string) => fallback) } as unknown as ConfigService,
|
||||||
|
local as never,
|
||||||
|
{ emitCreated: jest.fn(), emitUnreadCount: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
const transport = { send: jest.fn(), connect: jest.fn(), close: jest.fn() };
|
||||||
|
(service as unknown as { client: unknown }).client = transport;
|
||||||
|
|
||||||
|
await expect(service.getMine('user-1', {} as never)).resolves.toBe(expected);
|
||||||
|
expect(local.getMine).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transport.send).not.toHaveBeenCalled();
|
||||||
|
expect(transport.connect).not.toHaveBeenCalled();
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers follow approval from the consumer to each compatible Socket.IO event once', async () => {
|
||||||
|
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
const event: FollowRequestApprovedEvent = {
|
||||||
|
eventId: 'event-socket-1',
|
||||||
|
eventType: FOLLOW_REQUEST_APPROVED_EVENT,
|
||||||
|
occurredAt: '2026-08-03T10:00:00.000Z',
|
||||||
|
payload: { requestId: 'request-1', actorId: 'actor-1', recipientId: 'recipient-1' },
|
||||||
|
};
|
||||||
|
const notification = { _id: 'notification-1', type: 'follow_request_approved' };
|
||||||
|
const consumer = new NotificationConsumer({
|
||||||
|
createFollowRequestApprovedNotification: jest.fn().mockResolvedValue(notification),
|
||||||
|
getUnreadCounts: jest.fn().mockResolvedValue({ total: 1, followRequests: 1 }),
|
||||||
|
} as never);
|
||||||
|
const channel = { ack: jest.fn(), nack: jest.fn() };
|
||||||
|
const response = await consumer.followRequestApproved(event, {
|
||||||
|
getChannelRef: () => channel,
|
||||||
|
getMessage: () => ({ properties: { headers: {} } }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const gateway = new NotificationsGateway({} as never, {} as never, {} as never);
|
||||||
|
const roomEmitter = { emit: jest.fn() };
|
||||||
|
(gateway as unknown as { server: unknown }).server = {
|
||||||
|
to: jest.fn().mockReturnValue(roomEmitter),
|
||||||
|
};
|
||||||
|
const client = new NotificationServiceClient(remoteConfig(), {} as never, gateway);
|
||||||
|
const transport = { send: jest.fn().mockReturnValue(of(response)), close: jest.fn() };
|
||||||
|
(client as unknown as { client: unknown }).client = transport;
|
||||||
|
|
||||||
|
await client.publishFollowRequestApproved(event);
|
||||||
|
|
||||||
|
expect(roomEmitter.emit).toHaveBeenCalledWith('notification_created', notification);
|
||||||
|
expect(roomEmitter.emit).toHaveBeenCalledWith('notification:new', notification);
|
||||||
|
expect(roomEmitter.emit.mock.calls.filter(([name]) => name === 'notification_created')).toHaveLength(1);
|
||||||
|
expect(roomEmitter.emit.mock.calls.filter(([name]) => name === 'notification:new')).toHaveLength(1);
|
||||||
|
await client.onModuleDestroy();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
describe('Notification event idempotency', () => {
|
||||||
|
const dto = {
|
||||||
|
eventId: 'event-duplicate',
|
||||||
|
actorId: '507f1f77bcf86cd799439011',
|
||||||
|
recipientId: '507f191e810c19729de860ea',
|
||||||
|
requestId: '507f1f77bcf86cd799439012',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns the existing record when the same event is delivered twice', async () => {
|
||||||
|
const existing = { _id: 'notification-1' };
|
||||||
|
const repository = {
|
||||||
|
findByEventId: jest.fn().mockResolvedValue(existing),
|
||||||
|
create: jest.fn(),
|
||||||
|
};
|
||||||
|
const gateway = { emitCreated: jest.fn() };
|
||||||
|
const service = new NotificationsService(repository as never, gateway as never);
|
||||||
|
|
||||||
|
await expect(service.createFollowRequestApprovedNotification(dto)).resolves.toBe(existing);
|
||||||
|
await expect(service.createFollowRequestApprovedNotification(dto)).resolves.toBe(existing);
|
||||||
|
expect(repository.findByEventId).toHaveBeenCalledTimes(2);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
expect(gateway.emitCreated).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers from a concurrent duplicate-key insert', async () => {
|
||||||
|
const existing = { _id: 'notification-1' };
|
||||||
|
const repository = {
|
||||||
|
findByEventId: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(existing),
|
||||||
|
create: jest.fn().mockRejectedValue({ code: 11000 }),
|
||||||
|
};
|
||||||
|
const service = new NotificationsService(repository as never, { emitCreated: jest.fn() } as never);
|
||||||
|
|
||||||
|
await expect(service.createFollowRequestApprovedNotification(dto)).resolves.toBe(existing);
|
||||||
|
expect(repository.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { resolveRabbitMqConfig } from '../../../libs/common/src';
|
||||||
|
import { NOTIFICATION_RMQ_DLQ, NOTIFICATION_RMQ_QUEUE, NOTIFICATION_RPC, NotificationRpcResponse } from '../../../libs/contracts/src';
|
||||||
|
import { FollowRequestApprovedEvent } from '../../../libs/events/src';
|
||||||
|
import { Injectable, OnApplicationBootstrap, OnModuleDestroy, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ClientProxy, ClientProxyFactory, RmqRecordBuilder, Transport } from '@nestjs/microservices';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { firstValueFrom, retry, timeout, timer } from 'rxjs';
|
||||||
|
import { NotificationCategory, NotificationQueryDto } from './dto/notification-query.dto';
|
||||||
|
import { NotificationsGateway } from './notifications.gateway';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationServiceClient implements OnApplicationBootstrap, OnModuleDestroy {
|
||||||
|
private client?: ClientProxy;
|
||||||
|
private rabbitReady = false;
|
||||||
|
private consecutiveFailures = 0;
|
||||||
|
private circuitOpenUntil = 0;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService, private readonly local: NotificationsService, private readonly gateway: NotificationsGateway) {
|
||||||
|
if (this.isRemoteEnabled()) this.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
isRemoteEnabled(): boolean {
|
||||||
|
return String(this.config.get('NOTIFICATION_SERVICE_ENABLED') ?? 'false').toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
isReady(): boolean {
|
||||||
|
return !this.isRemoteEnabled() || this.rabbitReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
if (!this.isRemoteEnabled()) return;
|
||||||
|
try { await this.getClient().connect(); }
|
||||||
|
catch { this.rabbitReady = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMine(recipientId: string, query: NotificationQueryDto, requestId?: string) {
|
||||||
|
if (!this.isRemoteEnabled()) return this.local.getMine(recipientId, query);
|
||||||
|
return this.call(NOTIFICATION_RPC.list, { recipientId, query }, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(recipientId: string, category?: NotificationCategory, requestId?: string) {
|
||||||
|
if (!this.isRemoteEnabled()) return this.local.getUnreadCountByCategory(recipientId, category);
|
||||||
|
return this.call(NOTIFICATION_RPC.unreadCount, { recipientId, category }, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(recipientId: string, notificationId: string, requestId?: string) {
|
||||||
|
if (!this.isRemoteEnabled()) return this.local.markRead(recipientId, notificationId);
|
||||||
|
return this.call(NOTIFICATION_RPC.markRead, { recipientId, notificationId }, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(recipientId: string, requestId?: string) {
|
||||||
|
if (!this.isRemoteEnabled()) return this.local.markAllRead(recipientId);
|
||||||
|
return this.call(NOTIFICATION_RPC.markAllRead, { recipientId }, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishFollowRequestApproved(event: FollowRequestApprovedEvent): Promise<void> {
|
||||||
|
if (!this.isRemoteEnabled()) {
|
||||||
|
await this.local.createFollowRequestApprovedNotification({ ...event.payload, eventId: event.eventId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.call(event.eventType, event, event.payload.requestId, event.eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.client) await this.client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClient(): ClientProxy {
|
||||||
|
if (this.client) return this.client;
|
||||||
|
const rabbit = resolveRabbitMqConfig(
|
||||||
|
{ get: (key) => {
|
||||||
|
const value = this.config.get(key);
|
||||||
|
return value === undefined || value === null ? undefined : String(value);
|
||||||
|
} },
|
||||||
|
'api-gateway',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.client = ClientProxyFactory.create({
|
||||||
|
transport: Transport.RMQ,
|
||||||
|
options: {
|
||||||
|
urls: [rabbit.url], socketOptions: rabbit.socketOptions,
|
||||||
|
queue: NOTIFICATION_RMQ_QUEUE,
|
||||||
|
queueOptions: { durable: true, arguments: { 'x-dead-letter-exchange': '', 'x-dead-letter-routing-key': NOTIFICATION_RMQ_DLQ } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.client.status.subscribe((status) => {
|
||||||
|
this.rabbitReady = status === 'connected' || status === 'unblocked';
|
||||||
|
});
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async call<T>(pattern: string, payload: unknown, requestId?: string, correlationId?: string): Promise<T> {
|
||||||
|
requestId ??= randomUUID();
|
||||||
|
correlationId ??= randomUUID();
|
||||||
|
if (Date.now() < this.circuitOpenUntil) throw new ServiceUnavailableException('Notification service circuit is open');
|
||||||
|
const record = new RmqRecordBuilder(payload).setOptions({ messageId: correlationId, persistent: true, headers: { correlationId, requestId } }).build();
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(this.getClient().send<NotificationRpcResponse<T>>(pattern, record).pipe(
|
||||||
|
timeout(Number(this.config.get<string>('NOTIFICATION_RPC_TIMEOUT_MS') ?? 3000)),
|
||||||
|
retry({ count: Number(this.config.get<string>('NOTIFICATION_RPC_RETRIES') ?? 1), delay: (_error, count) => timer(100 * count) }),
|
||||||
|
));
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
if (response.realtime) {
|
||||||
|
const realtime = response.realtime;
|
||||||
|
if (realtime.notification) this.gateway.emitCreated(realtime.recipientId, realtime.notification, realtime.unreadCount, realtime.unreadCounts);
|
||||||
|
else this.gateway.emitUnreadCount(realtime.recipientId, realtime.unreadCount, realtime.unreadCounts);
|
||||||
|
}
|
||||||
|
return response.result;
|
||||||
|
} catch (error) {
|
||||||
|
this.consecutiveFailures += 1;
|
||||||
|
const threshold = Number(this.config.get<string>('NOTIFICATION_CIRCUIT_FAILURE_THRESHOLD') ?? 3);
|
||||||
|
if (this.consecutiveFailures >= threshold) {
|
||||||
|
this.circuitOpenUntil = Date.now() + Number(this.config.get<string>('NOTIFICATION_CIRCUIT_RESET_MS') ?? 15_000);
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('Notification service is unavailable', { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||||
@@ -12,12 +13,16 @@ import {
|
|||||||
NotificationUnreadCountQueryDto,
|
NotificationUnreadCountQueryDto,
|
||||||
} from './dto/notification-query.dto';
|
} from './dto/notification-query.dto';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { NotificationServiceClient } from './notification-service.client';
|
||||||
|
|
||||||
@ApiTags('Notifications')
|
@ApiTags('Notifications')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
export class NotificationsController {
|
export class NotificationsController {
|
||||||
constructor(private readonly notificationsService: NotificationsService) {}
|
constructor(
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
|
private readonly notificationClient: NotificationServiceClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||||
@@ -29,8 +34,12 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get()
|
@Get()
|
||||||
async getMine(@CurrentUser() user: JwtPayload, @Query() query: NotificationQueryDto) {
|
async getMine(
|
||||||
return this.notificationsService.getMine(user.sub, query);
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query() query: NotificationQueryDto,
|
||||||
|
@Req() request: Request,
|
||||||
|
) {
|
||||||
|
return this.notificationClient.getMine(user.sub, query, this.requestId(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -38,8 +47,9 @@ export class NotificationsController {
|
|||||||
async getUnreadCount(
|
async getUnreadCount(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query() query: NotificationUnreadCountQueryDto,
|
@Query() query: NotificationUnreadCountQueryDto,
|
||||||
|
@Req() request: Request,
|
||||||
) {
|
) {
|
||||||
return this.notificationsService.getUnreadCountByCategory(user.sub, query.category);
|
return this.notificationClient.getUnreadCount(user.sub, query.category, this.requestId(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -50,13 +60,22 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Patch('read-all')
|
@Patch('read-all')
|
||||||
async markAllRead(@CurrentUser() user: JwtPayload) {
|
async markAllRead(@CurrentUser() user: JwtPayload, @Req() request: Request) {
|
||||||
return this.notificationsService.markAllRead(user.sub);
|
return this.notificationClient.markAllRead(user.sub, this.requestId(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Patch(':id/read')
|
@Patch(':id/read')
|
||||||
async markRead(@CurrentUser() user: JwtPayload, @Param('id') notificationId: string) {
|
async markRead(
|
||||||
return this.notificationsService.markRead(user.sub, notificationId);
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') notificationId: string,
|
||||||
|
@Req() request: Request,
|
||||||
|
) {
|
||||||
|
return this.notificationClient.markRead(user.sub, notificationId, this.requestId(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestId(request: Request): string | undefined {
|
||||||
|
const value = request.headers['x-request-id'];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NotificationsService } from './notifications.service';
|
|||||||
import { NotificationsRepository } from './notifications.repository';
|
import { NotificationsRepository } from './notifications.repository';
|
||||||
import { Notification, NotificationSchema } from './schemas/notification.schema';
|
import { Notification, NotificationSchema } from './schemas/notification.schema';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { NotificationServiceClient } from './notification-service.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,7 +18,12 @@ import { UsersModule } from '../users/users.module';
|
|||||||
MongooseModule.forFeature([{ name: Notification.name, schema: NotificationSchema }]),
|
MongooseModule.forFeature([{ name: Notification.name, schema: NotificationSchema }]),
|
||||||
],
|
],
|
||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [NotificationsService, NotificationsRepository, NotificationsGateway],
|
providers: [
|
||||||
exports: [NotificationsService],
|
NotificationsService,
|
||||||
|
NotificationsRepository,
|
||||||
|
NotificationsGateway,
|
||||||
|
NotificationServiceClient,
|
||||||
|
],
|
||||||
|
exports: [NotificationsService, NotificationsGateway, NotificationServiceClient],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export class NotificationsRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByEventId(eventId: string): Promise<NotificationDocument | null> {
|
||||||
|
return this.notificationModel
|
||||||
|
.findOne({ eventId })
|
||||||
|
.populate({ path: 'actorId', select: 'name username stageName avatar isVerified isDisabled' })
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
async countUnreadByFilter(
|
async countUnreadByFilter(
|
||||||
recipientId: string,
|
recipientId: string,
|
||||||
filter: FilterQuery<NotificationDocument> = {},
|
filter: FilterQuery<NotificationDocument> = {},
|
||||||
|
|||||||
@@ -45,11 +45,19 @@ export class NotificationsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.eventId) {
|
||||||
|
const existing = await this.notificationsRepository.findByEventId(dto.eventId);
|
||||||
|
if (existing) return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const resourceType = (dto.resourceType ?? this.resolveResourceType(dto.type)).trim();
|
const resourceType = (dto.resourceType ?? this.resolveResourceType(dto.type)).trim();
|
||||||
const deepLink = (dto.deepLink ?? this.buildDeepLink(dto.type, dto.referenceId, resourceType)).trim();
|
const deepLink = (dto.deepLink ?? this.buildDeepLink(dto.type, dto.referenceId, resourceType)).trim();
|
||||||
const title = (dto.title ?? this.buildTitle(dto.type)).trim();
|
const title = (dto.title ?? this.buildTitle(dto.type)).trim();
|
||||||
const previewText = (dto.previewText ?? '').trim();
|
const previewText = (dto.previewText ?? '').trim();
|
||||||
const notification = await this.notificationsRepository.create({
|
let notification;
|
||||||
|
try {
|
||||||
|
notification = await this.notificationsRepository.create({
|
||||||
|
eventId: dto.eventId,
|
||||||
recipientId: new Types.ObjectId(dto.recipientId),
|
recipientId: new Types.ObjectId(dto.recipientId),
|
||||||
actorId: new Types.ObjectId(dto.actorId),
|
actorId: new Types.ObjectId(dto.actorId),
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
@@ -61,7 +69,14 @@ export class NotificationsService {
|
|||||||
metadata: dto.metadata ?? {},
|
metadata: dto.metadata ?? {},
|
||||||
read: false,
|
read: false,
|
||||||
readAt: null,
|
readAt: null,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (dto.eventId && this.isDuplicateKeyError(error)) {
|
||||||
|
const existing = await this.notificationsRepository.findByEventId(dto.eventId);
|
||||||
|
if (existing) return existing;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const unreadCounts = await this.getUnreadCounts(dto.recipientId);
|
const unreadCounts = await this.getUnreadCounts(dto.recipientId);
|
||||||
const unreadCount = unreadCounts.total;
|
const unreadCount = unreadCounts.total;
|
||||||
@@ -194,11 +209,13 @@ export class NotificationsService {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
recipientId: string;
|
recipientId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
eventId?: string;
|
||||||
}) {
|
}) {
|
||||||
const actorId = String(input.actorId);
|
const actorId = String(input.actorId);
|
||||||
const recipientId = String(input.recipientId);
|
const recipientId = String(input.recipientId);
|
||||||
const requestId = String(input.requestId);
|
const requestId = String(input.requestId);
|
||||||
return this.create({
|
return this.create({
|
||||||
|
eventId: input.eventId,
|
||||||
actorId,
|
actorId,
|
||||||
recipientId,
|
recipientId,
|
||||||
type: 'follow_request_approved',
|
type: 'follow_request_approved',
|
||||||
@@ -480,4 +497,9 @@ export class NotificationsService {
|
|||||||
type: { $in: NOTIFICATION_CATEGORY_TYPES[category] },
|
type: { $in: NOTIFICATION_CATEGORY_TYPES[category] },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isDuplicateKeyError(error: unknown): boolean {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error
|
||||||
|
&& (error as { code?: number }).code === 11000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,16 @@ export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
|||||||
|
|
||||||
@Schema({ timestamps: true, versionKey: false })
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
export class Notification {
|
export class Notification {
|
||||||
|
@Prop({ type: String, trim: true })
|
||||||
|
eventId?: string;
|
||||||
|
|
||||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
recipientId!: Types.ObjectId;
|
recipientId!: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||||
actorId!: Types.ObjectId;
|
actorId!: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ required: true, enum: NOTIFICATION_TYPES })
|
@Prop({ type: String, required: true, enum: NOTIFICATION_TYPES })
|
||||||
type!: NotificationType;
|
type!: NotificationType;
|
||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
@@ -69,6 +72,10 @@ NotificationSchema.index({ recipientId: 1, read: 1, type: 1, createdAt: -1 });
|
|||||||
NotificationSchema.index({ recipientId: 1, type: 1, createdAt: -1 });
|
NotificationSchema.index({ recipientId: 1, type: 1, createdAt: -1 });
|
||||||
NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 });
|
NotificationSchema.index({ recipientId: 1, resourceType: 1, createdAt: -1 });
|
||||||
NotificationSchema.index({ referenceId: 1 });
|
NotificationSchema.index({ referenceId: 1 });
|
||||||
|
NotificationSchema.index(
|
||||||
|
{ eventId: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { eventId: { $type: 'string' } } },
|
||||||
|
);
|
||||||
NotificationSchema.index(
|
NotificationSchema.index(
|
||||||
{ recipientId: 1, type: 1, referenceId: 1 },
|
{ recipientId: 1, type: 1, referenceId: 1 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { OutboxDispatcherService } from './outbox-dispatcher.service';
|
||||||
|
|
||||||
|
describe('OutboxDispatcherService', () => {
|
||||||
|
const event = (attempts = 0) => ({
|
||||||
|
_id: 'mongo-id', eventId: 'follow.request.approved:request-1',
|
||||||
|
eventType: 'follow_request_approved_notification', status: 'processing', attempts,
|
||||||
|
createdAt: new Date('2026-08-03T12:00:00.000Z'),
|
||||||
|
payload: { actorId: 'actor', recipientId: 'recipient', requestId: 'request-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(values: Record<string, string> = {}) {
|
||||||
|
const model = {
|
||||||
|
findOneAndUpdate: jest.fn(), updateMany: jest.fn(), updateOne: jest.fn(),
|
||||||
|
};
|
||||||
|
model.updateMany.mockReturnValue({ exec: jest.fn().mockResolvedValue({ modifiedCount: 0 }) });
|
||||||
|
model.updateOne.mockReturnValue({ exec: jest.fn().mockResolvedValue({ modifiedCount: 1 }) });
|
||||||
|
const config = { get: jest.fn((key: string, fallback?: string) => values[key] ?? fallback) };
|
||||||
|
const client = { publishFollowRequestApproved: jest.fn() };
|
||||||
|
const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||||
|
const connection = { db: { admin: () => ({ command: jest.fn().mockResolvedValue({ setName: 'rs0' }) }) } };
|
||||||
|
const service = new OutboxDispatcherService(model as never, connection as never, config as never, client as never, logger as never);
|
||||||
|
return { service, model, client, logger };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not start or claim when either phase-1 flag is false', async () => {
|
||||||
|
const ctx = setup({ NOTIFICATION_SERVICE_ENABLED: 'false', OUTBOX_DISPATCHER_ENABLED: 'true' });
|
||||||
|
await ctx.service.onApplicationBootstrap();
|
||||||
|
await ctx.service.poll();
|
||||||
|
expect(ctx.model.findOneAndUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.client.publishFollowRequestApproved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an atomic claim and recovers expired processing leases', async () => {
|
||||||
|
const ctx = setup({ NOTIFICATION_SERVICE_ENABLED: 'true', OUTBOX_DISPATCHER_ENABLED: 'true', OUTBOX_LEASE_TIMEOUT_MS: '1000' });
|
||||||
|
ctx.model.findOneAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(event()) });
|
||||||
|
await ctx.service.claimNext(new Date('2026-08-03T12:00:05.000Z'));
|
||||||
|
expect(ctx.model.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: { $in: ['pending', 'failed'] }, $and: expect.any(Array) }),
|
||||||
|
{ $set: { status: 'processing', lockedAt: expect.any(Date), lockedBy: expect.any(String) } },
|
||||||
|
{ new: true, sort: { createdAt: 1 } },
|
||||||
|
);
|
||||||
|
await ctx.service.recoverExpiredLeases(new Date('2026-08-03T12:00:05.000Z'));
|
||||||
|
expect(ctx.model.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'processing', lockedAt: { $lte: new Date('2026-08-03T12:00:04.000Z') } }),
|
||||||
|
expect.objectContaining({ $set: expect.objectContaining({ status: 'failed', lockedAt: null, lockedBy: null }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies bounded exponential backoff and moves exhausted events to dead', async () => {
|
||||||
|
const ctx = setup({
|
||||||
|
NOTIFICATION_SERVICE_ENABLED: 'true', OUTBOX_DISPATCHER_ENABLED: 'true', OUTBOX_BATCH_SIZE: '1',
|
||||||
|
OUTBOX_MAX_ATTEMPTS: '2', OUTBOX_RETRY_BASE_MS: '100', OUTBOX_RETRY_MAX_MS: '250',
|
||||||
|
});
|
||||||
|
expect(ctx.service.calculateBackoff(1)).toBe(100);
|
||||||
|
expect(ctx.service.calculateBackoff(4)).toBe(250);
|
||||||
|
ctx.model.findOneAndUpdate.mockReturnValue({ exec: jest.fn().mockResolvedValue(event(1)) });
|
||||||
|
ctx.client.publishFollowRequestApproved.mockRejectedValue(new Error('amqp://secret:password@broker failed'));
|
||||||
|
await ctx.service.poll();
|
||||||
|
expect(ctx.model.updateOne).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'processing' }),
|
||||||
|
expect.objectContaining({ $set: expect.objectContaining({ status: 'dead', attempts: 2, lockedAt: null, lockedBy: null }) }),
|
||||||
|
);
|
||||||
|
expect(ctx.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ error: expect.not.stringContaining('secret:password') }), 'OutboxDispatcherService');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries after a lost RPC response and marks processed only after a later response', async () => {
|
||||||
|
const ctx = setup({ NOTIFICATION_SERVICE_ENABLED: 'true', OUTBOX_DISPATCHER_ENABLED: 'true', OUTBOX_BATCH_SIZE: '1' });
|
||||||
|
ctx.model.findOneAndUpdate
|
||||||
|
.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(event(0)) })
|
||||||
|
.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(event(1)) });
|
||||||
|
ctx.client.publishFollowRequestApproved
|
||||||
|
.mockRejectedValueOnce(new Error('RPC response timeout'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
await ctx.service.poll();
|
||||||
|
await ctx.service.poll();
|
||||||
|
expect(ctx.client.publishFollowRequestApproved).toHaveBeenCalledTimes(2);
|
||||||
|
expect(ctx.model.updateOne.mock.calls[0][1].$set.status).toBe('failed');
|
||||||
|
expect(ctx.model.updateOne.mock.calls[1][1].$set.status).toBe('processed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { FOLLOW_REQUEST_APPROVED_EVENT } from '../../../libs/events/src';
|
||||||
|
import { Injectable, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Connection, Model } from 'mongoose';
|
||||||
|
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
|
||||||
|
import { NotificationServiceClient } from '../notifications/notification-service.client';
|
||||||
|
import { OutboxEvent, OutboxEventDocument } from './schemas/outbox-event.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||||
|
private timer?: NodeJS.Timeout;
|
||||||
|
private running = false;
|
||||||
|
private stopped = false;
|
||||||
|
private readonly instanceId = `${process.env.HOSTNAME ?? 'gateway'}-${process.pid}-${randomUUID()}`;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(OutboxEvent.name) private readonly model: Model<OutboxEventDocument>,
|
||||||
|
@InjectConnection() private readonly connection: Connection,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly client: NotificationServiceClient,
|
||||||
|
private readonly logger: AppLoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
if (!this.enabled()) return;
|
||||||
|
await this.assertTransactionCapableMongo();
|
||||||
|
this.schedule(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
while (this.running) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll(): Promise<void> {
|
||||||
|
if (!this.enabled() || this.running || this.stopped) return;
|
||||||
|
this.running = true;
|
||||||
|
let claimed = 0;
|
||||||
|
let processed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
await this.recoverExpiredLeases();
|
||||||
|
for (let index = 0; index < this.number('OUTBOX_BATCH_SIZE', 25); index += 1) {
|
||||||
|
const event = await this.claimNext();
|
||||||
|
if (!event) break;
|
||||||
|
claimed += 1;
|
||||||
|
try {
|
||||||
|
await this.dispatch(event);
|
||||||
|
await this.markProcessed(event);
|
||||||
|
processed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
await this.markFailed(event, error);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (claimed > 0) this.logger.log({ message: 'outbox poll completed', instanceId: this.instanceId, claimed, processed, failed }, OutboxDispatcherService.name);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimNext(now = new Date()): Promise<OutboxEventDocument | null> {
|
||||||
|
const staleBefore = new Date(now.getTime() - this.number('OUTBOX_LEASE_TIMEOUT_MS', 60_000));
|
||||||
|
return this.model.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
eventType: 'follow_request_approved_notification',
|
||||||
|
status: { $in: ['pending', 'failed'] },
|
||||||
|
$and: [
|
||||||
|
{ $or: [{ nextRetryAt: { $lte: now } }, { nextRetryAt: { $exists: false } }] },
|
||||||
|
{ $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }, { lockedAt: { $lte: staleBefore } }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ $set: { status: 'processing', lockedAt: now, lockedBy: this.instanceId } },
|
||||||
|
{ new: true, sort: { createdAt: 1 } },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async recoverExpiredLeases(now = new Date()): Promise<number> {
|
||||||
|
const staleBefore = new Date(now.getTime() - this.number('OUTBOX_LEASE_TIMEOUT_MS', 60_000));
|
||||||
|
const result = await this.model.updateMany(
|
||||||
|
{ eventType: 'follow_request_approved_notification', status: 'processing', lockedAt: { $lte: staleBefore } },
|
||||||
|
{ $set: { status: 'failed', nextRetryAt: now, lastError: 'processing lease expired', lockedAt: null, lockedBy: null } },
|
||||||
|
).exec();
|
||||||
|
return result.modifiedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateBackoff(attempts: number): number {
|
||||||
|
return Math.min(
|
||||||
|
this.number('OUTBOX_RETRY_MAX_MS', 60_000),
|
||||||
|
this.number('OUTBOX_RETRY_BASE_MS', 1_000) * (2 ** Math.max(0, attempts - 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(event: OutboxEventDocument): Promise<void> {
|
||||||
|
await this.client.publishFollowRequestApproved({
|
||||||
|
eventId: event.eventId || `follow.request.approved:${String(event.payload.requestId ?? '')}`,
|
||||||
|
eventType: FOLLOW_REQUEST_APPROVED_EVENT,
|
||||||
|
occurredAt: event.createdAt.toISOString(),
|
||||||
|
payload: {
|
||||||
|
requestId: String(event.payload.requestId ?? ''),
|
||||||
|
actorId: String(event.payload.actorId ?? ''),
|
||||||
|
recipientId: String(event.payload.recipientId ?? ''),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markProcessed(event: OutboxEventDocument): Promise<void> {
|
||||||
|
await this.model.updateOne(
|
||||||
|
{ _id: event._id, status: 'processing', lockedBy: this.instanceId },
|
||||||
|
{ $set: { status: 'processed', processedAt: new Date(), lastError: '', lockedAt: null, lockedBy: null } },
|
||||||
|
).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markFailed(event: OutboxEventDocument, error: unknown): Promise<void> {
|
||||||
|
const attempts = event.attempts + 1;
|
||||||
|
const dead = attempts >= this.number('OUTBOX_MAX_ATTEMPTS', 8);
|
||||||
|
const message = this.safeError(error);
|
||||||
|
await this.model.updateOne(
|
||||||
|
{ _id: event._id, status: 'processing', lockedBy: this.instanceId },
|
||||||
|
{ $set: {
|
||||||
|
status: dead ? 'dead' : 'failed', attempts, lastError: message,
|
||||||
|
nextRetryAt: new Date(Date.now() + this.calculateBackoff(attempts)),
|
||||||
|
deadAt: dead ? new Date() : null, lockedAt: null, lockedBy: null,
|
||||||
|
} },
|
||||||
|
).exec();
|
||||||
|
this.logger.warn({ eventId: event.eventId, attempts, status: dead ? 'dead' : 'failed', error: message }, OutboxDispatcherService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule(delay: number): void {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.timer = setTimeout(async () => {
|
||||||
|
try { await this.poll(); }
|
||||||
|
catch (error) { this.logger.error({ error: this.safeError(error) }, OutboxDispatcherService.name); }
|
||||||
|
finally { this.schedule(this.number('OUTBOX_POLL_INTERVAL_MS', 1_000)); }
|
||||||
|
}, delay);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private enabled(): boolean {
|
||||||
|
return String(this.config.get('OUTBOX_DISPATCHER_ENABLED') ?? 'false').toLowerCase() === 'true'
|
||||||
|
&& String(this.config.get('NOTIFICATION_SERVICE_ENABLED') ?? 'false').toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertTransactionCapableMongo(): Promise<void> {
|
||||||
|
const hello = await this.connection.db?.admin().command({ hello: 1 }) as { setName?: string; msg?: string };
|
||||||
|
if (!hello?.setName && hello?.msg !== 'isdbgrid') throw new Error('Outbox dispatcher requires a transaction-capable MongoDB replica set or mongos');
|
||||||
|
}
|
||||||
|
|
||||||
|
private number(key: string, fallback: number): number {
|
||||||
|
return Number(this.config.get<string>(key) ?? fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeError(error: unknown): string {
|
||||||
|
return (error instanceof Error ? error.message : String(error)).replace(/amqps?:\/\/[^@\s]+@/gi, 'amqp://[redacted]@').slice(0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { MongooseModule } from '@nestjs/mongoose';
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { OutboxService } from './outbox.service';
|
import { OutboxService } from './outbox.service';
|
||||||
|
import { OutboxDispatcherService } from './outbox-dispatcher.service';
|
||||||
import { OutboxEvent, OutboxEventSchema } from './schemas/outbox-event.schema';
|
import { OutboxEvent, OutboxEventSchema } from './schemas/outbox-event.schema';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -9,7 +10,7 @@ import { OutboxEvent, OutboxEventSchema } from './schemas/outbox-event.schema';
|
|||||||
MongooseModule.forFeature([{ name: OutboxEvent.name, schema: OutboxEventSchema }]),
|
MongooseModule.forFeature([{ name: OutboxEvent.name, schema: OutboxEventSchema }]),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [OutboxService],
|
providers: [OutboxService, OutboxDispatcherService],
|
||||||
exports: [OutboxService],
|
exports: [OutboxService, OutboxDispatcherService],
|
||||||
})
|
})
|
||||||
export class OutboxModule {}
|
export class OutboxModule {}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
|||||||
import { OutboxService } from './outbox.service';
|
import { OutboxService } from './outbox.service';
|
||||||
|
|
||||||
describe('OutboxService', () => {
|
describe('OutboxService', () => {
|
||||||
const model = { create: jest.fn(), findById: jest.fn() };
|
const model = { create: jest.fn(), findOne: jest.fn(), updateOne: jest.fn() };
|
||||||
const notifications = {
|
const notifications = {
|
||||||
createFollowNotification: jest.fn(),
|
createFollowNotification: jest.fn(),
|
||||||
createFollowRequestApprovedNotification: jest.fn(),
|
createFollowRequestApprovedNotification: jest.fn(),
|
||||||
@@ -18,6 +18,10 @@ describe('OutboxService', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.NOTIFICATION_SERVICE_ENABLED;
|
||||||
|
});
|
||||||
|
|
||||||
it('registers its processor and enqueues persisted follow events', async () => {
|
it('registers its processor and enqueues persisted follow events', async () => {
|
||||||
service.onModuleInit();
|
service.onModuleInit();
|
||||||
expect(queue.registerProcessor).toHaveBeenCalledWith('process_outbox_event', expect.any(Function));
|
expect(queue.registerProcessor).toHaveBeenCalledWith('process_outbox_event', expect.any(Function));
|
||||||
@@ -28,47 +32,63 @@ describe('OutboxService', () => {
|
|||||||
await processor({});
|
await processor({});
|
||||||
expect(processSpy).toHaveBeenLastCalledWith('');
|
expect(processSpy).toHaveBeenLastCalledWith('');
|
||||||
|
|
||||||
model.create.mockResolvedValue({ id: 'event-1' });
|
model.create.mockResolvedValue({ eventId: 'event-1' });
|
||||||
await service.enqueueFollowNotification('actor', 'recipient');
|
await service.enqueueFollowNotification('actor', 'recipient');
|
||||||
expect(model.create).toHaveBeenCalledWith({
|
expect(model.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
eventId: expect.any(String),
|
||||||
eventType: 'follow_notification',
|
eventType: 'follow_notification',
|
||||||
payload: { actorId: 'actor', recipientId: 'recipient', referenceId: '' },
|
payload: { actorId: 'actor', recipientId: 'recipient', referenceId: '' },
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
});
|
}));
|
||||||
expect(queue.enqueue).toHaveBeenCalledWith('process_outbox_event', { eventId: 'event-1' });
|
expect(queue.enqueue).toHaveBeenCalledWith('process_outbox_event', { eventId: 'event-1' });
|
||||||
|
|
||||||
model.create.mockResolvedValue({ id: 'event-2' });
|
model.updateOne.mockReturnValue({ exec: jest.fn().mockResolvedValue({ upsertedCount: 1 }) });
|
||||||
await service.enqueueFollowRequestApprovedNotification('actor', 'recipient', 'request');
|
await service.enqueueFollowRequestApprovedNotification('actor', 'recipient', 'request');
|
||||||
expect(model.create).toHaveBeenCalledWith({
|
expect(model.updateOne).toHaveBeenCalledWith(
|
||||||
eventType: 'follow_request_approved_notification',
|
{ eventId: 'follow.request.approved:request' },
|
||||||
payload: { actorId: 'actor', recipientId: 'recipient', requestId: 'request' },
|
expect.objectContaining({ $setOnInsert: expect.objectContaining({ eventType: 'follow_request_approved_notification' }) }),
|
||||||
status: 'pending',
|
{ upsert: true, session: undefined },
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('processes follow request approval notifications once', async () => {
|
it('processes follow request approval notifications once', async () => {
|
||||||
const event = {
|
const event = {
|
||||||
id: 'e-approved',
|
id: 'e-approved',
|
||||||
|
eventId: 'e-approved',
|
||||||
eventType: 'follow_request_approved_notification',
|
eventType: 'follow_request_approved_notification',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
payload: { actorId: 'owner', recipientId: 'requester', requestId: 'request' },
|
payload: { actorId: 'owner', recipientId: 'requester', requestId: 'request' },
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
};
|
};
|
||||||
model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||||
await service.processEvent(event.id);
|
await service.processEvent(event.id);
|
||||||
expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledWith({
|
expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledWith({
|
||||||
actorId: 'owner',
|
actorId: 'owner',
|
||||||
recipientId: 'requester',
|
recipientId: 'requester',
|
||||||
requestId: 'request',
|
requestId: 'request',
|
||||||
|
eventId: 'e-approved',
|
||||||
});
|
});
|
||||||
expect(event.status).toBe('processed');
|
expect(event.status).toBe('processed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never lets a legacy Gateway worker create approval notifications in remote mode', async () => {
|
||||||
|
process.env.NOTIFICATION_SERVICE_ENABLED = 'true';
|
||||||
|
const event = {
|
||||||
|
id: 'legacy-job', eventId: 'approval-event', eventType: 'follow_request_approved_notification',
|
||||||
|
status: 'pending', attempts: 0, payload: {}, save: jest.fn(),
|
||||||
|
};
|
||||||
|
model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||||
|
await service.processEvent('approval-event');
|
||||||
|
expect(notifications.createFollowRequestApprovedNotification).not.toHaveBeenCalled();
|
||||||
|
expect(event.status).toBe('pending');
|
||||||
|
expect(event.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores missing and already processed events', async () => {
|
it('ignores missing and already processed events', async () => {
|
||||||
model.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) });
|
model.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) });
|
||||||
await service.processEvent('missing');
|
await service.processEvent('missing');
|
||||||
model.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'processed' }) });
|
model.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'processed' }) });
|
||||||
await service.processEvent('done');
|
await service.processEvent('done');
|
||||||
expect(notifications.createFollowNotification).not.toHaveBeenCalled();
|
expect(notifications.createFollowNotification).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -78,7 +98,7 @@ describe('OutboxService', () => {
|
|||||||
id: 'e1', eventType: 'follow_notification', status: 'pending', attempts: 0,
|
id: 'e1', eventType: 'follow_notification', status: 'pending', attempts: 0,
|
||||||
payload: { actorId: 'a', recipientId: 'r', referenceId: 'post' }, save: jest.fn(),
|
payload: { actorId: 'a', recipientId: 'r', referenceId: 'post' }, save: jest.fn(),
|
||||||
};
|
};
|
||||||
model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||||
await service.processEvent('e1');
|
await service.processEvent('e1');
|
||||||
expect(notifications.createFollowNotification).toHaveBeenCalledWith('a', 'r', 'post');
|
expect(notifications.createFollowNotification).toHaveBeenCalledWith('a', 'r', 'post');
|
||||||
expect(event).toEqual(expect.objectContaining({ status: 'processed', attempts: 1, lastError: '', processedAt: expect.any(Date) }));
|
expect(event).toEqual(expect.objectContaining({ status: 'processed', attempts: 1, lastError: '', processedAt: expect.any(Date) }));
|
||||||
@@ -90,9 +110,9 @@ describe('OutboxService', () => {
|
|||||||
id: 'e2', eventType: 'follow_notification', status: 'pending', attempts: 2,
|
id: 'e2', eventType: 'follow_notification', status: 'pending', attempts: 2,
|
||||||
payload: {}, save: jest.fn(),
|
payload: {}, save: jest.fn(),
|
||||||
};
|
};
|
||||||
model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||||
(notifications.createFollowNotification as jest.Mock).mockRejectedValue('network');
|
(notifications.createFollowNotification as jest.Mock).mockRejectedValue('network');
|
||||||
await service.processEvent('e2');
|
await expect(service.processEvent('e2')).rejects.toBe('network');
|
||||||
expect(event).toEqual(expect.objectContaining({ status: 'failed', lastError: 'unknown outbox error', attempts: 3 }));
|
expect(event).toEqual(expect.objectContaining({ status: 'failed', lastError: 'unknown outbox error', attempts: 3 }));
|
||||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e2' }), 'OutboxService');
|
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e2' }), 'OutboxService');
|
||||||
expect(event.save).toHaveBeenCalled();
|
expect(event.save).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { InjectModel } from '@nestjs/mongoose';
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
import { Model } from 'mongoose';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ClientSession, Model } from 'mongoose';
|
||||||
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
|
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
|
||||||
import { AppQueueService } from '../../infrastructure/queue/app-queue.service';
|
import { AppQueueService } from '../../infrastructure/queue/app-queue.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
@@ -25,16 +26,12 @@ export class OutboxService implements OnModuleInit {
|
|||||||
|
|
||||||
async enqueueFollowNotification(actorId: string, recipientId: string, referenceId?: string): Promise<void> {
|
async enqueueFollowNotification(actorId: string, recipientId: string, referenceId?: string): Promise<void> {
|
||||||
const event = await this.outboxEventModel.create({
|
const event = await this.outboxEventModel.create({
|
||||||
|
eventId: randomUUID(),
|
||||||
eventType: 'follow_notification',
|
eventType: 'follow_notification',
|
||||||
payload: {
|
payload: { actorId, recipientId, referenceId: referenceId ?? '' },
|
||||||
actorId,
|
|
||||||
recipientId,
|
|
||||||
referenceId: referenceId ?? '',
|
|
||||||
},
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
});
|
});
|
||||||
|
await this.enqueuePersistedEvent(event.eventId);
|
||||||
await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId: event.id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueFollowRequestApprovedNotification(
|
async enqueueFollowRequestApprovedNotification(
|
||||||
@@ -42,24 +39,59 @@ export class OutboxService implements OnModuleInit {
|
|||||||
recipientId: string,
|
recipientId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const event = await this.outboxEventModel.create({
|
const eventId = await this.createFollowRequestApprovedEvent(actorId, recipientId, requestId);
|
||||||
eventType: 'follow_request_approved_notification',
|
await this.enqueuePersistedEvent(eventId);
|
||||||
payload: {
|
}
|
||||||
actorId: String(actorId),
|
|
||||||
recipientId: String(recipientId),
|
|
||||||
requestId: String(requestId),
|
|
||||||
},
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId: event.id });
|
async createFollowRequestApprovedEvent(
|
||||||
|
actorId: string,
|
||||||
|
recipientId: string,
|
||||||
|
requestId: string,
|
||||||
|
session?: ClientSession,
|
||||||
|
): Promise<string> {
|
||||||
|
const eventId = `follow.request.approved:${String(requestId)}`;
|
||||||
|
await this.outboxEventModel.updateOne(
|
||||||
|
{ eventId },
|
||||||
|
{ $setOnInsert: {
|
||||||
|
eventId,
|
||||||
|
eventType: 'follow_request_approved_notification',
|
||||||
|
payload: { actorId: String(actorId), recipientId: String(recipientId), requestId: String(requestId) },
|
||||||
|
correlationId: eventId,
|
||||||
|
requestId: String(requestId),
|
||||||
|
status: 'pending',
|
||||||
|
attempts: 0,
|
||||||
|
nextRetryAt: new Date(),
|
||||||
|
} },
|
||||||
|
{ upsert: true, session },
|
||||||
|
).exec();
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueuePersistedEvent(eventId: string): Promise<void> {
|
||||||
|
await this.queueService.enqueue(OutboxService.PROCESS_EVENT_JOB, { eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByStatuses(statuses: OutboxEvent['status'][], limit = 100): Promise<OutboxEventDocument[]> {
|
||||||
|
return this.outboxEventModel.find({ status: { $in: statuses } }).sort({ createdAt: 1 }).limit(limit).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryEvent(eventId: string): Promise<boolean> {
|
||||||
|
const result = await this.outboxEventModel.updateOne(
|
||||||
|
{ eventId, status: { $in: ['failed', 'dead'] } },
|
||||||
|
{ $set: { status: 'pending', nextRetryAt: new Date(), lastError: '', lockedAt: null, lockedBy: null }, $unset: { deadAt: 1 } },
|
||||||
|
).exec();
|
||||||
|
return result.modifiedCount === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processEvent(eventId: string): Promise<void> {
|
async processEvent(eventId: string): Promise<void> {
|
||||||
const event = await this.outboxEventModel.findById(eventId).exec();
|
const event = await this.outboxEventModel.findOne({
|
||||||
if (!event || event.status === 'processed') {
|
$or: [{ eventId }, ...(eventId.match(/^[a-f\d]{24}$/i) ? [{ _id: eventId }] : [])],
|
||||||
return;
|
}).exec();
|
||||||
}
|
if (!event || event.status === 'processed') return;
|
||||||
|
// A legacy BullMQ job may still exist during cutover. The dispatcher is the sole
|
||||||
|
// owner of approval events whenever remote notification routing is enabled.
|
||||||
|
if (event.eventType === 'follow_request_approved_notification'
|
||||||
|
&& process.env.NOTIFICATION_SERVICE_ENABLED === 'true') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (event.eventType === 'follow_notification') {
|
if (event.eventType === 'follow_notification') {
|
||||||
@@ -68,29 +100,22 @@ export class OutboxService implements OnModuleInit {
|
|||||||
String(event.payload.recipientId ?? ''),
|
String(event.payload.recipientId ?? ''),
|
||||||
String(event.payload.referenceId ?? ''),
|
String(event.payload.referenceId ?? ''),
|
||||||
);
|
);
|
||||||
}
|
} else if (event.eventType === 'follow_request_approved_notification') {
|
||||||
if (event.eventType === 'follow_request_approved_notification') {
|
|
||||||
await this.notificationsService.createFollowRequestApprovedNotification({
|
await this.notificationsService.createFollowRequestApprovedNotification({
|
||||||
actorId: String(event.payload.actorId ?? ''),
|
actorId: String(event.payload.actorId ?? ''),
|
||||||
recipientId: String(event.payload.recipientId ?? ''),
|
recipientId: String(event.payload.recipientId ?? ''),
|
||||||
requestId: String(event.payload.requestId ?? ''),
|
requestId: String(event.payload.requestId ?? ''),
|
||||||
|
eventId: event.eventId || event.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
event.status = 'processed';
|
event.status = 'processed';
|
||||||
event.processedAt = new Date();
|
event.processedAt = new Date();
|
||||||
event.lastError = '';
|
event.lastError = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
event.status = 'failed';
|
event.status = 'failed';
|
||||||
event.lastError = error instanceof Error ? error.message : 'unknown outbox error';
|
event.lastError = error instanceof Error ? error.message : 'unknown outbox error';
|
||||||
this.logger.warn(
|
this.logger.warn({ eventId: event.eventId || event.id, eventType: event.eventType, error: event.lastError }, OutboxService.name);
|
||||||
{
|
throw error;
|
||||||
eventId: event.id,
|
|
||||||
eventType: event.eventType,
|
|
||||||
error: event.lastError,
|
|
||||||
},
|
|
||||||
OutboxService.name,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
event.attempts += 1;
|
event.attempts += 1;
|
||||||
await event.save();
|
await event.save();
|
||||||
|
|||||||
@@ -5,14 +5,26 @@ export type OutboxEventDocument = HydratedDocument<OutboxEvent>;
|
|||||||
|
|
||||||
@Schema({ timestamps: true, versionKey: false })
|
@Schema({ timestamps: true, versionKey: false })
|
||||||
export class OutboxEvent {
|
export class OutboxEvent {
|
||||||
|
declare createdAt: Date;
|
||||||
|
|
||||||
|
declare updatedAt: Date;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
eventId!: string;
|
||||||
|
|
||||||
@Prop({ required: true, index: true })
|
@Prop({ required: true, index: true })
|
||||||
eventType!: string;
|
eventType!: string;
|
||||||
|
|
||||||
@Prop({ required: true, type: Object })
|
@Prop({ required: true, type: Object })
|
||||||
payload!: Record<string, unknown>;
|
payload!: Record<string, unknown>;
|
||||||
|
|
||||||
@Prop({ required: true, default: 'pending', enum: ['pending', 'processed', 'failed'], index: true })
|
@Prop({
|
||||||
status!: 'pending' | 'processed' | 'failed';
|
required: true,
|
||||||
|
default: 'pending',
|
||||||
|
enum: ['pending', 'processing', 'processed', 'failed', 'dead'],
|
||||||
|
index: true,
|
||||||
|
})
|
||||||
|
status!: 'pending' | 'processing' | 'processed' | 'failed' | 'dead';
|
||||||
|
|
||||||
@Prop({ required: true, default: 0, min: 0 })
|
@Prop({ required: true, default: 0, min: 0 })
|
||||||
attempts!: number;
|
attempts!: number;
|
||||||
@@ -20,9 +32,31 @@ export class OutboxEvent {
|
|||||||
@Prop({ default: '' })
|
@Prop({ default: '' })
|
||||||
lastError!: string;
|
lastError!: string;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: Date.now, index: true })
|
||||||
|
nextRetryAt!: Date;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: null, index: true })
|
||||||
|
lockedAt?: Date | null;
|
||||||
|
|
||||||
|
@Prop({ type: String, default: null })
|
||||||
|
lockedBy?: string | null;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
correlationId!: string;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
requestId!: string;
|
||||||
|
|
||||||
@Prop({ type: Date, default: null, index: true })
|
@Prop({ type: Date, default: null, index: true })
|
||||||
processedAt?: Date | null;
|
processedAt?: Date | null;
|
||||||
|
|
||||||
|
@Prop({ type: Date, default: null })
|
||||||
|
deadAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutboxEventSchema = SchemaFactory.createForClass(OutboxEvent);
|
export const OutboxEventSchema = SchemaFactory.createForClass(OutboxEvent);
|
||||||
OutboxEventSchema.index({ status: 1, createdAt: 1 });
|
OutboxEventSchema.index(
|
||||||
|
{ eventId: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { eventId: { $type: 'string' } } },
|
||||||
|
);
|
||||||
|
OutboxEventSchema.index({ eventType: 1, status: 1, nextRetryAt: 1, lockedAt: 1, createdAt: 1 });
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ export class UsersRepository {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFollowersCount(userId: string, followersCount: number): Promise<void> {
|
async setFollowersCount(userId: string, followersCount: number, session?: ClientSession): Promise<void> {
|
||||||
await this.userModel.findByIdAndUpdate(userId, { followersCount }, { new: false }).exec();
|
await this.userModel.findByIdAndUpdate(userId, { followersCount }, { new: false, session }).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFollowingCount(userId: string, followingCount: number): Promise<void> {
|
async setFollowingCount(userId: string, followingCount: number, session?: ClientSession): Promise<void> {
|
||||||
await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false }).exec();
|
await this.userModel.findByIdAndUpdate(userId, { followingCount }, { new: false, session }).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPresence(userId: string, isOnline: boolean, lastSeenAt: Date): Promise<void> {
|
async setPresence(userId: string, isOnline: boolean, lastSeenAt: Date): Promise<void> {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@app/contracts": ["libs/contracts/src"],
|
||||||
|
"@app/events": ["libs/events/src"],
|
||||||
|
"@app/common": ["libs/common/src"]
|
||||||
|
},
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "./dist/legacy",
|
||||||
|
"declaration": false,
|
||||||
|
"incremental": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "libs/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "test", "oudelaa_dashboard", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user