diff --git a/apps/backend/src/app/utils/mail/mail.controller.ts b/apps/backend/src/app/utils/mail/mail.controller.ts index 49b99b47..ca2467ce 100644 --- a/apps/backend/src/app/utils/mail/mail.controller.ts +++ b/apps/backend/src/app/utils/mail/mail.controller.ts @@ -9,6 +9,7 @@ import { Post, } from '@nestjs/common'; import { + ApiConflictResponse, ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, @@ -44,6 +45,7 @@ export class MailController { @Post() @ApiOperation({ summary: 'Adds a new Mail Receiver.' }) @ApiCreatedResponse() + @ApiConflictResponse({ description: 'Mail Receiver already exists.' }) async create( @Body() createMailReceiverDto: CreateMailReceiverDto ): Promise { diff --git a/apps/backend/src/app/utils/mail/mail.service.ts b/apps/backend/src/app/utils/mail/mail.service.ts index 49c40546..63013750 100644 --- a/apps/backend/src/app/utils/mail/mail.service.ts +++ b/apps/backend/src/app/utils/mail/mail.service.ts @@ -1,5 +1,10 @@ import { MailerService } from '@nestjs-modules/mailer'; -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as path from 'path'; import { Alert } from '../../alerting/entity/alerts/alert'; @@ -165,6 +170,13 @@ export class MailService { async addMailReceiver( createMailReceiverDto: CreateMailReceiverDto ): Promise { + if ( + await this.mailReceiverEntityRepository.findOneBy({ + mail: createMailReceiverDto.mail, + }) + ) { + throw new ConflictException('Mail receiver already exists'); + } return await this.mailReceiverEntityRepository.save(createMailReceiverDto); } diff --git a/apps/frontend/src/app/app.component.html b/apps/frontend/src/app/app.component.html index 0543c1c3..ad71637e 100644 --- a/apps/frontend/src/app/app.component.html +++ b/apps/frontend/src/app/app.component.html @@ -12,6 +12,15 @@ Notifications + diff --git a/apps/frontend/src/app/app.module.ts b/apps/frontend/src/app/app.module.ts index edcee333..49f20c86 100644 --- a/apps/frontend/src/app/app.module.ts +++ b/apps/frontend/src/app/app.module.ts @@ -23,6 +23,10 @@ import { tagIcon, dataClusterIcon, filterIcon, + envelopeIcon, + plusIcon, + lockIcon, + trashIcon, } from '@cds/core/icon'; import { NgxEchartsModule } from 'ngx-echarts'; import { TestUploadComponent } from './test-upload/component/test-upload/test-upload.component'; @@ -31,6 +35,8 @@ import { BackupsComponent } from './backups-overview/backups/backups/backups.com import { BASE_URL } from './shared/types/configuration'; import { AlertComponent } from './alert/component/alert.component'; import { NotificationSettingsComponent } from './management/components/settings/notification-settings/notification-settings.component'; +import { EmailReceiverSettingsComponent } from './management/components/settings/email-receiver-settings/email-receiver-settings.component'; +import { ConfirmDialogComponent } from './shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component'; @NgModule({ declarations: [ @@ -40,6 +46,8 @@ import { NotificationSettingsComponent } from './management/components/settings/ BackupsComponent, AlertComponent, NotificationSettingsComponent, + EmailReceiverSettingsComponent, + ConfirmDialogComponent, ], imports: [ BrowserModule, @@ -69,7 +77,11 @@ export class AppModule { angleIcon, tagIcon, dataClusterIcon, - filterIcon + filterIcon, + envelopeIcon, + plusIcon, + lockIcon, + trashIcon, ); } } diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 20cccd76..3ffd76f8 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -3,9 +3,12 @@ import { TestUploadComponent } from './test-upload/component/test-upload/test-up import { FindTestDataComponent } from './test-upload/component/find-test-data/find-test-data.component'; import { BackupsComponent } from './backups-overview/backups/backups/backups.component'; import { AlertComponent } from './alert/component/alert.component'; +import { EmailReceiverSettingsComponent } from './management/components/settings/email-receiver-settings/email-receiver-settings.component'; export const appRoutes: Route[] = [ { path: 'upload', component: TestUploadComponent }, { path: 'findData', component: FindTestDataComponent }, + { path: 'email-receiver', component: EmailReceiverSettingsComponent}, { path: '', component: BackupsComponent }, + ]; diff --git a/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.css b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.css new file mode 100644 index 00000000..c0b32791 --- /dev/null +++ b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.css @@ -0,0 +1,7 @@ +.clr-dg-cell { + text-align: center ; +} + +.error { + color: red; +} \ No newline at end of file diff --git a/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.html b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.html new file mode 100644 index 00000000..78349d8d --- /dev/null +++ b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.html @@ -0,0 +1,78 @@ +

Email Alert Recipients

+
+
+ + +
+ + +
+
+ + Email + We couldn't find any emails! + + {{ email.mail }} + + + +
+
+
+ + + + + + diff --git a/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.spec.ts b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.spec.ts new file mode 100644 index 00000000..60594532 --- /dev/null +++ b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.spec.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EmailReceiverSettingsComponent } from './email-receiver-settings.component'; +import { FormBuilder } from '@angular/forms'; +import { EmailReceiverService } from '../../../services/email-receiver/email-receiver.service'; +import { of, throwError } from 'rxjs'; +import { ConfirmDialogService } from '../../../../shared/components/confirm-dialog/service/confirm-dialog.service'; +import { EmailType } from '../../../../shared/types/email'; + +describe('EmailReceiverSettingsComponent', () => { + let component: EmailReceiverSettingsComponent; + let emailService: EmailReceiverService; + let confirmDialogService: ConfirmDialogService; + + const mockEmails: EmailType[] = [ + { id: '1', mail: 'test1@example.com' }, + { id: '2', mail: 'test2@example.com' }, + ]; + + beforeEach(() => { + emailService = { + getAllEmailReceiver: vi.fn().mockReturnValue(of(mockEmails)), + deleteEmail: vi.fn().mockReturnValue(of({})), + updateEmailReceiver: vi.fn(), + } as any; + + confirmDialogService = { + handleConfirmation: vi.fn().mockImplementation((options, onConfirm) => { + onConfirm(); + return Promise.resolve(); + }), + } as any; + + component = new EmailReceiverSettingsComponent( + new FormBuilder(), + emailService, + confirmDialogService + ); + + component.ngOnInit(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('loadEmailReceiver', () => { + it('should load emails successfully', () => { + component.loadEmailReceiver(); + + expect(component.isLoading).toBe(false); + expect(emailService.getAllEmailReceiver).toHaveBeenCalled(); + + component['emailsSubject$'].subscribe((emails) => { + expect(emails).toEqual(mockEmails); + }); + }); + + it('should handle error when loading emails', () => { + emailService.getAllEmailReceiver = vi + .fn() + .mockReturnValue(throwError(() => new Error('Failed to load'))); + + component.loadEmailReceiver(); + + expect(component.isLoading).toBe(false); + expect(emailService.getAllEmailReceiver).toHaveBeenCalled(); + }); + }); + + describe('removeEmail', () => { + it('should not proceed if no emails to remove', async () => { + await component.removeEmail([]); + + expect(confirmDialogService.handleConfirmation).not.toHaveBeenCalled(); + expect(emailService.deleteEmail).not.toHaveBeenCalled(); + }); + + it('should delete emails when confirmed', async () => { + const emailsToRemove = [mockEmails[0]]; + + await component.removeEmail(emailsToRemove); + + expect(confirmDialogService.handleConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Delete Email Recipients', + confirmText: 'Delete', + cancelText: 'Cancel', + }), + expect.any(Function) + ); + expect(emailService.deleteEmail).toHaveBeenCalledWith('1'); + }); + + it('should handle cancel confirmation', async () => { + confirmDialogService.handleConfirmation = vi + .fn() + .mockImplementation((options, onConfirm, onCancel) => { + if (onCancel) onCancel(); + return Promise.resolve(); + }); + + await component.removeEmail([mockEmails[0]]); + + expect(emailService.deleteEmail).not.toHaveBeenCalled(); + }); + + it('should handle error during deletion', async () => { + emailService.deleteEmail = vi + .fn() + .mockReturnValue(throwError(() => new Error('Delete failed'))); + + await component.removeEmail([mockEmails[0]]); + + expect(component.isLoading).toBe(false); + }); + }); + + describe('saveChanges', () => { + it('should not save when form is invalid', () => { + component.emailForm.controls['email'].setValue(''); + + component.saveChanges(); + + expect(emailService.updateEmailReceiver).not.toHaveBeenCalled(); + }); + + it('should create new email when form is valid', () => { + const newEmail = { mail: 'new@example.com' }; + const createdEmail = { id: '3', mail: 'new@example.com' }; + + emailService.updateEmailReceiver = vi + .fn() + .mockReturnValue(of(createdEmail)); + component.emailForm.controls['email'].setValue('new@example.com'); + + component.saveChanges(); + + expect(emailService.updateEmailReceiver).toHaveBeenCalledWith(newEmail); + expect(component.showEmailModal).toBe(false); + expect(component.isLoading).toBe(false); + }); + + it('should handle duplicate email error', () => { + emailService.updateEmailReceiver = vi + .fn() + .mockReturnValue(throwError(() => ({ status: 409 }))); + component.emailForm.controls['email'].setValue('existing@example.com'); + + component.saveChanges(); + + expect(component.modalError).toBe('This email already exists'); + expect(component.isLoading).toBe(false); + }); + }); + + describe('resetForm', () => { + it('should reset form and modal state', () => { + // Setup form with values + component.emailForm.controls['email'].setValue('test@example.com'); + component.showEmailModal = true; + component.modalError = 'Some error'; + component.isLoading = true; + + // Reset form + component.resetForm(); + + // Verify reset state + expect(component.emailForm.get('email')?.value).toBeNull(); + expect(component.showEmailModal).toBe(false); + expect(component.modalError).toBe(''); + expect(component.isLoading).toBe(false); + }); + }); + + describe('ngOnDestroy', () => { + it('should complete subjects', () => { + const destroySpy = vi.spyOn(component['destroy$'], 'complete'); + + component.ngOnDestroy(); + + expect(destroySpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.ts b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.ts new file mode 100644 index 00000000..1a62d964 --- /dev/null +++ b/apps/frontend/src/app/management/components/settings/email-receiver-settings/email-receiver-settings.component.ts @@ -0,0 +1,147 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { + BehaviorSubject, + from, + mergeMap, + Subject, + takeUntil, +} from 'rxjs'; +import { EmailReceiverService } from '../../../services/email-receiver/email-receiver.service'; +import { CustomEmailFilter } from './emailfilter'; +import { EmailType } from '../../../../shared/types/email'; +import { ConfirmDialogService } from '../../../../shared/components/confirm-dialog/service/confirm-dialog.service'; + +@Component({ + selector: 'app-email-receiver-settings', + templateUrl: './email-receiver-settings.component.html', + styleUrl: './email-receiver-settings.component.css', +}) +export class EmailReceiverSettingsComponent implements OnInit, OnDestroy { + isLoading = false; + emailForm: FormGroup; + showEmailModal = false; + modalError = ''; + protected readonly selectedEmails: EmailType[] = []; + + protected emailsSubject$ = new BehaviorSubject([]); + private readonly destroy$ = new Subject(); + + protected emailFilter: CustomEmailFilter; + protected emailIdFilter: CustomEmailFilter; + + constructor( + private fb: FormBuilder, + private emailService: EmailReceiverService, + private confirmationService: ConfirmDialogService + ) { + this.emailFilter = new CustomEmailFilter('mail'); + this.emailIdFilter = new CustomEmailFilter('id'); + + this.emailForm = this.fb.group({ + email: [null, [Validators.required, Validators.email]], + }); + } + + ngOnInit() { + this.loadEmailReceiver(); + } + + loadEmailReceiver(): void { + this.isLoading = true; + this.emailService + .getAllEmailReceiver() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (emails) => { + this.emailsSubject$.next(emails); + this.isLoading = false; + }, + error: (error) => (this.isLoading = false), + }); + } + + removeEmail(emailsToRemove: EmailType[]) { + if (!emailsToRemove.length) return; + + this.confirmationService.handleConfirmation( + { + title: 'Delete Email Recipients', + message: `Are you sure you want to delete ${ + emailsToRemove.length > 1 ? 'these' : 'this' + } email recipient${emailsToRemove.length > 1 ? 's' : ''}?`, + confirmText: 'Delete', + cancelText: 'Cancel', + confirmButtonClass: 'btn btn-danger', + }, + async () => { + this.isLoading = true; + from(emailsToRemove) + .pipe( + mergeMap((email) => this.emailService.deleteEmail(email.id)), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => { + const currentEmails = this.emailsSubject$.getValue(); + const updatedEmails = currentEmails.filter( + (email) => + !emailsToRemove.some((toRemove) => toRemove.id === email.id) + ); + this.emailsSubject$.next(updatedEmails); + this.isLoading = false; + }, + error: (error) => { + console.error('Failed to delete emails:', error); + this.isLoading = false; + }, + complete: () => { + this.selectedEmails.length = 0; + }, + }); + } + ); + } + + saveChanges() { + if (this.emailForm.valid) { + this.isLoading = true; + this.modalError = ''; + + const newEmail: Partial = { + mail: this.emailForm.get('email')?.value, + }; + + this.emailService + .updateEmailReceiver(newEmail) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (createdEmail) => { + const currentEmails = this.emailsSubject$.getValue(); + this.emailsSubject$.next([...currentEmails, createdEmail]); + this.resetForm(); + }, + error: (error) => { + console.error('Failed to create email:', error); + this.modalError = + error.status === 409 + ? 'This email already exists' + : 'Failed to create email. Please try again.'; + this.isLoading = false; + }, + }); + } + } + + resetForm(): void { + this.emailForm.controls['email'].reset(); + this.showEmailModal = false; + this.modalError = ''; + this.isLoading = false; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/frontend/src/app/management/components/settings/email-receiver-settings/emailfilter.ts b/apps/frontend/src/app/management/components/settings/email-receiver-settings/emailfilter.ts new file mode 100644 index 00000000..c740fa4a --- /dev/null +++ b/apps/frontend/src/app/management/components/settings/email-receiver-settings/emailfilter.ts @@ -0,0 +1,39 @@ +import { ClrDatagridFilterInterface } from '@clr/angular'; +import { Subject } from 'rxjs'; +import { EmailType } from '../../../../shared/types/email'; + +export class CustomEmailFilter implements ClrDatagridFilterInterface { + public ranges: { + mail: string | null; + id: string | null; + } = { + mail: null, + id: null, + }; + + public changes = new Subject(); + public filterType: 'mail' | 'id'; + + constructor(filterType: 'mail' | 'id') { + this.filterType = filterType; + } + + isActive(): boolean { + if (this.filterType === 'mail') { + return !!(this.ranges.mail || this.ranges.mail); + } else if (this.filterType === 'id') { + return !!(this.ranges.id || this.ranges.id); + } else { + return !!this.ranges.id; + } + } + + accepts(backup: EmailType): boolean { + return true; + } + + updateRanges(ranges: Partial): void { + Object.assign(this.ranges, ranges); + this.changes.next(true); + } +} diff --git a/apps/frontend/src/app/management/components/settings/notification-settings/notification-settings.component.ts b/apps/frontend/src/app/management/components/settings/notification-settings/notification-settings.component.ts index 0e34694e..3da3d0a7 100644 --- a/apps/frontend/src/app/management/components/settings/notification-settings/notification-settings.component.ts +++ b/apps/frontend/src/app/management/components/settings/notification-settings/notification-settings.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormArray, FormBuilder, FormGroup } from '@angular/forms'; import { BehaviorSubject, forkJoin, Subject, takeUntil } from 'rxjs'; -import { NotificationService } from '../../../services/notification.service'; +import { NotificationService } from '../../../services/alert-notification/notification.service'; import { AlertType } from '../../../../shared/types/alertType'; import { AlertServiceService } from '../../../../alert/service/alert-service.service'; diff --git a/apps/frontend/src/app/management/services/notification.service.spec.ts b/apps/frontend/src/app/management/services/alert-notification/notification.service.spec.ts similarity index 96% rename from apps/frontend/src/app/management/services/notification.service.spec.ts rename to apps/frontend/src/app/management/services/alert-notification/notification.service.spec.ts index a5d84617..08080c78 100644 --- a/apps/frontend/src/app/management/services/notification.service.spec.ts +++ b/apps/frontend/src/app/management/services/alert-notification/notification.service.spec.ts @@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { HttpClient } from '@angular/common/http'; import { NotificationService } from './notification.service'; import { of } from 'rxjs'; -import { AlertType } from '../../shared/types/alertType'; -import { SeverityType } from '../../shared/enums/severityType'; +import { AlertType } from '../../../shared/types/alertType'; +import { SeverityType } from '../../../shared/enums/severityType'; describe('NotificationService', () => { let service: NotificationService; diff --git a/apps/frontend/src/app/management/services/notification.service.ts b/apps/frontend/src/app/management/services/alert-notification/notification.service.ts similarity index 84% rename from apps/frontend/src/app/management/services/notification.service.ts rename to apps/frontend/src/app/management/services/alert-notification/notification.service.ts index 42246c99..6c89b395 100644 --- a/apps/frontend/src/app/management/services/notification.service.ts +++ b/apps/frontend/src/app/management/services/alert-notification/notification.service.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@angular/core'; -import { BASE_URL } from '../../shared/types/configuration'; +import { BASE_URL } from '../../../shared/types/configuration'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { AlertType } from '../../shared/types/alertType'; +import { AlertType } from '../../../shared/types/alertType'; @Injectable({ providedIn: 'root', diff --git a/apps/frontend/src/app/management/services/email-receiver/email-receiver.service.spec.ts b/apps/frontend/src/app/management/services/email-receiver/email-receiver.service.spec.ts new file mode 100644 index 00000000..32ca3240 --- /dev/null +++ b/apps/frontend/src/app/management/services/email-receiver/email-receiver.service.spec.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { FormBuilder } from '@angular/forms'; +import { of, throwError } from 'rxjs'; +import { EmailReceiverService } from './email-receiver.service'; +import { EmailReceiverSettingsComponent } from '../../components/settings/email-receiver-settings/email-receiver-settings.component'; +import { ConfirmDialogService } from '../../../shared/components/confirm-dialog/service/confirm-dialog.service'; +import { EmailType } from '../../../shared/types/email'; + +describe('EmailReceiverSettingsComponent', () => { + let component: EmailReceiverSettingsComponent; + let emailService: EmailReceiverService; + let confirmDialogService: ConfirmDialogService; + + const mockEmails: EmailType[] = [ + { id: '1', mail: 'test1@example.com' }, + { id: '2', mail: 'test2@example.com' }, + ]; + + beforeEach(() => { + emailService = { + getAllEmailReceiver: vi.fn().mockReturnValue(of(mockEmails)), + deleteEmail: vi.fn().mockReturnValue(of({})), + updateEmailReceiver: vi.fn(), + } as any; + + confirmDialogService = { + handleConfirmation: vi.fn().mockImplementation((options, onConfirm) => { + onConfirm(); + return Promise.resolve(); + }), + } as any; + + component = new EmailReceiverSettingsComponent( + new FormBuilder(), + emailService, + confirmDialogService + ); + + component.ngOnInit(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('loadEmailReceiver', () => { + it('should load emails successfully', () => { + component.loadEmailReceiver(); + + expect(component.isLoading).toBe(false); + expect(emailService.getAllEmailReceiver).toHaveBeenCalled(); + + component['emailsSubject$'].subscribe((emails) => { + expect(emails).toEqual(mockEmails); + }); + }); + + it('should handle error when loading emails', () => { + emailService.getAllEmailReceiver = vi + .fn() + .mockReturnValue(throwError(() => new Error('Failed to load'))); + + component.loadEmailReceiver(); + + expect(component.isLoading).toBe(false); + expect(emailService.getAllEmailReceiver).toHaveBeenCalled(); + }); + }); + + describe('removeEmail', () => { + it('should not proceed if no emails to remove', async () => { + await component.removeEmail([]); + + expect(confirmDialogService.handleConfirmation).not.toHaveBeenCalled(); + expect(emailService.deleteEmail).not.toHaveBeenCalled(); + }); + + it('should delete emails when confirmed', async () => { + const emailsToRemove = [mockEmails[0]]; + + await component.removeEmail(emailsToRemove); + + expect(confirmDialogService.handleConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Delete Email Recipients', + confirmText: 'Delete', + cancelText: 'Cancel', + }), + expect.any(Function) + ); + expect(emailService.deleteEmail).toHaveBeenCalledWith('1'); + }); + + it('should handle cancel confirmation', async () => { + confirmDialogService.handleConfirmation = vi + .fn() + .mockImplementation((options, onConfirm, onCancel) => { + if (onCancel) onCancel(); + return Promise.resolve(); + }); + + await component.removeEmail([mockEmails[0]]); + + expect(emailService.deleteEmail).not.toHaveBeenCalled(); + }); + + it('should handle error during deletion', async () => { + emailService.deleteEmail = vi + .fn() + .mockReturnValue(throwError(() => new Error('Delete failed'))); + + await component.removeEmail([mockEmails[0]]); + + expect(component.isLoading).toBe(false); + }); + }); + + describe('saveChanges', () => { + it('should not save when form is invalid', () => { + component.emailForm.controls['email'].setValue(''); + + component.saveChanges(); + + expect(emailService.updateEmailReceiver).not.toHaveBeenCalled(); + }); + + it('should create new email when form is valid', () => { + const newEmail = { mail: 'new@example.com' }; + const createdEmail = { id: '3', mail: 'new@example.com' }; + + emailService.updateEmailReceiver = vi + .fn() + .mockReturnValue(of(createdEmail)); + component.emailForm.controls['email'].setValue('new@example.com'); + + component.saveChanges(); + + expect(emailService.updateEmailReceiver).toHaveBeenCalledWith(newEmail); + expect(component.showEmailModal).toBe(false); + expect(component.isLoading).toBe(false); + }); + + it('should handle duplicate email error', () => { + emailService.updateEmailReceiver = vi + .fn() + .mockReturnValue(throwError(() => ({ status: 409 }))); + component.emailForm.controls['email'].setValue('existing@example.com'); + + component.saveChanges(); + + expect(component.modalError).toBe('This email already exists'); + expect(component.isLoading).toBe(false); + }); + }); + + describe('resetForm', () => { + it('should reset form and modal state', () => { + // Setup form with values + component.emailForm.controls['email'].setValue('test@example.com'); + component.showEmailModal = true; + component.modalError = 'Some error'; + component.isLoading = true; + + // Reset form + component.resetForm(); + + // Verify reset state + expect(component.emailForm.get('email')?.value).toBeNull(); + expect(component.showEmailModal).toBe(false); + expect(component.modalError).toBe(''); + expect(component.isLoading).toBe(false); + }); + }); + + describe('ngOnDestroy', () => { + it('should complete subjects', () => { + const destroySpy = vi.spyOn(component['destroy$'], 'complete'); + + component.ngOnDestroy(); + + expect(destroySpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/app/management/services/email-receiver/email-receiver.service.ts b/apps/frontend/src/app/management/services/email-receiver/email-receiver.service.ts new file mode 100644 index 00000000..bfa6f0b8 --- /dev/null +++ b/apps/frontend/src/app/management/services/email-receiver/email-receiver.service.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@angular/core'; +import { BASE_URL } from '../../../shared/types/configuration'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { catchError, Observable, throwError } from 'rxjs'; +import { EmailType } from '../../../shared/types/email'; + +@Injectable({ + providedIn: 'root', +}) +export class EmailReceiverService { + constructor( + @Inject(BASE_URL) private readonly baseUrl: string, + private readonly http: HttpClient + ) {} + + getAllEmailReceiver(): Observable { + return this.http.get(`${this.baseUrl}/mail`); + } + + updateEmailReceiver(email: Partial): Observable { + return this.http + .post(`${this.baseUrl}/mail`, { + mail: email.mail, + }) + .pipe(catchError(this.handleError)); + } + + deleteEmail(id: string): Observable { + return this.http + .delete(`${this.baseUrl}/mail/${id}`) + .pipe(catchError(this.handleError)); + } + + private handleError(error: HttpErrorResponse) { + let errorMessage = 'An error occurred'; + if (error.error instanceof ErrorEvent) { + // Client-side error + errorMessage = error.error.message; + } else { + // Server-side error + errorMessage = + error.status === 409 + ? 'Email already exists' + : `Error Code: ${error.status}\nMessage: ${error.message}`; + } + return throwError(() => error); + } +} diff --git a/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.css b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.css new file mode 100644 index 00000000..e69de29b diff --git a/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.html b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.html new file mode 100644 index 00000000..3220ffb0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.html @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.spec.ts b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.spec.ts new file mode 100644 index 00000000..b82162b7 --- /dev/null +++ b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.spec.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfirmDialogComponent } from './confirm-dialog.component'; + +describe('ConfirmDialogComponent', () => { + let component: ConfirmDialogComponent; + + beforeEach(() => { + component = new ConfirmDialogComponent(); + }); + + describe('initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with default values', () => { + expect(component.isOpen).toBe(false); + expect(component.title).toBe('Confirm Action'); + expect(component.message).toBe('Are you sure you want to proceed?'); + expect(component.confirmText).toBe('Confirm'); + expect(component.cancelText).toBe('Cancel'); + expect(component.confirmButtonClass).toBe('btn btn-primary'); + expect(component.onConfirm).toBeDefined(); + expect(component.onCancel).toBeDefined(); + }); + + it('should have default no-op functions for onConfirm and onCancel', () => { + expect(() => component.onConfirm()).not.toThrow(); + expect(() => component.onCancel()).not.toThrow(); + }); + }); + + describe('confirm action', () => { + it('should call onConfirm and close dialog when confirm is called', () => { + const mockOnConfirm = vi.fn(); + component.isOpen = true; + component.onConfirm = mockOnConfirm; + + component.confirm(); + + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + expect(component.isOpen).toBe(false); + }); + + it('should handle undefined onConfirm without error', () => { + component.isOpen = true; + component.onConfirm = undefined as any; + + expect(() => component.confirm()).not.toThrow(); + expect(component.isOpen).toBe(false); + }); + }); + + describe('cancel action', () => { + it('should call onCancel and close dialog when cancel is called', () => { + const mockOnCancel = vi.fn(); + component.isOpen = true; + component.onCancel = mockOnCancel; + + component.cancel(); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + expect(component.isOpen).toBe(false); + }); + + it('should handle undefined onCancel without error', () => { + component.isOpen = true; + component.onCancel = undefined as any; + + expect(() => component.cancel()).not.toThrow(); + expect(component.isOpen).toBe(false); + }); + }); + + describe('input properties', () => { + it('should accept custom button class', () => { + const customClass = 'btn btn-danger'; + component.confirmButtonClass = customClass; + expect(component.confirmButtonClass).toBe(customClass); + }); + + it('should accept custom title and message', () => { + const customTitle = 'Delete Item'; + const customMessage = 'Do you want to delete this item?'; + + component.title = customTitle; + component.message = customMessage; + + expect(component.title).toBe(customTitle); + expect(component.message).toBe(customMessage); + }); + + it('should accept custom button text', () => { + const customConfirmText = 'Delete'; + const customCancelText = 'Keep'; + + component.confirmText = customConfirmText; + component.cancelText = customCancelText; + + expect(component.confirmText).toBe(customConfirmText); + expect(component.cancelText).toBe(customCancelText); + }); + + it('should handle empty string inputs', () => { + component.title = ''; + component.message = ''; + component.confirmText = ''; + component.cancelText = ''; + component.confirmButtonClass = ''; + + expect(component.title).toBe(''); + expect(component.message).toBe(''); + expect(component.confirmText).toBe(''); + expect(component.cancelText).toBe(''); + expect(component.confirmButtonClass).toBe(''); + }); + }); + + describe('dialog state', () => { + it('should toggle isOpen state', () => { + expect(component.isOpen).toBe(false); + + component.isOpen = true; + expect(component.isOpen).toBe(true); + + component.isOpen = false; + expect(component.isOpen).toBe(false); + }); + + it('should close dialog after confirm regardless of callback execution', () => { + component.isOpen = true; + + component.confirm(); + expect(component.isOpen).toBe(false); + }); + + it('should close dialog after cancel regardless of callback execution', () => { + component.isOpen = true; + component.cancel(); + expect(component.isOpen).toBe(false); + }); + }); +}); diff --git a/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.ts b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 00000000..0a0e5935 --- /dev/null +++ b/apps/frontend/src/app/shared/components/confirm-dialog/component/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-confirm-dialog', + templateUrl: './confirm-dialog.component.html', + styleUrl: './confirm-dialog.component.css', +}) +export class ConfirmDialogComponent { + @Input() isOpen = false; + @Input() title = 'Confirm Action'; + @Input() message = 'Are you sure you want to proceed?'; + @Input() confirmText = 'Confirm'; + @Input() cancelText = 'Cancel'; + @Input() confirmButtonClass = 'btn btn-primary'; + @Input() onConfirm: () => void = () => {}; + @Input() onCancel: () => void = () => {}; + + confirm() { + if (this.onConfirm) { + this.onConfirm(); + } + this.isOpen = false; + } + + cancel() { + if (this.onCancel) { + this.onCancel(); + } + this.isOpen = false; + } +} diff --git a/apps/frontend/src/app/shared/components/confirm-dialog/service/confirm-dialog.service.spec.ts b/apps/frontend/src/app/shared/components/confirm-dialog/service/confirm-dialog.service.spec.ts new file mode 100644 index 00000000..0615d09c --- /dev/null +++ b/apps/frontend/src/app/shared/components/confirm-dialog/service/confirm-dialog.service.spec.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ConfirmDialogService } from './confirm-dialog.service'; +import { ConfirmDialogComponent } from '../component/confirm-dialog/confirm-dialog.component'; +import { + ApplicationRef, + ComponentFactoryResolver, + ComponentRef, + Injector, + EmbeddedViewRef, +} from '@angular/core'; + +describe('ConfirmDialogService', () => { + let service: ConfirmDialogService; + let componentFactoryResolver: ComponentFactoryResolver; + let appRef: ApplicationRef; + let injector: Injector; + let mockComponentRef: Partial>; + let mockHostView: Partial>; + + beforeEach(() => { + // Setup mock host view + mockHostView = { + detectChanges: vi.fn(), + markForCheck: vi.fn(), + detach: vi.fn(), + destroy: vi.fn(), + destroyed: false, + rootNodes: [document.createElement('div')], + } as Partial>; + + // Setup mock component reference + mockComponentRef = { + instance: { + isOpen: false, + title: '', + message: '', + confirmText: '', + cancelText: '', + confirmButtonClass: '', + onConfirm: vi.fn(), + onCancel: vi.fn(), + confirm: function (): void { + throw new Error('Function not implemented.'); + }, + cancel: function (): void { + throw new Error('Function not implemented.'); + }, + }, + hostView: mockHostView as any, + destroy: vi.fn(), + }; + + // Setup service dependencies + componentFactoryResolver = { + resolveComponentFactory: vi.fn().mockReturnValue({ + create: vi.fn().mockReturnValue(mockComponentRef), + }), + } as any; + + appRef = { + attachView: vi.fn(), + detachView: vi.fn(), + } as any; + + injector = {} as Injector; + + // Create service + service = new ConfirmDialogService( + componentFactoryResolver, + appRef, + injector + ); + + // Mock document.body.appendChild + vi.spyOn(document.body, 'appendChild').mockImplementation( + () => null as any + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('initialization', () => { + it('should create the service', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize with null modalComponentRef', () => { + expect(service['modalComponentRef']).toBeNull(); + }); + }); + + describe('confirm', () => { + it('should create and show confirmation dialog with default options', async () => { + const confirmPromise = service.confirm(); + + expect( + componentFactoryResolver.resolveComponentFactory + ).toHaveBeenCalledWith(ConfirmDialogComponent); + expect(appRef.attachView).toHaveBeenCalledWith(mockHostView); + expect(mockComponentRef.instance?.isOpen).toBe(true); + }); + + it('should create dialog with custom options', async () => { + const options = { + title: 'Custom Title', + message: 'Custom Message', + confirmText: 'Yes', + cancelText: 'No', + confirmButtonClass: 'btn-danger', + }; + + const confirmPromise = service.confirm(options); + + expect(mockComponentRef.instance?.title).toBe(options.title); + expect(mockComponentRef.instance?.message).toBe(options.message); + expect(mockComponentRef.instance?.confirmText).toBe(options.confirmText); + expect(mockComponentRef.instance?.cancelText).toBe(options.cancelText); + expect(mockComponentRef.instance?.confirmButtonClass).toBe( + options.confirmButtonClass + ); + }); + it('should resolve true and remove component on confirm', async () => { + const confirmPromise = service.confirm(); + + // Simulate confirmation + const { onConfirm } = mockComponentRef.instance as ConfirmDialogComponent; + onConfirm(); + + const result = await confirmPromise; + expect(result).toBe(true); + expect(appRef.detachView).toHaveBeenCalledWith(mockHostView); + expect(mockComponentRef.destroy).toHaveBeenCalled(); + }); + + it('should resolve false and remove component on cancel', async () => { + const confirmPromise = service.confirm(); + + // Simulate cancellation + const { onCancel } = mockComponentRef.instance as ConfirmDialogComponent; + (onCancel as Function)(); + + const result = await confirmPromise; + expect(result).toBe(false); + expect(appRef.detachView).toHaveBeenCalledWith(mockHostView); + expect(mockComponentRef.destroy).toHaveBeenCalled(); + }); + }); + + describe('handleConfirmation', () => { + it('should execute onConfirm callback when confirmed', async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const options = { title: 'Test' }; + + vi.spyOn(service, 'confirm').mockResolvedValue(true); + + await service.handleConfirmation(options, onConfirm, onCancel); + + expect(onConfirm).toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('should execute onCancel callback when cancelled', async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const options = { title: 'Test' }; + + vi.spyOn(service, 'confirm').mockResolvedValue(false); + + await service.handleConfirmation(options, onConfirm, onCancel); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should not call onCancel if not provided and cancelled', async () => { + const onConfirm = vi.fn(); + vi.spyOn(service, 'confirm').mockResolvedValue(false); + + await service.handleConfirmation({}, onConfirm); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('should handle errors in confirmation flow', async () => { + const onConfirm = vi.fn(); + const error = new Error('Test error'); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + vi.spyOn(service, 'confirm').mockRejectedValue(error); + + await service.handleConfirmation({}, onConfirm); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error in confirmation flow:', + error + ); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('should handle errors in onConfirm callback', async () => { + const error = new Error('Confirm error'); + const onConfirm = vi.fn().mockRejectedValue(error); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + vi.spyOn(service, 'confirm').mockResolvedValue(true); + + await service.handleConfirmation({}, onConfirm); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error in confirmation flow:', + error + ); + }); + }); + + describe('component lifecycle', () => { + it('should properly clean up when removing component', () => { + service['modalComponentRef'] = + mockComponentRef as ComponentRef; + + service['removeComponent'](); + + expect(appRef.detachView).toHaveBeenCalledWith(mockHostView); + expect(mockComponentRef.destroy).toHaveBeenCalled(); + expect(service['modalComponentRef']).toBeNull(); + }); + + it('should handle removeComponent when modalComponentRef is null', () => { + service['modalComponentRef'] = null; + + expect(() => service['removeComponent']()).not.toThrow(); + expect(appRef.detachView).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/app/shared/components/confirm-dialog/service/confirm-dialog.service.ts b/apps/frontend/src/app/shared/components/confirm-dialog/service/confirm-dialog.service.ts new file mode 100644 index 00000000..261a6514 --- /dev/null +++ b/apps/frontend/src/app/shared/components/confirm-dialog/service/confirm-dialog.service.ts @@ -0,0 +1,82 @@ +import { + ApplicationRef, + ComponentFactoryResolver, + ComponentRef, + EmbeddedViewRef, + Injectable, + Injector, + Type, +} from '@angular/core'; +import { ConfirmDialogComponent } from '../component/confirm-dialog/confirm-dialog.component'; +import { ConfirmationOptions } from '../../../types/confirmationOptions'; + +@Injectable({ + providedIn: 'root', +}) +export class ConfirmDialogService { + private modalComponentRef: ComponentRef | null = null; + + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + private appRef: ApplicationRef, + private injector: Injector + ) {} + + confirm(options: ConfirmationOptions = {}): Promise { + return new Promise((resolve) => { + this.modalComponentRef = this.createComponent(ConfirmDialogComponent); + Object.assign(this.modalComponentRef.instance, { + isOpen: true, + ...options, + onConfirm: () => { + resolve(true); + this.removeComponent(); + }, + onCancel: () => { + resolve(false); + this.removeComponent(); + }, + }); + }); + } + + private createComponent(component: Type): ComponentRef { + const componentRef = this.componentFactoryResolver + .resolveComponentFactory(component) + .create(this.injector); + + this.appRef.attachView(componentRef.hostView); + + const domElem = (componentRef.hostView as EmbeddedViewRef) + .rootNodes[0]; + document.body.appendChild(domElem); + + return componentRef; + } + + private removeComponent(): void { + if (this.modalComponentRef) { + this.appRef.detachView(this.modalComponentRef.hostView); + this.modalComponentRef.destroy(); + this.modalComponentRef = null; + } + } + + async handleConfirmation( + options: ConfirmationOptions, + onConfirm: () => void, + onCancel?: () => void + ): Promise { + try { + const result = await this.confirm(options); + + if (result) { + await onConfirm(); + } else if (onCancel) { + await onCancel(); + } + } catch (error) { + console.error('Error in confirmation flow:', error); + } + } +} diff --git a/apps/frontend/src/app/shared/types/confirmationOptions.ts b/apps/frontend/src/app/shared/types/confirmationOptions.ts new file mode 100644 index 00000000..b186d17e --- /dev/null +++ b/apps/frontend/src/app/shared/types/confirmationOptions.ts @@ -0,0 +1,7 @@ +export interface ConfirmationOptions { + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + confirmButtonClass?: string; +} diff --git a/apps/frontend/src/app/shared/types/email.ts b/apps/frontend/src/app/shared/types/email.ts new file mode 100644 index 00000000..afa5120a --- /dev/null +++ b/apps/frontend/src/app/shared/types/email.ts @@ -0,0 +1,4 @@ +export interface EmailType { + id: string; + mail: string; +} \ No newline at end of file