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
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ 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`,
+ );
+ }
+}