Skip to content

Commit d39ac69

Browse files
committed
feat(nestjs-notifications): register BullMQ workers in forRootAsync path
The async module path created Queue instances but never registered processor workers, leaving enqueued jobs unprocessed. This adds manual Worker factories that bypass @processor decorator discovery (which would crash before async options resolve) and a WorkerCleanupService for graceful shutdown.
1 parent 6fe99ce commit d39ac69

6 files changed

Lines changed: 212 additions & 1 deletion

File tree

packages/nestjs-notifications/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,18 @@ export { PreferenceController } from './preferences/preference.controller';
3434
// Templates
3535
export { TemplateService } from './templates/template.service';
3636

37+
// Worker cleanup
38+
export { WorkerCleanupService } from './worker-cleanup.service';
39+
3740
// Interfaces and constants
3841
export {
3942
NOTIFICATION_MODULE_OPTIONS,
4043
EMAIL_PROVIDER,
4144
SMS_PROVIDER,
4245
PUSH_PROVIDER,
46+
EMAIL_WORKER,
47+
SMS_WORKER,
48+
PUSH_WORKER,
4349
} from './interfaces';
4450

4551
// Auth integration

packages/nestjs-notifications/src/interfaces/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,6 @@ export const NOTIFICATION_MODULE_OPTIONS = 'NOTIFICATION_MODULE_OPTIONS';
170170
export const EMAIL_PROVIDER = 'EMAIL_PROVIDER';
171171
export const SMS_PROVIDER = 'SMS_PROVIDER';
172172
export const PUSH_PROVIDER = 'PUSH_PROVIDER';
173+
export const EMAIL_WORKER = 'EMAIL_WORKER';
174+
export const SMS_WORKER = 'SMS_WORKER';
175+
export const PUSH_WORKER = 'PUSH_WORKER';

packages/nestjs-notifications/src/notification.module.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jest.mock('./channels/push/providers/firebase.provider', () => ({
1616
}));
1717
jest.mock('bullmq', () => ({
1818
Queue: jest.fn().mockImplementation(() => ({})),
19+
Worker: jest.fn().mockImplementation(() => ({ close: jest.fn() })),
1920
}));
2021
jest.mock('@nestjs/bullmq', () => ({
2122
BullModule: {
@@ -48,8 +49,12 @@ import {
4849
EMAIL_PROVIDER,
4950
SMS_PROVIDER,
5051
PUSH_PROVIDER,
52+
EMAIL_WORKER,
53+
SMS_WORKER,
54+
PUSH_WORKER,
5155
type NotificationModuleOptions,
5256
} from './interfaces';
57+
import { WorkerCleanupService } from './worker-cleanup.service';
5358

5459
describe('NotificationModule', () => {
5560
describe('forRoot', () => {
@@ -368,6 +373,21 @@ describe('NotificationModule', () => {
368373
expect(optionsProvider.inject).toEqual([]);
369374
});
370375

376+
it('should include worker tokens and WorkerCleanupService in providers', () => {
377+
const result = NotificationModule.forRootAsync({
378+
useFactory: () => ({
379+
channels: {},
380+
}),
381+
inject: [],
382+
});
383+
384+
const providerTokens = extractProviderTokens(result.providers as any[]);
385+
expect(providerTokens).toContain(EMAIL_WORKER);
386+
expect(providerTokens).toContain(SMS_WORKER);
387+
expect(providerTokens).toContain(PUSH_WORKER);
388+
expect(providerTokens).toContain(WorkerCleanupService);
389+
});
390+
371391
it('should export all core services', () => {
372392
const result = NotificationModule.forRootAsync({
373393
useFactory: () => ({ channels: {} }),

packages/nestjs-notifications/src/notification.module.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
22
import { BullModule, getQueueToken } from '@nestjs/bullmq';
3-
import { Queue } from 'bullmq';
3+
import { Queue, Worker, Job } from 'bullmq';
44
import {
55
NOTIFICATION_MODULE_OPTIONS,
66
EMAIL_PROVIDER,
77
SMS_PROVIDER,
88
PUSH_PROVIDER,
9+
EMAIL_WORKER,
10+
SMS_WORKER,
11+
PUSH_WORKER,
912
type NotificationModuleOptions,
1013
type NotificationModuleAsyncOptions,
14+
type EmailProvider,
15+
type SmsProvider,
16+
type PushProvider,
1117
} from './interfaces';
1218
import { NotificationService } from './notification.service';
1319
import { InAppService } from './channels/in-app/in-app.service';
@@ -24,6 +30,7 @@ import { SmtpEmailProvider } from './channels/email/providers/smtp.provider';
2430
import { SendGridEmailProvider } from './channels/email/providers/sendgrid.provider';
2531
import { TwilioSmsProvider } from './channels/sms/providers/twilio.provider';
2632
import { FirebasePushProvider } from './channels/push/providers/firebase.provider';
33+
import { WorkerCleanupService } from './worker-cleanup.service';
2734

2835
@Module({})
2936
export class NotificationModule {
@@ -256,6 +263,84 @@ export class NotificationModule {
256263
},
257264
inject: [NOTIFICATION_MODULE_OPTIONS],
258265
},
266+
// Worker factories — create BullMQ Workers manually to bypass @Processor
267+
// decorator discovery which would start workers before async options resolve.
268+
{
269+
provide: EMAIL_WORKER,
270+
useFactory: (
271+
options: NotificationModuleOptions,
272+
emailProvider: EmailProvider | null,
273+
prisma: any,
274+
) => {
275+
if (!options.channels.email?.enabled || !options.queue?.redis || !emailProvider) {
276+
return null;
277+
}
278+
const processor = new EmailProcessor(emailProvider, prisma);
279+
return new Worker(
280+
'notifications-email',
281+
async (job: Job) => processor.process(job),
282+
{
283+
connection: {
284+
host: options.queue.redis.host,
285+
port: options.queue.redis.port ?? 6379,
286+
password: options.queue.redis.password,
287+
},
288+
},
289+
);
290+
},
291+
inject: [NOTIFICATION_MODULE_OPTIONS, EMAIL_PROVIDER, 'PRISMA_SERVICE'],
292+
},
293+
{
294+
provide: SMS_WORKER,
295+
useFactory: (
296+
options: NotificationModuleOptions,
297+
smsProvider: SmsProvider | null,
298+
prisma: any,
299+
) => {
300+
if (!options.channels.sms?.enabled || !options.queue?.redis || !smsProvider) {
301+
return null;
302+
}
303+
const processor = new SmsProcessor(smsProvider, prisma);
304+
return new Worker(
305+
'notifications-sms',
306+
async (job: Job) => processor.process(job),
307+
{
308+
connection: {
309+
host: options.queue.redis.host,
310+
port: options.queue.redis.port ?? 6379,
311+
password: options.queue.redis.password,
312+
},
313+
},
314+
);
315+
},
316+
inject: [NOTIFICATION_MODULE_OPTIONS, SMS_PROVIDER, 'PRISMA_SERVICE'],
317+
},
318+
{
319+
provide: PUSH_WORKER,
320+
useFactory: (
321+
options: NotificationModuleOptions,
322+
pushProvider: PushProvider | null,
323+
prisma: any,
324+
) => {
325+
if (!options.channels.push?.enabled || !options.queue?.redis || !pushProvider) {
326+
return null;
327+
}
328+
const processor = new PushProcessor(pushProvider, prisma);
329+
return new Worker(
330+
'notifications-push',
331+
async (job: Job) => processor.process(job),
332+
{
333+
connection: {
334+
host: options.queue.redis.host,
335+
port: options.queue.redis.port ?? 6379,
336+
password: options.queue.redis.password,
337+
},
338+
},
339+
);
340+
},
341+
inject: [NOTIFICATION_MODULE_OPTIONS, PUSH_PROVIDER, 'PRISMA_SERVICE'],
342+
},
343+
WorkerCleanupService,
259344
];
260345

261346
const controllers: Type[] = [InAppController, DeviceTokenController, PreferenceController];
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { WorkerCleanupService } from './worker-cleanup.service';
2+
3+
describe('WorkerCleanupService', () => {
4+
function createMockWorker() {
5+
return { close: jest.fn().mockResolvedValue(undefined) };
6+
}
7+
8+
it('should call close on all non-null workers', async () => {
9+
const emailWorker = createMockWorker();
10+
const smsWorker = createMockWorker();
11+
const pushWorker = createMockWorker();
12+
13+
const service = new WorkerCleanupService(
14+
emailWorker as any,
15+
smsWorker as any,
16+
pushWorker as any,
17+
);
18+
19+
await service.onModuleDestroy();
20+
21+
expect(emailWorker.close).toHaveBeenCalledTimes(1);
22+
expect(smsWorker.close).toHaveBeenCalledTimes(1);
23+
expect(pushWorker.close).toHaveBeenCalledTimes(1);
24+
});
25+
26+
it('should handle null workers gracefully', async () => {
27+
const service = new WorkerCleanupService(null, null, null);
28+
29+
await expect(service.onModuleDestroy()).resolves.not.toThrow();
30+
});
31+
32+
it('should handle mixed null and non-null workers', async () => {
33+
const smsWorker = createMockWorker();
34+
35+
const service = new WorkerCleanupService(null, smsWorker as any, null);
36+
37+
await service.onModuleDestroy();
38+
39+
expect(smsWorker.close).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it('should not block other workers if one close fails', async () => {
43+
const emailWorker = { close: jest.fn().mockRejectedValue(new Error('close failed')) };
44+
const smsWorker = createMockWorker();
45+
const pushWorker = createMockWorker();
46+
47+
const service = new WorkerCleanupService(
48+
emailWorker as any,
49+
smsWorker as any,
50+
pushWorker as any,
51+
);
52+
53+
await expect(service.onModuleDestroy()).resolves.not.toThrow();
54+
55+
expect(emailWorker.close).toHaveBeenCalledTimes(1);
56+
expect(smsWorker.close).toHaveBeenCalledTimes(1);
57+
expect(pushWorker.close).toHaveBeenCalledTimes(1);
58+
});
59+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Injectable, Optional, Inject, OnModuleDestroy, Logger } from '@nestjs/common';
2+
import type { Worker } from 'bullmq';
3+
import { EMAIL_WORKER, SMS_WORKER, PUSH_WORKER } from './interfaces';
4+
5+
@Injectable()
6+
export class WorkerCleanupService implements OnModuleDestroy {
7+
private readonly logger = new Logger(WorkerCleanupService.name);
8+
9+
constructor(
10+
@Optional() @Inject(EMAIL_WORKER) private readonly emailWorker: Worker | null,
11+
@Optional() @Inject(SMS_WORKER) private readonly smsWorker: Worker | null,
12+
@Optional() @Inject(PUSH_WORKER) private readonly pushWorker: Worker | null,
13+
) {}
14+
15+
async onModuleDestroy(): Promise<void> {
16+
const workers = [
17+
{ name: 'email', worker: this.emailWorker },
18+
{ name: 'sms', worker: this.smsWorker },
19+
{ name: 'push', worker: this.pushWorker },
20+
];
21+
22+
const results = await Promise.allSettled(
23+
workers
24+
.filter(({ worker }) => worker != null)
25+
.map(async ({ name, worker }) => {
26+
this.logger.log(`Closing ${name} worker...`);
27+
await worker!.close();
28+
this.logger.log(`${name} worker closed`);
29+
}),
30+
);
31+
32+
for (const result of results) {
33+
if (result.status === 'rejected') {
34+
this.logger.error(`Failed to close worker: ${result.reason}`);
35+
}
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)