From 31d23dc4c775732b0f30d5d5b36cd6f3eada0952 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Wed, 6 Sep 2023 15:50:42 -0700 Subject: [PATCH] Add Notification Documents * Add Notification Documents and Other Attachments Page --- .../edit-submission.component.html | 11 +- .../edit-submission.component.spec.ts | 5 + .../edit-submission.component.ts | 18 +- .../edit-submission/edit-submission.module.ts | 2 + .../edit-submission/files-step.partial.ts | 24 +- .../other-attachments.component.html | 104 ++++++ .../other-attachments.component.scss | 38 ++ .../other-attachments.component.spec.ts | 68 ++++ .../other-attachments.component.ts | 121 +++++++ .../notification-document.dto.ts | 18 + .../notification-document.service.spec.ts | 109 ++++++ .../notification-document.service.ts | 103 ++++++ services/apps/alcs/src/alcs/alcs.module.ts | 3 + .../notification-document.controller.spec.ts | 189 ++++++++++ .../notification-document.controller.ts | 211 +++++++++++ .../notification-document.dto.ts | 29 ++ .../notification-document.entity.ts | 67 ++++ .../notification-document.service.spec.ts | 342 ++++++++++++++++++ .../notification-document.service.ts | 305 ++++++++++++++++ .../alcs/notification/notification.entity.ts | 6 + .../alcs/notification/notification.module.ts | 20 +- .../notification.automapper.profile.ts | 41 ++- .../notification-document.controller.spec.ts | 197 ++++++++++ .../notification-document.controller.ts | 172 +++++++++ .../notification-document.dto.ts | 30 ++ .../notification-document.module.ts | 13 + ...notification-submission.controller.spec.ts | 4 +- .../apps/alcs/src/portal/portal.module.ts | 3 + ...694037493007-add_notification_documents.ts | 35 ++ 29 files changed, 2262 insertions(+), 26 deletions(-) create mode 100644 portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.html create mode 100644 portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.scss create mode 100644 portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.spec.ts create mode 100644 portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts create mode 100644 portal-frontend/src/app/services/notification-document/notification-document.dto.ts create mode 100644 portal-frontend/src/app/services/notification-document/notification-document.service.spec.ts create mode 100644 portal-frontend/src/app/services/notification-document/notification-document.service.ts create mode 100644 services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.spec.ts create mode 100644 services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.ts create mode 100644 services/apps/alcs/src/alcs/notification/notification-document/notification-document.dto.ts create mode 100644 services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts create mode 100644 services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts create mode 100644 services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts create mode 100644 services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts create mode 100644 services/apps/alcs/src/portal/notification-document/notification-document.controller.ts create mode 100644 services/apps/alcs/src/portal/notification-document/notification-document.dto.ts create mode 100644 services/apps/alcs/src/portal/notification-document/notification-document.module.ts create mode 100644 services/apps/alcs/src/providers/typeorm/migrations/1694037493007-add_notification_documents.ts diff --git a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.html b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.html index b906c29f4c..6e94936da9 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.html +++ b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.html @@ -77,7 +77,16 @@
-
+
+ + +
diff --git a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.spec.ts b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.spec.ts index cc48c1cd01..72e2b1f4e7 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.spec.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.spec.ts @@ -2,6 +2,7 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute } from '@angular/router'; +import { NotificationDocumentService } from '../../../services/notification-document/notification-document.service'; import { NotificationSubmissionService } from '../../../services/notification-submission/notification-submission.service'; import { ToastService } from '../../../services/toast/toast.service'; @@ -19,6 +20,10 @@ describe('EditSubmissionComponent', () => { provide: NotificationSubmissionService, useValue: {}, }, + { + provide: NotificationDocumentService, + useValue: {}, + }, { provide: ToastService, useValue: {}, diff --git a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.ts b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.ts index 2d718d73e8..695ab88ad4 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.component.ts @@ -4,6 +4,8 @@ import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { BehaviorSubject, combineLatest, Observable, of, Subject, takeUntil } from 'rxjs'; import { NoticeOfIntentDocumentDto } from '../../../services/notice-of-intent-document/notice-of-intent-document.dto'; +import { NotificationDocumentDto } from '../../../services/notification-document/notification-document.dto'; +import { NotificationDocumentService } from '../../../services/notification-document/notification-document.service'; import { NOTIFICATION_STATUS, NotificationSubmissionDetailedDto, @@ -13,6 +15,7 @@ import { ToastService } from '../../../services/toast/toast.service'; import { CustomStepperComponent } from '../../../shared/custom-stepper/custom-stepper.component'; import { OverlaySpinnerService } from '../../../shared/overlay-spinner/overlay-spinner.service'; import { scrollToElement } from '../../../shared/utils/scroll-helper'; +import { OtherAttachmentsComponent } from './other-attachments/other-attachments.component'; import { ParcelDetailsComponent } from './parcels/parcel-details.component'; import { PrimaryContactComponent } from './primary-contact/primary-contact.component'; import { SelectGovernmentComponent } from './select-government/select-government.component'; @@ -37,7 +40,7 @@ export class EditSubmissionComponent implements OnDestroy, AfterViewInit { $destroy = new Subject(); $notificationSubmission = new BehaviorSubject(undefined); - $notificationDocuments = new BehaviorSubject([]); + $notificationDocuments = new BehaviorSubject([]); notificationSubmission: NotificationSubmissionDetailedDto | undefined; steps = EditNotificationSteps; @@ -48,9 +51,11 @@ export class EditSubmissionComponent implements OnDestroy, AfterViewInit { @ViewChild(ParcelDetailsComponent) parcelDetailsComponent!: ParcelDetailsComponent; @ViewChild(PrimaryContactComponent) primaryContactComponent!: PrimaryContactComponent; @ViewChild(SelectGovernmentComponent) selectGovernmentComponent!: SelectGovernmentComponent; + @ViewChild(OtherAttachmentsComponent) otherAttachmentsComponent!: OtherAttachmentsComponent; constructor( private notificationSubmissionService: NotificationSubmissionService, + private notificationDocumentService: NotificationDocumentService, private activatedRoute: ActivatedRoute, private dialog: MatDialog, private toastService: ToastService, @@ -149,7 +154,12 @@ export class EditSubmissionComponent implements OnDestroy, AfterViewInit { } break; case EditNotificationSteps.Proposal: + break; case EditNotificationSteps.Attachments: + if (this.otherAttachmentsComponent) { + await this.otherAttachmentsComponent.onSave(); + } + break; case EditNotificationSteps.ReviewAndSubmit: //DO NOTHING break; @@ -164,10 +174,6 @@ export class EditSubmissionComponent implements OnDestroy, AfterViewInit { } } - onChangeSubmissionType() { - //TODO - } - async onSubmit() { //TODO } @@ -193,7 +199,7 @@ export class EditSubmissionComponent implements OnDestroy, AfterViewInit { await this.router.navigateByUrl(`/home`); } - const documents: NoticeOfIntentDocumentDto[] = []; //TODO await this.noticeOfIntentDocumentService.getByFileId(fileId); + const documents = await this.notificationDocumentService.getByFileId(fileId); if (documents) { this.$notificationDocuments.next(documents); } diff --git a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.module.ts b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.module.ts index 6124c74f91..08d1f751b4 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.module.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/edit-submission.module.ts @@ -8,6 +8,7 @@ import { NgxMaskPipe } from 'ngx-mask'; import { CanDeactivateGuard } from '../../../shared/guard/can-deactivate.guard'; import { SharedModule } from '../../../shared/shared.module'; import { EditSubmissionComponent } from './edit-submission.component'; +import { OtherAttachmentsComponent } from './other-attachments/other-attachments.component'; import { DeleteParcelDialogComponent } from './parcels/delete-parcel/delete-parcel-dialog.component'; import { ParcelDetailsComponent } from './parcels/parcel-details.component'; import { ParcelEntryConfirmationDialogComponent } from './parcels/parcel-entry/parcel-entry-confirmation-dialog/parcel-entry-confirmation-dialog.component'; @@ -42,6 +43,7 @@ const routes: Routes = [ TransfereeDialogComponent, PrimaryContactComponent, SelectGovernmentComponent, + OtherAttachmentsComponent, ], imports: [ CommonModule, diff --git a/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts b/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts index 000753042e..152f173c0a 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts @@ -1,8 +1,8 @@ import { Component, Input } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { BehaviorSubject } from 'rxjs'; -import { NoticeOfIntentDocumentDto } from '../../../services/notice-of-intent-document/notice-of-intent-document.dto'; -import { NoticeOfIntentDocumentService } from '../../../services/notice-of-intent-document/notice-of-intent-document.service'; +import { NotificationDocumentDto } from '../../../services/notification-document/notification-document.dto'; +import { NotificationDocumentService } from '../../../services/notification-document/notification-document.service'; import { DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { FileHandle } from '../../../shared/file-drag-drop/drag-drop.directive'; import { StepComponent } from './step.partial'; @@ -13,7 +13,7 @@ import { StepComponent } from './step.partial'; styleUrls: [], }) export abstract class FilesStepComponent extends StepComponent { - @Input() $noiDocuments!: BehaviorSubject; + @Input() $notificationDocuments!: BehaviorSubject; DOCUMENT_TYPE = DOCUMENT_TYPE; @@ -22,7 +22,7 @@ export abstract class FilesStepComponent extends StepComponent { protected abstract save(): Promise; protected constructor( - protected noticeOfIntentDocumentService: NoticeOfIntentDocumentService, + protected notificationDocumentService: NotificationDocumentService, protected dialog: MatDialog ) { super(); @@ -32,26 +32,26 @@ export abstract class FilesStepComponent extends StepComponent { if (this.fileId) { await this.save(); const mappedFiles = file.file; - await this.noticeOfIntentDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); - const documents = await this.noticeOfIntentDocumentService.getByFileId(this.fileId); + await this.notificationDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + const documents = await this.notificationDocumentService.getByFileId(this.fileId); if (documents) { - this.$noiDocuments.next(documents); + this.$notificationDocuments.next(documents); } } } - async onDeleteFile($event: NoticeOfIntentDocumentDto) { - await this.noticeOfIntentDocumentService.deleteExternalFile($event.uuid); + async onDeleteFile($event: NotificationDocumentDto) { + await this.notificationDocumentService.deleteExternalFile($event.uuid); if (this.fileId) { - const documents = await this.noticeOfIntentDocumentService.getByFileId(this.fileId); + const documents = await this.notificationDocumentService.getByFileId(this.fileId); if (documents) { - this.$noiDocuments.next(documents); + this.$notificationDocuments.next(documents); } } } async openFile(uuid: string) { - const res = await this.noticeOfIntentDocumentService.openFile(uuid); + const res = await this.notificationDocumentService.openFile(uuid); if (res) { window.open(res.url, '_blank'); } diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.html b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.html new file mode 100644 index 0000000000..ccd065dabd --- /dev/null +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.html @@ -0,0 +1,104 @@ +
+

Optional Attachments

+

+ Please upload any optional supporting documents. Where possible, provide KML/KMZ Google Earth files or GIS + shapefiles and geodatabases. +

+
+
+

Upload Optional Attachments (Max. 100 MB per attachment)

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Type + + + {{ type.label }} + + +
+ warning +
This field is required
+
+
Description + + + +
+ warning +
+ This field is required +
+
+
File Name + {{ element.fileName }} + Action + +
No attachments
+
+
+
+ +
+
+
+ +
+ + +
+
diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.scss b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.scss new file mode 100644 index 0000000000..7a223440c8 --- /dev/null +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.scss @@ -0,0 +1,38 @@ +@use '../../../../../styles/functions' as *; +@use '../../../../../styles/colors'; + +section { + margin-top: rem(32); +} + +.uploader { + margin-top: rem(24); +} + +h4 { + margin-bottom: rem(8) !important; +} + +.scrollable { + overflow-x: auto; +} + +.mat-mdc-table .mdc-data-table__row { + height: rem(75); +} + +.mat-mdc-form-field { + width: 100%; +} + +:host::ng-deep { + .mdc-text-field--invalid { + margin-top: rem(8); + } +} + +.no-data-text { + text-align: center; + color: colors.$grey; + padding-top: rem(12); +} diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.spec.ts b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.spec.ts new file mode 100644 index 0000000000..333944d2cc --- /dev/null +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.spec.ts @@ -0,0 +1,68 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { CodeService } from '../../../../services/code/code.service'; +import { NotificationDocumentDto } from '../../../../services/notification-document/notification-document.dto'; +import { NotificationDocumentService } from '../../../../services/notification-document/notification-document.service'; +import { NotificationSubmissionDetailedDto } from '../../../../services/notification-submission/notification-submission.dto'; +import { NotificationSubmissionService } from '../../../../services/notification-submission/notification-submission.service'; + +import { OtherAttachmentsComponent } from './other-attachments.component'; + +describe('OtherAttachmentsComponent', () => { + let component: OtherAttachmentsComponent; + let fixture: ComponentFixture; + let mockNotificationSubmissionService: DeepMocked; + let mockNotificationDocumentService: DeepMocked; + let mockRouter: DeepMocked; + let mockCodeService: DeepMocked; + + let documentPipe = new BehaviorSubject([]); + + beforeEach(async () => { + mockNotificationSubmissionService = createMock(); + mockNotificationDocumentService = createMock(); + mockRouter = createMock(); + mockCodeService = createMock(); + + await TestBed.configureTestingModule({ + providers: [ + { + provide: NotificationSubmissionService, + useValue: mockNotificationSubmissionService, + }, + { + provide: NotificationDocumentService, + useValue: mockNotificationDocumentService, + }, + { + provide: Router, + useValue: mockRouter, + }, + { + provide: CodeService, + useValue: mockCodeService, + }, + { + provide: MatDialog, + useValue: {}, + }, + ], + declarations: [OtherAttachmentsComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(OtherAttachmentsComponent); + component = fixture.componentInstance; + component.$notificationSubmission = new BehaviorSubject(undefined); + component.$notificationDocuments = documentPipe; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts new file mode 100644 index 0000000000..920bc18586 --- /dev/null +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts @@ -0,0 +1,121 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { takeUntil } from 'rxjs'; +import { CodeService } from '../../../../services/code/code.service'; +import { + NotificationDocumentDto, + NotificationDocumentUpdateDto, +} from '../../../../services/notification-document/notification-document.dto'; +import { NotificationDocumentService } from '../../../../services/notification-document/notification-document.service'; +import { NotificationSubmissionService } from '../../../../services/notification-submission/notification-submission.service'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from '../../../../shared/dto/document.dto'; +import { EditNotificationSteps } from '../edit-submission.component'; +import { FilesStepComponent } from '../files-step.partial'; + +const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; + +@Component({ + selector: 'app-other-attachments', + templateUrl: './other-attachments.component.html', + styleUrls: ['./other-attachments.component.scss'], +}) +export class OtherAttachmentsComponent extends FilesStepComponent implements OnInit, OnDestroy { + currentStep = EditNotificationSteps.Attachments; + + displayedColumns = ['type', 'description', 'fileName', 'actions']; + selectableTypes: DocumentTypeDto[] = []; + otherFiles: NotificationDocumentDto[] = []; + + private isDirty = false; + + form = new FormGroup({} as any); + private documentCodes: DocumentTypeDto[] = []; + + constructor( + private router: Router, + private applicationService: NotificationSubmissionService, + private codeService: CodeService, + notificationDocumentService: NotificationDocumentService, + dialog: MatDialog + ) { + super(notificationDocumentService, dialog); + } + + ngOnInit(): void { + this.$notificationSubmission.pipe(takeUntil(this.$destroy)).subscribe((submission) => { + if (submission) { + this.fileId = submission.fileNumber; + } + }); + + this.loadDocumentCodes(); + + this.$notificationDocuments.pipe(takeUntil(this.$destroy)).subscribe((documents) => { + this.otherFiles = documents + .filter((file) => (file.type ? USER_CONTROLLED_TYPES.includes(file.type.code) : true)) + .filter((file) => file.source === DOCUMENT_SOURCE.APPLICANT) + .sort((a, b) => { + return a.uploadedAt - b.uploadedAt; + }); + const newForm = new FormGroup({}); + for (const file of this.otherFiles) { + newForm.addControl(`${file.uuid}-type`, new FormControl(file.type?.code, [Validators.required])); + newForm.addControl(`${file.uuid}-description`, new FormControl(file.description, [Validators.required])); + } + this.form = newForm; + if (this.showErrors) { + this.form.markAllAsTouched(); + } + }); + } + + async onSave() { + await this.save(); + } + + protected async save() { + if (this.isDirty) { + const updateDtos: NotificationDocumentUpdateDto[] = this.otherFiles.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + await this.notificationDocumentService.update(this.fileId, updateDtos); + } + } + + onChangeDescription(uuid: string, event: Event) { + this.isDirty = true; + const input = event.target as HTMLInputElement; + const description = input.value; + this.otherFiles = this.otherFiles.map((file) => { + if (uuid === file.uuid) { + file.description = description; + } + return file; + }); + } + + onChangeType(uuid: string, selectedValue: DOCUMENT_TYPE) { + this.isDirty = true; + this.otherFiles = this.otherFiles.map((file) => { + if (uuid === file.uuid) { + const newType = this.documentCodes.find((code) => code.code === selectedValue); + if (newType) { + file.type = newType; + } else { + console.error('Failed to find matching document type'); + } + } + return file; + }); + } + + private async loadDocumentCodes() { + const codes = await this.codeService.loadCodes(); + this.documentCodes = codes.documentTypes; + this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); + } +} diff --git a/portal-frontend/src/app/services/notification-document/notification-document.dto.ts b/portal-frontend/src/app/services/notification-document/notification-document.dto.ts new file mode 100644 index 0000000000..9e345f8edf --- /dev/null +++ b/portal-frontend/src/app/services/notification-document/notification-document.dto.ts @@ -0,0 +1,18 @@ +import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from '../../shared/dto/document.dto'; + +export interface NotificationDocumentDto { + type: DocumentTypeDto | null; + description?: string | null; + uuid: string; + fileName: string; + fileSize: number; + uploadedBy: string; + uploadedAt: number; + source: DOCUMENT_SOURCE; +} + +export interface NotificationDocumentUpdateDto { + uuid: string; + type: DOCUMENT_TYPE | null; + description?: string | null; +} diff --git a/portal-frontend/src/app/services/notification-document/notification-document.service.spec.ts b/portal-frontend/src/app/services/notification-document/notification-document.service.spec.ts new file mode 100644 index 0000000000..b5d044ac77 --- /dev/null +++ b/portal-frontend/src/app/services/notification-document/notification-document.service.spec.ts @@ -0,0 +1,109 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { of, throwError } from 'rxjs'; +import { ToastService } from '../toast/toast.service'; +import { NotificationDocumentService } from './notification-document.service'; + +describe('NotificationDocumentService', () => { + let service: NotificationDocumentService; + let mockToastService: DeepMocked; + let mockHttpClient: DeepMocked; + + beforeEach(() => { + mockToastService = createMock(); + mockHttpClient = createMock(); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: ToastService, + useValue: mockToastService, + }, + { + provide: HttpClient, + useValue: mockHttpClient, + }, + ], + }); + service = TestBed.inject(NotificationDocumentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should make a get request for open file', async () => { + mockHttpClient.get.mockReturnValue(of({})); + + await service.openFile('fileId'); + + expect(mockHttpClient.get).toHaveBeenCalledTimes(1); + expect(mockHttpClient.get.mock.calls[0][0]).toContain('notification-document'); + }); + + it('should show an error toast if opening a file fails', async () => { + mockHttpClient.get.mockReturnValue(throwError(() => ({}))); + + await service.openFile('fileId'); + + expect(mockHttpClient.get).toHaveBeenCalledTimes(1); + expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make a delete request for delete file', async () => { + mockHttpClient.delete.mockReturnValue(of({})); + + await service.deleteExternalFile('fileId'); + + expect(mockHttpClient.delete).toHaveBeenCalledTimes(1); + expect(mockHttpClient.delete.mock.calls[0][0]).toContain('notification-document'); + }); + + it('should show an error toast if deleting a file fails', async () => { + mockHttpClient.delete.mockReturnValue(throwError(() => ({}))); + + await service.deleteExternalFile('fileId'); + + expect(mockHttpClient.delete).toHaveBeenCalledTimes(1); + expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make a patch request for update file', async () => { + mockHttpClient.patch.mockReturnValue(of({})); + + await service.update('fileId', []); + + expect(mockHttpClient.patch).toHaveBeenCalledTimes(1); + expect(mockHttpClient.patch.mock.calls[0][0]).toContain('notification-document'); + }); + + it('should show an error toast if updating a file fails', async () => { + mockHttpClient.patch.mockReturnValue(throwError(() => ({}))); + + await service.update('fileId', []); + + expect(mockHttpClient.patch).toHaveBeenCalledTimes(1); + expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make a post request for deleting multiple files', async () => { + mockHttpClient.post.mockReturnValue(of({})); + + await service.deleteExternalFiles(['fileId']); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + expect(mockHttpClient.post.mock.calls[0][0]).toContain('notification-document'); + }); + + it('should show an error toast if deleting a file fails', async () => { + mockHttpClient.post.mockReturnValue(throwError(() => ({}))); + + await service.deleteExternalFiles(['fileId']); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); + }); +}); diff --git a/portal-frontend/src/app/services/notification-document/notification-document.service.ts b/portal-frontend/src/app/services/notification-document/notification-document.service.ts new file mode 100644 index 0000000000..60eff39d81 --- /dev/null +++ b/portal-frontend/src/app/services/notification-document/notification-document.service.ts @@ -0,0 +1,103 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../shared/dto/document.dto'; +import { OverlaySpinnerService } from '../../shared/overlay-spinner/overlay-spinner.service'; +import { DocumentService } from '../document/document.service'; +import { ToastService } from '../toast/toast.service'; +import { NotificationDocumentDto, NotificationDocumentUpdateDto } from './notification-document.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class NotificationDocumentService { + private serviceUrl = `${environment.apiUrl}/notification-document`; + + constructor( + private httpClient: HttpClient, + private toastService: ToastService, + private documentService: DocumentService, + private overlayService: OverlaySpinnerService + ) {} + + async attachExternalFile( + fileNumber: string, + file: File, + documentType: DOCUMENT_TYPE | null, + source = DOCUMENT_SOURCE.APPLICANT + ) { + try { + const res = await this.documentService.uploadFile( + fileNumber, + file, + documentType, + source, + `${this.serviceUrl}/notification/${fileNumber}/attachExternal` + ); + this.toastService.showSuccessToast('Document uploaded'); + return res; + } catch (e) { + console.error(e); + this.toastService.showErrorToast('Failed to attach document, please try again'); + } + return undefined; + } + + async openFile(fileUuid: string) { + try { + return await firstValueFrom(this.httpClient.get<{ url: string }>(`${this.serviceUrl}/${fileUuid}/open`)); + } catch (e) { + console.error(e); + this.toastService.showErrorToast('Failed to open the document, please try again'); + } + return undefined; + } + + async deleteExternalFile(fileUuid: string) { + try { + this.overlayService.showSpinner(); + await firstValueFrom(this.httpClient.delete(`${this.serviceUrl}/${fileUuid}`)); + this.toastService.showSuccessToast('Document deleted'); + } catch (e) { + console.error(e); + this.toastService.showErrorToast('Failed to delete document, please try again'); + } finally { + this.overlayService.hideSpinner(); + } + } + + async deleteExternalFiles(fileUuids: string[]) { + try { + this.overlayService.showSpinner(); + await firstValueFrom(this.httpClient.post(`${this.serviceUrl}/delete-files`, fileUuids)); + } catch (e) { + console.error(e); + this.toastService.showErrorToast('Failed to delete documents'); + } finally { + this.overlayService.hideSpinner(); + } + } + + async update(fileNumber: string | undefined, updateDtos: NotificationDocumentUpdateDto[]) { + try { + await firstValueFrom(this.httpClient.patch(`${this.serviceUrl}/notification/${fileNumber}`, updateDtos)); + } catch (e) { + console.error(e); + this.toastService.showErrorToast('Failed to update documents, please try again'); + } + return undefined; + } + + async getByFileId(fileNumber: string) { + try { + return await firstValueFrom( + this.httpClient.get(`${this.serviceUrl}/notification/${fileNumber}`) + ); + } catch (e) { + console.error(e); + this.toastService.showErrorToast('Failed to fetch documents, please try again'); + } + return undefined; + } +} diff --git a/services/apps/alcs/src/alcs/alcs.module.ts b/services/apps/alcs/src/alcs/alcs.module.ts index 8a49f98dd7..29bbc475a5 100644 --- a/services/apps/alcs/src/alcs/alcs.module.ts +++ b/services/apps/alcs/src/alcs/alcs.module.ts @@ -19,6 +19,7 @@ import { NoticeOfIntentTimelineModule } from './notice-of-intent/notice-of-inten import { NoticeOfIntentSubmissionStatusModule } from './notice-of-intent/notice-of-intent-submission-status/notice-of-intent-submission-status.module'; import { NoticeOfIntentModule } from './notice-of-intent/notice-of-intent.module'; import { MessageModule } from './message/message.module'; +import { NotificationModule } from './notification/notification.module'; import { PlanningReviewModule } from './planning-review/planning-review.module'; import { SearchModule } from './search/search.module'; import { StaffJournalModule } from './staff-journal/staff-journal.module'; @@ -45,6 +46,7 @@ import { StaffJournalModule } from './staff-journal/staff-journal.module'; NoticeOfIntentTimelineModule, SearchModule, LocalGovernmentModule, + NotificationModule, RouterModule.register([ { path: 'alcs', module: ApplicationModule }, { path: 'alcs', module: CommentModule }, @@ -67,6 +69,7 @@ import { StaffJournalModule } from './staff-journal/staff-journal.module'; { path: 'alcs', module: LocalGovernmentModule }, { path: 'alcs', module: ApplicationTimelineModule }, { path: 'alcs', module: NoticeOfIntentTimelineModule }, + { path: 'alcs', module: NotificationModule }, ]), ], controllers: [], diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.spec.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.spec.ts new file mode 100644 index 0000000000..99172858f8 --- /dev/null +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.spec.ts @@ -0,0 +1,189 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; +import { NotificationProfile } from '../../../common/automapper/notification.automapper.profile'; +import { DOCUMENT_TYPE } from '../../../document/document-code.entity'; +import { DOCUMENT_SOURCE } from '../../../document/document.dto'; +import { Document } from '../../../document/document.entity'; +import { User } from '../../../user/user.entity'; +import { CodeService } from '../../code/code.service'; +import { NotificationDocumentController } from './notification-document.controller'; +import { NotificationDocument } from './notification-document.entity'; +import { NotificationDocumentService } from './notification-document.service'; + +describe('NotificationDocumentController', () => { + let controller: NotificationDocumentController; + let notificationDocumentService: DeepMocked; + + const mockDocument = new NotificationDocument({ + document: new Document({ + mimeType: 'mimeType', + uploadedBy: new User(), + uploadedAt: new Date(), + }), + }); + + beforeEach(async () => { + notificationDocumentService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [NotificationDocumentController], + providers: [ + { + provide: CodeService, + useValue: {}, + }, + NotificationProfile, + { + provide: NotificationDocumentService, + useValue: notificationDocumentService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + controller = module.get( + NotificationDocumentController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return the attached document', async () => { + const mockFile = {}; + const mockUser = {}; + + notificationDocumentService.attachDocument.mockResolvedValue(mockDocument); + + const res = await controller.attachDocument('fileNumber', { + isMultipart: () => true, + body: { + documentType: { + value: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + }, + fileName: { + value: 'file', + }, + source: { + value: DOCUMENT_SOURCE.APPLICANT, + }, + visibilityFlags: { + value: '', + }, + file: mockFile, + }, + user: { + entity: mockUser, + }, + }); + + expect(res.mimeType).toEqual(mockDocument.document.mimeType); + + expect(notificationDocumentService.attachDocument).toHaveBeenCalledTimes(1); + const callData = + notificationDocumentService.attachDocument.mock.calls[0][0]; + expect(callData.fileName).toEqual('file'); + expect(callData.file).toEqual(mockFile); + expect(callData.user).toEqual(mockUser); + }); + + it('should throw an exception if request is not the right type', async () => { + const mockFile = {}; + const mockUser = {}; + + notificationDocumentService.attachDocument.mockResolvedValue(mockDocument); + + await expect( + controller.attachDocument('fileNumber', { + isMultipart: () => false, + file: () => mockFile, + user: { + entity: mockUser, + }, + }), + ).rejects.toMatchObject( + new BadRequestException('Request is not multipart'), + ); + }); + + it('should list documents', async () => { + notificationDocumentService.list.mockResolvedValue([mockDocument]); + + const res = await controller.listDocuments( + 'fake-number', + DOCUMENT_TYPE.DECISION_DOCUMENT, + ); + + expect(res[0].mimeType).toEqual(mockDocument.document.mimeType); + }); + + it('should call through to delete documents', async () => { + notificationDocumentService.delete.mockResolvedValue(mockDocument); + notificationDocumentService.get.mockResolvedValue(mockDocument); + + await controller.delete('fake-uuid'); + + expect(notificationDocumentService.get).toHaveBeenCalledTimes(1); + expect(notificationDocumentService.delete).toHaveBeenCalledTimes(1); + }); + + it('should call through for open', async () => { + const fakeUrl = 'fake-url'; + notificationDocumentService.getInlineUrl.mockResolvedValue(fakeUrl); + notificationDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.open('fake-uuid'); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for download', async () => { + const fakeUrl = 'fake-url'; + notificationDocumentService.getDownloadUrl.mockResolvedValue(fakeUrl); + notificationDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.download('fake-uuid'); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for list types', async () => { + notificationDocumentService.fetchTypes.mockResolvedValue([]); + + const res = await controller.listTypes(); + + expect(notificationDocumentService.fetchTypes).toHaveBeenCalledTimes(1); + }); + + it('should call through for list app documents', async () => { + notificationDocumentService.getApplicantDocuments.mockResolvedValue([]); + + const res = await controller.listApplicantDocuments(''); + + expect( + notificationDocumentService.getApplicantDocuments, + ).toHaveBeenCalledTimes(1); + }); + + it('should call through for list review documents', async () => { + notificationDocumentService.list.mockResolvedValue([]); + + const res = await controller.listReviewDocuments(''); + + expect(notificationDocumentService.list).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.ts new file mode 100644 index 0000000000..c7633e4fcd --- /dev/null +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.controller.ts @@ -0,0 +1,211 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { + BadRequestException, + Controller, + Delete, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import * as config from 'config'; +import { ANY_AUTH_ROLE } from '../../../common/authorization/roles'; +import { RolesGuard } from '../../../common/authorization/roles-guard.service'; +import { UserRoles } from '../../../common/authorization/roles.decorator'; +import { + DOCUMENT_TYPE, + DocumentCode, +} from '../../../document/document-code.entity'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, + DocumentTypeDto, +} from '../../../document/document.dto'; +import { NotificationDocumentDto } from './notification-document.dto'; +import { + NotificationDocument, + VISIBILITY_FLAG, +} from './notification-document.entity'; +import { NotificationDocumentService } from './notification-document.service'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@UseGuards(RolesGuard) +@Controller('notification-document') +export class NotificationDocumentController { + constructor( + private notificationDocumentService: NotificationDocumentService, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/notification/:fileNumber') + @UserRoles(...ANY_AUTH_ROLE) + async listAll( + @Param('fileNumber') fileNumber: string, + ): Promise { + const documents = await this.notificationDocumentService.list(fileNumber); + return this.mapper.mapArray( + documents, + NotificationDocument, + NotificationDocumentDto, + ); + } + + @Post('/notification/:fileNumber') + @UserRoles(...ANY_AUTH_ROLE) + async attachDocument( + @Param('fileNumber') fileNumber: string, + @Req() req, + ): Promise { + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const savedDocument = await this.saveUploadedFile(req, fileNumber); + + return this.mapper.map( + savedDocument, + NotificationDocument, + NotificationDocumentDto, + ); + } + + @Post('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async updateDocument( + @Param('uuid') documentUuid: string, + @Req() req, + ): Promise { + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const documentType = req.body.documentType.value as DOCUMENT_TYPE; + const file = req.body.file; + const fileName = req.body.fileName.value as string; + const documentSource = req.body.source.value as DOCUMENT_SOURCE; + const visibilityFlags = req.body.visibilityFlags.value.split(', '); + + const savedDocument = await this.notificationDocumentService.update({ + uuid: documentUuid, + fileName, + file, + documentType: documentType as DOCUMENT_TYPE, + source: documentSource, + visibilityFlags, + user: req.user.entity, + }); + + return this.mapper.map( + savedDocument, + NotificationDocument, + NotificationDocumentDto, + ); + } + + @Get('/notification/:fileNumber/reviewDocuments') + @UserRoles(...ANY_AUTH_ROLE) + async listReviewDocuments( + @Param('fileNumber') fileNumber: string, + ): Promise { + const documents = await this.notificationDocumentService.list(fileNumber); + const reviewDocuments = documents.filter( + (doc) => doc.document.source === DOCUMENT_SOURCE.LFNG, + ); + + return this.mapper.mapArray( + reviewDocuments, + NotificationDocument, + NotificationDocumentDto, + ); + } + + @Get('/notification/:fileNumber/applicantDocuments') + @UserRoles(...ANY_AUTH_ROLE) + async listApplicantDocuments( + @Param('fileNumber') fileNumber: string, + ): Promise { + const documents = + await this.notificationDocumentService.getApplicantDocuments(fileNumber); + + return this.mapper.mapArray( + documents, + NotificationDocument, + NotificationDocumentDto, + ); + } + + @Get('/notification/:fileNumber/:visibilityFlags') + @UserRoles(...ANY_AUTH_ROLE) + async listDocuments( + @Param('fileNumber') fileNumber: string, + @Param('visibilityFlags') visibilityFlags: string, + ): Promise { + const mappedFlags = visibilityFlags.split('') as VISIBILITY_FLAG[]; + const documents = await this.notificationDocumentService.list( + fileNumber, + mappedFlags, + ); + return this.mapper.mapArray( + documents, + NotificationDocument, + NotificationDocumentDto, + ); + } + + @Get('/types') + @UserRoles(...ANY_AUTH_ROLE) + async listTypes() { + const types = await this.notificationDocumentService.fetchTypes(); + return this.mapper.mapArray(types, DocumentCode, DocumentTypeDto); + } + + @Get('/:uuid/open') + @UserRoles(...ANY_AUTH_ROLE) + async open(@Param('uuid') fileUuid: string) { + const document = await this.notificationDocumentService.get(fileUuid); + const url = await this.notificationDocumentService.getInlineUrl(document); + return { + url, + }; + } + + @Get('/:uuid/download') + @UserRoles(...ANY_AUTH_ROLE) + async download(@Param('uuid') fileUuid: string) { + const document = await this.notificationDocumentService.get(fileUuid); + const url = await this.notificationDocumentService.getDownloadUrl(document); + return { + url, + }; + } + + @Delete('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async delete(@Param('uuid') fileUuid: string) { + const document = await this.notificationDocumentService.get(fileUuid); + await this.notificationDocumentService.delete(document); + return {}; + } + + private async saveUploadedFile(req, fileNumber: string) { + const documentType = req.body.documentType.value as DOCUMENT_TYPE; + const file = req.body.file; + const fileName = req.body.fileName.value as string; + const documentSource = req.body.source.value as DOCUMENT_SOURCE; + const visibilityFlags = req.body.visibilityFlags.value.split(', '); + + return await this.notificationDocumentService.attachDocument({ + fileNumber, + fileName, + file, + user: req.user.entity, + documentType: documentType as DOCUMENT_TYPE, + source: documentSource, + visibilityFlags, + system: DOCUMENT_SYSTEM.ALCS, + }); + } +} diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.dto.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.dto.ts new file mode 100644 index 0000000000..4a909c1efc --- /dev/null +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.dto.ts @@ -0,0 +1,29 @@ +import { AutoMap } from '@automapper/classes'; +import { DocumentTypeDto } from '../../../document/document.dto'; + +export class NotificationDocumentDto { + @AutoMap(() => String) + description?: string; + + @AutoMap() + uuid: string; + + @AutoMap(() => DocumentTypeDto) + type?: DocumentTypeDto; + + @AutoMap(() => [String]) + visibilityFlags: string[]; + + @AutoMap(() => [Number]) + evidentiaryRecordSorting?: number; + + //Document Fields + documentUuid: string; + fileName: string; + fileSize?: number; + source: string; + system: string; + mimeType: string; + uploadedBy: string; + uploadedAt: number; +} diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts new file mode 100644 index 0000000000..c8c2fe60d0 --- /dev/null +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts @@ -0,0 +1,67 @@ +import { AutoMap } from '@automapper/classes'; +import { + BaseEntity, + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { DocumentCode } from '../../../document/document-code.entity'; +import { Document } from '../../../document/document.entity'; +import { Notification } from '../notification.entity'; + +export enum VISIBILITY_FLAG { + APPLICANT = 'A', + COMMISSIONER = 'C', + PUBLIC = 'P', + GOVERNMENT = 'G', +} + +@Entity() +export class NotificationDocument extends BaseEntity { + constructor(data?: Partial) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @AutoMap() + @PrimaryGeneratedColumn('uuid') + uuid: string; + + @ManyToOne(() => DocumentCode) + type?: DocumentCode; + + @Column({ nullable: true }) + typeCode?: string | null; + + @Column({ type: 'text', nullable: true }) + description?: string | null; + + @ManyToOne(() => Notification, { nullable: false }) + notification: Notification; + + @Column() + notificationUuid: string; + + @Column({ nullable: true, type: 'uuid' }) + documentUuid?: string | null; + + @AutoMap(() => [String]) + @Column({ default: [], array: true, type: 'text' }) + visibilityFlags: VISIBILITY_FLAG[]; + + @OneToOne(() => Document) + @JoinColumn() + document: Document; + + @Column({ + nullable: true, + type: 'text', + comment: 'used only for oats etl process', + }) + auditCreatedBy?: string | null; +} diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts new file mode 100644 index 0000000000..cc44d8acfd --- /dev/null +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts @@ -0,0 +1,342 @@ +import { ServiceNotFoundException } from '@app/common/exceptions/base.exception'; +import { MultipartFile } from '@fastify/multipart'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + DOCUMENT_TYPE, + DocumentCode, +} from '../../../document/document-code.entity'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, +} from '../../../document/document.dto'; +import { Document } from '../../../document/document.entity'; +import { DocumentService } from '../../../document/document.service'; +import { User } from '../../../user/user.entity'; +import { UserService } from '../../../user/user.service'; +import { Notification } from '../notification.entity'; +import { NotificationService } from '../notification.service'; +import { NotificationDocument } from './notification-document.entity'; +import { NotificationDocumentService } from './notification-document.service'; + +describe('NotificationDocumentService', () => { + let service: NotificationDocumentService; + let mockDocumentService: DeepMocked; + let mockNotificationService: DeepMocked; + let mockRepository: DeepMocked>; + let mockTypeRepository: DeepMocked>; + + let mockNotification; + const fileNumber = '12345'; + + beforeEach(async () => { + mockDocumentService = createMock(); + mockNotificationService = createMock(); + mockRepository = createMock(); + mockTypeRepository = createMock(); + + mockNotification = new Notification(); + mockNotificationService.getByFileNumber.mockResolvedValue(mockNotification); + mockDocumentService.create.mockResolvedValue({} as Document); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationDocumentService, + { + provide: DocumentService, + useValue: mockDocumentService, + }, + { + provide: NotificationService, + useValue: mockNotificationService, + }, + { + provide: getRepositoryToken(DocumentCode), + useValue: mockTypeRepository, + }, + { + provide: getRepositoryToken(NotificationDocument), + useValue: mockRepository, + }, + { + provide: UserService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get( + NotificationDocumentService, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create a document in the happy path', async () => { + const mockUser = new User(); + const mockFile = {}; + const mockSavedDocument = {}; + + mockRepository.save.mockResolvedValue( + mockSavedDocument as NotificationDocument, + ); + + const res = await service.attachDocument({ + fileNumber, + file: mockFile as MultipartFile, + user: mockUser, + documentType: DOCUMENT_TYPE.DECISION_DOCUMENT, + fileName: '', + source: DOCUMENT_SOURCE.APPLICANT, + system: DOCUMENT_SYSTEM.PORTAL, + visibilityFlags: [], + }); + + expect(mockNotificationService.getByFileNumber).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create.mock.calls[0][0]).toBe( + 'notification/12345', + ); + expect(mockDocumentService.create.mock.calls[0][2]).toBe(mockFile); + expect(mockDocumentService.create.mock.calls[0][3]).toBe(mockUser); + + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockRepository.save.mock.calls[0][0].notification).toBe( + mockNotification, + ); + + expect(res).toBe(mockSavedDocument); + }); + + it('should delete document and application document when deleting', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as NotificationDocument; + + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.remove.mockResolvedValue({} as any); + + await service.delete(mockAppDocument); + + expect(mockDocumentService.softRemove).toHaveBeenCalledTimes(1); + expect(mockDocumentService.softRemove.mock.calls[0][0]).toBe(mockDocument); + + expect(mockRepository.remove).toHaveBeenCalledTimes(1); + expect(mockRepository.remove.mock.calls[0][0]).toBe(mockAppDocument); + }); + + it('should call through for get', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as NotificationDocument; + + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.findOne.mockResolvedValue(mockAppDocument); + + const res = await service.get('fake-uuid'); + expect(res).toBe(mockAppDocument); + }); + + it("should throw an exception when getting a document that doesn't exist", async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as NotificationDocument; + + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.get(mockAppDocument.uuid)).rejects.toMatchObject( + new ServiceNotFoundException( + `Failed to find document ${mockAppDocument.uuid}`, + ), + ); + }); + + it('should call through for list', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as NotificationDocument; + mockRepository.find.mockResolvedValue([mockAppDocument]); + + const res = await service.list(fileNumber); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(res[0]).toBe(mockAppDocument); + }); + + it('should call through for download', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as NotificationDocument; + + const fakeUrl = 'mock-url'; + mockDocumentService.getDownloadUrl.mockResolvedValue(fakeUrl); + + const res = await service.getInlineUrl(mockAppDocument); + + expect(mockDocumentService.getDownloadUrl).toHaveBeenCalledTimes(1); + expect(res).toEqual(fakeUrl); + }); + + it('should load all applicant sourced documents correctly', async () => { + const mockAppDocument = new NotificationDocument({ + uuid: '1', + document: new Document({ + source: DOCUMENT_SOURCE.APPLICANT, + }), + }); + const mockLgDocument = new NotificationDocument({ + uuid: '2', + document: new Document({ + source: DOCUMENT_SOURCE.LFNG, + }), + }); + + mockRepository.find.mockResolvedValue([mockAppDocument, mockLgDocument]); + + const res = await service.getApplicantDocuments('1'); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(res.length).toEqual(1); + expect(res[0]).toBe(mockAppDocument); + }); + + it('should call delete for each document loaded', async () => { + const mockAppDocument = new NotificationDocument({ + uuid: '1', + document: new Document({ + source: DOCUMENT_SOURCE.APPLICANT, + }), + }); + const mockLgDocument = new NotificationDocument({ + uuid: '2', + document: new Document({ + source: DOCUMENT_SOURCE.LFNG, + }), + }); + + mockRepository.find.mockResolvedValue([mockAppDocument, mockLgDocument]); + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.remove.mockResolvedValue({} as any); + + const res = await service.deleteByType(DOCUMENT_TYPE.STAFF_REPORT, ''); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(mockDocumentService.softRemove).toHaveBeenCalledTimes(2); + }); + + it('should call through for fetchTypes', async () => { + mockTypeRepository.find.mockResolvedValue([]); + + const res = await service.fetchTypes(); + + expect(mockTypeRepository.find).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + }); + + it('should set the type and description for multiple files', async () => { + const mockDocument1 = new NotificationDocument({ + typeCode: DOCUMENT_TYPE.DECISION_DOCUMENT, + description: undefined, + }); + const mockDocument2 = new NotificationDocument({ + typeCode: DOCUMENT_TYPE.DECISION_DOCUMENT, + description: undefined, + }); + mockRepository.findOne + .mockResolvedValueOnce(mockDocument1) + .mockResolvedValueOnce(mockDocument2); + mockRepository.save.mockResolvedValue(new NotificationDocument()); + const mockUpdates = [ + { + uuid: '1', + type: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + description: 'Secret Documents', + }, + { + uuid: '2', + type: DOCUMENT_TYPE.RESOLUTION_DOCUMENT, + description: 'New Description', + }, + ]; + + const res = await service.updateDescriptionAndType(mockUpdates, ''); + + expect(mockRepository.findOne).toHaveBeenCalledTimes(2); + expect(mockRepository.save).toHaveBeenCalledTimes(2); + expect(res).toBeDefined(); + expect(res.length).toEqual(2); + expect(mockDocument1.typeCode).toEqual(DOCUMENT_TYPE.CERTIFICATE_OF_TITLE); + expect(mockDocument1.description).toEqual('Secret Documents'); + expect(mockDocument2.typeCode).toEqual(DOCUMENT_TYPE.RESOLUTION_DOCUMENT); + expect(mockDocument2.description).toEqual('New Description'); + }); + + it('should create a record for external documents', async () => { + mockRepository.save.mockResolvedValue(new NotificationDocument()); + mockNotificationService.getUuid.mockResolvedValueOnce('app-uuid'); + mockRepository.findOne.mockResolvedValue(new NotificationDocument()); + + const res = await service.attachExternalDocument( + '', + { + type: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + description: '', + documentUuid: 'fake-uuid', + }, + [], + ); + + expect(mockNotificationService.getUuid).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockRepository.save.mock.calls[0][0].notificationUuid).toEqual( + 'app-uuid', + ); + expect(mockRepository.save.mock.calls[0][0].typeCode).toEqual( + DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + ); + expect(mockRepository.findOne).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + }); + + it('should delete the existing file and create a new when updating', async () => { + mockRepository.findOne.mockResolvedValue( + new NotificationDocument({ + document: new Document(), + }), + ); + mockNotificationService.getFileNumber.mockResolvedValue('app-uuid'); + mockRepository.save.mockResolvedValue(new NotificationDocument()); + mockDocumentService.create.mockResolvedValue(new Document()); + mockDocumentService.softRemove.mockResolvedValue(); + + const res = await service.update({ + source: DOCUMENT_SOURCE.APPLICANT, + fileName: 'fileName', + user: new User(), + file: {} as File, + uuid: '', + documentType: DOCUMENT_TYPE.DECISION_DOCUMENT, + visibilityFlags: [], + }); + + expect(mockRepository.findOne).toHaveBeenCalledTimes(1); + expect(mockNotificationService.getFileNumber).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts new file mode 100644 index 0000000000..fbca313a7a --- /dev/null +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts @@ -0,0 +1,305 @@ +import { MultipartFile } from '@fastify/multipart'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + ArrayOverlap, + FindOptionsRelations, + FindOptionsWhere, + Repository, +} from 'typeorm'; +import { + DOCUMENT_TYPE, + DocumentCode, +} from '../../../document/document-code.entity'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, +} from '../../../document/document.dto'; +import { DocumentService } from '../../../document/document.service'; +import { PortalNotificationDocumentUpdateDto } from '../../../portal/notification-document/notification-document.dto'; +import { User } from '../../../user/user.entity'; +import { NotificationService } from '../notification.service'; +import { + NotificationDocument, + VISIBILITY_FLAG, +} from './notification-document.entity'; + +@Injectable() +export class NotificationDocumentService { + private DEFAULT_RELATIONS: FindOptionsRelations = { + document: true, + type: true, + }; + + constructor( + private documentService: DocumentService, + private notificationService: NotificationService, + @InjectRepository(NotificationDocument) + private notificationDocumentRepository: Repository, + @InjectRepository(DocumentCode) + private documentCodeRepository: Repository, + ) {} + + async attachDocument({ + fileNumber, + fileName, + file, + documentType, + user, + system, + source = DOCUMENT_SOURCE.ALC, + visibilityFlags, + }: { + fileNumber: string; + fileName: string; + file: MultipartFile; + user: User; + documentType: DOCUMENT_TYPE; + source?: DOCUMENT_SOURCE; + system: DOCUMENT_SYSTEM; + visibilityFlags: VISIBILITY_FLAG[]; + }) { + const notification = await this.notificationService.getByFileNumber( + fileNumber, + ); + const document = await this.documentService.create( + `notification/${fileNumber}`, + fileName, + file, + user, + source, + system, + ); + const appDocument = new NotificationDocument({ + typeCode: documentType, + notification, + document, + visibilityFlags, + }); + + return this.notificationDocumentRepository.save(appDocument); + } + + async attachDocumentAsBuffer({ + fileNumber, + fileName, + file, + mimeType, + fileSize, + documentType, + user, + system, + source = DOCUMENT_SOURCE.ALC, + visibilityFlags, + }: { + fileNumber: string; + fileName: string; + file: Buffer; + mimeType: string; + fileSize: number; + user: User; + documentType: DOCUMENT_TYPE; + source?: DOCUMENT_SOURCE; + system: DOCUMENT_SYSTEM; + visibilityFlags: VISIBILITY_FLAG[]; + }) { + const notification = await this.notificationService.getByFileNumber( + fileNumber, + ); + const document = await this.documentService.createFromBuffer( + `notification/${fileNumber}`, + fileName, + file, + mimeType, + fileSize, + user, + source, + system, + ); + const appDocument = new NotificationDocument({ + typeCode: documentType, + notification, + document, + visibilityFlags, + }); + + return this.notificationDocumentRepository.save(appDocument); + } + + async get(uuid: string) { + const document = await this.notificationDocumentRepository.findOne({ + where: { + uuid: uuid, + }, + relations: this.DEFAULT_RELATIONS, + }); + if (!document) { + throw new NotFoundException(`Failed to find document ${uuid}`); + } + return document; + } + + async delete(document: NotificationDocument) { + await this.notificationDocumentRepository.remove(document); + await this.documentService.softRemove(document.document); + return document; + } + + async list(fileNumber: string, visibilityFlags?: VISIBILITY_FLAG[]) { + const where: FindOptionsWhere = { + notification: { + fileNumber, + }, + }; + if (visibilityFlags) { + where.visibilityFlags = ArrayOverlap(visibilityFlags); + } + return this.notificationDocumentRepository.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, + }, + relations: this.DEFAULT_RELATIONS, + }); + } + + async getInlineUrl(document: NotificationDocument) { + return this.documentService.getDownloadUrl(document.document, true); + } + + async getDownloadUrl(document: NotificationDocument) { + return this.documentService.getDownloadUrl(document.document); + } + + async attachExternalDocument( + fileNumber: string, + data: { + type?: DOCUMENT_TYPE; + documentUuid: string; + description?: string; + }, + visibilityFlags: VISIBILITY_FLAG[], + ) { + const notificationUuid = await this.notificationService.getUuid(fileNumber); + const document = new NotificationDocument({ + notificationUuid, + typeCode: data.type, + documentUuid: data.documentUuid, + description: data.description, + visibilityFlags, + }); + + const savedDocument = await this.notificationDocumentRepository.save( + document, + ); + return this.get(savedDocument.uuid); + } + + async updateDescriptionAndType( + updates: PortalNotificationDocumentUpdateDto[], + notificationUuid: string, + ) { + const results: NotificationDocument[] = []; + for (const update of updates) { + const file = await this.notificationDocumentRepository.findOne({ + where: { + uuid: update.uuid, + notificationUuid, + }, + relations: { + document: true, + }, + }); + if (!file) { + throw new BadRequestException( + 'Failed to find file linked to provided notification', + ); + } + + file.typeCode = update.type; + file.description = update.description; + const updatedFile = await this.notificationDocumentRepository.save(file); + results.push(updatedFile); + } + return results; + } + + async deleteByType(documentType: DOCUMENT_TYPE, notificationUuid: string) { + const documents = await this.notificationDocumentRepository.find({ + where: { + notificationUuid, + typeCode: documentType, + }, + relations: { + document: true, + }, + }); + for (const document of documents) { + await this.documentService.softRemove(document.document); + await this.notificationDocumentRepository.remove(document); + } + + return; + } + + async getApplicantDocuments(fileNumber: string) { + const documents = await this.list(fileNumber); + return documents.filter( + (doc) => doc.document.source === DOCUMENT_SOURCE.APPLICANT, + ); + } + + async fetchTypes() { + return await this.documentCodeRepository.find(); + } + + async update({ + uuid, + documentType, + file, + fileName, + source, + visibilityFlags, + user, + }: { + uuid: string; + file?: any; + fileName: string; + documentType: DOCUMENT_TYPE; + visibilityFlags: VISIBILITY_FLAG[]; + source: DOCUMENT_SOURCE; + user: User; + }) { + const notificationDocument = await this.get(uuid); + + if (file) { + const fileNumber = await this.notificationService.getFileNumber( + notificationDocument.notificationUuid, + ); + await this.documentService.softRemove(notificationDocument.document); + notificationDocument.document = await this.documentService.create( + `notification/${fileNumber}`, + fileName, + file, + user, + source, + notificationDocument.document.system as DOCUMENT_SYSTEM, + ); + } else { + await this.documentService.update(notificationDocument.document, { + fileName, + source, + }); + } + notificationDocument.type = undefined; + notificationDocument.typeCode = documentType; + notificationDocument.visibilityFlags = visibilityFlags; + return await this.notificationDocumentRepository.save(notificationDocument); + } +} diff --git a/services/apps/alcs/src/alcs/notification/notification.entity.ts b/services/apps/alcs/src/alcs/notification/notification.entity.ts index c8ce3c0544..f594854341 100644 --- a/services/apps/alcs/src/alcs/notification/notification.entity.ts +++ b/services/apps/alcs/src/alcs/notification/notification.entity.ts @@ -6,12 +6,14 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, OneToOne, } from 'typeorm'; import { Base } from '../../common/entities/base.entity'; import { Card } from '../card/card.entity'; import { ApplicationRegion } from '../code/application-code/application-region/application-region.entity'; import { LocalGovernment } from '../local-government/local-government.entity'; +import { NotificationDocument } from './notification-document/notification-document.entity'; import { NotificationType } from './notification-type/notification-type.entity'; @Entity() @@ -79,4 +81,8 @@ export class Notification extends Base { @Column() typeCode: string; + + @AutoMap() + @OneToMany(() => NotificationDocument, (document) => document.notification) + documents: NotificationDocument[]; } diff --git a/services/apps/alcs/src/alcs/notification/notification.module.ts b/services/apps/alcs/src/alcs/notification/notification.module.ts index 626e18522d..ee4d21c5e8 100644 --- a/services/apps/alcs/src/alcs/notification/notification.module.ts +++ b/services/apps/alcs/src/alcs/notification/notification.module.ts @@ -8,6 +8,9 @@ import { BoardModule } from '../board/board.module'; import { CardModule } from '../card/card.module'; import { CodeModule } from '../code/code.module'; import { LocalGovernmentModule } from '../local-government/local-government.module'; +import { NotificationDocumentController } from './notification-document/notification-document.controller'; +import { NotificationDocument } from './notification-document/notification-document.entity'; +import { NotificationDocumentService } from './notification-document/notification-document.service'; import { NotificationSubmissionStatusModule } from './notification-submission-status/notification-submission-status.module'; import { NotificationType } from './notification-type/notification-type.entity'; import { NotificationController } from './notification.controller'; @@ -16,7 +19,12 @@ import { Notification } from './notification.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Notification, NotificationType, DocumentCode]), + TypeOrmModule.forFeature([ + Notification, + NotificationType, + NotificationDocument, + DocumentCode, + ]), forwardRef(() => BoardModule), CardModule, FileNumberModule, @@ -25,8 +33,12 @@ import { Notification } from './notification.entity'; LocalGovernmentModule, NotificationSubmissionStatusModule, ], - providers: [NotificationService, NotificationProfile], - controllers: [NotificationController], - exports: [NotificationService], + providers: [ + NotificationService, + NotificationProfile, + NotificationDocumentService, + ], + controllers: [NotificationController, NotificationDocumentController], + exports: [NotificationService, NotificationDocumentService], }) export class NotificationModule {} diff --git a/services/apps/alcs/src/common/automapper/notification.automapper.profile.ts b/services/apps/alcs/src/common/automapper/notification.automapper.profile.ts index 9e5838daaa..b9bce4509a 100644 --- a/services/apps/alcs/src/common/automapper/notification.automapper.profile.ts +++ b/services/apps/alcs/src/common/automapper/notification.automapper.profile.ts @@ -1,12 +1,14 @@ import { createMap, forMember, mapFrom, Mapper } from '@automapper/core'; import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; import { Injectable } from '@nestjs/common'; +import { NotificationDocumentDto } from '../../alcs/notification/notification-document/notification-document.dto'; +import { NotificationDocument } from '../../alcs/notification/notification-document/notification-document.entity'; import { NotificationTypeDto } from '../../alcs/notification/notification-type/notification-type.dto'; import { NotificationType } from '../../alcs/notification/notification-type/notification-type.entity'; import { NotificationDto } from '../../alcs/notification/notification.dto'; +import { Notification } from '../../alcs/notification/notification.entity'; import { DocumentCode } from '../../document/document-code.entity'; import { DocumentTypeDto } from '../../document/document.dto'; -import { Notification } from '../../alcs/notification/notification.entity'; @Injectable() export class NotificationProfile extends AutomapperProfile { @@ -28,6 +30,43 @@ export class NotificationProfile extends AutomapperProfile { ), ); + createMap( + mapper, + NotificationDocument, + NotificationDocumentDto, + forMember( + (a) => a.mimeType, + mapFrom((ad) => ad.document.mimeType), + ), + forMember( + (a) => a.fileName, + mapFrom((ad) => ad.document.fileName), + ), + forMember( + (a) => a.fileSize, + mapFrom((ad) => ad.document.fileSize), + ), + forMember( + (a) => a.uploadedBy, + mapFrom((ad) => ad.document.uploadedBy?.name), + ), + forMember( + (a) => a.uploadedAt, + mapFrom((ad) => ad.document.uploadedAt.getTime()), + ), + forMember( + (a) => a.documentUuid, + mapFrom((ad) => ad.document.uuid), + ), + forMember( + (a) => a.source, + mapFrom((ad) => ad.document.source), + ), + forMember( + (a) => a.system, + mapFrom((ad) => ad.document.system), + ), + ); createMap(mapper, DocumentCode, DocumentTypeDto); }; } diff --git a/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts b/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts new file mode 100644 index 0000000000..6073fc54db --- /dev/null +++ b/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts @@ -0,0 +1,197 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; +import { NotificationDocument } from '../../alcs/notification/notification-document/notification-document.entity'; +import { NotificationDocumentService } from '../../alcs/notification/notification-document/notification-document.service'; +import { NotificationService } from '../../alcs/notification/notification.service'; +import { NotificationProfile } from '../../common/automapper/notification.automapper.profile'; +import { DocumentCode } from '../../document/document-code.entity'; +import { DOCUMENT_SOURCE, DOCUMENT_SYSTEM } from '../../document/document.dto'; +import { Document } from '../../document/document.entity'; +import { DocumentService } from '../../document/document.service'; +import { User } from '../../user/user.entity'; +import { NotificationSubmission } from '../notification-submission/notification-submission.entity'; +import { NotificationSubmissionService } from '../notification-submission/notification-submission.service'; +import { NotificationDocumentController } from './notification-document.controller'; +import { AttachExternalDocumentDto } from './notification-document.dto'; + +describe('NotificationDocumentController', () => { + let controller: NotificationDocumentController; + let mockNotificationDocumentService: DeepMocked; + let mockNotificationSubmissionService: DeepMocked; + let mockDocumentService: DeepMocked; + let mockNotificationService: DeepMocked; + + const mockDocument = new NotificationDocument({ + document: new Document({ + fileName: 'fileName', + uploadedAt: new Date(), + uploadedBy: new User(), + }), + }); + + beforeEach(async () => { + mockNotificationDocumentService = createMock(); + mockDocumentService = createMock(); + mockNotificationSubmissionService = createMock(); + mockNotificationService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [NotificationDocumentController], + providers: [ + NotificationProfile, + { + provide: NotificationDocumentService, + useValue: mockNotificationDocumentService, + }, + { + provide: ClsService, + useValue: {}, + }, + { + provide: NotificationSubmissionService, + useValue: mockNotificationSubmissionService, + }, + { + provide: DocumentService, + useValue: mockDocumentService, + }, + { + provide: NotificationService, + useValue: mockNotificationService, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + controller = module.get( + NotificationDocumentController, + ); + + mockNotificationSubmissionService.getByFileNumber.mockResolvedValue( + new NotificationSubmission(), + ); + mockNotificationService.getUuid.mockResolvedValue('uuid'); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should call through to delete documents', async () => { + mockNotificationDocumentService.delete.mockResolvedValue(mockDocument); + mockNotificationDocumentService.get.mockResolvedValue(mockDocument); + + await controller.delete('fake-uuid', { + user: { + entity: {}, + }, + }); + + expect(mockNotificationDocumentService.get).toHaveBeenCalledTimes(1); + expect(mockNotificationDocumentService.delete).toHaveBeenCalledTimes(1); + }); + + it('should call through to update documents', async () => { + mockNotificationDocumentService.updateDescriptionAndType.mockResolvedValue( + [], + ); + + await controller.update( + 'file-number', + { + user: { + entity: {}, + }, + }, + [], + ); + + expect( + mockNotificationDocumentService.updateDescriptionAndType, + ).toHaveBeenCalledTimes(1); + }); + + it('should call through for open', async () => { + const fakeUrl = 'fake-url'; + mockNotificationDocumentService.getInlineUrl.mockResolvedValue(fakeUrl); + mockNotificationDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.open('fake-uuid', { + user: { + entity: {}, + }, + }); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for download', async () => { + const fakeUrl = 'fake-url'; + mockNotificationDocumentService.getDownloadUrl.mockResolvedValue(fakeUrl); + mockNotificationDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.download('fake-uuid', { + user: { + entity: {}, + }, + }); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call out to service to attach external document', async () => { + const user = { user: { entity: 'Bruce' } }; + const fakeUuid = 'fakeUuid'; + const docObj = new Document({ uuid: 'fake-uuid' }); + const userEntity = new User({ + name: user.user.entity, + }); + + const docDto: AttachExternalDocumentDto = { + fileSize: 0, + mimeType: 'mimeType', + fileName: 'fileName', + fileKey: 'fileKey', + source: DOCUMENT_SOURCE.APPLICANT, + }; + + mockDocumentService.createDocumentRecord.mockResolvedValue(docObj); + + mockNotificationDocumentService.attachExternalDocument.mockResolvedValue( + new NotificationDocument({ + notification: undefined, + type: new DocumentCode(), + uuid: fakeUuid, + document: new Document({ + uploadedAt: new Date(), + uploadedBy: userEntity, + }), + }), + ); + + const res = await controller.attachExternalDocument( + 'fake-number', + docDto, + user, + ); + + expect(mockDocumentService.createDocumentRecord).toBeCalledTimes(1); + expect( + mockNotificationDocumentService.attachExternalDocument, + ).toBeCalledTimes(1); + expect(mockDocumentService.createDocumentRecord).toBeCalledWith({ + ...docDto, + system: DOCUMENT_SYSTEM.PORTAL, + }); + expect(res.uploadedBy).toEqual(user.user.entity); + expect(res.uuid).toEqual(fakeUuid); + }); +}); diff --git a/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts b/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts new file mode 100644 index 0000000000..cfe6e67121 --- /dev/null +++ b/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts @@ -0,0 +1,172 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import * as config from 'config'; +import { NotificationDocumentDto } from '../../alcs/notification/notification-document/notification-document.dto'; +import { + NotificationDocument, + VISIBILITY_FLAG, +} from '../../alcs/notification/notification-document/notification-document.entity'; +import { NotificationDocumentService } from '../../alcs/notification/notification-document/notification-document.service'; +import { NotificationService } from '../../alcs/notification/notification.service'; +import { PortalAuthGuard } from '../../common/authorization/portal-auth-guard.service'; +import { DOCUMENT_TYPE } from '../../document/document-code.entity'; +import { DOCUMENT_SYSTEM } from '../../document/document.dto'; +import { DocumentService } from '../../document/document.service'; +import { NotificationSubmissionService } from '../notification-submission/notification-submission.service'; +import { + AttachExternalDocumentDto, + PortalNotificationDocumentUpdateDto, +} from './notification-document.dto'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@UseGuards(PortalAuthGuard) +@Controller('notification-document') +export class NotificationDocumentController { + constructor( + private notificationDocumentService: NotificationDocumentService, + private notificationSubmissionService: NotificationSubmissionService, + private notificationService: NotificationService, + private documentService: DocumentService, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/notification/:fileNumber') + async listApplicantDocuments( + @Param('fileNumber') fileNumber: string, + @Param('documentType') documentType: DOCUMENT_TYPE | null, + @Req() req, + ): Promise { + await this.notificationSubmissionService.getByFileNumber( + fileNumber, + req.user.entity, + ); + + const documents = await this.notificationDocumentService.list(fileNumber, [ + VISIBILITY_FLAG.APPLICANT, + ]); + return this.mapPortalDocuments(documents); + } + + @Get('/:uuid/open') + async open(@Param('uuid') fileUuid: string, @Req() req) { + const document = await this.notificationDocumentService.get(fileUuid); + + //TODO: How do we know which documents applicant can access? + // await this.notificationSubmissionService.verifyAccess( + // document.applicationUuid, + // req.user.entity, + // ); + + const url = await this.notificationDocumentService.getInlineUrl(document); + return { url }; + } + + @Get('/:uuid/download') + async download(@Param('uuid') fileUuid: string, @Req() req) { + const document = await this.notificationDocumentService.get(fileUuid); + + //TODO: How do we know which documents applicant can access? + // await this.notificationSubmissionService.verifyAccess( + // document.applicationUuid, + // req.user.entity, + // ); + + const url = await this.notificationDocumentService.getDownloadUrl(document); + return { url }; + } + + @Patch('/notification/:fileNumber') + async update( + @Param('fileNumber') fileNumber: string, + @Req() req, + @Body() body: PortalNotificationDocumentUpdateDto[], + ) { + await this.notificationSubmissionService.getByFileNumber( + fileNumber, + req.user.entity, + ); + + //Map from file number to uuid + const notificationUuid = await this.notificationService.getUuid(fileNumber); + + const res = await this.notificationDocumentService.updateDescriptionAndType( + body, + notificationUuid, + ); + return this.mapPortalDocuments(res); + } + + @Delete('/:uuid') + async delete(@Param('uuid') fileUuid: string, @Req() req) { + const document = await this.notificationDocumentService.get(fileUuid); + + //TODO: How do we know which documents applicant can delete? + // await this.notificationSubmissionService.verifyAccess( + // document.applicationUuid, + // req.user.entity, + // ); + + await this.notificationDocumentService.delete(document); + return {}; + } + + @Post('/notification/:uuid/attachExternal') + async attachExternalDocument( + @Param('uuid') fileNumber: string, + @Body() data: AttachExternalDocumentDto, + @Req() req, + ): Promise { + const submission = await this.notificationSubmissionService.getByFileNumber( + fileNumber, + req.user.entity, + ); + + const document = await this.documentService.createDocumentRecord({ + ...data, + system: DOCUMENT_SYSTEM.PORTAL, + }); + + const savedDocument = + await this.notificationDocumentService.attachExternalDocument( + submission.fileNumber, + { + documentUuid: document.uuid, + type: data.documentType, + }, + [ + VISIBILITY_FLAG.APPLICANT, + VISIBILITY_FLAG.GOVERNMENT, + VISIBILITY_FLAG.COMMISSIONER, + ], + ); + + const mappedDocs = this.mapPortalDocuments([savedDocument]); + return mappedDocs[0]; + } + + private mapPortalDocuments(documents: NotificationDocument[]) { + const labeledDocuments = documents.map((document) => { + if (document.type?.portalLabel) { + document.type.label = document.type.portalLabel; + } + return document; + }); + return this.mapper.mapArray( + labeledDocuments, + NotificationDocument, + NotificationDocumentDto, + ); + } +} diff --git a/services/apps/alcs/src/portal/notification-document/notification-document.dto.ts b/services/apps/alcs/src/portal/notification-document/notification-document.dto.ts new file mode 100644 index 0000000000..d377534e57 --- /dev/null +++ b/services/apps/alcs/src/portal/notification-document/notification-document.dto.ts @@ -0,0 +1,30 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator'; +import { DOCUMENT_TYPE } from '../../document/document-code.entity'; +import { DOCUMENT_SOURCE } from '../../document/document.dto'; + +export class AttachExternalDocumentDto { + @IsString() + mimeType: string; + + @IsString() + fileName: string; + + @IsNumber() + fileSize: number; + + @IsString() + fileKey: string; + + @IsString() + source: DOCUMENT_SOURCE.APPLICANT; + + @IsString() + @IsOptional() + documentType?: DOCUMENT_TYPE; +} + +export class PortalNotificationDocumentUpdateDto { + uuid: string; + type: DOCUMENT_TYPE | null; + description: string | null; +} diff --git a/services/apps/alcs/src/portal/notification-document/notification-document.module.ts b/services/apps/alcs/src/portal/notification-document/notification-document.module.ts new file mode 100644 index 0000000000..fb544d8268 --- /dev/null +++ b/services/apps/alcs/src/portal/notification-document/notification-document.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { NotificationModule } from '../../alcs/notification/notification.module'; +import { DocumentModule } from '../../document/document.module'; +import { NotificationSubmissionModule } from '../notification-submission/notification-submission.module'; +import { NotificationDocumentController } from './notification-document.controller'; + +@Module({ + imports: [DocumentModule, NotificationModule, NotificationSubmissionModule], + controllers: [NotificationDocumentController], + providers: [], + exports: [], +}) +export class PortalNotificationDocumentModule {} diff --git a/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.spec.ts b/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.spec.ts index 1735fe4aae..73f24fdeac 100644 --- a/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.spec.ts +++ b/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.spec.ts @@ -8,9 +8,9 @@ import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; import { LocalGovernment } from '../../alcs/local-government/local-government.entity'; import { LocalGovernmentService } from '../../alcs/local-government/local-government.service'; import { NoticeOfIntentDocumentService } from '../../alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service'; -import { NoticeOfIntent } from '../../alcs/notice-of-intent/notice-of-intent.entity'; import { NOTIFICATION_STATUS } from '../../alcs/notification/notification-submission-status/notification-status.dto'; import { NotificationSubmissionToSubmissionStatus } from '../../alcs/notification/notification-submission-status/notification-status.entity'; +import { Notification } from '../../alcs/notification/notification.entity'; import { NotificationSubmissionProfile } from '../../common/automapper/notification-submission.automapper.profile'; import { EmailService } from '../../providers/email/email.service'; import { User } from '../../user/user.entity'; @@ -267,7 +267,7 @@ describe('NotificationSubmissionController', () => { }); mockNotificationSubmissionService.submitToAlcs.mockResolvedValue( - new NoticeOfIntent(), + new Notification(), ); mockNotificationSubmissionService.getByUuid.mockResolvedValue( mockSubmission, diff --git a/services/apps/alcs/src/portal/portal.module.ts b/services/apps/alcs/src/portal/portal.module.ts index 89269be6d1..f7b1ebb88a 100644 --- a/services/apps/alcs/src/portal/portal.module.ts +++ b/services/apps/alcs/src/portal/portal.module.ts @@ -16,6 +16,7 @@ import { PortalNoticeOfIntentDecisionModule } from './notice-of-intent-decision/ import { PortalNoticeOfIntentDocumentModule } from './notice-of-intent-document/notice-of-intent-document.module'; import { NoticeOfIntentSubmissionDraftModule } from './notice-of-intent-submission-draft/notice-of-intent-submission-draft.module'; import { NoticeOfIntentSubmissionModule } from './notice-of-intent-submission/notice-of-intent-submission.module'; +import { PortalNotificationDocumentModule } from './notification-document/notification-document.module'; import { ParcelModule } from './parcel/parcel.module'; import { PdfGenerationModule } from './pdf-generation/pdf-generation.module'; import { NotificationSubmissionModule } from './notification-submission/notification-submission.module'; @@ -40,6 +41,7 @@ import { NotificationSubmissionModule } from './notification-submission/notifica NoticeOfIntentSubmissionDraftModule, PortalNoticeOfIntentDecisionModule, NotificationSubmissionModule, + PortalNotificationDocumentModule, RouterModule.register([ { path: 'portal', module: ApplicationSubmissionModule }, { path: 'portal', module: NoticeOfIntentSubmissionModule }, @@ -54,6 +56,7 @@ import { NotificationSubmissionModule } from './notification-submission/notifica { path: 'portal', module: NoticeOfIntentSubmissionDraftModule }, { path: 'portal', module: PortalNoticeOfIntentDecisionModule }, { path: 'portal', module: NotificationSubmissionModule }, + { path: 'portal', module: PortalNotificationDocumentModule }, ]), ], controllers: [CodeController], diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1694037493007-add_notification_documents.ts b/services/apps/alcs/src/providers/typeorm/migrations/1694037493007-add_notification_documents.ts new file mode 100644 index 0000000000..9e80077254 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1694037493007-add_notification_documents.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class addNotificationDocuments1694037493007 + implements MigrationInterface +{ + name = 'addNotificationDocuments1694037493007'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "alcs"."notification_document" ("uuid" uuid NOT NULL DEFAULT gen_random_uuid(), "type_code" text, "description" text, "notification_uuid" uuid NOT NULL, "document_uuid" uuid, "visibility_flags" text array NOT NULL DEFAULT '{}', "audit_created_by" text, CONSTRAINT "REL_754c65b2ab78e39c64c31f2f9f" UNIQUE ("document_uuid"), CONSTRAINT "PK_cb4155e1f9d5b5ebd27c8de6381" PRIMARY KEY ("uuid")); COMMENT ON COLUMN "alcs"."notification_document"."audit_created_by" IS 'used only for oats etl process'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" ADD CONSTRAINT "FK_dc6a7789a73ec2e0eac2b2307d3" FOREIGN KEY ("type_code") REFERENCES "alcs"."document_code"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" ADD CONSTRAINT "FK_fdb3697b2dfc6ee1e72e85b01e2" FOREIGN KEY ("notification_uuid") REFERENCES "alcs"."notification"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" ADD CONSTRAINT "FK_754c65b2ab78e39c64c31f2f9f9" FOREIGN KEY ("document_uuid") REFERENCES "alcs"."document"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" DROP CONSTRAINT "FK_754c65b2ab78e39c64c31f2f9f9"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" DROP CONSTRAINT "FK_fdb3697b2dfc6ee1e72e85b01e2"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" DROP CONSTRAINT "FK_dc6a7789a73ec2e0eac2b2307d3"`, + ); + await queryRunner.query(`DROP TABLE "alcs"."notification_document"`); + } +}