diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index cf1d379..866d21d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -30,6 +30,7 @@ import { ValidationExceptionFilter } from './helpers/validation-filter.exception import { LocalGovernmentModule } from './modules/local-government/local-government.module'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; +import { NotificationModule } from './modules/notification/notification.module'; @Module({ imports: [ @@ -168,6 +169,7 @@ import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConne GuardModule, PhaseModule, DistrictModule, + NotificationModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/helpers/env.validator.ts b/backend/src/helpers/env.validator.ts index 4a9a5dc..5539720 100644 --- a/backend/src/helpers/env.validator.ts +++ b/backend/src/helpers/env.validator.ts @@ -117,6 +117,14 @@ class EnvVariables { @IsString() FRONTEND_URL!: string; + + @IsNumber() + @Transform(({ value }) => parseInt(value as string, 10)) + NOTIFICATION_INDIVIDUAL_THRESHOLD!: number; + + @IsNumber() + @Transform(({ value }) => parseInt(value as string, 10)) + NOTIFICATION_BULK_THRESHOLD!: number; } export function validateEnv(config: Record): EnvVariables { diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 0f375fa..d5f8dcc 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -21,7 +21,11 @@ import { import { AdminService } from './admin.service'; import { SkipThrottle } from '@nestjs/throttler'; import { StoreQueryValidator } from '../store/dto/store.dto'; -import { AssignLocationDto, UserQueryValidator } from '../user/dto/user.dto'; +import { + AssignLocationDto, + UserQueryValidator, + BulkApproveUsersDto, +} from '../user/dto/user.dto'; @Controller('admin') @UseGuards(RoleGuard) @@ -72,6 +76,19 @@ export class AdminController { return this.userService.verifyUser(userId, req.user.sub); } + @Mutation() + @HttpCode(HttpStatus.OK) + @Post('users/bulk-approve') + async bulkApproveUsers( + @Body() bulkApproveUsersDto: BulkApproveUsersDto, + @Req() req: Request & { user: { sub: string } }, + ) { + return this.userService.verifyUsersBulk( + bulkApproveUsersDto.userIds, + req.user.sub, + ); + } + @Get('users') async getUsers(@Query() query: UserQueryValidator) { return this.userService.listUsers(query); diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 93e5cc0..2ed2ba1 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -4,9 +4,10 @@ import { UserModule } from '../user/user.module'; import { MailModule } from '../mail/mail.module'; import { AuthController } from './auth.controller'; import { TokenModule } from '../token/token.module'; +import { NotificationModule } from '../notification/notification.module'; @Module({ - imports: [UserModule, TokenModule, MailModule], + imports: [UserModule, TokenModule, MailModule, NotificationModule], controllers: [AuthController], providers: [AuthService], }) diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index fc5ded3..d36f2cb 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -12,6 +12,7 @@ import { UserInterface } from '../user/types/user.interface'; import { CustomHttpException } from '~/helpers/custom.exception'; import CreateUserRecordOptions from '../user/types/create-user.type'; import { UserRole, UserStatus } from '../user/constants/user.constant'; +import { NotificationService } from '../notification/notification.service'; const mockUserService = { getUserByEmail: jest.fn(), @@ -39,6 +40,10 @@ const mockConfigService = { }), }; +const mockNotificationService = { + notifyNewUserSignup: jest.fn(), +}; + const testEmail = 'test@example.com'; const testPassword = 'password123'; const testHashedPassword = 'hashedPassword123'; @@ -173,6 +178,7 @@ describe('AuthService', () => { { provide: TokenService, useValue: mockTokenService }, { provide: MailService, useValue: mockMailService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: NotificationService, useValue: mockNotificationService }, ], }).compile(); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 1010765..8c765ce 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -20,6 +20,7 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { CustomHttpException } from '~/helpers/custom.exception'; import CreateUserRecordOptions from '../user/types/create-user.type'; import { UserRole, UserStatus } from '../user/constants/user.constant'; +import { NotificationService } from '../notification/notification.service'; @Injectable() export class AuthService { @@ -28,6 +29,7 @@ export class AuthService { private readonly mailService: MailService, private readonly tokenService: TokenService, private readonly configService: ConfigService, + private readonly notificationService: NotificationService, ) {} private readonly logger = new Logger('AuthService'); @@ -117,6 +119,16 @@ export class AuthService { ); }); + if (createdUser.status === UserStatus.UNVERIFIED) { + void this.notificationService + .notifyNewUserSignup(createdUser.id) + .catch(() => { + this.logger.error( + `Failed to queue notification for user ${createdUser.id}`, + ); + }); + } + return { data: createdUser, message: SYS_MSG.RESOURCE_CREATED_SUCCESSFULLY('User'), @@ -191,6 +203,16 @@ export class AuthService { ); }); + if (createdUser.status === UserStatus.UNVERIFIED) { + void this.notificationService + .notifyNewUserSignup(createdUser.id) + .catch(() => { + this.logger.error( + `Failed to queue notification for user ${createdUser.id}`, + ); + }); + } + return { message: SYS_MSG.RESOURCE_CREATED_SUCCESSFULLY('User'), data: createdUser, diff --git a/backend/src/modules/mail/templates/user-approval-bulk.hbs b/backend/src/modules/mail/templates/user-approval-bulk.hbs new file mode 100644 index 0000000..3050fe7 --- /dev/null +++ b/backend/src/modules/mail/templates/user-approval-bulk.hbs @@ -0,0 +1,233 @@ + + + + + + + Bulk User Approval Required + + + + +
+ + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/backend/src/modules/mail/templates/user-approval-individual.hbs b/backend/src/modules/mail/templates/user-approval-individual.hbs new file mode 100644 index 0000000..b7f32e1 --- /dev/null +++ b/backend/src/modules/mail/templates/user-approval-individual.hbs @@ -0,0 +1,222 @@ + + + + + + + New User Signup - Approval Required + + + + +
+ + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/backend/src/modules/notification/notification.module.ts b/backend/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..767b5a8 --- /dev/null +++ b/backend/src/modules/notification/notification.module.ts @@ -0,0 +1,21 @@ +import { ConfigModule } from '@nestjs/config'; +import { BullModule } from '@nestjs/bullmq'; +import { NotificationService } from './notification.service'; +import { Module, Logger } from '@nestjs/common'; +import { NotificationProcessor } from './notification.processor'; +import { MailModule } from '../mail/mail.module'; +import { UserModule } from '../user/user.module'; + +@Module({ + imports: [ + ConfigModule, + BullModule.registerQueueAsync({ + name: 'notification', + }), + MailModule, + UserModule, + ], + providers: [NotificationService, NotificationProcessor, Logger], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/backend/src/modules/notification/notification.processor.spec.ts b/backend/src/modules/notification/notification.processor.spec.ts new file mode 100644 index 0000000..586f719 --- /dev/null +++ b/backend/src/modules/notification/notification.processor.spec.ts @@ -0,0 +1,581 @@ +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MailService } from '../mail/mail.service'; +import { UserService } from '../user/user.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationJobData } from './notification.service'; +import { NotificationProcessor } from './notification.processor'; +import { UserStatus, UserRole } from '../user/constants/user.constant'; + +const mockMailService = { + sendMail: jest.fn(), +}; + +const mockUserService = { + getUserById: jest.fn(), + getUnverifiedUsersInLast24Hours: jest.fn(), + getAdmins: jest.fn(), +}; + +const mockConfigService = { + get: jest.fn(), +}; + +describe('NotificationProcessor', () => { + let processor: NotificationProcessor; + let userService: UserService; + let module: TestingModule; + let logSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + NotificationProcessor, + { + provide: MailService, + useValue: mockMailService, + }, + { + provide: UserService, + useValue: mockUserService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + processor = module.get(NotificationProcessor); + userService = module.get(UserService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockMailService.sendMail.mockClear(); + mockUserService.getUserById.mockClear(); + mockUserService.getUnverifiedUsersInLast24Hours.mockClear(); + mockUserService.getAdmins.mockClear(); + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => {}); + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'FRONTEND_URL') return 'https://example.com'; + if (key === 'NOTIFICATION_INDIVIDUAL_THRESHOLD') return 3; + if (key === 'NOTIFICATION_BULK_THRESHOLD') return 5; + return undefined; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be defined', () => { + expect(processor).toBeDefined(); + }); + + describe('process', () => { + const mockUser = { + id: 'user-123', + email: 'test@example.com', + status: UserStatus.UNVERIFIED, + }; + + const mockAdmin1 = { + id: 'admin-1', + email: 'admin1@example.com', + role: UserRole.ADMIN, + }; + + const mockAdmin2 = { + id: 'admin-2', + email: 'admin2@example.com', + role: UserRole.SUPER_ADMIN, + }; + + it('should throw error if userId is missing', async () => { + const mockJob = { + data: {}, + id: 'job-123', + } as Job; + + await expect(processor.process(mockJob)).rejects.toThrow( + 'User ID is required', + ); + + expect(userService.getUserById).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('should skip notification if user is not found', async () => { + const mockJob = { + data: { userId: 'non-existent' }, + id: 'job-123', + } as Job; + + mockUserService.getUserById.mockResolvedValueOnce(null); + + await processor.process(mockJob); + + expect(userService.getUserById).toHaveBeenCalledWith('non-existent'); + expect(warnSpy).toHaveBeenCalledWith( + 'User non-existent not found, skipping notification', + ); + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + }); + + it('should skip notification if user is already verified', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + const verifiedUser = { ...mockUser, status: UserStatus.VERIFIED }; + mockUserService.getUserById.mockResolvedValueOnce(verifiedUser); + + await processor.process(mockJob); + + expect(userService.getUserById).toHaveBeenCalledWith('user-123'); + expect(logSpy).toHaveBeenCalledWith( + 'User user-123 is already verified, skipping notification', + ); + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + }); + + it('should skip notification if no admins found', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + mockUserService.getUserById.mockResolvedValueOnce(mockUser); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce([ + mockUser, + ]); + mockUserService.getAdmins.mockResolvedValueOnce([]); + + await processor.process(mockJob); + + expect(warnSpy).toHaveBeenCalledWith( + 'No admins found, skipping notification', + ); + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + }); + + describe('Individual notifications (count <= 3)', () => { + it('should send individual notification for first user', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + mockUserService.getUserById.mockResolvedValueOnce(mockUser); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce([ + mockUser, + ]); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + mockMailService.sendMail.mockResolvedValueOnce(true); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(1); + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + to: 'admin1@example.com', + subject: 'New User Signup - Approval Required', + template: 'user-approval-individual', + context: { + userName: 'test', + userEmail: 'test@example.com', + approvalUrl: 'https://example.com/admin/approve?userId=user-123', + }, + }); + expect(logSpy).toHaveBeenCalledWith( + 'Sent individual approval notification for user user-123 to 1 admin(s)', + ); + expect(logSpy).toHaveBeenCalledWith( + 'Notification job job-123 processed successfully.', + ); + }); + + it('should send individual notification to multiple admins', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + mockUserService.getUserById.mockResolvedValueOnce(mockUser); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce([ + mockUser, + ]); + mockUserService.getAdmins.mockResolvedValueOnce([ + mockAdmin1, + mockAdmin2, + ]); + mockMailService.sendMail.mockResolvedValue(true); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(2); + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + to: 'admin1@example.com', + subject: 'New User Signup - Approval Required', + template: 'user-approval-individual', + context: { + userName: 'test', + userEmail: 'test@example.com', + approvalUrl: 'https://example.com/admin/approve?userId=user-123', + }, + }); + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + to: 'admin2@example.com', + subject: 'New User Signup - Approval Required', + template: 'user-approval-individual', + context: { + userName: 'test', + userEmail: 'test@example.com', + approvalUrl: 'https://example.com/admin/approve?userId=user-123', + }, + }); + expect(logSpy).toHaveBeenCalledWith( + 'Sent individual approval notification for user user-123 to 2 admin(s)', + ); + }); + + it('should skip notification if user is not in unverified users list', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + mockUserService.getUserById.mockResolvedValueOnce(mockUser); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce( + [], + ); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + + await processor.process(mockJob); + + expect(warnSpy).toHaveBeenCalledWith( + 'User user-123 not found in unverified users list, skipping notification', + ); + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + }); + + it('should handle partial email failures with Promise.allSettled', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + mockUserService.getUserById.mockResolvedValueOnce(mockUser); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce([ + mockUser, + ]); + mockUserService.getAdmins.mockResolvedValueOnce([ + mockAdmin1, + mockAdmin2, + ]); + mockMailService.sendMail + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('SMTP error')); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith( + 'Sent individual approval notification for user user-123 to 1/2 admin(s). 1 failed.', + ); + }); + + it('should send individual notification for third user', async () => { + const mockJob = { + data: { userId: 'user-3' }, + id: 'job-123', + } as Job; + + const user3 = { + id: 'user-3', + email: 'user3@example.com', + status: UserStatus.UNVERIFIED, + }; + mockUserService.getUserById.mockResolvedValueOnce(user3); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce([ + mockUser, + { + id: 'user-2', + email: 'user2@example.com', + status: UserStatus.UNVERIFIED, + }, + user3, + ]); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + mockMailService.sendMail.mockResolvedValueOnce(true); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + 'Sent individual approval notification for user user-3 to 1 admin(s)', + ); + }); + }); + + describe('Bulk notifications (count > 3)', () => { + it('should skip notification when count is 4 (not a multiple of 5 after initial 3)', async () => { + const mockJob = { + data: { userId: 'user-4' }, + id: 'job-123', + } as Job; + + const user4 = { + id: 'user-4', + email: 'user4@example.com', + status: UserStatus.UNVERIFIED, + }; + mockUserService.getUserById.mockResolvedValueOnce(user4); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce([ + mockUser, + { + id: 'user-2', + email: 'user2@example.com', + status: UserStatus.UNVERIFIED, + }, + { + id: 'user-3', + email: 'user3@example.com', + status: UserStatus.UNVERIFIED, + }, + user4, + ]); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + 'Skipping notification: 4 signups (need 8 for next bulk)', + ); + }); + + it('should send bulk notification when count is 8 (3 + 5) and user is in last 5', async () => { + const mockJob = { + data: { userId: 'user-8' }, + id: 'job-123', + } as Job; + + const users = Array.from({ length: 8 }, (_, i) => ({ + id: `user-${i + 1}`, + email: `user${i + 1}@example.com`, + status: UserStatus.UNVERIFIED, + })); + + mockUserService.getUserById.mockResolvedValueOnce(users[7]); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce( + users, + ); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + mockMailService.sendMail.mockResolvedValueOnce(true); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(1); + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + to: 'admin1@example.com', + subject: 'Bulk User Approval - 5 New Signups', + template: 'user-approval-bulk', + context: { + users: [ + { email: 'user4@example.com', name: 'user4' }, + { email: 'user5@example.com', name: 'user5' }, + { email: 'user6@example.com', name: 'user6' }, + { email: 'user7@example.com', name: 'user7' }, + { email: 'user8@example.com', name: 'user8' }, + ], + userCount: 5, + approvalUrl: + 'https://example.com/admin/approve?userIds=user-4,user-5,user-6,user-7,user-8', + }, + }); + expect(logSpy).toHaveBeenCalledWith( + 'Sent bulk approval notification for 5 users to 1 admin(s)', + ); + }); + + it('should skip bulk notification when count is 8 but user is not in last 5', async () => { + const mockJob = { + data: { userId: 'user-1' }, + id: 'job-123', + } as Job; + + const users = Array.from({ length: 8 }, (_, i) => ({ + id: `user-${i + 1}`, + email: `user${i + 1}@example.com`, + status: UserStatus.UNVERIFIED, + })); + + mockUserService.getUserById.mockResolvedValueOnce(users[0]); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce( + users, + ); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + 'Skipping bulk notification: current user user-1 is not in the last 5 users', + ); + }); + + it('should send bulk notification when count is 13 (3 + 10) and user is in last 5', async () => { + const mockJob = { + data: { userId: 'user-13' }, + id: 'job-123', + } as Job; + + const users = Array.from({ length: 13 }, (_, i) => ({ + id: `user-${i + 1}`, + email: `user${i + 1}@example.com`, + status: UserStatus.UNVERIFIED, + })); + + mockUserService.getUserById.mockResolvedValueOnce(users[12]); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce( + users, + ); + mockUserService.getAdmins.mockResolvedValueOnce([mockAdmin1]); + mockMailService.sendMail.mockResolvedValueOnce(true); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(1); + const lastFiveUsers = users.slice(-5); + expect(mockMailService.sendMail).toHaveBeenCalledWith({ + to: 'admin1@example.com', + subject: 'Bulk User Approval - 5 New Signups', + template: 'user-approval-bulk', + context: { + users: lastFiveUsers.map((u) => ({ + email: u.email, + name: u.email.split('@')[0], + })), + userCount: 5, + approvalUrl: `https://example.com/admin/approve?userIds=${lastFiveUsers.map((u) => u.id).join(',')}`, + }, + }); + }); + + it('should send bulk notification to multiple admins', async () => { + const mockJob = { + data: { userId: 'user-8' }, + id: 'job-123', + } as Job; + + const users = Array.from({ length: 8 }, (_, i) => ({ + id: `user-${i + 1}`, + email: `user${i + 1}@example.com`, + status: UserStatus.UNVERIFIED, + })); + + mockUserService.getUserById.mockResolvedValueOnce(users[7]); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce( + users, + ); + mockUserService.getAdmins.mockResolvedValueOnce([ + mockAdmin1, + mockAdmin2, + ]); + mockMailService.sendMail.mockResolvedValue(true); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith( + 'Sent bulk approval notification for 5 users to 2 admin(s)', + ); + }); + + it('should handle partial email failures in bulk notifications', async () => { + const mockJob = { + data: { userId: 'user-8' }, + id: 'job-123', + } as Job; + + const users = Array.from({ length: 8 }, (_, i) => ({ + id: `user-${i + 1}`, + email: `user${i + 1}@example.com`, + status: UserStatus.UNVERIFIED, + })); + + mockUserService.getUserById.mockResolvedValueOnce(users[7]); + mockUserService.getUnverifiedUsersInLast24Hours.mockResolvedValueOnce( + users, + ); + mockUserService.getAdmins.mockResolvedValueOnce([ + mockAdmin1, + mockAdmin2, + ]); + // First email succeeds, second fails + mockMailService.sendMail + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('SMTP error')); + + await processor.process(mockJob); + + expect(mockMailService.sendMail).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith( + 'Sent bulk approval notification for 5 users to 1/2 admin(s). 1 failed.', + ); + }); + }); + + it('should handle errors from getUserById gracefully', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + const testError = new Error('Database connection failed'); + mockUserService.getUserById.mockRejectedValueOnce(testError); + + await processor.process(mockJob); + + expect(userService.getUserById).toHaveBeenCalledWith('user-123'); + expect(warnSpy).toHaveBeenCalledWith( + 'User user-123 not found, skipping notification', + ); + expect(mockMailService.sendMail).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + `Notification job ${mockJob.id} processed successfully.`, + ); + }); + + it('should throw errors from the outer trySafe wrapper', async () => { + const mockJob = { + data: { userId: 'user-123' }, + id: 'job-123', + } as Job; + + const testError = new Error( + 'Unexpected error in notification processing', + ); + mockUserService.getUserById.mockResolvedValueOnce(mockUser); + mockUserService.getUnverifiedUsersInLast24Hours.mockRejectedValueOnce( + testError, + ); + + await expect(processor.process(mockJob)).rejects.toThrow(testError); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + `Failed to process notification job ${mockJob.id}: ${testError.message}`, + ); + }); + }); +}); diff --git a/backend/src/modules/notification/notification.processor.ts b/backend/src/modules/notification/notification.processor.ts new file mode 100644 index 0000000..73bdb57 --- /dev/null +++ b/backend/src/modules/notification/notification.processor.ts @@ -0,0 +1,191 @@ +import { Job } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { trySafe } from '~/helpers/try-safe'; +import { ConfigService } from '@nestjs/config'; +import { MailService } from '../mail/mail.service'; +import { UserService } from '../user/user.service'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { NotificationJobData } from './notification.service'; +import { UserStatus } from '../user/constants/user.constant'; + +@Processor('notification') +export class NotificationProcessor extends WorkerHost { + private readonly logger = new Logger(NotificationProcessor.name); + + constructor( + private readonly mailService: MailService, + private readonly userService: UserService, + private readonly configService: ConfigService, + ) { + super(); + } + + async process(job: Job) { + const [error] = await trySafe(async () => { + if (!job.data?.userId) { + throw new Error('User ID is required'); + } + + const { userId } = job.data; + + const [userError, user] = await trySafe(() => + this.userService.getUserById(userId), + ); + + if (userError || !user) { + this.logger.warn(`User ${userId} not found, skipping notification`); + return true; + } + + if (user.status !== UserStatus.UNVERIFIED) { + this.logger.log( + `User ${userId} is already verified, skipping notification`, + ); + return true; + } + + const unverifiedUsers = + await this.userService.getUnverifiedUsersInLast24Hours(); + const count = unverifiedUsers.length; + + const admins = await this.userService.getAdmins(); + + if (admins.length === 0) { + this.logger.warn('No admins found, skipping notification'); + return true; + } + + const currentUserIndex = unverifiedUsers.findIndex( + (u) => u.id === userId, + ); + + if ( + count <= + this.configService.get('NOTIFICATION_INDIVIDUAL_THRESHOLD')! + ) { + if (currentUserIndex === -1) { + this.logger.warn( + `User ${userId} not found in unverified users list, skipping notification`, + ); + return true; + } + await this.sendIndividualNotification(user, admins); + } else { + const countAfterInitial = + count - + this.configService.get('NOTIFICATION_INDIVIDUAL_THRESHOLD')!; + if ( + countAfterInitial % + this.configService.get('NOTIFICATION_BULK_THRESHOLD')! === + 0 + ) { + const lastBulkUsers = unverifiedUsers.slice( + -this.configService.get('NOTIFICATION_BULK_THRESHOLD')!, + ); + const isCurrentUserInBulk = lastBulkUsers.some( + (u) => u.id === userId, + ); + + if (isCurrentUserInBulk) { + await this.sendBulkNotification(lastBulkUsers, admins); + } else { + this.logger.log( + `Skipping bulk notification: current user ${userId} is not in the last ${this.configService.get('NOTIFICATION_BULK_THRESHOLD')!} users`, + ); + return true; + } + } else { + this.logger.log( + `Skipping notification: ${count} signups (need ${this.configService.get('NOTIFICATION_INDIVIDUAL_THRESHOLD')! + Math.ceil(countAfterInitial / this.configService.get('NOTIFICATION_BULK_THRESHOLD')!) * this.configService.get('NOTIFICATION_BULK_THRESHOLD')!} for next bulk)`, + ); + return true; + } + } + + return true; + }); + + if (error) { + this.logger.error( + `Failed to process notification job ${job.id}: ${error.message}`, + ); + throw error; + } + + this.logger.log(`Notification job ${job.id} processed successfully.`); + } + + private async sendIndividualNotification( + user: { id: string; email: string }, + admins: { email: string }[], + ) { + const approvalUrl = `${this.configService.get('FRONTEND_URL')}/admin/approve?userId=${user.id}`; + const userName = user.email.split('@')[0]; + + const emailPromises = admins.map((admin) => + this.mailService.sendMail({ + to: admin.email, + subject: 'New User Signup - Approval Required', + template: 'user-approval-individual', + context: { + userName, + userEmail: user.email, + approvalUrl, + }, + }), + ); + + const results = await Promise.allSettled(emailPromises); + const successful = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + + if (failed > 0) { + this.logger.warn( + `Sent individual approval notification for user ${user.id} to ${successful}/${admins.length} admin(s). ${failed} failed.`, + ); + } else { + this.logger.log( + `Sent individual approval notification for user ${user.id} to ${admins.length} admin(s)`, + ); + } + } + + private async sendBulkNotification( + users: { id: string; email: string }[], + admins: { email: string }[], + ) { + const userIds = users.map((u) => u.id).join(','); + const approvalUrl = `${this.configService.get('FRONTEND_URL')}/admin/approve?userIds=${userIds}`; + const userList = users.map((u) => ({ + email: u.email, + name: u.email.split('@')[0], + })); + + const emailPromises = admins.map((admin) => + this.mailService.sendMail({ + to: admin.email, + subject: `Bulk User Approval - ${users.length} New Signups`, + template: 'user-approval-bulk', + context: { + users: userList, + userCount: users.length, + approvalUrl, + }, + }), + ); + + const results = await Promise.allSettled(emailPromises); + const successful = results.filter((r) => r.status === 'fulfilled').length; + const failed = results.filter((r) => r.status === 'rejected').length; + + if (failed > 0) { + this.logger.warn( + `Sent bulk approval notification for ${users.length} users to ${successful}/${admins.length} admin(s). ${failed} failed.`, + ); + } else { + this.logger.log( + `Sent bulk approval notification for ${users.length} users to ${admins.length} admin(s)`, + ); + } + } +} diff --git a/backend/src/modules/notification/notification.service.spec.ts b/backend/src/modules/notification/notification.service.spec.ts new file mode 100644 index 0000000..232be0e --- /dev/null +++ b/backend/src/modules/notification/notification.service.spec.ts @@ -0,0 +1,90 @@ +import { Queue } from 'bullmq'; +import { Logger } from '@nestjs/common'; +import { + NotificationService, + NotificationJobData, +} from './notification.service'; +import { getQueueToken } from '@nestjs/bullmq'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockNotificationQueue = { + add: jest.fn(), +}; + +let errorSpy: jest.SpyInstance; +let logSpy: jest.SpyInstance; + +describe('NotificationService', () => { + let service: NotificationService; + let queue: Queue; + + beforeEach(async () => { + jest.clearAllMocks(); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => {}); + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationService, + { + provide: getQueueToken('notification'), + useValue: mockNotificationQueue, + }, + ], + }).compile(); + + service = module.get(NotificationService); + queue = module.get(getQueueToken('notification')); + + logSpy.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('notifyNewUserSignup', () => { + const userId = 'user-123'; + + it('should add a job to the notification queue and log success', async () => { + const mockJob = { id: 'job-123' }; + mockNotificationQueue.add.mockResolvedValueOnce(mockJob); + const expectedData: NotificationJobData = { userId }; + + const result = await service.notifyNewUserSignup(userId); + + expect(queue.add).toHaveBeenCalledTimes(1); + expect(queue.add).toHaveBeenCalledWith('notify-new-signup', expectedData); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + `Notification job added to queue for user ${userId} with job ID: ${mockJob.id}.`, + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should log an error and return false if adding to the queue fails', async () => { + const errorMessage = 'Queue connection failed'; + const testError = new Error(errorMessage); + mockNotificationQueue.add.mockRejectedValueOnce(testError); + logSpy.mockClear(); + + const result = await service.notifyNewUserSignup(userId); + + expect(queue.add).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + `Failed to add notification job to queue for user ${userId}`, + testError.message, + ); + expect(logSpy).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); +}); diff --git a/backend/src/modules/notification/notification.service.ts b/backend/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..a56eea4 --- /dev/null +++ b/backend/src/modules/notification/notification.service.ts @@ -0,0 +1,42 @@ +import { Queue, Job } from 'bullmq'; +import { trySafe } from '~/helpers/try-safe'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger } from '@nestjs/common'; + +export interface NotificationJobData { + userId: string; +} + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + constructor( + @InjectQueue('notification') + private readonly notificationQueue: Queue, + ) {} + + async notifyNewUserSignup(userId: string) { + let job: Job | undefined; + + const [error] = await trySafe(async () => { + job = await this.notificationQueue.add('notify-new-signup', { + userId, + }); + return job; + }); + + if (error) { + this.logger.error( + `Failed to add notification job to queue for user ${userId}`, + error.message, + ); + return false; + } + + this.logger.log( + `Notification job added to queue for user ${userId} with job ID: ${job?.id}.`, + ); + return true; + } +} diff --git a/backend/src/modules/store/dto/store.dto.ts b/backend/src/modules/store/dto/store.dto.ts index ecdfff3..710379b 100644 --- a/backend/src/modules/store/dto/store.dto.ts +++ b/backend/src/modules/store/dto/store.dto.ts @@ -48,7 +48,9 @@ export class StoreDto { @IsString() @IsOptional() - @Length(1, 500, { message: 'Store type description must be between 1 and 500 characters' }) + @Length(1, 500, { + message: 'Store type description must be between 1 and 500 characters', + }) @ValidateIf((o) => o.storeType === 'SHOP' || o.storeType === 'OTHER') @IsNotEmpty() storeTypeDescription?: string; diff --git a/backend/src/modules/user/dto/user.dto.ts b/backend/src/modules/user/dto/user.dto.ts index 5e43788..ddcbb3a 100644 --- a/backend/src/modules/user/dto/user.dto.ts +++ b/backend/src/modules/user/dto/user.dto.ts @@ -6,6 +6,8 @@ import { MinLength, IsEnum, IsOptional, + IsArray, + IsUUID, } from 'class-validator'; import { QueryValidator } from '~/helpers/query.helper'; import { AuthProvider } from '~/modules/auth/constants/auth.constant'; @@ -60,3 +62,10 @@ export class AssignLocationDto { @IsOptional() districtId?: string; } + +export class BulkApproveUsersDto { + @IsArray() + @IsNotEmpty() + @IsUUID('4', { each: true }) + userIds: string[]; +} diff --git a/backend/src/modules/user/user.model-action.ts b/backend/src/modules/user/user.model-action.ts index a7eb4d7..31fd686 100644 --- a/backend/src/modules/user/user.model-action.ts +++ b/backend/src/modules/user/user.model-action.ts @@ -1,7 +1,8 @@ -import { Repository } from 'typeorm'; import { Injectable } from '@nestjs/common'; import { User } from './entities/user.entity'; import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual, In } from 'typeorm'; +import { UserRole, UserStatus } from './constants/user.constant'; import { AbstractModelAction } from '~/database/base/base.model-action'; @Injectable() @@ -9,4 +10,38 @@ export class UserModelAction extends AbstractModelAction { constructor(@InjectRepository(User) repository: Repository) { super(repository, User); } + + async findUnverifiedUsersInLast24Hours( + twentyFourHoursAgo: Date, + ): Promise { + return this.repository.find({ + where: { + status: UserStatus.UNVERIFIED, + createdAt: MoreThanOrEqual(twentyFourHoursAgo), + }, + order: { + createdAt: 'ASC', + }, + }); + } + + async findAdmins(): Promise { + return this.repository.find({ + where: { + role: In([UserRole.ADMIN, UserRole.SUPER_ADMIN]), + }, + }); + } + + async findUsersByIdsAndStatus( + userIds: string[], + status: UserStatus, + ): Promise { + return this.repository.find({ + where: userIds.map((id) => ({ + id, + status, + })), + }); + } } diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index b0f3736..f1d8f0a 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -3,7 +3,7 @@ import * as SYS_MSG from '~/helpers/system-messages'; import { StateService } from '../state/state.service'; import { UserModelAction } from './user.model-action'; import { EntityPropertyNotFoundError } from 'typeorm'; -import { HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import UpdateUserRecordOptions from './types/update-user.type'; import CreateUserRecordOptions from './types/create-user.type'; import { NullishValueError, trySafe } from '~/helpers/try-safe'; @@ -14,9 +14,12 @@ import { ListUserRecordOptions, } from './types/list-user.type'; import { validateUUID } from '~/helpers/validation.helper'; +import { User } from './entities/user.entity'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + constructor( private stateService: StateService, private userModelAction: UserModelAction, @@ -467,4 +470,124 @@ export class UserService { data, }; } + + async getUnverifiedUsersInLast24Hours(): Promise { + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const [error, users] = await trySafe(() => + this.userModelAction.findUnverifiedUsersInLast24Hours(twentyFourHoursAgo), + ); + + if (error) { + this.logger.error( + 'Failed to fetch unverified users in last 24 hours', + error.message, + ); + return []; + } + + return users || []; + } + + async getAdmins(): Promise { + const [error, admins] = await trySafe(() => + this.userModelAction.findAdmins(), + ); + + if (error) { + this.logger.error('Failed to fetch admins', error.message); + return []; + } + + return admins || []; + } + + async verifyUsersBulk( + userIds: string[], + verifiedBy: string, + ): Promise<{ message: string; data: User[] }> { + validateUUID(verifiedBy, 'verifiedBy'); + + if (!userIds || userIds.length === 0) { + throw new CustomHttpException( + SYS_MSG.INVALID_PARAMETER('User IDs'), + HttpStatus.BAD_REQUEST, + ); + } + + userIds.forEach((id) => validateUUID(id, 'userId')); + + const [verifiedByError, verifiedByUser] = await trySafe(() => + this.getUserById(verifiedBy), + ); + + if (verifiedByError || !verifiedByUser) { + throw new CustomHttpException( + SYS_MSG.RESOURCE_OPERATION_FAILED('Bulk User Verification'), + HttpStatus.BAD_REQUEST, + ); + } + + const verifiedByRole = verifiedByUser.role; + + if ( + verifiedByRole !== UserRole.SUPER_ADMIN && + verifiedByRole !== UserRole.ADMIN + ) { + throw new CustomHttpException( + SYS_MSG.RESOURCE_OPERATION_FAILED('Bulk User Verification'), + HttpStatus.FORBIDDEN, + ); + } + + const [usersError, users] = await trySafe(() => + this.userModelAction.findUsersByIdsAndStatus( + userIds, + UserStatus.UNVERIFIED, + ), + ); + + if (usersError) { + throw new CustomHttpException( + SYS_MSG.RESOURCE_FETCH_FAILED('Users'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + if (users.length !== userIds.length) { + throw new CustomHttpException( + SYS_MSG.RESOURCE_NOT_FOUND('Some users not found or already verified'), + HttpStatus.BAD_REQUEST, + ); + } + + const updatePromises = users.map((user: User) => + this.userModelAction.update({ + identifierOptions: { + id: user.id, + status: UserStatus.UNVERIFIED, + }, + updatePayload: { status: UserStatus.VERIFIED }, + transactionOptions: { + useTransaction: false, + }, + }), + ); + + const [updateError, updatedUsers] = await trySafe(() => + Promise.all(updatePromises), + ); + + if (updateError) { + throw new CustomHttpException( + SYS_MSG.RESOURCE_OPERATION_FAILED('Bulk User Verification'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + message: SYS_MSG.RESOURCE_OPERATION_SUCCESSFUL('Bulk User Verification'), + data: updatedUsers.filter((u) => u !== null) as User[], + }; + } }