diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a9fe94a..e2a4a29 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -10,5 +10,4 @@ MAIL_HOST=smtp.example.com MAIL_PORT=465 MAIL_USER=user@example.com MAIL_PASSWORD=topsecret -MAIL_FROM=noreply@example.com -MAILING_LIST=example1@example.com,example2@example.com \ No newline at end of file +MAIL_FROM=noreply@example.com \ No newline at end of file diff --git a/apps/backend/src/app/alerting/alerting.controller.spec.ts b/apps/backend/src/app/alerting/alerting.controller.spec.ts index d69a41d..92e3442 100644 --- a/apps/backend/src/app/alerting/alerting.controller.spec.ts +++ b/apps/backend/src/app/alerting/alerting.controller.spec.ts @@ -15,6 +15,7 @@ import { CreateSizeAlertDto } from './dto/alerts/createSizeAlert.dto'; import { CREATION_DATE_ALERT, SIZE_ALERT } from '../utils/constants'; import { CreateCreationDateAlertDto } from './dto/alerts/createCreationDateAlert.dto'; import { CreationDateAlertEntity } from './entity/alerts/creationDateAlert.entity'; +import { MailReceiverEntity } from '../utils/mail/entity/MailReceiver.entity'; const mockedBackupDataEntity: BackupDataEntity = { id: 'backup-id', @@ -104,6 +105,8 @@ describe('AlertingController (e2e)', () => { .useValue(mockCreationDateAlertRepository) .overrideProvider(getRepositoryToken(AlertTypeEntity)) .useValue(mockAlertTypeRepository) + .overrideProvider(getRepositoryToken(MailReceiverEntity)) + .useValue({}) .compile(); repository = module.get(getRepositoryToken(SizeAlertEntity)); diff --git a/apps/backend/src/app/db-config.service.ts b/apps/backend/src/app/db-config.service.ts index b8b4646..f7695c8 100644 --- a/apps/backend/src/app/db-config.service.ts +++ b/apps/backend/src/app/db-config.service.ts @@ -20,6 +20,8 @@ import { CreationDateAlertEntity } from './alerting/entity/alerts/creationDateAl import { CreationDateAlert1733070019992 } from './migrations/1733070019992-CreationDateAlert'; import { TaskEntity } from './tasks/entity/task.entity'; import { Tasks1733397652480 } from './migrations/1733397652480-Tasks'; +import { MailReceiverEntity } from './utils/mail/entity/MailReceiver.entity'; +import { MailReceiver1733580333590 } from './migrations/1733580333590-MailReceiver'; /** * Used by NestJS to reach database. @@ -46,6 +48,7 @@ export class DbConfigService implements TypeOrmOptionsFactory { SizeAlertEntity, CreationDateAlertEntity, TaskEntity, + MailReceiverEntity, ], migrationsRun: true, migrations: [ @@ -61,6 +64,7 @@ export class DbConfigService implements TypeOrmOptionsFactory { NewAlertStructure1732887680122, CreationDateAlert1733070019992, Tasks1733397652480, + MailReceiver1733580333590, ], logging: true, }; diff --git a/apps/backend/src/app/migrations/1733580333590-MailReceiver.ts b/apps/backend/src/app/migrations/1733580333590-MailReceiver.ts new file mode 100644 index 0000000..93b171b --- /dev/null +++ b/apps/backend/src/app/migrations/1733580333590-MailReceiver.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MailReceiver1733580333590 implements MigrationInterface { + name = 'MailReceiver1733580333590' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "MailReceiver" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "mail" character varying NOT NULL, CONSTRAINT "UQ_9b0a9d71b5bb67a4b0bb2499b4c" UNIQUE ("mail"), CONSTRAINT "PK_71d9ee85e0f42a3a278d0ed19cf" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "MailReceiver"`); + } + +} diff --git a/apps/backend/src/app/utils/mail/dto/createMailReceiver.dto.ts b/apps/backend/src/app/utils/mail/dto/createMailReceiver.dto.ts new file mode 100644 index 0000000..07a6016 --- /dev/null +++ b/apps/backend/src/app/utils/mail/dto/createMailReceiver.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail } from 'class-validator'; + +export class CreateMailReceiverDto { + @ApiProperty({ + description: 'Mail Address', + required: true, + uniqueItems: true, + }) + @IsEmail() + mail!: string; +} diff --git a/apps/backend/src/app/utils/mail/entity/MailReceiver.entity.ts b/apps/backend/src/app/utils/mail/entity/MailReceiver.entity.ts new file mode 100644 index 0000000..482c595 --- /dev/null +++ b/apps/backend/src/app/utils/mail/entity/MailReceiver.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; + +@Entity('MailReceiver') +export class MailReceiverEntity { + @ApiProperty({ + description: 'Auto-generated UUID of the Mail Receiver', + required: true, + }) + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ApiProperty({ + description: 'Mail Address', + required: true, + uniqueItems: true, + }) + @Column({ nullable: false, unique: true }) + mail!: string; +} diff --git a/apps/backend/src/app/utils/mail/mail.controller.spec.ts b/apps/backend/src/app/utils/mail/mail.controller.spec.ts new file mode 100644 index 0000000..193f071 --- /dev/null +++ b/apps/backend/src/app/utils/mail/mail.controller.spec.ts @@ -0,0 +1,98 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { MailController } from './mail.controller'; +import { MailService } from './mail.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { MailReceiverEntity } from './entity/MailReceiver.entity'; +import { DeleteResult, Repository } from 'typeorm'; +import { MailerService } from '@nestjs-modules/mailer'; +import { ConfigService } from '@nestjs/config'; + +describe('MailController (e2e)', () => { + let app: INestApplication; + let mailReceiverRepository: Repository; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [MailController], + providers: [ + MailService, + { + provide: MailerService, + useValue: { + sendMail: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('true'), + }, + }, + { + provide: getRepositoryToken(MailReceiverEntity), + useValue: { + find: jest.fn(), + save: jest.fn(), + findOneBy: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + + mailReceiverRepository = moduleFixture.get>( + getRepositoryToken(MailReceiverEntity) + ); + }); + + it('/GET mail receivers', async () => { + const receivers = [{ id: '1', mail: 'test@example.com' }]; + jest.spyOn(mailReceiverRepository, 'find').mockResolvedValue(receivers); + + const response = await request(app.getHttpServer()).get('/mail'); + expect(response.status).toBe(200); + expect(response.body).toEqual(receivers); + }); + + it('/POST mail receiver', async () => { + const createMailReceiverDto = { mail: 'new@example.com' }; + const savedReceiver = { id: '2', mail: 'new@example.com' }; + jest.spyOn(mailReceiverRepository, 'save').mockResolvedValue(savedReceiver); + + const response = await request(app.getHttpServer()) + .post('/mail') + .send(createMailReceiverDto); + expect(response.status).toBe(201); + expect(response.body).toEqual(savedReceiver); + }); + + it('/DELETE mail receiver', async () => { + const id = 'ea1a2f52-5cf4-44a6-b266-175ee396a18c'; + jest + .spyOn(mailReceiverRepository, 'findOneBy') + .mockResolvedValue({ id, mail: 'test@example.com' }); + jest + .spyOn(mailReceiverRepository, 'delete') + .mockResolvedValue(new DeleteResult()); + + const response = await request(app.getHttpServer()).delete(`/mail/${id}`); + expect(response.status).toBe(200); + }); + + it('should throw NotFoundException if mail receiver not found', async () => { + const id = 'ea1a2f52-5cf4-44a6-b266-175ee396a18e'; + jest.spyOn(mailReceiverRepository, 'findOneBy').mockResolvedValue(null); + + const response = await request(app.getHttpServer()).delete(`/mail/${id}`); + expect(response.status).toBe(404); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/apps/backend/src/app/utils/mail/mail.controller.ts b/apps/backend/src/app/utils/mail/mail.controller.ts new file mode 100644 index 0000000..49b99b4 --- /dev/null +++ b/apps/backend/src/app/utils/mail/mail.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Delete, + Get, + Logger, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { MailService } from './mail.service'; +import { CreateMailReceiverDto } from './dto/createMailReceiver.dto'; +import { MailReceiverEntity } from './entity/MailReceiver.entity'; + +@ApiTags('Mail') +@Controller('mail') +export class MailController { + readonly logger = new Logger(MailController.name); + + constructor(private readonly mailService: MailService) {} + + @Get() + @ApiOperation({ summary: 'Returns all Mail Receiver.' }) + @ApiOkResponse() + async findAll(): Promise { + return this.mailService.getAllMailReceiver(); + } + + @Delete(':id') + @ApiOperation({ summary: 'Removes the mail receiver with the given id.' }) + @ApiOkResponse() + @ApiNotFoundResponse() + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.mailService.removeMailReceiver(id); + } + + @Post() + @ApiOperation({ summary: 'Adds a new Mail Receiver.' }) + @ApiCreatedResponse() + async create( + @Body() createMailReceiverDto: CreateMailReceiverDto + ): Promise { + return this.mailService.addMailReceiver(createMailReceiverDto); + } +} diff --git a/apps/backend/src/app/utils/mail/mail.module.ts b/apps/backend/src/app/utils/mail/mail.module.ts index 1a18774..7b9845e 100644 --- a/apps/backend/src/app/utils/mail/mail.module.ts +++ b/apps/backend/src/app/utils/mail/mail.module.ts @@ -4,6 +4,9 @@ import { MailService } from './mail.service'; import { join } from 'path'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter.js'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MailReceiverEntity } from './entity/MailReceiver.entity'; +import { MailController } from './mail.controller'; @Global() @Module({ @@ -35,8 +38,10 @@ import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handleba }), inject: [ConfigService], }), + TypeOrmModule.forFeature([MailReceiverEntity]), ], providers: [MailService], exports: [MailService], + controllers: [MailController], }) export class MailModule {} diff --git a/apps/backend/src/app/utils/mail/mail.service.spec.ts b/apps/backend/src/app/utils/mail/mail.service.spec.ts index cea783f..7821598 100644 --- a/apps/backend/src/app/utils/mail/mail.service.spec.ts +++ b/apps/backend/src/app/utils/mail/mail.service.spec.ts @@ -6,15 +6,39 @@ import { BackupType } from '../../backupData/dto/backupType'; import { SizeAlertEntity } from '../../alerting/entity/alerts/sizeAlert.entity'; import { SeverityType } from '../../alerting/dto/severityType'; import { SIZE_ALERT } from '../constants'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { MailReceiverEntity } from './entity/MailReceiver.entity'; +import { NotFoundException } from '@nestjs/common'; jest.mock('path', () => ({ resolve: jest.fn().mockReturnValue('mocked/path/to/logo.png'), + dirname: jest.fn(), })); +const mockMailReceiverRepository = { + find: jest.fn().mockResolvedValue([ + { + id: '1', + mail: 'test@example.com', + }, + ]), + save: jest.fn().mockImplementation((receiver) => Promise.resolve(receiver)), + findOneBy: jest.fn().mockImplementation(({ id }) => { + if (id === '1') { + return Promise.resolve({ + id: '1', + mail: 'test@example.com', + }); + } else { + return Promise.resolve(null); + } + }), + delete: jest.fn().mockResolvedValue({}), +}; + describe('MailService', () => { let service: MailService; let mailerService: MailerService; - let configService: ConfigService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -29,16 +53,18 @@ describe('MailService', () => { { provide: ConfigService, useValue: { - getOrThrow: jest.fn().mockReturnValue('test@example.com'), get: jest.fn().mockReturnValue('true'), }, }, + { + provide: getRepositoryToken(MailReceiverEntity), + useValue: mockMailReceiverRepository, + }, ], }).compile(); service = module.get(MailService); mailerService = module.get(MailerService); - configService = module.get(ConfigService); }); it('should be defined', () => { @@ -112,4 +138,41 @@ describe('MailService', () => { attachments, }); }); + + it('should get all mail receivers', async () => { + const receivers = [{ id: '1', mail: 'test@example.com' }]; + + expect(await service.getAllMailReceiver()).toStrictEqual(receivers); + }); + + it('should add a mail receiver', async () => { + const createMailReceiverDto = { mail: 'new@example.com' }; + const savedReceiver = { mail: 'new@example.com' }; + + await service.addMailReceiver(createMailReceiverDto); + + expect(mockMailReceiverRepository.save).toBeCalledWith({ + mail: createMailReceiverDto.mail, + }); + + expect(await service.addMailReceiver(createMailReceiverDto)).toStrictEqual( + savedReceiver + ); + }); + + it('should remove a mail receiver', async () => { + const id = '1'; + await service.removeMailReceiver(id); + + expect(mockMailReceiverRepository.findOneBy).toHaveBeenCalledWith({ id }); + expect(mockMailReceiverRepository.delete).toHaveBeenCalledWith({ id }); + }); + + it('should throw NotFoundException if mail receiver not found', async () => { + const id = 'non-existent-id'; + + await expect(service.removeMailReceiver(id)).rejects.toThrow( + NotFoundException + ); + }); }); diff --git a/apps/backend/src/app/utils/mail/mail.service.ts b/apps/backend/src/app/utils/mail/mail.service.ts index 9d3ccaa..2129c2e 100644 --- a/apps/backend/src/app/utils/mail/mail.service.ts +++ b/apps/backend/src/app/utils/mail/mail.service.ts @@ -1,11 +1,15 @@ import { MailerService } from '@nestjs-modules/mailer'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import { Alert } from '../../alerting/entity/alerts/alert'; import { SizeAlertEntity } from '../../alerting/entity/alerts/sizeAlert.entity'; import { CREATION_DATE_ALERT, SIZE_ALERT } from '../constants'; import { CreationDateAlertEntity } from '../../alerting/entity/alerts/creationDateAlert.entity'; +import { CreateMailReceiverDto } from './dto/createMailReceiver.dto'; +import { MailReceiverEntity } from './entity/MailReceiver.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; @Injectable() export class MailService { @@ -13,8 +17,10 @@ export class MailService { MAILING_ACTIVE = true; constructor( - private mailerService: MailerService, - private configService: ConfigService + @InjectRepository(MailReceiverEntity) + private readonly mailReceiverEntityRepository: Repository, + private readonly mailerService: MailerService, + private readonly configService: ConfigService ) { if (this.configService.get('MAILING_ACTIVE') === 'false') { this.MAILING_ACTIVE = false; @@ -22,16 +28,24 @@ export class MailService { } async sendAlertMail(alert: Alert) { - const receivers = - this.configService.getOrThrow('MAILING_LIST').split(',') || []; + const receiverEntities = await this.getAllMailReceiver(); + if (receiverEntities.length === 0) { + this.logger.log('No mail receivers found. Skipping sending mail'); + return; + } + const receivers = receiverEntities + .map((receiver) => receiver.mail) + .join(',') + .split(','); + let reason = ''; let description = ''; let valueColumnName = ''; let referenceValueColumnName = ''; - let percentage: string = 'Infinity'; - let value: string = '-'; - let referenceValue: string = '-'; + let percentage = 'Infinity'; + let value = '-'; + let referenceValue = '-'; switch (alert.alertType.name) { case SIZE_ALERT: const sizeAlert = alert as SizeAlertEntity; @@ -129,4 +143,21 @@ export class MailService { attachments: attachments ?? [], }); } + + async getAllMailReceiver(): Promise { + return this.mailReceiverEntityRepository.find(); + } + + async addMailReceiver( + createMailReceiverDto: CreateMailReceiverDto + ): Promise { + return await this.mailReceiverEntityRepository.save(createMailReceiverDto); + } + + async removeMailReceiver(id: string) { + if (!(await this.mailReceiverEntityRepository.findOneBy({ id }))) { + throw new NotFoundException('Mail '); + } + await this.mailReceiverEntityRepository.delete({ id }); + } }