diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html index 6fbed0a354..484e386ec6 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html @@ -9,7 +9,10 @@
Planning Review

{{ cardTitle }} - +

@@ -31,7 +34,7 @@

- Due Date: {{ planningReferral.dueDate | momentFormat }} + Due: {{ planningReferral.dueDate | momentFormat }}
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDECPACKDocument Name + {{ element.fileName }} + SourceALC + Visibility +
* = Pending
+
+ C* + Upload Date{{ element.uploadedAt | momentFormat }}File Actions + + + +
No Documents
diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.scss b/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.scss new file mode 100644 index 0000000000..7a5547e702 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.scss @@ -0,0 +1,37 @@ +@use '../../../../../styles/colors'; + +.documents { + margin-top: 12px; + border-collapse: collapse; +} + +.upload-button { + width: 100%; + margin: 4px 0 12px !important; +} + +.mat-mdc-no-data-row { + height: 56px; + color: colors.$grey-dark; +} + +a { + word-break: break-all; +} + +table { + box-shadow: none; + + th { + font-weight: 700; + padding-bottom: 16px; + position: relative; + + .subheading { + font-size: 11px; + line-height: 16px; + font-weight: 400; + position: absolute; + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.spec.ts b/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.spec.ts new file mode 100644 index 0000000000..e648bca2c9 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.spec.ts @@ -0,0 +1,52 @@ +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 { PlanningReviewDecisionDto } from '../../../../services/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecisionService } from '../../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { ToastService } from '../../../../services/toast/toast.service'; + +import { DecisionDocumentsComponent } from './decision-documents.component'; + +describe('DecisionDocumentsComponent', () => { + let component: DecisionDocumentsComponent; + let fixture: ComponentFixture; + let mockPRDecService: DeepMocked; + let mockDialog: DeepMocked; + let mockToastService: DeepMocked; + + beforeEach(async () => { + mockPRDecService = createMock(); + mockDialog = createMock(); + mockToastService = createMock(); + mockPRDecService.$decision = new BehaviorSubject(undefined); + + await TestBed.configureTestingModule({ + declarations: [DecisionDocumentsComponent], + providers: [ + { + provide: PlanningReviewDecisionService, + useValue: mockPRDecService, + }, + { + provide: MatDialog, + useValue: mockDialog, + }, + { + provide: ToastService, + useValue: mockToastService, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DecisionDocumentsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.ts b/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.ts new file mode 100644 index 0000000000..72be901bc2 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-documents/decision-documents.component.ts @@ -0,0 +1,112 @@ +import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { Subject } from 'rxjs'; +import { + PlanningReviewDecisionDocumentDto, + PlanningReviewDecisionDto, +} from '../../../../services/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecisionService } from '../../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { DecisionDocumentUploadDialogComponent } from '../decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component'; + +@Component({ + selector: 'app-decision-documents', + templateUrl: './decision-documents.component.html', + styleUrls: ['./decision-documents.component.scss'], +}) +export class DecisionDocumentsComponent implements OnDestroy, OnChanges { + $destroy = new Subject(); + + @Input() editable = true; + @Input() loadData = true; + @Input() decision: PlanningReviewDecisionDto | undefined; + @Input() showError = false; + @Output() beforeDocumentUpload = new EventEmitter(); + + displayedColumns: string[] = ['type', 'fileName', 'source', 'visibilityFlags', 'uploadedAt', 'actions']; + private fileId = ''; + areDocumentsReleased = false; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource = + new MatTableDataSource(); + + constructor( + private decisionService: PlanningReviewDecisionService, + private dialog: MatDialog, + private toastService: ToastService, + private confirmationDialogService: ConfirmationDialogService, + ) {} + + async openFile(fileUuid: string, fileName: string) { + if (this.decision) { + await this.decisionService.downloadFile(this.decision.uuid, fileUuid, fileName); + } + } + + async downloadFile(fileUuid: string, fileName: string) { + if (this.decision) { + await this.decisionService.downloadFile(this.decision.uuid, fileUuid, fileName, false); + } + } + + async onUploadFile() { + this.beforeDocumentUpload.emit(); + this.openFileDialog(); + } + + onEditFile(element: PlanningReviewDecisionDocumentDto) { + this.openFileDialog(element); + } + + private openFileDialog(existingDocument?: PlanningReviewDecisionDocumentDto) { + if (this.decision) { + this.dialog + .open(DecisionDocumentUploadDialogComponent, { + minWidth: '600px', + maxWidth: '800px', + width: '70%', + data: { + fileId: this.fileId, + decisionUuid: this.decision?.uuid, + existingDocument: existingDocument, + }, + }) + .beforeClosed() + .subscribe((isDirty: boolean) => { + if (isDirty && this.decision) { + this.decisionService.loadDecision(this.decision.uuid); + } + }); + } + } + + onDeleteFile(element: PlanningReviewDecisionDocumentDto) { + this.confirmationDialogService + .openDialog({ + body: 'Are you sure you want to delete the selected file?', + }) + .subscribe(async (accepted) => { + if (accepted && this.decision) { + await this.decisionService.deleteFile(this.decision.uuid, element.uuid); + await this.decisionService.loadDecision(this.decision.uuid); + this.toastService.showSuccessToast('Document deleted'); + } + }); + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.areDocumentsReleased = true; + if (this.decision) { + this.dataSource = new MatTableDataSource(this.decision.documents); + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html new file mode 100644 index 0000000000..2cbdbbadfa --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html @@ -0,0 +1,98 @@ +
+

{{ title }} Document

+
+
+
+
+
+ Document Upload* +
+ + +
+
+ {{ pendingFile.name }} +  ({{ pendingFile.size | filesize }}) +
+ +
+
+ + +
+ + warning A virus was detected in the file. Choose another file and try again. + +
+ +
+ + Document Name + + +
+ +
+ + +
+
+ + Source + + {{ source }} + + +
+
+ Visible To: +
+ Commissioner +
+
+
+ + +
+ + + +
+
+
diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss new file mode 100644 index 0000000000..f8e75ea5f5 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss @@ -0,0 +1,49 @@ +@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; + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts new file mode 100644 index 0000000000..33c3c65019 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-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 { PlanningReviewDecisionService } from '../../../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; + +import { DecisionDocumentUploadDialogComponent } from './decision-document-upload-dialog.component'; + +describe('DecisionDocumentUploadDialogComponent', () => { + let component: DecisionDocumentUploadDialogComponent; + let fixture: ComponentFixture; + + let mockPRDecService: DeepMocked; + + beforeEach(async () => { + mockPRDecService = createMock(); + + const mockDialogRef = { + close: jest.fn(), + afterClosed: jest.fn(), + subscribe: jest.fn(), + backdropClick: () => new EventEmitter(), + }; + + await TestBed.configureTestingModule({ + declarations: [DecisionDocumentUploadDialogComponent], + providers: [ + { + provide: PlanningReviewDecisionService, + useValue: mockPRDecService, + }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: ToastService, useValue: {} }, + ], + imports: [MatDialogModule], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DecisionDocumentUploadDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts new file mode 100644 index 0000000000..d58cd35f4f --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts @@ -0,0 +1,122 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, Inject, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { PlanningReviewDecisionDocumentDto } from '../../../../../services/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecisionService } from '../../../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { DOCUMENT_SOURCE } from '../../../../../shared/document/document.dto'; + +@Component({ + selector: 'app-app-decision-document-upload-dialog', + templateUrl: './decision-document-upload-dialog.component.html', + styleUrls: ['./decision-document-upload-dialog.component.scss'], +}) +export class DecisionDocumentUploadDialogComponent implements OnInit { + title = 'Create'; + isDirty = false; + isSaving = false; + allowsFileEdit = true; + documentType = 'Decision Package'; + + name = new FormControl('', [Validators.required]); + type = new FormControl({ disabled: true, value: undefined }, [Validators.required]); + source = new FormControl({ disabled: true, value: DOCUMENT_SOURCE.ALC }, [Validators.required]); + visibleToComissioner = new FormControl({ disabled: true, value: true }, [Validators.required]); + + documentSources = Object.values(DOCUMENT_SOURCE); + + form = new FormGroup({ + name: this.name, + type: this.type, + source: this.source, + visibleToComissioner: this.visibleToComissioner, + }); + + pendingFile: File | undefined; + existingFile: string | undefined; + showVirusError = false; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { fileId: string; decisionUuid: string; existingDocument?: PlanningReviewDecisionDocumentDto }, + protected dialog: MatDialogRef, + private decisionService: PlanningReviewDecisionService, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + if (this.data.existingDocument) { + const document = this.data.existingDocument; + this.title = 'Edit'; + this.form.patchValue({ + name: document.fileName, + }); + this.existingFile = document.fileName; + } + } + + async onSubmit() { + const file = this.pendingFile; + if (file) { + const renamedFile = new File([file], this.name.value ?? file.name); + this.isSaving = true; + if (this.data.existingDocument) { + await this.decisionService.deleteFile(this.data.decisionUuid, this.data.existingDocument.uuid); + } + + try { + await this.decisionService.uploadFile(this.data.decisionUuid, renamedFile); + } 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.dialog.close(true); + this.isSaving = false; + } else if (this.data.existingDocument) { + this.isSaving = true; + await this.decisionService.updateFile(this.data.decisionUuid, this.data.existingDocument.uuid, this.name.value!); + + this.dialog.close(true); + this.isSaving = false; + } + } + + 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.decisionService.downloadFile( + this.data.decisionUuid, + this.data.existingDocument.uuid, + this.data.existingDocument.fileName, + ); + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.html b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.html new file mode 100644 index 0000000000..a37ce156d7 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.html @@ -0,0 +1,109 @@ +
+

Decision #{{ index }} Draft

+
+ +

Resolution

+
+
+
+ +
+ +
+
+ + +
+
+
+ Res #{{ resolutionNumberControl.getRawValue() }} / {{ resolutionYearControl.getRawValue() }} + +
+
+
+ + + Decision Date + + + + + + + +
+ + Decision Description + + +
+
+ +
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.scss b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.scss new file mode 100644 index 0000000000..2c8b75063c --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.scss @@ -0,0 +1,82 @@ +@use '../../../../../styles/colors.scss'; + +.bottom-scroller { + margin-top: 36px; + position: absolute; + bottom: 0; + left: 240px; + right: 0; + background-color: #fff; + padding: 16px 94px 16px 48px; + z-index: 2; + border-top: 1px solid colors.$grey-light; + + button:not(:last-child) { + margin-right: 16px !important; + } +} + +form { + padding-bottom: 120px; +} + +.grid, +.grid-2 { + display: grid; + grid-template-columns: calc(50% - 12px) calc(50% - 12px); + grid-column-gap: 24px; + grid-row-gap: 32px; + + .full-width { + grid-column: 1/3; + } +} + +h3 { + margin-top: 36px !important; + margin-bottom: 12px !important; +} + +.resolution-number-wrapper { + display: flex; + align-items: center; + + .resolution-number-btn-wrapper { + width: 100%; + } + + .generate-number-btn { + width: 100%; + } + + .resolution-number { + display: flex; + align-items: center; + font-size: 20px; + color: colors.$grey-dark; + + .delete-icon { + color: colors.$error-color; + } + } +} + +.documents-container { + margin-top: 32px; +} + +:host::ng-deep { + .error-field-outlined.ng-invalid { + border: 1px solid colors.$error-color !important; + + .mat-button-toggle { + border-color: colors.$error-color; + color: colors.$error-color !important; + } + + &.upload-button { + border: 2px solid colors.$error-color; + margin-bottom: 0 !important; + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.spec.ts b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.spec.ts new file mode 100644 index 0000000000..3b70768f65 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.spec.ts @@ -0,0 +1,72 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { PlanningReviewDecisionService } from '../../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { PlanningReviewDetailService } from '../../../../services/planning-review/planning-review-detail.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { StartOfDayPipe } from '../../../../shared/pipes/startOfDay.pipe'; + +import { DecisionInputComponent } from './decision-input.component'; + +describe('DecisionInputComponent', () => { + let component: DecisionInputComponent; + let fixture: ComponentFixture; + let mockPRDecService: DeepMocked; + let mockPRDetailService: DeepMocked; + let mockRouter: DeepMocked; + let mockToastService: DeepMocked; + let mockMatDialog: DeepMocked; + + beforeEach(async () => { + mockPRDecService = createMock(); + mockToastService = createMock(); + mockRouter = createMock(); + mockRouter.navigateByUrl.mockResolvedValue(true); + mockMatDialog = createMock(); + mockPRDetailService = createMock(); + + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + declarations: [DecisionInputComponent, StartOfDayPipe], + providers: [ + { + provide: PlanningReviewDecisionService, + useValue: mockPRDecService, + }, + { provide: PlanningReviewDetailService, useValue: mockPRDetailService }, + { + provide: ToastService, + useValue: mockToastService, + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ uuid: 'fake' }), + }, + }, + }, + { + provide: Router, + useValue: mockRouter, + }, + { + provide: MatDialog, + useValue: mockMatDialog, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DecisionInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.ts b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.ts new file mode 100644 index 0000000000..11a03b252f --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-input.component.ts @@ -0,0 +1,271 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import moment from 'moment'; +import { Subject, takeUntil } from 'rxjs'; +import { + PlanningReviewDecisionDto, + PlanningReviewDecisionOutcomeCodeDto, + UpdatePlanningReviewDecisionDto, +} from '../../../../services/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecisionService } from '../../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { PlanningReviewDetailService } from '../../../../services/planning-review/planning-review-detail.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { formatDateForApi } from '../../../../shared/utils/api-date-formatter'; +import { ReleaseDialogComponent } from '../release-dialog/release-dialog.component'; + +@Component({ + selector: 'app-decision-input', + templateUrl: './decision-input.component.html', + styleUrls: ['./decision-input.component.scss'], +}) +export class DecisionInputComponent implements OnInit, OnDestroy { + $destroy = new Subject(); + isLoading = false; + minDate = new Date(0); + showErrors = false; + index = 1; + requiresDocuments = true; + + fileNumber: string = ''; + uuid: string = ''; + outcomes: PlanningReviewDecisionOutcomeCodeDto[] = []; + + resolutionYears: number[] = []; + existingDecision: PlanningReviewDecisionDto | undefined; + + resolutionNumberControl = new FormControl(null, [Validators.required]); + resolutionYearControl = new FormControl(null, [Validators.required]); + + form = new FormGroup({ + outcome: new FormControl(null, [Validators.required]), + date: new FormControl(undefined, [Validators.required]), + resolutionNumber: this.resolutionNumberControl, + resolutionYear: this.resolutionYearControl, + decisionDescription: new FormControl(undefined, [Validators.required]), + }); + + constructor( + private decisionService: PlanningReviewDecisionService, + public router: Router, + private route: ActivatedRoute, + private toastService: ToastService, + private planningReviewDetailService: PlanningReviewDetailService, + public dialog: MatDialog, + ) {} + + ngOnInit(): void { + this.resolutionYearControl.disable(); + this.setYear(); + + this.extractAndPopulateQueryParams(); + + if (this.fileNumber) { + this.loadData(); + this.setupSubscribers(); + } + } + + private extractAndPopulateQueryParams() { + const fileNumber = this.route.parent?.parent?.snapshot.paramMap.get('fileNumber'); + const uuid = this.route.snapshot.paramMap.get('uuid'); + const index = this.route.snapshot.paramMap.get('index'); + this.index = index ? parseInt(index) : 1; + + if (uuid) { + this.uuid = uuid; + } + + if (fileNumber) { + this.fileNumber = fileNumber; + } + } + + private setYear() { + const year = moment('1974'); + const currentYear = moment().year(); + while (year.year() <= currentYear) { + this.resolutionYears.push(year.year()); + year.add(1, 'year'); + } + this.resolutionYears.reverse(); + } + + ngOnDestroy(): void { + this.decisionService.clearDecision(); + this.decisionService.clearDecisions(); + this.$destroy.next(); + this.$destroy.complete(); + } + + async loadData() { + if (this.uuid) { + await this.decisionService.loadDecision(this.uuid); + } + + await this.decisionService.loadDecisions(this.fileNumber); + + this.outcomes = (await this.decisionService.fetchCodes()) ?? []; + } + + private setupSubscribers() { + this.decisionService.$decision.pipe(takeUntil(this.$destroy)).subscribe((decision) => { + if (decision) { + this.existingDecision = decision; + this.uuid = decision.uuid; + } + + if (this.existingDecision) { + this.patchFormWithExistingData(this.existingDecision); + } else { + this.resolutionYearControl.enable(); + } + }); + } + + private patchFormWithExistingData(existingDecision: PlanningReviewDecisionDto) { + this.form.patchValue({ + outcome: existingDecision.outcome?.code, + date: existingDecision.date ? new Date(existingDecision.date) : undefined, + resolutionYear: existingDecision.resolutionYear, + resolutionNumber: existingDecision.resolutionNumber?.toString(10) || undefined, + decisionDescription: existingDecision.decisionDescription, + }); + + if (!existingDecision.resolutionNumber) { + this.resolutionYearControl.enable(); + } + + if (existingDecision.outcome?.code === 'OTHR') { + this.requiresDocuments = false; + } + } + + async onSubmit(isStayOnPage: boolean = false, isDraft: boolean = true) { + this.isLoading = true; + + try { + await this.saveDecision(isDraft); + } finally { + if (!isStayOnPage) { + this.onCancel(); + } else { + await this.loadData(); + } + + this.isLoading = false; + } + } + + async saveDecision(isDraft: boolean = true) { + const data = this.mapDecisionDataForSave(isDraft); + + if (this.uuid) { + await this.decisionService.update(this.uuid, data); + } + } + + private mapDecisionDataForSave(isDraft: boolean) { + const { date, outcome, resolutionNumber, resolutionYear, decisionDescription } = this.form.getRawValue(); + + const data: UpdatePlanningReviewDecisionDto = { + date: formatDateForApi(date!), + resolutionNumber: parseInt(resolutionNumber!), + resolutionYear: resolutionYear!, + outcomeCode: outcome!, + isDraft, + decisionDescription: decisionDescription, + }; + + return data; + } + + onCancel() { + this.router.navigate([`planning-review/${this.fileNumber}/decision`]); + } + + async onGenerateResolutionNumber() { + const selectedYear = this.form.controls.resolutionYear.getRawValue(); + if (selectedYear) { + const number = await this.decisionService.getNextAvailableResolutionNumber(selectedYear); + if (number) { + this.setResolutionNumber(number); + } else { + this.toastService.showErrorToast('Failed to retrieve resolution number.'); + } + } else { + this.toastService.showErrorToast('Resolution year is not selected. Select a resolution year first.'); + } + } + + private async setResolutionNumber(number: number) { + try { + this.resolutionYearControl.disable(); + this.form.controls.resolutionNumber.setValue(number.toString()); + await this.onSubmit(true); + } catch { + this.resolutionYearControl.enable(); + } + } + + async onDeleteResolutionNumber() { + this.resolutionNumberControl.setValue(null); + await this.onSubmit(true); + this.resolutionYearControl.enable(); + } + + private runValidation() { + this.form.markAllAsTouched(); + this.showErrors = true; + + if ( + !this.form.valid || + !this.existingDecision || + (this.requiresDocuments && this.existingDecision.documents.length === 0) + ) { + this.toastService.showErrorToast('Please correct all errors before submitting the form'); + + // this will ensure that error rendering complete + setTimeout(() => this.scrollToError()); + + return false; + } else { + return true; + } + } + + private scrollToError() { + let elements = document.getElementsByClassName('ng-invalid'); + let elArray = Array.from(elements).filter((el) => el.nodeName !== 'FORM'); + + elArray[0]?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + async onRelease() { + if (this.runValidation()) { + this.dialog + .open(ReleaseDialogComponent, { + minWidth: '600px', + maxWidth: '900px', + maxHeight: '80vh', + width: '90%', + autoFocus: false, + }) + .afterClosed() + .subscribe(async (didAccept) => { + if (didAccept) { + await this.onSubmit(false, false); + await this.planningReviewDetailService.loadReview(this.fileNumber); + } + }); + } + } + + onChangeDecisionOutcome(selectedOutcome: PlanningReviewDecisionOutcomeCodeDto) { + this.requiresDocuments = selectedOutcome.code !== 'OTHR'; + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision.component.html b/alcs-frontend/src/app/features/planning-review/decision/decision.component.html new file mode 100644 index 0000000000..bfbf8398f7 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision.component.html @@ -0,0 +1,78 @@ +
+

Decisions

+
+ +
+
+
+
No Decisions
+
+
+
+
+
+
+

Decision #{{ decisions.length - i }}

+
Res #{{ decision.resolutionNumber }}/{{ decision.resolutionYear }}
+ + + + + + +
+ + + + +
+ +
+
+ +

Resolution

+
+
+
+
Decision Date
+ {{ decision.date | momentFormat }} + +
+
+
Decision Outcome
+ {{ decision.outcome?.label }} + +
+ +
+
Decision Description
+ {{ decision.decisionDescription }} + +
+
+
+ +

Documents

+
+ +
+ +
+ +
+
+
+
diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision.component.scss b/alcs-frontend/src/app/features/planning-review/decision/decision.component.scss new file mode 100644 index 0000000000..6ec9eb54e7 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision.component.scss @@ -0,0 +1,156 @@ +@use '../../../../styles/colors'; + +section { + margin-bottom: 64px; +} + +h4 { + margin-bottom: 12px !important; +} + +hr { + margin: 36px 0; + stroke: colors.$grey; +} + +.decision-container { + position: relative; +} + +.decision { + margin: 24px 0; + box-shadow: 0 2px 8px 1px rgba(0, 0, 0, 0.25); +} + +.decision.draft { + border: 2px solid colors.$secondary-color-light; +} + +.decision-section { + background: colors.$grey-light; + padding: 18px; +} + +.decision-section-no-title { + background: colors.$grey-light; + padding: 1px 18px; +} + +.header { + display: flex; + justify-content: space-between; + margin-bottom: 36px; + + .title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; + + .days { + display: inline-block; + margin-right: 6px; + margin-top: 4px; + + .mat-icon { + font-size: 19px !important; + line-height: 21px !important; + width: 19px; + vertical-align: middle; + } + } + } +} + +.loading-overlay { + position: absolute; + z-index: 2; + background-color: colors.$grey; + opacity: 0.4; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.post-decisions { + padding: 6px 32px; + background-color: colors.$grey-light; + grid-template-columns: 50% 50%; + display: grid; + min-height: 36px; + text-transform: uppercase; + border-radius: 4px 4px 0 0; +} + +.decision-menu { + position: absolute; + top: 0; + right: 0; + height: 36px; + background: colors.$accent-color; + box-shadow: -1px 1px 4px rgba(0, 0, 0, 0.25); + border-radius: 0 4px 0 10px; +} + +.decision-padding { + padding: 18px 32px; +} + +.no-decisions { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: center; + background-color: colors.$grey-light; + height: 72px; +} + +.conditions-link-icon { + position: absolute; +} + +:host ::ng-deep { + .grid-2 { + margin-top: 18px; + margin-bottom: 18px; + display: grid; + grid-template-columns: 50% 50%; + grid-row-gap: 18px; + + .full-width { + grid-column: 1/3; + } + } + + .mat-mdc-table { + background: colors.$grey-light; + } + + .subheading2 { + margin-bottom: 6px !important; + } + + .row { + margin: 16px 0; + } + + .application-pill-wrapper, + .application-pill { + margin-right: 0 !important; + } + + table { + border-spacing: 12px; + margin-left: -12px; + } + + .soil-table-label { + font-weight: 700; + } + + .pre-wrapped-text { + white-space: pre-wrap; + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision.component.spec.ts b/alcs-frontend/src/app/features/planning-review/decision/decision.component.spec.ts new file mode 100644 index 0000000000..19b40612bc --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision.component.spec.ts @@ -0,0 +1,76 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { PlanningReviewDecisionDto } from '../../../services/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecisionService } from '../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDetailedDto } from '../../../services/planning-review/planning-review.dto'; +import { ToastService } from '../../../services/toast/toast.service'; +import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; + +import { DecisionComponent } from './decision.component'; + +describe('DecisionComponent', () => { + let component: DecisionComponent; + let fixture: ComponentFixture; + let mockPRDecisionService: DeepMocked; + let mockPRDetailService: DeepMocked; + + beforeEach(async () => { + mockPRDecisionService = createMock(); + mockPRDecisionService.$decision = new BehaviorSubject(undefined); + mockPRDecisionService.$decisions = new BehaviorSubject([]); + + mockPRDetailService = createMock(); + mockPRDetailService.$planningReview = new BehaviorSubject(undefined); + + await TestBed.configureTestingModule({ + imports: [MatSnackBarModule, MatMenuModule], + declarations: [DecisionComponent], + providers: [ + { + provide: PlanningReviewDecisionService, + useValue: mockPRDecisionService, + }, + { + provide: PlanningReviewDetailService, + useValue: mockPRDetailService, + }, + { + provide: MatDialogRef, + useValue: {}, + }, + { + provide: ConfirmationDialogService, + useValue: {}, + }, + { + provide: ToastService, + useValue: {}, + }, + { + provide: MatDialog, + useValue: {}, + }, + { + provide: ActivatedRoute, + useValue: {}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DecisionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision.component.ts b/alcs-frontend/src/app/features/planning-review/decision/decision.component.ts new file mode 100644 index 0000000000..9d332f2d44 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision.component.ts @@ -0,0 +1,166 @@ +import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subject, takeUntil } from 'rxjs'; +import { APPLICATION_DECISION_COMPONENT_TYPE } from '../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { + PlanningReviewDecisionDto, + PlanningReviewDecisionOutcomeCodeDto, +} from '../../../services/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecisionService } from '../../../services/planning-review/planning-review-decision/planning-review-decision.service'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDto } from '../../../services/planning-review/planning-review.dto'; +import { ToastService } from '../../../services/toast/toast.service'; +import { + DRAFT_DECISION_TYPE_LABEL, + RELEASED_DECISION_TYPE_LABEL, +} from '../../../shared/application-type-pill/application-type-pill.constants'; +import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { RevertToDraftDialogComponent } from './revert-to-draft-dialog/revert-to-draft-dialog.component'; + +@Component({ + selector: 'app-decision-v2', + templateUrl: './decision.component.html', + styleUrls: ['./decision.component.scss'], +}) +export class DecisionComponent implements OnInit, OnDestroy { + $destroy = new Subject(); + isDraftExists = true; + disabledCreateBtnTooltip = + 'A planning review can only have one decision draft at a time. Please release or delete the existing decision draft to continue.'; + + fileNumber: string = ''; + decisionDate: number | undefined; + decisions: PlanningReviewDecisionDto[] = []; + outcomes: PlanningReviewDecisionOutcomeCodeDto[] = []; + + planningReview: PlanningReviewDto | undefined; + dratDecisionLabel = DRAFT_DECISION_TYPE_LABEL; + releasedDecisionLabel = RELEASED_DECISION_TYPE_LABEL; + + constructor( + public dialog: MatDialog, + private planningReviewDetailService: PlanningReviewDetailService, + private decisionService: PlanningReviewDecisionService, + private toastService: ToastService, + private confirmationDialogService: ConfirmationDialogService, + private router: Router, + private activatedRouter: ActivatedRoute, + private elementRef: ElementRef, + ) {} + + ngOnInit(): void { + this.planningReviewDetailService.$planningReview.pipe(takeUntil(this.$destroy)).subscribe((planningReview) => { + if (planningReview) { + this.fileNumber = planningReview.fileNumber; + this.decisionDate = planningReview.decisionDate; + this.loadDecisions(planningReview.fileNumber); + this.planningReview = planningReview; + } + }); + } + + async loadDecisions(fileNumber: string) { + const codes = await this.decisionService.fetchCodes(); + if (codes) { + this.outcomes = codes; + } + this.decisionService.loadDecisions(fileNumber); + + this.isDraftExists = this.decisions.some((d) => d.isDraft); + + this.decisionService.$decisions.pipe(takeUntil(this.$destroy)).subscribe((decisions) => { + this.decisions = decisions; + this.isDraftExists = this.decisions.some((d) => d.isDraft); + this.scrollToDecision(); + }); + } + + scrollToDecision() { + const decisionUuid = this.activatedRouter.snapshot.queryParamMap.get('uuid'); + + setTimeout(() => { + if (this.decisions.length > 0 && decisionUuid) { + this.scrollToElement(decisionUuid); + } + }); + } + + async onCreate() { + const newDecision = await this.decisionService.create({ + planningReviewFileNumber: this.fileNumber, + }); + + const index = this.decisions.length; + await this.router.navigate([ + `/planning-review/${this.fileNumber}/decision/draft/${newDecision.uuid}/edit/${index + 1}`, + ]); + } + + async onEdit(selectedDecision: PlanningReviewDecisionDto) { + const position = this.decisions.findIndex((decision) => decision.uuid === selectedDecision.uuid); + const index = this.decisions.length - position; + await this.router.navigate([ + `/planning-review/${this.fileNumber}/decision/draft/${selectedDecision.uuid}/edit/${index}`, + ]); + } + + async onRevertToDraft(uuid: string) { + const position = this.decisions.findIndex((decision) => decision.uuid === uuid); + const index = this.decisions.length - position; + this.dialog + .open(RevertToDraftDialogComponent, { + data: { fileNumber: this.fileNumber }, + }) + .beforeClosed() + .subscribe(async (didConfirm) => { + if (didConfirm) { + await this.decisionService.update(uuid, { + isDraft: true, + }); + await this.planningReviewDetailService.loadReview(this.fileNumber); + + await this.router.navigate([`/planning-review/${this.fileNumber}/decision/draft/${uuid}/edit/${index}`]); + } + }); + } + + async deleteDecision(uuid: string) { + this.confirmationDialogService + .openDialog({ + body: 'Are you sure you want to delete the selected decision?', + }) + .subscribe(async (confirmed) => { + if (confirmed) { + this.decisions = this.decisions.map((decision) => { + return { + ...decision, + loading: decision.uuid === uuid, + }; + }); + await this.decisionService.delete(uuid); + await this.planningReviewDetailService.loadReview(this.fileNumber); + this.toastService.showSuccessToast('Decision deleted'); + } + }); + } + + ngOnDestroy(): void { + this.decisionService.clearDecisions(); + this.$destroy.next(); + this.$destroy.complete(); + } + + scrollToElement(elementId: string) { + const id = `#${CSS.escape(elementId)}`; + const element = this.elementRef.nativeElement.querySelector(id); + + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'start', + }); + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/decision.module.ts b/alcs-frontend/src/app/features/planning-review/decision/decision.module.ts new file mode 100644 index 0000000000..be0cf9b7a4 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/decision.module.ts @@ -0,0 +1,46 @@ +import { NgModule } from '@angular/core'; +import { MatOptionModule } from '@angular/material/core'; +import { MatTabsModule } from '@angular/material/tabs'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from '../../../shared/shared.module'; +import { DecisionDocumentsComponent } from './decision-documents/decision-documents.component'; +import { DecisionDocumentUploadDialogComponent } from './decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component'; +import { DecisionInputComponent } from './decision-input/decision-input.component'; +import { DecisionComponent } from './decision.component'; +import { ReleaseDialogComponent } from './release-dialog/release-dialog.component'; +import { RevertToDraftDialogComponent } from './revert-to-draft-dialog/revert-to-draft-dialog.component'; + +export const decisionChildRoutes = [ + { + path: '', + menuTitle: 'Decision', + component: DecisionComponent, + portalOnly: false, + }, + { + path: 'create', + menuTitle: 'Decision', + component: DecisionInputComponent, + portalOnly: false, + }, + { + path: 'draft/:uuid/edit/:index', + menuTitle: 'Decision', + component: DecisionInputComponent, + portalOnly: false, + }, +]; + +@NgModule({ + declarations: [ + DecisionComponent, + DecisionComponent, + DecisionInputComponent, + DecisionDocumentsComponent, + RevertToDraftDialogComponent, + ReleaseDialogComponent, + DecisionDocumentUploadDialogComponent, + ], + imports: [SharedModule, RouterModule.forChild(decisionChildRoutes), MatTabsModule, MatOptionModule], +}) +export class DecisionModule {} diff --git a/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.html b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.html new file mode 100644 index 0000000000..86fbc042a3 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.html @@ -0,0 +1,19 @@ +
+

Confirm Release Decision

+
+ +
+

+ Decisions are not currently visible outside of the ALC so this action finalizes the decision but doesn't release the decision externally. + There are no auto-emails associated with this action.
+ This action does not update the status of the planning review file. +

+
+
+ + +
+ + +
+
diff --git a/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.scss b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.scss new file mode 100644 index 0000000000..756d26e192 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.scss @@ -0,0 +1,6 @@ +.grid { + display: grid; + grid-template-columns: 1fr; + grid-column-gap: 24px; + grid-row-gap: 24px; +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.spec.ts b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.spec.ts new file mode 100644 index 0000000000..b649151a3a --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.spec.ts @@ -0,0 +1,31 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ReleaseDialogComponent } from './release-dialog.component'; + +describe('ReleaseDialogComponent', () => { + let component: ReleaseDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ReleaseDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: {}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ReleaseDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.ts b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.ts new file mode 100644 index 0000000000..be2dfeb60a --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/release-dialog/release-dialog.component.ts @@ -0,0 +1,18 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-release-dialog', + templateUrl: './release-dialog.component.html', + styleUrls: ['./release-dialog.component.scss'], +}) +export class ReleaseDialogComponent { + constructor( + public matDialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) data: any, + ) {} + + onRelease() { + this.matDialogRef.close(true); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.html b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.html new file mode 100644 index 0000000000..3204415807 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.html @@ -0,0 +1,18 @@ +
+

Confirm Revert to Draft

+
+ +
+

+ There are no auto-emails associated with this action.
+ This action does not update the status of the planning review file. +

+
+
+ + +
+ + +
+
diff --git a/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.scss b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.scss new file mode 100644 index 0000000000..756d26e192 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.scss @@ -0,0 +1,6 @@ +.grid { + display: grid; + grid-template-columns: 1fr; + grid-column-gap: 24px; + grid-row-gap: 24px; +} diff --git a/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.spec.ts b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.spec.ts new file mode 100644 index 0000000000..fe67054014 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.spec.ts @@ -0,0 +1,30 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { RevertToDraftDialogComponent } from './revert-to-draft-dialog.component'; + +describe('RevertToDraftDialogComponent', () => { + let component: RevertToDraftDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RevertToDraftDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(RevertToDraftDialogComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.ts b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.ts new file mode 100644 index 0000000000..fa3ce0f416 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/decision/revert-to-draft-dialog/revert-to-draft-dialog.component.ts @@ -0,0 +1,18 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +@Component({ + selector: 'app-pr-revert-to-draft-dialog', + templateUrl: './revert-to-draft-dialog.component.html', + styleUrls: ['./revert-to-draft-dialog.component.scss'], +}) +export class RevertToDraftDialogComponent { + constructor( + public matDialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) data: { fileNumber: string }, + ) {} + + onConfirm() { + this.matDialogRef.close(true); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts b/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts index 252adb9bbc..89016c03ba 100644 --- a/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts +++ b/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts @@ -1,3 +1,4 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; @@ -12,6 +13,7 @@ describe('HeaderComponent', () => { imports: [RouterTestingModule], declarations: [HeaderComponent], providers: [], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(HeaderComponent); 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 fdd55b1b22..eba0051892 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 @@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router'; import { Subject, takeUntil } from 'rxjs'; import { PlanningReviewDetailService } from '../../services/planning-review/planning-review-detail.service'; import { PlanningReviewDetailedDto } from '../../services/planning-review/planning-review.dto'; +import { decisionChildRoutes, DecisionModule } from './decision/decision.module'; import { DocumentsComponent } from './documents/documents.component'; import { OverviewComponent } from './overview/overview.component'; import { ReferralComponent } from './referrals/referral.component'; @@ -26,6 +27,14 @@ export const childRoutes = [ icon: 'description', component: DocumentsComponent, }, + { + path: 'decision', + menuTitle: 'Decisions', + icon: 'gavel', + module: DecisionModule, + portalOnly: false, + children: decisionChildRoutes, + }, ]; @Component({ diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.dto.ts b/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.dto.ts new file mode 100644 index 0000000000..1c74f5c5af --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.dto.ts @@ -0,0 +1,39 @@ +import { BaseCodeDto } from '../../../shared/dto/base.dto'; + +export interface UpdatePlanningReviewDecisionDto { + resolutionNumber?: number; + resolutionYear?: number; + date?: number; + outcomeCode?: string; + decisionDescription?: string | null; + isDraft?: boolean; +} + +export interface CreatePlanningReviewDecisionDto { + planningReviewFileNumber: string; +} + +export interface PlanningReviewDecisionOutcomeCodeDto extends BaseCodeDto {} + +export interface PlanningReviewDecisionDto { + uuid: string; + planningReviewFileNumber: string; + date?: number; + resolutionNumber: number; + resolutionYear: number; + documents: PlanningReviewDecisionDocumentDto[]; + isDraft: boolean; + decisionDescription?: string | null; + createdAt?: number | null; + wasReleased: boolean; + outcome?: PlanningReviewDecisionOutcomeCodeDto; +} + +export interface PlanningReviewDecisionDocumentDto { + uuid: string; + fileName: string; + fileSize: number; + mimeType: string; + uploadedBy: string; + uploadedAt: number; +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.service.spec.ts b/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.service.spec.ts new file mode 100644 index 0000000000..7f2a333d63 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.service.spec.ts @@ -0,0 +1,201 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { of, throwError } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { ToastService } from '../../toast/toast.service'; +import { PlanningReviewDecisionService } from './planning-review-decision.service'; + +describe('PlanningReviewDecisionService', () => { + let service: PlanningReviewDecisionService; + 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(PlanningReviewDecisionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fetch and return a mapped dto', async () => { + httpClient.get.mockReturnValue( + of([ + { + planningReviewFileNumber: '1', + }, + ]), + ); + + const res = await service.fetchByPlanningReview('1'); + + expect(res.length).toEqual(1); + expect(res[0].planningReviewFileNumber).toEqual('1'); + }); + + it('should show a toast message if fetch fails', async () => { + httpClient.get.mockReturnValue( + throwError(() => { + new Error(''); + }), + ); + + const res = await service.fetchByPlanningReview('1'); + + expect(res.length).toEqual(0); + expect(toastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make an http patch and show a success toast when updating', async () => { + httpClient.patch.mockReturnValue( + of({ + planningReviewFileNumber: '1', + }), + ); + + await service.update('1', {}); + + expect(httpClient.patch).toHaveBeenCalledTimes(1); + expect(toastService.showSuccessToast).toHaveBeenCalledTimes(1); + }); + + it('should show a toast message if update fails', async () => { + httpClient.patch.mockReturnValue( + throwError(() => { + new Error(''); + }), + ); + + try { + await service.update('1', {}); + } catch (e) { + //OM NOM NOM + } + + expect(httpClient.patch).toHaveBeenCalledTimes(1); + expect(toastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make an http post and show a success toast when creating', async () => { + httpClient.post.mockReturnValue( + of({ + planningReviewFileNumber: '1', + }), + ); + + await service.create({ + planningReviewFileNumber: '', + }); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(toastService.showSuccessToast).toHaveBeenCalledTimes(1); + }); + + it('should show a toast message if create fails', async () => { + httpClient.post.mockReturnValue( + throwError(() => { + new Error(''); + }), + ); + + try { + await service.create({ + planningReviewFileNumber: '', + }); + } catch (e) { + //OM NOM NOM + } + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(toastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make an http delete and show a success toast', async () => { + httpClient.delete.mockReturnValue( + of({ + planningReviewFileNumber: '1', + }), + ); + + await service.delete(''); + + expect(httpClient.delete).toHaveBeenCalledTimes(1); + expect(toastService.showSuccessToast).toHaveBeenCalledTimes(1); + }); + + it('should show a toast message if delete fails', async () => { + httpClient.delete.mockReturnValue( + throwError(() => { + new Error(''); + }), + ); + + try { + await service.delete(''); + } catch (e) { + //OM NOM NOM + } + + expect(httpClient.delete).toHaveBeenCalledTimes(1); + expect(toastService.showErrorToast).toHaveBeenCalledTimes(1); + }); + + it('should make an http patch call for update file', async () => { + httpClient.patch.mockReturnValue( + of([ + { + fileNumber: '1', + }, + ]), + ); + await service.updateFile('', '', ''); + + expect(httpClient.patch).toHaveBeenCalledTimes(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.uploadFile('', file); + + expect(toastService.showWarningToast).toHaveBeenCalledTimes(1); + expect(httpClient.post).toHaveBeenCalledTimes(0); + }); + + it('should make an http delete when deleting a file', async () => { + httpClient.delete.mockReturnValue( + of({ + planningReviewFileNumber: '1', + }), + ); + + await service.deleteFile('', ''); + + expect(httpClient.delete).toHaveBeenCalledTimes(1); + }); + + it('should make an http get when requesting a new resolution number', async () => { + httpClient.get.mockReturnValue(of(1)); + + await service.getNextAvailableResolutionNumber(2023); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + }); +}); diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.service.ts b/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.service.ts new file mode 100644 index 0000000000..aeb151a463 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-decision/planning-review-decision.service.ts @@ -0,0 +1,178 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { downloadFileFromUrl, openFileInline } from '../../../shared/utils/file'; +import { verifyFileSize } from '../../../shared/utils/file-size-checker'; +import { ToastService } from '../../toast/toast.service'; +import { + CreatePlanningReviewDecisionDto, + PlanningReviewDecisionDto, + PlanningReviewDecisionOutcomeCodeDto, + UpdatePlanningReviewDecisionDto, +} from './planning-review-decision.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class PlanningReviewDecisionService { + private url = `${environment.apiUrl}/planning-review-decision`; + private decision: PlanningReviewDecisionDto | undefined; + private decisions: PlanningReviewDecisionDto[] = []; + $decision = new BehaviorSubject(undefined); + $decisions = new BehaviorSubject([]); + + constructor( + private http: HttpClient, + private toastService: ToastService, + ) {} + + async fetchByPlanningReview(fileNumber: string) { + let decisions: PlanningReviewDecisionDto[] = []; + try { + decisions = await firstValueFrom( + this.http.get(`${this.url}/planning-review/${fileNumber}`), + ); + } catch (err) { + this.toastService.showErrorToast('Failed to fetch decisions'); + } + return decisions; + } + + async fetchCodes() { + try { + return await firstValueFrom(this.http.get(`${this.url}/codes`)); + } catch (err) { + this.toastService.showErrorToast('Failed to fetch decisions'); + } + return; + } + + async update(uuid: string, data: UpdatePlanningReviewDecisionDto) { + try { + const res = await firstValueFrom(this.http.patch(`${this.url}/${uuid}`, data)); + this.toastService.showSuccessToast('Decision updated'); + return res; + } catch (e) { + if (e instanceof HttpErrorResponse && e.status === 400 && e.error?.message) { + this.toastService.showErrorToast(e.error.message); + } else { + this.toastService.showErrorToast('Failed to update decision'); + } + throw e; + } + } + + async create(decision: CreatePlanningReviewDecisionDto) { + try { + const res = await firstValueFrom(this.http.post(`${this.url}`, decision)); + this.toastService.showSuccessToast('Decision created'); + return res; + } catch (e) { + if (e instanceof HttpErrorResponse && e.status === 400 && e.error?.message) { + this.toastService.showErrorToast(e.error.message); + } else { + this.toastService.showErrorToast(`Failed to create decision`); + } + throw e; + } + } + + async delete(uuid: string) { + try { + await firstValueFrom(this.http.delete(`${this.url}/${uuid}`)); + this.toastService.showSuccessToast('Decision deleted'); + } catch (err) { + this.toastService.showErrorToast('Failed to delete meeting'); + } + } + + async uploadFile(decisionUuid: string, file: File) { + const isValidSize = verifyFileSize(file, this.toastService); + if (!isValidSize) { + return; + } + + let formData: FormData = new FormData(); + formData.append('file', file, file.name); + const res = await firstValueFrom(this.http.post(`${this.url}/${decisionUuid}/file`, formData)); + this.toastService.showSuccessToast('Document uploaded'); + return res; + } + + async downloadFile(decisionUuid: string, documentUuid: string, fileName: string, isInline = true) { + const url = `${this.url}/${decisionUuid}/file/${documentUuid}`; + const finalUrl = isInline ? `${url}/open` : `${url}/download`; + const data = await firstValueFrom(this.http.get<{ url: string }>(finalUrl)); + if (isInline) { + openFileInline(data.url, fileName); + } else { + downloadFileFromUrl(data.url, fileName); + } + } + + async updateFile(decisionUuid: string, documentUuid: string, fileName: string) { + try { + await firstValueFrom( + this.http.patch(`${this.url}/${decisionUuid}/file/${documentUuid}`, { + fileName, + }), + ); + this.toastService.showSuccessToast('File updated'); + } catch (err) { + this.toastService.showErrorToast('Failed to update file'); + } + } + + async deleteFile(decisionUuid: string, documentUuid: string) { + const url = `${this.url}/${decisionUuid}/file/${documentUuid}`; + return await firstValueFrom(this.http.delete<{ url: string }>(url)); + } + + async getByUuid(uuid: string) { + let decision: PlanningReviewDecisionDto | undefined; + try { + decision = await firstValueFrom(this.http.get(`${this.url}/${uuid}`)); + } catch (err) { + this.toastService.showErrorToast('Failed to fetch decision'); + } + return decision; + } + + async loadDecision(uuid: string) { + this.clearDecision(); + this.decision = await this.getByUuid(uuid); + this.$decision.next(this.decision); + } + + async loadDecisions(fileNumber: string) { + this.clearDecisions(); + const decisions = await this.fetchByPlanningReview(fileNumber); + const decisionsLength = decisions.length; + + this.decisions = decisions.map((decision, ind) => ({ + ...decision, + index: decisionsLength - ind, + })); + + this.$decisions.next(this.decisions); + } + + clearDecision() { + this.$decision.next(undefined); + } + + clearDecisions() { + this.$decisions.next([]); + } + + async getNextAvailableResolutionNumber(resolutionYear: number) { + let result: number | undefined = undefined; + try { + result = await firstValueFrom(this.http.get(`${this.url}/next-resolution-number/${resolutionYear}`)); + } catch (err) { + this.toastService.showErrorToast('Failed to fetch resolutionNumber'); + } + return result; + } +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts b/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts index e2aeb87e96..d47146dda1 100644 --- a/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts +++ b/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts @@ -7,14 +7,11 @@ import { PlanningReviewService } from './planning-review.service'; export class PlanningReviewDetailService { $planningReview = new BehaviorSubject(undefined); - private selectedFileNumber: string | undefined; - constructor(private planningReviewService: PlanningReviewService) {} async loadReview(fileNumber: string) { this.clearReview(); - this.selectedFileNumber = fileNumber; const planningReview = await this.planningReviewService.fetchDetailedByFileNumber(fileNumber); this.$planningReview.next(planningReview); } diff --git a/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts b/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts index 0871f29396..b29e725e77 100644 --- a/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts +++ b/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts @@ -16,6 +16,7 @@ export interface CreatePlanningReviewDto { export interface PlanningReviewDto { uuid: string; fileNumber: string; + decisionDate?: number; legacyId: string | null; open: boolean; localGovernment: ApplicationLocalGovernmentDto; diff --git a/services/apps/alcs/src/alcs/alcs.module.ts b/services/apps/alcs/src/alcs/alcs.module.ts index eda56204d1..2038251041 100644 --- a/services/apps/alcs/src/alcs/alcs.module.ts +++ b/services/apps/alcs/src/alcs/alcs.module.ts @@ -22,6 +22,7 @@ import { MessageModule } from './message/message.module'; import { NotificationSubmissionStatusModule } from './notification/notification-submission-status/notification-submission-status.module'; import { NotificationTimelineModule } from './notification/notification-timeline/notification-timeline.module'; import { NotificationModule } from './notification/notification.module'; +import { PlanningReviewDecisionModule } from './planning-review/planning-review-decision/planning-review-decision.module'; import { PlanningReviewModule } from './planning-review/planning-review.module'; import { SearchModule } from './search/search.module'; import { StaffJournalModule } from './staff-journal/staff-journal.module'; @@ -35,6 +36,7 @@ import { StaffJournalModule } from './staff-journal/staff-journal.module'; BoardModule, CodeModule, PlanningReviewModule, + PlanningReviewDecisionModule, CovenantModule, CommissionerModule, ApplicationDecisionModule, @@ -57,6 +59,7 @@ import { StaffJournalModule } from './staff-journal/staff-journal.module'; { path: 'alcs', module: BoardModule }, { path: 'alcs', module: CodeModule }, { path: 'alcs', module: PlanningReviewModule }, + { path: 'alcs', module: PlanningReviewDecisionModule }, { path: 'alcs', module: CovenantModule }, { path: 'alcs', module: CommissionerModule }, { path: 'alcs', module: ApplicationDecisionModule }, diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision-document/planning-review-decision-document.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision-document/planning-review-decision-document.entity.ts new file mode 100644 index 0000000000..b91910325b --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision-document/planning-review-decision-document.entity.ts @@ -0,0 +1,38 @@ +import { AutoMap } from 'automapper-classes'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Auditable } from '../../../../common/entities/audit.entity'; +import { Document } from '../../../../document/document.entity'; +import { PlanningReviewDecision } from '../planning-review-decision.entity'; + +@Entity() +export class PlanningReviewDecisionDocument extends Auditable { + constructor(data?: Partial) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @AutoMap() + @PrimaryGeneratedColumn('uuid') + uuid: string; + + @ManyToOne(() => PlanningReviewDecision, { nullable: false }) + decision: PlanningReviewDecision; + + @Column() + decisionUuid: string; + + @OneToOne(() => Document, { + cascade: true, + }) + @JoinColumn() + document: Document; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision-outcome.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision-outcome.entity.ts new file mode 100644 index 0000000000..bbd6440c9b --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision-outcome.entity.ts @@ -0,0 +1,5 @@ +import { Entity } from 'typeorm'; +import { BaseCodeEntity } from '../../../common/entities/base.code.entity'; + +@Entity() +export class PlanningReviewDecisionOutcomeCode extends BaseCodeEntity {} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.controller.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.controller.spec.ts new file mode 100644 index 0000000000..c511394a09 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.controller.spec.ts @@ -0,0 +1,207 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +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 { PlanningReviewDecisionProfile } from '../../../common/automapper/planning-review-decision.automapper.profile'; +import { PlanningReviewProfile } from '../../../common/automapper/planning-review.automapper.profile'; +import { UserProfile } from '../../../common/automapper/user.automapper.profile'; +import { PlanningReview } from '../planning-review.entity'; +import { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; +import { PlanningReviewDecisionController } from './planning-review-decision.controller'; +import { + CreatePlanningReviewDecisionDto, + UpdatePlanningReviewDecisionDto, +} from './planning-review-decision.dto'; +import { PlanningReviewDecision } from './planning-review-decision.entity'; +import { PlanningReviewDecisionService } from './planning-review-decision.service'; + +describe('PlanningReviewDecisionController', () => { + let controller: PlanningReviewDecisionController; + let mockDecisionService: DeepMocked; + + let mockPlanningReview; + let mockDecision; + + beforeEach(async () => { + mockDecisionService = createMock(); + + mockPlanningReview = new PlanningReview(); + mockDecision = new PlanningReviewDecision({ + planningReview: mockPlanningReview, + }); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [PlanningReviewDecisionController], + providers: [ + PlanningReviewProfile, + PlanningReviewDecisionProfile, + UserProfile, + { + provide: PlanningReviewDecisionService, + useValue: mockDecisionService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + + controller = module.get( + PlanningReviewDecisionController, + ); + + mockDecisionService.fetchCodes.mockResolvedValue({ + outcomes: [ + { + code: 'decision-code', + label: 'decision-label', + } as PlanningReviewDecisionOutcomeCode, + ], + }); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should get all for planning review', async () => { + mockDecisionService.getByFileNumber.mockResolvedValue([mockDecision]); + + const result = await controller.getByFileNumber('fake-number'); + + expect(mockDecisionService.getByFileNumber).toBeCalledTimes(1); + expect(result[0].uuid).toStrictEqual(mockDecision.uuid); + }); + + it('should get a specific decision', async () => { + mockDecisionService.get.mockResolvedValue(mockDecision); + const result = await controller.get('fake-uuid'); + + expect(mockDecisionService.get).toBeCalledTimes(1); + expect(result.uuid).toStrictEqual(mockDecision.uuid); + }); + + it('should call through for deletion', async () => { + mockDecisionService.delete.mockResolvedValue({} as any); + + await controller.delete('fake-uuid'); + + expect(mockDecisionService.delete).toBeCalledTimes(1); + expect(mockDecisionService.delete).toBeCalledWith('fake-uuid'); + }); + + it('should create the decision if planning review exists', async () => { + mockDecisionService.create.mockResolvedValue(mockDecision); + + const decisionToCreate: CreatePlanningReviewDecisionDto = { + planningReviewFileNumber: mockPlanningReview.fileNumber, + }; + + await controller.create(decisionToCreate); + + expect(mockDecisionService.create).toBeCalledTimes(1); + expect(mockDecisionService.create).toBeCalledWith({ + planningReviewFileNumber: mockPlanningReview.fileNumber, + }); + }); + + it('should update the decision', async () => { + mockDecisionService.update.mockResolvedValue(mockDecision); + + const updates: UpdatePlanningReviewDecisionDto = { + outcomeCode: 'New Outcome', + date: new Date(2022, 2, 2, 2, 2, 2, 2).valueOf(), + isDraft: true, + }; + + await controller.update('fake-uuid', updates); + + expect(mockDecisionService.update).toBeCalledTimes(1); + expect(mockDecisionService.update).toBeCalledWith('fake-uuid', { + outcomeCode: 'New Outcome', + date: updates.date, + isDraft: true, + }); + }); + + it('should call through for attaching the document', async () => { + mockDecisionService.attachDocument.mockResolvedValue({} as any); + await controller.attachDocument('fake-uuid', { + isMultipart: () => true, + body: { + file: {}, + }, + user: { + entity: {}, + }, + }); + + expect(mockDecisionService.attachDocument).toBeCalledTimes(1); + }); + + it('should throw an exception if there is no file for file upload', async () => { + mockDecisionService.attachDocument.mockResolvedValue({} as any); + const promise = controller.attachDocument('fake-uuid', { + file: () => ({}), + isMultipart: () => false, + user: { + entity: {}, + }, + }); + + await expect(promise).rejects.toMatchObject( + new Error('Request is not multipart'), + ); + }); + + it('should call through for getting download url', async () => { + const fakeUrl = 'fake-url'; + mockDecisionService.getDownloadUrl.mockResolvedValue(fakeUrl); + const res = await controller.getDownloadUrl('fake-uuid', 'document-uuid'); + + expect(mockDecisionService.getDownloadUrl).toBeCalledTimes(1); + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for updating the file', async () => { + mockDecisionService.updateDocument.mockResolvedValue({} as any); + await controller.updateDocument('fake-uuid', 'document-uuid', { + fileName: '', + }); + + expect(mockDecisionService.updateDocument).toBeCalledTimes(1); + }); + + it('should call through for getting open url', async () => { + const fakeUrl = 'fake-url'; + mockDecisionService.getDownloadUrl.mockResolvedValue(fakeUrl); + const res = await controller.getOpenUrl('fake-uuid', 'document-uuid'); + + expect(mockDecisionService.getDownloadUrl).toBeCalledTimes(1); + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for document deletion', async () => { + mockDecisionService.deleteDocument.mockResolvedValue({} as any); + await controller.deleteDocument('fake-uuid', 'document-uuid'); + + expect(mockDecisionService.deleteDocument).toBeCalledTimes(1); + }); + + it('should call through for resolution number generation', async () => { + mockDecisionService.generateResolutionNumber.mockResolvedValue(1); + await controller.getNextAvailableResolutionNumber(2023); + + expect(mockDecisionService.generateResolutionNumber).toBeCalledTimes(1); + expect(mockDecisionService.generateResolutionNumber).toBeCalledWith(2023); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.controller.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.controller.ts new file mode 100644 index 0000000000..7df9104110 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.controller.ts @@ -0,0 +1,197 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Patch, + 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 { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; +import { + CreatePlanningReviewDecisionDto, + PlanningReviewDecisionDto, + PlanningReviewDecisionOutcomeCodeDto, + UpdatePlanningReviewDecisionDto, +} from './planning-review-decision.dto'; +import { PlanningReviewDecision } from './planning-review-decision.entity'; +import { PlanningReviewDecisionService } from './planning-review-decision.service'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@Controller('planning-review-decision') +@UseGuards(RolesGuard) +export class PlanningReviewDecisionController { + constructor( + private plannigReviewDecisionService: PlanningReviewDecisionService, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/planning-review/:fileNumber') + @UserRoles(...ANY_AUTH_ROLE) + async getByFileNumber( + @Param('fileNumber') fileNumber, + ): Promise { + const decisions = + await this.plannigReviewDecisionService.getByFileNumber(fileNumber); + + return await this.mapper.mapArrayAsync( + decisions, + PlanningReviewDecision, + PlanningReviewDecisionDto, + ); + } + + @Get('/codes') + @UserRoles(...ANY_AUTH_ROLE) + async getCodes() { + const codes = await this.plannigReviewDecisionService.fetchCodes(); + return await this.mapper.mapArrayAsync( + codes.outcomes, + PlanningReviewDecisionOutcomeCode, + PlanningReviewDecisionOutcomeCodeDto, + ); + } + + @Get('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async get(@Param('uuid') uuid: string): Promise { + const decision = await this.plannigReviewDecisionService.get(uuid); + + return this.mapper.mapAsync( + decision, + PlanningReviewDecision, + PlanningReviewDecisionDto, + ); + } + + @Post() + @UserRoles(...ANY_AUTH_ROLE) + async create( + @Body() createDto: CreatePlanningReviewDecisionDto, + ): Promise { + const newDecision = + await this.plannigReviewDecisionService.create(createDto); + + return this.mapper.mapAsync( + newDecision, + PlanningReviewDecision, + PlanningReviewDecisionDto, + ); + } + + @Patch('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async update( + @Param('uuid') uuid: string, + @Body() updateDto: UpdatePlanningReviewDecisionDto, + ): Promise { + const updatedDecision = await this.plannigReviewDecisionService.update( + uuid, + updateDto, + ); + + return this.mapper.mapAsync( + updatedDecision, + PlanningReviewDecision, + PlanningReviewDecisionDto, + ); + } + + @Delete('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async delete(@Param('uuid') uuid: string) { + return await this.plannigReviewDecisionService.delete(uuid); + } + + @Post('/:uuid/file') + @UserRoles(...ANY_AUTH_ROLE) + async attachDocument(@Param('uuid') decisionUuid: string, @Req() req) { + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const file = req.body.file; + await this.plannigReviewDecisionService.attachDocument( + decisionUuid, + file, + req.user.entity, + ); + return { + uploaded: true, + }; + } + + @Patch('/:uuid/file/:documentUuid') + @UserRoles(...ANY_AUTH_ROLE) + async updateDocument( + @Param('uuid') decisionUuid: string, + @Param('documentUuid') documentUuid: string, + @Body() body: { fileName: string }, + ) { + await this.plannigReviewDecisionService.updateDocument( + documentUuid, + body.fileName, + ); + return { + uploaded: true, + }; + } + + @Get('/:uuid/file/:fileUuid/download') + @UserRoles(...ANY_AUTH_ROLE) + async getDownloadUrl( + @Param('uuid') decisionUuid: string, + @Param('fileUuid') documentUuid: string, + ) { + const downloadUrl = + await this.plannigReviewDecisionService.getDownloadUrl(documentUuid); + return { + url: downloadUrl, + }; + } + + @Get('/:uuid/file/:fileUuid/open') + @UserRoles(...ANY_AUTH_ROLE) + async getOpenUrl( + @Param('uuid') decisionUuid: string, + @Param('fileUuid') documentUuid: string, + ) { + const downloadUrl = await this.plannigReviewDecisionService.getDownloadUrl( + documentUuid, + true, + ); + return { + url: downloadUrl, + }; + } + + @Delete('/:uuid/file/:fileUuid') + @UserRoles(...ANY_AUTH_ROLE) + async deleteDocument( + @Param('uuid') decisionUuid: string, + @Param('fileUuid') documentUuid: string, + ) { + await this.plannigReviewDecisionService.deleteDocument(documentUuid); + return {}; + } + + @Get('next-resolution-number/:resolutionYear') + @UserRoles(...ANY_AUTH_ROLE) + async getNextAvailableResolutionNumber( + @Param('resolutionYear') resolutionYear: number, + ) { + return this.plannigReviewDecisionService.generateResolutionNumber( + resolutionYear, + ); + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.dto.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.dto.ts new file mode 100644 index 0000000000..8e04fb28c7 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.dto.ts @@ -0,0 +1,91 @@ +import { AutoMap } from 'automapper-classes'; +import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; +import { BaseCodeDto } from '../../../common/dtos/base.dto'; +import { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; + +export class UpdatePlanningReviewDecisionDto { + @IsNumber() + @IsOptional() + resolutionNumber?: number; + + @IsNumber() + @IsOptional() + resolutionYear?: number; + + @IsNumber() + @IsOptional() + date?: number; + + @IsString() + @IsOptional() + outcomeCode?: string; + + @IsString() + @IsOptional() + decisionDescription?: string | null; + + @IsBoolean() + isDraft: boolean; +} + +export class CreatePlanningReviewDecisionDto { + @IsString() + planningReviewFileNumber: string; +} + +export class PlanningReviewDecisionOutcomeCodeDto extends BaseCodeDto {} + +export class PlanningReviewDecisionDto { + @AutoMap() + uuid: string; + + @AutoMap() + planningReviewFileNumber: string; + + @AutoMap() + date?: number; + + @AutoMap() + resolutionNumber: string; + + @AutoMap() + resolutionYear: number; + + @AutoMap(() => PlanningReviewDecisionOutcomeCodeDto) + outcome?: PlanningReviewDecisionOutcomeCodeDto; + + @AutoMap(() => [PlanningReviewDecisionDocumentDto]) + documents: PlanningReviewDecisionDocumentDto[]; + + @AutoMap(() => Boolean) + isDraft: boolean; + + @AutoMap(() => String) + decisionDescription?: string | null; + + @AutoMap(() => Number) + createdAt?: number | null; + + @AutoMap(() => Boolean) + wasReleased: boolean; +} + +export class PlanningReviewDecisionDocumentDto { + @AutoMap() + uuid: string; + + @AutoMap() + fileName: string; + + @AutoMap() + fileSize: number; + + @AutoMap() + mimeType: string; + + @AutoMap() + uploadedBy: string; + + @AutoMap() + uploadedAt: number; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.entity.ts new file mode 100644 index 0000000000..3c39793741 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.entity.ts @@ -0,0 +1,92 @@ +import { AutoMap } from 'automapper-classes'; +import { + Column, + CreateDateColumn, + Entity, + Index, + ManyToOne, + OneToMany, +} from 'typeorm'; +import { Base } from '../../../common/entities/base.entity'; +import { PlanningReview } from '../planning-review.entity'; +import { PlanningReviewDecisionDocument } from './planning-review-decision-document/planning-review-decision-document.entity'; +import { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; + +@Entity() +@Index(['resolutionNumber', 'resolutionYear'], { + unique: true, + where: '"audit_deleted_date_at" is null and "resolution_number" is not null', +}) +export class PlanningReviewDecision extends Base { + constructor(data?: Partial) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @AutoMap() + @Column({ type: 'timestamptz', nullable: true }) + date: Date | null; + + @AutoMap() + @Column({ type: 'boolean', default: false }) + wasReleased: boolean; + + @AutoMap(() => PlanningReviewDecisionOutcomeCode) + @ManyToOne(() => PlanningReviewDecisionOutcomeCode, { + nullable: true, + }) + outcome: PlanningReviewDecisionOutcomeCode | null; + + @AutoMap() + @Column({ nullable: true }) + outcomeCode: string | null; + + @AutoMap() + @Column({ type: 'int4', nullable: true }) + resolutionNumber: number; + + @AutoMap() + @Column({ type: 'smallint' }) + resolutionYear: number; + + @AutoMap() + @Column({ + comment: 'Indicates whether the decision is currently draft or not', + default: false, + }) + isDraft: boolean; + + @AutoMap(() => String) + @Column({ + comment: 'Staff input field for a description of the decision', + nullable: true, + type: 'text', + }) + decisionDescription?: string | null; + + @CreateDateColumn({ + type: 'timestamptz', + nullable: false, + update: false, + comment: + 'Date that indicates when decision was created. It is not editable by user.', + }) + createdAt: Date; + + @AutoMap() + @ManyToOne(() => PlanningReview) + planningReview: PlanningReview; + + @AutoMap() + @Column({ type: 'uuid' }) + planningReviewUuid: string; + + @AutoMap(() => [PlanningReviewDecisionDocument]) + @OneToMany( + () => PlanningReviewDecisionDocument, + (document) => document.decision, + ) + documents: PlanningReviewDecisionDocument[]; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.module.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.module.ts new file mode 100644 index 0000000000..10c147f67a --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PlanningReviewDecisionProfile } from '../../../common/automapper/planning-review-decision.automapper.profile'; +import { DocumentModule } from '../../../document/document.module'; +import { PlanningReviewModule } from '../planning-review.module'; +import { PlanningReviewDecisionDocument } from './planning-review-decision-document/planning-review-decision-document.entity'; +import { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; +import { PlanningReviewDecisionController } from './planning-review-decision.controller'; +import { PlanningReviewDecision } from './planning-review-decision.entity'; +import { PlanningReviewDecisionService } from './planning-review-decision.service'; + +@Module({ + imports: [ + PlanningReviewModule, + DocumentModule, + TypeOrmModule.forFeature([ + PlanningReviewDecision, + PlanningReviewDecisionDocument, + PlanningReviewDecisionOutcomeCode, + ]), + ], + providers: [PlanningReviewDecisionService, PlanningReviewDecisionProfile], + controllers: [PlanningReviewDecisionController], + exports: [], +}) +export class PlanningReviewDecisionModule {} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.service.spec.ts new file mode 100644 index 0000000000..bb442dcce8 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.service.spec.ts @@ -0,0 +1,368 @@ +import { + ServiceNotFoundException, + ServiceValidationException, +} from '@app/common/exceptions/base.exception'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; +import { DocumentService } from '../../../document/document.service'; +import { PlanningReview } from '../planning-review.entity'; +import { PlanningReviewService } from '../planning-review.service'; +import { PlanningReviewDecisionDocument } from './planning-review-decision-document/planning-review-decision-document.entity'; +import { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; +import { + CreatePlanningReviewDecisionDto, + UpdatePlanningReviewDecisionDto, +} from './planning-review-decision.dto'; +import { PlanningReviewDecision } from './planning-review-decision.entity'; +import { PlanningReviewDecisionService } from './planning-review-decision.service'; + +describe('PlanningReviewDecisionService', () => { + let service: PlanningReviewDecisionService; + let mockDecisionRepository: DeepMocked>; + let mockDecisionDocumentRepository: DeepMocked< + Repository + >; + let mockDecisionOutcomeRepository: DeepMocked< + Repository + >; + let mockDocumentService: DeepMocked; + let mockPlanningReviewService: DeepMocked; + const mockFileNumber = '125'; + let mockPlanningReview; + let mockDecision; + + beforeEach(async () => { + mockDocumentService = createMock(); + mockDecisionRepository = createMock(); + mockDecisionDocumentRepository = createMock(); + mockDecisionOutcomeRepository = createMock(); + mockPlanningReviewService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + PlanningReviewDecisionService, + { + provide: getRepositoryToken(PlanningReviewDecision), + useValue: mockDecisionRepository, + }, + { + provide: getRepositoryToken(PlanningReviewDecisionDocument), + useValue: mockDecisionDocumentRepository, + }, + { + provide: getRepositoryToken(PlanningReviewDecisionOutcomeCode), + useValue: mockDecisionOutcomeRepository, + }, + { + provide: DocumentService, + useValue: mockDocumentService, + }, + { + provide: PlanningReviewService, + useValue: mockPlanningReviewService, + }, + ], + }).compile(); + + service = module.get( + PlanningReviewDecisionService, + ); + + mockPlanningReview = new PlanningReview({ + uuid: v4(), + fileNumber: mockFileNumber, + }); + mockDecision = new PlanningReviewDecision({ + planningReview: mockPlanningReview, + documents: [], + }); + + mockDecisionRepository.find.mockResolvedValue([mockDecision]); + mockDecisionRepository.findOne.mockResolvedValue(mockDecision); + mockDecisionRepository.findOneOrFail(mockDecision); + mockDecisionRepository.save.mockResolvedValue(mockDecision); + + mockDecisionDocumentRepository.find.mockResolvedValue([]); + + mockPlanningReviewService.getByFileNumber.mockResolvedValue( + mockPlanningReview, + ); + mockPlanningReviewService.update.mockResolvedValue({} as any); + + mockDecisionOutcomeRepository.find.mockResolvedValue([]); + mockDecisionOutcomeRepository.findOneOrFail.mockResolvedValue({} as any); + }); + + describe('PlanningReviewDecisionService Core Tests', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should get decisions by file number', async () => { + const result = await service.getByFileNumber( + mockPlanningReview.fileNumber, + ); + + expect(result).toStrictEqual([mockDecision]); + }); + + it('should return decisions by uuid', async () => { + const result = await service.get(mockDecision.uuid); + + expect(result).toStrictEqual(mockDecision); + }); + + it('should delete decision with uuid and update planning review', async () => { + mockDecisionRepository.softRemove.mockResolvedValue({} as any); + mockDecisionRepository.findOne.mockResolvedValue({ + ...mockDecision, + reconsiders: 'reconsider-uuid', + modifies: 'modified-uuid', + }); + mockDecisionRepository.find.mockResolvedValue([]); + + await service.delete(mockDecision.uuid); + + expect(mockDecisionRepository.softRemove).toBeCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledWith( + mockPlanningReview.fileNumber, + { + decisionDate: null, + }, + ); + }); + + it('should create a decision', async () => { + mockDecisionRepository.exists.mockResolvedValue(false); + mockPlanningReviewService.getByFileNumber.mockResolvedValue( + new PlanningReview(), + ); + + const decisionToCreate: CreatePlanningReviewDecisionDto = { + planningReviewFileNumber: '', + }; + + await service.create(decisionToCreate); + + expect(mockDecisionRepository.save).toBeCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledTimes(0); + }); + + it('should fail create a draft decision if draft already exists', async () => { + mockDecisionRepository.findOne.mockResolvedValue( + new PlanningReviewDecision({ + documents: [], + }), + ); + mockDecisionRepository.exists.mockResolvedValueOnce(true); + + const decisionToCreate: CreatePlanningReviewDecisionDto = { + planningReviewFileNumber: '', + }; + + await expect(service.create(decisionToCreate)).rejects.toMatchObject( + new ServiceValidationException( + 'Draft decision already exists for this planning review.', + ), + ); + + expect(mockDecisionRepository.save).toBeCalledTimes(0); + expect(mockPlanningReviewService.update).toHaveBeenCalledTimes(0); + }); + + it('should create a decision and NOT update the planning review if this was the second decision', async () => { + mockDecisionRepository.findOne.mockResolvedValue({ + documents: [] as PlanningReviewDecisionDocument[], + } as PlanningReviewDecision); + mockDecisionRepository.exists.mockResolvedValueOnce(false); + + const decisionToCreate: CreatePlanningReviewDecisionDto = { + planningReviewFileNumber: '', + }; + + await service.create(decisionToCreate); + + expect(mockDecisionRepository.save).toBeCalledTimes(1); + expect(mockPlanningReviewService.update).not.toHaveBeenCalled(); + }); + + it('should update the decision and update the planning review if it was the only decision', async () => { + const decisionDate = new Date(2022, 3, 3, 3, 3, 3, 3); + const decisionUpdate: UpdatePlanningReviewDecisionDto = { + date: decisionDate.getTime(), + outcomeCode: 'New Outcome', + isDraft: false, + }; + + const createdDecision = new PlanningReviewDecision({ + date: decisionDate, + isDraft: false, + documents: [], + }); + + mockDecisionRepository.findOne.mockResolvedValue(createdDecision); + mockDecisionRepository.find.mockResolvedValue([createdDecision]); + + await service.update(mockDecision.uuid, decisionUpdate); + + expect(mockDecisionRepository.findOne).toBeCalledTimes(2); + expect(mockDecisionRepository.save).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledWith( + mockPlanningReview.fileNumber, + { + decisionDate: decisionDate.getTime(), + }, + ); + }); + + it('should update decision and update the planning review date to null if it is draft decision', async () => { + const decisionDate = new Date(2022, 3, 3, 3, 3, 3, 3); + const decisionUpdate: UpdatePlanningReviewDecisionDto = { + date: decisionDate.getTime(), + outcomeCode: 'New Outcome', + isDraft: true, + }; + + await service.update(mockDecision.uuid, decisionUpdate); + + expect(mockDecisionRepository.findOne).toBeCalledTimes(2); + expect(mockDecisionRepository.save).toBeCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewService.update).toHaveBeenCalledWith( + mockFileNumber, + { + decisionDate: null, + }, + ); + }); + + it('should not update the planning review dates when updating a draft decision', async () => { + const secondDecision = new PlanningReviewDecision({ + ...mockPlanningReview, + documents: [], + }); + secondDecision.isDraft = true; + secondDecision.uuid = 'second-uuid'; + mockDecisionRepository.find.mockResolvedValue([ + secondDecision, + mockDecision, + ]); + mockDecisionRepository.findOne.mockResolvedValue(secondDecision); + + const decisionUpdate: UpdatePlanningReviewDecisionDto = { + outcomeCode: 'New Outcome', + isDraft: true, + }; + + await service.update(mockDecision.uuid, decisionUpdate); + + expect(mockDecisionRepository.findOne).toBeCalledTimes(2); + expect(mockDecisionRepository.save).toBeCalledTimes(1); + expect(mockPlanningReviewService.update).not.toHaveBeenCalled(); + }); + + it('should call through for get code', async () => { + await service.fetchCodes(); + expect(mockDecisionOutcomeRepository.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('PlanningReviewDecisionService File Tests', () => { + let mockDocument; + beforeEach(() => { + mockDecisionDocumentRepository.findOne.mockResolvedValue(mockDocument); + mockDecisionDocumentRepository.save.mockResolvedValue(mockDocument); + + mockDocument = { + uuid: 'fake-uuid', + decisionUuid: 'decision-uuid', + } as PlanningReviewDecisionDocument; + }); + + it('should call the repository for attaching a file', async () => { + mockDocumentService.create.mockResolvedValue({} as any); + + await service.attachDocument('uuid', {} as any, {} as any); + expect(mockDecisionDocumentRepository.save).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + }); + + it('should throw an exception when attaching a document to a non-existent decision', async () => { + mockDecisionRepository.findOne.mockResolvedValue(null); + await expect( + service.attachDocument('uuid', {} as any, {} as any), + ).rejects.toMatchObject( + new ServiceNotFoundException(`Decision with UUID uuid not found`), + ); + expect(mockDocumentService.create).not.toHaveBeenCalled(); + }); + + it('should call the repository to delete documents', async () => { + mockDecisionDocumentRepository.softRemove.mockResolvedValue({} as any); + + await service.deleteDocument('fake-uuid'); + expect(mockDecisionDocumentRepository.softRemove).toHaveBeenCalledTimes( + 1, + ); + }); + + it('should call the repository to check if portal user can download document', async () => { + mockDecisionDocumentRepository.findOne.mockResolvedValue( + new PlanningReviewDecisionDocument(), + ); + mockDocumentService.getDownloadUrl.mockResolvedValue(''); + + await service.getDownloadForPortal('fake-uuid'); + expect(mockDecisionDocumentRepository.findOne).toHaveBeenCalledTimes(1); + expect(mockDocumentService.getDownloadUrl).toHaveBeenCalledTimes(1); + }); + + it('should throw an exception when document not found for deletion', async () => { + mockDecisionDocumentRepository.findOne.mockResolvedValue(null); + await expect(service.deleteDocument('fake-uuid')).rejects.toMatchObject( + new ServiceNotFoundException( + `Failed to find document with uuid fake-uuid`, + ), + ); + expect(mockDocumentService.softRemove).not.toHaveBeenCalled(); + }); + + it('should call through to document service for update', async () => { + mockDocumentService.update.mockResolvedValue({} as any); + + await service.updateDocument('document-uuid', 'file-name'); + expect(mockDocumentService.update).toHaveBeenCalledTimes(1); + }); + + it('should call through to document service for download', async () => { + const downloadUrl = 'download-url'; + mockDocumentService.getDownloadUrl.mockResolvedValue(downloadUrl); + + const res = await service.getDownloadUrl('fake-uuid'); + + expect(mockDocumentService.getDownloadUrl).toHaveBeenCalledTimes(1); + expect(res).toEqual(downloadUrl); + }); + + it('should throw an exception when document not found for download', async () => { + mockDecisionDocumentRepository.findOne.mockResolvedValue(null); + await expect(service.getDownloadUrl('fake-uuid')).rejects.toMatchObject( + new ServiceNotFoundException( + `Failed to find document with uuid fake-uuid`, + ), + ); + }); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.service.ts new file mode 100644 index 0000000000..94c23884be --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-decision/planning-review-decision.service.ts @@ -0,0 +1,351 @@ +import { + ServiceNotFoundException, + ServiceValidationException, +} from '@app/common/exceptions/base.exception'; +import { MultipartFile } from '@fastify/multipart'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, +} from '../../../document/document.dto'; +import { DocumentService } from '../../../document/document.service'; +import { User } from '../../../user/user.entity'; +import { formatIncomingDate } from '../../../utils/incoming-date.formatter'; +import { PlanningReviewService } from '../planning-review.service'; +import { PlanningReviewDecisionDocument } from './planning-review-decision-document/planning-review-decision-document.entity'; +import { PlanningReviewDecisionOutcomeCode } from './planning-review-decision-outcome.entity'; +import { + CreatePlanningReviewDecisionDto, + UpdatePlanningReviewDecisionDto, +} from './planning-review-decision.dto'; +import { PlanningReviewDecision } from './planning-review-decision.entity'; + +@Injectable() +export class PlanningReviewDecisionService { + constructor( + @InjectRepository(PlanningReviewDecision) + private planningReviewDecisionRepository: Repository, + @InjectRepository(PlanningReviewDecisionDocument) + private decisionDocumentRepository: Repository, + @InjectRepository(PlanningReviewDecisionOutcomeCode) + private decisionOutcomeRepository: Repository, + private planningReviewService: PlanningReviewService, + private documentService: DocumentService, + ) {} + + async getByFileNumber(fileNumber: string) { + const planningReview = + await this.planningReviewService.getByFileNumber(fileNumber); + + const decisions = await this.planningReviewDecisionRepository.find({ + where: { + planningReviewUuid: planningReview.uuid, + }, + order: { + createdAt: 'DESC', + }, + relations: { + outcome: true, + }, + }); + + //Query Documents separately as when added to the above joins caused performance issues + for (const decision of decisions) { + decision.documents = await this.decisionDocumentRepository.find({ + where: { + decisionUuid: decision.uuid, + document: { + auditDeletedDateAt: IsNull(), + }, + }, + relations: { + document: true, + }, + }); + } + return decisions; + } + + async get(uuid) { + const decision = await this.planningReviewDecisionRepository.findOne({ + where: { + uuid, + }, + relations: { + outcome: true, + documents: { + document: true, + }, + }, + }); + + if (!decision) { + throw new ServiceNotFoundException( + `Failed to load decision with uuid ${uuid}`, + ); + } + + decision.documents = decision.documents.filter( + (document) => !!document.document, + ); + + return decision; + } + + async update(uuid: string, updateDto: UpdatePlanningReviewDecisionDto) { + const existingDecision: Partial = + await this.getOrFail(uuid); + + const isChangingDraftStatus = + existingDecision.isDraft !== updateDto.isDraft; + existingDecision.resolutionNumber = updateDto.resolutionNumber; + existingDecision.resolutionYear = updateDto.resolutionYear; + existingDecision.decisionDescription = updateDto.decisionDescription; + existingDecision.isDraft = updateDto.isDraft; + existingDecision.wasReleased = + existingDecision.wasReleased || !updateDto.isDraft; + + if (updateDto.outcomeCode) { + existingDecision.outcome = await this.getOutcomeByCode( + updateDto.outcomeCode, + ); + } + let dateHasChanged = false; + if ( + updateDto.date !== undefined && + existingDecision.date !== formatIncomingDate(updateDto.date) + ) { + dateHasChanged = true; + existingDecision.date = formatIncomingDate(updateDto.date); + } + + const updatedDecision = + await this.planningReviewDecisionRepository.save(existingDecision); + + if (dateHasChanged || isChangingDraftStatus) { + await this.updateDecisionDates(updatedDecision); + } + + return this.get(existingDecision.uuid); + } + + async create(createDto: CreatePlanningReviewDecisionDto) { + const isDraftExists = await this.planningReviewDecisionRepository.exists({ + where: { + planningReview: { fileNumber: createDto.planningReviewFileNumber }, + isDraft: true, + }, + }); + + if (isDraftExists) { + throw new ServiceValidationException( + 'Draft decision already exists for this planning review.', + ); + } + + const planningReview = await this.planningReviewService.getByFileNumber( + createDto.planningReviewFileNumber, + ); + + const decision = new PlanningReviewDecision({ + resolutionYear: new Date().getFullYear(), + planningReviewUuid: planningReview.uuid, + }); + + const savedDecision = await this.planningReviewDecisionRepository.save( + decision, + { + transaction: true, + }, + ); + + return this.get(savedDecision.uuid); + } + + async delete(uuid) { + const planningReviewDecision = + await this.planningReviewDecisionRepository.findOne({ + where: { uuid }, + relations: { + outcome: true, + documents: { + document: true, + }, + planningReview: true, + }, + }); + + if (!planningReviewDecision) { + throw new ServiceNotFoundException( + `Failed to find decision with uuid ${uuid}`, + ); + } + + for (const document of planningReviewDecision.documents) { + await this.documentService.softRemove(document.document); + } + await this.planningReviewDecisionRepository.save(planningReviewDecision); + + await this.planningReviewDecisionRepository.softRemove([ + planningReviewDecision, + ]); + await this.updateDecisionDates(planningReviewDecision); + } + + async attachDocument(decisionUuid: string, file: MultipartFile, user: User) { + const decision = await this.getOrFail(decisionUuid); + const document = await this.documentService.create( + `decision/${decision.uuid}`, + file.filename, + file, + user, + DOCUMENT_SOURCE.ALC, + DOCUMENT_SYSTEM.ALCS, + ); + const appDocument = new PlanningReviewDecisionDocument({ + decision, + document, + }); + + return this.decisionDocumentRepository.save(appDocument); + } + + async deleteDocument(decisionDocumentUuid: string) { + const decisionDocument = + await this.getDecisionDocumentOrFail(decisionDocumentUuid); + + await this.decisionDocumentRepository.softRemove(decisionDocument); + return decisionDocument; + } + + async getDownloadUrl(decisionDocumentUuid: string, openInline = false) { + const decisionDocument = + await this.getDecisionDocumentOrFail(decisionDocumentUuid); + + return this.documentService.getDownloadUrl( + decisionDocument.document, + openInline, + ); + } + + async getDownloadForPortal(decisionDocumentUuid: string) { + const decisionDocument = await this.decisionDocumentRepository.findOne({ + where: { + decision: { + isDraft: false, + }, + uuid: decisionDocumentUuid, + }, + relations: { + document: true, + }, + }); + + if (decisionDocument) { + return this.documentService.getDownloadUrl( + decisionDocument.document, + true, // FIXME: Document does not open inline despite flag being true + ); + } + throw new ServiceNotFoundException('Failed to find document'); + } + + getOutcomeByCode(code: string) { + return this.decisionOutcomeRepository.findOneOrFail({ + where: { + code, + }, + }); + } + + async fetchCodes() { + const values = await Promise.all([this.decisionOutcomeRepository.find()]); + + return { + outcomes: values[0], + }; + } + + getMany(modifiesDecisionUuids: string[]) { + return this.planningReviewDecisionRepository.find({ + where: { + uuid: In(modifiesDecisionUuids), + }, + }); + } + + async generateResolutionNumber(resolutionYear: number) { + const result = await this.planningReviewDecisionRepository.query( + `SELECT * FROM alcs.generate_next_resolution_number(${resolutionYear})`, + ); + + return result[0].generate_next_resolution_number; + } + + private async getOrFail(uuid: string) { + const existingDecision = + await this.planningReviewDecisionRepository.findOne({ + where: { + uuid, + }, + relations: { + planningReview: true, + }, + }); + + if (!existingDecision) { + throw new ServiceNotFoundException( + `Decision with UUID ${uuid} not found`, + ); + } + return existingDecision; + } + + private async updateDecisionDates( + planningReviewDecision: PlanningReviewDecision, + ) { + const fileNumber = planningReviewDecision.planningReview.fileNumber; + const existingDecisions = await this.getByFileNumber(fileNumber); + const releasedDecisions = existingDecisions.filter( + (decision) => !decision.isDraft, + ); + if (releasedDecisions.length === 0) { + await this.planningReviewService.update(fileNumber, { + decisionDate: null, + }); + } else { + const decisionDate = existingDecisions[existingDecisions.length - 1].date; + await this.planningReviewService.update(fileNumber, { + decisionDate: decisionDate?.getTime(), + }); + } + } + + private async getDecisionDocumentOrFail(decisionDocumentUuid: string) { + const decisionDocument = await this.decisionDocumentRepository.findOne({ + where: { + uuid: decisionDocumentUuid, + }, + relations: { + document: true, + }, + }); + + if (!decisionDocument) { + throw new ServiceNotFoundException( + `Failed to find document with uuid ${decisionDocumentUuid}`, + ); + } + return decisionDocument; + } + + async updateDocument(documentUuid: string, fileName: string) { + const document = await this.getDecisionDocumentOrFail(documentUuid); + await this.documentService.update(document.document, { + fileName, + source: DOCUMENT_SOURCE.ALC, + }); + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts index 39442427c9..998ec30f65 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts @@ -162,4 +162,8 @@ export class UpdatePlanningReviewDto { @IsString() @IsOptional() typeCode?: string; + + @IsNumber() + @IsOptional() + decisionDate?: number | null; } 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 d56139b567..22a9e6c358 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 @@ -67,4 +67,7 @@ export class PlanningReview extends Base { @Column({ type: 'timestamptz', nullable: true }) closedDate: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + decisionDate: Date | null; } diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts index 447a0327f4..5c038279cc 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts @@ -99,16 +99,12 @@ describe('PlanningReviewService', () => { expect(mockReferralRepository.save).toHaveBeenCalledTimes(1); }); - it('should call through to the repo for getby', async () => { - const mockFilter = { - uuid: '5', - }; - mockRepository.find.mockResolvedValue([]); + it('should call through to the repo for getByFileNumber', async () => { + mockRepository.findOneOrFail.mockResolvedValue(new PlanningReview()); - await service.getBy(mockFilter); + await service.getByFileNumber('fileNumber'); - expect(mockRepository.find).toHaveBeenCalledTimes(1); - expect(mockRepository.find.mock.calls[0][0]!.where).toEqual(mockFilter); + expect(mockRepository.findOneOrFail).toHaveBeenCalledTimes(1); }); it('should call through to the repo for getDetailedReview', async () => { 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 561b8c1dad..109fde16f2 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 @@ -85,9 +85,11 @@ export class PlanningReviewService { ); } - getBy(findOptions: FindOptionsWhere) { - return this.reviewRepository.find({ - where: findOptions, + getByFileNumber(fileNumber: string) { + return this.reviewRepository.findOneOrFail({ + where: { + fileNumber, + }, relations: this.DEFAULT_RELATIONS, }); } diff --git a/services/apps/alcs/src/common/automapper/planning-review-decision.automapper.profile.ts b/services/apps/alcs/src/common/automapper/planning-review-decision.automapper.profile.ts new file mode 100644 index 0000000000..7bd2a95ee5 --- /dev/null +++ b/services/apps/alcs/src/common/automapper/planning-review-decision.automapper.profile.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { createMap, forMember, mapFrom, Mapper } from 'automapper-core'; +import { AutomapperProfile, InjectMapper } from 'automapper-nestjs'; +import { PlanningReviewDecisionDocument } from '../../alcs/planning-review/planning-review-decision/planning-review-decision-document/planning-review-decision-document.entity'; +import { PlanningReviewDecisionOutcomeCode } from '../../alcs/planning-review/planning-review-decision/planning-review-decision-outcome.entity'; +import { + PlanningReviewDecisionDocumentDto, + PlanningReviewDecisionDto, + PlanningReviewDecisionOutcomeCodeDto, +} from '../../alcs/planning-review/planning-review-decision/planning-review-decision.dto'; +import { PlanningReviewDecision } from '../../alcs/planning-review/planning-review-decision/planning-review-decision.entity'; +import { DocumentCode } from '../../document/document-code.entity'; +import { DocumentTypeDto } from '../../document/document.dto'; + +@Injectable() +export class PlanningReviewDecisionProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + PlanningReviewDecision, + PlanningReviewDecisionDto, + forMember( + (dto) => dto.date, + mapFrom((entity) => entity.date?.getTime()), + ), + ); + createMap( + mapper, + PlanningReviewDecisionOutcomeCode, + PlanningReviewDecisionOutcomeCodeDto, + ); + + createMap( + mapper, + PlanningReviewDecisionDocument, + PlanningReviewDecisionDocumentDto, + forMember( + (dto) => dto.mimeType, + mapFrom((entity) => entity.document.mimeType), + ), + forMember( + (dto) => dto.fileName, + mapFrom((entity) => entity.document.fileName), + ), + forMember( + (dto) => dto.fileSize, + mapFrom((entity) => entity.document.fileSize), + ), + forMember( + (dto) => dto.uploadedBy, + mapFrom((entity) => entity.document.uploadedBy?.name), + ), + forMember( + (dto) => dto.uploadedAt, + mapFrom((entity) => entity.document.uploadedAt.getTime()), + ), + ); + createMap(mapper, DocumentCode, DocumentTypeDto); + }; + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1683048988258-generate_next_resolution_number.ts b/services/apps/alcs/src/providers/typeorm/migrations/1683048988258-generate_next_resolution_number.ts index 3f4bcb2fb4..204f4b081d 100644 --- a/services/apps/alcs/src/providers/typeorm/migrations/1683048988258-generate_next_resolution_number.ts +++ b/services/apps/alcs/src/providers/typeorm/migrations/1683048988258-generate_next_resolution_number.ts @@ -8,33 +8,42 @@ export class generateNextResolutionNumber1683048988258 `CREATE OR REPLACE FUNCTION alcs.generate_next_resolution_number(p_resolution_year integer) RETURNS integer LANGUAGE plpgsql - AS $function$ - - declare next_resolution_number integer; - BEGIN + AS $function$ + declare next_resolution_number integer; + BEGIN + select + row_num into next_resolution_number + from + ( select - row_num into next_resolution_number + coalesce(resolution_number, 0) as resolution_number, + row_number() over ( + order by resolution_number) row_num from ( - select - coalesce(resolution_number, 0) as resolution_number, - row_number() over ( - order by resolution_number) row_num - from - alcs.application_decision - where - resolution_year = p_resolution_year - and audit_deleted_date_at is null - ) z + select resolution_number, audit_deleted_date_at + from alcs.application_decision + where resolution_year = p_resolution_year + UNION + select resolution_number, audit_deleted_date_at + from alcs.noi_decision + where resolution_year = p_resolution_year + UNION + select resolution_number, audit_deleted_date_at + from alcs.planning_review_decision + where resolution_year = p_resolution_year + ) as combined where - row_num != resolution_number - order by - row_num offset 0 row fetch next 1 row only; - - return coalesce(next_resolution_number, 1); - END; - $function$ - ; + audit_deleted_date_at is null + ) z + where + row_num != resolution_number + order by + row_num offset 0 row fetch next 1 row only; + + return coalesce(next_resolution_number, 1); + END; + $function$ `, ); } diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1710273748053-add_pr_decisions.ts b/services/apps/alcs/src/providers/typeorm/migrations/1710273748053-add_pr_decisions.ts new file mode 100644 index 0000000000..8c8d5a877f --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1710273748053-add_pr_decisions.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPrDecisions1710273748053 implements MigrationInterface { + name = 'AddPrDecisions1710273748053'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "alcs"."planning_review_decision_document" ("audit_deleted_date_at" TIMESTAMP WITH TIME ZONE, "audit_created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "audit_updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "audit_created_by" character varying NOT NULL, "audit_updated_by" character varying, "uuid" uuid NOT NULL DEFAULT gen_random_uuid(), "decision_uuid" uuid NOT NULL, "document_uuid" uuid, CONSTRAINT "REL_82ba9c2d75bf10e7c6abae2e07" UNIQUE ("document_uuid"), CONSTRAINT "PK_b9304374dee3b46de5eca18dd67" PRIMARY KEY ("uuid"))`, + ); + await queryRunner.query( + `CREATE TABLE "alcs"."planning_review_decision_outcome_code" ("audit_deleted_date_at" TIMESTAMP WITH TIME ZONE, "audit_created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "audit_updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "audit_created_by" character varying NOT NULL, "audit_updated_by" character varying, "label" character varying NOT NULL, "code" text NOT NULL, "description" text NOT NULL, CONSTRAINT "UQ_f0ca5d421aa101c87c61e66c0e3" UNIQUE ("description"), CONSTRAINT "PK_ff611ee7c39894dd101e3d52914" PRIMARY KEY ("code"))`, + ); + await queryRunner.query( + `CREATE TABLE "alcs"."planning_review_decision" ("audit_deleted_date_at" TIMESTAMP WITH TIME ZONE, "audit_created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "audit_updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "audit_created_by" character varying NOT NULL, "audit_updated_by" character varying, "uuid" uuid NOT NULL DEFAULT gen_random_uuid(), "date" TIMESTAMP WITH TIME ZONE, "was_released" boolean NOT NULL DEFAULT false, "outcome_code" text NOT NULL, "resolution_number" integer, "resolution_year" smallint NOT NULL, "is_draft" boolean NOT NULL DEFAULT false, "decision_description" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "planning_review_uuid" uuid NOT NULL, CONSTRAINT "PK_fd29fa62e1b5b13602b851aa180" PRIMARY KEY ("uuid")); COMMENT ON COLUMN "alcs"."planning_review_decision"."is_draft" IS 'Indicates whether the decision is currently draft or not'; COMMENT ON COLUMN "alcs"."planning_review_decision"."decision_description" IS 'Staff input field for a description of the decision'; COMMENT ON COLUMN "alcs"."planning_review_decision"."created_at" IS 'Date that indicates when decision was created. It is not editable by user.'`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_c85b10d6e99cb1585f56f60ae8" ON "alcs"."planning_review_decision" ("resolution_number", "resolution_year") WHERE "audit_deleted_date_at" is null and "resolution_number" is not null`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "decision_date" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision_document" ADD CONSTRAINT "FK_5866953e6d14233cace5d93564d" FOREIGN KEY ("decision_uuid") REFERENCES "alcs"."planning_review_decision"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision_document" ADD CONSTRAINT "FK_82ba9c2d75bf10e7c6abae2e079" FOREIGN KEY ("document_uuid") REFERENCES "alcs"."document"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ADD CONSTRAINT "FK_98f71d634dd9388cf287b02c728" FOREIGN KEY ("outcome_code") REFERENCES "alcs"."planning_review_decision_outcome_code"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ADD CONSTRAINT "FK_3d54a8ce0b6c8a61d413aeb0080" 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_decision" ALTER COLUMN "outcome_code" SET DEFAULT 'ENDO'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" DROP CONSTRAINT "FK_3d54a8ce0b6c8a61d413aeb0080"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" DROP CONSTRAINT "FK_98f71d634dd9388cf287b02c728"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision_document" DROP CONSTRAINT "FK_82ba9c2d75bf10e7c6abae2e079"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision_document" DROP CONSTRAINT "FK_5866953e6d14233cace5d93564d"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "decision_date"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_c85b10d6e99cb1585f56f60ae8"`, + ); + await queryRunner.query(`DROP TABLE "alcs"."planning_review_decision"`); + await queryRunner.query( + `DROP TABLE "alcs"."planning_review_decision_outcome_code"`, + ); + await queryRunner.query( + `DROP TABLE "alcs"."planning_review_decision_document"`, + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1710278788366-seed_pr_dec_outcomes.ts b/services/apps/alcs/src/providers/typeorm/migrations/1710278788366-seed_pr_dec_outcomes.ts new file mode 100644 index 0000000000..6f65395d8e --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1710278788366-seed_pr_dec_outcomes.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedPrDecOutcomes1710278788366 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO "alcs"."planning_review_decision_outcome_code" + ("audit_deleted_date_at", "audit_created_at", "audit_updated_at", "audit_created_by", "audit_updated_by", "label", "code", "description") VALUES + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Endorsed', 'ENDO', 'Endorsed'), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Not Endorsed', 'NEND', 'Not Endorsed'), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Partially Endorsed', 'PEND', 'Partially Endorsed'), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Other', 'OTHR', 'Other'); + `); + } + + public async down(): Promise { + //Not needed + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1710288340795-update_resolution_generation_for_pr.ts b/services/apps/alcs/src/providers/typeorm/migrations/1710288340795-update_resolution_generation_for_pr.ts new file mode 100644 index 0000000000..de7980dbd8 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1710288340795-update_resolution_generation_for_pr.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateResolutionGenerationForPr1710288340795 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION alcs.generate_next_resolution_number(p_resolution_year integer) + RETURNS integer + LANGUAGE plpgsql + AS $function$ + declare next_resolution_number integer; + BEGIN\t + select + row_num into next_resolution_number + from + ( + select + coalesce(resolution_number, 0) as resolution_number, + row_number() over ( + order by resolution_number) row_num + from + ( + select resolution_number, audit_deleted_date_at + from alcs.application_decision + where resolution_year = p_resolution_year + UNION + select resolution_number, audit_deleted_date_at + from alcs.notice_of_intent_decision + where resolution_year = p_resolution_year + UNION + select resolution_number, audit_deleted_date_at + from alcs.planning_review_decision + where resolution_year = p_resolution_year + ) as combined + where + audit_deleted_date_at is null + ) z + where + row_num != resolution_number + order by + row_num offset 0 row fetch next 1 row only; + + return coalesce(next_resolution_number, 1); + END; + $function$; + `); + } + + public async down(): Promise { + //No + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1710348044213-remove_default_pr_dec_outcome.ts b/services/apps/alcs/src/providers/typeorm/migrations/1710348044213-remove_default_pr_dec_outcome.ts new file mode 100644 index 0000000000..595ef0483c --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1710348044213-remove_default_pr_dec_outcome.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveDefaultPrDecOutcome1710348044213 + implements MigrationInterface +{ + name = 'RemoveDefaultPrDecOutcome1710348044213'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" DROP CONSTRAINT "FK_98f71d634dd9388cf287b02c728"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ALTER COLUMN "outcome_code" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ALTER COLUMN "outcome_code" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ADD CONSTRAINT "FK_98f71d634dd9388cf287b02c728" FOREIGN KEY ("outcome_code") REFERENCES "alcs"."planning_review_decision_outcome_code"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" DROP CONSTRAINT "FK_98f71d634dd9388cf287b02c728"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ALTER COLUMN "outcome_code" SET DEFAULT 'ENDO'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ALTER COLUMN "outcome_code" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_decision" ADD CONSTRAINT "FK_98f71d634dd9388cf287b02c728" FOREIGN KEY ("outcome_code") REFERENCES "alcs"."planning_review_decision_outcome_code"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +}