From e3f83fb3266e2cf1600e9ae8d2cf80da323753ac Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Mon, 11 Mar 2024 11:07:47 -0700 Subject: [PATCH] Add Planning Review Documents --- .../document-upload-dialog.component.html | 131 ++++++++ .../document-upload-dialog.component.scss | 55 ++++ .../document-upload-dialog.component.spec.ts | 49 +++ .../document-upload-dialog.component.ts | 190 ++++++++++++ .../documents/documents.component.html | 83 +++++ .../documents/documents.component.scss | 44 +++ .../documents/documents.component.spec.ts | 59 ++++ .../documents/documents.component.ts | 121 ++++++++ .../planning-review.component.spec.ts | 2 + .../planning-review.component.ts | 13 +- .../planning-review/planning-review.module.ts | 10 +- .../planning-review-document.dto.ts | 35 +++ .../planning-review-document.service.spec.ts | 111 +++++++ .../planning-review-document.service.ts | 101 ++++++ .../planning-referral.entity.ts | 9 - ...lanning-review-document.controller.spec.ts | 189 +++++++++++ .../planning-review-document.controller.ts | 206 ++++++++++++ .../planning-review-document.dto.ts | 29 ++ .../planning-review-document.entity.ts | 64 ++++ .../planning-review-document.service.spec.ts | 293 ++++++++++++++++++ .../planning-review-document.service.ts | 219 +++++++++++++ .../planning-review/planning-review.entity.ts | 9 + .../planning-review/planning-review.module.ts | 15 +- .../planning-review.service.ts | 9 + .../planning-review.automapper.profile.ts | 43 +++ .../1709856439937-add_pr_documents.ts | 42 +++ .../1709857038186-move_legacy_id.ts | 29 ++ 27 files changed, 2146 insertions(+), 14 deletions(-) create mode 100644 alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html create mode 100644 alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss create mode 100644 alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts create mode 100644 alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts create mode 100644 alcs-frontend/src/app/features/planning-review/documents/documents.component.html create mode 100644 alcs-frontend/src/app/features/planning-review/documents/documents.component.scss create mode 100644 alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts create mode 100644 alcs-frontend/src/app/features/planning-review/documents/documents.component.ts create mode 100644 alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts create mode 100644 alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts create mode 100644 alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts create mode 100644 services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts create mode 100644 services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts create mode 100644 services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.dto.ts create mode 100644 services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.entity.ts create mode 100644 services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts create mode 100644 services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts create mode 100644 services/apps/alcs/src/providers/typeorm/migrations/1709856439937-add_pr_documents.ts create mode 100644 services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html new file mode 100644 index 0000000000..9c81054cd2 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html @@ -0,0 +1,131 @@ +
+

{{ title }} Document

+ Superseded - Not associated with Applicant Submission in Portal +
+
+
+
+
+ Document Upload* +
+ + +
+
+ {{ pendingFile.name }} +  ({{ pendingFile.size | filesize }}) +
+ +
+
+
+ {{ existingFile.name }} +  ({{ existingFile.size | filesize }}) +
+ +
+ + warning A virus was detected in the file. Choose another file and try again. + +
+ +
+ + Document Name + + +
+ +
+ + +
+
+ + Source + + {{ source }} + + +
+
+ + Associated Parcel + + + #{{ parcel.index + 1 }} PID: + {{ parcel.pid | mask : '000-000-000' }} + No Data + + +
+
+ + Associated Organization + + + {{ owner.label }} + + + +
+
+ Visible To: +
+ Commissioner +
+
+
+ + +
+ + + +
+
+
diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss new file mode 100644 index 0000000000..e4fa72a650 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss @@ -0,0 +1,55 @@ +@use '../../../../../styles/colors'; + +.form { + display: grid; + grid-template-columns: 1fr 1fr; + row-gap: 32px; + column-gap: 32px; + + .double { + grid-column: 1/3; + } +} + +.full-width { + width: 100%; +} + +a { + word-break: break-all; +} + +.file { + border: 1px solid #000; + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; +} + +.upload-button { + margin-top: 6px !important; + + &.error { + border: 2px solid colors.$error-color; + } +} + +.spinner { + display: inline-block; + margin-right: 4px; +} + +:host::ng-deep { + .mdc-button__label { + display: flex; + align-items: center; + } +} + +.superseded-warning { + background-color: colors.$secondary-color-dark; + color: #fff; + padding: 0 4px; +} diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts new file mode 100644 index 0000000000..614aa11ccc --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts @@ -0,0 +1,49 @@ +import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { PlanningReviewDocumentService } from '../../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { ToastService } from '../../../../services/toast/toast.service'; + +import { DocumentUploadDialogComponent } from './document-upload-dialog.component'; + +describe('DocumentUploadDialogComponent', () => { + let component: DocumentUploadDialogComponent; + let fixture: ComponentFixture; + + let mockAppDocService: DeepMocked; + + beforeEach(async () => { + mockAppDocService = createMock(); + + const mockDialogRef = { + close: jest.fn(), + afterClosed: jest.fn(), + subscribe: jest.fn(), + backdropClick: () => new EventEmitter(), + }; + + await TestBed.configureTestingModule({ + declarations: [DocumentUploadDialogComponent], + providers: [ + { + provide: PlanningReviewDocumentService, + useValue: mockAppDocService, + }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: ToastService, useValue: {} }, + ], + imports: [MatDialogModule], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DocumentUploadDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts new file mode 100644 index 0000000000..779cdab8a1 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts @@ -0,0 +1,190 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { + PlanningReviewDocumentDto, + UpdateDocumentDto, +} from '../../../../services/planning-review/planning-review-document/planning-review-document.dto'; +import { PlanningReviewDocumentService } from '../../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, + DOCUMENT_TYPE, + DocumentTypeDto, +} from '../../../../shared/document/document.dto'; + +@Component({ + selector: 'app-document-upload-dialog', + templateUrl: './document-upload-dialog.component.html', + styleUrls: ['./document-upload-dialog.component.scss'], +}) +export class DocumentUploadDialogComponent implements OnInit, OnDestroy { + $destroy = new Subject(); + DOCUMENT_TYPE = DOCUMENT_TYPE; + + title = 'Create'; + isDirty = false; + isSaving = false; + allowsFileEdit = true; + documentTypeAhead: string | undefined = undefined; + + name = new FormControl('', [Validators.required]); + type = new FormControl(undefined, [Validators.required]); + source = new FormControl('', [Validators.required]); + + parcelId = new FormControl(null); + ownerId = new FormControl(null); + + visibleToCommissioner = new FormControl(false, [Validators.required]); + + documentTypes: DocumentTypeDto[] = []; + documentSources = Object.values(DOCUMENT_SOURCE); + selectableParcels: { uuid: string; index: number; pid?: string }[] = []; + selectableOwners: { uuid: string; label: string }[] = []; + + form = new FormGroup({ + name: this.name, + type: this.type, + source: this.source, + visibleToCommissioner: this.visibleToCommissioner, + parcelId: this.parcelId, + ownerId: this.ownerId, + }); + + pendingFile: File | undefined; + existingFile: { name: string; size: number } | undefined; + showSupersededWarning = false; + showVirusError = false; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { fileId: string; existingDocument?: PlanningReviewDocumentDto }, + protected dialog: MatDialogRef, + private planningReviewDocumentService: PlanningReviewDocumentService, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + this.loadDocumentTypes(); + + if (this.data.existingDocument) { + const document = this.data.existingDocument; + this.title = 'Edit'; + this.allowsFileEdit = document.system === DOCUMENT_SYSTEM.ALCS; + this.form.patchValue({ + name: document.fileName, + type: document.type?.code, + source: document.source, + visibleToCommissioner: document.visibilityFlags.includes('C'), + }); + this.documentTypeAhead = document.type!.code; + this.existingFile = { + name: document.fileName, + size: 0, + }; + } + } + + async onSubmit() { + const visibilityFlags: 'C'[] = []; + + if (this.visibleToCommissioner.getRawValue()) { + visibilityFlags.push('C'); + } + + const dto: UpdateDocumentDto = { + fileName: this.name.value!, + source: this.source.value as DOCUMENT_SOURCE, + typeCode: this.type.value as DOCUMENT_TYPE, + visibilityFlags, + parcelUuid: this.parcelId.value ?? undefined, + ownerUuid: this.ownerId.value ?? undefined, + }; + + const file = this.pendingFile; + this.isSaving = true; + if (this.data.existingDocument) { + await this.planningReviewDocumentService.update(this.data.existingDocument.uuid, dto); + } else if (file !== undefined) { + try { + await this.planningReviewDocumentService.upload(this.data.fileId, { + ...dto, + file, + }); + } catch (err) { + this.toastService.showErrorToast('Document upload failed'); + if (err instanceof HttpErrorResponse && err.status === 403) { + this.showVirusError = true; + this.isSaving = false; + this.pendingFile = undefined; + return; + } + } + this.showVirusError = false; + } + + this.dialog.close(true); + this.isSaving = false; + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + filterDocumentTypes(term: string, item: DocumentTypeDto) { + const termLower = term.toLocaleLowerCase(); + return ( + item.label.toLocaleLowerCase().indexOf(termLower) > -1 || + item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 + ); + } + + async onDocTypeSelected($event?: DocumentTypeDto) { + if ($event) { + this.type.setValue($event.code); + } else { + this.type.setValue(undefined); + } + } + + uploadFile(event: Event) { + const element = event.target as HTMLInputElement; + const selectedFiles = element.files; + if (selectedFiles && selectedFiles[0]) { + this.pendingFile = selectedFiles[0]; + this.name.setValue(selectedFiles[0].name); + this.showVirusError = false; + } + } + + onRemoveFile() { + this.pendingFile = undefined; + this.existingFile = undefined; + } + + openFile() { + if (this.pendingFile) { + const fileURL = URL.createObjectURL(this.pendingFile); + window.open(fileURL, '_blank'); + } + } + + async openExistingFile() { + if (this.data.existingDocument) { + await this.planningReviewDocumentService.download( + this.data.existingDocument.uuid, + this.data.existingDocument.fileName, + ); + } + } + + private async loadDocumentTypes() { + const docTypes = await this.planningReviewDocumentService.fetchTypes(); + docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); + this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.html b/alcs-frontend/src/app/features/planning-review/documents/documents.component.html new file mode 100644 index 0000000000..6a55826bb8 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.html @@ -0,0 +1,83 @@ +
+

Documents

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Type + {{ element.type.oatsCode }} + Document Name + {{ element.fileName }} + Source - System{{ element.source }} - {{ element.system }} + Visibility +
* = Pending
+
+ + A* + , + + + + C* + , + + + + G* + , + + + P* + + Upload Date{{ element.uploadedAt | date }}Actions + + + +
No Documents
diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.scss b/alcs-frontend/src/app/features/planning-review/documents/documents.component.scss new file mode 100644 index 0000000000..dadc053396 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.scss @@ -0,0 +1,44 @@ +@use '../../../../styles/colors'; + +:host { + display: block; + padding-bottom: 48px; +} + +.header { + display: flex; + justify-content: space-between; +} + +.documents { + margin-top: 64px; +} + +.mat-mdc-no-data-row { + height: 56px; + color: colors.$grey-dark; +} + +a { + word-break: break-all; +} + +table { + position: relative; + + th mat-header-cell { + position: relative; + } + + .subheading { + font-size: 11px; + line-height: 16px; + font-weight: 400; + position: absolute; + top: 100%; /* Position it below the header text */ + left: 0; /* Align it to the left edge of the header cell */ + } +} + + + diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts b/alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts new file mode 100644 index 0000000000..801b897e8f --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts @@ -0,0 +1,59 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDocumentService } from '../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { PlanningReviewDetailedDto } from '../../../services/planning-review/planning-review.dto'; +import { ToastService } from '../../../services/toast/toast.service'; + +import { DocumentsComponent } from './documents.component'; + +describe('DocumentsComponent', () => { + let component: DocumentsComponent; + let fixture: ComponentFixture; + let mockPRDocService: DeepMocked; + let mockPRDetailService: DeepMocked; + let mockDialog: DeepMocked; + let mockToastService: DeepMocked; + + beforeEach(async () => { + mockPRDocService = createMock(); + mockPRDetailService = createMock(); + mockDialog = createMock(); + mockToastService = createMock(); + mockPRDetailService.$planningReview = new BehaviorSubject(undefined); + + await TestBed.configureTestingModule({ + declarations: [DocumentsComponent], + providers: [ + { + provide: PlanningReviewDocumentService, + useValue: mockPRDocService, + }, + { + provide: PlanningReviewDetailService, + useValue: mockPRDetailService, + }, + { + provide: MatDialog, + useValue: mockDialog, + }, + { + provide: ToastService, + useValue: mockToastService, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DocumentsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.ts b/alcs-frontend/src/app/features/planning-review/documents/documents.component.ts new file mode 100644 index 0000000000..be582c6df5 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.ts @@ -0,0 +1,121 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDocumentDto } from '../../../services/planning-review/planning-review-document/planning-review-document.dto'; +import { PlanningReviewDocumentService } from '../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { ToastService } from '../../../services/toast/toast.service'; +import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { DOCUMENT_SYSTEM } from '../../../shared/document/document.dto'; +import { DocumentUploadDialogComponent } from './document-upload-dialog/document-upload-dialog.component'; + +@Component({ + selector: 'app-documents', + templateUrl: './documents.component.html', + styleUrls: ['./documents.component.scss'], +}) +export class DocumentsComponent implements OnInit { + displayedColumns: string[] = ['type', 'fileName', 'source', 'visibilityFlags', 'uploadedAt', 'actions']; + documents: PlanningReviewDocumentDto[] = []; + private fileId = ''; + + DOCUMENT_SYSTEM = DOCUMENT_SYSTEM; + + hasBeenReceived = false; + hasBeenSetForDiscussion = false; + hiddenFromPortal = false; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource = new MatTableDataSource(); + + constructor( + private planningReviewDocumentService: PlanningReviewDocumentService, + private planningReviewDetailService: PlanningReviewDetailService, + private confirmationDialogService: ConfirmationDialogService, + private toastService: ToastService, + public dialog: MatDialog, + ) {} + + ngOnInit(): void { + this.planningReviewDetailService.$planningReview.subscribe((planningReview) => { + if (planningReview) { + this.fileId = planningReview.fileNumber; + this.loadDocuments(planningReview.fileNumber); + } + }); + } + + async onUploadFile() { + this.dialog + .open(DocumentUploadDialogComponent, { + minWidth: '600px', + maxWidth: '800px', + width: '70%', + data: { + fileId: this.fileId, + }, + }) + .beforeClosed() + .subscribe((isDirty) => { + if (isDirty) { + this.loadDocuments(this.fileId); + } + }); + } + + async openFile(uuid: string, fileName: string) { + await this.planningReviewDocumentService.download(uuid, fileName); + } + + async downloadFile(uuid: string, fileName: string) { + await this.planningReviewDocumentService.download(uuid, fileName, false); + } + + private async loadDocuments(fileNumber: string) { + this.documents = await this.planningReviewDocumentService.listAll(fileNumber); + this.dataSource = new MatTableDataSource(this.documents); + this.dataSource.sortingDataAccessor = (item, property) => { + switch (property) { + case 'type': + return item.type?.oatsCode; + default: // @ts-ignore Does not like using String for Key access, but that's what Angular provides + return item[property]; + } + }; + this.dataSource.sort = this.sort; + } + + onEditFile(element: PlanningReviewDocumentDto) { + this.dialog + .open(DocumentUploadDialogComponent, { + minWidth: '600px', + maxWidth: '800px', + width: '70%', + data: { + fileId: this.fileId, + existingDocument: element, + }, + }) + .beforeClosed() + .subscribe((isDirty: boolean) => { + if (isDirty) { + this.loadDocuments(this.fileId); + } + }); + } + + onDeleteFile(element: PlanningReviewDocumentDto) { + this.confirmationDialogService + .openDialog({ + body: 'Are you sure you want to delete the selected file?', + }) + .subscribe(async (accepted) => { + if (accepted) { + await this.planningReviewDocumentService.delete(element.uuid); + this.loadDocuments(this.fileId); + this.toastService.showSuccessToast('Document deleted'); + } + }); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts b/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts index 3c34e108ec..6d3d24740c 100644 --- a/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts +++ b/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, ParamMap } from '@angular/router'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; @@ -34,6 +35,7 @@ describe('PlanningReviewComponent', () => { useValue: mockActivateRoute, }, ], + schemas: [NO_ERRORS_SCHEMA], }); fixture = TestBed.createComponent(PlanningReviewComponent); component = fixture.componentInstance; diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.component.ts b/alcs-frontend/src/app/features/planning-review/planning-review.component.ts index 163daaf48f..90e8e6ef00 100644 --- a/alcs-frontend/src/app/features/planning-review/planning-review.component.ts +++ b/alcs-frontend/src/app/features/planning-review/planning-review.component.ts @@ -1,9 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Subject, take, takeUntil } from 'rxjs'; +import { Subject, takeUntil } from 'rxjs'; import { PlanningReviewDetailService } from '../../services/planning-review/planning-review-detail.service'; -import { PlanningReviewDetailedDto, PlanningReviewDto } from '../../services/planning-review/planning-review.dto'; -import { PlanningReviewService } from '../../services/planning-review/planning-review.service'; +import { PlanningReviewDetailedDto } from '../../services/planning-review/planning-review.dto'; +import { DocumentsComponent } from './documents/documents.component'; import { OverviewComponent } from './overview/overview.component'; export const childRoutes = [ @@ -13,6 +13,13 @@ export const childRoutes = [ icon: 'summarize', component: OverviewComponent, }, + { + path: 'documents', + menuTitle: 'Documents', + icon: 'description', + component: DocumentsComponent, + portalOnly: false, + }, ]; @Component({ diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.module.ts b/alcs-frontend/src/app/features/planning-review/planning-review.module.ts index 7e02a8f587..8d55601ff0 100644 --- a/alcs-frontend/src/app/features/planning-review/planning-review.module.ts +++ b/alcs-frontend/src/app/features/planning-review/planning-review.module.ts @@ -3,6 +3,8 @@ import { CommonModule } from '@angular/common'; import { RouterModule, Routes } from '@angular/router'; import { PlanningReviewDetailService } from '../../services/planning-review/planning-review-detail.service'; import { SharedModule } from '../../shared/shared.module'; +import { DocumentUploadDialogComponent } from './documents/document-upload-dialog/document-upload-dialog.component'; +import { DocumentsComponent } from './documents/documents.component'; import { HeaderComponent } from './header/header.component'; import { OverviewComponent } from './overview/overview.component'; import { childRoutes, PlanningReviewComponent } from './planning-review.component'; @@ -17,7 +19,13 @@ const routes: Routes = [ @NgModule({ providers: [PlanningReviewDetailService], - declarations: [PlanningReviewComponent, OverviewComponent, HeaderComponent], + declarations: [ + PlanningReviewComponent, + OverviewComponent, + HeaderComponent, + DocumentsComponent, + DocumentUploadDialogComponent, + ], imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], }) export class PlanningReviewModule {} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts new file mode 100644 index 0000000000..29bedaf646 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts @@ -0,0 +1,35 @@ +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, + DOCUMENT_TYPE, + DocumentTypeDto, +} from '../../../shared/document/document.dto'; + +export interface PlanningReviewDocumentDto { + uuid: string; + documentUuid: string; + type?: DocumentTypeDto; + description?: string; + visibilityFlags: string[]; + source: DOCUMENT_SOURCE; + system: DOCUMENT_SYSTEM; + fileName: string; + mimeType: string; + uploadedBy: string; + uploadedAt: number; + evidentiaryRecordSorting?: number; +} + +export interface UpdateDocumentDto { + file?: File; + parcelUuid?: string; + ownerUuid?: string; + fileName: string; + typeCode: DOCUMENT_TYPE; + source: DOCUMENT_SOURCE; + visibilityFlags: 'C'[]; +} + +export interface CreateDocumentDto extends UpdateDocumentDto { + file: File; +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts new file mode 100644 index 0000000000..c79917d60c --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts @@ -0,0 +1,111 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { of } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../shared/document/document.dto'; +import { ToastService } from '../../toast/toast.service'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +describe('PlanningReviewDocumentService', () => { + let service: PlanningReviewDocumentService; + let httpClient: DeepMocked; + let toastService: DeepMocked; + + beforeEach(() => { + httpClient = createMock(); + toastService = createMock(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: HttpClient, + useValue: httpClient, + }, + { + provide: ToastService, + useValue: toastService, + }, + ], + }); + service = TestBed.inject(PlanningReviewDocumentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should make a get call for list', async () => { + httpClient.get.mockReturnValue( + of([ + { + uuid: '1', + }, + ]), + ); + + const res = await service.listByVisibility('1', []); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(res.length).toEqual(1); + expect(res[0].uuid).toEqual('1'); + }); + + it('should make a delete call for delete', async () => { + httpClient.delete.mockReturnValue( + of({ + uuid: '1', + }), + ); + + const res = await service.delete('1'); + + expect(httpClient.delete).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + expect(res.uuid).toEqual('1'); + }); + + it('should show a toast warning when uploading a file thats too large', async () => { + const file = createMock(); + Object.defineProperty(file, 'size', { value: environment.maxFileSize + 1 }); + + await service.upload('', { + file, + fileName: '', + typeCode: DOCUMENT_TYPE.AUTHORIZATION_LETTER, + source: DOCUMENT_SOURCE.APPLICANT, + visibilityFlags: [], + }); + + expect(toastService.showWarningToast).toHaveBeenCalledTimes(1); + expect(httpClient.post).toHaveBeenCalledTimes(0); + }); + + it('should make a get call for list review documents', async () => { + httpClient.get.mockReturnValue( + of([ + { + uuid: '1', + }, + ]), + ); + + const res = await service.getReviewDocuments('1'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(res.length).toEqual(1); + expect(res[0].uuid).toEqual('1'); + }); + + it('should make a post call for sort', async () => { + httpClient.post.mockReturnValue( + of({ + uuid: '1', + }), + ); + + await service.updateSort([]); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts new file mode 100644 index 0000000000..bfafc6d8d1 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts @@ -0,0 +1,101 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { DocumentTypeDto } from '../../../shared/document/document.dto'; +import { downloadFileFromUrl, openFileInline } from '../../../shared/utils/file'; +import { verifyFileSize } from '../../../shared/utils/file-size-checker'; +import { ToastService } from '../../toast/toast.service'; +import { PlanningReviewDocumentDto, CreateDocumentDto, UpdateDocumentDto } from './planning-review-document.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class PlanningReviewDocumentService { + private url = `${environment.apiUrl}/planning-review-document`; + + constructor( + private http: HttpClient, + private toastService: ToastService, + ) {} + + async listAll(fileNumber: string) { + return firstValueFrom(this.http.get(`${this.url}/planning-review/${fileNumber}`)); + } + + async listByVisibility(fileNumber: string, visibilityFlags: string[]) { + return firstValueFrom( + this.http.get(`${this.url}/planning-review/${fileNumber}/${visibilityFlags.join()}`), + ); + } + + async upload(fileNumber: string, createDto: CreateDocumentDto) { + const file = createDto.file; + const isValidSize = verifyFileSize(file, this.toastService); + if (!isValidSize) { + return; + } + let formData = this.convertDtoToFormData(createDto); + + const res = await firstValueFrom(this.http.post(`${this.url}/planning-review/${fileNumber}`, formData)); + this.toastService.showSuccessToast('Document uploaded'); + return res; + } + + async delete(uuid: string) { + return firstValueFrom(this.http.delete(`${this.url}/${uuid}`)); + } + + async download(uuid: string, fileName: string, isInline = true) { + const url = isInline ? `${this.url}/${uuid}/open` : `${this.url}/${uuid}/download`; + const data = await firstValueFrom(this.http.get<{ url: string }>(url)); + if (isInline) { + openFileInline(data.url, fileName); + } else { + downloadFileFromUrl(data.url, fileName); + } + } + + async getReviewDocuments(fileNumber: string) { + return firstValueFrom( + this.http.get(`${this.url}/planning-review/${fileNumber}/reviewDocuments`), + ); + } + + async fetchTypes() { + return firstValueFrom(this.http.get(`${this.url}/types`)); + } + + async update(uuid: string, updateDto: UpdateDocumentDto) { + let formData = this.convertDtoToFormData(updateDto); + const res = await firstValueFrom(this.http.post(`${this.url}/${uuid}`, formData)); + this.toastService.showSuccessToast('Document uploaded'); + return res; + } + + async updateSort(sortOrder: { uuid: string; order: number }[]) { + try { + await firstValueFrom(this.http.post(`${this.url}/sort`, sortOrder)); + } catch (e) { + this.toastService.showErrorToast(`Failed to save document order`); + } + } + + private convertDtoToFormData(dto: UpdateDocumentDto) { + let formData: FormData = new FormData(); + formData.append('documentType', dto.typeCode); + formData.append('source', dto.source); + formData.append('visibilityFlags', dto.visibilityFlags.join(', ')); + formData.append('fileName', dto.fileName); + if (dto.file) { + formData.append('file', dto.file, dto.file.name); + } + if (dto.parcelUuid) { + formData.append('parcelUuid', dto.parcelUuid); + } + if (dto.ownerUuid) { + formData.append('ownerUuid', dto.ownerUuid); + } + return formData; + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts index 3c60915954..e126888a8f 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts @@ -17,15 +17,6 @@ export class PlanningReferral extends Base { } } - @AutoMap(() => String) - @Column({ - type: 'text', - comment: - 'Application Id that is applicable only to paper version applications from 70s - 80s', - nullable: true, - }) - legacyId?: string | null; - @AutoMap() @Column({ type: 'timestamptz' }) submissionDate: Date; diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts new file mode 100644 index 0000000000..8de38ef3fc --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts @@ -0,0 +1,189 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; +import { PlanningReviewProfile } from '../../../common/automapper/planning-review.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 { PlanningReviewDocumentController } from './planning-review-document.controller'; +import { PlanningReviewDocument } from './planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +describe('PlanningReviewDocumentController', () => { + let controller: PlanningReviewDocumentController; + let mockPlanningReviewDocumentService: DeepMocked; + + const mockDocument = new PlanningReviewDocument({ + document: new Document({ + mimeType: 'mimeType', + uploadedBy: new User(), + uploadedAt: new Date(), + }), + }); + + beforeEach(async () => { + mockPlanningReviewDocumentService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [PlanningReviewDocumentController], + providers: [ + { + provide: CodeService, + useValue: {}, + }, + PlanningReviewProfile, + { + provide: PlanningReviewDocumentService, + useValue: mockPlanningReviewDocumentService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + controller = module.get( + PlanningReviewDocumentController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return the attached document', async () => { + const mockFile = {}; + const mockUser = {}; + + mockPlanningReviewDocumentService.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( + mockPlanningReviewDocumentService.attachDocument, + ).toHaveBeenCalledTimes(1); + const callData = + mockPlanningReviewDocumentService.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 = {}; + + mockPlanningReviewDocumentService.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 () => { + mockPlanningReviewDocumentService.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 () => { + mockPlanningReviewDocumentService.delete.mockResolvedValue(mockDocument); + mockPlanningReviewDocumentService.get.mockResolvedValue(mockDocument); + + await controller.delete('fake-uuid'); + + expect(mockPlanningReviewDocumentService.get).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewDocumentService.delete).toHaveBeenCalledTimes(1); + }); + + it('should call through for open', async () => { + const fakeUrl = 'fake-url'; + mockPlanningReviewDocumentService.getInlineUrl.mockResolvedValue(fakeUrl); + mockPlanningReviewDocumentService.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'; + mockPlanningReviewDocumentService.getDownloadUrl.mockResolvedValue(fakeUrl); + mockPlanningReviewDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.download('fake-uuid'); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for list types', async () => { + mockPlanningReviewDocumentService.fetchTypes.mockResolvedValue([]); + + const res = await controller.listTypes(); + + expect(mockPlanningReviewDocumentService.fetchTypes).toHaveBeenCalledTimes( + 1, + ); + }); + + it('should call through for setting sort', async () => { + mockPlanningReviewDocumentService.setSorting.mockResolvedValue(); + + await controller.sortDocuments([]); + + expect(mockPlanningReviewDocumentService.setSorting).toHaveBeenCalledTimes( + 1, + ); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts new file mode 100644 index 0000000000..7cc91ebe29 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts @@ -0,0 +1,206 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; +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 { PlanningReviewDocumentDto } from './planning-review-document.dto'; +import { + PlanningReviewDocument, + PR_VISIBILITY_FLAG, +} from './planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@UseGuards(RolesGuard) +@Controller('planning-review-document') +export class PlanningReviewDocumentController { + constructor( + private planningReviewDocumentService: PlanningReviewDocumentService, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/planning-review/:fileNumber') + @UserRoles(...ANY_AUTH_ROLE) + async listAll( + @Param('fileNumber') fileNumber: string, + ): Promise { + const documents = await this.planningReviewDocumentService.list(fileNumber); + return this.mapper.mapArray( + documents, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Post('/planning-review/: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, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @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.planningReviewDocumentService.update({ + uuid: documentUuid, + fileName, + file, + documentType: documentType as DOCUMENT_TYPE, + source: documentSource, + visibilityFlags, + user: req.user.entity, + }); + + return this.mapper.map( + savedDocument, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Get('/planning-review/:fileNumber/reviewDocuments') + @UserRoles(...ANY_AUTH_ROLE) + async listReviewDocuments( + @Param('fileNumber') fileNumber: string, + ): Promise { + const documents = await this.planningReviewDocumentService.list(fileNumber); + const reviewDocuments = documents.filter( + (doc) => doc.document.source === DOCUMENT_SOURCE.LFNG, + ); + + return this.mapper.mapArray( + reviewDocuments, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Get('/planning-review/:fileNumber/:visibilityFlags') + @UserRoles(...ANY_AUTH_ROLE) + async listDocuments( + @Param('fileNumber') fileNumber: string, + @Param('visibilityFlags') visibilityFlags: string, + ): Promise { + const mappedFlags = visibilityFlags.split('') as PR_VISIBILITY_FLAG[]; + const documents = await this.planningReviewDocumentService.list( + fileNumber, + mappedFlags, + ); + return this.mapper.mapArray( + documents, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Get('/types') + @UserRoles(...ANY_AUTH_ROLE) + async listTypes() { + const types = await this.planningReviewDocumentService.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.planningReviewDocumentService.get(fileUuid); + const url = await this.planningReviewDocumentService.getInlineUrl(document); + return { + url, + }; + } + + @Get('/:uuid/download') + @UserRoles(...ANY_AUTH_ROLE) + async download(@Param('uuid') fileUuid: string) { + const document = await this.planningReviewDocumentService.get(fileUuid); + const url = + await this.planningReviewDocumentService.getDownloadUrl(document); + return { + url, + }; + } + + @Delete('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async delete(@Param('uuid') fileUuid: string) { + const document = await this.planningReviewDocumentService.get(fileUuid); + await this.planningReviewDocumentService.delete(document); + return {}; + } + + @Post('/sort') + @UserRoles(...ANY_AUTH_ROLE) + async sortDocuments( + @Body() data: { uuid: string; order: number }[], + ): Promise { + await this.planningReviewDocumentService.setSorting(data); + } + + 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.planningReviewDocumentService.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/planning-review/planning-review-document/planning-review-document.dto.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.dto.ts new file mode 100644 index 0000000000..14b50a1387 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.dto.ts @@ -0,0 +1,29 @@ +import { AutoMap } from 'automapper-classes'; +import { DocumentTypeDto } from '../../../document/document.dto'; + +export class PlanningReviewDocumentDto { + @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/planning-review/planning-review-document/planning-review-document.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.entity.ts new file mode 100644 index 0000000000..3ae3f2c7b2 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.entity.ts @@ -0,0 +1,64 @@ +import { AutoMap } from 'automapper-classes'; +import { + BaseEntity, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { DocumentCode } from '../../../document/document-code.entity'; +import { Document } from '../../../document/document.entity'; +import { PlanningReview } from '../planning-review.entity'; + +export enum PR_VISIBILITY_FLAG { + COMMISSIONER = 'C', +} + +@Entity({ + comment: 'Stores planning review documents', +}) +export class PlanningReviewDocument 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(() => PlanningReview, { nullable: false }) + planningReview: PlanningReview; + + @Column() + @Index() + planningReviewUuid: string; + + @Column({ nullable: true, type: 'uuid' }) + documentUuid?: string | null; + + @AutoMap(() => [String]) + @Column({ default: [], array: true, type: 'text' }) + visibilityFlags: PR_VISIBILITY_FLAG[]; + + @Column({ nullable: true, type: 'int' }) + evidentiaryRecordSorting?: number | null; + + @OneToOne(() => Document) + @JoinColumn() + document: Document; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts new file mode 100644 index 0000000000..74805b78a3 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts @@ -0,0 +1,293 @@ +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 { PlanningReview } from '../planning-review.entity'; +import { PlanningReviewService } from '../planning-review.service'; +import { PlanningReviewDocument } from './planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +describe('PlanningReviewDocumentService', () => { + let service: PlanningReviewDocumentService; + let mockDocumentService: DeepMocked; + let mockPlanningReviewService: DeepMocked; + let mockRepository: DeepMocked>; + let mockTypeRepository: DeepMocked>; + + let mockPlanningReview; + const fileNumber = '12345'; + + beforeEach(async () => { + mockDocumentService = createMock(); + mockPlanningReviewService = createMock(); + mockRepository = createMock(); + mockTypeRepository = createMock(); + + mockPlanningReview = new PlanningReview({ + fileNumber, + }); + mockPlanningReviewService.getDetailedReview.mockResolvedValue( + mockPlanningReview, + ); + mockDocumentService.create.mockResolvedValue({} as Document); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlanningReviewDocumentService, + { + provide: DocumentService, + useValue: mockDocumentService, + }, + { + provide: PlanningReviewService, + useValue: mockPlanningReviewService, + }, + { + provide: getRepositoryToken(DocumentCode), + useValue: mockTypeRepository, + }, + { + provide: getRepositoryToken(PlanningReviewDocument), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get( + PlanningReviewDocumentService, + ); + }); + + 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 PlanningReviewDocument, + ); + + 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(mockPlanningReviewService.getDetailedReview).toHaveBeenCalledTimes( + 1, + ); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create.mock.calls[0][0]).toBe( + 'planning-review/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].planningReview).toBe( + mockPlanningReview, + ); + + expect(res).toBe(mockSavedDocument); + }); + + it('should delete document and planning review document when deleting', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as PlanningReviewDocument; + + 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 PlanningReviewDocument; + + 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 PlanningReviewDocument; + + 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 PlanningReviewDocument; + 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 PlanningReviewDocument; + + 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 call through for fetchTypes', async () => { + mockTypeRepository.find.mockResolvedValue([]); + + const res = await service.fetchTypes(); + + expect(mockTypeRepository.find).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + }); + + it('should create a record for external documents', async () => { + mockRepository.save.mockResolvedValue(new PlanningReviewDocument()); + mockPlanningReviewService.getDetailedReview.mockResolvedValueOnce( + mockPlanningReview, + ); + mockRepository.findOne.mockResolvedValue(new PlanningReviewDocument()); + + const res = await service.attachExternalDocument( + '', + { + type: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + description: '', + documentUuid: 'fake-uuid', + }, + [], + ); + + expect(mockPlanningReviewService.getDetailedReview).toHaveBeenCalledTimes( + 1, + ); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockRepository.save.mock.calls[0][0].planningReview).toBe( + mockPlanningReview, + ); + 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 PlanningReviewDocument({ + document: new Document(), + }), + ); + mockPlanningReviewService.getFileNumber.mockResolvedValue( + mockPlanningReview, + ); + mockRepository.save.mockResolvedValue(new PlanningReviewDocument()); + mockDocumentService.create.mockResolvedValue(new Document()); + mockDocumentService.softRemove.mockResolvedValue(); + + 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(mockPlanningReviewService.getFileNumber).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + }); + + it('should load and save the documents with the new sort order', async () => { + const mockDoc1 = new PlanningReviewDocument({ + uuid: 'uuid-1', + evidentiaryRecordSorting: 5, + }); + const mockDoc2 = new PlanningReviewDocument({ + uuid: 'uuid-2', + evidentiaryRecordSorting: 6, + }); + mockRepository.find.mockResolvedValue([mockDoc1, mockDoc2]); + mockRepository.save.mockResolvedValue({} as any); + + await service.setSorting([ + { + uuid: mockDoc1.uuid, + order: 0, + }, + { + uuid: mockDoc2.uuid, + order: 1, + }, + ]); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockDoc1.evidentiaryRecordSorting).toEqual(0); + expect(mockDoc2.evidentiaryRecordSorting).toEqual(1); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts new file mode 100644 index 0000000000..d029a129de --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts @@ -0,0 +1,219 @@ +import { MultipartFile } from '@fastify/multipart'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + ArrayOverlap, + FindOptionsRelations, + FindOptionsWhere, + In, + 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 { User } from '../../../user/user.entity'; +import { PlanningReviewService } from '../planning-review.service'; +import { + PlanningReviewDocument, + PR_VISIBILITY_FLAG, +} from './planning-review-document.entity'; + +@Injectable() +export class PlanningReviewDocumentService { + private DEFAULT_RELATIONS: FindOptionsRelations = { + document: true, + type: true, + }; + + constructor( + private documentService: DocumentService, + private planningReviewService: PlanningReviewService, + @InjectRepository(PlanningReviewDocument) + private planningReviewDocumentRepo: 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: PR_VISIBILITY_FLAG[]; + }) { + const planningReview = + await this.planningReviewService.getDetailedReview(fileNumber); + const document = await this.documentService.create( + `planning-review/${fileNumber}`, + fileName, + file, + user, + source, + system, + ); + const appDocument = new PlanningReviewDocument({ + typeCode: documentType, + planningReview, + document, + visibilityFlags, + }); + + return this.planningReviewDocumentRepo.save(appDocument); + } + + async get(uuid: string) { + const document = await this.planningReviewDocumentRepo.findOne({ + where: { + uuid: uuid, + }, + relations: this.DEFAULT_RELATIONS, + }); + if (!document) { + throw new NotFoundException(`Failed to find document ${uuid}`); + } + return document; + } + + async delete(document: PlanningReviewDocument) { + await this.planningReviewDocumentRepo.remove(document); + await this.documentService.softRemove(document.document); + return document; + } + + async list(fileNumber: string, visibilityFlags?: PR_VISIBILITY_FLAG[]) { + const where: FindOptionsWhere = { + planningReview: { + fileNumber, + }, + }; + if (visibilityFlags) { + where.visibilityFlags = ArrayOverlap(visibilityFlags); + } + return this.planningReviewDocumentRepo.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, + }, + relations: this.DEFAULT_RELATIONS, + }); + } + + async getInlineUrl(document: PlanningReviewDocument) { + return this.documentService.getDownloadUrl(document.document, true); + } + + async getDownloadUrl(document: PlanningReviewDocument) { + return this.documentService.getDownloadUrl(document.document); + } + + async attachExternalDocument( + fileNumber: string, + data: { + type?: DOCUMENT_TYPE; + documentUuid: string; + description?: string; + }, + visibilityFlags: PR_VISIBILITY_FLAG[], + ) { + const planningReview = + await this.planningReviewService.getDetailedReview(fileNumber); + const document = new PlanningReviewDocument({ + planningReview, + typeCode: data.type, + documentUuid: data.documentUuid, + description: data.description, + visibilityFlags, + }); + + const savedDocument = await this.planningReviewDocumentRepo.save(document); + return this.get(savedDocument.uuid); + } + + 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: PR_VISIBILITY_FLAG[]; + source: DOCUMENT_SOURCE; + user: User; + }) { + const appDocument = await this.get(uuid); + + if (file) { + const fileNumber = await this.planningReviewService.getFileNumber( + appDocument.planningReviewUuid, + ); + await this.documentService.softRemove(appDocument.document); + appDocument.document = await this.documentService.create( + `planning-review/${fileNumber}`, + fileName, + file, + user, + source, + appDocument.document.system as DOCUMENT_SYSTEM, + ); + } else { + await this.documentService.update(appDocument.document, { + fileName, + source, + }); + } + appDocument.type = undefined; + appDocument.typeCode = documentType; + appDocument.visibilityFlags = visibilityFlags; + return await this.planningReviewDocumentRepo.save(appDocument); + } + + async setSorting(data: { uuid: string; order: number }[]) { + const uuids = data.map((data) => data.uuid); + const documents = await this.planningReviewDocumentRepo.find({ + where: { + uuid: In(uuids), + }, + }); + + for (const document of data) { + const existingDocument = documents.find( + (doc) => doc.uuid === document.uuid, + ); + if (existingDocument) { + existingDocument.evidentiaryRecordSorting = document.order; + } + } + + await this.planningReviewDocumentRepo.save(documents); + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts index a6141523f6..d56139b567 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts @@ -21,6 +21,15 @@ export class PlanningReview extends Base { @Column({ unique: true }) fileNumber: string; + @AutoMap(() => String) + @Column({ + type: 'text', + comment: + 'Application Id that is applicable only to paper version applications from 70s - 80s', + nullable: true, + }) + legacyId?: string | null; + @Column({ nullable: false }) documentName: string; diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts index 0e9ee0b166..9b81109c95 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts @@ -1,6 +1,8 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PlanningReviewProfile } from '../../common/automapper/planning-review.automapper.profile'; +import { DocumentCode } from '../../document/document-code.entity'; +import { DocumentModule } from '../../document/document.module'; import { FileNumberModule } from '../../file-number/file-number.module'; import { BoardModule } from '../board/board.module'; import { CardModule } from '../card/card.module'; @@ -8,6 +10,9 @@ import { CodeModule } from '../code/code.module'; import { PlanningReferralController } from './planning-referral/planning-referral.controller'; import { PlanningReferral } from './planning-referral/planning-referral.entity'; import { PlanningReferralService } from './planning-referral/planning-referral.service'; +import { PlanningReviewDocumentController } from './planning-review-document/planning-review-document.controller'; +import { PlanningReviewDocument } from './planning-review-document/planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document/planning-review-document.service'; import { PlanningReviewType } from './planning-review-type.entity'; import { PlanningReviewController } from './planning-review.controller'; import { PlanningReview } from './planning-review.entity'; @@ -19,17 +24,25 @@ import { PlanningReviewService } from './planning-review.service'; PlanningReview, PlanningReferral, PlanningReviewType, + PlanningReviewDocument, + DocumentCode, ]), forwardRef(() => BoardModule), CardModule, CodeModule, FileNumberModule, + DocumentModule, + ], + controllers: [ + PlanningReviewController, + PlanningReferralController, + PlanningReviewDocumentController, ], - controllers: [PlanningReviewController, PlanningReferralController], providers: [ PlanningReviewService, PlanningReviewProfile, PlanningReferralService, + PlanningReviewDocumentService, ], exports: [PlanningReviewService, PlanningReferralService], }) diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts index 7b1d0c9520..8843d04fa3 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts @@ -137,4 +137,13 @@ export class PlanningReviewService { await this.reviewRepository.save(existingApp); return this.getDetailedReview(fileNumber); } + + async getFileNumber(planningReviewUuid: string) { + return this.reviewRepository.findOneOrFail({ + where: { + uuid: planningReviewUuid, + }, + select: ['fileNumber'], + }); + } } diff --git a/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts b/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts index 27d1bfdd6b..a9a9182841 100644 --- a/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts +++ b/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { createMap, forMember, mapFrom, Mapper } from 'automapper-core'; import { AutomapperProfile, InjectMapper } from 'automapper-nestjs'; import { PlanningReferral } from '../../alcs/planning-review/planning-referral/planning-referral.entity'; +import { PlanningReviewDocumentDto } from '../../alcs/planning-review/planning-review-document/planning-review-document.dto'; +import { PlanningReviewDocument } from '../../alcs/planning-review/planning-review-document/planning-review-document.entity'; import { PlanningReviewType } from '../../alcs/planning-review/planning-review-type.entity'; import { PlanningReferralDto, @@ -10,6 +12,8 @@ import { PlanningReviewTypeDto, } from '../../alcs/planning-review/planning-review.dto'; import { PlanningReview } from '../../alcs/planning-review/planning-review.entity'; +import { DocumentCode } from '../../document/document-code.entity'; +import { DocumentTypeDto } from '../../document/document.dto'; @Injectable() export class PlanningReviewProfile extends AutomapperProfile { @@ -35,6 +39,45 @@ export class PlanningReviewProfile extends AutomapperProfile { ), ); createMap(mapper, PlanningReview, PlanningReviewDetailedDto); + + createMap( + mapper, + PlanningReviewDocument, + PlanningReviewDocumentDto, + 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/providers/typeorm/migrations/1709856439937-add_pr_documents.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709856439937-add_pr_documents.ts new file mode 100644 index 0000000000..c2b21071a5 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709856439937-add_pr_documents.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPrDocuments1709856439937 implements MigrationInterface { + name = 'AddPrDocuments1709856439937'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "alcs"."planning_review_document" ("uuid" uuid NOT NULL DEFAULT gen_random_uuid(), "type_code" text, "description" text, "planning_review_uuid" uuid NOT NULL, "document_uuid" uuid, "visibility_flags" text array NOT NULL DEFAULT '{}', "evidentiary_record_sorting" integer, CONSTRAINT "REL_80d9441726c3d26ccd426cd469" UNIQUE ("document_uuid"), CONSTRAINT "PK_b8b1ceeaebfc4a6b5a746f0a85b" PRIMARY KEY ("uuid"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e95903f18d734736a1ba855569" ON "alcs"."planning_review_document" ("planning_review_uuid") `, + ); + await queryRunner.query( + `COMMENT ON TABLE "alcs"."planning_review_document" IS 'Stores planning review documents'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" ADD CONSTRAINT "FK_6ed3e4681afbbcd3444d7600a84" FOREIGN KEY ("type_code") REFERENCES "alcs"."document_code"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" ADD CONSTRAINT "FK_e95903f18d734736a1ba8555698" FOREIGN KEY ("planning_review_uuid") REFERENCES "alcs"."planning_review"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" ADD CONSTRAINT "FK_80d9441726c3d26ccd426cd4699" 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"."planning_review_document" DROP CONSTRAINT "FK_80d9441726c3d26ccd426cd4699"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" DROP CONSTRAINT "FK_e95903f18d734736a1ba8555698"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" DROP CONSTRAINT "FK_6ed3e4681afbbcd3444d7600a84"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_e95903f18d734736a1ba855569"`, + ); + await queryRunner.query(`DROP TABLE "alcs"."planning_review_document"`); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts new file mode 100644 index 0000000000..afebde99c4 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveLegacyId1709857038186 implements MigrationInterface { + name = 'MoveLegacyId1709857038186'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" DROP COLUMN "legacy_id"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "legacy_id" text`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."planning_review"."legacy_id" IS 'Application Id that is applicable only to paper version applications from 70s - 80s'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."planning_review"."legacy_id" IS 'Application Id that is applicable only to paper version applications from 70s - 80s'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "legacy_id"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" ADD "legacy_id" text`, + ); + } +}