diff --git a/alcs-frontend/Dockerfile b/alcs-frontend/Dockerfile index 0e4e9569d5..a188b58063 100644 --- a/alcs-frontend/Dockerfile +++ b/alcs-frontend/Dockerfile @@ -13,7 +13,7 @@ RUN npm ci # Copy the source code to the /app directory COPY . . -ENV NODE_OPTIONS="--max-old-space-size=2048" +ENV NODE_OPTIONS "--max-old-space-size=2048" # Build the application RUN npm run build -- --output-path=dist --output-hashing=all @@ -47,10 +47,10 @@ COPY --from=build /app/dist /usr/share/nginx/html RUN chmod -R go+rwx /usr/share/nginx/html/assets # provide dynamic scp content-src -ENV ENABLED_CONNECT_SRC=" 'self' http://localhost:* nrs.objectstore.gov.bc.ca" +ENV ENABLED_CONNECT_SRC " 'self' http://localhost:* nrs.objectstore.gov.bc.ca" # set to true to enable maintenance mode -ENV MAINTENANCE_MODE="false" +ENV MAINTENANCE_MODE "false" # When the container starts, replace the settings.json with values from environment variables ENTRYPOINT [ "./init.sh" ] diff --git a/alcs-frontend/src/app/features/admin/unarchive/unarchive.component.ts b/alcs-frontend/src/app/features/admin/unarchive/unarchive.component.ts index 86e1d641b7..963ac60cf3 100644 --- a/alcs-frontend/src/app/features/admin/unarchive/unarchive.component.ts +++ b/alcs-frontend/src/app/features/admin/unarchive/unarchive.component.ts @@ -5,6 +5,7 @@ import { UnarchiveCardService, } from '../../../services/unarchive-card/unarchive-card.service'; import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { CardType } from '../../../shared/card/card.component'; @Component({ selector: 'app-unarchive', @@ -18,7 +19,7 @@ export class UnarchiveComponent { constructor( private unarchiveCardService: UnarchiveCardService, - private confirmationDialogService: ConfirmationDialogService + private confirmationDialogService: ConfirmationDialogService, ) {} onUnarchive(uuid: string) { @@ -36,6 +37,11 @@ export class UnarchiveComponent { async onSearch() { const results = await this.unarchiveCardService.search(this.search); if (results) { + results.forEach((result) => { + if (result.type === CardType.APP_CON || result.type === CardType.NOI_CON) { + result.type = 'Condition'; + } + }); this.cards = results; } } diff --git a/alcs-frontend/src/app/features/application/application.component.html b/alcs-frontend/src/app/features/application/application.component.html index b3bd0e1254..65781178ad 100644 --- a/alcs-frontend/src/app/features/application/application.component.html +++ b/alcs-frontend/src/app/features/application/application.component.html @@ -4,6 +4,7 @@ [application]="application" [modifications]="modifications" [reconsiderations]="reconsiderations" + [conditionCards]="decisionConditionCards" [showStatus]="true" [submissionStatusService]="applicationStatusService" [applicationDetailService]="applicationDetailService" diff --git a/alcs-frontend/src/app/features/application/application.component.spec.ts b/alcs-frontend/src/app/features/application/application.component.spec.ts index c7075098d1..2964e51ddf 100644 --- a/alcs-frontend/src/app/features/application/application.component.spec.ts +++ b/alcs-frontend/src/app/features/application/application.component.spec.ts @@ -15,6 +15,7 @@ import { ApplicationSubmissionService } from '../../services/application/applica import { ApplicationSubmissionStatusService } from '../../services/application/application-submission-status/application-submission-status.service'; import { ApplicationComponent } from './application.component'; +import { ApplicationDecisionConditionCardService } from '../../services/application/decision/application-decision-v2/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; describe('ApplicationComponent', () => { let component: ApplicationComponent; @@ -25,6 +26,7 @@ describe('ApplicationComponent', () => { let mockReviewService: DeepMocked; let mockAppSubmissionService: DeepMocked; let mockAppStatusService: DeepMocked; + let mockApplicationDecisionConditionCardService: DeepMocked; beforeEach(async () => { mockAppDetailService = createMock(); @@ -40,6 +42,8 @@ describe('ApplicationComponent', () => { mockAppSubmissionService = createMock(); mockAppStatusService = createMock(); + mockApplicationDecisionConditionCardService = createMock(); + await TestBed.configureTestingModule({ providers: [ { @@ -70,6 +74,10 @@ describe('ApplicationComponent', () => { provide: ApplicationSubmissionStatusService, useValue: mockAppStatusService, }, + { + provide: ApplicationDecisionConditionCardService, + useValue: mockApplicationDecisionConditionCardService, + }, { provide: ActivatedRoute, useValue: { diff --git a/alcs-frontend/src/app/features/application/application.component.ts b/alcs-frontend/src/app/features/application/application.component.ts index b69a7b2ecd..a8bb3436d7 100644 --- a/alcs-frontend/src/app/features/application/application.component.ts +++ b/alcs-frontend/src/app/features/application/application.component.ts @@ -30,6 +30,9 @@ import { ReviewComponent } from './review/review.component'; import { ApplicationSubmissionStatusService } from '../../services/application/application-submission-status/application-submission-status.service'; import { ApplicationTagService } from '../../services/application/application-tag/application-tag.service'; import { FileTagService } from '../../services/common/file-tag.service'; +import { ApplicationDecisionV2Service } from '../../services/application/decision/application-decision-v2/application-decision-v2.service'; +import { ApplicationDecisionConditionCardDto } from '../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { ApplicationDecisionConditionCardService } from '../../services/application/decision/application-decision-v2/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; export const unsubmittedRoutes = [ { @@ -184,6 +187,7 @@ export class ApplicationComponent implements OnInit, OnDestroy { application: ApplicationDto | undefined; reconsiderations: ApplicationReconsiderationDto[] = []; modifications: ApplicationModificationDto[] = []; + decisionConditionCards: ApplicationDecisionConditionCardDto[] = []; submission?: ApplicationSubmissionDto; isApplicantSubmission = false; @@ -195,6 +199,7 @@ export class ApplicationComponent implements OnInit, OnDestroy { public applicationSubmissionService: ApplicationSubmissionService, private reconsiderationService: ApplicationReconsiderationService, private modificationService: ApplicationModificationService, + private decisionConditionCardService: ApplicationDecisionConditionCardService, private route: ActivatedRoute, private titleService: Title, public applicationStatusService: ApplicationSubmissionStatusService, @@ -214,6 +219,9 @@ export class ApplicationComponent implements OnInit, OnDestroy { this.reconsiderationService.fetchByApplication(application.fileNumber); this.modificationService.fetchByApplication(application.fileNumber); + this.decisionConditionCards = + (await this.decisionConditionCardService.fetchByApplicationFileNumber(application.fileNumber)) || []; + this.isApplicantSubmission = application.source !== SYSTEM_SOURCE_TYPES.ALCS; let wasSubmittedToLfng = false; diff --git a/alcs-frontend/src/app/features/application/application.module.ts b/alcs-frontend/src/app/features/application/application.module.ts index e347318a34..826bf2f72d 100644 --- a/alcs-frontend/src/app/features/application/application.module.ts +++ b/alcs-frontend/src/app/features/application/application.module.ts @@ -15,7 +15,6 @@ import { appChildRoutes, ApplicationComponent } from './application.component'; import { BoundaryAmendmentComponent } from './boundary-amendment/boundary-amendment.component'; import { EditBoundaryAmendmentDialogComponent } from './boundary-amendment/edit-boundary-amendment-dialog/edit-boundary-amendment-dialog.component'; import { DecisionModule } from './decision/decision.module'; -import { DocumentUploadDialogComponent } from './documents/document-upload-dialog/document-upload-dialog.component'; import { DocumentsComponent } from './documents/documents.component'; import { InfoRequestsComponent } from './info-requests/info-requests.component'; import { InfoRequestDialogComponent } from './info-requests/info-request-dialog/info-request-dialog.component'; @@ -73,7 +72,6 @@ const routes: Routes = [ ApplicantInfoComponent, LfngInfoComponent, DocumentsComponent, - DocumentUploadDialogComponent, ProposalComponent, NfuProposalComponent, SubdProposalComponent, diff --git a/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.html b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.html new file mode 100644 index 0000000000..da9ca2c38d --- /dev/null +++ b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.html @@ -0,0 +1,70 @@ +
+
+

Create New Condition Card

+
+
+ + + + + + + + +
+
+ Add one or more conditions* +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + #{{ element.index }}Type{{ element.condition.type.label }}Description{{ element.condition.description }}
+
+
+
+
+ + +
+
+
diff --git a/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.scss b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.scss new file mode 100644 index 0000000000..592e2506d1 --- /dev/null +++ b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.scss @@ -0,0 +1,53 @@ +@use '../../../../../../styles/colors.scss' as *; + +.container { + display: flex; + flex-direction: column; + width: 100%; + padding: 12px 8px; + overflow-y: hidden; +} + +.section { + width: 100%; + padding: 16px; +} + +.button-row { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.column-select { + width: 10%; +} + +.column-index { + width: 10%; +} + +.column-type { + width: 30%; +} + +.column-description { + width: 50%; +} + +.conditions-table { + margin-top: 16px; + width: 100%; +} + +.table-container { + max-height: 300px; + overflow-y: auto; + padding: 2px; +} + +.disabled-row { + background-color: #f0f0f0; + opacity: 0.6; + cursor: default; +} diff --git a/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.spec.ts b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.spec.ts new file mode 100644 index 0000000000..319806b791 --- /dev/null +++ b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.spec.ts @@ -0,0 +1,55 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ApplicationDecisionConditionCardService } from '../../../../../services/application/decision/application-decision-v2/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { BoardService } from '../../../../../services/board/board.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { ConditionCardDialogComponent } from './condition-card-dialog.component'; + +describe('ConditionCardDialogComponent', () => { + let component: ConditionCardDialogComponent; + let fixture: ComponentFixture; + let mockDecisionConditionCardService: DeepMocked; + let mockBoardService: DeepMocked; + let mockToastService: DeepMocked; + + beforeEach(async () => { + mockDecisionConditionCardService = createMock(); + mockBoardService = createMock(); + mockToastService = createMock(); + + await TestBed.configureTestingModule({ + declarations: [ConditionCardDialogComponent], + imports: [ + MatDialogModule, + BrowserAnimationsModule, + MatTableModule, + MatSortModule, + HttpClientTestingModule, + RouterTestingModule, + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { conditions: [], decision: 'decision-uuid' } }, + { provide: MatDialogRef, useValue: {} }, + { provide: ApplicationDecisionConditionCardService, useValue: mockDecisionConditionCardService }, + { provide: BoardService, useValue: mockBoardService }, + { provide: ToastService, useValue: mockToastService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ConditionCardDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.ts b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.ts new file mode 100644 index 0000000000..ef298bfeec --- /dev/null +++ b/alcs-frontend/src/app/features/application/decision/conditions/condition-card-dialog/condition-card-dialog.component.ts @@ -0,0 +1,90 @@ +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { MatTableDataSource } from '@angular/material/table'; +import { MatSort } from '@angular/material/sort'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + ApplicationDecisionConditionDto, + CreateApplicationDecisionConditionCardDto, +} from '../../../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { ApplicationDecisionConditionDto as OriginalApplicationDecisionConditionDto } from '../../../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { ApplicationDecisionConditionCardService } from '../../../../../services/application/decision/application-decision-v2/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { BOARD_TYPE_CODES, BoardService } from '../../../../../services/board/board.service'; +import { BoardDto, BoardStatusDto } from '../../../../../services/board/board.dto'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { CardType } from '../../../../../shared/card/card.component'; + +@Component({ + selector: 'app-condition-card-dialog', + templateUrl: './condition-card-dialog.component.html', + styleUrl: './condition-card-dialog.component.scss', +}) +export class ConditionCardDialogComponent implements OnInit { + displayColumns: string[] = ['select', 'index', 'type', 'description']; + conditionBoard: BoardDto | undefined; + selectedStatus = ''; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource<{ condition: ApplicationDecisionConditionDto; index: number; selected: boolean }> = + new MatTableDataSource(); + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { conditions: { condition: ApplicationDecisionConditionDto; index: number }[]; decision: string }, + private dialogRef: MatDialogRef, + private decisionConditionCardService: ApplicationDecisionConditionCardService, + private boardService: BoardService, + private toastService: ToastService, + ) {} + + async ngOnInit() { + this.dataSource.data = this.data.conditions.map((item) => ({ + condition: item.condition, + selected: false, + index: item.index + 1, + })); + + this.conditionBoard = await this.boardService.fetchBoardDetail(BOARD_TYPE_CODES.APPCON); + } + + onStatusSelected(applicationStatus: BoardStatusDto) { + this.selectedStatus = applicationStatus.statusCode; + } + + isConditionCardNotNull(element: any): boolean { + return element.condition.conditionCard !== null; + } + + isSaveDisabled(): boolean { + const isStatusSelected = !!this.selectedStatus; + const isAnyRowSelected = this.dataSource.data.some((item) => item.selected); + return !(isStatusSelected && isAnyRowSelected); + } + + onCancel(): void { + this.dialogRef.close({ action: 'cancel' }); + } + + async onSave() { + const selectedStatusCode = this.conditionBoard?.statuses.find( + (status) => status.label === this.selectedStatus, + )?.statusCode; + const selectedConditions = this.dataSource.data.filter((item) => item.selected).map((item) => item.condition.uuid); + const createDto: CreateApplicationDecisionConditionCardDto = { + conditionsUuids: selectedConditions, + decisionUuid: this.data.decision, + cardStatusCode: this.selectedStatus, + }; + const res = await this.decisionConditionCardService.create(createDto); + if (res) { + this.toastService.showSuccessToastWithLink( + 'Condition card created successfully', + 'GO TO BOARD', + `/board/appcon?card=${res.cardUuid}&type=${CardType.APP_CON}`, + ); + this.dialogRef.close({ action: 'save', result: true }); + } else { + this.toastService.showErrorToast('Failed to create condition card'); + this.dialogRef.close({ action: 'save', result: false }); + } + } +} diff --git a/alcs-frontend/src/app/features/application/decision/conditions/condition/condition.component.html b/alcs-frontend/src/app/features/application/decision/conditions/condition/condition.component.html index 1ee778dbbe..eca365eeda 100644 --- a/alcs-frontend/src/app/features/application/decision/conditions/condition/condition.component.html +++ b/alcs-frontend/src/app/features/application/decision/conditions/condition/condition.component.html @@ -99,20 +99,20 @@

{{ condition.type.label }}

@@ -132,8 +132,8 @@

{{ condition.type.label }}

@@ -153,8 +153,8 @@

{{ condition.type.label }}

Due @@ -163,8 +163,8 @@

{{ condition.type.label }}

Completed @@ -173,8 +173,8 @@

{{ condition.type.label }}

Comment @@ -182,7 +182,7 @@

{{ condition.type.label }}

Action - @@ -200,7 +200,7 @@

{{ condition.type.label }}

Description

View Conditions

- +
+ + +
@@ -35,7 +40,7 @@

View Conditions

[condition]="condition" [isDraftDecision]="decision.isDraft" [fileNumber]="fileNumber" - [index]="j+1" + [index]="j + 1" >
diff --git a/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.scss b/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.scss index 21b94e2d54..121ac38402 100644 --- a/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.scss +++ b/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.scss @@ -21,6 +21,11 @@ p { justify-content: flex-end; } +.header-buttons { + display: flex; + gap: 8px; +} + :host ::ng-deep { .display-none { display: none !important; diff --git a/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.ts b/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.ts index 769ac3f52d..64628fa15d 100644 --- a/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.ts +++ b/alcs-frontend/src/app/features/application/decision/conditions/conditions.component.ts @@ -19,6 +19,8 @@ import { RELEASED_DECISION_TYPE_LABEL, } from '../../../../shared/application-type-pill/application-type-pill.constants'; import { ApplicationDecisionConditionService } from '../../../../services/application/decision/application-decision-v2/application-decision-condition/application-decision-condition.service'; +import { MatDialog } from '@angular/material/dialog'; +import { ConditionCardDialogComponent } from './condition-card-dialog/condition-card-dialog.component'; export type ConditionComponentLabels = { label: string[]; @@ -70,6 +72,7 @@ export class ConditionsComponent implements OnInit { private decisionService: ApplicationDecisionV2Service, private conditionService: ApplicationDecisionConditionService, private activatedRouter: ActivatedRoute, + private dialog: MatDialog, ) { this.today = moment().startOf('day').toDate().getTime(); } @@ -187,4 +190,27 @@ export class ConditionsComponent implements OnInit { }), ); } + + openConditionCardDialog(): void { + const dialogRef = this.dialog.open(ConditionCardDialogComponent, { + minWidth: '800px', + maxWidth: '1100px', + maxHeight: '80vh', + data: { + conditions: this.decision.conditions.map((condition, index) => ({ + condition: condition, + index: index, + })), + decision: this.decision.uuid, + }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + if (result.action === 'save' && result.result === true) { + this.loadDecisions(this.fileNumber); + } + } + }); + } } diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-documents/decision-documents.component.ts b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-documents/decision-documents.component.ts index 1435b8b0ef..7f8a710ab1 100644 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-documents/decision-documents.component.ts +++ b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-documents/decision-documents.component.ts @@ -8,7 +8,7 @@ import { ApplicationDecisionV2Service } from '../../../../../services/applicatio import { ApplicationDecisionDocumentDto } from '../../../../../services/application/decision/application-decision-v2/application-decision.dto'; 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'; +import { DocumentUploadDialogComponent } from '../../../../../shared/document-upload-dialog/document-upload-dialog.component'; import { FILE_NAME_TRUNCATE_LENGTH } from '../../../../../shared/constants'; @Component({ @@ -102,7 +102,7 @@ export class DecisionDocumentsComponent implements OnInit, OnDestroy { private openFileDialog(existingDocument?: ApplicationDecisionDocumentDto) { if (this.decision) { this.dialog - .open(DecisionDocumentUploadDialogComponent, { + .open(DocumentUploadDialogComponent, { minWidth: '600px', maxWidth: '800px', width: '70%', @@ -110,9 +110,12 @@ export class DecisionDocumentsComponent implements OnInit, OnDestroy { fileId: this.fileId, decisionUuid: this.decision?.uuid, existingDocument: existingDocument, + decisionService: this.decisionService, + allowedVisibilityFlags: ['A', 'C', 'G', 'P'], + allowsFileEdit: true, }, }) - .beforeClosed() + .afterClosed() .subscribe((isDirty: boolean) => { if (isDirty && this.decision) { this.decisionService.loadDecision(this.decision.uuid); diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html deleted file mode 100644 index 7ea010a0d0..0000000000 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html +++ /dev/null @@ -1,111 +0,0 @@ -
-

{{ title }} Document

-
-
-
-
-
- Document Upload* -
- -
- -
or drag and drop them here
- -
-
-
- {{ pendingFile.name }} -  ({{ pendingFile.size | filesize }}) -
- -
-
- - -
- - warning A virus was detected in the file. Choose another file and try again. - -
- -
- - Document Name - - {{ extension }} - -
- -
- - -
-
- - Source - - {{ source }} - - -
-
- Visible To: -
- Applicant, L/FNG, and Commissioner -
-
- Public -
-
-
- - -
- - - -
-
-
diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss deleted file mode 100644 index 7c95496402..0000000000 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss +++ /dev/null @@ -1,82 +0,0 @@ -@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; - } -} - -.file-drag-drop { - background: colors.$white; - border-radius: 4px; - - &:hover { - background: colors.$grey-light !important; - } - - button:nth-child(1) { - width: 100%; - background: colors.$white; - padding: 24px; - border: none; - - &:hover { - background: colors.$grey-light !important; - } - } - - .drag-text { - margin-top: 14px; - color: colors.$grey; - } - - .icon { - color: colors.$grey; - font-size: 36px; - height: 36px; - align-content: center; - margin-bottom: 4px; - } -} \ No newline at end of file diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts deleted file mode 100644 index 759e0e8534..0000000000 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 { ApplicationDecisionV2Service } from '../../../../../../services/application/decision/application-decision-v2/application-decision-v2.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 mockAppDecService: DeepMocked; - - beforeEach(async () => { - mockAppDecService = createMock(); - - const mockDialogRef = { - close: jest.fn(), - afterClosed: jest.fn(), - subscribe: jest.fn(), - backdropClick: () => new EventEmitter(), - }; - - await TestBed.configureTestingModule({ - declarations: [DecisionDocumentUploadDialogComponent], - providers: [ - { - provide: ApplicationDecisionV2Service, - useValue: mockAppDecService, - }, - { 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/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts deleted file mode 100644 index 651bb281c4..0000000000 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { ApplicationDecisionDocumentDto } from '../../../../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; -import { ApplicationDecisionV2Service } from '../../../../../../services/application/decision/application-decision-v2/application-decision-v2.service'; -import { ToastService } from '../../../../../../services/toast/toast.service'; -import { DOCUMENT_SOURCE } from '../../../../../../shared/document/document.dto'; -import { FileHandle } from '../../../../../../shared/drag-drop-file/drag-drop-file.directive'; -import { splitExtension } from '../../../../../../shared/utils/file'; - -@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'; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - 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]); - - visibleToInternal = new FormControl({ disabled: true, value: true }, [Validators.required]); - visibleToPublic = 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, - visibleToInternal: this.visibleToInternal, - visibleToPublic: this.visibleToPublic, - }); - - pendingFile: File | undefined; - existingFile: string | undefined; - showVirusError = false; - extension = ''; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { fileId: string; decisionUuid: string; existingDocument?: ApplicationDecisionDocumentDto }, - protected dialog: MatDialogRef, - private decisionService: ApplicationDecisionV2Service, - private toastService: ToastService, - ) {} - - ngOnInit(): void { - if (this.data.existingDocument) { - const document = this.data.existingDocument; - this.title = 'Edit'; - - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - this.form.patchValue({ - name: fileName, - }); - this.existingFile = document.fileName; - } - } - - async onSubmit() { - const file = this.pendingFile; - if (file) { - const renamedFile = new File([file], this.name.value + this.extension ?? file.name, { type: file.type }) - 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.extension, - ); - - 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]; - const { fileName, extension } = splitExtension(selectedFiles[0].name); - this.name.setValue(fileName); - this.extension = extension; - this.showVirusError = false; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - 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, - ); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } -} diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.html b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.html index 03c8a7f60b..4012eb1cfa 100644 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.html +++ b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.html @@ -83,11 +83,28 @@
Res #{{ decision.resolutionNumber }}/{{ decision.resolutionYear }}
+ +
+ +
+ - +
Res #{{ decision.resolutionNumber }}/{{ decision.resolutionYear }}
-

Resolution

-
Decision Date
+ Decision Date:  {{ decision.date | momentFormat }}
-
-
Decision Maker
+
+ Decision Maker:  {{ decision.decisionMaker?.label }}
- -
-
Decision Outcome
- {{ decision.outcome.label }} {{ decision.isSubjectToConditions ? '- Subject to Conditions' : '' }} -
- +
+
-
CEO Criterion
- {{ decision.ceoCriterion?.label }} + CEO Criterion:  + {{ decision.ceoCriterion.label }} - Time extension , Other
- +
+
+
+ Rescinded Date:  + {{ decision.rescindedDate | momentFormat }} + +
+
+
+
+ Rescinded Comment:  + {{ decision.rescindedComment }} + +
+
+
-
Decision Description
{{ decision.decisionDescription }}
- -
-
Rescinded Date
- {{ decision.rescindedDate | momentFormat }} - +
+ +
+ {{ component.applicationDecisionComponentType?.label }} + {{ getDate(component.uuid) }} + Conditions: + + + + + None +
- -
-
Rescinded Comment
- {{ decision.rescindedComment }} - + + +
+ +
+
+
+
+
+ {{ document.fileName }} +  ({{ document.fileSize ? (document.fileSize | filesize) : '' }}) +
+
-
-

Documents

-
- -
+ +
+
+
+ {{ decision.flaggedBy?.prettyName }} +
+
Follow-Up Date: {{ formatDate(decision.followUpAt) || 'No Data' }}
+
- - -

Components

-
- +
+ Flagged for condition follow-up because: {{ decision.reasonFlagged }} +
- -
{{ component.applicationDecisionComponentType?.label }}
-
- - +
+ {{ formatDate(decision.flagEditedAt, true) }} (Last Edited by {{ decision.flagEditedBy?.prettyName }}) +
+ +
+
+
+
+ + + +

Components

+
+ + + +
{{ component.applicationDecisionComponentType?.label }}
+
+ - + > + + - - + > + - - + > + - - + > + - - + > + - - + > + - - + > + - - + > + -
-
-
Agricultural Capability
- {{ component.agCap }} - -
-
-
Agricultural Capability Source
- {{ component.agCapSource }} - -
+
+
+
Agricultural Capability
+ {{ component.agCap }} + +
+
+
Agricultural Capability Source
+ {{ component.agCapSource }} + +
-
-
Agricultural Capability Mapsheet Reference
- {{ component.agCapMap }} - -
-
-
Agricultural Capability Consultant
- {{ component.agCapConsultant }} - +
+
Agricultural Capability Mapsheet Reference
+ {{ component.agCapMap }} + +
+
+
Agricultural Capability Consultant
+ {{ component.agCapConsultant }} + +
-
-
- -
-
+
+
+ +
- -

Conditions

-
-
- View Conditions - open_in_new - +

Audit and Chair Review

+
+
+
+
Audit Date
+ {{ decision.auditDate | momentFormat }} + + + +
+
+
Chair Review
+ {{ decision.chairReviewRequired ? 'Required' : 'Not Needed' }} +
+
+
Chair Review Date
+ + + + {{ decision.chairReviewDate | momentFormat }} +
+
+
Chair Review Outcome
+ +
- -

Audit and Chair Review

-
-
-
-
Audit Date
- {{ decision.auditDate | momentFormat }} - - - -
-
-
Chair Review
- {{ decision.chairReviewRequired ? 'Required' : 'Not Needed' }} -
-
-
Chair Review Date
- - - - {{ decision.chairReviewDate | momentFormat }} -
-
-
Chair Review Outcome
- -
-
-
- -
- +
+ +
diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.scss b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.scss index 9c66ba4c47..ffe555c27b 100644 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.scss +++ b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.scss @@ -1,10 +1,11 @@ @use '../../../../../styles/colors'; section { - margin-bottom: 64px; + margin-bottom: 15px; } h4 { + padding-top: 15px; margin-bottom: 12px !important; } @@ -32,7 +33,6 @@ hr { .decision-section { background: colors.$grey-light; - padding: 18px; } .decision-section-no-title { @@ -40,9 +40,24 @@ hr { padding: 1px 18px; } +.component-summary { + padding: 10px 0; +} + +.component-title { + padding-top: 20px; +} + +.status-pill { + padding: 6px 6px; +} + .header { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: auto auto auto; + grid-template-rows: auto auto; + row-gap: 10px; + column-gap: 28px; margin-bottom: 36px; .title { @@ -50,6 +65,8 @@ hr { align-items: center; justify-content: space-between; gap: 28px; + grid-row: 1/2; + grid-column: 1/2; .days { display: inline-block; @@ -66,6 +83,16 @@ hr { } } +.edit-decision-button, +.revert-to-draft-button { + grid-row: 1/2; + grid-column: 3/4; +} + +.revert-to-draft-button { + text-align: right; +} + .loading-overlay { position: absolute; z-index: 2; @@ -115,10 +142,23 @@ hr { position: absolute; } +.document { + padding: 9px 16px; + border-radius: 4px; + border: 1px solid colors.$grey; + background: colors.$white; + margin: 8px 0; +} + +.align-right { + display: flex; + justify-content: flex-end; +} + :host ::ng-deep { .grid-2 { margin-top: 18px; - margin-bottom: 18px; + margin-bottom: 6px; display: grid; grid-template-columns: 50% 50%; grid-row-gap: 18px; @@ -157,4 +197,90 @@ hr { .pre-wrapped-text { white-space: pre-wrap; } + + .mat-link { + color: colors.$link-color; + } +} + +.flag-button-container { + justify-self: end; + grid-row: 2/3; + grid-column: 1/4; + + @media screen and (min-width: 1440px) { + grid-row: 1/2; + grid-column: 2/3; + } +} + +.flag-button { + display: flex; + align-items: center; + column-gap: 5px; + + text-align: left; + font-weight: normal; + text-wrap: nowrap; + + background-color: transparent; + padding: 5px; + border: none; + border-radius: 5px; + margin: 0; + + &:hover { + background-color: colors.$grey-light; + } + + mat-icon { + flex-shrink: 0; + } + + &.flagged { + mat-icon { + color: blue; + } + } +} + +.flag-details { + background-color: white; + display: flex; + flex-direction: column; + gap: 16px; + + padding: 16px; + border: 1px solid colors.$grey; + border-radius: 4px; +} + +.flag-details-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.flag-details-flagger { + display: flex; + gap: 5px; + + mat-icon { + color: blue; + } +} + +.flag-details-body { + line-height: 1.5; +} + +.flag-details-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.flag-details-edited-details { + font-size: 12px; + color: colors.$grey; } diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.spec.ts b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.spec.ts index a1b490e13f..8b28e9a38b 100644 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.spec.ts +++ b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.spec.ts @@ -16,8 +16,8 @@ import { import { ApplicationDecisionV2Service } from '../../../../services/application/decision/application-decision-v2/application-decision-v2.service'; import { ToastService } from '../../../../services/toast/toast.service'; import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; - import { DecisionV2Component } from './decision-v2.component'; +import { HttpClient } from '@angular/common/http'; describe('DecisionV2Component', () => { let component: DecisionV2Component; @@ -25,6 +25,7 @@ describe('DecisionV2Component', () => { let mockApplicationDecisionService: DeepMocked; let mockAppDetailService: DeepMocked; let mockApplicationDecisionComponentService: DeepMocked; + let mockHttpClient: DeepMocked; beforeEach(async () => { mockApplicationDecisionService = createMock(); @@ -36,6 +37,8 @@ describe('DecisionV2Component', () => { mockApplicationDecisionComponentService = createMock(); + mockHttpClient = createMock(); + await TestBed.configureTestingModule({ imports: [MatSnackBarModule, MatMenuModule], declarations: [DecisionV2Component], @@ -72,6 +75,10 @@ describe('DecisionV2Component', () => { provide: ActivatedRoute, useValue: {}, }, + { + provide: HttpClient, + useValue: mockHttpClient, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.ts b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.ts index e9409657f4..6d0d165f1e 100644 --- a/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.ts +++ b/alcs-frontend/src/app/features/application/decision/decision-v2/decision-v2.component.ts @@ -19,10 +19,22 @@ import { MODIFICATION_TYPE_LABEL, RECON_TYPE_LABEL, RELEASED_DECISION_TYPE_LABEL, + DECISION_CONDITION_COMPLETE_LABEL, + DECISION_CONDITION_ONGOING_LABEL, + DECISION_CONDITION_PASTDUE_LABEL, + DECISION_CONDITION_PENDING_LABEL, + DECISION_CONDITION_EXPIRED_LABEL, } from '../../../../shared/application-type-pill/application-type-pill.constants'; import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; import { formatDateForApi } from '../../../../shared/utils/api-date-formatter'; import { RevertToDraftDialogComponent } from './revert-to-draft-dialog/revert-to-draft-dialog.component'; +import { ApplicationConditionWithStatus, getEndDate } from '../../../../shared/utils/decision-methods'; +import { UserService } from '../../../../services/user/user.service'; +import { UserDto } from '../../../../services/user/user.dto'; +import { FlagDialogComponent, FlagDialogIO } from '../../../../shared/flag-dialog/flag-dialog.component'; +import { UpdateApplicationDecisionDto } from '../../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import moment from 'moment'; +import { UnFlagDialogComponent, UnFlagDialogIO } from '../../../../shared/unflag-dialog/unflag-dialog.component'; type LoadingDecision = ApplicationDecisionWithLinkedResolutionDto & { loading: boolean; @@ -56,6 +68,11 @@ export class DecisionV2Component implements OnInit, OnDestroy { COMPONENT_TYPE = APPLICATION_DECISION_COMPONENT_TYPE; + isSummary = false; + + conditions: Record = {}; + profile: UserDto | undefined; + constructor( public dialog: MatDialog, private applicationDetailService: ApplicationDetailService, @@ -66,6 +83,7 @@ export class DecisionV2Component implements OnInit, OnDestroy { private router: Router, private activatedRouter: ActivatedRoute, private elementRef: ElementRef, + private userService: UserService, ) {} ngOnInit(): void { @@ -78,6 +96,10 @@ export class DecisionV2Component implements OnInit, OnDestroy { this.application = application; } }); + + this.userService.$userProfile.pipe(takeUntil(this.$destroy)).subscribe((profile) => { + this.profile = profile; + }); } async loadDecisions(fileNumber: string) { @@ -102,7 +124,26 @@ export class DecisionV2Component implements OnInit, OnDestroy { if (duplicateComponentCodes.length > 0) { disabledMessage = 'Editing disabled - contact admin'; } - + decision.conditions.map(async (x) => { + if (x.components) { + const componentId = x.components[0].uuid; + if (componentId) { + const conditionStatus = await this.decisionService.getStatus(x.uuid); + + if (this.conditions[componentId]) { + this.conditions[componentId].push({ + ...x, + conditionStatus: conditionStatus, + }); + } else { + this.conditions[componentId] = [{ + ...x, + conditionStatus: conditionStatus, + }]; + } + } + } + }); return { ...decision, loading: false, @@ -260,4 +301,114 @@ export class DecisionV2Component implements OnInit, OnDestroy { }); } } + + toggleSummary() { + this.isSummary = !this.isSummary; + } + + getConditions(uuid: string | undefined) { + return uuid && this.conditions[uuid] ? [...new Set(this.conditions[uuid].map((x) => this.getPillLabel(x.conditionStatus.status)))] : []; + } + + getDate(uuid: string | undefined) { + return getEndDate(uuid, this.conditions); + } + + async openFile(decisionUuid: string, fileUuid: string, fileName: string) { + await this.decisionService.downloadFile(decisionUuid, fileUuid, fileName); + } + + private getPillLabel(status: string) { + switch (status) { + case 'ONGOING': + return DECISION_CONDITION_ONGOING_LABEL; + case 'COMPLETED': + return DECISION_CONDITION_COMPLETE_LABEL; + case 'PASTDUE': + return DECISION_CONDITION_PASTDUE_LABEL; + case 'PENDING': + return DECISION_CONDITION_PENDING_LABEL; + case 'EXPIRED': + return DECISION_CONDITION_EXPIRED_LABEL; + default: + return DECISION_CONDITION_ONGOING_LABEL; + } + } + + async flag(decision: ApplicationDecisionWithLinkedResolutionDto, isEditing: boolean) { + this.dialog + .open(FlagDialogComponent, { + minWidth: '800px', + maxWidth: '800px', + maxHeight: '80vh', + width: '90%', + autoFocus: false, + data: { + isEditing, + decisionNumber: decision.index, + reasonFlagged: decision.reasonFlagged, + followUpAt: decision.followUpAt, + }, + }) + .beforeClosed() + .subscribe(async ({ isEditing, reasonFlagged, followUpAt, isSaving }: FlagDialogIO) => { + if (isSaving) { + const updateDto: UpdateApplicationDecisionDto = { + isDraft: decision.isDraft, + isFlagged: true, + reasonFlagged, + flagEditedByUuid: this.profile?.uuid, + flagEditedAt: moment().toDate().getTime(), + }; + + if (!isEditing) { + updateDto.flaggedByUuid = this.profile?.uuid; + } + + if (followUpAt !== undefined) { + updateDto.followUpAt = followUpAt; + } + + await this.decisionService.update(decision.uuid, updateDto); + await this.loadDecisions(this.fileNumber); + } + }); + } + + async unflag(decision: ApplicationDecisionWithLinkedResolutionDto) { + this.dialog + .open(UnFlagDialogComponent, { + minWidth: '800px', + maxWidth: '800px', + maxHeight: '80vh', + width: '90%', + autoFocus: false, + data: { + decisionNumber: decision.index, + }, + }) + .beforeClosed() + .subscribe(async ({ confirmed }: UnFlagDialogIO) => { + if (confirmed) { + await this.decisionService.update(decision.uuid, { + isDraft: decision.isDraft, + isFlagged: false, + reasonFlagged: null, + followUpAt: null, + flaggedByUuid: null, + flagEditedByUuid: null, + flagEditedAt: null, + }); + await this.loadDecisions(this.fileNumber); + } + }); + } + + formatDate(timestamp?: number | null, includeTime = false): string { + if (timestamp === undefined || timestamp === null) { + return ''; + } + + return moment(new Date(timestamp)).format(`YYYY-MMM-DD ${includeTime ? 'hh:mm:ss A' : ''}`); + } } diff --git a/alcs-frontend/src/app/features/application/decision/decision.module.ts b/alcs-frontend/src/app/features/application/decision/decision.module.ts index 9ae5697437..4b3fd69221 100644 --- a/alcs-frontend/src/app/features/application/decision/decision.module.ts +++ b/alcs-frontend/src/app/features/application/decision/decision.module.ts @@ -28,13 +28,13 @@ import { SubdInputComponent } from './decision-v2/decision-input/decision-compon import { DecisionComponentsComponent } from './decision-v2/decision-input/decision-components/decision-components.component'; import { DecisionConditionComponent } from './decision-v2/decision-input/decision-conditions/decision-condition/decision-condition.component'; import { DecisionConditionsComponent } from './decision-v2/decision-input/decision-conditions/decision-conditions.component'; -import { DecisionDocumentUploadDialogComponent } from './decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component'; import { DecisionInputV2Component } from './decision-v2/decision-input/decision-input-v2.component'; import { DecisionV2Component } from './decision-v2/decision-v2.component'; import { ReleaseDialogComponent } from './decision-v2/release-dialog/release-dialog.component'; import { RevertToDraftDialogComponent } from './decision-v2/revert-to-draft-dialog/revert-to-draft-dialog.component'; import { DecisionComponent } from './decision.component'; import { DecisionConditionDateDialogComponent } from './decision-v2/decision-input/decision-conditions/decision-condition/decision-condition-date-dialog/decision-condition-date-dialog.component'; +import { ConditionCardDialogComponent } from './conditions/condition-card-dialog/condition-card-dialog.component'; export const decisionChildRoutes = [ { @@ -72,7 +72,6 @@ export const decisionChildRoutes = [ DecisionConditionDateDialogComponent, DecisionComponentComponent, DecisionComponentsComponent, - DecisionDocumentUploadDialogComponent, RevertToDraftDialogComponent, DecisionDocumentsComponent, NfuInputComponent, @@ -96,6 +95,7 @@ export const decisionChildRoutes = [ ConditionsComponent, ConditionComponent, BasicComponent, + ConditionCardDialogComponent, ], imports: [SharedModule, RouterModule.forChild(decisionChildRoutes), MatTabsModule, MatOptionModule, MatChipsModule], }) diff --git a/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.scss b/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.scss deleted file mode 100644 index ba93743c38..0000000000 --- a/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.scss +++ /dev/null @@ -1,88 +0,0 @@ -@use '../../../../../styles/colors'; - -.form { - display: grid; - grid-template-columns: 1fr 1fr; - row-gap: 32px; - column-gap: 32px; - - .double { - grid-column: 1/3; - } -} - -.full-width { - width: 100%; -} - -a { - word-break: break-all; -} - -.file { - border: 1px solid #000; - border-radius: 8px; - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; -} - -.upload-button { - margin-top: 6px !important; - - &.error { - border: 2px solid colors.$error-color; - } -} - -.spinner { - display: inline-block; - margin-right: 4px; -} - -:host::ng-deep { - .mdc-button__label { - display: flex; - align-items: center; - } -} - -.superseded-warning { - background-color: colors.$secondary-color-dark; - color: #fff; - padding: 0 4px; -} - -.file-drag-drop { - background: colors.$white; - border-radius: 4px; - - &:hover { - background: colors.$grey-light !important; - } - - button:nth-child(1) { - width: 100%; - background: colors.$white; - padding: 24px; - border: none; - - &:hover { - background: colors.$grey-light !important; - } - } - - .drag-text { - margin-top: 14px; - color: colors.$grey; - } - - .icon { - color: colors.$grey; - font-size: 36px; - height: 36px; - align-content: center; - margin-bottom: 4px; - } -} \ No newline at end of file diff --git a/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.ts deleted file mode 100644 index 793a43da4a..0000000000 --- a/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Subject } from 'rxjs'; -import { - ApplicationDocumentDto, - UpdateDocumentDto, -} from '../../../../services/application/application-document/application-document.dto'; -import { ApplicationDocumentService } from '../../../../services/application/application-document/application-document.service'; -import { ApplicationParcelService } from '../../../../services/application/application-parcel/application-parcel.service'; -import { ApplicationSubmissionService } from '../../../../services/application/application-submission/application-submission.service'; -import { ToastService } from '../../../../services/toast/toast.service'; -import { - DOCUMENT_SOURCE, - DOCUMENT_SYSTEM, - DOCUMENT_TYPE, - DocumentTypeDto, -} from '../../../../shared/document/document.dto'; -import { splitExtension } from '../../../../shared/utils/file'; -import { FileHandle } from '../../../../shared/drag-drop-file/drag-drop-file.directive'; - -@Component({ - selector: 'app-document-upload-dialog', - templateUrl: './document-upload-dialog.component.html', - styleUrls: ['./document-upload-dialog.component.scss'], -}) -export class DocumentUploadDialogComponent implements OnInit, OnDestroy { - $destroy = new Subject(); - DOCUMENT_TYPE = DOCUMENT_TYPE; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - title = 'Create'; - isDirty = false; - isSaving = false; - allowsFileEdit = true; - documentTypeAhead: string | undefined = undefined; - - name = new FormControl('', [Validators.required]); - type = new FormControl(undefined, [Validators.required]); - source = new FormControl('', [Validators.required]); - - parcelId = new FormControl(null); - ownerId = new FormControl(null); - - visibleToInternal = new FormControl(false, [Validators.required]); - visibleToPublic = new FormControl(false, [Validators.required]); - - documentTypes: DocumentTypeDto[] = []; - documentSources = Object.values(DOCUMENT_SOURCE); - selectableParcels: { uuid: string; index: number; pid?: string }[] = []; - selectableOwners: { uuid: string; label: string }[] = []; - - form = new FormGroup({ - name: this.name, - type: this.type, - source: this.source, - visibleToInternal: this.visibleToInternal, - visibleToPublic: this.visibleToPublic, - parcelId: this.parcelId, - ownerId: this.ownerId, - }); - - pendingFile: File | undefined; - existingFile: { name: string; size: number } | undefined; - showSupersededWarning = false; - showVirusError = false; - extension = ''; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { fileId: string; existingDocument?: ApplicationDocumentDto }, - protected dialog: MatDialogRef, - private applicationDocumentService: ApplicationDocumentService, - private parcelService: ApplicationParcelService, - private submissionService: ApplicationSubmissionService, - private toastService: ToastService, - ) {} - - ngOnInit(): void { - this.loadDocumentTypes(); - - if (this.data.existingDocument) { - const document = this.data.existingDocument; - this.title = 'Edit'; - this.allowsFileEdit = document.system === DOCUMENT_SYSTEM.ALCS; - if (document.type?.code === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE) { - this.prepareCertificateOfTitleUpload(document.uuid); - this.allowsFileEdit = false; - } - if (document.type?.code === DOCUMENT_TYPE.CORPORATE_SUMMARY) { - this.allowsFileEdit = false; - this.prepareCorporateSummaryUpload(document.uuid); - } - - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - this.form.patchValue({ - name: fileName, - type: document.type?.code, - source: document.source, - visibleToInternal: document.visibilityFlags.includes('C') || document.visibilityFlags.includes('A'), - visibleToPublic: document.visibilityFlags.includes('P'), - }); - this.documentTypeAhead = document.type!.code; - this.existingFile = { - name: document.fileName, - size: 0, - }; - } - } - - async onSubmit() { - const visibilityFlags: ('A' | 'C' | 'G' | 'P')[] = []; - - if (this.visibleToInternal.getRawValue()) { - visibilityFlags.push('A'); - visibilityFlags.push('G'); - visibilityFlags.push('C'); - } - - if (this.visibleToPublic.getRawValue()) { - visibilityFlags.push('P'); - } - - const file = this.pendingFile; - const dto: UpdateDocumentDto = { - fileName: this.name.value! + this.extension, - source: this.source.value as DOCUMENT_SOURCE, - typeCode: this.type.value as DOCUMENT_TYPE, - visibilityFlags, - parcelUuid: this.parcelId.value ?? undefined, - ownerUuid: this.ownerId.value ?? undefined, - file, - }; - - this.isSaving = true; - if (this.data.existingDocument) { - await this.applicationDocumentService.update(this.data.existingDocument.uuid, dto); - } else if (file !== undefined) { - try { - await this.applicationDocumentService.upload(this.data.fileId, { - ...dto, - file, - }); - } catch (err) { - this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - this.showVirusError = true; - this.isSaving = false; - this.pendingFile = undefined; - return; - } - } - this.showVirusError = false; - } - - this.dialog.close(true); - this.isSaving = false; - } - - ngOnDestroy(): void { - this.$destroy.next(); - this.$destroy.complete(); - } - - filterDocumentTypes(term: string, item: DocumentTypeDto) { - const termLower = term.toLocaleLowerCase(); - return ( - item.label.toLocaleLowerCase().indexOf(termLower) > -1 || - item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 - ); - } - - async prepareCertificateOfTitleUpload(uuid?: string) { - const parcels = await this.parcelService.fetchParcels(this.data.fileId); - if (parcels.length > 0) { - this.parcelId.setValidators([Validators.required]); - this.parcelId.updateValueAndValidity(); - this.source.setValue(DOCUMENT_SOURCE.APPLICANT); - - const selectedParcel = parcels.find((parcel) => parcel.certificateOfTitleUuid === uuid); - if (selectedParcel) { - this.parcelId.setValue(selectedParcel.uuid); - } else if (uuid) { - this.showSupersededWarning = true; - } - - this.selectableParcels = parcels.map((parcel, index) => ({ - uuid: parcel.uuid, - pid: parcel.pid, - index: index, - })); - } - } - - async prepareCorporateSummaryUpload(uuid?: string) { - const submission = await this.submissionService.fetchSubmission(this.data.fileId); - if (submission.owners.length > 0) { - const owners = submission.owners; - this.ownerId.setValidators([Validators.required]); - this.ownerId.updateValueAndValidity(); - this.source.setValue(DOCUMENT_SOURCE.APPLICANT); - - const selectedOwner = owners.find((owner) => owner.corporateSummaryUuid === uuid); - if (selectedOwner) { - this.ownerId.setValue(selectedOwner.uuid); - } else if (uuid) { - this.showSupersededWarning = true; - } - - this.selectableOwners = owners - .filter((owner) => owner.type.code === 'ORGZ') - .map((owner, index) => ({ - label: owner.organizationName ?? owner.displayName, - uuid: owner.uuid, - })); - } - } - - async onDocTypeSelected($event?: DocumentTypeDto) { - if ($event) { - this.type.setValue($event.code); - } else { - this.type.setValue(undefined); - } - - if (this.type.value === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE) { - await this.prepareCertificateOfTitleUpload(); - this.visibleToInternal.setValue(true); - } else { - this.parcelId.setValue(null); - this.parcelId.setValidators([]); - this.parcelId.updateValueAndValidity(); - } - - if (this.type.value === DOCUMENT_TYPE.CORPORATE_SUMMARY) { - await this.prepareCorporateSummaryUpload(); - this.visibleToInternal.setValue(true); - } else { - this.ownerId.setValue(null); - this.ownerId.setValidators([]); - this.ownerId.updateValueAndValidity(); - } - } - - uploadFile(event: Event) { - const element = event.target as HTMLInputElement; - const selectedFiles = element.files; - if (selectedFiles && selectedFiles[0]) { - this.pendingFile = selectedFiles[0]; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - openFile() { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile); - window.open(fileURL, '_blank'); - } - } - - async openExistingFile() { - if (this.data.existingDocument) { - await this.applicationDocumentService.download( - this.data.existingDocument.uuid, - this.data.existingDocument.fileName, - ); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } - - private async loadDocumentTypes() { - const docTypes = await this.applicationDocumentService.fetchTypes(); - docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); - this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); - } -} diff --git a/alcs-frontend/src/app/features/application/documents/documents.component.html b/alcs-frontend/src/app/features/application/documents/documents.component.html index ac28b6b961..62e246e24e 100644 --- a/alcs-frontend/src/app/features/application/documents/documents.component.html +++ b/alcs-frontend/src/app/features/application/documents/documents.component.html @@ -17,7 +17,7 @@

Documents

(click)="openFile(element.uuid, element.fileName)" [matTooltip]="element.fileName" [matTooltipDisabled]="element.fileName.length <= fileNameTruncLen"> - {{ element.fileName | truncate: fileNameTruncLen}} + {{ element.fileName }} @@ -68,14 +68,14 @@

Documents

Actions - - + +
+
+

+ {{ cardTitle }} +

+
+
+ +
+
+
+ + + + + + +
+
+
+
+ {{ application.localGovernment?.name }} - {{ application.region?.label }} Region +
+
+
+ + + + + + +
+
+ + +
+ + + + + + + + + + +
+

{{ item.prettyName }}

+

{{ item.email }}

+
+
+
+
+
+
+ Decision #{{ applicationDecisionConditionCard.decisionOrder }} - Conditions to Review +
+ + + + + + + +
+
+
+ +
No Conditions
+
+ + + + + + + + + + + + + + + + + + + +
+ + + {{ element.index }}. {{ element.condition.type.label }} + + + + + Due Date: + + + End Date: + + + + Due Date: + + + {{ getDate(element.condition) }} + + + No Data + + + + +
+
+
+
+
+ +
+
+ +
+
diff --git a/alcs-frontend/src/app/features/board/dialogs/application-decision-condition-dialog/application-decision-condition-dialog.component.scss b/alcs-frontend/src/app/features/board/dialogs/application-decision-condition-dialog/application-decision-condition-dialog.component.scss new file mode 100644 index 0000000000..5f723f5488 --- /dev/null +++ b/alcs-frontend/src/app/features/board/dialogs/application-decision-condition-dialog/application-decision-condition-dialog.component.scss @@ -0,0 +1,128 @@ +@use '../../../../../styles/colors.scss'; + +.column-select { + width: 10%; +} + +.column-condition { + width: 40%; +} + +.column-date { + width: 35%; +} + +.column-status { + width: 15%; +} + +.conditions-table { + width: 100%; +} + +.disabled-row { + background-color: #f0f0f0; + cursor: default; + opacity: 0.6; +} + +.conditions-table-container { + max-height: 300px; + overflow-y: auto; + margin-top: 8px; + border: 2px solid #929292; + border-radius: 4px; + + ::ng-deep .mdc-checkbox { + --mdc-checkbox-selected-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-focus-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-focus-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-hover-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-pressed-icon-color: #{colors.$primary-color}; + } +} + +.error-state { + border: 2px solid colors.$error-color !important; + + ::ng-deep .mdc-checkbox { + --mdc-checkbox-unselected-icon-color: #{colors.$error-color}; + --mdc-checkbox-unselected-focus-icon-color: #{colors.$error-color}; + --mdc-checkbox-unselected-hover-icon-color: #{colors.$error-color}; + --mdc-checkbox-unselected-pressed-icon-color: #{colors.$error-color}; + } +} + +.date-label { + font-weight: 700; + color: #313132; +} + +.conditions-container { + display: flex; + flex-direction: column; + margin-top: 20px; +} + +.conditions-header-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.buttons-container { + width: 30%; + display: flex; + flex-direction: row; + gap: 20px; + justify-content: flex-end; +} + +.conditions-header { + font-weight: 700; + color: #565656; + flex-grow: 1; +} + +.edit-button { + position: relative; + height: 32px; + width: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.edit-icon { + position: absolute; + top: 50%; + left: 50%; + font-size: 24px; + transform: translate(-50%, -50%); +} + +.pill-row { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 1px; +} + +.no-data { + color: colors.$grey; + font-weight: 400; +} + +.no-conditions { + border: none; + background-color: colors.$grey-light; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + width: 100%; +} + +.flag-icon { + color: blue; +} diff --git a/alcs-frontend/src/app/features/board/dialogs/application-decision-condition-dialog/application-decision-condition-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/application-decision-condition-dialog/application-decision-condition-dialog.component.ts new file mode 100644 index 0000000000..4da8884adb --- /dev/null +++ b/alcs-frontend/src/app/features/board/dialogs/application-decision-condition-dialog/application-decision-condition-dialog.component.ts @@ -0,0 +1,262 @@ +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { CardDialogComponent } from '../card-dialog/card-dialog.component'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + ApplicationDecisionConditionCardBoardDto, + ApplicationDecisionConditionDateDto, + ApplicationDecisionConditionDto, + ApplicationDecisionDto, + UpdateApplicationDecisionConditionCardDto, +} from '../../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { ApplicationDecisionV2Service } from '../../../../services/application/decision/application-decision-v2/application-decision-v2.service'; +import { UserService } from '../../../../services/user/user.service'; +import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { BoardService, BoardWithFavourite } from '../../../../services/board/board.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { CardService } from '../../../../services/card/card.service'; +import { AuthenticationService } from '../../../../services/authentication/authentication.service'; +import { ApplicationDecisionConditionCardService } from '../../../../services/application/decision/application-decision-v2/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { ApplicationDto } from '../../../../services/application/application.dto'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { countToString } from '../../../../shared/utils/count-to-string'; +import { + CONDITION_LABEL, + DECISION_CONDITION_COMPLETE_LABEL, + DECISION_CONDITION_EXPIRED_LABEL, + DECISION_CONDITION_ONGOING_LABEL, + DECISION_CONDITION_PASTDUE_LABEL, + DECISION_CONDITION_PENDING_LABEL, + MODIFICATION_TYPE_LABEL, + RECON_TYPE_LABEL, +} from '../../../../shared/application-type-pill/application-type-pill.constants'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-application-decision-condition-dialog', + templateUrl: './application-decision-condition-dialog.component.html', + styleUrls: ['../card-dialog/card-dialog.component.scss', './application-decision-condition-dialog.component.scss'], +}) +export class ApplicationDecisionConditionDialogComponent extends CardDialogComponent implements OnInit { + cardTitle = ''; + application: ApplicationDto = this.data.application; + decision: ApplicationDecisionDto | undefined; + applicationDecisionConditionCard: ApplicationDecisionConditionCardBoardDto = this.data.decisionConditionCard; + isModification: boolean = false; + isReconsideration: boolean = false; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource<{ condition: ApplicationDecisionConditionDto; index: number; selected: boolean }> = + new MatTableDataSource(); + + editColumns: string[] = ['select', 'condition', 'date', 'status']; + defaultColumns: string[] = ['condition', 'date', 'status']; + + displayColumns: string[] = this.defaultColumns; + + isEditing: boolean = false; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { + decisionConditionCard: ApplicationDecisionConditionCardBoardDto; + application: ApplicationDto; + }, + private applicationDecisionService: ApplicationDecisionV2Service, + private applicationDecisionConditionCardService: ApplicationDecisionConditionCardService, + dialogRef: MatDialogRef, + userService: UserService, + confirmationDialogService: ConfirmationDialogService, + boardService: BoardService, + toastService: ToastService, + cardService: CardService, + authService: AuthenticationService, + private router: Router, + ) { + super(authService, dialogRef, cardService, confirmationDialogService, toastService, userService, boardService); + } + override ngOnInit(): void { + super.ngOnInit(); + this.populateData(); + } + + async populateData() { + await this.loadDecision(); + + this.populateCardData(this.applicationDecisionConditionCard.card); + this.cardTitle = `${this.application.fileNumber} (${this.application.applicant})`; + + this.loadTableData(true); + } + + async loadDecision() { + const decision = await this.applicationDecisionService.getByUuid( + this.applicationDecisionConditionCard.decisionUuid, + true, + ); + if (decision) { + this.decision = decision; + } + } + + loadTableData(filterSelected: boolean = false) { + if (!this.decision) { + return; + } + + const data = this.decision.conditions + .filter((condition) => !filterSelected || this.isConditionSelected(condition)) + .map((condition, index) => ({ + condition, + index: index + 1, + selected: this.isConditionSelected(condition), + })); + + this.dataSource.data = data; + } + + isConditionSelected(condition: ApplicationDecisionConditionDto): boolean { + return condition.conditionCard?.uuid === this.applicationDecisionConditionCard.uuid; + } + + isConditionDisabled(condition: ApplicationDecisionConditionDto): boolean { + if (condition.conditionCard === null) { + return false; + } + return condition.conditionCard?.uuid !== this.applicationDecisionConditionCard.uuid; + } + + getStatusPill(status: string) { + if (status === 'ONGOING') { + return DECISION_CONDITION_ONGOING_LABEL; + } else if (status === 'COMPLETED') { + return DECISION_CONDITION_COMPLETE_LABEL; + } else if (status === 'PASTDUE') { + return DECISION_CONDITION_PASTDUE_LABEL; + } else if (status === 'PENDING') { + return DECISION_CONDITION_PENDING_LABEL; + } else if (status === 'EXPIRED') { + return DECISION_CONDITION_EXPIRED_LABEL; + } else if (status === 'MODIFICATION') { + return MODIFICATION_TYPE_LABEL; + } else if (status === 'RECONSIDERATION') { + return RECON_TYPE_LABEL; + } else if (status === 'CONDITION') { + return CONDITION_LABEL; + } else { + return DECISION_CONDITION_ONGOING_LABEL; + } + } + + formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: '2-digit' }; + const formattedDate = date.toLocaleDateString('en-CA', options).replace(',', ''); + const [month, day, year] = formattedDate.split(' '); + return `${year}-${month}-${day}`; + } + + getDate(condition: ApplicationDecisionConditionDto) { + if (condition.type!.dateType === 'Single') { + if (!condition.dates || condition.dates?.length <= 0) { + return null; + } + return condition.dates[0].date ? this.formatTimestamp(condition.dates[0].date) : null; + } else { + if (condition.dates && condition.dates.length > 0) { + let minDueDate: ApplicationDecisionConditionDateDto | null = null; + let maxDueDate: ApplicationDecisionConditionDateDto | null = null; + let allDatesNull = true; + + for (const date of condition.dates) { + if (date.date !== null) { + allDatesNull = false; + if (!maxDueDate || date.date! > maxDueDate.date!) { + maxDueDate = date; + } + if (!date.completedDate) { + if (!minDueDate || date.date! < minDueDate.date!) { + minDueDate = date; + } + } + } + } + + if (allDatesNull) { + return null; + } + + const selectedDate = minDueDate || maxDueDate; + return selectedDate ? this.formatTimestamp(selectedDate.date!) : null; + } + return null; + } + } + + editClicked() { + this.isEditing = true; + this.displayColumns = this.editColumns; + this.loadTableData(); + } + + onCancel() { + this.isEditing = false; + this.displayColumns = this.defaultColumns; + + this.loadTableData(true); + } + + async onSave() { + const selectedConditions = this.dataSource.data.filter((item) => item.selected).map((item) => item.condition.uuid); + const updateDto: UpdateApplicationDecisionConditionCardDto = { + conditionsUuids: selectedConditions, + }; + + const res = await this.applicationDecisionConditionCardService.update( + this.applicationDecisionConditionCard.uuid, + updateDto, + ); + res + ? this.toastService.showSuccessToast('Condition card updated successfully') + : this.toastService.showErrorToast('Failed to update condition card'); + + this.isEditing = false; + this.displayColumns = this.defaultColumns; + this.isDirty = true; + + await this.loadDecision(); + this.loadTableData(true); + } + + isSaveDisabled(): boolean { + return !this.dataSource.data.some((item) => item.selected); + } + + async onBoardSelected(board: BoardWithFavourite) { + this.selectedBoard = board.code; + try { + await this.boardService.changeBoard(this.applicationDecisionConditionCard.card!.uuid, board.code); + const loadedBoard = await this.boardService.fetchBoardDetail(board.code); + if (loadedBoard) { + this.boardStatuses = loadedBoard.statuses; + } + + this.isDirty = true; + const toast = this.toastService.showSuccessToast(`Application Condition moved to ${board.title}`, 'Go to Board'); + toast.onAction().subscribe(() => { + this.router.navigate(['/board', board.code]); + }); + await this.reloadApplicationCondition(); + } catch (e) { + this.toastService.showErrorToast('Failed to move to new board'); + } + } + + async reloadApplicationCondition() { + const applicationDecisionConditionCard = await this.applicationDecisionConditionCardService.getByCard( + this.applicationDecisionConditionCard.card.uuid, + ); + this.applicationDecisionConditionCard = applicationDecisionConditionCard!; + this.populateData(); + } +} diff --git a/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.html b/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.html index 29fb97720d..ad53175c06 100644 --- a/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.html +++ b/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.html @@ -18,7 +18,7 @@

color="accent" mat-flat-button [mat-dialog-close]="isDirty" - [routerLink]="['application', application.fileNumber]" + [routerLink]="[routerLink]" > View Detail @@ -72,7 +72,7 @@

class="priority" [ngClass]="{ 'filled-priority': card.highPriority, - 'empty-priority': !card.highPriority + 'empty-priority': !card.highPriority, }" > diff --git a/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.ts index 4b9dc74724..382ff334ad 100644 --- a/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.ts +++ b/alcs-frontend/src/app/features/board/dialogs/application/application-dialog.component.ts @@ -5,6 +5,7 @@ import { ApplicationSubmissionToSubmissionStatusDto, DEFAULT_NO_STATUS, } from '../../../../services/application/application-submission-status/application-submission-status.dto'; +import { SUBMISSION_STATUS } from '../../../../services/application/application.dto'; import { ApplicationSubmissionStatusService } from '../../../../services/application/application-submission-status/application-submission-status.service'; import { ApplicationDto } from '../../../../services/application/application.dto'; import { ApplicationService } from '../../../../services/application/application.service'; @@ -30,6 +31,8 @@ export class ApplicationDialogComponent extends CardDialogComponent implements O application: ApplicationDto = this.data; status?: ApplicationSubmissionStatusPill; + routerLink = `application/`; + constructor( @Inject(MAT_DIALOG_DATA) public data: ApplicationDto, private applicationService: ApplicationService, @@ -61,7 +64,7 @@ export class ApplicationDialogComponent extends CardDialogComponent implements O async populateApplicationSubmissionStatus(fileNumber: string) { let submissionStatus: ApplicationSubmissionToSubmissionStatusDto | null = null; - + this.routerLink = this.routerLink + fileNumber; try { submissionStatus = await this.applicationSubmissionStatusService.fetchCurrentStatusByFileNumber( fileNumber, @@ -72,6 +75,9 @@ export class ApplicationDialogComponent extends CardDialogComponent implements O } if (submissionStatus) { + if (submissionStatus.statusTypeCode === SUBMISSION_STATUS.ALC_DECISION) { + this.routerLink = this.routerLink + '/decision' + } this.status = { backgroundColor: submissionStatus.status.alcsBackgroundColor, textColor: submissionStatus.status.alcsColor, diff --git a/alcs-frontend/src/app/features/board/dialogs/card-dialog/card-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/card-dialog/card-dialog.component.ts index 7338251c87..2c0e31f4fc 100644 --- a/alcs-frontend/src/app/features/board/dialogs/card-dialog/card-dialog.component.ts +++ b/alcs-frontend/src/app/features/board/dialogs/card-dialog/card-dialog.component.ts @@ -52,7 +52,7 @@ export class CardDialogComponent implements OnInit, OnDestroy { this.canArchive = !!currentUser && !!currentUser.client_roles && - (currentUser.client_roles.includes(ROLES.ADMIN) || currentUser.client_roles.includes(ROLES.APP_SPECIALIST)); + Object.values(ROLES).some((role) => role !== ROLES.COMMISSIONER && currentUser.client_roles?.includes(role)); }); this.dialog.backdropClick().subscribe(() => { diff --git a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.html b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.html new file mode 100644 index 0000000000..0ce8550b45 --- /dev/null +++ b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.html @@ -0,0 +1,207 @@ +
+
+
Notice Of Intent
+ +
+
+
+

+ {{ cardTitle }} +

+
+
+ +
+
+
+ + + + + +
+
+
+
+ {{ noticeOfIntent.localGovernment.name }} - {{ noticeOfIntent.region.label }} Region +
+
+
+ + +
+
+
+ +
+ + + + + + + + + + +
+

{{ item.prettyName }}

+

{{ item.email }}

+
+
+
+
+
+
+ Decision #{{ noticeOfIntentDecisionConditionCard.decisionOrder }} - Conditions to Review +
+ + + + + + + +
+
+
+ +
No Conditions
+
+ + + + + + + + + + + + + + + + + + + +
+ + + {{ element.index }}. {{ element.condition.type.label }} + + + + + Due Date: + + + End Date: + + + + Due Date: + + + {{ getDate(element.condition) }} + + + No Data + + + + +
+
+
+
+
+ +
+
+ +
+
diff --git a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.scss b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.scss new file mode 100644 index 0000000000..5f723f5488 --- /dev/null +++ b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.scss @@ -0,0 +1,128 @@ +@use '../../../../../styles/colors.scss'; + +.column-select { + width: 10%; +} + +.column-condition { + width: 40%; +} + +.column-date { + width: 35%; +} + +.column-status { + width: 15%; +} + +.conditions-table { + width: 100%; +} + +.disabled-row { + background-color: #f0f0f0; + cursor: default; + opacity: 0.6; +} + +.conditions-table-container { + max-height: 300px; + overflow-y: auto; + margin-top: 8px; + border: 2px solid #929292; + border-radius: 4px; + + ::ng-deep .mdc-checkbox { + --mdc-checkbox-selected-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-focus-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-focus-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-hover-icon-color: #{colors.$primary-color}; + --mdc-checkbox-selected-pressed-icon-color: #{colors.$primary-color}; + } +} + +.error-state { + border: 2px solid colors.$error-color !important; + + ::ng-deep .mdc-checkbox { + --mdc-checkbox-unselected-icon-color: #{colors.$error-color}; + --mdc-checkbox-unselected-focus-icon-color: #{colors.$error-color}; + --mdc-checkbox-unselected-hover-icon-color: #{colors.$error-color}; + --mdc-checkbox-unselected-pressed-icon-color: #{colors.$error-color}; + } +} + +.date-label { + font-weight: 700; + color: #313132; +} + +.conditions-container { + display: flex; + flex-direction: column; + margin-top: 20px; +} + +.conditions-header-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.buttons-container { + width: 30%; + display: flex; + flex-direction: row; + gap: 20px; + justify-content: flex-end; +} + +.conditions-header { + font-weight: 700; + color: #565656; + flex-grow: 1; +} + +.edit-button { + position: relative; + height: 32px; + width: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.edit-icon { + position: absolute; + top: 50%; + left: 50%; + font-size: 24px; + transform: translate(-50%, -50%); +} + +.pill-row { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 1px; +} + +.no-data { + color: colors.$grey; + font-weight: 400; +} + +.no-conditions { + border: none; + background-color: colors.$grey-light; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + width: 100%; +} + +.flag-icon { + color: blue; +} diff --git a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.ts new file mode 100644 index 0000000000..e843321b22 --- /dev/null +++ b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent-decision-condition-dialog/notice-of-intent-decision-condition-dialog.component.ts @@ -0,0 +1,256 @@ +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; +import { NoticeOfIntentDto } from '../../../../services/notice-of-intent/notice-of-intent.dto'; +import { + NoticeOfIntentDecisionConditionCardBoardDto, + NoticeOfIntentDecisionConditionDateDto, + NoticeOfIntentDecisionConditionDto, + NoticeOfIntentDecisionDto, + UpdateNoticeOfIntentDecisionConditionCardDto, +} from '../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { CardDialogComponent } from '../card-dialog/card-dialog.component'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { NoticeOfIntentDecisionV2Service } from '../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-v2.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; +import { UserService } from '../../../../services/user/user.service'; +import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { BoardService, BoardWithFavourite } from '../../../../services/board/board.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { CardService } from '../../../../services/card/card.service'; +import { AuthenticationService } from '../../../../services/authentication/authentication.service'; +import { Router } from '@angular/router'; +import { + CONDITION_LABEL, + DECISION_CONDITION_COMPLETE_LABEL, + DECISION_CONDITION_EXPIRED_LABEL, + DECISION_CONDITION_ONGOING_LABEL, + DECISION_CONDITION_PASTDUE_LABEL, + DECISION_CONDITION_PENDING_LABEL, + MODIFICATION_TYPE_LABEL, +} from '../../../../shared/application-type-pill/application-type-pill.constants'; + +@Component({ + selector: 'app-notice-of-intent-decision-condition-dialog', + templateUrl: './notice-of-intent-decision-condition-dialog.component.html', + styleUrls: [ + '../card-dialog/card-dialog.component.scss', + './notice-of-intent-decision-condition-dialog.component.scss', + ], +}) +export class NoticeOfIntentDecisionConditionDialogComponent extends CardDialogComponent implements OnInit { + cardTitle = ''; + noticeOfIntent: NoticeOfIntentDto = this.data.noticeOfIntent; + decision!: NoticeOfIntentDecisionDto; + noticeOfIntentDecisionConditionCard: NoticeOfIntentDecisionConditionCardBoardDto = this.data.decisionConditionCard; + isModification: boolean = false; + isReconsideration: boolean = false; + isFlagged = false; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource<{ condition: NoticeOfIntentDecisionConditionDto; index: number; selected: boolean }> = + new MatTableDataSource(); + + editColumns: string[] = ['select', 'condition', 'date', 'status']; + defaultColumns: string[] = ['condition', 'date', 'status']; + + displayColumns: string[] = this.defaultColumns; + + isEditing: boolean = false; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { + decisionConditionCard: NoticeOfIntentDecisionConditionCardBoardDto; + noticeOfIntent: NoticeOfIntentDto; + }, + private noticeOfIntentDecisionService: NoticeOfIntentDecisionV2Service, + private noticeOfIntentDecisionConditionCardService: NoticeOfIntentDecisionConditionCardService, + dialogRef: MatDialogRef, + userService: UserService, + confirmationDialogService: ConfirmationDialogService, + boardService: BoardService, + toastService: ToastService, + cardService: CardService, + authService: AuthenticationService, + private router: Router, + ) { + super(authService, dialogRef, cardService, confirmationDialogService, toastService, userService, boardService); + } + override ngOnInit(): void { + super.ngOnInit(); + this.populateData(); + } + + async populateData() { + await this.loadDecision(); + + this.populateCardData(this.noticeOfIntentDecisionConditionCard.card); + this.cardTitle = `${this.noticeOfIntent.fileNumber} (${this.noticeOfIntent.applicant})`; + + this.loadTableData(true); + } + + async loadDecision() { + const decision = await this.noticeOfIntentDecisionService.getByUuid( + this.noticeOfIntentDecisionConditionCard.decisionUuid, + true, + ); + if (decision) { + this.decision = decision; + this.isFlagged = this.decision.isFlagged; + } + } + + loadTableData(filterSelected: boolean = false) { + const data = this.decision.conditions + .filter((condition) => !filterSelected || this.isConditionSelected(condition)) + .map((condition, index) => ({ + condition, + index: index + 1, + selected: this.isConditionSelected(condition), + })); + + this.dataSource.data = data; + } + + isConditionSelected(condition: NoticeOfIntentDecisionConditionDto): boolean { + return condition.conditionCard?.uuid === this.noticeOfIntentDecisionConditionCard.uuid; + } + + isConditionDisabled(condition: NoticeOfIntentDecisionConditionDto): boolean { + if (condition.conditionCard === null) { + return false; + } + return condition.conditionCard?.uuid !== this.noticeOfIntentDecisionConditionCard.uuid; + } + + getStatusPill(status: string) { + if (status === 'ONGOING') { + return DECISION_CONDITION_ONGOING_LABEL; + } else if (status === 'COMPLETED') { + return DECISION_CONDITION_COMPLETE_LABEL; + } else if (status === 'PASTDUE') { + return DECISION_CONDITION_PASTDUE_LABEL; + } else if (status === 'PENDING') { + return DECISION_CONDITION_PENDING_LABEL; + } else if (status === 'EXPIRED') { + return DECISION_CONDITION_EXPIRED_LABEL; + } else if (status === 'MODIFICATION') { + return MODIFICATION_TYPE_LABEL; + } else if (status === 'CONDITION') { + return CONDITION_LABEL; + } else { + return DECISION_CONDITION_ONGOING_LABEL; + } + } + + formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: '2-digit' }; + const formattedDate = date.toLocaleDateString('en-CA', options).replace(',', ''); + const [month, day, year] = formattedDate.split(' '); + return `${year}-${month}-${day}`; + } + + getDate(condition: NoticeOfIntentDecisionConditionDto) { + if (condition.type!.dateType === 'Single') { + return condition.dates![0].date ? this.formatTimestamp(condition.dates![0].date) : null; + } else { + if (condition.dates && condition.dates.length > 0) { + let minDueDate: NoticeOfIntentDecisionConditionDateDto | null = null; + let maxDueDate: NoticeOfIntentDecisionConditionDateDto | null = null; + let allDatesNull = true; + + for (const date of condition.dates) { + if (date.date !== null) { + allDatesNull = false; + if (!maxDueDate || date.date! > maxDueDate.date!) { + maxDueDate = date; + } + if (!date.completedDate) { + if (!minDueDate || date.date! < minDueDate.date!) { + minDueDate = date; + } + } + } + } + + if (allDatesNull) { + return null; + } + + const selectedDate = minDueDate || maxDueDate; + return selectedDate ? this.formatTimestamp(selectedDate.date!) : null; + } + return null; + } + } + + editClicked() { + this.isEditing = true; + this.displayColumns = this.editColumns; + this.loadTableData(); + } + + onCancel() { + this.isEditing = false; + this.displayColumns = this.defaultColumns; + + this.loadTableData(true); + } + + async onSave() { + const selectedConditions = this.dataSource.data.filter((item) => item.selected).map((item) => item.condition.uuid); + const updateDto: UpdateNoticeOfIntentDecisionConditionCardDto = { + conditionsUuids: selectedConditions, + }; + + const res = await this.noticeOfIntentDecisionConditionCardService.update( + this.noticeOfIntentDecisionConditionCard.uuid, + updateDto, + ); + res + ? this.toastService.showSuccessToast('Condition card updated successfully') + : this.toastService.showErrorToast('Failed to update condition card'); + + this.isEditing = false; + this.displayColumns = this.defaultColumns; + this.isDirty = true; + + await this.loadDecision(); + this.loadTableData(true); + } + + isSaveDisabled(): boolean { + return !this.dataSource.data.some((item) => item.selected); + } + + async onBoardSelected(board: BoardWithFavourite) { + this.selectedBoard = board.code; + try { + await this.boardService.changeBoard(this.noticeOfIntentDecisionConditionCard.card!.uuid, board.code); + const loadedBoard = await this.boardService.fetchBoardDetail(board.code); + if (loadedBoard) { + this.boardStatuses = loadedBoard.statuses; + } + + this.isDirty = true; + const toast = this.toastService.showSuccessToast(`NOI Condition moved to ${board.title}`, 'Go to Board'); + toast.onAction().subscribe(() => { + this.router.navigate(['/board', board.code]); + }); + await this.reloadApplicationCondition(); + } catch (e) { + this.toastService.showErrorToast('Failed to move to new board'); + } + } + + async reloadApplicationCondition() { + const noticeOfIntentDecisionConditionCard = await this.noticeOfIntentDecisionConditionCardService.getByCard( + this.noticeOfIntentDecisionConditionCard.card.uuid, + ); + this.noticeOfIntentDecisionConditionCard = noticeOfIntentDecisionConditionCard!; + this.populateData(); + } +} diff --git a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.html b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.html index 23aab8ffdc..7a679916e3 100644 --- a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.html +++ b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.html @@ -22,7 +22,7 @@

color="accent" mat-flat-button [mat-dialog-close]="isDirty" - [routerLink]="['notice-of-intent', noticeOfIntent.fileNumber]" + [routerLink]="[routerLink]" > View Detail diff --git a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.ts index aeebef486f..1d164524ed 100644 --- a/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.ts +++ b/alcs-frontend/src/app/features/board/dialogs/notice-of-intent/notice-of-intent-dialog.component.ts @@ -2,9 +2,9 @@ import { Component, Inject, OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { - ApplicationSubmissionToSubmissionStatusDto, DEFAULT_NO_STATUS, } from '../../../../services/application/application-submission-status/application-submission-status.dto'; +import { NOI_SUBMISSION_STATUS } from '../../../../services/notice-of-intent/notice-of-intent.dto'; import { AuthenticationService } from '../../../../services/authentication/authentication.service'; import { BoardService, BoardWithFavourite } from '../../../../services/board/board.service'; import { CardService } from '../../../../services/card/card.service'; @@ -35,6 +35,8 @@ export class NoticeOfIntentDialogComponent extends CardDialogComponent implement noticeOfIntent: NoticeOfIntentDto = this.data; RETROACTIVE_TYPE = RETROACTIVE_TYPE_LABEL; + routerLink = `notice-of-intent/`; + constructor( @Inject(MAT_DIALOG_DATA) public data: NoticeOfIntentDto, private dialogRef: MatDialogRef, @@ -76,7 +78,7 @@ export class NoticeOfIntentDialogComponent extends CardDialogComponent implement private async populateSubmissionStatus(fileNumber: string) { let submissionStatus: NoticeOfIntentSubmissionToSubmissionStatusDto | null = null; - + this.routerLink = this.routerLink + fileNumber; try { submissionStatus = await this.noticeOfIntentSubmissionStatusService.fetchCurrentStatusByFileNumber( fileNumber, @@ -85,8 +87,10 @@ export class NoticeOfIntentDialogComponent extends CardDialogComponent implement } catch (e) { console.warn(`No statuses for ${fileNumber}. Is it a manually created submission?`); } - if (submissionStatus) { + if (submissionStatus.statusTypeCode === NOI_SUBMISSION_STATUS.ALC_DECISION) { + this.routerLink = this.routerLink + '/decision' + } this.status = { backgroundColor: submissionStatus.status.alcsBackgroundColor, textColor: submissionStatus.status.alcsColor, diff --git a/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.html b/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.html index ed29539a60..b64264ba89 100644 --- a/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.html +++ b/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.html @@ -1,6 +1,13 @@
- +
{ let component: CommissionerApplicationComponent; let fixture: ComponentFixture; let mockCommissionerService: DeepMocked; + let mockApplicationSubmissionStatusService: DeepMocked; beforeEach(async () => { mockCommissionerService = createMock(); + mockApplicationSubmissionStatusService = createMock(); await TestBed.configureTestingModule({ imports: [RouterTestingModule], @@ -22,6 +25,10 @@ describe('CommissionerApplicationComponent', () => { provide: CommissionerService, useValue: mockCommissionerService, }, + { + provide: ApplicationSubmissionStatusService, + useValue: mockApplicationSubmissionStatusService, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.ts b/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.ts index 8673a068f7..13355563ab 100644 --- a/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.ts +++ b/alcs-frontend/src/app/features/commissioner/application/commissioner-application.component.ts @@ -6,11 +6,15 @@ import { DOCUMENT_TYPE } from '../../../shared/document/document.dto'; import { environment } from '../../../../environments/environment'; import { CommissionerApplicationDto } from '../../../services/commissioner/commissioner.dto'; import { CommissionerService } from '../../../services/commissioner/commissioner.service'; +import { FileTagService } from '../../../services/common/file-tag.service'; +import { ApplicationTagService } from '../../../services/application/application-tag/application-tag.service'; +import { ApplicationSubmissionStatusService } from '../../../services/application/application-submission-status/application-submission-status.service'; @Component({ selector: 'app-commissioner-application', templateUrl: './commissioner-application.component.html', styleUrls: ['./commissioner-application.component.scss'], + providers: [{ provide: FileTagService, useClass: ApplicationTagService }], }) export class CommissionerApplicationComponent implements OnInit, OnDestroy { destroy = new Subject(); @@ -20,6 +24,7 @@ export class CommissionerApplicationComponent implements OnInit, OnDestroy { constructor( private commissionerService: CommissionerService, + public applicationStatusService: ApplicationSubmissionStatusService, private route: ActivatedRoute, private titleService: Title, ) {} diff --git a/alcs-frontend/src/app/features/home/assigned/assigned-table/assigned-table.component.scss b/alcs-frontend/src/app/features/home/assigned/assigned-table/assigned-table.component.scss index 70ce14c93f..b841823ab0 100644 --- a/alcs-frontend/src/app/features/home/assigned/assigned-table/assigned-table.component.scss +++ b/alcs-frontend/src/app/features/home/assigned/assigned-table/assigned-table.component.scss @@ -35,7 +35,7 @@ } .type-cell { - width: 90px; + width: 300px; } tr.mdc-data-table__row:hover { diff --git a/alcs-frontend/src/app/features/home/assigned/assigned.component.spec.ts b/alcs-frontend/src/app/features/home/assigned/assigned.component.spec.ts index 246849af6f..318f5537da 100644 --- a/alcs-frontend/src/app/features/home/assigned/assigned.component.spec.ts +++ b/alcs-frontend/src/app/features/home/assigned/assigned.component.spec.ts @@ -43,6 +43,8 @@ describe('AssignedComponent', () => { noticeOfIntents: [], notifications: [], inquiries: [], + applicationsConditions: [], + noticeOfIntentsConditions: [], }); fixture.detectChanges(); diff --git a/alcs-frontend/src/app/features/home/assigned/assigned.component.ts b/alcs-frontend/src/app/features/home/assigned/assigned.component.ts index 860b654887..b0baa85ec1 100644 --- a/alcs-frontend/src/app/features/home/assigned/assigned.component.ts +++ b/alcs-frontend/src/app/features/home/assigned/assigned.component.ts @@ -6,15 +6,20 @@ import { ApplicationService } from '../../../services/application/application.se import { HomeService } from '../../../services/home/home.service'; import { InquiryDto } from '../../../services/inquiry/inquiry.dto'; import { NoticeOfIntentModificationDto } from '../../../services/notice-of-intent/notice-of-intent-modification/notice-of-intent-modification.dto'; -import { NoticeOfIntentDto } from '../../../services/notice-of-intent/notice-of-intent.dto'; +import { NoticeOfIntentDto, NoticeOfIntentTypeDto } from '../../../services/notice-of-intent/notice-of-intent.dto'; import { NotificationDto } from '../../../services/notification/notification.dto'; import { PlanningReferralDto } from '../../../services/planning-review/planning-review.dto'; import { + CONDITION_LABEL, MODIFICATION_TYPE_LABEL, RECON_TYPE_LABEL, RETROACTIVE_TYPE_LABEL, } from '../../../shared/application-type-pill/application-type-pill.constants'; import { AssignedToMeFile } from './assigned-table/assigned-table.component'; +import { ApplicationDecisionConditionHomeDto } from '../../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { ApplicationTypeDto } from '../../../services/application/application-code.dto'; +import { ApplicationPill } from '../../../shared/application-type-pill/application-type-pill.component'; +import { NoticeOfIntentDecisionConditionHomeDto } from '../../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto'; @Component({ selector: 'app-assigned', @@ -49,6 +54,8 @@ export class AssignedComponent implements OnInit { noticeOfIntentModifications, notifications, inquiries, + applicationsConditions, + noticeOfIntentsConditions, } = await this.homeService.fetchAssignedToMe(); this.noticeOfIntents = [ @@ -56,6 +63,10 @@ export class AssignedComponent implements OnInit { .filter((a) => a.card!.highPriority) .map((a) => this.mapNoticeOfIntent(a)) .sort((a, b) => b.activeDays! - a.activeDays!), + ...noticeOfIntentsConditions + .filter((a) => a.conditionCard?.card!.highPriority) + .map((a) => this.mapNoticeOfIntentCondition(a)) + .sort((a, b) => b.activeDays! - a.activeDays!), ...noticeOfIntentModifications .filter((a) => a.card!.highPriority) .map((a) => this.mapNoticeOfIntentModification(a)) @@ -64,6 +75,10 @@ export class AssignedComponent implements OnInit { .filter((a) => !a.card!.highPriority) .map((a) => this.mapNoticeOfIntent(a)) .sort((a, b) => b.activeDays! - a.activeDays!), + ...noticeOfIntentsConditions + .filter((a) => !a.conditionCard?.card!.highPriority) + .map((a) => this.mapNoticeOfIntentCondition(a)) + .sort((a, b) => b.activeDays! - a.activeDays!), ...noticeOfIntentModifications .filter((a) => !a.card!.highPriority) .map((a) => this.mapNoticeOfIntentModification(a)) @@ -74,6 +89,10 @@ export class AssignedComponent implements OnInit { .filter((a) => a.card!.highPriority) .map((a) => this.mapApplication(a)) .sort((a, b) => b.activeDays! - a.activeDays!), + ...applicationsConditions + .filter((a) => a.conditionCard?.card!.highPriority) + .map((a) => this.mapApplicationCondition(a)) + .sort((a, b) => b.activeDays! - a.activeDays!), ...modifications .filter((a) => a.card.highPriority) .map((a) => this.mapModification(a)) @@ -86,6 +105,10 @@ export class AssignedComponent implements OnInit { .filter((a) => !a.card!.highPriority) .map((a) => this.mapApplication(a)) .sort((a, b) => b.activeDays! - a.activeDays!), + ...applicationsConditions + .filter((a) => !a.conditionCard?.card!.highPriority) + .map((a) => this.mapApplicationCondition(a)) + .sort((a, b) => b.activeDays! - a.activeDays!), ...modifications .filter((r) => !r.card.highPriority) .map((r) => this.mapModification(r)) @@ -171,6 +194,41 @@ export class AssignedComponent implements OnInit { }; } + private mapApplicationCondition(a: ApplicationDecisionConditionHomeDto): AssignedToMeFile { + const pills: ApplicationTypeDto | ApplicationPill[] = [a.decision.application.type]; + if (a.isReconsideration) { + pills.push(RECON_TYPE_LABEL); + } + if (a.isModification) { + pills.push(MODIFICATION_TYPE_LABEL); + } + return { + title: `${a.decision?.application.fileNumber} (${a.decision.application.applicant})`, + activeDays: a.decision.application.activeDays, + type: a.conditionCard!.card.type, + paused: a.decision.application.paused, + card: a.conditionCard!.card, + highPriority: a.conditionCard!.card.highPriority, + labels: [...pills, CONDITION_LABEL], + }; + } + + private mapNoticeOfIntentCondition(a: NoticeOfIntentDecisionConditionHomeDto): AssignedToMeFile { + const pills: ApplicationPill[] = []; + if (a.isModification) { + pills.push(MODIFICATION_TYPE_LABEL); + } + return { + title: `${a.decision?.noticeOfIntent.fileNumber} (${a.decision.noticeOfIntent.applicant})`, + activeDays: a.decision.noticeOfIntent.activeDays, + type: a.conditionCard!.card.type, + paused: a.decision.noticeOfIntent.paused, + card: a.conditionCard!.card, + highPriority: a.conditionCard!.card.highPriority, + labels: [...pills, CONDITION_LABEL], + }; + } + private mapModification(r: ApplicationModificationDto): AssignedToMeFile { return { title: `${r.application.fileNumber} (${r.application.applicant})`, diff --git a/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.html b/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.html index ea893b75dc..a05845df0d 100644 --- a/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.html +++ b/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.html @@ -16,21 +16,27 @@ Type - - + - + + diff --git a/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.scss b/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.scss index 165ca2dd74..09eaa6a81d 100644 --- a/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.scss +++ b/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.scss @@ -22,7 +22,7 @@ } .type-cell { - width: 15%; + width: 20%; } .active-days-cell { diff --git a/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.ts b/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.ts index 39246cf0b6..8b7b59ffc0 100644 --- a/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.ts +++ b/alcs-frontend/src/app/features/home/subtask/subtask-table/subtask-table.component.ts @@ -7,6 +7,7 @@ import { AssigneeDto, UserDto } from '../../../../services/user/user.dto'; import { MODIFICATION_TYPE_LABEL, RECON_TYPE_LABEL, + CONDITION_LABEL, } from '../../../../shared/application-type-pill/application-type-pill.constants'; import { CardType } from '../../../../shared/card/card.component'; @@ -21,6 +22,7 @@ export class SubtaskTableComponent { MODIFICATION_TYPE_LABEL = MODIFICATION_TYPE_LABEL; RECON_TYPE_LABEL = RECON_TYPE_LABEL; + CONDITION_LABEL = CONDITION_LABEL; CardType = CardType; diff --git a/alcs-frontend/src/app/features/home/subtask/subtask.component.ts b/alcs-frontend/src/app/features/home/subtask/subtask.component.ts index f5e1bf47a1..2b6bf8bcda 100644 --- a/alcs-frontend/src/app/features/home/subtask/subtask.component.ts +++ b/alcs-frontend/src/app/features/home/subtask/subtask.component.ts @@ -62,20 +62,26 @@ export class SubtaskComponent implements OnInit, OnDestroy { const noiModifications = allSubtasks.filter((s) => s.card.type === CardType.NOI_MODI); const notifications = allSubtasks.filter((s) => s.card.type === CardType.NOTIFICATION); const inquiries = allSubtasks.filter((s) => s.card.type === CardType.INQUIRY); + const applicationConditions = allSubtasks.filter((s) => s.card.type === CardType.APP_CON); + const noiConditions = allSubtasks.filter((s) => s.card.type === CardType.NOI_CON); this.applicationSubtasks = [ ...applications.filter((a) => a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), + ...applicationConditions.filter((a) => a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), ...modifications.filter((r) => r.card.highPriority).sort((a, b) => a.createdAt! - b.createdAt!), ...reconsiderations.filter((r) => r.card.highPriority).sort((a, b) => a.createdAt! - b.createdAt!), ...applications.filter((a) => !a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), + ...applicationConditions.filter((a) => !a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), ...modifications.filter((r) => !r.card.highPriority).sort((a, b) => a.createdAt! - b.createdAt!), ...reconsiderations.filter((r) => !r.card.highPriority).sort((a, b) => a.createdAt! - b.createdAt!), ]; this.noticeOfIntentSubtasks = [ ...nois.filter((a) => a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), + ...noiConditions.filter((a) => a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), ...noiModifications.filter((r) => r.card.highPriority).sort((a, b) => a.createdAt! - b.createdAt!), ...nois.filter((a) => !a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), + ...noiConditions.filter((a) => !a.card.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), ...noiModifications.filter((r) => !r.card.highPriority).sort((a, b) => a.createdAt! - b.createdAt!), ]; diff --git a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.html b/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.html deleted file mode 100644 index fdc228b65b..0000000000 --- a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.html +++ /dev/null @@ -1,108 +0,0 @@ -
-

{{ title }} Document

-
-
-
-
-
- Document Upload* -
- -
- -
or drag and drop them here
- -
-
-
- {{ pendingFile.name }} -  ({{ pendingFile.size | filesize }}) -
- -
-
-
- {{ existingFile.name }} -  ({{ existingFile.size | filesize }}) -
- -
- - warning A virus was detected in the file. Choose another file and try again. - -
- -
- - Document Name - - {{ extension }} - -
- -
- - -
-
- - Source - - {{ source }} - - -
-
- - -
- - - -
-
-
diff --git a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.scss b/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.scss deleted file mode 100644 index fcdc947261..0000000000 --- a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.scss +++ /dev/null @@ -1,89 +0,0 @@ -@use '../../../../../styles/colors'; - -.form { - display: grid; - grid-template-columns: 1fr 1fr; - row-gap: 32px; - column-gap: 32px; - - .double { - grid-column: 1/3; - } -} - -.full-width { - width: 100%; -} - -a { - word-break: break-all; -} - -.file { - border: 1px solid #000; - border-radius: 8px; - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; -} - -.upload-button { - margin-top: 6px !important; - - &.error { - border: 2px solid colors.$error-color; - } -} - -.spinner { - display: inline-block; - margin-right: 4px; -} - -:host::ng-deep { - .mdc-button__label { - display: flex; - align-items: center; - } -} - -.superseded-warning { - background-color: colors.$secondary-color-dark; - color: #fff; - padding: 0 4px; -} - - -.file-drag-drop { - background: colors.$white; - border-radius: 4px; - - &:hover { - background: colors.$grey-light !important; - } - - button:nth-child(1) { - width: 100%; - background: colors.$white; - padding: 24px; - border: none; - - &:hover { - background: colors.$grey-light !important; - } - } - - .drag-text { - margin-top: 14px; - color: colors.$grey; - } - - .icon { - color: colors.$grey; - font-size: 36px; - height: 36px; - align-content: center; - margin-bottom: 4px; - } -} \ No newline at end of file diff --git a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.spec.ts deleted file mode 100644 index 1571be1108..0000000000 --- a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 { InquiryDocumentService } from '../../../../services/inquiry/inquiry-document/inquiry-document.service'; -import { ToastService } from '../../../../services/toast/toast.service'; - -import { DocumentUploadDialogComponent } from './document-upload-dialog.component'; - -describe('DocumentUploadDialogComponent', () => { - let component: DocumentUploadDialogComponent; - let fixture: ComponentFixture; - - let mockAppDocService: DeepMocked; - - beforeEach(async () => { - mockAppDocService = createMock(); - - const mockDialogRef = { - close: jest.fn(), - afterClosed: jest.fn(), - subscribe: jest.fn(), - backdropClick: () => new EventEmitter(), - }; - - await TestBed.configureTestingModule({ - declarations: [DocumentUploadDialogComponent], - providers: [ - { - provide: InquiryDocumentService, - useValue: mockAppDocService, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: ToastService, useValue: {} }, - ], - imports: [MatDialogModule], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(DocumentUploadDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.ts deleted file mode 100644 index 1b0c751f46..0000000000 --- a/alcs-frontend/src/app/features/inquiry/documents/document-upload-dialog/document-upload-dialog.component.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Subject } from 'rxjs'; -import { - InquiryDocumentDto, - UpdateDocumentDto, -} from '../../../../services/inquiry/inquiry-document/inquiry-document.dto'; -import { InquiryDocumentService } from '../../../../services/inquiry/inquiry-document/inquiry-document.service'; -import { ToastService } from '../../../../services/toast/toast.service'; -import { - DOCUMENT_SOURCE, - DOCUMENT_SYSTEM, - DOCUMENT_TYPE, - DocumentTypeDto, -} from '../../../../shared/document/document.dto'; -import { splitExtension } from '../../../../shared/utils/file'; -import { FileHandle } from '../../../../shared/drag-drop-file/drag-drop-file.directive'; - -@Component({ - selector: 'app-document-upload-dialog', - templateUrl: './document-upload-dialog.component.html', - styleUrls: ['./document-upload-dialog.component.scss'], -}) -export class DocumentUploadDialogComponent implements OnInit, OnDestroy { - $destroy = new Subject(); - DOCUMENT_TYPE = DOCUMENT_TYPE; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - title = 'Create'; - isDirty = false; - isSaving = false; - allowsFileEdit = true; - documentTypeAhead: string | undefined = undefined; - - name = new FormControl('', [Validators.required]); - type = new FormControl(undefined, [Validators.required]); - source = new FormControl('', [Validators.required]); - - documentTypes: DocumentTypeDto[] = []; - documentSources = Object.values(DOCUMENT_SOURCE); - - form = new FormGroup({ - name: this.name, - type: this.type, - source: this.source, - }); - - pendingFile: File | undefined; - existingFile: { name: string; size: number } | undefined; - showVirusError = false; - extension = ''; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { fileId: string; existingDocument?: InquiryDocumentDto }, - protected dialog: MatDialogRef, - private inquiryDocumentService: InquiryDocumentService, - private toastService: ToastService, - ) {} - - ngOnInit(): void { - this.loadDocumentTypes(); - - if (this.data.existingDocument) { - const document = this.data.existingDocument; - this.title = 'Edit'; - this.allowsFileEdit = document.system === DOCUMENT_SYSTEM.ALCS; - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - - this.form.patchValue({ - name: fileName, - type: document.type?.code, - source: document.source, - }); - this.documentTypeAhead = document.type!.code; - - this.existingFile = { - name: document.fileName, - size: 0, - }; - } - } - - async onSubmit() { - const file = this.pendingFile; - const dto: UpdateDocumentDto = { - fileName: this.name.value! + this.extension, - source: this.source.value as DOCUMENT_SOURCE, - typeCode: this.type.value as DOCUMENT_TYPE, - file, - }; - - this.isSaving = true; - if (this.data.existingDocument) { - await this.inquiryDocumentService.update(this.data.existingDocument.uuid, dto); - } else if (file !== undefined) { - try { - await this.inquiryDocumentService.upload(this.data.fileId, { - ...dto, - file, - }); - } catch (err) { - this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - this.showVirusError = true; - this.isSaving = false; - this.pendingFile = undefined; - return; - } - } - this.showVirusError = false; - } - - this.dialog.close(true); - this.isSaving = false; - } - - ngOnDestroy(): void { - this.$destroy.next(); - this.$destroy.complete(); - } - - filterDocumentTypes(term: string, item: DocumentTypeDto) { - const termLower = term.toLocaleLowerCase(); - return ( - item.label.toLocaleLowerCase().indexOf(termLower) > -1 || - item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 - ); - } - - async onDocTypeSelected($event?: DocumentTypeDto) { - if ($event) { - this.type.setValue($event.code); - } else { - this.type.setValue(undefined); - } - } - - uploadFile(event: Event) { - const element = event.target as HTMLInputElement; - const selectedFiles = element.files; - if (selectedFiles && selectedFiles[0]) { - this.pendingFile = selectedFiles[0]; - - const documentName = selectedFiles[0].name; - const { fileName, extension } = splitExtension(documentName); - this.name.setValue(fileName); - this.extension = extension; - this.showVirusError = false; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - openFile() { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile); - window.open(fileURL, '_blank'); - } - } - - async openExistingFile() { - if (this.data.existingDocument) { - await this.inquiryDocumentService.download(this.data.existingDocument.uuid, this.data.existingDocument.fileName); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } - - private async loadDocumentTypes() { - const docTypes = await this.inquiryDocumentService.fetchTypes(); - docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); - this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); - } -} diff --git a/alcs-frontend/src/app/features/inquiry/documents/documents.component.html b/alcs-frontend/src/app/features/inquiry/documents/documents.component.html index 460be3ac0d..ec85aabb03 100644 --- a/alcs-frontend/src/app/features/inquiry/documents/documents.component.html +++ b/alcs-frontend/src/app/features/inquiry/documents/documents.component.html @@ -17,7 +17,7 @@

Documents

(click)="openFile(element.uuid, element.fileName)" [matTooltip]="element.fileName" [matTooltipDisabled]="element.fileName.length <= fileNameTruncLen"> - {{ element.fileName | truncate: fileNameTruncLen}} + {{ element.fileName }} @@ -35,13 +35,14 @@

Documents

Actions - - + +
+
+ diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.scss b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.scss new file mode 100644 index 0000000000..592e2506d1 --- /dev/null +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.scss @@ -0,0 +1,53 @@ +@use '../../../../../../styles/colors.scss' as *; + +.container { + display: flex; + flex-direction: column; + width: 100%; + padding: 12px 8px; + overflow-y: hidden; +} + +.section { + width: 100%; + padding: 16px; +} + +.button-row { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.column-select { + width: 10%; +} + +.column-index { + width: 10%; +} + +.column-type { + width: 30%; +} + +.column-description { + width: 50%; +} + +.conditions-table { + margin-top: 16px; + width: 100%; +} + +.table-container { + max-height: 300px; + overflow-y: auto; + padding: 2px; +} + +.disabled-row { + background-color: #f0f0f0; + opacity: 0.6; + cursor: default; +} diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.spec.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.spec.ts new file mode 100644 index 0000000000..83c4ba7ea8 --- /dev/null +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConditionCardDialogComponent } from './condition-card-dialog.component'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { NoticeOfIntentDecisionConditionCardService } from '../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; +import { BoardService } from '../../../../../services/board/board.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('ConditionCardDialogComponent', () => { + let component: ConditionCardDialogComponent; + let fixture: ComponentFixture; + let mockDecisionConditionCardService: DeepMocked; + let mockBoardService: DeepMocked; + let mockToastService: DeepMocked; + + beforeEach(async () => { + mockDecisionConditionCardService = createMock(); + mockBoardService = createMock(); + mockToastService = createMock(); + + await TestBed.configureTestingModule({ + declarations: [ConditionCardDialogComponent], + imports: [MatDialogModule, BrowserAnimationsModule, MatTableModule, MatSortModule, HttpClientTestingModule], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { conditions: [], decision: 'decision-uuid' } }, + { provide: MatDialogRef, useValue: {} }, + { provide: NoticeOfIntentDecisionConditionCardService, useValue: mockDecisionConditionCardService }, + { provide: BoardService, useValue: mockBoardService }, + { provide: ToastService, useValue: mockToastService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConditionCardDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.ts new file mode 100644 index 0000000000..efbc53c06a --- /dev/null +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition-card-dialog/condition-card-dialog.component.ts @@ -0,0 +1,89 @@ +import { Component, Inject, ViewChild } from '@angular/core'; +import { CardType } from '../../../../../shared/card/card.component'; +import { BoardDto, BoardStatusDto } from '../../../../../services/board/board.dto'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BOARD_TYPE_CODES, BoardService } from '../../../../../services/board/board.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { + CreateNoticeOfIntentDecisionConditionCardDto, + NoticeOfIntentDecisionConditionDto, +} from '../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto'; +import { NoticeOfIntentDecisionConditionCardService } from '../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; + +@Component({ + selector: 'app-noi-condition-card-dialog', + templateUrl: './condition-card-dialog.component.html', + styleUrl: './condition-card-dialog.component.scss', +}) +export class ConditionCardDialogComponent { + displayColumns: string[] = ['select', 'index', 'type', 'description']; + conditionBoard: BoardDto | undefined; + selectedStatus = ''; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource<{ condition: NoticeOfIntentDecisionConditionDto; index: number; selected: boolean }> = + new MatTableDataSource(); + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { conditions: { condition: NoticeOfIntentDecisionConditionDto; index: number }[]; decision: string }, + private dialogRef: MatDialogRef, + private decisionConditionCardService: NoticeOfIntentDecisionConditionCardService, + private boardService: BoardService, + private toastService: ToastService, + ) {} + + async ngOnInit() { + this.dataSource.data = this.data.conditions.map((item) => ({ + condition: item.condition, + selected: false, + index: item.index + 1, + })); + + this.conditionBoard = await this.boardService.fetchBoardDetail(BOARD_TYPE_CODES.NOICON); + } + + onStatusSelected(noticeOfIntentStatus: BoardStatusDto) { + this.selectedStatus = noticeOfIntentStatus.statusCode; + } + + isConditionCardNotNull(element: any): boolean { + return element.condition.conditionCard !== null; + } + + isSaveDisabled(): boolean { + const isStatusSelected = !!this.selectedStatus; + const isAnyRowSelected = this.dataSource.data.some((item) => item.selected); + return !(isStatusSelected && isAnyRowSelected); + } + + onCancel(): void { + this.dialogRef.close({ action: 'cancel' }); + } + + async onSave() { + const selectedStatusCode = this.conditionBoard?.statuses.find( + (status) => status.label === this.selectedStatus, + )?.statusCode; + const selectedConditions = this.dataSource.data.filter((item) => item.selected).map((item) => item.condition.uuid); + const createDto: CreateNoticeOfIntentDecisionConditionCardDto = { + conditionsUuids: selectedConditions, + decisionUuid: this.data.decision, + cardStatusCode: this.selectedStatus, + }; + const res = await this.decisionConditionCardService.create(createDto); + if (res) { + this.toastService.showSuccessToastWithLink( + 'Condition card created successfully', + 'GO TO BOARD', + `/board/noicon?card=${res.cardUuid}&type=${CardType.NOI_CON}`, + ); + this.dialogRef.close({ action: 'save', result: true }); + } else { + this.toastService.showErrorToast('Failed to create condition card'); + this.dialogRef.close({ action: 'save', result: false }); + } + } +} diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition/condition.component.html b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition/condition.component.html index 31a5929f59..36a0d45ae7 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition/condition.component.html +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/condition/condition.component.html @@ -44,20 +44,20 @@

{{ condition.type.label }}

@@ -77,8 +77,8 @@

{{ condition.type.label }}

@@ -98,8 +98,8 @@

{{ condition.type.label }}

Due @@ -108,8 +108,8 @@

{{ condition.type.label }}

Completed @@ -118,8 +118,8 @@

{{ condition.type.label }}

Comment @@ -127,7 +127,7 @@

{{ condition.type.label }}

Action - @@ -145,7 +145,7 @@

{{ condition.type.label }}

Description

View Conditions

- +
+ + +
@@ -30,7 +35,7 @@

View Conditions

[condition]="condition" [isDraftDecision]="decision.isDraft" [fileNumber]="fileNumber" - [index]="j+1" + [index]="j + 1" >
diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.scss b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.scss index 21b94e2d54..121ac38402 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.scss +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.scss @@ -21,6 +21,11 @@ p { justify-content: flex-end; } +.header-buttons { + display: flex; + gap: 8px; +} + :host ::ng-deep { .display-none { display: none !important; diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.ts index dccc4d8142..94e6702d7b 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/conditions/conditions.component.ts @@ -17,6 +17,8 @@ import { RELEASED_DECISION_TYPE_LABEL, } from '../../../../shared/application-type-pill/application-type-pill.constants'; import { NoticeOfIntentDecisionConditionService } from '../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service'; +import { MatDialog } from '@angular/material/dialog'; +import { ConditionCardDialogComponent } from './condition-card-dialog/condition-card-dialog.component'; export type ConditionComponentLabels = { label: string[]; @@ -66,6 +68,7 @@ export class ConditionsComponent implements OnInit { private decisionService: NoticeOfIntentDecisionV2Service, private conditionService: NoticeOfIntentDecisionConditionService, private activatedRouter: ActivatedRoute, + private dialog: MatDialog, ) { this.today = moment().startOf('day').toDate().getTime(); } @@ -183,4 +186,27 @@ export class ConditionsComponent implements OnInit { }), ); } + + openConditionCardDialog(): void { + const dialogRef = this.dialog.open(ConditionCardDialogComponent, { + minWidth: '800px', + maxWidth: '1100px', + maxHeight: '80vh', + data: { + conditions: this.decision.conditions.map((condition, index) => ({ + condition: condition, + index: index, + })), + decision: this.decision.uuid, + }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + if (result.action === 'save' && result.result === true) { + this.loadDecisions(this.fileNumber); + } + } + }); + } } diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-documents/decision-documents.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-documents/decision-documents.component.ts index 3bcbfb3485..6155911f76 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-documents/decision-documents.component.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-documents/decision-documents.component.ts @@ -10,8 +10,8 @@ import { } from '../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto'; 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'; import { FILE_NAME_TRUNCATE_LENGTH } from '../../../../../shared/constants'; +import { DocumentUploadDialogComponent } from '../../../../../shared/document-upload-dialog/document-upload-dialog.component'; @Component({ selector: 'app-decision-documents', @@ -37,7 +37,7 @@ export class DecisionDocumentsComponent implements OnInit, OnDestroy { dataSource = new MatTableDataSource(); readonly fileNameTruncLen = FILE_NAME_TRUNCATE_LENGTH; - + constructor( private decisionService: NoticeOfIntentDecisionV2Service, private dialog: MatDialog, @@ -85,7 +85,7 @@ export class DecisionDocumentsComponent implements OnInit, OnDestroy { private openFileDialog(existingDocument?: NoticeOfIntentDecisionDocumentDto) { if (this.decision) { this.dialog - .open(DecisionDocumentUploadDialogComponent, { + .open(DocumentUploadDialogComponent, { minWidth: '600px', maxWidth: '800px', width: '70%', @@ -93,9 +93,12 @@ export class DecisionDocumentsComponent implements OnInit, OnDestroy { fileId: this.fileId, decisionUuid: this.decision?.uuid, existingDocument: existingDocument, + decisionService: this.decisionService, + allowedVisibilityFlags: ['A', 'C', 'G', 'P'], + allowsFileEdit: true, }, }) - .beforeClosed() + .afterClosed() .subscribe((isDirty: boolean) => { if (isDirty && this.decision) { this.decisionService.loadDecision(this.decision.uuid); diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html deleted file mode 100644 index 7ea010a0d0..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.html +++ /dev/null @@ -1,111 +0,0 @@ -
-

{{ title }} Document

-
-
-
-
-
- Document Upload* -
- -
- -
or drag and drop them here
- -
-
-
- {{ pendingFile.name }} -  ({{ pendingFile.size | filesize }}) -
- -
-
- - -
- - warning A virus was detected in the file. Choose another file and try again. - -
- -
- - Document Name - - {{ extension }} - -
- -
- - -
-
- - Source - - {{ source }} - - -
-
- Visible To: -
- Applicant, L/FNG, and Commissioner -
-
- Public -
-
-
- - -
- - - -
-
-
diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss deleted file mode 100644 index 27b3b52f32..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss +++ /dev/null @@ -1,82 +0,0 @@ -@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; - } -} - -.file-drag-drop { - background: colors.$white; - border-radius: 4px; - - &:hover { - background: colors.$grey-light !important; - } - - button:nth-child(1) { - width: 100%; - background: colors.$white; - padding: 24px; - border: none; - - &:hover { - background: colors.$grey-light !important; - } - } - - .drag-text { - margin-top: 14px; - color: colors.$grey; - } - - .icon { - color: colors.$grey; - font-size: 36px; - height: 36px; - align-content: center; - margin-bottom: 4px; - } -} diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts deleted file mode 100644 index 10697accdf..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 { NoticeOfIntentDecisionV2Service } from '../../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-v2.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 mockNOIDecService: DeepMocked; - - beforeEach(async () => { - mockNOIDecService = createMock(); - - const mockDialogRef = { - close: jest.fn(), - afterClosed: jest.fn(), - subscribe: jest.fn(), - backdropClick: () => new EventEmitter(), - }; - - await TestBed.configureTestingModule({ - declarations: [DecisionDocumentUploadDialogComponent], - providers: [ - { - provide: NoticeOfIntentDecisionV2Service, - useValue: mockNOIDecService, - }, - { 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/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts deleted file mode 100644 index 97ada1a8c9..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { NoticeOfIntentDecisionV2Service } from '../../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision-v2.service'; -import { NoticeOfIntentDecisionDocumentDto } from '../../../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto'; -import { ToastService } from '../../../../../../services/toast/toast.service'; -import { DOCUMENT_SOURCE } from '../../../../../../shared/document/document.dto'; -import { FileHandle } from '../../../../../../shared/drag-drop-file/drag-drop-file.directive'; -import { splitExtension } from '../../../../../../shared/utils/file'; - -@Component({ - selector: 'app-noi-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'; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - 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]); - - visibleToInternal = new FormControl({ disabled: true, value: true }, [Validators.required]); - visibleToPublic = 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, - visibleToInternal: this.visibleToInternal, - visibleToPublic: this.visibleToPublic, - }); - - pendingFile: File | undefined; - existingFile: string | undefined; - showVirusError = false; - extension = ''; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { fileId: string; decisionUuid: string; existingDocument?: NoticeOfIntentDecisionDocumentDto }, - protected dialog: MatDialogRef, - private decisionService: NoticeOfIntentDecisionV2Service, - private toastService: ToastService, - ) {} - - ngOnInit(): void { - if (this.data.existingDocument) { - const document = this.data.existingDocument; - this.title = 'Edit'; - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - this.form.patchValue({ - name: fileName, - }); - this.existingFile = document.fileName; - } - } - - async onSubmit() { - const file = this.pendingFile; - if (file) { - const renamedFile = new File([file], this.name.value + this.extension ?? file.name, { type: file.type }); - 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.extension, - ); - - 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]; - - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.name.setValue(fileName); - this.extension = extension; - this.showVirusError = false; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - 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, - ); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } -} diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.html b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.html index e96e0dd99c..d01d248922 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.html +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.html @@ -75,11 +75,28 @@
Res #{{ decision.resolutionNumber }}/{{ decision.resolutionYear }}
+ +
+ +
+ - +
Resolution

-
Decision Date
- {{ decision.date | momentFormat }} + Decision Date:  + {{ decision.date ? (decision.date | momentFormat) : undefined }}
-
-
Decision Maker
+
+ Decision Maker:  {{ decision.decisionMaker }}
-
-
Decision Outcome
- {{ decision.outcome.label }} {{ decision.isSubjectToConditions ? '- Subject to Conditions' : '' }} -
- -
-
Decision Maker Name
- {{ decision.decisionMakerName }} - +
+
+ Rescinded Date:  + {{ decision.rescindedDate | momentFormat }} + +
- -
-
Decision Description
- {{ decision.decisionDescription }} - +
+
+ Rescinded Comment:  + {{ decision.rescindedComment }} + +
- -
-
Rescinded Date
- {{ decision.rescindedDate | momentFormat }} - +
+
+ {{ decision.decisionDescription }} + +
+ +
+ {{ component.noticeOfIntentDecisionComponentType?.label }} + {{ getDate(component.uuid) }} + Conditions: + + + + + None +
- -
-
Rescinded Comment
- {{ decision.rescindedComment }} - + + +
+ +
+
+
+
+
+ {{ document.fileName }} +  ({{ document.fileSize ? (document.fileSize | filesize) : '' }}) +
+
-
-

Documents

-
- -
+ +
+
+
+ {{ decision.flaggedBy?.prettyName }} +
+
Follow-Up Date: {{ formatDate(decision.followUpAt) || 'No Data' }}
+
- - -

Components

-
- +
+ Flagged for condition follow-up because: {{ decision.reasonFlagged }} +
- -
{{ component.noticeOfIntentDecisionComponentType?.label }}
-
- - +
+ {{ formatDate(decision.flagEditedAt, true) }} (Last Edited by {{ decision.flagEditedBy?.prettyName }}) +
+ +
+
+
+
+ + + +

Components

+
+ + + +
{{ component.noticeOfIntentDecisionComponentType?.label }}
+
+ - + > + + - - - + > + + - - - + > + + -
-
-
Agricultural Capability
- {{ component.agCap }} - -
-
-
Agricultural Capability Source
- {{ component.agCapSource }} - -
+
+
+
Agricultural Capability
+ {{ component.agCap }} + +
+
+
Agricultural Capability Source
+ {{ component.agCapSource }} + +
-
-
Agricultural Capability Mapsheet Reference
- {{ component.agCapMap }} - -
-
-
Agricultural Capability Consultant
- {{ component.agCapConsultant }} - +
+
Agricultural Capability Mapsheet Reference
+ {{ component.agCapMap }} + +
+
+
Agricultural Capability Consultant
+ {{ component.agCapConsultant }} + +
+
+ +
+
+

Audit

+
+
+
+
Audit Date
+ {{ decision.auditDate | momentFormat }} + + +
-
- -
-
- - -

Conditions

-
-
- -

Audit

-
-
-
-
Audit Date
- {{ decision.auditDate | momentFormat }} - - - -
-
-
- -
- +
+ +
diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.scss b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.scss index 9c66ba4c47..ffe555c27b 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.scss +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.scss @@ -1,10 +1,11 @@ @use '../../../../../styles/colors'; section { - margin-bottom: 64px; + margin-bottom: 15px; } h4 { + padding-top: 15px; margin-bottom: 12px !important; } @@ -32,7 +33,6 @@ hr { .decision-section { background: colors.$grey-light; - padding: 18px; } .decision-section-no-title { @@ -40,9 +40,24 @@ hr { padding: 1px 18px; } +.component-summary { + padding: 10px 0; +} + +.component-title { + padding-top: 20px; +} + +.status-pill { + padding: 6px 6px; +} + .header { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: auto auto auto; + grid-template-rows: auto auto; + row-gap: 10px; + column-gap: 28px; margin-bottom: 36px; .title { @@ -50,6 +65,8 @@ hr { align-items: center; justify-content: space-between; gap: 28px; + grid-row: 1/2; + grid-column: 1/2; .days { display: inline-block; @@ -66,6 +83,16 @@ hr { } } +.edit-decision-button, +.revert-to-draft-button { + grid-row: 1/2; + grid-column: 3/4; +} + +.revert-to-draft-button { + text-align: right; +} + .loading-overlay { position: absolute; z-index: 2; @@ -115,10 +142,23 @@ hr { position: absolute; } +.document { + padding: 9px 16px; + border-radius: 4px; + border: 1px solid colors.$grey; + background: colors.$white; + margin: 8px 0; +} + +.align-right { + display: flex; + justify-content: flex-end; +} + :host ::ng-deep { .grid-2 { margin-top: 18px; - margin-bottom: 18px; + margin-bottom: 6px; display: grid; grid-template-columns: 50% 50%; grid-row-gap: 18px; @@ -157,4 +197,90 @@ hr { .pre-wrapped-text { white-space: pre-wrap; } + + .mat-link { + color: colors.$link-color; + } +} + +.flag-button-container { + justify-self: end; + grid-row: 2/3; + grid-column: 1/4; + + @media screen and (min-width: 1440px) { + grid-row: 1/2; + grid-column: 2/3; + } +} + +.flag-button { + display: flex; + align-items: center; + column-gap: 5px; + + text-align: left; + font-weight: normal; + text-wrap: nowrap; + + background-color: transparent; + padding: 5px; + border: none; + border-radius: 5px; + margin: 0; + + &:hover { + background-color: colors.$grey-light; + } + + mat-icon { + flex-shrink: 0; + } + + &.flagged { + mat-icon { + color: blue; + } + } +} + +.flag-details { + background-color: white; + display: flex; + flex-direction: column; + gap: 16px; + + padding: 16px; + border: 1px solid colors.$grey; + border-radius: 4px; +} + +.flag-details-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.flag-details-flagger { + display: flex; + gap: 5px; + + mat-icon { + color: blue; + } +} + +.flag-details-body { + line-height: 1.5; +} + +.flag-details-footer { + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.flag-details-edited-details { + font-size: 12px; + color: colors.$grey; } diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.spec.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.spec.ts index 5ea1e1d7dc..c7134f5ddf 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.spec.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.spec.ts @@ -16,6 +16,7 @@ import { NoticeOfIntentDetailService } from '../../../../services/notice-of-inte import { NoticeOfIntentDto } from '../../../../services/notice-of-intent/notice-of-intent.dto'; import { ToastService } from '../../../../services/toast/toast.service'; import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { HttpClient } from '@angular/common/http'; import { DecisionV2Component } from './decision-v2.component'; @@ -25,6 +26,7 @@ describe('DecisionV2Component', () => { let mockNOIDecisionService: DeepMocked; let mockNOIDetailService: DeepMocked; let mockNOIDecisionComponentService: DeepMocked; + let mockHttpClient: DeepMocked; beforeEach(async () => { mockNOIDecisionService = createMock(); @@ -36,6 +38,8 @@ describe('DecisionV2Component', () => { mockNOIDecisionComponentService = createMock(); + mockHttpClient = createMock(); + await TestBed.configureTestingModule({ imports: [MatSnackBarModule, MatMenuModule], declarations: [DecisionV2Component], @@ -72,6 +76,10 @@ describe('DecisionV2Component', () => { provide: ActivatedRoute, useValue: {}, }, + { + provide: HttpClient, + useValue: mockHttpClient, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.ts index 5e43460b39..dd386ad7d4 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-v2.component.ts @@ -8,6 +8,7 @@ import { NOI_DECISION_COMPONENT_TYPE, NoticeOfIntentDecisionDto, NoticeOfIntentDecisionOutcomeCodeDto, + UpdateNoticeOfIntentDecisionDto, } from '../../../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto'; import { NoticeOfIntentDetailService } from '../../../../services/notice-of-intent/notice-of-intent-detail.service'; import { NoticeOfIntentDto } from '../../../../services/notice-of-intent/notice-of-intent.dto'; @@ -16,10 +17,21 @@ import { DRAFT_DECISION_TYPE_LABEL, MODIFICATION_TYPE_LABEL, RELEASED_DECISION_TYPE_LABEL, + DECISION_CONDITION_COMPLETE_LABEL, + DECISION_CONDITION_ONGOING_LABEL, + DECISION_CONDITION_PASTDUE_LABEL, + DECISION_CONDITION_PENDING_LABEL, + DECISION_CONDITION_EXPIRED_LABEL, } from '../../../../shared/application-type-pill/application-type-pill.constants'; import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; import { formatDateForApi } from '../../../../shared/utils/api-date-formatter'; import { RevertToDraftDialogComponent } from './revert-to-draft-dialog/revert-to-draft-dialog.component'; +import { NoticeOfIntentConditionWithStatus, getEndDate } from '../../../../shared/utils/decision-methods'; +import moment from 'moment'; +import { FlagDialogComponent, FlagDialogIO } from '../../../../shared/flag-dialog/flag-dialog.component'; +import { UserDto } from '../../../../services/user/user.dto'; +import { UserService } from '../../../../services/user/user.service'; +import { UnFlagDialogComponent, UnFlagDialogIO } from '../../../../shared/unflag-dialog/unflag-dialog.component'; type LoadingDecision = NoticeOfIntentDecisionDto & { loading: boolean; @@ -51,6 +63,11 @@ export class DecisionV2Component implements OnInit, OnDestroy { COMPONENT_TYPE = NOI_DECISION_COMPONENT_TYPE; + isSummary = false; + + conditions: Record = {}; + profile: UserDto | undefined; + constructor( public dialog: MatDialog, private noticeOfIntentDetailService: NoticeOfIntentDetailService, @@ -61,6 +78,7 @@ export class DecisionV2Component implements OnInit, OnDestroy { private router: Router, private activatedRouter: ActivatedRoute, private elementRef: ElementRef, + private userService: UserService, ) {} ngOnInit(): void { @@ -73,6 +91,10 @@ export class DecisionV2Component implements OnInit, OnDestroy { this.noticeOfIntent = noticeOfIntent; } }); + + this.userService.$userProfile.pipe(takeUntil(this.$destroy)).subscribe((profile) => { + this.profile = profile; + }); } async loadDecisions(fileNumber: string) { @@ -81,10 +103,31 @@ export class DecisionV2Component implements OnInit, OnDestroy { this.decisionService.loadDecisions(fileNumber); this.decisionService.$decisions.pipe(takeUntil(this.$destroy)).subscribe((decisions) => { - this.decisions = decisions.map((decision) => ({ - ...decision, - loading: false, - })); + this.decisions = decisions.map((decision) => { + decision.conditions.map(async (x) => { + if (x.components) { + const componentId = x.components[0].uuid; + if (componentId) { + const conditionStatus = await this.decisionService.getStatus(x.uuid); + if (this.conditions[componentId]) { + this.conditions[componentId].push({ + ...x, + conditionStatus: conditionStatus, + }); + } else { + this.conditions[componentId] = [{ + ...x, + conditionStatus: conditionStatus, + }]; + } + } + } + }); + return { + ...decision, + loading: false, + } + }); this.scrollToDecision(); @@ -215,4 +258,114 @@ export class DecisionV2Component implements OnInit, OnDestroy { }); } } + + toggleSummary() { + this.isSummary = !this.isSummary; + } + + getConditions(uuid: string | undefined) { + return uuid && this.conditions[uuid] ? [...new Set(this.conditions[uuid].map((x) => this.getPillLabel(x.conditionStatus.status)))] : []; + } + + getDate(uuid: string | undefined) { + return getEndDate(uuid, this.conditions); + } + + async openFile(decisionUuid: string, fileUuid: string, fileName: string) { + await this.decisionService.downloadFile(decisionUuid, fileUuid, fileName); + } + + private getPillLabel(status: string) { + switch (status) { + case 'ONGOING': + return DECISION_CONDITION_ONGOING_LABEL; + case 'COMPLETED': + return DECISION_CONDITION_COMPLETE_LABEL; + case 'PASTDUE': + return DECISION_CONDITION_PASTDUE_LABEL; + case 'PENDING': + return DECISION_CONDITION_PENDING_LABEL; + case 'EXPIRED': + return DECISION_CONDITION_EXPIRED_LABEL; + default: + return DECISION_CONDITION_ONGOING_LABEL; + } + } + + async flag(decision: LoadingDecision, index: number, isEditing: boolean) { + this.dialog + .open(FlagDialogComponent, { + minWidth: '800px', + maxWidth: '800px', + maxHeight: '80vh', + width: '90%', + autoFocus: false, + data: { + isEditing, + decisionNumber: index, + reasonFlagged: decision.reasonFlagged, + followUpAt: decision.followUpAt, + }, + }) + .beforeClosed() + .subscribe(async ({ isEditing, reasonFlagged, followUpAt, isSaving }: FlagDialogIO) => { + if (isSaving) { + const updateDto: UpdateNoticeOfIntentDecisionDto = { + isDraft: decision.isDraft, + isFlagged: true, + reasonFlagged, + flagEditedByUuid: this.profile?.uuid, + flagEditedAt: moment().toDate().getTime(), + }; + + if (!isEditing) { + updateDto.flaggedByUuid = this.profile?.uuid; + } + + if (followUpAt !== undefined) { + updateDto.followUpAt = followUpAt; + } + + await this.decisionService.update(decision.uuid, updateDto); + await this.loadDecisions(this.fileNumber); + } + }); + } + + async unflag(decision: LoadingDecision, index: number) { + this.dialog + .open(UnFlagDialogComponent, { + minWidth: '800px', + maxWidth: '800px', + maxHeight: '80vh', + width: '90%', + autoFocus: false, + data: { + decisionNumber: index, + }, + }) + .beforeClosed() + .subscribe(async ({confirmed} : UnFlagDialogIO) => { + if (confirmed) { + await this.decisionService.update(decision.uuid, { + isDraft: decision.isDraft, + isFlagged: false, + reasonFlagged: null, + followUpAt: null, + flaggedByUuid: null, + flagEditedByUuid: null, + flagEditedAt: null, + }); + await this.loadDecisions(this.fileNumber); + } + }); + } + + formatDate(timestamp?: number | null, includeTime = false): string { + if (timestamp === undefined || timestamp === null) { + return ''; + } + + return moment(new Date(timestamp)).format(`YYYY-MMM-DD ${includeTime ? 'hh:mm:ss A' : ''}`); + } } diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision.module.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision.module.ts index 505be59742..165a426cb8 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision.module.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision.module.ts @@ -17,12 +17,12 @@ import { RosoInputComponent } from './decision-v2/decision-input/decision-compon import { DecisionComponentsComponent } from './decision-v2/decision-input/decision-components/decision-components.component'; import { DecisionConditionComponent } from './decision-v2/decision-input/decision-conditions/decision-condition/decision-condition.component'; import { DecisionConditionsComponent } from './decision-v2/decision-input/decision-conditions/decision-conditions.component'; -import { DecisionDocumentUploadDialogComponent } from './decision-v2/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component'; import { DecisionInputV2Component } from './decision-v2/decision-input/decision-input-v2.component'; import { DecisionV2Component } from './decision-v2/decision-v2.component'; import { ReleaseDialogComponent } from './decision-v2/release-dialog/release-dialog.component'; import { RevertToDraftDialogComponent } from './decision-v2/revert-to-draft-dialog/revert-to-draft-dialog.component'; import { DecisionComponent } from './decision.component'; +import { ConditionCardDialogComponent } from './conditions/condition-card-dialog/condition-card-dialog.component'; export const decisionChildRoutes = [ { @@ -59,7 +59,6 @@ export const decisionChildRoutes = [ ReleaseDialogComponent, DecisionComponentComponent, DecisionComponentsComponent, - DecisionDocumentUploadDialogComponent, RevertToDraftDialogComponent, DecisionDocumentsComponent, DecisionConditionComponent, @@ -73,6 +72,7 @@ export const decisionChildRoutes = [ ConditionsComponent, ConditionComponent, BasicComponent, + ConditionCardDialogComponent, ], imports: [SharedModule.forRoot(), RouterModule.forChild(decisionChildRoutes), NgxMaskDirective, MatChipsModule], }) diff --git a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.html b/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.html deleted file mode 100644 index 25a0845a98..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.html +++ /dev/null @@ -1,142 +0,0 @@ -
-

{{ title }} Document

- Superseded - Not associated with Applicant Submission in Portal -
-
-
-
-
- Document Upload* -
- -
- -
or drag and drop them here
- -
-
-
- {{ pendingFile.name }} -  ({{ pendingFile.size | filesize }}) -
- -
-
-
- {{ existingFile.name }} -  ({{ existingFile.size | filesize }}) -
- -
- - warning A virus was detected in the file. Choose another file and try again. - -
- -
- - Document Name - - {{ this.extension }} - -
- -
- - -
-
- - Source - - {{ source }} - - -
-
- - Associated Parcel - - - #{{ parcel.index + 1 }} PID: - {{ parcel.pid | mask: '000-000-000' }} - No Data - - -
-
- - Associated Organization - - - {{ owner.label }} - - - -
-
- Visible To: -
- Applicant, L/FNG -
-
- Public -
-
-
- - -
- - - -
-
-
diff --git a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.scss b/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.scss deleted file mode 100644 index ba93743c38..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.scss +++ /dev/null @@ -1,88 +0,0 @@ -@use '../../../../../styles/colors'; - -.form { - display: grid; - grid-template-columns: 1fr 1fr; - row-gap: 32px; - column-gap: 32px; - - .double { - grid-column: 1/3; - } -} - -.full-width { - width: 100%; -} - -a { - word-break: break-all; -} - -.file { - border: 1px solid #000; - border-radius: 8px; - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; -} - -.upload-button { - margin-top: 6px !important; - - &.error { - border: 2px solid colors.$error-color; - } -} - -.spinner { - display: inline-block; - margin-right: 4px; -} - -:host::ng-deep { - .mdc-button__label { - display: flex; - align-items: center; - } -} - -.superseded-warning { - background-color: colors.$secondary-color-dark; - color: #fff; - padding: 0 4px; -} - -.file-drag-drop { - background: colors.$white; - border-radius: 4px; - - &:hover { - background: colors.$grey-light !important; - } - - button:nth-child(1) { - width: 100%; - background: colors.$white; - padding: 24px; - border: none; - - &:hover { - background: colors.$grey-light !important; - } - } - - .drag-text { - margin-top: 14px; - color: colors.$grey; - } - - .icon { - color: colors.$grey; - font-size: 36px; - height: 36px; - align-content: center; - margin-bottom: 4px; - } -} \ No newline at end of file diff --git a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.spec.ts deleted file mode 100644 index 113f491adb..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 { NoiDocumentService } from '../../../../services/notice-of-intent/noi-document/noi-document.service'; -import { NoticeOfIntentParcelService } from '../../../../services/notice-of-intent/notice-of-intent-parcel/notice-of-intent-parcel.service'; -import { NoticeOfIntentSubmissionService } from '../../../../services/notice-of-intent/notice-of-intent-submission/notice-of-intent-submission.service'; -import { ToastService } from '../../../../services/toast/toast.service'; - -import { DocumentUploadDialogComponent } from './document-upload-dialog.component'; - -describe('DocumentUploadDialogComponent', () => { - let component: DocumentUploadDialogComponent; - let fixture: ComponentFixture; - - let mockNoiDocService: DeepMocked; - let mockParcelService: DeepMocked; - let mockSubmissionService: DeepMocked; - - beforeEach(async () => { - mockNoiDocService = createMock(); - - const mockDialogRef = { - close: jest.fn(), - afterClosed: jest.fn(), - subscribe: jest.fn(), - backdropClick: () => new EventEmitter(), - }; - - await TestBed.configureTestingModule({ - declarations: [DocumentUploadDialogComponent], - providers: [ - { - provide: NoiDocumentService, - useValue: mockNoiDocService, - }, - { - provide: NoticeOfIntentParcelService, - useValue: mockParcelService, - }, - { - provide: NoticeOfIntentSubmissionService, - useValue: mockSubmissionService, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: ToastService, useValue: {} }, - ], - imports: [MatDialogModule], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(DocumentUploadDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.ts deleted file mode 100644 index 2f4a9ff3a1..0000000000 --- a/alcs-frontend/src/app/features/notice-of-intent/documents/document-upload-dialog/document-upload-dialog.component.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Subject } from 'rxjs'; -import { - NoticeOfIntentDocumentDto, - UpdateNoticeOfIntentDocumentDto, -} from '../../../../services/notice-of-intent/noi-document/noi-document.dto'; -import { NoiDocumentService } from '../../../../services/notice-of-intent/noi-document/noi-document.service'; -import { NoticeOfIntentParcelService } from '../../../../services/notice-of-intent/notice-of-intent-parcel/notice-of-intent-parcel.service'; -import { NoticeOfIntentSubmissionService } from '../../../../services/notice-of-intent/notice-of-intent-submission/notice-of-intent-submission.service'; -import { ToastService } from '../../../../services/toast/toast.service'; -import { - DOCUMENT_SOURCE, - DOCUMENT_SYSTEM, - DOCUMENT_TYPE, - DocumentTypeDto, -} from '../../../../shared/document/document.dto'; -import { FileHandle } from '../../../../shared/drag-drop-file/drag-drop-file.directive'; -import { splitExtension } from '../../../../shared/utils/file'; - -@Component({ - selector: 'app-document-upload-dialog', - templateUrl: './document-upload-dialog.component.html', - styleUrls: ['./document-upload-dialog.component.scss'], -}) -export class DocumentUploadDialogComponent implements OnInit, OnDestroy { - $destroy = new Subject(); - DOCUMENT_TYPE = DOCUMENT_TYPE; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - title = 'Create'; - isDirty = false; - isSaving = false; - allowsFileEdit = true; - documentTypeAhead: string | undefined = undefined; - - name = new FormControl('', [Validators.required]); - type = new FormControl(undefined, [Validators.required]); - source = new FormControl('', [Validators.required]); - - parcelId = new FormControl(null); - ownerId = new FormControl(null); - - visibleToInternal = new FormControl(false, [Validators.required]); - visibleToPublic = new FormControl(false, [Validators.required]); - - documentTypes: DocumentTypeDto[] = []; - documentSources = Object.values(DOCUMENT_SOURCE); - selectableParcels: { uuid: string; index: number; pid?: string }[] = []; - selectableOwners: { uuid: string; label: string }[] = []; - - form = new FormGroup({ - name: this.name, - type: this.type, - source: this.source, - visibleToInternal: this.visibleToInternal, - visibleToPublic: this.visibleToPublic, - parcelId: this.parcelId, - ownerId: this.ownerId, - }); - - pendingFile: File | undefined; - existingFile: { name: string; size: number } | undefined; - showSupersededWarning = false; - showVirusError = false; - extension = ''; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { fileId: string; existingDocument?: NoticeOfIntentDocumentDto }, - protected dialog: MatDialogRef, - private noiDocumentService: NoiDocumentService, - private noiSubmissionService: NoticeOfIntentSubmissionService, - private noiParcelService: NoticeOfIntentParcelService, - private toastService: ToastService, - ) {} - - ngOnInit(): void { - this.loadDocumentTypes(); - - if (this.data.existingDocument) { - const document = this.data.existingDocument; - this.title = 'Edit'; - this.allowsFileEdit = document.system === DOCUMENT_SYSTEM.ALCS; - - if (document.type?.code === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE) { - this.prepareCertificateOfTitleUpload(document.uuid); - this.allowsFileEdit = false; - } - if (document.type?.code === DOCUMENT_TYPE.CORPORATE_SUMMARY) { - this.allowsFileEdit = false; - this.prepareCorporateSummaryUpload(document.uuid); - } - - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - this.form.patchValue({ - name: fileName, - type: document.type?.code, - source: document.source, - visibleToInternal: document.visibilityFlags.includes('A'), - visibleToPublic: document.visibilityFlags.includes('P'), - }); - this.documentTypeAhead = document.type!.code; - this.existingFile = { - name: document.fileName, - size: 0, - }; - } - } - - async onSubmit() { - const visibilityFlags: ('A' | 'G' | 'P')[] = []; - - if (this.visibleToInternal.getRawValue()) { - visibilityFlags.push('A'); - visibilityFlags.push('G'); - } - - if (this.visibleToPublic.getRawValue()) { - visibilityFlags.push('P'); - } - - const file = this.pendingFile; - const dto: UpdateNoticeOfIntentDocumentDto = { - fileName: this.name.value! + this.extension, - source: this.source.value as DOCUMENT_SOURCE, - typeCode: this.type.value as DOCUMENT_TYPE, - visibilityFlags, - parcelUuid: this.parcelId.value ?? undefined, - ownerUuid: this.ownerId.value ?? undefined, - file, - }; - - this.isSaving = true; - if (this.data.existingDocument) { - await this.noiDocumentService.update(this.data.existingDocument.uuid, dto); - } else if (file !== undefined) { - try { - await this.noiDocumentService.upload(this.data.fileId, { - ...dto, - file, - }); - } catch (err) { - this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - this.showVirusError = true; - this.isSaving = false; - this.pendingFile = undefined; - return; - } - } - } - this.dialog.close(true); - this.isSaving = false; - } - - ngOnDestroy(): void { - this.$destroy.next(); - this.$destroy.complete(); - } - - filterDocumentTypes(term: string, item: DocumentTypeDto) { - const termLower = term.toLocaleLowerCase(); - return ( - item.label.toLocaleLowerCase().indexOf(termLower) > -1 || - item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 - ); - } - - async onDocTypeSelected($event?: DocumentTypeDto) { - if ($event) { - this.type.setValue($event.code); - } else { - this.type.setValue(undefined); - } - - if (this.type.value === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE) { - await this.prepareCertificateOfTitleUpload(); - this.visibleToInternal.setValue(true); - } else { - this.parcelId.setValue(null); - this.parcelId.setValidators([]); - this.parcelId.updateValueAndValidity(); - } - - if (this.type.value === DOCUMENT_TYPE.CORPORATE_SUMMARY) { - await this.prepareCorporateSummaryUpload(); - this.visibleToInternal.setValue(true); - } else { - this.ownerId.setValue(null); - this.ownerId.setValidators([]); - this.ownerId.updateValueAndValidity(); - } - } - - uploadFile(event: Event) { - const element = event.target as HTMLInputElement; - const selectedFiles = element.files; - if (selectedFiles && selectedFiles[0]) { - this.pendingFile = selectedFiles[0]; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.name.setValue(fileName); - this.extension = extension; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - openFile() { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile); - window.open(fileURL, '_blank'); - } - } - - async openExistingFile() { - if (this.data.existingDocument) { - await this.noiDocumentService.download(this.data.existingDocument.uuid, this.data.existingDocument.fileName); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } - - private async prepareCertificateOfTitleUpload(uuid?: string) { - const parcels = await this.noiParcelService.fetchParcels(this.data.fileId); - if (parcels.length > 0) { - this.parcelId.setValidators([Validators.required]); - this.parcelId.updateValueAndValidity(); - this.source.setValue(DOCUMENT_SOURCE.APPLICANT); - - const selectedParcel = parcels.find((parcel) => parcel.certificateOfTitleUuid === uuid); - if (selectedParcel) { - this.parcelId.setValue(selectedParcel.uuid); - } else if (uuid) { - this.showSupersededWarning = true; - } - - this.selectableParcels = parcels.map((parcel, index) => ({ - uuid: parcel.uuid, - pid: parcel.pid, - index: index, - })); - } - } - - private async prepareCorporateSummaryUpload(uuid?: string) { - const submission = await this.noiSubmissionService.fetchSubmission(this.data.fileId); - if (submission.owners.length > 0) { - const owners = submission.owners; - this.ownerId.setValidators([Validators.required]); - this.ownerId.updateValueAndValidity(); - this.source.setValue(DOCUMENT_SOURCE.APPLICANT); - - const selectedOwner = owners.find((owner) => owner.corporateSummaryUuid === uuid); - if (selectedOwner) { - this.ownerId.setValue(selectedOwner.uuid); - } else if (uuid) { - this.showSupersededWarning = true; - } - - this.selectableOwners = owners - .filter((owner) => owner.type.code === 'ORGZ') - .map((owner, index) => ({ - label: owner.organizationName ?? owner.displayName, - uuid: owner.uuid, - })); - } - } - - private async loadDocumentTypes() { - const docTypes = await this.noiDocumentService.fetchTypes(); - docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); - this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); - } -} diff --git a/alcs-frontend/src/app/features/notice-of-intent/documents/documents.component.html b/alcs-frontend/src/app/features/notice-of-intent/documents/documents.component.html index b7f7923896..281cbdf840 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/documents/documents.component.html +++ b/alcs-frontend/src/app/features/notice-of-intent/documents/documents.component.html @@ -17,7 +17,7 @@

Documents

(click)="openFile(element.uuid, element.fileName)" [matTooltip]="element.fileName" [matTooltipDisabled]="element.fileName.length <= fileNameTruncLen"> - {{ element.fileName | truncate: fileNameTruncLen}} + {{ element.fileName }}
@@ -55,13 +55,14 @@

Documents

Actions - - -
or drag and drop them here
- - -
-
- {{ pendingFile.name }} -  ({{ pendingFile.size | filesize }}) -
- -
-
-
- {{ existingFile.name }} -  ({{ existingFile.size | filesize }}) -
- -
- - warning A virus was detected in the file. Choose another file and try again. - - - -
- - Document Name - - {{ extension }} - -
- -
- - -
-
- - Source - - {{ source }} - - -
-
- Visible To: -
- Applicant, L/FNG -
-
- Public -
-
- - - -
- - - -
-
- diff --git a/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.scss b/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.scss deleted file mode 100644 index ba93743c38..0000000000 --- a/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.scss +++ /dev/null @@ -1,88 +0,0 @@ -@use '../../../../../styles/colors'; - -.form { - display: grid; - grid-template-columns: 1fr 1fr; - row-gap: 32px; - column-gap: 32px; - - .double { - grid-column: 1/3; - } -} - -.full-width { - width: 100%; -} - -a { - word-break: break-all; -} - -.file { - border: 1px solid #000; - border-radius: 8px; - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; -} - -.upload-button { - margin-top: 6px !important; - - &.error { - border: 2px solid colors.$error-color; - } -} - -.spinner { - display: inline-block; - margin-right: 4px; -} - -:host::ng-deep { - .mdc-button__label { - display: flex; - align-items: center; - } -} - -.superseded-warning { - background-color: colors.$secondary-color-dark; - color: #fff; - padding: 0 4px; -} - -.file-drag-drop { - background: colors.$white; - border-radius: 4px; - - &:hover { - background: colors.$grey-light !important; - } - - button:nth-child(1) { - width: 100%; - background: colors.$white; - padding: 24px; - border: none; - - &:hover { - background: colors.$grey-light !important; - } - } - - .drag-text { - margin-top: 14px; - color: colors.$grey; - } - - .icon { - color: colors.$grey; - font-size: 36px; - height: 36px; - align-content: center; - margin-bottom: 4px; - } -} \ No newline at end of file diff --git a/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.spec.ts deleted file mode 100644 index 1ccefa2049..0000000000 --- a/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 { NoiDocumentService } from '../../../../services/notice-of-intent/noi-document/noi-document.service'; -import { NoticeOfIntentParcelService } from '../../../../services/notice-of-intent/notice-of-intent-parcel/notice-of-intent-parcel.service'; -import { NoticeOfIntentSubmissionService } from '../../../../services/notice-of-intent/notice-of-intent-submission/notice-of-intent-submission.service'; -import { NotificationDocumentService } from '../../../../services/notification/notification-document/notification-document.service'; -import { ToastService } from '../../../../services/toast/toast.service'; - -import { DocumentUploadDialogComponent } from './document-upload-dialog.component'; - -describe('DocumentUploadDialogComponent', () => { - let component: DocumentUploadDialogComponent; - let fixture: ComponentFixture; - - let mockNotificationDocumentService: DeepMocked; - - beforeEach(async () => { - mockNotificationDocumentService = createMock(); - - const mockDialogRef = { - close: jest.fn(), - afterClosed: jest.fn(), - subscribe: jest.fn(), - backdropClick: () => new EventEmitter(), - }; - - await TestBed.configureTestingModule({ - declarations: [DocumentUploadDialogComponent], - providers: [ - { - provide: NotificationDocumentService, - useValue: mockNotificationDocumentService, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: ToastService, useValue: {} }, - ], - imports: [MatDialogModule], - schemas: [NO_ERRORS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(DocumentUploadDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.ts deleted file mode 100644 index 0118619579..0000000000 --- a/alcs-frontend/src/app/features/notification/documents/document-upload-dialog/document-upload-dialog.component.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Subject } from 'rxjs'; -import { UpdateNoticeOfIntentDocumentDto } from '../../../../services/notice-of-intent/noi-document/noi-document.dto'; -import { NotificationDocumentDto } from '../../../../services/notification/notification-document/notification-document.dto'; -import { NotificationDocumentService } from '../../../../services/notification/notification-document/notification-document.service'; -import { ToastService } from '../../../../services/toast/toast.service'; -import { - DOCUMENT_SOURCE, - DOCUMENT_SYSTEM, - DOCUMENT_TYPE, - DocumentTypeDto, -} from '../../../../shared/document/document.dto'; -import { splitExtension } from '../../../../shared/utils/file'; -import { FileHandle } from '../../../../shared/drag-drop-file/drag-drop-file.directive'; - -@Component({ - selector: 'app-document-upload-dialog', - templateUrl: './document-upload-dialog.component.html', - styleUrls: ['./document-upload-dialog.component.scss'], -}) -export class DocumentUploadDialogComponent implements OnInit, OnDestroy { - $destroy = new Subject(); - DOCUMENT_TYPE = DOCUMENT_TYPE; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - title = 'Create'; - isDirty = false; - isSaving = false; - allowsFileEdit = true; - documentTypeAhead: string | undefined = undefined; - - name = new FormControl('', [Validators.required]); - type = new FormControl(undefined, [Validators.required]); - source = new FormControl('', [Validators.required]); - - visibleToInternal = new FormControl(false, [Validators.required]); - visibleToPublic = new FormControl(false, [Validators.required]); - - documentTypes: DocumentTypeDto[] = []; - documentSources = Object.values(DOCUMENT_SOURCE); - - form = new FormGroup({ - name: this.name, - type: this.type, - source: this.source, - visibleToInternal: this.visibleToInternal, - visibleToPublic: this.visibleToPublic, - }); - - pendingFile: File | undefined; - existingFile: { name: string; size: number } | undefined; - showVirusError = false; - extension = ''; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { fileId: string; existingDocument?: NotificationDocumentDto }, - protected dialog: MatDialogRef, - private notificationDocumentService: NotificationDocumentService, - private toastService: ToastService, - ) {} - - ngOnInit(): void { - this.loadDocumentTypes(); - - if (this.data.existingDocument) { - const document = this.data.existingDocument; - this.title = 'Edit'; - this.allowsFileEdit = document.system === DOCUMENT_SYSTEM.ALCS; - - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - this.form.patchValue({ - name: fileName, - type: document.type?.code, - source: document.source, - visibleToInternal: document.visibilityFlags.includes('A'), - visibleToPublic: document.visibilityFlags.includes('P'), - }); - this.documentTypeAhead = document.type!.code; - this.existingFile = { - name: document.fileName, - size: 0, - }; - } - } - - async onSubmit() { - const visibilityFlags: ('A' | 'G' | 'P')[] = []; - - if (this.visibleToInternal.getRawValue()) { - visibilityFlags.push('A'); - visibilityFlags.push('G'); - } - - if (this.visibleToPublic.getRawValue()) { - visibilityFlags.push('P'); - } - - const file = this.pendingFile; - const dto: UpdateNoticeOfIntentDocumentDto = { - fileName: this.name.value! + this.extension, - source: this.source.value as DOCUMENT_SOURCE, - typeCode: this.type.value as DOCUMENT_TYPE, - visibilityFlags, - file, - }; - - this.isSaving = true; - if (this.data.existingDocument) { - await this.notificationDocumentService.update(this.data.existingDocument.uuid, dto); - } else if (file !== undefined) { - try { - await this.notificationDocumentService.upload(this.data.fileId, { - ...dto, - file, - }); - } catch (err) { - this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - this.showVirusError = true; - this.isSaving = false; - this.pendingFile = undefined; - return; - } - } - } - this.dialog.close(true); - this.isSaving = false; - } - - ngOnDestroy(): void { - this.$destroy.next(); - this.$destroy.complete(); - } - - filterDocumentTypes(term: string, item: DocumentTypeDto) { - const termLower = term.toLocaleLowerCase(); - return ( - item.label.toLocaleLowerCase().indexOf(termLower) > -1 || - item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 - ); - } - - async onDocTypeSelected($event?: DocumentTypeDto) { - if ($event) { - this.type.setValue($event.code); - } else { - this.type.setValue(undefined); - } - } - - uploadFile(event: Event) { - const element = event.target as HTMLInputElement; - const selectedFiles = element.files; - if (selectedFiles && selectedFiles[0]) { - const { fileName, extension } = splitExtension(selectedFiles[0].name); - this.pendingFile = selectedFiles[0]; - this.name.setValue(fileName); - this.extension = extension; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - openFile() { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile); - window.open(fileURL, '_blank'); - } - } - - async openExistingFile() { - if (this.data.existingDocument) { - await this.notificationDocumentService.download( - this.data.existingDocument.uuid, - this.data.existingDocument.fileName, - ); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } - - private async loadDocumentTypes() { - const docTypes = await this.notificationDocumentService.fetchTypes(); - docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); - this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); - } -} diff --git a/alcs-frontend/src/app/features/notification/documents/documents.component.html b/alcs-frontend/src/app/features/notification/documents/documents.component.html index 9a374eafc8..3d206e15f4 100644 --- a/alcs-frontend/src/app/features/notification/documents/documents.component.html +++ b/alcs-frontend/src/app/features/notification/documents/documents.component.html @@ -17,7 +17,7 @@

Documents

(click)="openFile(element.uuid, element.fileName)" [matTooltip]="element.fileName" [matTooltipDisabled]="element.fileName.length <= fileNameTruncLen"> - {{ element.fileName | truncate: fileNameTruncLen}} + {{ element.fileName }}
@@ -65,13 +65,14 @@

Documents

Actions - - -
or drag and drop them here
- - -
-
- {{ pendingFile.name }} -  ({{ pendingFile.size | filesize }}) -
- -
-
- - -
- - warning A virus was detected in the file. Choose another file and try again. - - - -
- - Document Name - - {{ extension }} - -
- -
- - -
-
- - Source - - {{ source }} - - -
- - - -
- - - -
-
- 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 deleted file mode 100644 index 33c3c65019..0000000000 --- a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 050c883714..0000000000 --- a/alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, EventEmitter, Inject, OnInit, Output } 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'; -import { FileHandle } from '../../../../../shared/drag-drop-file/drag-drop-file.directive'; -import { splitExtension } from '../../../../../shared/utils/file'; - -@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'; - - @Output() uploadFiles: EventEmitter = new EventEmitter(); - - 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]); - - documentSources = Object.values(DOCUMENT_SOURCE); - - form = new FormGroup({ - name: this.name, - type: this.type, - source: this.source, - }); - - pendingFile: File | undefined; - existingFile: string | undefined; - showVirusError = false; - extension = ''; - - 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'; - const { fileName, extension } = splitExtension(document.fileName); - this.extension = extension; - this.form.patchValue({ - name: fileName, - }); - this.existingFile = document.fileName; - } - } - - async onSubmit() { - const file = this.pendingFile; - if (file) { - const renamedFile = new File([file], this.name.value + this.extension ?? file.name, { type: file.type }); - 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.extension, - ); - - 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]; - - const { fileName, extension } = splitExtension(selectedFiles[0].name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - } - } - - onRemoveFile() { - this.pendingFile = undefined; - this.existingFile = undefined; - this.extension = ''; - this.name.setValue(''); - } - - 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, - ); - } - } - - filesDropped($event: FileHandle) { - this.pendingFile = $event.file; - const { fileName, extension } = splitExtension(this.pendingFile.name); - this.extension = extension; - this.name.setValue(fileName); - this.showVirusError = false; - this.uploadFiles.emit($event); - } -} 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 index be0cf9b7a4..d923cf225a 100644 --- a/alcs-frontend/src/app/features/planning-review/decision/decision.module.ts +++ b/alcs-frontend/src/app/features/planning-review/decision/decision.module.ts @@ -4,7 +4,6 @@ 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'; @@ -39,7 +38,6 @@ export const decisionChildRoutes = [ DecisionDocumentsComponent, RevertToDraftDialogComponent, ReleaseDialogComponent, - DecisionDocumentUploadDialogComponent, ], imports: [SharedModule, RouterModule.forChild(decisionChildRoutes), MatTabsModule, MatOptionModule], }) diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html deleted file mode 100644 index e1e82bbaa9..0000000000 --- a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html +++ /dev/null @@ -1,114 +0,0 @@ -
-

{{ title }} Document

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

Documents

(click)="openFile(element.uuid, element.fileName)" [matTooltip]="element.fileName" [matTooltipDisabled]="element.fileName.length <= fileNameTruncLen"> - {{ element.fileName | truncate: fileNameTruncLen}} + {{ element.fileName }}
@@ -47,13 +47,14 @@

Documents

Actions - - - + + warning A virus was detected in the file. Choose another file and try again. + + warning There was a problem scanning the file for viruses. Please try again. +
@@ -65,20 +65,19 @@

{{ title }} Document

+
Source @@ -87,11 +86,11 @@

{{ title }} Document

-
+
Associated Parcel - + #{{ parcel.index + 1 }} PID: {{ parcel.pid | mask: '000-000-000' }} No Data{{ title }} Document

-
+
Associated Organization - + {{ owner.label }}
-
+
Visible To: -
- Applicant, L/FNG, and Commissioner +
+ {{ internalVisibilityLabel }}
-
+
Public
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/shared/document-upload-dialog/document-upload-dialog.component.scss similarity index 96% rename from alcs-frontend/src/app/features/planning-review/decision/decision-input/decision-file-upload-dialog/decision-document-upload-dialog.component.scss rename to alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.scss index 5a86cf7e2c..85f904a71a 100644 --- 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/shared/document-upload-dialog/document-upload-dialog.component.scss @@ -1,4 +1,4 @@ -@use '../../../../../../styles/colors'; +@use '../../../styles/colors'; .form { display: grid; @@ -79,4 +79,4 @@ a { align-content: center; margin-bottom: 4px; } -} \ No newline at end of file +} diff --git a/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.spec.ts similarity index 81% rename from alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.spec.ts rename to alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.spec.ts index 88572c1418..e750d2ccf2 100644 --- a/alcs-frontend/src/app/features/application/documents/document-upload-dialog/document-upload-dialog.component.spec.ts +++ b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.spec.ts @@ -2,10 +2,10 @@ 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 { ApplicationDocumentService } from '../../../../services/application/application-document/application-document.service'; -import { ApplicationParcelService } from '../../../../services/application/application-parcel/application-parcel.service'; -import { ApplicationSubmissionService } from '../../../../services/application/application-submission/application-submission.service'; -import { ToastService } from '../../../../services/toast/toast.service'; +import { ApplicationDocumentService } from '../../services/application/application-document/application-document.service'; +import { ApplicationParcelService } from '../../services/application/application-parcel/application-parcel.service'; +import { ApplicationSubmissionService } from '../../services/application/application-submission/application-submission.service'; +import { ToastService } from '../../services/toast/toast.service'; import { DocumentUploadDialogComponent } from './document-upload-dialog.component'; diff --git a/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.ts new file mode 100644 index 0000000000..8dcd3d988c --- /dev/null +++ b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.component.ts @@ -0,0 +1,418 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ToastService } from '../../services/toast/toast.service'; +import { DOCUMENT_SOURCE, DOCUMENT_SYSTEM, DOCUMENT_TYPE, DocumentTypeDto } from '../document/document.dto'; +import { FileHandle } from '../drag-drop-file/drag-drop-file.directive'; +import { splitExtension } from '../utils/file'; +import { DecisionService, DocumentService } from './document-upload-dialog.interface'; +import { + CreateDocumentDto, + DocumentDto, + SelectableOwnerDto, + SelectableParcelDto, + UpdateDocumentDto, +} from './document-upload-dialog.dto'; +import { Subject } from 'rxjs'; + +export enum VisibilityGroup { + INTERNAL = 'Internal', + PUBLIC = 'Public', +} + +export interface DocumentTypeConfig { + visibilityGroups: VisibilityGroup[]; + allowsFileEdit: boolean; +} + +@Component({ + selector: 'app-document-upload-dialog', + templateUrl: './document-upload-dialog.component.html', + styleUrls: ['./document-upload-dialog.component.scss'], +}) +export class DocumentUploadDialogComponent implements OnInit, OnDestroy { + $destroy = new Subject(); + DOCUMENT_TYPE = DOCUMENT_TYPE; + + title = 'Create'; + isDirty = false; + isSaving = false; + allowsFileEdit = true; + + @Output() uploadFiles: EventEmitter = new EventEmitter(); + + name = new FormControl('', [Validators.required]); + type = new FormControl(undefined, [Validators.required]); + source = new FormControl('', [Validators.required]); + + parcelId = new FormControl(null); + ownerId = new FormControl(null); + + visibleToInternal = new FormControl(false, [Validators.required]); + visibleToPublic = new FormControl(false, [Validators.required]); + + documentTypes: DocumentTypeDto[] = []; + documentSources = Object.values(DOCUMENT_SOURCE); + + form = new FormGroup({ + name: this.name, + type: this.type, + source: this.source, + visibleToInternal: this.visibleToInternal, + visibleToPublic: this.visibleToPublic, + parcelId: this.parcelId, + ownerId: this.ownerId, + }); + + pendingFile: File | undefined; + existingFile: { name: string; size: number } | undefined; + showSupersededWarning = false; + showHasVirusError = false; + showVirusScanFailedError = false; + extension = ''; + + internalVisibilityLabel = ''; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { + fileId: string; + decisionUuid?: string; + existingDocument?: DocumentDto; + decisionService?: DecisionService; + documentService?: DocumentService; + selectableParcels?: SelectableParcelDto[]; + selectableOwners?: SelectableOwnerDto[]; + allowedVisibilityFlags?: ('A' | 'C' | 'G' | 'P')[]; + allowsFileEdit?: boolean; + documentTypeOverrides?: Record; + }, + protected dialog: MatDialogRef, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + this.loadDocumentTypes(); + + this.internalVisibilityLabel = this.buildInternalVisibilityLabel(); + + if (this.data.existingDocument) { + const document = this.data.existingDocument; + this.title = 'Edit'; + + this.allowsFileEdit = this.data.allowsFileEdit ?? this.allowsFileEdit; + + if (document.type && this.data.documentTypeOverrides && this.data.documentTypeOverrides[document.type.code]) { + this.allowsFileEdit = this.data.documentTypeOverrides[document.type.code].allowsFileEdit; + } + + if (document.type?.code === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE) { + this.prepareCertificateOfTitleUpload(document.uuid); + } + if (document.type?.code === DOCUMENT_TYPE.CORPORATE_SUMMARY) { + this.prepareCorporateSummaryUpload(document.uuid); + } + + const { fileName, extension } = splitExtension(document.fileName); + this.extension = extension; + + this.form.patchValue({ + name: fileName, + source: document.source, + visibleToInternal: !!( + document.visibilityFlags?.includes('A') || + document.visibilityFlags?.includes('C') || + document.visibilityFlags?.includes('G') + ), + visibleToPublic: !!document.visibilityFlags?.includes('P'), + }); + + this.existingFile = { name: document.fileName, size: 0 }; + + if (this.data.documentService) { + this.type.setValue(document.type!.code); + } + } + + if (this.data.decisionService) { + this.type.disable(); + this.source.disable(); + this.visibleToInternal.disable(); + this.visibleToPublic.disable(); + + this.type.setValue(DOCUMENT_TYPE.DECISION_DOCUMENT); + this.source.setValue(DOCUMENT_SOURCE.ALC); + this.visibleToInternal.setValue(true); + this.visibleToPublic.setValue(true); + } + } + + buildInternalVisibilityLabel(): string { + const ordinalsByWord = { + Applicant: 0, + 'L/FNG': 1, + Commissioner: 2, + }; + + type Word = keyof typeof ordinalsByWord; + + const wordsByFlag: { + A: Word; + G: Word; + C: Word; + } = { + A: 'Applicant', + G: 'L/FNG', + C: 'Commissioner', + }; + + const words = ( + this.data.allowedVisibilityFlags?.reduce((words, flag) => { + if (flag !== 'P') { + words.push(wordsByFlag[flag]); + } + return words; + }, [] as Word[]) ?? [] + ).sort((word1, word2) => ordinalsByWord[word1] - ordinalsByWord[word2]); + + if (words.length === 0) { + return ''; + } + + if (words.length === 1) { + return words[0]; + } + + if (words.length === 2) { + return `${words[0]} and ${words[1]}`; + } + + return `${words.slice(0, -1).join(', ')}, and ${words[words.length - 1]}`; + } + + async onSubmit() { + const file = this.pendingFile; + const visibilityFlags: ('A' | 'C' | 'G' | 'P')[] = []; + + if (this.visibleToInternal.getRawValue()) { + for (const flag of this.data.allowedVisibilityFlags ?? []) { + if (flag !== 'P') { + visibilityFlags.push(flag); + } + } + } + + if (this.visibleToPublic.getRawValue() && this.data.allowedVisibilityFlags?.includes('P')) { + visibilityFlags.push('P'); + } + + const dto: UpdateDocumentDto = { + fileName: this.name.value! + this.extension, + source: this.source.value as DOCUMENT_SOURCE, + typeCode: this.type.value as DOCUMENT_TYPE, + visibilityFlags, + parcelUuid: this.parcelId.value ?? undefined, + ownerUuid: this.ownerId.value ?? undefined, + }; + + if (file) { + const renamedFile = new File([file], this.name.value! + this.extension, { type: file.type }); + this.isSaving = true; + if (this.data.existingDocument) { + if (this.data.decisionService && this.data.decisionUuid) { + await this.data.decisionService.deleteFile(this.data.decisionUuid, this.data.existingDocument.uuid); + } else if (this.data.documentService) { + await this.data.documentService.delete(this.data.existingDocument.uuid); + } + } + + try { + if (this.data.decisionService && this.data.decisionUuid) { + await this.data.decisionService.uploadFile(this.data.decisionUuid, renamedFile); + } else if (this.data.documentService) { + await this.data.documentService.upload(this.data.fileId, { ...dto, file } as CreateDocumentDto); + } + } catch (err) { + this.toastService.showErrorToast('Document upload failed'); + if (err instanceof HttpErrorResponse) { + if (err.status === 400) { + this.showHasVirusError = true; + } else if (err.status === 500) { + this.showVirusScanFailedError = true; + } + this.isSaving = false; + this.pendingFile = undefined; + return; + } + } + + this.dialog.close(true); + this.isSaving = false; + } else if (this.data.existingDocument) { + this.isSaving = true; + if (this.data.decisionService && this.data.decisionUuid) { + await this.data.decisionService.updateFile( + this.data.decisionUuid, + this.data.existingDocument.uuid, + this.name.value! + this.extension, + ); + } else if (this.data.documentService) { + await this.data.documentService.update(this.data.existingDocument.uuid, dto); + } + + this.dialog.close(true); + this.isSaving = false; + } + } + + async prepareCertificateOfTitleUpload(uuid?: string) { + if (this.data.selectableParcels && this.data.selectableParcels.length > 0) { + this.parcelId.setValidators([Validators.required]); + this.parcelId.updateValueAndValidity(); + this.source.setValue(DOCUMENT_SOURCE.APPLICANT); + + const selectedParcel = this.data.selectableParcels.find((parcel) => parcel.certificateOfTitleUuid === uuid); + if (selectedParcel) { + this.parcelId.setValue(selectedParcel.uuid); + } else if (uuid) { + this.showSupersededWarning = true; + } + } + } + + async prepareCorporateSummaryUpload(uuid?: string) { + if (this.data.selectableOwners && this.data.selectableOwners.length > 0) { + this.ownerId.setValidators([Validators.required]); + this.ownerId.updateValueAndValidity(); + this.source.setValue(DOCUMENT_SOURCE.APPLICANT); + + const selectedOwner = this.data.selectableOwners.find((owner) => owner.corporateSummaryUuid === uuid); + if (selectedOwner) { + this.ownerId.setValue(selectedOwner.uuid); + } else if (uuid) { + this.showSupersededWarning = true; + } + } + } + + async onDocTypeSelected($event?: DocumentTypeDto) { + if (!$event) { + return; + } + + if (this.data.documentTypeOverrides && this.data.documentTypeOverrides[$event.code]) { + for (const visibilityGroup of this.data.documentTypeOverrides[$event.code].visibilityGroups) { + if (visibilityGroup === VisibilityGroup.INTERNAL) { + this.visibleToInternal.setValue(true); + } + + if (visibilityGroup === VisibilityGroup.PUBLIC) { + this.visibleToPublic.setValue(true); + } + } + } + + if ($event.code === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE) { + await this.prepareCertificateOfTitleUpload(); + } else { + this.parcelId.setValue(null); + this.parcelId.setValidators([]); + this.parcelId.updateValueAndValidity(); + } + + if ($event.code === DOCUMENT_TYPE.CORPORATE_SUMMARY) { + await this.prepareCorporateSummaryUpload(); + } else { + this.ownerId.setValue(null); + this.ownerId.setValidators([]); + this.ownerId.updateValueAndValidity(); + } + } + + filterDocumentTypes(term: string, item: DocumentTypeDto) { + const termLower = term.toLocaleLowerCase(); + return ( + item.label.toLocaleLowerCase().indexOf(termLower) > -1 || + item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 + ); + } + + uploadFile(event: Event) { + const element = event.target as HTMLInputElement; + const selectedFiles = element.files; + if (selectedFiles && selectedFiles[0]) { + this.pendingFile = selectedFiles[0]; + const { fileName, extension } = splitExtension(selectedFiles[0].name); + this.name.setValue(fileName); + this.extension = extension; + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + } + } + + onRemoveFile() { + this.pendingFile = undefined; + this.existingFile = undefined; + this.extension = ''; + this.name.setValue(''); + } + + openFile() { + if (this.pendingFile) { + const fileURL = URL.createObjectURL(this.pendingFile); + window.open(fileURL, '_blank'); + } + } + + async openExistingFile() { + if (this.data.existingDocument) { + if (this.data.decisionService && this.data.decisionUuid) { + await this.data.decisionService.downloadFile( + this.data.decisionUuid, + this.data.existingDocument.uuid, + this.data.existingDocument.fileName, + true, + ); + } else if (this.data.documentService) { + await this.data.documentService.download( + this.data.existingDocument.uuid, + this.data.existingDocument.fileName, + true, + ); + } + } + } + + filesDropped($event: FileHandle) { + this.pendingFile = $event.file; + const { fileName, extension } = splitExtension(this.pendingFile.name); + this.extension = extension; + this.name.setValue(fileName); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + this.uploadFiles.emit($event); + } + + private async loadDocumentTypes() { + if (this.data.documentService) { + const docTypes = await this.data.documentService.fetchTypes(); + docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); + this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); + } else if (this.data.decisionService) { + this.documentTypes = [ + { + code: DOCUMENT_TYPE.DECISION_DOCUMENT, + label: 'Decision Package', + description: '', + oatsCode: '', + }, + ]; + } + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } +} diff --git a/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.dto.ts b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.dto.ts new file mode 100644 index 0000000000..73bb4085ff --- /dev/null +++ b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.dto.ts @@ -0,0 +1,44 @@ +import { DOCUMENT_SOURCE, DOCUMENT_SYSTEM, DOCUMENT_TYPE, DocumentTypeDto } from '../../shared/document/document.dto'; + +export interface UpdateDocumentDto { + file?: File; + parcelUuid?: string; + ownerUuid?: string; + fileName: string; + typeCode: DOCUMENT_TYPE; + source: DOCUMENT_SOURCE; + visibilityFlags?: ('A' | 'C' | 'G' | 'P')[]; +} + +export interface CreateDocumentDto extends UpdateDocumentDto { + file: File; +} + +export interface DocumentDto { + uuid: string; + documentUuid: string; + type?: DocumentTypeDto; + description?: string; + visibilityFlags?: string[]; + source: DOCUMENT_SOURCE; + system: DOCUMENT_SYSTEM; + fileName: string; + mimeType: string; + uploadedBy: string; + uploadedAt: number; + evidentiaryRecordSorting?: number; + fileSize?: number; +} + +export interface SelectableParcelDto { + uuid: string; + pid: string; + certificateOfTitleUuid: string; + index: string; +} + +export interface SelectableOwnerDto { + label: string; + uuid: string; + corporateSummaryUuid: string; +} diff --git a/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.interface.ts b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.interface.ts new file mode 100644 index 0000000000..34e7f4dbc5 --- /dev/null +++ b/alcs-frontend/src/app/shared/document-upload-dialog/document-upload-dialog.interface.ts @@ -0,0 +1,17 @@ +import { DocumentTypeDto } from '../document/document.dto'; +import { CreateDocumentDto, UpdateDocumentDto } from './document-upload-dialog.dto'; + +export interface DecisionService { + uploadFile(decisionUuid: string, file: File): Promise; + downloadFile(decisionUuid: string, documentUuid: string, fileName: string, isInline: boolean): Promise; + updateFile(decisionUuid: string, documentUuid: string, fileName: string): Promise; + deleteFile(decisionUuid: string, documentUuid: string): Promise<{ url: string }>; +} + +export interface DocumentService { + update(uuid: string, updateDto: UpdateDocumentDto): Promise; + upload(fileNumber: string, createDto: CreateDocumentDto): Promise; + download(uuid: string, fileName: string, isInline: boolean): Promise; + fetchTypes(): Promise; + delete(uuid: string): Promise; +} diff --git a/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.html b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.html new file mode 100644 index 0000000000..4afe2fc5e4 --- /dev/null +++ b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.html @@ -0,0 +1,43 @@ +
+

{{ data.isEditing ? 'Edit' : '' }} Flag Decision #{{ data.decisionNumber }}

+
+ + + + Flagged for condition follow-up because: + + + + +
+
+ info +
+
+ Add a follow-up date if: +
    +
  • the decision is a refusal; or
  • +
  • there are no End/Due date(s) on the condition(s)
  • +
+
+
+
+ + +
+ + +
+
diff --git a/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.scss b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.scss new file mode 100644 index 0000000000..f2794a3456 --- /dev/null +++ b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.scss @@ -0,0 +1,47 @@ +@use '../../../styles/colors.scss'; + +.content { + display: flex; + flex-direction: column; + gap: 24px; + + color: colors.$black; + + height: 100%; + margin-top: 24px; +} + +.reason-flagged { + margin-top: 5px; +} + +.follow-up-at { + align-self: flex-start; +} + +.button-container { + margin: 16px; +} + +.warning { + background-color: colors.$secondary-color-light; + border-radius: 4px; + padding: 16px; + display: flex; + align-items: center; + margin-bottom: 24px; + + mat-icon { + margin-left: 20px; + margin-top: 10px; + } +} + +.warning-text { + padding-left: 20px; +} + +.no-margin-ul { + margin-top: 0px; + margin-bottom: 0px; +} diff --git a/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.spec.ts b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.spec.ts new file mode 100644 index 0000000000..3673e6f9d6 --- /dev/null +++ b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.spec.ts @@ -0,0 +1,56 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { ApplicationSubmissionStatusService } from '../../services/application/application-submission-status/application-submission-status.service'; +import { + ApplicationDecisionDto, + ApplicationDecisionWithLinkedResolutionDto, +} from '../../services/application/decision/application-decision-v2/application-decision-v2.dto'; +import { ApplicationDecisionV2Service } from '../../services/application/decision/application-decision-v2/application-decision-v2.service'; +import { FlagDialogComponent } from './flag-dialog.component'; + +describe('FlagDialogComponent', () => { + let component: FlagDialogComponent; + let fixture: ComponentFixture; + let mockApplicationDecisionV2Service: DeepMocked; + let mockSubmissionStatusService: DeepMocked; + + beforeEach(async () => { + mockApplicationDecisionV2Service = createMock(); + mockApplicationDecisionV2Service.$decision = new BehaviorSubject(undefined); + mockApplicationDecisionV2Service.$decisions = new BehaviorSubject([]); + mockSubmissionStatusService = createMock(); + + await TestBed.configureTestingModule({ + declarations: [FlagDialogComponent], + providers: [ + { + provide: ApplicationDecisionV2Service, + useValue: mockApplicationDecisionV2Service, + }, + { + provide: ApplicationSubmissionStatusService, + useValue: mockSubmissionStatusService, + }, + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + fileNumber: '12313', + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(FlagDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.ts b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.ts new file mode 100644 index 0000000000..d9c4a4d0b8 --- /dev/null +++ b/alcs-frontend/src/app/shared/flag-dialog/flag-dialog.component.ts @@ -0,0 +1,54 @@ +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import moment, { Moment } from 'moment'; + +export interface FlagDialogIO { + isEditing?: boolean; + decisionNumber?: number; + reasonFlagged?: string; + followUpAt?: number | null; + isSaving?: boolean; +} + +@Component({ + selector: 'app-flag-dialog', + templateUrl: './flag-dialog.component.html', + styleUrls: ['./flag-dialog.component.scss'], +}) +export class FlagDialogComponent implements OnInit, OnDestroy { + $destroy = new Subject(); + + followUpAt?: Moment | null; + + constructor( + public matDialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + protected data: FlagDialogIO, + ) {} + + ngOnInit(): void { + if (this.data.followUpAt) { + this.followUpAt = moment(this.data.followUpAt); + } + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + save() { + const output: FlagDialogIO = { + isEditing: this.data.isEditing, + reasonFlagged: this.data.reasonFlagged, + isSaving: true, + }; + + if (this.followUpAt !== undefined) { + output.followUpAt = this.followUpAt ? this.followUpAt.toDate().getTime() : null; + } + + this.matDialogRef.close(output); + } +} diff --git a/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts b/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts index bb8ab24f7f..1e3f7b20ed 100644 --- a/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts +++ b/alcs-frontend/src/app/shared/header/search-bar/search-bar.component.ts @@ -112,12 +112,22 @@ export class SearchBarComponent implements AfterViewInit, OnInit { const result = searchResult[0]; switch (result.type) { case 'APP': + const appStatusResult = await this.searchService.advancedSearchApplicationStatusFetch([result.fileNumber]); + let appDecisionUrl = ''; + if (appStatusResult && appStatusResult.length > 0 && appStatusResult[0].status === 'ALCD') { + appDecisionUrl = '/decision' + } this.isCommissioner ? await this.router.navigate(['commissioner/application', result.referenceId]) - : await this.router.navigate(['application', result.referenceId]); + : await this.router.navigate([`application/${result.referenceId}${appDecisionUrl}`]); break; case 'NOI': - await this.router.navigate(['notice-of-intent', result.referenceId]); + const noiStatusResult = await this.searchService.advancedSearchNoiStatusFetch([result.fileNumber]); + let noiDecisionUrl = ''; + if (noiStatusResult && noiStatusResult.length > 0 && noiStatusResult[0].status === 'ALCD') { + noiDecisionUrl = '/decision' + } + await this.router.navigate([`notice-of-intent/${result.referenceId}${noiDecisionUrl}`]); break; case 'NOTI': await this.router.navigate(['notification', result.referenceId]); diff --git a/alcs-frontend/src/app/shared/shared.module.ts b/alcs-frontend/src/app/shared/shared.module.ts index 339b86ec59..28ea05cc7a 100644 --- a/alcs-frontend/src/app/shared/shared.module.ts +++ b/alcs-frontend/src/app/shared/shared.module.ts @@ -78,6 +78,10 @@ import { TagsHeaderComponent } from './tags/tags-header/tags-header.component'; import { MatChipsModule } from '@angular/material/chips'; import { TagChipComponent } from './tags/tag-chip/tag-chip.component'; import { DomSanitizer } from '@angular/platform-browser'; +import { CommissionerTagsHeaderComponent } from './tags/commissioner-tags-header/commissioner-tags-header.component'; +import { DocumentUploadDialogComponent } from './document-upload-dialog/document-upload-dialog.component'; +import { FlagDialogComponent } from './flag-dialog/flag-dialog.component'; +import { UnFlagDialogComponent } from './unflag-dialog/unflag-dialog.component'; @NgModule({ declarations: [ @@ -121,6 +125,10 @@ import { DomSanitizer } from '@angular/platform-browser'; TruncatePipe, TagsHeaderComponent, TagChipComponent, + CommissionerTagsHeaderComponent, + DocumentUploadDialogComponent, + FlagDialogComponent, + UnFlagDialogComponent, ], imports: [ CommonModule, @@ -148,6 +156,7 @@ import { DomSanitizer } from '@angular/platform-browser'; MatSlideToggleModule, MatChipsModule, MatAutocompleteModule, + MatCheckboxModule, ], exports: [ CommonModule, @@ -224,6 +233,9 @@ import { DomSanitizer } from '@angular/platform-browser'; TruncatePipe, TagsHeaderComponent, TagChipComponent, + DocumentUploadDialogComponent, + FlagDialogComponent, + UnFlagDialogComponent, ], }) export class SharedModule { @@ -232,6 +244,10 @@ export class SharedModule { 'cancel_filled', domSanitizer.bypassSecurityTrustResourceUrl('/assets/icons/cancel_filled.svg'), ); + matIconRegistry.addSvgIcon( + 'personal_places', + domSanitizer.bypassSecurityTrustResourceUrl('/assets/icons/personal_places.svg'), + ); } static forRoot(): ModuleWithProviders { return { diff --git a/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.html b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.html new file mode 100644 index 0000000000..7502e487f3 --- /dev/null +++ b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.scss b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.scss new file mode 100644 index 0000000000..72d84b961d --- /dev/null +++ b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.scss @@ -0,0 +1,20 @@ +.tag-container { + width: 100%; + margin-left: -10px; + margin-top: -5px; + border: 1px solid transparent; + border-radius: 4px; + padding: 5px; + + &.hovered { + border: 1px solid #aaaaaa; + } + + &.clicked { + border: 1px solid #929292; + } +} + +.category { + color: #a0a0a0; +} diff --git a/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.spec.ts b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.spec.ts new file mode 100644 index 0000000000..907516726a --- /dev/null +++ b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CommissionerTagsHeaderComponent } from './commissioner-tags-header.component'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { FileTagService } from '../../../services/common/file-tag.service'; + +describe('CommissionerTagsHeaderComponent', () => { + let component: CommissionerTagsHeaderComponent; + let fixture: ComponentFixture; + let mockFileTagService: DeepMocked; + + beforeEach(async () => { + mockFileTagService = createMock(); + + await TestBed.configureTestingModule({ + declarations: [CommissionerTagsHeaderComponent], + providers: [ + { + provide: FileTagService, + useValue: mockFileTagService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CommissionerTagsHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.ts b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.ts new file mode 100644 index 0000000000..11354f8367 --- /dev/null +++ b/alcs-frontend/src/app/shared/tags/commissioner-tags-header/commissioner-tags-header.component.ts @@ -0,0 +1,36 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { TagDto } from '../../../services/tag/tag.dto'; +import { FileTagService } from '../../../services/common/file-tag.service'; +import { ApplicationDto } from '../../../services/application/application.dto'; +import { CommissionerApplicationDto } from '../../../services/commissioner/commissioner.dto'; +import { NoticeOfIntentDto } from '../../../services/notice-of-intent/notice-of-intent.dto'; +import { NotificationDto } from '../../../services/notification/notification.dto'; + +@Component({ + selector: 'app-commissioner-tags-header', + templateUrl: './commissioner-tags-header.component.html', + styleUrl: './commissioner-tags-header.component.scss', +}) +export class CommissionerTagsHeaderComponent implements OnInit, OnChanges { + tags: TagDto[] = []; + + hovered = false; + clicked = false; + + @Input() application: ApplicationDto | CommissionerApplicationDto | NoticeOfIntentDto | NotificationDto | undefined; + @Input() isHidden: boolean = false; + + constructor(private fileTagService: FileTagService) {} + + ngOnInit(): void {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes['application'] && changes['application'].currentValue !== undefined) { + this.initFileTags(); + } + } + async initFileTags() { + const res = await this.fileTagService.getTags(this.application?.fileNumber!); + this.tags = res!; + } +} diff --git a/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.html b/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.html index aee058b24f..78a441da7a 100644 --- a/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.html +++ b/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.html @@ -1,6 +1,6 @@ -{{ tag.name }} - diff --git a/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.scss b/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.scss index a7deee2c28..8caca14e56 100644 --- a/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.scss +++ b/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.scss @@ -4,3 +4,7 @@ mat-chip-row { border: 1px solid; background-color: #f3f3f3 !important; } + +.removable { + margin: 4px; +} diff --git a/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.ts b/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.ts index 6eb2c35a2c..6b4ef6f68d 100644 --- a/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.ts +++ b/alcs-frontend/src/app/shared/tags/tag-chip/tag-chip.component.ts @@ -8,6 +8,7 @@ import { TagDto } from '../../../services/tag/tag.dto'; }) export class TagChipComponent { @Input() tag!: TagDto; + @Input() removable: boolean = true; @Output() removeClicked = new EventEmitter(); onRemove() { diff --git a/alcs-frontend/src/app/shared/tags/tags-header/tags-header.component.ts b/alcs-frontend/src/app/shared/tags/tags-header/tags-header.component.ts index 8a1c45bfc4..f76e5cae83 100644 --- a/alcs-frontend/src/app/shared/tags/tags-header/tags-header.component.ts +++ b/alcs-frontend/src/app/shared/tags/tags-header/tags-header.component.ts @@ -50,7 +50,6 @@ export class TagsHeaderComponent implements OnInit, OnChanges { @ViewChild(MatAutocompleteTrigger) autoCompleteTrigger!: MatAutocompleteTrigger; @Input() application: ApplicationDto | CommissionerApplicationDto | NoticeOfIntentDto | NotificationDto | undefined; - @Input() service: FileTagService | undefined; @Input() isHidden: boolean = false; constructor( diff --git a/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.html b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.html new file mode 100644 index 0000000000..5a5e7f51d8 --- /dev/null +++ b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.html @@ -0,0 +1,30 @@ +
+

Unflag Decision #{{ data.decisionNumber }}

+
+ + +
+
+ info +
+
+ Warning: Only remove if flagged in error. +
+
+ This action will also remove the follow-up date and explanatory text + associated with the flag and cannot be undone. +
+
+ Are you sure you want to remove the Flag? +
+
+
+ + +
+ + +
+
diff --git a/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.scss b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.scss new file mode 100644 index 0000000000..a41fc62fd9 --- /dev/null +++ b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.scss @@ -0,0 +1,38 @@ +@use '../../../styles/colors.scss'; + +.content { + display: flex; + flex-direction: column; + gap: 24px; + + color: colors.$black; + + height: 100%; + margin-top: 24px; +} + +.reason-flagged { + margin-top: 5px; +} + +.follow-up-at { + align-self: flex-start; +} + +.button-container { + margin: 16px; +} + +.warning { + background-color: rgba(colors.$field-warning-bg-color, 0.5); + border-radius: 4px; + padding: 16px; + display: flex; + align-items: center; + margin-bottom: 24px; + + mat-icon { + margin-right: 30px; + margin-left: 20px; + } +} diff --git a/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.spec.ts b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.spec.ts new file mode 100644 index 0000000000..b717380df3 --- /dev/null +++ b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.spec.ts @@ -0,0 +1,33 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { UnFlagDialogComponent } from './unflag-dialog.component'; + +describe('UnFlagDialogComponent', () => { + let component: UnFlagDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UnFlagDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: { + fileNumber: '12313', + }, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(UnFlagDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.ts b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.ts new file mode 100644 index 0000000000..a28001eb59 --- /dev/null +++ b/alcs-frontend/src/app/shared/unflag-dialog/unflag-dialog.component.ts @@ -0,0 +1,35 @@ +import { Component, Inject, OnDestroy } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; + +export interface UnFlagDialogIO { + decisionNumber?: number; + confirmed?: boolean; +} + +@Component({ + selector: 'app-unflag-dialog', + templateUrl: './unflag-dialog.component.html', + styleUrls: ['./unflag-dialog.component.scss'], +}) +export class UnFlagDialogComponent implements OnDestroy { + $destroy = new Subject(); + + constructor( + public matDialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + protected data: UnFlagDialogIO, + ) {} + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + save() { + const output: UnFlagDialogIO = { + confirmed: true, + }; + this.matDialogRef.close(output); + } +} diff --git a/alcs-frontend/src/app/shared/utils/decision-methods.ts b/alcs-frontend/src/app/shared/utils/decision-methods.ts new file mode 100644 index 0000000000..22f4040b00 --- /dev/null +++ b/alcs-frontend/src/app/shared/utils/decision-methods.ts @@ -0,0 +1,28 @@ +import moment from 'moment-timezone'; +import { ApplicationDecisionConditionDto } from "../../services/application/decision/application-decision-v2/application-decision-v2.dto"; +import { NoticeOfIntentDecisionConditionDto } from "../../services/notice-of-intent/decision-v2/notice-of-intent-decision.dto"; +import { ApplicationDecisionStatus } from '../../services/application/decision/application-decision-v2/application-condition-status.dto'; + +export type ApplicationConditionWithStatus = ApplicationDecisionConditionDto & { + conditionStatus: ApplicationDecisionStatus; +} + +export type NoticeOfIntentConditionWithStatus = NoticeOfIntentDecisionConditionDto & { + conditionStatus: ApplicationDecisionStatus; +} + +export function getEndDate(uuid: string | undefined, conditions: Record) { + const dates = uuid && conditions[uuid] ? + conditions[uuid].filter((x) => x.type?.code === 'UEND') + .map((x) => x.dates?.map((x) => x.date)) + : []; + if (dates.length === 0) { + return 'approved'; + } + const reducedDates = dates + .reduce((pre, cur) => pre?.concat(cur)) + ?.filter((x) => x !== null) + .sort().reverse(); + const lastDate = reducedDates && reducedDates.length > 0 ? reducedDates[0] : null; + return lastDate ? `approved until: ${moment(lastDate).format('YYYY-MMM-DD')}` : 'approved until: No Data'; +} diff --git a/alcs-frontend/src/assets/icons/personal_places.svg b/alcs-frontend/src/assets/icons/personal_places.svg new file mode 100644 index 0000000000..ffdb3a6601 --- /dev/null +++ b/alcs-frontend/src/assets/icons/personal_places.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/portal-frontend/Dockerfile b/portal-frontend/Dockerfile index 212720b0e3..410a505cd2 100644 --- a/portal-frontend/Dockerfile +++ b/portal-frontend/Dockerfile @@ -13,7 +13,7 @@ RUN npm ci # Copy the source code to the /app directory COPY . . -ENV NODE_OPTIONS="--max-old-space-size=2048" +ENV NODE_OPTIONS "--max-old-space-size=2048" # Build the application RUN npm run build -- --output-path=dist --output-hashing=all @@ -47,7 +47,7 @@ COPY --from=build /app/dist /usr/share/nginx/html RUN chmod -R go+rwx /usr/share/nginx/html/assets # provide dynamic scp content-src -ENV ENABLED_CONNECT_SRC=" 'self' http://localhost:* nrs.objectstore.gov.bc.ca" +ENV ENABLED_CONNECT_SRC " 'self' http://localhost:* nrs.objectstore.gov.bc.ca" # When the container starts, replace the settings.json with values from environment variables ENTRYPOINT [ "./init.sh" ] diff --git a/portal-frontend/src/app/features/applications/edit-submission/files-step.partial.ts b/portal-frontend/src/app/features/applications/edit-submission/files-step.partial.ts index b57fbfa6a2..f3fcaf1be9 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/files-step.partial.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/files-step.partial.ts @@ -38,24 +38,22 @@ export abstract class FilesStepComponent extends StepComponent { await this.save(); const mappedFiles = file.file; - let res; try { - res = await this.applicationDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + const res = await this.applicationDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + + if (res) { + this.toastService.showSuccessToast('Document uploaded'); + const documents = await this.applicationDocumentService.getByFileId(this.fileId); + if (documents) { + this.$applicationDocuments.next(documents); + } + } } catch (err) { this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - return false; - } - } - if (res) { - const documents = await this.applicationDocumentService.getByFileId(this.fileId); - if (documents) { - this.$applicationDocuments.next(documents); - } + throw err; } } - return true; } async onDeleteFile($event: ApplicationDocumentDto) { diff --git a/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html b/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html index 8622a33a24..a915d3aa02 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html @@ -1,79 +1,79 @@
-

{{title}} optional attachment

+

{{ title }} optional attachment

-
- - -
- -
-
- - - - {{ type.label }} - - - -
- warning -
- This field is required -
-
-
-
- - - -
- warning -
- This field is required -
-
-
+
+ + +
+ +
+
+ + + + {{ type.label }} + + + +
+ warning +
This field is required
+
+
+
+ + + +
+ warning +
This field is required
- +
+
+
-
- - - -
-
\ No newline at end of file +
+ + + +
+ diff --git a/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts b/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts index a84f186148..451db086cf 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts @@ -1,212 +1,232 @@ -import { CommonModule } from "@angular/common"; +import { CommonModule } from '@angular/common'; import { Component, Inject, OnInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; -import { ApplicationDocumentDto, ApplicationDocumentUpdateDto } from "../../../../../services/application-document/application-document.dto"; -import { ToastService } from "../../../../../services/toast/toast.service"; -import { ApplicationDocumentService } from "../../../../../services/application-document/application-document.service"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { OtherAttachmentsComponent } from "../other-attachments.component"; -import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from "../../../../../shared/dto/document.dto"; -import { CodeService } from "../../../../../services/code/code.service"; -import { FileHandle } from "../../../../../shared/file-drag-drop/drag-drop.directive"; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + ApplicationDocumentDto, + ApplicationDocumentUpdateDto, +} from '../../../../../services/application-document/application-document.dto'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { ApplicationDocumentService } from '../../../../../services/application-document/application-document.service'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { OtherAttachmentsComponent } from '../other-attachments.component'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from '../../../../../shared/dto/document.dto'; +import { CodeService } from '../../../../../services/code/code.service'; +import { FileHandle } from '../../../../../shared/file-drag-drop/drag-drop.directive'; +import { HttpErrorResponse } from '@angular/common/http'; const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; @Component({ - selector: 'app-other-attachments-upload-dialog', - templateUrl: './other-attachments-upload-dialog.component.html', - styleUrl: './other-attachments-upload-dialog.component.scss', + selector: 'app-other-attachments-upload-dialog', + templateUrl: './other-attachments-upload-dialog.component.html', + styleUrl: './other-attachments-upload-dialog.component.scss', }) export class OtherAttachmentsUploadDialogComponent implements OnInit { - isDirty = false; - isFileDirty = false; - isSaving = false; - showVirusError = false; - showFileRequiredError = false; - title: string = ''; - isEditing = false; - - attachment: ApplicationDocumentDto[] = []; - attachmentForDelete: ApplicationDocumentDto[] = []; - pendingFile: FileHandle | undefined; - selectableTypes: DocumentTypeDto[] = []; - private documentCodes: DocumentTypeDto[] = []; - - fileDescription = new FormControl(null, [Validators.required]); - fileType = new FormControl(null, [Validators.required]); - currentDescription: string | null = null; - currentType: DocumentTypeDto | null = null; - - form = new FormGroup({ - fileDescription: this.fileDescription, - fileType: this.fileType, - }); - - constructor( - private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) - public data: { - otherAttachmentsComponent: OtherAttachmentsComponent, - existingDocument?: ApplicationDocumentDto, - fileId: string, - }, - private applicationDcoumentService: ApplicationDocumentService, - private codeService: CodeService, - private toastService: ToastService - ) {} - - ngOnInit(): void { - this.loadDocumentCodes(); - if (this.data.existingDocument) { - this.title = 'Edit'; - this.isEditing = true; - this.fileType.setValue(this.data.existingDocument.type!.code); - this.fileDescription.setValue(this.data.existingDocument.description!); - this.currentDescription = this.data.existingDocument.description!; - this.currentType = this.data.existingDocument.type; - this.attachment = [this.data.existingDocument]; - } else { - this.title = 'Add'; - } - } - - async attachDocument(file: FileHandle) { - this.pendingFile = file; - this.attachment = [{uuid: '', - fileName: file.file.name, - type: null, - fileSize: file.file.size, - uploadedBy: '', - uploadedAt: file.file.lastModified, - source: DOCUMENT_SOURCE.APPLICANT, - }]; - this.isFileDirty = true; - this.showFileRequiredError = false; + isDirty = false; + isFileDirty = false; + isSaving = false; + showHasVirusError = false; + showVirusScanFailedError = false; + showFileRequiredError = false; + title: string = ''; + isEditing = false; + + attachment: ApplicationDocumentDto[] = []; + attachmentForDelete: ApplicationDocumentDto[] = []; + pendingFile: FileHandle | undefined; + selectableTypes: DocumentTypeDto[] = []; + private documentCodes: DocumentTypeDto[] = []; + + fileDescription = new FormControl(null, [Validators.required]); + fileType = new FormControl(null, [Validators.required]); + currentDescription: string | null = null; + currentType: DocumentTypeDto | null = null; + + form = new FormGroup({ + fileDescription: this.fileDescription, + fileType: this.fileType, + }); + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public data: { + otherAttachmentsComponent: OtherAttachmentsComponent; + existingDocument?: ApplicationDocumentDto; + fileId: string; + }, + private applicationDcoumentService: ApplicationDocumentService, + private codeService: CodeService, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + this.loadDocumentCodes(); + if (this.data.existingDocument) { + this.title = 'Edit'; + this.isEditing = true; + this.fileType.setValue(this.data.existingDocument.type!.code); + this.fileDescription.setValue(this.data.existingDocument.description!); + this.currentDescription = this.data.existingDocument.description!; + this.currentType = this.data.existingDocument.type; + this.attachment = [this.data.existingDocument]; + } else { + this.title = 'Add'; } - - openFile() { - if (this.isEditing && this.pendingFile === undefined) { - this.data.otherAttachmentsComponent.openFile(this.attachment[0]); - } else { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile.file); - window.open(fileURL, '_blank'); - } - } + } + + async attachDocument(file: FileHandle) { + this.pendingFile = file; + this.attachment = [ + { + uuid: '', + fileName: file.file.name, + type: null, + fileSize: file.file.size, + uploadedBy: '', + uploadedAt: file.file.lastModified, + source: DOCUMENT_SOURCE.APPLICANT, + }, + ]; + this.isFileDirty = true; + this.showFileRequiredError = false; + } + + openFile() { + if (this.isEditing && this.pendingFile === undefined) { + this.data.otherAttachmentsComponent.openFile(this.attachment[0]); + } else { + if (this.pendingFile) { + const fileURL = URL.createObjectURL(this.pendingFile.file); + window.open(fileURL, '_blank'); + } } + } - deleteFile() { - this.pendingFile = undefined; - if (this.isEditing) { - this.attachmentForDelete = this.attachment; - } - this.attachment = []; + deleteFile() { + this.pendingFile = undefined; + if (this.isEditing) { + this.attachmentForDelete = this.attachment; } - - onChangeDescription() { - this.isDirty = true; - this.currentDescription = this.fileDescription.value; + this.attachment = []; + } + + onChangeDescription() { + this.isDirty = true; + this.currentDescription = this.fileDescription.value; + } + + onChangeType(selectedValue: DOCUMENT_TYPE) { + this.isDirty = true; + const newType = this.documentCodes.find((code) => code.code === selectedValue); + this.currentType = newType !== undefined ? newType : null; + } + + validateForm() { + if (this.form.valid && this.attachment.length !== 0) { + return true; } - onChangeType(selectedValue: DOCUMENT_TYPE) { - this.isDirty = true; - const newType = this.documentCodes.find((code) => code.code === selectedValue); - this.currentType = newType !== undefined ? newType : null; + if (this.form.invalid) { + this.form.markAllAsTouched(); } - validateForm() { - if (this.form.valid && this.attachment.length !== 0) { - return true; - } - - if (this.form.invalid) { - this.form.markAllAsTouched(); - } + if (this.attachment.length == 0) { + this.showFileRequiredError = true; + } + return false; + } - if (this.attachment.length == 0) { - this.showFileRequiredError = true; - } - return false; + async onAdd() { + if (this.validateForm()) { + await this.add(); } - - async onAdd() { - if (this.validateForm()) { - await this.add(); + } + + protected async add() { + if (this.isFileDirty) { + this.isSaving = true; + try { + await this.data.otherAttachmentsComponent.attachFile(this.pendingFile!, null); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + + const documents = await this.applicationDcoumentService.getByFileId(this.data.fileId); + + if (documents) { + const sortedDocuments = documents.sort((a, b) => { + return b.uploadedAt - a.uploadedAt; + }); + const updateDtos: ApplicationDocumentUpdateDto[] = sortedDocuments.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + updateDtos[0] = { + ...updateDtos[0], + description: this.currentDescription, + type: this.currentType?.code ?? null, + }; + await this.applicationDcoumentService.update(this.data.fileId, updateDtos); + this.toastService.showSuccessToast('Attachment added successfully'); + this.dialogRef.close(); + } else { + this.toastService.showErrorToast('Could not read attached documents'); } - } - - protected async add() { - if (this.isFileDirty) { - this.isSaving = true; - const res = await this.data.otherAttachmentsComponent.attachFile(this.pendingFile!, null); - this.showVirusError = !res; - if (res) { - const documents = await this.applicationDcoumentService.getByFileId(this.data.fileId); - if (documents) { - const sortedDocuments = documents.sort((a, b) => {return b.uploadedAt - a.uploadedAt}); - const updateDtos: ApplicationDocumentUpdateDto[] = sortedDocuments.map((file) => ({ - uuid: file.uuid, - description: file.description, - type: file.type?.code ?? null, - })); - updateDtos[0] = { - ...updateDtos[0], - description: this.currentDescription, - type: this.currentType?.code ?? null, - } - await this.applicationDcoumentService.update(this.data.fileId, updateDtos); - this.toastService.showSuccessToast('Attachment added successfully'); - this.dialogRef.close(); - } else { - this.toastService.showErrorToast("Could not read attached documents"); - } - } + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; } + } + this.isDirty = false; + this.isFileDirty = true; + this.isSaving = false; } + } - async onEdit() { - if (this.validateForm()) { - this.edit(); - } + async onEdit() { + if (this.validateForm()) { + this.edit(); } - - protected async edit() { - if (this.isFileDirty) { - this.data.otherAttachmentsComponent.onDeleteFile(this.attachmentForDelete[0]); - await this.add(); - } else { - if (this.isDirty) { - this.isSaving = true; - const documents = await this.applicationDcoumentService.getByFileId(this.data.fileId); - if (documents) { - const updateDtos: ApplicationDocumentUpdateDto[] = documents.map((file) => ({ - uuid: file.uuid, - description: file.description, - type: file.type?.code ?? null, - })); - for (let i = 0; i < updateDtos.length; i++) { - if (updateDtos[i].uuid === this.data.existingDocument?.uuid) { - updateDtos[i] = { - ...updateDtos[i], - description: this.currentDescription, - type: this.currentType?.code ?? null, - } - } - } - await this.applicationDcoumentService.update(this.data.fileId, updateDtos); - this.toastService.showSuccessToast('Attachment updated successully'); - } else { - this.toastService.showErrorToast("Could not read attached documents"); - } + } + + protected async edit() { + if (this.isFileDirty) { + this.data.otherAttachmentsComponent.onDeleteFile(this.attachmentForDelete[0]); + await this.add(); + } else { + if (this.isDirty) { + this.isSaving = true; + const documents = await this.applicationDcoumentService.getByFileId(this.data.fileId); + if (documents) { + const updateDtos: ApplicationDocumentUpdateDto[] = documents.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + for (let i = 0; i < updateDtos.length; i++) { + if (updateDtos[i].uuid === this.data.existingDocument?.uuid) { + updateDtos[i] = { + ...updateDtos[i], + description: this.currentDescription, + type: this.currentType?.code ?? null, + }; } - this.dialogRef.close(); + } + await this.applicationDcoumentService.update(this.data.fileId, updateDtos); + this.toastService.showSuccessToast('Attachment updated successully'); + } else { + this.toastService.showErrorToast('Could not read attached documents'); } + } + this.dialogRef.close(); } + } - private async loadDocumentCodes() { - const codes = await this.codeService.loadCodes(); - this.documentCodes = codes.documentTypes; - this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); - } - + private async loadDocumentCodes() { + const codes = await this.codeService.loadCodes(); + this.documentCodes = codes.documentTypes; + this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); + } } diff --git a/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments.component.ts b/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments.component.ts index 51ad7db354..e6ca352239 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/other-attachments/other-attachments.component.ts @@ -17,6 +17,7 @@ import { EditApplicationSteps } from '../edit-submission.component'; import { FilesStepComponent } from '../files-step.partial'; import { OtherAttachmentsUploadDialogComponent } from './other-attachments-upload-dialog/other-attachments-upload-dialog.component'; import { MOBILE_BREAKPOINT } from '../../../../shared/utils/breakpoints'; +import { HttpErrorResponse } from '@angular/common/http'; const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; @@ -33,7 +34,8 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI otherFiles: ApplicationDocumentDto[] = []; private isDirty = false; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; isMobile = window.innerWidth <= MOBILE_BREAKPOINT; form = new FormGroup({} as any); @@ -45,7 +47,7 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI private codeService: CodeService, applicationDocumentService: ApplicationDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(applicationDocumentService, dialog, toastService); } @@ -74,8 +76,16 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI } async attachDocument(file: FileHandle) { - const res = await this.attachFile(file, null); - this.showVirusError = !res; + try { + await this.attachFile(file, null); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { @@ -98,15 +108,17 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI onAddEditAttachment(attachment: ApplicationDocumentDto | undefined) { this.dialog .open(OtherAttachmentsUploadDialogComponent, { - width: this.isMobile? '90%' : '50%', + width: this.isMobile ? '90%' : '50%', data: { fileId: this.fileId, otherAttachmentsComponent: this, existingDocument: attachment, - } - }).afterClosed().subscribe(async res => { - await this.refreshFiles(); - }); + }, + }) + .afterClosed() + .subscribe(async (res) => { + await this.refreshFiles(); + }); } @HostListener('window:resize', ['$event']) diff --git a/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.html b/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.html index 34240f1843..0d225d5bb8 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.html @@ -241,7 +241,8 @@ (beforeFileUploadOpened)="saveParcelProgress()" [showErrors]="showErrors" [isRequired]="isCertificateOfTitleRequired" - [showVirusError]="showVirusError" + [showHasVirusError]="showHasVirusError" + [showVirusScanFailedError]="showVirusScanFailedError" [disabled]="parcelForm.controls.pid.disabled" >
@@ -353,11 +354,12 @@
OR
+ (editClicked)="onEditCrownOwner(selectedOwner)" + >
@@ -376,18 +378,20 @@
OR
- - + [ngClass]="{ error: ownerInput.errors && ownerInput.errors['required'] }" + > +
- {{owner.firstName + ' ' + owner.lastName}} + {{ owner.firstName + ' ' + owner.lastName }}
@@ -407,7 +411,7 @@
OR
'error-outline': enableUserSignOff && isConfirmedByApplicant.invalid && - (isConfirmedByApplicant.dirty || isConfirmedByApplicant.touched) + (isConfirmedByApplicant.dirty || isConfirmedByApplicant.touched), }" > diff --git a/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.ts b/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.ts index 617204efdf..ebadf41620 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/parcel-details/parcel-entry/parcel-entry.component.ts @@ -23,6 +23,7 @@ import { openFileInline } from '../../../../../shared/utils/file'; import { RemoveFileConfirmationDialogComponent } from '../../../alcs-edit-submission/remove-file-confirmation-dialog/remove-file-confirmation-dialog.component'; import { ParcelEntryConfirmationDialogComponent } from './parcel-entry-confirmation-dialog/parcel-entry-confirmation-dialog.component'; import { MOBILE_BREAKPOINT } from '../../../../../shared/utils/breakpoints'; +import { HttpErrorResponse } from '@angular/common/http'; export interface ParcelEntryFormData { uuid: string; @@ -57,7 +58,8 @@ export class ParcelEntryComponent implements OnInit { @Input() _disabled = false; @Input() isDraft = false; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; @Output() private onFormGroupChange = new EventEmitter>(); @Output() private onSaveProgress = new EventEmitter(); @@ -301,18 +303,25 @@ export class ParcelEntryComponent implements OnInit { async attachFile(file: FileHandle, parcelUuid: string) { if (parcelUuid) { const mappedFiles = file.file; + try { this.parcel.certificateOfTitle = await this.applicationParcelService.attachCertificateOfTitle( this.fileId, parcelUuid, mappedFiles, ); - } catch (e) { - this.showVirusError = true; - this.toastService.showErrorToast('Document upload failed'); + this.toastService.showSuccessToast('Document uploaded'); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; return; + } catch (err) { + this.toastService.showErrorToast('Document upload failed'); + + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } } - this.showVirusError = false; } } diff --git a/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.html b/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.html index 50ab41d8fb..8016f86310 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.html @@ -82,7 +82,7 @@

Primary Contact

Phone Number:
-
{{ phoneNumber.value ?? '' | mask : '(000) 000-0000' }}
+
{{ phoneNumber.value ?? '' | mask: '(000) 000-0000' }}
Email:
@@ -217,7 +217,8 @@

(openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="needsAuthorizationLetter" - [showVirusError]="showVirusError" + [showHasVirusError]="showHasVirusError" + [showVirusScanFailedError]="showVirusScanFailedError" >

diff --git a/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.ts b/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.ts index eb7c05bd35..1207be60b2 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/primary-contact/primary-contact.component.ts @@ -22,6 +22,7 @@ import { CrownOwnerDialogComponent } from '../../../../shared/owner-dialogs/crow import { scrollToElement } from '../../../../shared/utils/scroll-helper'; import { MatButtonToggleChange } from '@angular/material/button-toggle'; import { strictEmailValidator } from '../../../../shared/validators/email-validator'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-primary-contact', @@ -43,7 +44,8 @@ export class PrimaryContactComponent extends FilesStepComponent implements OnIni isGovernmentUser = false; governmentName: string | undefined; isDirty = false; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; hasCrownParcels = false; ownersList = new FormControl(null, [Validators.required]); @@ -102,8 +104,16 @@ export class PrimaryContactComponent extends FilesStepComponent implements OnIni } async attachAuthorizationLetter(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.AUTHORIZATION_LETTER); - this.showVirusError = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.AUTHORIZATION_LETTER); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } set selectedOwnerUuid(value: string | undefined) { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.html index 3a405b9f67..04928a197d 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.html @@ -32,7 +32,7 @@

Proposal

mat-flat-button color="accent" [ngClass]="{ - 'error-outline': transferees.length === 0 && showErrors + 'error-outline': transferees.length === 0 && showErrors, }" (click)="onAdd()" > @@ -185,7 +185,9 @@

Proposal

(openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" >
@@ -201,7 +203,7 @@

Proposal

Yes @@ -209,7 +211,7 @@

Proposal

No @@ -229,7 +231,8 @@

Proposal

[showErrors]="showErrors" [isRequired]="true" [disabled]="!canUploadDraft" - [showVirusError]="showDraftCovenantVirus" + [showHasVirusError]="showDraftCovenantHasVirusError" + [showVirusScanFailedError]="showDraftCovenantVirusScanFailedError" >
diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.ts index 91ba649d56..2f97b0e10b 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/cove-proposal/cove-proposal.component.ts @@ -20,6 +20,7 @@ import { CovenantTransfereeDialogComponent } from './transferee-dialog/transfere import { ConfirmationDialogService } from '../../../../../shared/confirmation-dialog/confirmation-dialog.service'; import { MOBILE_BREAKPOINT } from '../../../../../shared/utils/breakpoints'; import { VISIBLE_COUNT_INCREMENT } from '../../../../../shared/constants'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-cove-proposal', @@ -49,9 +50,11 @@ export class CoveProposalComponent extends FilesStepComponent implements OnInit, }); proposalMap: ApplicationDocumentDto[] = []; - showProposalMapVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; draftCovenant: ApplicationDocumentDto[] = []; - showDraftCovenantVirus = false; + showDraftCovenantHasVirusError = false; + showDraftCovenantVirusScanFailedError = false; isMobile = false; visibleCount = VISIBLE_COUNT_INCREMENT; @@ -127,13 +130,29 @@ export class CoveProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachDraftCovenant(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.SRW_TERMS); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.SRW_TERMS); + this.showDraftCovenantHasVirusError = false; + this.showDraftCovenantVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showDraftCovenantHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showDraftCovenantVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } onAdd() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.html index c9b3eae36c..d01819fae6 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.html @@ -134,7 +134,9 @@

Proposal

(openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -176,7 +178,9 @@

Notification and Public Hearing Requirements

[showErrors]="showErrors" [allowMultiple]="true" [isRequired]="true" - [showVirusError]="showProofOfAdvertisingVirus" + [showHasVirusError]="showProofOfAdvertisingHasVirusError" + [showVirusScanFailedError]="showProofOfAdvertisingVirusScanFailedError" + [showVirusScanFailedError]="showProofOfAdvertisingVirusScanFailedError" >
@@ -194,7 +198,9 @@

Notification and Public Hearing Requirements

[showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showProofOfSignageVirus" + [showHasVirusError]="showProofOfSignageHasVirusError" + [showVirusScanFailedError]="showProofOfSignageVirusScanFailedError" + [showVirusScanFailedError]="showProofOfSignageVirusScanFailedError" >
@@ -209,7 +215,8 @@

Notification and Public Hearing Requirements

[showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showReportOfPublicHearingVirus" + [showHasVirusError]="showReportOfPublicHearingHasVirusError" + [showVirusScanFailedError]="showReportOfPublicHearingVirusScanFailedError" >
diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.ts index 4bf9578a1c..c2b799792c 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/excl-proposal/excl-proposal.component.ts @@ -13,6 +13,7 @@ import { FileHandle } from '../../../../../shared/file-drag-drop/drag-drop.direc import { parseStringToBoolean } from '../../../../../shared/utils/string-helper'; import { EditApplicationSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-excl-proposal', @@ -25,10 +26,14 @@ export class ExclProposalComponent extends FilesStepComponent implements OnInit, currentStep = EditApplicationSteps.Proposal; prescribedBody: string | null = null; - showProposalMapVirus = false; - showProofOfAdvertisingVirus = false; - showProofOfSignageVirus = false; - showReportOfPublicHearingVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showProofOfAdvertisingHasVirusError = false; + showProofOfAdvertisingVirusScanFailedError = false; + showProofOfSignageHasVirusError = false; + showProofOfSignageVirusScanFailedError = false; + showReportOfPublicHearingHasVirusError = false; + showReportOfPublicHearingVirusScanFailedError = false; hectares = new FormControl(null, [Validators.required]); shareProperty = new FormControl(null, [Validators.required]); @@ -52,7 +57,7 @@ export class ExclProposalComponent extends FilesStepComponent implements OnInit, private pdfGenerationService: PdfGenerationService, applicationDocumentService: ApplicationDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(applicationDocumentService, dialog, toastService); } @@ -83,11 +88,11 @@ export class ExclProposalComponent extends FilesStepComponent implements OnInit, this.$applicationDocuments.pipe(takeUntil(this.$destroy)).subscribe((documents) => { this.proposalMap = documents.filter((document) => document.type?.code === DOCUMENT_TYPE.PROPOSAL_MAP); this.noticeOfPublicHearing = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.PROOF_OF_ADVERTISING + (document) => document.type?.code === DOCUMENT_TYPE.PROOF_OF_ADVERTISING, ); this.proofOfSignage = documents.filter((document) => document.type?.code === DOCUMENT_TYPE.PROOF_OF_SIGNAGE); this.reportOfPublicHearing = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING + (document) => document.type?.code === DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING, ); }); } @@ -97,23 +102,55 @@ export class ExclProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachProofOfAdvertising(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_ADVERTISING); - this.showProofOfAdvertisingVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_ADVERTISING); + this.showProofOfAdvertisingHasVirusError = false; + this.showProofOfAdvertisingVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProofOfAdvertisingHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProofOfAdvertisingVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachProofOfSignage(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_SIGNAGE); - this.showProofOfSignageVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_SIGNAGE); + this.showProofOfSignageHasVirusError = false; + this.showProofOfSignageVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProofOfSignageHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProofOfSignageVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReportOfPublicHearing(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING); - this.showReportOfPublicHearingVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING); + this.showReportOfPublicHearingHasVirusError = false; + this.showReportOfPublicHearingVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReportOfPublicHearingHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReportOfPublicHearingVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async onDownloadPdf() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.html index fc4f8ff429..7173b614d6 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.html @@ -125,7 +125,8 @@

Proposal

(openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" >
@@ -156,7 +157,7 @@

Proposal

value="true" [ngClass]="{ 'error-outline': - governmentOwnsAllParcels.invalid && (governmentOwnsAllParcels.dirty || governmentOwnsAllParcels.touched) + governmentOwnsAllParcels.invalid && (governmentOwnsAllParcels.dirty || governmentOwnsAllParcels.touched), }" >Yes @@ -164,7 +165,7 @@

Proposal

value="false" [ngClass]="{ 'error-outline': - governmentOwnsAllParcels.invalid && (governmentOwnsAllParcels.dirty || governmentOwnsAllParcels.touched) + governmentOwnsAllParcels.invalid && (governmentOwnsAllParcels.dirty || governmentOwnsAllParcels.touched), }" >No @@ -214,7 +215,8 @@

Notification and Public Hearing Requirements

[isRequired]="!disableNotificationFileUploads" [disabled]="disableNotificationFileUploads" [allowMultiple]="true" - [showVirusError]="showProofOfAdvertisingVirus" + [showHasVirusError]="showProofOfAdvertisingHasVirusError" + [showVirusScanFailedError]="showProofOfAdvertisingVirusScanFailedError" >
@@ -233,7 +235,9 @@

Notification and Public Hearing Requirements

[isRequired]="!disableNotificationFileUploads" [disabled]="disableNotificationFileUploads" [allowMultiple]="true" - [showVirusError]="showProofOfSignageVirus" + [showHasVirusError]="showProofOfSignageHasVirusError" + [showVirusScanFailedError]="showProofOfSignageVirusScanFailedError" + [showVirusScanFailedError]="showProofOfSignageVirusScanFailedError" >
@@ -248,7 +252,8 @@

Notification and Public Hearing Requirements

[showErrors]="showErrors" [isRequired]="!disableNotificationFileUploads" [disabled]="disableNotificationFileUploads" - [showVirusError]="showReportOfPublicHearingVirus" + [showHasVirusError]="showReportOfPublicHearingHasVirusError" + [showVirusScanFailedError]="showReportOfPublicHearingVirusScanFailedError" [allowMultiple]="true" >
diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.ts index b292bcd70d..240618bd47 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/incl-proposal/incl-proposal.component.ts @@ -15,6 +15,7 @@ import { ApplicationDocumentDto } from '../../../../../services/application-docu import { EditApplicationSteps } from '../../edit-submission.component'; import { takeUntil } from 'rxjs'; import { ApplicationSubmissionUpdateDto } from '../../../../../services/application-submission/application-submission.dto'; +import { HttpErrorResponse } from '@angular/common/http'; interface InclForm { hectares: FormControl; @@ -37,10 +38,14 @@ export class InclProposalComponent extends FilesStepComponent implements OnInit, governmentName? = ''; disableNotificationFileUploads = true; - showProposalMapVirus = false; - showProofOfAdvertisingVirus = false; - showProofOfSignageVirus = false; - showReportOfPublicHearingVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showProofOfAdvertisingHasVirusError = false; + showProofOfAdvertisingVirusScanFailedError = false; + showProofOfSignageHasVirusError = false; + showProofOfSignageVirusScanFailedError = false; + showReportOfPublicHearingHasVirusError = false; + showReportOfPublicHearingVirusScanFailedError = false; hectares = new FormControl(null, [Validators.required]); purpose = new FormControl(null, [Validators.required]); @@ -65,7 +70,7 @@ export class InclProposalComponent extends FilesStepComponent implements OnInit, private authenticationService: AuthenticationService, applicationDocumentService: ApplicationDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(applicationDocumentService, dialog, toastService); } @@ -86,7 +91,7 @@ export class InclProposalComponent extends FilesStepComponent implements OnInit, if (applicationSubmission.inclGovernmentOwnsAllParcels !== null) { this.showGovernmentQuestions = true; this.governmentOwnsAllParcels.setValue( - formatBooleanToString(applicationSubmission.inclGovernmentOwnsAllParcels) + formatBooleanToString(applicationSubmission.inclGovernmentOwnsAllParcels), ); this.disableNotificationFileUploads = applicationSubmission.inclGovernmentOwnsAllParcels; this.form.setControl('governmentOwnsAllParcels', this.governmentOwnsAllParcels); @@ -101,11 +106,11 @@ export class InclProposalComponent extends FilesStepComponent implements OnInit, this.$applicationDocuments.pipe(takeUntil(this.$destroy)).subscribe((documents) => { this.proposalMap = documents.filter((document) => document.type?.code === DOCUMENT_TYPE.PROPOSAL_MAP); this.noticeOfPublicHearing = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.PROOF_OF_ADVERTISING + (document) => document.type?.code === DOCUMENT_TYPE.PROOF_OF_ADVERTISING, ); this.proofOfSignage = documents.filter((document) => document.type?.code === DOCUMENT_TYPE.PROOF_OF_SIGNAGE); this.reportOfPublicHearing = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING + (document) => document.type?.code === DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING, ); }); @@ -129,23 +134,55 @@ export class InclProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachProofOfAdvertising(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_ADVERTISING); - this.showProofOfAdvertisingVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_ADVERTISING); + this.showProofOfAdvertisingHasVirusError = false; + this.showProofOfAdvertisingVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProofOfAdvertisingHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProofOfAdvertisingVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachProofOfSignage(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_SIGNAGE); - this.showProofOfSignageVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROOF_OF_SIGNAGE); + this.showProofOfSignageHasVirusError = false; + this.showProofOfSignageVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProofOfSignageHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProofOfSignageVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReportOfPublicHearing(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING); - this.showReportOfPublicHearingVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.REPORT_OF_PUBLIC_HEARING); + this.showReportOfPublicHearingHasVirusError = false; + this.showReportOfPublicHearingVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReportOfPublicHearingHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReportOfPublicHearingVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.html index fdad5b04ed..91d0423303 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.html @@ -23,10 +23,7 @@

Proposal

>
  • - + Housing in the ALR
  • @@ -642,7 +639,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -659,7 +657,8 @@

    Proposal

    (deleteFile)="onDeleteFile($event)" (openFile)="openFile($event)" [showErrors]="showErrors" - [showVirusError]="showBuildingPlanVirus" + [showHasVirusError]="showBuildingPlanHasVirusError" + [showVirusScanFailedError]="showBuildingPlanVirusScanFailedError" > diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.ts index 31dc7d862d..b7296fdb4f 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/naru-proposal/naru-proposal.component.ts @@ -21,6 +21,7 @@ import { ResidenceDialogComponent } from './residence-dialog/residence-dialog.co import { MOBILE_BREAKPOINT } from '../../../../../shared/utils/breakpoints'; import { isTruncated, truncate } from '../../../../../shared/utils/string-helper'; import { EXISTING_RESIDENCE_DESCRIPTION_CHAR_LIMIT } from '../../../../../shared/constants'; +import { HttpErrorResponse } from '@angular/common/http'; export type FormExisingResidence = { id?: number; floorArea: number; description: string; isExpanded?: boolean }; export type FormProposedResidence = { id?: number; floorArea: number; description: string; isExpanded?: boolean }; @@ -33,8 +34,10 @@ export type FormProposedResidence = { id?: number; floorArea: number; descriptio export class NaruProposalComponent extends FilesStepComponent implements OnInit, OnDestroy { currentStep = EditApplicationSteps.Proposal; - showProposalMapVirus = false; - showBuildingPlanVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showBuildingPlanHasVirusError = false; + showBuildingPlanVirusScanFailedError = false; willBeOverFiveHundredM2 = new FormControl(null, [Validators.required]); willRetainResidence = new FormControl(null, [Validators.required]); @@ -179,13 +182,29 @@ export class NaruProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachBuildingPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); - this.showBuildingPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); + this.showBuildingPlanHasVirusError = false; + this.showBuildingPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showBuildingPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showBuildingPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } onChangeOver500m2(answerIsYes: boolean) { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.html index f21a41495c..19fc39d22e 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.html @@ -120,7 +120,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" >
    diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.ts index 09fd87a73c..13e030b3fa 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/nfu-proposal/nfu-proposal.component.ts @@ -14,6 +14,7 @@ import { SoilTableData } from '../../../../../shared/soil-table/soil-table.compo import { EditApplicationSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; import { ConfirmationDialogService } from '../../../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-nfu-proposal', @@ -25,7 +26,8 @@ export class NfuProposalComponent extends FilesStepComponent implements OnInit, fillTableData: SoilTableData = {}; fillTableDisabled = true; - showProposalMapVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; hectares = new FormControl(null, [Validators.required]); purpose = new FormControl(null, [Validators.required]); @@ -103,8 +105,16 @@ export class NfuProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.html index a75d8cf34f..0ee431e81c 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.html @@ -314,12 +314,22 @@

    Proposal

    - + - - -
    #{{ i + 1 }} + {{ i + 1 }} + Type + Proposal Total Floor Area + Proposal Action + @@ -683,7 +701,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -709,7 +728,8 @@

    Proposal

    [showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showCrossSectionVirus" + [showHasVirusError]="showCrossSectionHasVirusError" + [showVirusScanFailedError]="showCrossSectionVirusScanFailedError" > @@ -737,7 +757,8 @@

    Proposal

    [showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showReclamationPlanVirus" + [showHasVirusError]="showReclamationPlanHasVirusError" + [showVirusScanFailedError]="showReclamationPlanVirusScanFailedError" > @@ -756,7 +777,8 @@

    Proposal

    (deleteFile)="onDeleteFile($event)" (openFile)="openFile($event)" [showErrors]="showErrors" - [showVirusError]="showBuildingPlanVirus" + [showHasVirusError]="showBuildingPlanHasVirusError" + [showVirusScanFailedError]="showBuildingPlanVirusScanFailedError" [isRequired]="true" [allowMultiple]="true" > @@ -862,7 +884,8 @@

    Proposal

    [isRequired]="true" [disabled]="!requiresNoticeOfWork" [allowMultiple]="true" - [showVirusError]="showNoticeOfWorkVirus" + [showHasVirusError]="showNoticeOfWorkHasVirusError" + [showVirusScanFailedError]="showNoticeOfWorkVirusScanFailedError" > diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.ts index e887069f3d..a30c6f11c3 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/pfrs-proposal/pfrs-proposal.component.ts @@ -25,6 +25,7 @@ import { } from '../../../../notice-of-intents/edit-submission/additional-information/additional-information.component'; import { ProposedStructure } from '../../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { AddStructureDialogComponent } from '../../../../notice-of-intents/edit-submission/additional-information/add-structure-dialog/add-structure-dialog.component'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-pfrs-proposal', @@ -54,11 +55,16 @@ export class PfrsProposalComponent extends FilesStepComponent implements OnInit, noticeOfWork: ApplicationDocumentDto[] = []; areComponentsDirty = false; - showProposalMapVirus = false; - showCrossSectionVirus = false; - showReclamationPlanVirus = false; - showBuildingPlanVirus = false; - showNoticeOfWorkVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showCrossSectionHasVirusError = false; + showCrossSectionVirusScanFailedError = false; + showReclamationPlanHasVirusError = false; + showReclamationPlanVirusScanFailedError = false; + showBuildingPlanHasVirusError = false; + showBuildingPlanVirusScanFailedError = false; + showNoticeOfWorkHasVirusError = false; + showNoticeOfWorkVirusScanFailedError = false; isNewStructure = new FormControl(null, [Validators.required]); isFollowUp = new FormControl(null, [Validators.required]); @@ -227,28 +233,68 @@ export class PfrsProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachCrossSection(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); - this.showCrossSectionVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); + this.showCrossSectionHasVirusError = false; + this.showCrossSectionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showCrossSectionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showCrossSectionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReclamationPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); - this.showReclamationPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); + this.showReclamationPlanHasVirusError = false; + this.showReclamationPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReclamationPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReclamationPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachBuildingPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); - this.showBuildingPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); + this.showBuildingPlanHasVirusError = false; + this.showBuildingPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showBuildingPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showBuildingPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachNoticeOfWork(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.NOTICE_OF_WORK); - this.showNoticeOfWorkVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.NOTICE_OF_WORK); + this.showNoticeOfWorkHasVirusError = false; + this.showNoticeOfWorkVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showNoticeOfWorkHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showNoticeOfWorkVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.html index 80c3e64ae8..694be894c0 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.html @@ -222,12 +222,22 @@

    Proposal

    - + - - -
    #{{ i + 1 }} + {{ i + 1 }} + Type + Proposal Total Floor Area + Proposal Action + @@ -593,7 +611,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -619,7 +638,8 @@

    Proposal

    [showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showCrossSectionVirus" + [showHasVirusError]="showCrossSectionHasVirusError" + [showVirusScanFailedError]="showCrossSectionVirusScanFailedError" > @@ -647,7 +667,8 @@

    Proposal

    [showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showReclamationPlanVirus" + [showHasVirusError]="showReclamationPlanHasVirusError" + [showVirusScanFailedError]="showReclamationPlanVirusScanFailedError" > @@ -666,7 +687,8 @@

    Proposal

    (deleteFile)="onDeleteFile($event)" (openFile)="openFile($event)" [showErrors]="showErrors" - [showVirusError]="showBuildingPlanVirus" + [showHasVirusError]="showBuildingPlanHasVirusError" + [showVirusScanFailedError]="showBuildingPlanVirusScanFailedError" [isRequired]="true" [allowMultiple]="true" > diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.ts index a9635c9777..28f920fcac 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/pofo-proposal/pofo-proposal.component.ts @@ -26,6 +26,7 @@ import { MOBILE_BREAKPOINT } from '../../../../../shared/utils/breakpoints'; import { AddStructureDialogComponent } from '../../../../../features/notice-of-intents/edit-submission/additional-information/add-structure-dialog/add-structure-dialog.component'; import { ProposedStructure } from '../../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { v4 } from 'uuid'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-pofo-proposal', @@ -53,10 +54,14 @@ export class PofoProposalComponent extends FilesStepComponent implements OnInit, reclamationPlan: ApplicationDocumentDto[] = []; buildingPlans: ApplicationDocumentDto[] = []; - showProposalMapVirus = false; - showCrossSectionVirus = false; - showReclamationPlanVirus = false; - showBuildingPlanVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showCrossSectionHasVirusError = false; + showCrossSectionVirusScanFailedError = false; + showReclamationPlanHasVirusError = false; + showReclamationPlanVirusScanFailedError = false; + showBuildingPlanHasVirusError = false; + showBuildingPlanVirusScanFailedError = false; isNewStructure = new FormControl(null, [Validators.required]); isFollowUp = new FormControl(null, [Validators.required]); @@ -188,23 +193,55 @@ export class PofoProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachCrossSection(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); - this.showCrossSectionVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); + this.showCrossSectionHasVirusError = false; + this.showCrossSectionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showCrossSectionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showCrossSectionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReclamationPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); - this.showReclamationPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); + this.showReclamationPlanHasVirusError = false; + this.showReclamationPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReclamationPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReclamationPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachBuildingPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); - this.showBuildingPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); + this.showBuildingPlanHasVirusError = false; + this.showBuildingPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showBuildingPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showBuildingPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.html index 91f79ea8ae..097f44ab52 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.html @@ -224,12 +224,22 @@

    Proposal

    - + - - -
    #{{ i + 1 }} + {{ i + 1 }} + Type + Proposal Total Floor Area + Proposal Action + @@ -561,7 +579,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -587,7 +606,8 @@

    Proposal

    [showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showCrossSectionVirus" + [showHasVirusError]="showCrossSectionHasVirusError" + [showVirusScanFailedError]="showCrossSectionVirusScanFailedError" > @@ -615,7 +635,8 @@

    Proposal

    [showErrors]="showErrors" [isRequired]="true" [allowMultiple]="true" - [showVirusError]="showReclamationPlanVirus" + [showHasVirusError]="showReclamationPlanHasVirusError" + [showVirusScanFailedError]="showReclamationPlanVirusScanFailedError" > @@ -634,7 +655,8 @@

    Proposal

    (deleteFile)="onDeleteFile($event)" (openFile)="openFile($event)" [showErrors]="showErrors" - [showVirusError]="showBuildingPlanVirus" + [showHasVirusError]="showBuildingPlanHasVirusError" + [showVirusScanFailedError]="showBuildingPlanVirusScanFailedError" [isRequired]="true" [allowMultiple]="true" > diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.ts index 3bea4eb978..2f1ee76a8d 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/roso-proposal/roso-proposal.component.ts @@ -26,6 +26,7 @@ import { MOBILE_BREAKPOINT } from '../../../../../shared/utils/breakpoints'; import { AddStructureDialogComponent } from '../../../../../features/notice-of-intents/edit-submission/additional-information/add-structure-dialog/add-structure-dialog.component'; import { ProposedStructure } from '../../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { v4 } from 'uuid'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-roso-proposal', @@ -53,10 +54,14 @@ export class RosoProposalComponent extends FilesStepComponent implements OnInit, reclamationPlan: ApplicationDocumentDto[] = []; buildingPlans: ApplicationDocumentDto[] = []; - showProposalMapVirus = false; - showCrossSectionVirus = false; - showReclamationPlanVirus = false; - showBuildingPlanVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showCrossSectionHasVirusError = false; + showCrossSectionVirusScanFailedError = false; + showReclamationPlanHasVirusError = false; + showReclamationPlanVirusScanFailedError = false; + showBuildingPlanHasVirusError = false; + showBuildingPlanVirusScanFailedError = false; isNewStructure = new FormControl(null, [Validators.required]); isFollowUp = new FormControl(null, [Validators.required]); @@ -185,23 +190,55 @@ export class RosoProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachCrossSection(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); - this.showCrossSectionVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); + this.showCrossSectionHasVirusError = false; + this.showCrossSectionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showCrossSectionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showCrossSectionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReclamationPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); - this.showReclamationPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); + this.showReclamationPlanHasVirusError = false; + this.showReclamationPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReclamationPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReclamationPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachBuildingPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); - this.showBuildingPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); + this.showBuildingPlanHasVirusError = false; + this.showBuildingPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showBuildingPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showBuildingPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.html index 9029fd3495..db08232046 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.html @@ -215,7 +215,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" >
    @@ -240,7 +241,7 @@

    Proposal

    Yes @@ -248,7 +249,7 @@

    Proposal

    No @@ -271,7 +272,8 @@

    Proposal

    [isRequired]="isHomeSiteSeverance.getRawValue() !== 'true'" [allowMultiple]="true" [disabled]="isHomeSiteSeverance.getRawValue() !== 'true'" - [showVirusError]="showHomesiteSeveranceVirus" + [showHasVirusError]="showHomesiteSeveranceHasVirusError" + [showVirusScanFailedError]="showHomesiteSeveranceVirusScanFailedError" >
    diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.ts index 8149f2a71a..2b9c66617f 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/subd-proposal/subd-proposal.component.ts @@ -17,6 +17,7 @@ import { DOCUMENT_TYPE } from '../../../../../shared/dto/document.dto'; import { FileHandle } from '../../../../../shared/file-drag-drop/drag-drop.directive'; import { EditApplicationSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; +import { HttpErrorResponse } from '@angular/common/http'; type FormProposedLot = { type: 'Lot' | 'Road Dedication' | null; size: string | null }; @@ -31,8 +32,10 @@ export class SubdProposalComponent extends FilesStepComponent implements OnInit, homesiteSeverance: ApplicationDocumentDto[] = []; proposalMap: ApplicationDocumentDto[] = []; - showHomesiteSeveranceVirus = false; - showProposalMapVirus = false; + showHomesiteSeveranceHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showProposalMapHasVirusError = false; + showHomesiteSeveranceVirusScanFailedError = false; lotsProposed = new FormControl(null, [Validators.required]); purpose = new FormControl(null, [Validators.required]); @@ -62,7 +65,7 @@ export class SubdProposalComponent extends FilesStepComponent implements OnInit, private parcelService: ApplicationParcelService, applicationDocumentService: ApplicationDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(applicationDocumentService, dialog, toastService); } @@ -120,13 +123,29 @@ export class SubdProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachHomesiteSeverance(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.HOMESITE_SEVERANCE); - this.showHomesiteSeveranceVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.HOMESITE_SEVERANCE); + this.showHomesiteSeveranceHasVirusError = false; + this.showHomesiteSeveranceVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHomesiteSeveranceHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showHomesiteSeveranceVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.html b/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.html index 730f9cf721..f72b17cfab 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.html +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.html @@ -149,7 +149,7 @@

    Proposal

    [formControl]="allOwnersNotified" [ngClass]="{ 'parcel-checkbox': true, - 'error-outline': allOwnersNotified.invalid && (allOwnersNotified.dirty || allOwnersNotified.touched) + 'error-outline': allOwnersNotified.invalid && (allOwnersNotified.dirty || allOwnersNotified.touched), }" >I confirm that all affected property owners with land in the ALR have been notified. @@ -178,7 +178,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showServingNoticeVirus" + [showHasVirusError]="showServingNoticeHasVirusError" + [showVirusScanFailedError]="showServingNoticeVirusScanFailedError" data-testid="proof-of-serving-notice-filechooser" > @@ -193,7 +194,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" data-testid="proposal-map-filechooser" > diff --git a/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.ts b/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.ts index adab6177fe..b640d60b21 100644 --- a/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.ts +++ b/portal-frontend/src/app/features/applications/edit-submission/proposal/tur-proposal/tur-proposal.component.ts @@ -12,6 +12,7 @@ import { DOCUMENT_TYPE } from '../../../../../shared/dto/document.dto'; import { FileHandle } from '../../../../../shared/file-drag-drop/drag-drop.directive'; import { EditApplicationSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-tur-proposal', @@ -26,8 +27,10 @@ export class TurProposalComponent extends FilesStepComponent implements OnInit, servingNotice: ApplicationDocumentDto[] = []; proposalMap: ApplicationDocumentDto[] = []; - showServingNoticeVirus = false; - showProposalMapVirus = false; + showServingNoticeHasVirusError = false; + showServingNoticeVirusScanFailedError = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; purpose = new FormControl(null, [Validators.required]); outsideLands = new FormControl(null, [Validators.required]); @@ -51,7 +54,7 @@ export class TurProposalComponent extends FilesStepComponent implements OnInit, private applicationService: ApplicationSubmissionService, applicationDocumentService: ApplicationDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(applicationDocumentService, dialog, toastService); } @@ -84,13 +87,29 @@ export class TurProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachServinceNotice(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.SERVING_NOTICE); - this.showServingNoticeVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.SERVING_NOTICE); + this.showServingNoticeHasVirusError = false; + this.showServingNoticeVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showServingNoticeHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showServingNoticeVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async onSave() { diff --git a/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html b/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html index 945a20c66d..b2656719a3 100644 --- a/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html +++ b/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.html @@ -18,7 +18,8 @@

    Attachments

    (openFile)="openFile($event)" [isRequired]="true" [showErrors]="showErrors" - [showVirusError]="showResolutionVirusError" + [showHasVirusError]="showResolutionHasVirusError" + [showVirusScanFailedError]="showResolutionVirusScanFailedError" >
    @@ -31,7 +32,8 @@

    Attachments

    (openFile)="openFile($event)" [isRequired]="isAuthorized" [showErrors]="showErrors" - [showVirusError]="showStaffReportVirusError" + [showHasVirusError]="showStaffReportHasVirusError" + [showVirusScanFailedError]="showStaffReportVirusScanFailedError" >
    @@ -43,7 +45,8 @@

    Attachments

    (deleteFile)="deleteFile($event)" (openFile)="openFile($event)" [allowMultiple]="true" - [showVirusError]="showOtherVirusError" + [showHasVirusError]="showOtherHasVirusError" + [showVirusScanFailedError]="showOtherVirusScanFailedError" >
    diff --git a/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.ts b/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.ts index f0156a2999..0fae15d00d 100644 --- a/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.ts +++ b/portal-frontend/src/app/features/applications/review-submission/review-attachments/review-attachments.component.ts @@ -8,6 +8,7 @@ import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../../shared/dto/document. import { FileHandle } from '../../../../shared/file-drag-drop/drag-drop.directive'; import { ReviewApplicationFngSteps, ReviewApplicationSteps } from '../review-submission.component'; import { openFileInline } from '../../../../shared/utils/file'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-review-attachments', @@ -32,14 +33,18 @@ export class ReviewAttachmentsComponent implements OnInit, OnDestroy { isAuthorized = false; showMandatoryUploads = false; hasCompletedPreviousSteps = false; - showResolutionVirusError = false; - showStaffReportVirusError = false; - showOtherVirusError = false; + + showResolutionHasVirusError = false; + showResolutionVirusScanFailedError = false; + showStaffReportHasVirusError = false; + showStaffReportVirusScanFailedError = false; + showOtherHasVirusError = false; + showOtherVirusScanFailedError = false; constructor( private applicationReviewService: ApplicationSubmissionReviewService, private applicationDocumentService: ApplicationDocumentService, - private toastService: ToastService + private toastService: ToastService, ) {} ngOnInit(): void { @@ -69,11 +74,11 @@ export class ReviewAttachmentsComponent implements OnInit, OnDestroy { this.$applicationDocuments.pipe(takeUntil(this.$destroy)).subscribe((documents) => { this.resolutionDocument = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.RESOLUTION_DOCUMENT + (document) => document.type?.code === DOCUMENT_TYPE.RESOLUTION_DOCUMENT, ); this.staffReport = documents.filter((document) => document.type?.code === DOCUMENT_TYPE.STAFF_REPORT); this.otherAttachments = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.OTHER && document.source === DOCUMENT_SOURCE.LFNG + (document) => document.type?.code === DOCUMENT_TYPE.OTHER && document.source === DOCUMENT_SOURCE.LFNG, ); }); } @@ -86,18 +91,42 @@ export class ReviewAttachmentsComponent implements OnInit, OnDestroy { } async attachStaffReport(fileHandle: FileHandle) { - const res = await this.attachFile(fileHandle, DOCUMENT_TYPE.STAFF_REPORT); - this.showStaffReportVirusError = !res; + try { + await this.attachFile(fileHandle, DOCUMENT_TYPE.STAFF_REPORT); + this.showStaffReportHasVirusError = false; + this.showStaffReportVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showStaffReportHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showStaffReportVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachResolutionDocument(fileHandle: FileHandle) { - const res = await this.attachFile(fileHandle, DOCUMENT_TYPE.RESOLUTION_DOCUMENT); - this.showResolutionVirusError = !res; + try { + await this.attachFile(fileHandle, DOCUMENT_TYPE.RESOLUTION_DOCUMENT); + this.showResolutionHasVirusError = false; + this.showResolutionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showResolutionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showResolutionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachOtherDocument(fileHandle: FileHandle) { - const res = await this.attachFile(fileHandle, DOCUMENT_TYPE.OTHER); - this.showOtherVirusError = !res; + try { + await this.attachFile(fileHandle, DOCUMENT_TYPE.OTHER); + this.showOtherHasVirusError = false; + this.showOtherVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showOtherHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showOtherVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } private async attachFile(fileHandle: FileHandle, documentType: DOCUMENT_TYPE) { @@ -107,15 +136,16 @@ export class ReviewAttachmentsComponent implements OnInit, OnDestroy { this.fileId, fileHandle.file, documentType, - DOCUMENT_SOURCE.LFNG + DOCUMENT_SOURCE.LFNG, ); - } catch (e) { + this.toastService.showSuccessToast('Document uploaded'); + } catch (err) { this.toastService.showErrorToast('Document upload failed'); - return false; + + throw err; } await this.loadApplicationDocuments(this.fileId); } - return true; } async deleteFile($event: ApplicationDocumentDto) { diff --git a/portal-frontend/src/app/features/login/login.component.spec.ts b/portal-frontend/src/app/features/login/login.component.spec.ts index c4dd62f746..e0edb8dc86 100644 --- a/portal-frontend/src/app/features/login/login.component.spec.ts +++ b/portal-frontend/src/app/features/login/login.component.spec.ts @@ -7,17 +7,21 @@ import { AuthenticationService } from '../../services/authentication/authenticat import { MaintenanceService } from '../../services/maintenance/maintenance.service'; import { LoginComponent } from './login.component'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { ToastService } from '../../services/toast/toast.service'; describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture; let mockAuthService: DeepMocked; let mockMaintenanceService: DeepMocked; + let mockToastService: DeepMocked; beforeEach(async () => { mockMaintenanceService = createMock(); mockAuthService = createMock(); mockAuthService.$currentProfile = new BehaviorSubject(undefined); + mockToastService = createMock(); await TestBed.configureTestingModule({ declarations: [LoginComponent], @@ -30,6 +34,18 @@ describe('LoginComponent', () => { provide: MaintenanceService, useValue: mockMaintenanceService, }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParamMap: convertToParamMap({ login_failed: 'true' }), + }, + }, + }, + { + provide: ToastService, + useValue: mockToastService, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/portal-frontend/src/app/features/login/login.component.ts b/portal-frontend/src/app/features/login/login.component.ts index 31b7c38e7d..6480fff895 100644 --- a/portal-frontend/src/app/features/login/login.component.ts +++ b/portal-frontend/src/app/features/login/login.component.ts @@ -1,8 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Subject, takeUntil } from 'rxjs'; import { AuthenticationService } from '../../services/authentication/authentication.service'; import { MaintenanceService } from '../../services/maintenance/maintenance.service'; +import { ToastService } from '../../services/toast/toast.service'; @Component({ selector: 'app-login', @@ -14,7 +15,9 @@ export class LoginComponent implements OnInit, OnDestroy { constructor( private authenticationService: AuthenticationService, private maintenanceService: MaintenanceService, - private router: Router + private route: ActivatedRoute, + private router: Router, + private toastService: ToastService, ) {} ngOnInit() { @@ -25,6 +28,14 @@ export class LoginComponent implements OnInit, OnDestroy { this.router.navigateByUrl('/home'); } }); + + const login_failed = this.route.snapshot.queryParamMap.get('login_failed')?.toLowerCase() === 'true'; + + if (login_failed) { + this.toastService.showErrorToast( + 'Login failed. Please try again. If the problem persists, contact ALC.Portal@gov.bc.ca."', + ); + } } async onLogin() { diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.html index a79f4e2a60..8861eeceff 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.html @@ -40,7 +40,7 @@

    Additional Proposal Information

    [ngClass]="{ 'error-outline': isRemovingSoilForNewStructure.invalid && - (isRemovingSoilForNewStructure.dirty || isRemovingSoilForNewStructure.touched) + (isRemovingSoilForNewStructure.dirty || isRemovingSoilForNewStructure.touched), }" value="true" >Yes @@ -50,7 +50,7 @@

    Additional Proposal Information

    [ngClass]="{ 'error-outline': isRemovingSoilForNewStructure.invalid && - (isRemovingSoilForNewStructure.dirty || isRemovingSoilForNewStructure.touched) + (isRemovingSoilForNewStructure.dirty || isRemovingSoilForNewStructure.touched), }" value="false" >No @@ -68,7 +68,7 @@

    Additional Proposal Information

    Provide the total floor area (m2) for each of the proposed structure(s)
    - Additional Proposal Information [isReviewStep]="false" (removeClicked)="onStructureRemove(structure.id)" (editClicked)="onStructureEdit(structure.id)" - > + >
    - + - - - @@ -381,7 +402,8 @@

    Additional Proposal Information

    (deleteFile)="onDeleteFile($event)" (openFile)="openFile($event)" [showErrors]="showErrors" - [showVirusError]="showBuildingPlanVirus" + [showHasVirusError]="showBuildingPlanHasVirusError" + [showVirusScanFailedError]="showBuildingPlanVirusScanFailedError" [isRequired]="confirmRemovalOfSoil" [allowMultiple]="true" [disabled]="!confirmRemovalOfSoil" diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.ts index cce35f4b30..8f068dd154 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/additional-information/additional-information.component.ts @@ -24,6 +24,7 @@ import { DeleteStructureConfirmationDialogComponent } from './delete-structure-c import { SoilRemovalConfirmationDialogComponent } from './soil-removal-confirmation-dialog/soil-removal-confirmation-dialog.component'; import { AddStructureDialogComponent } from './add-structure-dialog/add-structure-dialog.component'; import { MOBILE_BREAKPOINT } from '../../../../shared/utils/breakpoints'; +import { HttpErrorResponse } from '@angular/common/http'; export enum STRUCTURE_TYPES { FARM_STRUCTURE = 'Farm Structure', @@ -95,7 +96,8 @@ export class AdditionalInformationComponent extends FilesStepComponent implement typeCode: string = ''; confirmRemovalOfSoil = false; - showBuildingPlanVirus = false; + showBuildingPlanHasVirusError = false; + showBuildingPlanVirusScanFailedError = false; buildingPlans: NoticeOfIntentDocumentDto[] = []; proposedStructures: FormProposedStructure[] = []; @@ -195,8 +197,16 @@ export class AdditionalInformationComponent extends FilesStepComponent implement } async attachBuildingPlan(file: FileHandle) { - const attachmentSucceeded = await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); - this.showBuildingPlanVirus = !attachmentSucceeded; + try { + await this.attachFile(file, DOCUMENT_TYPE.BUILDING_PLAN); + this.showBuildingPlanHasVirusError = false; + this.showBuildingPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showBuildingPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showBuildingPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } prepareStructureSpecificTextInputs() { diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/files-step.partial.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/files-step.partial.ts index a3dcebd03f..b57663de3f 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/files-step.partial.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/files-step.partial.ts @@ -38,24 +38,22 @@ export abstract class FilesStepComponent extends StepComponent { await this.save(); const mappedFiles = file.file; - let res; try { - res = await this.noticeOfIntentDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + const res = await this.noticeOfIntentDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + + if (res) { + this.toastService.showSuccessToast('Document uploaded'); + const documents = await this.noticeOfIntentDocumentService.getByFileId(this.fileId); + if (documents) { + this.$noiDocuments.next(documents); + } + } } catch (err) { this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - return false; - } - } - if (res) { - const documents = await this.noticeOfIntentDocumentService.getByFileId(this.fileId); - if (documents) { - this.$noiDocuments.next(documents); - } + throw err; } } - return true; } async onDeleteFile($event: NoticeOfIntentDocumentDto) { diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html index 8622a33a24..a915d3aa02 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html @@ -1,79 +1,79 @@
    -

    {{title}} optional attachment

    +

    {{ title }} optional attachment

    -
    - - -
    -
    -
    -
    - - - - {{ type.label }} - - - -
    - warning -
    - This field is required -
    -
    -
    -
    - - - -
    - warning -
    - This field is required -
    -
    -
    +
    + + +
    + +
    +
    + + + + {{ type.label }} + + + +
    + warning +
    This field is required
    +
    +
    +
    + + + +
    + warning +
    This field is required
    - +
    +
    +
    -
    - - - -
    -
    \ No newline at end of file +
    + + + +
    + diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts index fc9d0fd864..bf241c192a 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts @@ -1,210 +1,230 @@ import { Component, Inject, type OnInit } from '@angular/core'; -import { NoticeOfIntentDocumentDto, NoticeOfIntentDocumentUpdateDto } from "../../../../../services/notice-of-intent-document/notice-of-intent-document.dto"; -import { FileHandle } from "../../../../../shared/file-drag-drop/drag-drop.directive"; -import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from "../../../../../shared/dto/document.dto"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; -import { OtherAttachmentsComponent } from "../other-attachments.component"; -import { NoticeOfIntentDocumentService } from "../../../../../services/notice-of-intent-document/notice-of-intent-document.service"; -import { CodeService } from "../../../../../services/code/code.service"; -import { ToastService } from "../../../../../services/toast/toast.service"; +import { + NoticeOfIntentDocumentDto, + NoticeOfIntentDocumentUpdateDto, +} from '../../../../../services/notice-of-intent-document/notice-of-intent-document.dto'; +import { FileHandle } from '../../../../../shared/file-drag-drop/drag-drop.directive'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from '../../../../../shared/dto/document.dto'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { OtherAttachmentsComponent } from '../other-attachments.component'; +import { NoticeOfIntentDocumentService } from '../../../../../services/notice-of-intent-document/notice-of-intent-document.service'; +import { CodeService } from '../../../../../services/code/code.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { HttpErrorResponse } from '@angular/common/http'; const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; @Component({ - selector: 'app-other-attachments-upload-dialog', - templateUrl: './other-attachments-upload-dialog.component.html', - styleUrl: './other-attachments-upload-dialog.component.scss', + selector: 'app-other-attachments-upload-dialog', + templateUrl: './other-attachments-upload-dialog.component.html', + styleUrl: './other-attachments-upload-dialog.component.scss', }) export class OtherAttachmentsUploadDialogComponent implements OnInit { - isDirty = false; - isFileDirty = false; - isSaving = false; - showVirusError = false; - showFileRequiredError = false; - title: string = ''; - isEditing = false; - - attachment: NoticeOfIntentDocumentDto[] = []; - attachmentForDelete: NoticeOfIntentDocumentDto[] = []; - pendingFile: FileHandle | undefined; - selectableTypes: DocumentTypeDto[] = []; - private documentCodes: DocumentTypeDto[] = []; - - fileDescription = new FormControl(null, [Validators.required]); - fileType = new FormControl(null, [Validators.required]); - currentDescription: string | null = null; - currentType: DocumentTypeDto | null = null; - - form = new FormGroup({ - fileDescription: this.fileDescription, - fileType: this.fileType, - }); - - constructor( - private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) - public data: { - otherAttachmentsComponent: OtherAttachmentsComponent, - existingDocument?: NoticeOfIntentDocumentDto, - fileId: string, - }, - private noticeOfIntentDocumentService: NoticeOfIntentDocumentService, - private codeService: CodeService, - private toastService: ToastService - ) {} - - ngOnInit(): void { - this.loadDocumentCodes(); - if (this.data.existingDocument) { - this.title = 'Edit'; - this.isEditing = true; - this.fileType.setValue(this.data.existingDocument.type!.code); - this.fileDescription.setValue(this.data.existingDocument.description!); - this.currentDescription = this.data.existingDocument.description!; - this.currentType = this.data.existingDocument.type; - this.attachment = [this.data.existingDocument]; - } else { - this.title = 'Add'; - } + isDirty = false; + isFileDirty = false; + isSaving = false; + showHasVirusError = false; + showVirusScanFailedError = false; + showFileRequiredError = false; + title: string = ''; + isEditing = false; + + attachment: NoticeOfIntentDocumentDto[] = []; + attachmentForDelete: NoticeOfIntentDocumentDto[] = []; + pendingFile: FileHandle | undefined; + selectableTypes: DocumentTypeDto[] = []; + private documentCodes: DocumentTypeDto[] = []; + + fileDescription = new FormControl(null, [Validators.required]); + fileType = new FormControl(null, [Validators.required]); + currentDescription: string | null = null; + currentType: DocumentTypeDto | null = null; + + form = new FormGroup({ + fileDescription: this.fileDescription, + fileType: this.fileType, + }); + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public data: { + otherAttachmentsComponent: OtherAttachmentsComponent; + existingDocument?: NoticeOfIntentDocumentDto; + fileId: string; + }, + private noticeOfIntentDocumentService: NoticeOfIntentDocumentService, + private codeService: CodeService, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + this.loadDocumentCodes(); + if (this.data.existingDocument) { + this.title = 'Edit'; + this.isEditing = true; + this.fileType.setValue(this.data.existingDocument.type!.code); + this.fileDescription.setValue(this.data.existingDocument.description!); + this.currentDescription = this.data.existingDocument.description!; + this.currentType = this.data.existingDocument.type; + this.attachment = [this.data.existingDocument]; + } else { + this.title = 'Add'; } - - async attachDocument(file: FileHandle) { - this.pendingFile = file; - this.attachment = [{uuid: '', - fileName: file.file.name, - type: null, - fileSize: file.file.size, - uploadedBy: '', - uploadedAt: file.file.lastModified, - source: DOCUMENT_SOURCE.APPLICANT, - }]; - this.isFileDirty = true; - this.showFileRequiredError = false; + } + + async attachDocument(file: FileHandle) { + this.pendingFile = file; + this.attachment = [ + { + uuid: '', + fileName: file.file.name, + type: null, + fileSize: file.file.size, + uploadedBy: '', + uploadedAt: file.file.lastModified, + source: DOCUMENT_SOURCE.APPLICANT, + }, + ]; + this.isFileDirty = true; + this.showFileRequiredError = false; + } + + openFile() { + if (this.isEditing && this.pendingFile === undefined) { + this.data.otherAttachmentsComponent.openFile(this.attachment[0]); + } else { + if (this.pendingFile) { + const fileURL = URL.createObjectURL(this.pendingFile.file); + window.open(fileURL, '_blank'); + } } + } - openFile() { - if (this.isEditing && this.pendingFile === undefined) { - this.data.otherAttachmentsComponent.openFile(this.attachment[0]); - } else { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile.file); - window.open(fileURL, '_blank'); - } - } + deleteFile() { + this.pendingFile = undefined; + if (this.isEditing) { + this.attachmentForDelete = this.attachment; } - - deleteFile() { - this.pendingFile = undefined; - if (this.isEditing) { - this.attachmentForDelete = this.attachment; - } - this.attachment = []; + this.attachment = []; + } + + onChangeDescription() { + this.isDirty = true; + this.currentDescription = this.fileDescription.value; + } + + onChangeType(selectedValue: DOCUMENT_TYPE) { + this.isDirty = true; + const newType = this.documentCodes.find((code) => code.code === selectedValue); + this.currentType = newType !== undefined ? newType : null; + } + + validateForm() { + if (this.form.valid && this.attachment.length !== 0) { + return true; } - onChangeDescription() { - this.isDirty = true; - this.currentDescription = this.fileDescription.value; + if (this.form.invalid) { + this.form.markAllAsTouched(); } - onChangeType(selectedValue: DOCUMENT_TYPE) { - this.isDirty = true; - const newType = this.documentCodes.find((code) => code.code === selectedValue); - this.currentType = newType !== undefined ? newType : null; + if (this.attachment.length == 0) { + this.showFileRequiredError = true; } + return false; + } - validateForm() { - if (this.form.valid && this.attachment.length !== 0) { - return true; - } - - if (this.form.invalid) { - this.form.markAllAsTouched(); - } - - if (this.attachment.length == 0) { - this.showFileRequiredError = true; - } - return false; + async onAdd() { + if (this.validateForm()) { + await this.add(); } - - async onAdd() { - if (this.validateForm()) { - await this.add(); + } + + protected async add() { + if (this.isFileDirty) { + this.isSaving = true; + try { + await this.data.otherAttachmentsComponent.attachFile(this.pendingFile!, null); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + + const documents = await this.noticeOfIntentDocumentService.getByFileId(this.data.fileId); + if (documents) { + const sortedDocuments = documents.sort((a, b) => { + return b.uploadedAt - a.uploadedAt; + }); + const updateDtos: NoticeOfIntentDocumentUpdateDto[] = sortedDocuments.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + updateDtos[0] = { + ...updateDtos[0], + description: this.currentDescription, + type: this.currentType?.code ?? null, + }; + await this.noticeOfIntentDocumentService.update(this.data.fileId, updateDtos); + this.toastService.showSuccessToast('Attachment added successfully'); + this.dialogRef.close(); + } else { + this.toastService.showErrorToast('Could not read attached documents'); } - } - - protected async add() { - if (this.isFileDirty) { - this.isSaving = true; - const res = await this.data.otherAttachmentsComponent.attachFile(this.pendingFile!, null); - this.showVirusError = !res; - if (res) { - const documents = await this.noticeOfIntentDocumentService.getByFileId(this.data.fileId); - if (documents) { - const sortedDocuments = documents.sort((a, b) => {return b.uploadedAt - a.uploadedAt}); - const updateDtos: NoticeOfIntentDocumentUpdateDto[] = sortedDocuments.map((file) => ({ - uuid: file.uuid, - description: file.description, - type: file.type?.code ?? null, - })); - updateDtos[0] = { - ...updateDtos[0], - description: this.currentDescription, - type: this.currentType?.code ?? null, - } - await this.noticeOfIntentDocumentService.update(this.data.fileId, updateDtos); - this.toastService.showSuccessToast('Attachment added successfully'); - this.dialogRef.close(); - } else { - this.toastService.showErrorToast("Could not read attached documents"); - } - } + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; } + } + this.isDirty = false; + this.isFileDirty = true; + this.isSaving = false; } + } - async onEdit() { - if (this.validateForm()) { - await this.edit(); - } + async onEdit() { + if (this.validateForm()) { + await this.edit(); } - - protected async edit() { - if (this.isFileDirty) { - this.data.otherAttachmentsComponent.onDeleteFile(this.attachmentForDelete[0]); - await this.add(); - } else { - if (this.isDirty) { - this.isSaving = true; - const documents = await this.noticeOfIntentDocumentService.getByFileId(this.data.fileId); - if (documents) { - const updateDtos: NoticeOfIntentDocumentUpdateDto[] = documents.map((file) => ({ - uuid: file.uuid, - description: file.description, - type: file.type?.code ?? null, - })); - for (let i = 0; i < updateDtos.length; i++) { - if (updateDtos[i].uuid === this.data.existingDocument?.uuid) { - updateDtos[i] = { - ...updateDtos[i], - description: this.currentDescription, - type: this.currentType?.code ?? null, - } - } - } - await this.noticeOfIntentDocumentService.update(this.data.fileId, updateDtos); - this.toastService.showSuccessToast('Attachment updated successully'); - } else { - this.toastService.showErrorToast("Could not read attached documents"); - } + } + + protected async edit() { + if (this.isFileDirty) { + this.data.otherAttachmentsComponent.onDeleteFile(this.attachmentForDelete[0]); + await this.add(); + } else { + if (this.isDirty) { + this.isSaving = true; + const documents = await this.noticeOfIntentDocumentService.getByFileId(this.data.fileId); + if (documents) { + const updateDtos: NoticeOfIntentDocumentUpdateDto[] = documents.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + for (let i = 0; i < updateDtos.length; i++) { + if (updateDtos[i].uuid === this.data.existingDocument?.uuid) { + updateDtos[i] = { + ...updateDtos[i], + description: this.currentDescription, + type: this.currentType?.code ?? null, + }; } - this.dialogRef.close(); + } + await this.noticeOfIntentDocumentService.update(this.data.fileId, updateDtos); + this.toastService.showSuccessToast('Attachment updated successully'); + } else { + this.toastService.showErrorToast('Could not read attached documents'); } + } + this.dialogRef.close(); } + } - private async loadDocumentCodes() { - const codes = await this.codeService.loadCodes(); - this.documentCodes = codes.documentTypes; - this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); - } + private async loadDocumentCodes() { + const codes = await this.codeService.loadCodes(); + this.documentCodes = codes.documentTypes; + this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); + } } diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments.component.ts index 09b6c7ac79..1ff2735298 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/other-attachments/other-attachments.component.ts @@ -15,6 +15,7 @@ import { EditNoiSteps } from '../edit-submission.component'; import { FilesStepComponent } from '../files-step.partial'; import { OtherAttachmentsUploadDialogComponent } from './other-attachments-upload-dialog/other-attachments-upload-dialog.component'; import { MOBILE_BREAKPOINT } from '../../../../shared/utils/breakpoints'; +import { HttpErrorResponse } from '@angular/common/http'; const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; @@ -33,14 +34,15 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI private isDirty = false; private documentCodes: DocumentTypeDto[] = []; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; isMobile = window.innerWidth <= MOBILE_BREAKPOINT; constructor( private codeService: CodeService, noticeOfIntentDocumentService: NoticeOfIntentDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(noticeOfIntentDocumentService, dialog, toastService); } @@ -69,8 +71,16 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI } async attachDocument(file: FileHandle) { - const res = await this.attachFile(file, null); - this.showVirusError = !res; + try { + await this.attachFile(file, null); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { @@ -93,15 +103,17 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI onAddEditAttachment(attachment: NoticeOfIntentDocumentDto | undefined) { this.dialog .open(OtherAttachmentsUploadDialogComponent, { - width: this.isMobile? '90%' : '50%', + width: this.isMobile ? '90%' : '50%', data: { fileId: this.fileId, otherAttachmentsComponent: this, existingDocument: attachment, - } - }).afterClosed().subscribe(async res => { - await this.refreshFiles(); - }); + }, + }) + .afterClosed() + .subscribe(async (res) => { + await this.refreshFiles(); + }); } @HostListener('window:resize', ['$event']) diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.html index 31b643215e..216735720f 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.html @@ -238,7 +238,8 @@ (beforeFileUploadOpened)="saveParcelProgress()" [showErrors]="showErrors" [isRequired]="isCertificateOfTitleRequired" - [showVirusError]="showVirusError" + [showHasVirusError]="showHasVirusError" + [showVirusScanFailedError]="showVirusScanFailedError" [disabled]="parcelForm.controls.pid.disabled" >
    @@ -347,11 +348,12 @@
    OR
    + (editClicked)="onEditCrownOwner(selectedOwner)" + >
    @@ -370,18 +372,20 @@
    OR
    - - + [ngClass]="{ error: ownerInput.errors && ownerInput.errors['required'] }" + > +
    - {{owner.firstName + ' ' + owner.lastName}} + {{ owner.firstName + ' ' + owner.lastName }}
    @@ -401,7 +405,7 @@
    OR
    'error-outline': enableUserSignOff && isConfirmedByApplicant.invalid && - (isConfirmedByApplicant.dirty || isConfirmedByApplicant.touched) + (isConfirmedByApplicant.dirty || isConfirmedByApplicant.touched), }" > diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.ts index aab4dbddbb..37416f4df0 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/parcels/parcel-entry/parcel-entry.component.ts @@ -22,6 +22,7 @@ import { scrollToElement } from '../../../../../shared/utils/scroll-helper'; import { RemoveFileConfirmationDialogComponent } from '../../../../applications/alcs-edit-submission/remove-file-confirmation-dialog/remove-file-confirmation-dialog.component'; import { ParcelEntryConfirmationDialogComponent } from './parcel-entry-confirmation-dialog/parcel-entry-confirmation-dialog.component'; import { MOBILE_BREAKPOINT } from '../../../../../shared/utils/breakpoints'; +import { HttpErrorResponse } from '@angular/common/http'; export interface ParcelEntryFormData { uuid: string; @@ -66,7 +67,8 @@ export class ParcelEntryComponent implements OnInit { searchBy = new FormControl(null); isCrownLand: boolean | null = null; isCertificateOfTitleRequired = true; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; isMobile = false; parcelType = new FormControl(null, [Validators.required]); @@ -311,12 +313,18 @@ export class ParcelEntryComponent implements OnInit { parcelUuid, mappedFiles, ); - } catch (e) { - this.showVirusError = true; - this.toastService.showErrorToast('Document upload failed'); + this.toastService.showSuccessToast('Document uploaded'); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; return; + } catch (err) { + this.toastService.showErrorToast('Document upload failed'); + + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } } - this.showVirusError = false; } } diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.html index c30b61a893..a84922a9e7 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.html @@ -84,7 +84,7 @@

    Primary Contact

    Phone Number:
    -
    {{ phoneNumber.value ?? '' | mask : '(000) 000-0000' }}
    +
    {{ phoneNumber.value ?? '' | mask: '(000) 000-0000' }}
    Email:
    @@ -218,7 +218,8 @@

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="needsAuthorizationLetter" - [showVirusError]="showVirusError" + [showHasVirusError]="showHasVirusError" + [showVirusScanFailedError]="showVirusScanFailedError" >

    diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.ts index 31eaaa9fbd..989b908965 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/primary-contact/primary-contact.component.ts @@ -23,6 +23,7 @@ import { CrownOwnerDialogComponent } from '../../../../shared/owner-dialogs/crow import { scrollToElement } from '../../../../shared/utils/scroll-helper'; import { MatButtonToggleChange } from '@angular/material/button-toggle'; import { strictEmailValidator } from '../../../../shared/validators/email-validator'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-primary-contact', @@ -37,7 +38,8 @@ export class PrimaryContactComponent extends FilesStepComponent implements OnIni files: (NoticeOfIntentDocumentDto & { errorMessage?: string })[] = []; needsAuthorizationLetter = false; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; selectedThirdPartyAgent: boolean | null = false; selectedLocalGovernment = false; _selectedOwnerUuid: string | undefined = undefined; @@ -104,8 +106,16 @@ export class PrimaryContactComponent extends FilesStepComponent implements OnIni } async attachAuthorizationLetter(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.AUTHORIZATION_LETTER); - this.showVirusError = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.AUTHORIZATION_LETTER); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } set selectedOwnerUuid(value: string | undefined) { diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.html index 6b8472096d..608c39f794 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.html @@ -40,7 +40,7 @@

    Proposal

    Yes @@ -48,7 +48,7 @@

    Proposal

    No @@ -277,7 +277,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -293,7 +294,7 @@

    Proposal

    Yes @@ -301,7 +302,7 @@

    Proposal

    No @@ -325,7 +326,7 @@

    Proposal

    class="toggle-button" [ngClass]="{ 'error-outline': - isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched) + isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched), }" value="true" >Yes @@ -334,7 +335,7 @@

    Proposal

    class="toggle-button" [ngClass]="{ 'error-outline': - isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched) + isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched), }" value="false" >No @@ -372,7 +373,8 @@

    Proposal

    [isRequired]="true" [allowMultiple]="true" [disabled]="!allowMiningUploads" - [showVirusError]="showCrossSectionVirus" + [showHasVirusError]="showCrossSectionHasVirusError" + [showVirusScanFailedError]="showCrossSectionVirusScanFailedError" >
    @@ -400,7 +402,8 @@

    Proposal

    [isRequired]="true" [allowMultiple]="true" [disabled]="!allowMiningUploads" - [showVirusError]="showReclamationPlanVirus" + [showHasVirusError]="showReclamationPlanHasVirusError" + [showVirusScanFailedError]="showReclamationPlanVirusScanFailedError" >
    @@ -419,7 +422,7 @@

    Proposal

    Yes @@ -427,7 +430,7 @@

    Proposal

    No @@ -464,7 +467,8 @@

    Proposal

    [isRequired]="true" [disabled]="!requiresNoticeOfWork" [allowMultiple]="true" - [showVirusError]="showNoticeOfWorkVirus" + [showHasVirusError]="showNoticeOfWorkHasVirusError" + [showVirusScanFailedError]="showNoticeOfWorkVirusScanFailedError" > diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.ts index dc2ab961cd..dc811f568d 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pfrs/pfrs-proposal.component.ts @@ -16,6 +16,7 @@ import { parseStringToBoolean } from '../../../../../shared/utils/string-helper' import { SoilTableData } from '../../../../../shared/soil-table/soil-table.component'; import { EditNoiSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-pfrs-proposal', @@ -34,10 +35,14 @@ export class PfrsProposalComponent extends FilesStepComponent implements OnInit, allowMiningUploads = false; requiresNoticeOfWork = false; - showProposalMapVirus = false; - showCrossSectionVirus = false; - showReclamationPlanVirus = false; - showNoticeOfWorkVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showCrossSectionHasVirusError = false; + showCrossSectionVirusScanFailedError = false; + showReclamationPlanHasVirusError = false; + showReclamationPlanVirusScanFailedError = false; + showNoticeOfWorkHasVirusError = false; + showNoticeOfWorkVirusScanFailedError = false; proposalMap: NoticeOfIntentDocumentDto[] = []; crossSections: NoticeOfIntentDocumentDto[] = []; @@ -81,7 +86,7 @@ export class PfrsProposalComponent extends FilesStepComponent implements OnInit, private noticeOfIntentSubmissionService: NoticeOfIntentSubmissionService, noticeOfIntentDocumentService: NoticeOfIntentDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(noticeOfIntentDocumentService, dialog, toastService); } @@ -166,23 +171,55 @@ export class PfrsProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachCrossSection(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); - this.showCrossSectionVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); + this.showCrossSectionHasVirusError = false; + this.showCrossSectionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showCrossSectionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showCrossSectionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReclamationPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); - this.showReclamationPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); + this.showReclamationPlanHasVirusError = false; + this.showReclamationPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReclamationPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReclamationPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachNoticeOfWork(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.NOTICE_OF_WORK); - this.showNoticeOfWorkVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.NOTICE_OF_WORK); + this.showNoticeOfWorkHasVirusError = false; + this.showNoticeOfWorkVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showNoticeOfWorkHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showNoticeOfWorkVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.html index 5b4768f031..bd4462b0ce 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.html @@ -40,7 +40,7 @@

    Proposal

    Yes @@ -48,7 +48,7 @@

    Proposal

    No @@ -195,7 +195,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -211,7 +212,7 @@

    Proposal

    Yes @@ -219,7 +220,7 @@

    Proposal

    No @@ -250,7 +251,8 @@

    Proposal

    [isRequired]="true" [allowMultiple]="true" [disabled]="!allowMiningUploads" - [showVirusError]="showCrossSectionVirus" + [showHasVirusError]="showCrossSectionHasVirusError" + [showVirusScanFailedError]="showCrossSectionVirusScanFailedError" >
    @@ -278,7 +280,8 @@

    Proposal

    [isRequired]="true" [allowMultiple]="true" [disabled]="!allowMiningUploads" - [showVirusError]="showReclamationPlanVirus" + [showHasVirusError]="showReclamationPlanHasVirusError" + [showVirusScanFailedError]="showReclamationPlanVirusScanFailedError" >
    diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.ts index ab5f87f5a5..731d365a74 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/pofo/pofo-proposal.component.ts @@ -15,6 +15,7 @@ import { parseStringToBoolean } from '../../../../../shared/utils/string-helper' import { SoilTableData } from '../../../../../shared/soil-table/soil-table.component'; import { EditNoiSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-pofo-proposal', @@ -27,9 +28,12 @@ export class PofoProposalComponent extends FilesStepComponent implements OnInit, DOCUMENT = DOCUMENT_TYPE; allowMiningUploads = false; - showProposalMapVirus = false; - showCrossSectionVirus = false; - showReclamationPlanVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showCrossSectionHasVirusError = false; + showCrossSectionVirusScanFailedError = false; + showReclamationPlanHasVirusError = false; + showReclamationPlanVirusScanFailedError = false; proposalMap: NoticeOfIntentDocumentDto[] = []; crossSections: NoticeOfIntentDocumentDto[] = []; @@ -117,18 +121,42 @@ export class PofoProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachCrossSection(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); - this.showCrossSectionVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); + this.showCrossSectionHasVirusError = false; + this.showCrossSectionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showCrossSectionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showCrossSectionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReclamationPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); - this.showReclamationPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); + this.showReclamationPlanHasVirusError = false; + this.showReclamationPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReclamationPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReclamationPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.html b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.html index 7f9b20bc25..777fdfd5fe 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.html +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.html @@ -40,7 +40,7 @@

    Proposal

    Yes @@ -48,7 +48,7 @@

    Proposal

    No @@ -180,7 +180,8 @@

    Proposal

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showProposalMapVirus" + [showHasVirusError]="showProposalMapHasVirusError" + [showVirusScanFailedError]="showProposalMapVirusScanFailedError" > @@ -199,7 +200,7 @@

    Proposal

    class="toggle-button" [ngClass]="{ 'error-outline': - isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched) + isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched), }" value="true" >Yes @@ -208,7 +209,7 @@

    Proposal

    class="toggle-button" [ngClass]="{ 'error-outline': - isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched) + isExtractionOrMining.invalid && (isExtractionOrMining.dirty || isExtractionOrMining.touched), }" value="false" >No @@ -239,7 +240,8 @@

    Proposal

    [isRequired]="true" [allowMultiple]="true" [disabled]="!allowMiningUploads" - [showVirusError]="showCrossSectionVirus" + [showHasVirusError]="showCrossSectionHasVirusError" + [showVirusScanFailedError]="showCrossSectionVirusScanFailedError" >
    @@ -267,7 +269,8 @@

    Proposal

    [isRequired]="true" [allowMultiple]="true" [disabled]="!allowMiningUploads" - [showVirusError]="showReclamationPlanVirus" + [showHasVirusError]="showReclamationPlanHasVirusError" + [showVirusScanFailedError]="showReclamationPlanVirusScanFailedError" >
    @@ -286,7 +289,7 @@

    Proposal

    Yes @@ -294,7 +297,7 @@

    Proposal

    No @@ -324,7 +327,8 @@

    Proposal

    [isRequired]="true" [disabled]="!requiresNoticeOfWork" [allowMultiple]="true" - [showVirusError]="showNoticeOfWorkVirus" + [showHasVirusError]="showNoticeOfWorkHasVirusError" + [showVirusScanFailedError]="showNoticeOfWorkVirusScanFailedError" > diff --git a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.ts b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.ts index 356fe65147..023f382523 100644 --- a/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/edit-submission/proposal/roso/roso-proposal.component.ts @@ -15,6 +15,7 @@ import { parseStringToBoolean } from '../../../../../shared/utils/string-helper' import { SoilTableData } from '../../../../../shared/soil-table/soil-table.component'; import { EditNoiSteps } from '../../edit-submission.component'; import { FilesStepComponent } from '../../files-step.partial'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-roso-proposal', @@ -28,10 +29,14 @@ export class RosoProposalComponent extends FilesStepComponent implements OnInit, allowMiningUploads = false; requiresNoticeOfWork = false; - showProposalMapVirus = false; - showCrossSectionVirus = false; - showReclamationPlanVirus = false; - showNoticeOfWorkVirus = false; + showProposalMapHasVirusError = false; + showProposalMapVirusScanFailedError = false; + showCrossSectionHasVirusError = false; + showCrossSectionVirusScanFailedError = false; + showReclamationPlanHasVirusError = false; + showReclamationPlanVirusScanFailedError = false; + showNoticeOfWorkHasVirusError = false; + showNoticeOfWorkVirusScanFailedError = false; proposalMap: NoticeOfIntentDocumentDto[] = []; crossSections: NoticeOfIntentDocumentDto[] = []; @@ -66,7 +71,7 @@ export class RosoProposalComponent extends FilesStepComponent implements OnInit, private noticeOfIntentSubmissionService: NoticeOfIntentSubmissionService, noticeOfIntentDocumentService: NoticeOfIntentDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(noticeOfIntentDocumentService, dialog, toastService); } @@ -132,23 +137,55 @@ export class RosoProposalComponent extends FilesStepComponent implements OnInit, } async attachProposalMap(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); - this.showProposalMapVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.PROPOSAL_MAP); + this.showProposalMapHasVirusError = false; + this.showProposalMapVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showProposalMapHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showProposalMapVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachCrossSection(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); - this.showCrossSectionVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.CROSS_SECTIONS); + this.showCrossSectionHasVirusError = false; + this.showCrossSectionVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showCrossSectionHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showCrossSectionVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachReclamationPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); - this.showReclamationPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.RECLAMATION_PLAN); + this.showReclamationPlanHasVirusError = false; + this.showReclamationPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showReclamationPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showReclamationPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachNoticeOfWork(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.NOTICE_OF_WORK); - this.showNoticeOfWorkVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.NOTICE_OF_WORK); + this.showNoticeOfWorkHasVirusError = false; + this.showNoticeOfWorkVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showNoticeOfWorkHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showNoticeOfWorkVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { diff --git a/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts b/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts index 9cc0e19699..a79c762e00 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/files-step.partial.ts @@ -38,24 +38,22 @@ export abstract class FilesStepComponent extends StepComponent { await this.save(); const mappedFiles = file.file; - let res; try { - res = await this.notificationDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + const res = await this.notificationDocumentService.attachExternalFile(this.fileId, mappedFiles, documentType); + + if (res) { + this.toastService.showSuccessToast('Document uploaded'); + const documents = await this.notificationDocumentService.getByFileId(this.fileId); + if (documents) { + this.$notificationDocuments.next(documents); + } + } } catch (err) { this.toastService.showErrorToast('Document upload failed'); - if (err instanceof HttpErrorResponse && err.status === 403) { - return false; - } - } - if (res) { - const documents = await this.notificationDocumentService.getByFileId(this.fileId); - if (documents) { - this.$notificationDocuments.next(documents); - } + throw err; } } - return true; } //Using ApplicationDocumentDto is "correct" here, quack quack diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html index 015de6d742..c83eb82cf4 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.html @@ -1,82 +1,82 @@
    -

    {{title}} optional attachment

    +

    {{ title }} optional attachment

    -
    - - -
    - - warning A virus was detected in the file. Choose another file and try again. - -
    -
    -
    - - - - {{ type.label }} - - - -
    - warning -
    - This field is required -
    -
    -
    -
    - - - -
    - warning -
    - This field is required -
    -
    -
    +
    + + +
    + + warning A virus was detected in the file. Choose another file and try again. + + +
    +
    + + + + {{ type.label }} + + + +
    + warning +
    This field is required
    +
    +
    +
    + + + +
    + warning +
    This field is required
    - +
    +
    +
    -
    - - - -
    +
    + + + +
    diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts index 65666c4581..4031f5008e 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments-upload-dialog/other-attachments-upload-dialog.component.ts @@ -1,215 +1,232 @@ import { Component, Inject, type OnInit } from '@angular/core'; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; -import { NotificationDocumentDto, NotificationDocumentUpdateDto } from "../../../../../services/notification-document/notification-document.dto"; -import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from "../../../../../shared/dto/document.dto"; -import { FileHandle } from "../../../../../shared/file-drag-drop/drag-drop.directive"; -import { OtherAttachmentsComponent } from "../other-attachments.component"; -import { NotificationDocumentService } from "../../../../../services/notification-document/notification-document.service"; -import { CodeService } from "../../../../../services/code/code.service"; -import { ToastService } from "../../../../../services/toast/toast.service"; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + NotificationDocumentDto, + NotificationDocumentUpdateDto, +} from '../../../../../services/notification-document/notification-document.dto'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE, DocumentTypeDto } from '../../../../../shared/dto/document.dto'; +import { FileHandle } from '../../../../../shared/file-drag-drop/drag-drop.directive'; +import { OtherAttachmentsComponent } from '../other-attachments.component'; +import { NotificationDocumentService } from '../../../../../services/notification-document/notification-document.service'; +import { CodeService } from '../../../../../services/code/code.service'; +import { ToastService } from '../../../../../services/toast/toast.service'; +import { HttpErrorResponse } from '@angular/common/http'; const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; @Component({ - selector: 'app-other-attachments-upload-dialog', - templateUrl: './other-attachments-upload-dialog.component.html', - styleUrl: './other-attachments-upload-dialog.component.scss', + selector: 'app-other-attachments-upload-dialog', + templateUrl: './other-attachments-upload-dialog.component.html', + styleUrl: './other-attachments-upload-dialog.component.scss', }) export class OtherAttachmentsUploadDialogComponent implements OnInit { - isDirty = false; - isFileDirty = false; - isSaving = false; - showVirusError = false; - showFileRequiredError = false; - title: string = ''; - isEditing = false; - - attachment: NotificationDocumentDto[] = []; - attachmentForDelete: NotificationDocumentDto[] = []; - pendingFile: FileHandle | undefined; - selectableTypes: DocumentTypeDto[] = []; - private documentCodes: DocumentTypeDto[] = []; - - fileDescription = new FormControl(null, [Validators.required]); - fileType = new FormControl(null, [Validators.required]); - currentDescription: string | null = null; - currentType: DocumentTypeDto | null = null; - - form = new FormGroup({ - fileDescription: this.fileDescription, - fileType: this.fileType, - }); - - constructor( - private dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) - public data: { - otherAttachmentsComponent: OtherAttachmentsComponent, - existingDocument?: NotificationDocumentDto, - fileId: string, - }, - private notificationDocumentService: NotificationDocumentService, - private codeService: CodeService, - private toastService: ToastService - ) {} - - ngOnInit(): void { - this.loadDocumentCodes(); - if (this.data.existingDocument) { - this.title = 'Edit'; - this.isEditing = true; - this.fileType.setValue(this.data.existingDocument.type!.code); - this.fileDescription.setValue(this.data.existingDocument.description!); - this.currentDescription = this.data.existingDocument.description!; - this.currentType = this.data.existingDocument.type; - this.attachment = [this.data.existingDocument]; - } else { - this.title = 'Add'; - } + isDirty = false; + isFileDirty = false; + isSaving = false; + showHasVirusError = false; + showVirusScanFailedError = false; + showFileRequiredError = false; + title: string = ''; + isEditing = false; + + attachment: NotificationDocumentDto[] = []; + attachmentForDelete: NotificationDocumentDto[] = []; + pendingFile: FileHandle | undefined; + selectableTypes: DocumentTypeDto[] = []; + private documentCodes: DocumentTypeDto[] = []; + + fileDescription = new FormControl(null, [Validators.required]); + fileType = new FormControl(null, [Validators.required]); + currentDescription: string | null = null; + currentType: DocumentTypeDto | null = null; + + form = new FormGroup({ + fileDescription: this.fileDescription, + fileType: this.fileType, + }); + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public data: { + otherAttachmentsComponent: OtherAttachmentsComponent; + existingDocument?: NotificationDocumentDto; + fileId: string; + }, + private notificationDocumentService: NotificationDocumentService, + private codeService: CodeService, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + this.loadDocumentCodes(); + if (this.data.existingDocument) { + this.title = 'Edit'; + this.isEditing = true; + this.fileType.setValue(this.data.existingDocument.type!.code); + this.fileDescription.setValue(this.data.existingDocument.description!); + this.currentDescription = this.data.existingDocument.description!; + this.currentType = this.data.existingDocument.type; + this.attachment = [this.data.existingDocument]; + } else { + this.title = 'Add'; } - - async attachDocument(file: FileHandle) { - this.pendingFile = file; - this.attachment = [{uuid: '', - fileName: file.file.name, - type: null, - fileSize: file.file.size, - uploadedBy: '', - uploadedAt: file.file.lastModified, - source: DOCUMENT_SOURCE.APPLICANT, - surveyPlanNumber: null, - controlNumber: null, - }]; - this.isFileDirty = true; - this.showFileRequiredError = true; + } + + async attachDocument(file: FileHandle) { + this.pendingFile = file; + this.attachment = [ + { + uuid: '', + fileName: file.file.name, + type: null, + fileSize: file.file.size, + uploadedBy: '', + uploadedAt: file.file.lastModified, + source: DOCUMENT_SOURCE.APPLICANT, + surveyPlanNumber: null, + controlNumber: null, + }, + ]; + this.isFileDirty = true; + this.showFileRequiredError = true; + } + + openFile() { + if (this.isEditing && this.pendingFile === undefined) { + this.data.otherAttachmentsComponent.openFile(this.attachment[0]); + } else { + if (this.pendingFile) { + const fileURL = URL.createObjectURL(this.pendingFile.file); + window.open(fileURL, '_blank'); + } } + } - openFile() { - if (this.isEditing && this.pendingFile === undefined) { - this.data.otherAttachmentsComponent.openFile(this.attachment[0]); - } else { - if (this.pendingFile) { - const fileURL = URL.createObjectURL(this.pendingFile.file); - window.open(fileURL, '_blank'); - } - } + deleteFile() { + this.pendingFile = undefined; + if (this.isEditing) { + this.attachmentForDelete = this.attachment; } - - deleteFile() { - this.pendingFile = undefined; - if (this.isEditing) { - this.attachmentForDelete = this.attachment; - } - this.attachment = []; + this.attachment = []; + } + + onChangeDescription() { + this.isDirty = true; + this.currentDescription = this.fileDescription.value; + } + + onChangeType(selectedValue: DOCUMENT_TYPE) { + this.isDirty = true; + const newType = this.documentCodes.find((code) => code.code === selectedValue); + this.currentType = newType !== undefined ? newType : null; + } + + validateForm() { + if (this.form.valid && this.attachment.length !== 0) { + return true; } - onChangeDescription() { - this.isDirty = true; - this.currentDescription = this.fileDescription.value; + if (this.form.invalid) { + this.form.markAllAsTouched(); } - onChangeType(selectedValue: DOCUMENT_TYPE) { - this.isDirty = true; - const newType = this.documentCodes.find((code) => code.code === selectedValue); - this.currentType = newType !== undefined ? newType : null; + if (this.attachment.length == 0) { + this.showFileRequiredError = true; } + return false; + } - validateForm() { - if (this.form.valid && this.attachment.length !== 0) { - return true; - } - - if (this.form.invalid) { - this.form.markAllAsTouched(); - } - - if (this.attachment.length == 0) { - this.showFileRequiredError = true; - } - return false; + async onAdd() { + if (this.validateForm()) { + await this.add(); } - - async onAdd() { - if (this.validateForm()) { - await this.add(); + } + + protected async add() { + if (this.isFileDirty) { + this.isSaving = true; + try { + await this.data.otherAttachmentsComponent.attachFile(this.pendingFile!, null); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + + const documents = await this.notificationDocumentService.getByFileId(this.data.fileId); + if (documents) { + const sortedDocuments = documents.sort((a, b) => { + return b.uploadedAt - a.uploadedAt; + }); + const updateDtos: NotificationDocumentUpdateDto[] = sortedDocuments.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + updateDtos[0] = { + ...updateDtos[0], + description: this.currentDescription, + type: this.currentType?.code ?? null, + }; + await this.notificationDocumentService.update(this.data.fileId, updateDtos); + this.toastService.showSuccessToast('Attachment added successfully'); + this.dialogRef.close(); + } else { + this.toastService.showErrorToast('Could not read attached documents'); } - } - - protected async add() { - if (this.isFileDirty) { - this.isSaving = true; - const res = await this.data.otherAttachmentsComponent.attachFile(this.pendingFile!, null); - this.showVirusError = !res; - if (res) { - const documents = await this.notificationDocumentService.getByFileId(this.data.fileId); - if (documents) { - const sortedDocuments = documents.sort((a, b) => {return b.uploadedAt - a.uploadedAt}); - const updateDtos: NotificationDocumentUpdateDto[] = sortedDocuments.map((file) => ({ - uuid: file.uuid, - description: file.description, - type: file.type?.code ?? null, - })); - updateDtos[0] = { - ...updateDtos[0], - description: this.currentDescription, - type: this.currentType?.code ?? null, - } - await this.notificationDocumentService.update(this.data.fileId, updateDtos); - this.toastService.showSuccessToast('Attachment added successfully'); - this.dialogRef.close(); - } else { - this.toastService.showErrorToast("Could not read attached documents"); - } - } - this.isDirty = false; - this.isFileDirty = true; - this.isSaving = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; } + } + this.isDirty = false; + this.isFileDirty = true; + this.isSaving = false; } + } - async onEdit() { - if (this.validateForm()) { - this.edit(); - } + async onEdit() { + if (this.validateForm()) { + this.edit(); } - - protected async edit() { - if (this.isFileDirty) { - this.data.otherAttachmentsComponent.onDeleteFile(this.attachmentForDelete[0]); - await this.onAdd(); - } else { - if (this.isDirty) { - this.isSaving = true; - const documents = await this.notificationDocumentService.getByFileId(this.data.fileId); - if (documents) { - const updateDtos: NotificationDocumentUpdateDto[] = documents.map((file) => ({ - uuid: file.uuid, - description: file.description, - type: file.type?.code ?? null, - })); - for (let i = 0; i < updateDtos.length; i++) { - if (updateDtos[i].uuid === this.data.existingDocument?.uuid) { - updateDtos[i] = { - ...updateDtos[i], - description: this.currentDescription, - type: this.currentType?.code ?? null, - } - } - } - await this.notificationDocumentService.update(this.data.fileId, updateDtos); - this.toastService.showSuccessToast('Attachment updated successully'); - } else { - this.toastService.showErrorToast("Could not read attached documents"); - } + } + + protected async edit() { + if (this.isFileDirty) { + this.data.otherAttachmentsComponent.onDeleteFile(this.attachmentForDelete[0]); + await this.onAdd(); + } else { + if (this.isDirty) { + this.isSaving = true; + const documents = await this.notificationDocumentService.getByFileId(this.data.fileId); + if (documents) { + const updateDtos: NotificationDocumentUpdateDto[] = documents.map((file) => ({ + uuid: file.uuid, + description: file.description, + type: file.type?.code ?? null, + })); + for (let i = 0; i < updateDtos.length; i++) { + if (updateDtos[i].uuid === this.data.existingDocument?.uuid) { + updateDtos[i] = { + ...updateDtos[i], + description: this.currentDescription, + type: this.currentType?.code ?? null, + }; } - this.dialogRef.close(); + } + await this.notificationDocumentService.update(this.data.fileId, updateDtos); + this.toastService.showSuccessToast('Attachment updated successully'); + } else { + this.toastService.showErrorToast('Could not read attached documents'); } + } + this.dialogRef.close(); } + } - private async loadDocumentCodes() { - const codes = await this.codeService.loadCodes(); - this.documentCodes = codes.documentTypes; - this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); - } + private async loadDocumentCodes() { + const codes = await this.codeService.loadCodes(); + this.documentCodes = codes.documentTypes; + this.selectableTypes = this.documentCodes.filter((code) => USER_CONTROLLED_TYPES.includes(code.code)); + } } diff --git a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts index 23a7abf237..bef9e6d4cc 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/other-attachments/other-attachments.component.ts @@ -17,6 +17,7 @@ import { EditNotificationSteps } from '../edit-submission.component'; import { FilesStepComponent } from '../files-step.partial'; import { OtherAttachmentsUploadDialogComponent } from './other-attachments-upload-dialog/other-attachments-upload-dialog.component'; import { MOBILE_BREAKPOINT } from '../../../../shared/utils/breakpoints'; +import { HttpErrorResponse } from '@angular/common/http'; const USER_CONTROLLED_TYPES = [DOCUMENT_TYPE.PHOTOGRAPH, DOCUMENT_TYPE.PROFESSIONAL_REPORT, DOCUMENT_TYPE.OTHER]; @@ -33,7 +34,8 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI otherFiles: NotificationDocumentDto[] = []; private isDirty = false; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; isMobile = window.innerWidth <= MOBILE_BREAKPOINT; private documentCodes: DocumentTypeDto[] = []; @@ -42,7 +44,7 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI private codeService: CodeService, toastService: ToastService, notificationDocumentService: NotificationDocumentService, - dialog: MatDialog + dialog: MatDialog, ) { super(notificationDocumentService, dialog, toastService); } @@ -71,8 +73,16 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI } async attachDocument(file: FileHandle) { - const res = await this.attachFile(file, null); - this.showVirusError = !res; + try { + await this.attachFile(file, null); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } protected async save() { @@ -95,15 +105,17 @@ export class OtherAttachmentsComponent extends FilesStepComponent implements OnI onAddEditAttachment(attachment: NotificationDocumentDto | undefined) { this.dialog .open(OtherAttachmentsUploadDialogComponent, { - width: this.isMobile? '90%' : '50%', + width: this.isMobile ? '90%' : '50%', data: { fileId: this.fileId, otherAttachmentsComponent: this, existingDocument: attachment, - } - }).afterClosed().subscribe(async res => { - await this.refreshFiles(); - }); + }, + }) + .afterClosed() + .subscribe(async (res) => { + await this.refreshFiles(); + }); } @HostListener('window:resize', ['$event']) diff --git a/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.html b/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.html index 3f8d717121..9c9d155aa0 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.html +++ b/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.html @@ -77,7 +77,8 @@

    Purpose of SRW

    (openFile)="openFile($event)" [showErrors]="showErrors" [isRequired]="true" - [showVirusError]="showSRWTermsVirus" + [showHasVirusError]="showSRWTermsHasVirusError" + [showVirusScanFailedError]="showSRWTermsVirusScanFailedError" >
    @@ -93,7 +94,7 @@

    Purpose of SRW

    Yes @@ -101,7 +102,7 @@

    Purpose of SRW

    No @@ -122,7 +123,8 @@

    Purpose of SRW

    [isRequired]="surveyPlans.length === 0" [disabled]="!allowSurveyPlanUploads" [allowMultiple]="true" - [showVirusError]="showSurveyPlanVirus" + [showHasVirusError]="showSurveyPlanHasVirusError" + [showVirusScanFailedError]="showSurveyPlanVirusScanFailedError" > diff --git a/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.ts b/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.ts index 9e9dd6c25b..74f3cc5639 100644 --- a/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.ts +++ b/portal-frontend/src/app/features/notifications/edit-submission/proposal/proposal.component.ts @@ -18,6 +18,7 @@ import { parseStringToBoolean } from '../../../../shared/utils/string-helper'; import { EditNotificationSteps } from '../edit-submission.component'; import { FilesStepComponent } from '../files-step.partial'; import { ChangeSurveyPlanConfirmationDialogComponent } from './change-survey-plan-confirmation-dialog/change-survey-plan-confirmation-dialog.component'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-proposal', @@ -48,15 +49,17 @@ export class ProposalComponent extends FilesStepComponent implements OnInit, OnD private submissionUuid = ''; private isDirty = false; surveyForm = new FormGroup({} as any); - showSRWTermsVirus = false; - showSurveyPlanVirus = false; + showSRWTermsHasVirusError = false; + showSRWTermsVirusScanFailedError = false; + showSurveyPlanHasVirusError = false; + showSurveyPlanVirusScanFailedError = false; constructor( private router: Router, private notificationSubmissionService: NotificationSubmissionService, notificationDocumentService: NotificationDocumentService, dialog: MatDialog, - toastService: ToastService + toastService: ToastService, ) { super(notificationDocumentService, dialog, toastService); } @@ -98,13 +101,29 @@ export class ProposalComponent extends FilesStepComponent implements OnInit, OnD } async attachSRWTerms(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.SRW_TERMS); - this.showSRWTermsVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.SRW_TERMS); + this.showSRWTermsHasVirusError = false; + this.showSRWTermsVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showSRWTermsHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showSRWTermsVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async attachSurveyPlan(file: FileHandle) { - const res = await this.attachFile(file, DOCUMENT_TYPE.SURVEY_PLAN); - this.showSurveyPlanVirus = !res; + try { + await this.attachFile(file, DOCUMENT_TYPE.SURVEY_PLAN); + this.showSurveyPlanHasVirusError = false; + this.showSurveyPlanVirusScanFailedError = false; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showSurveyPlanHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showSurveyPlanVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } + } } async onSave() { diff --git a/portal-frontend/src/app/features/public/application/alc-review/submission-documents/submission-documents.component.ts b/portal-frontend/src/app/features/public/application/alc-review/submission-documents/submission-documents.component.ts index 61a58bd1ce..6327e93701 100644 --- a/portal-frontend/src/app/features/public/application/alc-review/submission-documents/submission-documents.component.ts +++ b/portal-frontend/src/app/features/public/application/alc-review/submission-documents/submission-documents.component.ts @@ -7,6 +7,8 @@ import { PublicDocumentDto } from '../../../../../services/public/public.dto'; import { PublicService } from '../../../../../services/public/public.service'; import { openFileInline } from '../../../../../shared/utils/file'; +type PublicDocumentWithoutTypeDto = Omit; + @Component({ selector: 'app-submission-documents[applicationSubmission]', templateUrl: './submission-documents.component.html', @@ -21,13 +23,27 @@ export class PublicSubmissionDocumentsComponent implements OnInit, OnDestroy { @Input() applicationDocuments!: PublicDocumentDto[]; @Input() applicationSubmission!: PublicApplicationSubmissionDto; - @ViewChild(MatSort) sort!: MatSort; dataSource: MatTableDataSource = new MatTableDataSource(); + @ViewChild(MatSort) sort!: MatSort; constructor(private publicService: PublicService) {} ngOnInit(): void { - this.dataSource = new MatTableDataSource(this.applicationDocuments); + this.dataSource.data = this.applicationDocuments; + this.dataSource.sortingDataAccessor = ( + { type, ...rest }: PublicDocumentDto, + sortHeaderId: string, + ): string | number => { + if (sortHeaderId === 'type') { + return type?.label ?? ''; + } + + return (rest as PublicDocumentWithoutTypeDto)[sortHeaderId as keyof PublicDocumentWithoutTypeDto] ?? ''; + }; + } + + ngAfterViewInit() { + this.dataSource.sort = this.sort; } async openFile(file: PublicDocumentDto) { diff --git a/portal-frontend/src/app/features/public/notice-of-intent/alc-review/submission-documents/submission-documents.component.ts b/portal-frontend/src/app/features/public/notice-of-intent/alc-review/submission-documents/submission-documents.component.ts index cc7d5dda7b..05ef48a967 100644 --- a/portal-frontend/src/app/features/public/notice-of-intent/alc-review/submission-documents/submission-documents.component.ts +++ b/portal-frontend/src/app/features/public/notice-of-intent/alc-review/submission-documents/submission-documents.component.ts @@ -7,6 +7,8 @@ import { PublicDocumentDto } from '../../../../../services/public/public.dto'; import { PublicService } from '../../../../../services/public/public.service'; import { openFileInline } from '../../../../../shared/utils/file'; +type PublicDocumentWithoutTypeDto = Omit; + @Component({ selector: 'app-submission-documents', templateUrl: './submission-documents.component.html', @@ -19,13 +21,27 @@ export class PublicSubmissionDocumentsComponent implements OnInit, OnDestroy { @Input() documents!: PublicDocumentDto[]; @Input() submission!: PublicNoticeOfIntentSubmissionDto; - @ViewChild(MatSort) sort!: MatSort; dataSource: MatTableDataSource = new MatTableDataSource(); + @ViewChild(MatSort) sort!: MatSort; constructor(private publicService: PublicService) {} ngOnInit(): void { - this.dataSource = new MatTableDataSource(this.documents); + this.dataSource.data = this.documents; + this.dataSource.sortingDataAccessor = ( + { type, ...rest }: PublicDocumentDto, + sortHeaderId: string, + ): string | number => { + if (sortHeaderId === 'type') { + return type?.label ?? ''; + } + + return (rest as PublicDocumentWithoutTypeDto)[sortHeaderId as keyof PublicDocumentWithoutTypeDto] ?? ''; + }; + } + + ngAfterViewInit() { + this.dataSource.sort = this.sort; } async openFile(file: PublicDocumentDto) { diff --git a/portal-frontend/src/app/services/application-document/application-document.service.ts b/portal-frontend/src/app/services/application-document/application-document.service.ts index b7b69eab01..a4ce5b2153 100644 --- a/portal-frontend/src/app/services/application-document/application-document.service.ts +++ b/portal-frontend/src/app/services/application-document/application-document.service.ts @@ -27,26 +27,13 @@ export class ApplicationDocumentService { documentType: DOCUMENT_TYPE | null, source = DOCUMENT_SOURCE.APPLICANT ) { - try { - const res = await this.documentService.uploadFile( - fileNumber, - file, - documentType, - source, - `${this.serviceUrl}/application/${fileNumber}/attachExternal` - ); - if (res) { - this.toastService.showSuccessToast('Document uploaded'); - } - return res; - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document to Application, please try again'); - } - return undefined; + return await this.documentService.uploadFile( + fileNumber, + file, + documentType, + source, + `${this.serviceUrl}/application/${fileNumber}/attachExternal`, + ); } async openFile(fileUuid: string) { diff --git a/portal-frontend/src/app/services/application-owner/application-owner.service.ts b/portal-frontend/src/app/services/application-owner/application-owner.service.ts index 9124cb404f..feafe5b433 100644 --- a/portal-frontend/src/app/services/application-owner/application-owner.service.ts +++ b/portal-frontend/src/app/services/application-owner/application-owner.service.ts @@ -117,21 +117,12 @@ export class ApplicationOwnerService { } async uploadCorporateSummary(applicationFileId: string, file: File) { - try { - return await this.documentService.uploadFile<{ uuid: string }>( - applicationFileId, - file, - DOCUMENT_TYPE.CORPORATE_SUMMARY, - DOCUMENT_SOURCE.APPLICANT, - `${this.serviceUrl}/attachCorporateSummary` - ); - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document to Owner, please try again'); - } - return undefined; + return await this.documentService.uploadFile<{ uuid: string }>( + applicationFileId, + file, + DOCUMENT_TYPE.CORPORATE_SUMMARY, + DOCUMENT_SOURCE.APPLICANT, + `${this.serviceUrl}/attachCorporateSummary`, + ); } } diff --git a/portal-frontend/src/app/services/application-parcel/application-parcel.service.spec.ts b/portal-frontend/src/app/services/application-parcel/application-parcel.service.spec.ts index e9c2afcd58..c96eef6a09 100644 --- a/portal-frontend/src/app/services/application-parcel/application-parcel.service.spec.ts +++ b/portal-frontend/src/app/services/application-parcel/application-parcel.service.spec.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; @@ -99,31 +99,12 @@ describe('ApplicationParcelService', () => { expect(mockHttpClient.put.mock.calls[0][0]).toContain('application-parcel'); }); - it('should show an error toast if updating a parcel fails', async () => { - mockHttpClient.put.mockReturnValue(throwError(() => ({}))); - - await service.update([{}] as ApplicationParcelUpdateDto[]); - - expect(mockHttpClient.put).toHaveBeenCalledTimes(1); - expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); - }); - it('should call document service for attaching certificate of title', async () => { mockDocumentService.uploadFile.mockResolvedValue({}); await service.attachCertificateOfTitle('fileId', 'parcelUuid', {} as File); expect(mockDocumentService.uploadFile).toHaveBeenCalledTimes(1); - expect(mockToastService.showSuccessToast).toHaveBeenCalledTimes(1); - }); - - it('should show an error toast if document service fails', async () => { - mockDocumentService.uploadFile.mockRejectedValue({}); - - await service.attachCertificateOfTitle('fileId', 'parcelUuid', {} as File); - - expect(mockDocumentService.uploadFile).toHaveBeenCalledTimes(1); - expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); }); it('should make a delete request and show the overlay for removing all parcels', async () => { @@ -147,7 +128,7 @@ describe('ApplicationParcelService', () => { mockHttpClient.delete.mockReturnValue( throwError(() => { new Error(''); - }) + }), ); await service.deleteMany([]); diff --git a/portal-frontend/src/app/services/application-parcel/application-parcel.service.ts b/portal-frontend/src/app/services/application-parcel/application-parcel.service.ts index 65c8e9ac45..8891275130 100644 --- a/portal-frontend/src/app/services/application-parcel/application-parcel.service.ts +++ b/portal-frontend/src/app/services/application-parcel/application-parcel.service.ts @@ -73,24 +73,13 @@ export class ApplicationParcelService { } async attachCertificateOfTitle(fileId: string, parcelUuid: string, file: File) { - try { - const document = await this.documentService.uploadFile( - fileId, - file, - DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, - DOCUMENT_SOURCE.APPLICANT, - `${environment.apiUrl}/application-parcel/${parcelUuid}/attachCertificateOfTitle` - ); - this.toastService.showSuccessToast('Document uploaded'); - return document; - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document to Parcel, please try again'); - } - return undefined; + return await this.documentService.uploadFile( + fileId, + file, + DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + DOCUMENT_SOURCE.APPLICANT, + `${environment.apiUrl}/application-parcel/${parcelUuid}/attachCertificateOfTitle`, + ); } async deleteMany(parcelUuids: string[]) { diff --git a/portal-frontend/src/app/services/application-submission-review/application-submission-review.service.ts b/portal-frontend/src/app/services/application-submission-review/application-submission-review.service.ts index dfde6fd12f..78eeeb0e24 100644 --- a/portal-frontend/src/app/services/application-submission-review/application-submission-review.service.ts +++ b/portal-frontend/src/app/services/application-submission-review/application-submission-review.service.ts @@ -78,7 +78,9 @@ export class ApplicationSubmissionReviewService { this.toastService.showSuccessToast('Application Review Submitted'); } catch (e) { console.error(e); - this.toastService.showErrorToast('Failed to submit Application Review, please try again later'); + this.toastService.showErrorToast( + 'Failed to submit Application Review, please try again. If the problem persists, contact ALC.Portal@gov.bc.ca.', + ); } finally { this.overlayService.hideSpinner(); } diff --git a/portal-frontend/src/app/services/application-submission/application-submission.service.ts b/portal-frontend/src/app/services/application-submission/application-submission.service.ts index 47c54836c5..189f2bab0d 100644 --- a/portal-frontend/src/app/services/application-submission/application-submission.service.ts +++ b/portal-frontend/src/app/services/application-submission/application-submission.service.ts @@ -113,7 +113,9 @@ export class ApplicationSubmissionService { this.toastService.showSuccessToast('Application Submitted'); } catch (e) { console.error(e); - this.toastService.showErrorToast('Failed to submit Application, please try again'); + this.toastService.showErrorToast( + 'Failed to submit Application, please try again. If the problem persists, contact ALC.Portal@gov.bc.ca.', + ); } finally { this.overlayService.hideSpinner(); } diff --git a/portal-frontend/src/app/services/notice-of-intent-document/notice-of-intent-document.service.ts b/portal-frontend/src/app/services/notice-of-intent-document/notice-of-intent-document.service.ts index 40461754d6..b2593f56d0 100644 --- a/portal-frontend/src/app/services/notice-of-intent-document/notice-of-intent-document.service.ts +++ b/portal-frontend/src/app/services/notice-of-intent-document/notice-of-intent-document.service.ts @@ -27,26 +27,13 @@ export class NoticeOfIntentDocumentService { documentType: DOCUMENT_TYPE | null, source = DOCUMENT_SOURCE.APPLICANT ) { - try { - const res = await this.documentService.uploadFile( - fileNumber, - file, - documentType, - source, - `${this.serviceUrl}/notice-of-intent/${fileNumber}/attachExternal` - ); - if (res) { - this.toastService.showSuccessToast('Document uploaded'); - } - return res; - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document, please try again'); - } - return undefined; + return await this.documentService.uploadFile( + fileNumber, + file, + documentType, + source, + `${this.serviceUrl}/notice-of-intent/${fileNumber}/attachExternal`, + ); } async openFile(fileUuid: string) { diff --git a/portal-frontend/src/app/services/notice-of-intent-owner/notice-of-intent-owner.service.ts b/portal-frontend/src/app/services/notice-of-intent-owner/notice-of-intent-owner.service.ts index eee2f7b09c..fd8d5c6ccc 100644 --- a/portal-frontend/src/app/services/notice-of-intent-owner/notice-of-intent-owner.service.ts +++ b/portal-frontend/src/app/services/notice-of-intent-owner/notice-of-intent-owner.service.ts @@ -127,21 +127,12 @@ export class NoticeOfIntentOwnerService { } async uploadCorporateSummary(noticeOfIntentFileId: string, file: File) { - try { - return await this.documentService.uploadFile<{ uuid: string }>( - noticeOfIntentFileId, - file, - DOCUMENT_TYPE.CORPORATE_SUMMARY, - DOCUMENT_SOURCE.APPLICANT, - `${this.serviceUrl}/attachCorporateSummary` - ); - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document to Owner, please try again'); - } - return undefined; + return await this.documentService.uploadFile<{ uuid: string }>( + noticeOfIntentFileId, + file, + DOCUMENT_TYPE.CORPORATE_SUMMARY, + DOCUMENT_SOURCE.APPLICANT, + `${this.serviceUrl}/attachCorporateSummary`, + ); } } diff --git a/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.spec.ts b/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.spec.ts index e5dc086569..7d223c3f05 100644 --- a/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.spec.ts +++ b/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.spec.ts @@ -95,35 +95,15 @@ describe('NoticeOfIntentParcelService', () => { await service.update([{ uuid: mockUuid }] as NoticeOfIntentParcelUpdateDto[]); expect(mockHttpClient.put).toHaveBeenCalledTimes(1); - expect(mockToastService.showSuccessToast).toHaveBeenCalledTimes(1); expect(mockHttpClient.put.mock.calls[0][0]).toContain('notice-of-intent-parcel'); }); - it('should show an error toast if updating a parcel fails', async () => { - mockHttpClient.put.mockReturnValue(throwError(() => ({}))); - - await service.update([{}] as NoticeOfIntentParcelUpdateDto[]); - - expect(mockHttpClient.put).toHaveBeenCalledTimes(1); - expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); - }); - it('should call document service for attaching certificate of title', async () => { mockDocumentService.uploadFile.mockResolvedValue({}); await service.attachCertificateOfTitle('fileId', 'parcelUuid', {} as File); expect(mockDocumentService.uploadFile).toHaveBeenCalledTimes(1); - expect(mockToastService.showSuccessToast).toHaveBeenCalledTimes(1); - }); - - it('should show an error toast if document service fails', async () => { - mockDocumentService.uploadFile.mockRejectedValue({}); - - await service.attachCertificateOfTitle('fileId', 'parcelUuid', {} as File); - - expect(mockDocumentService.uploadFile).toHaveBeenCalledTimes(1); - expect(mockToastService.showErrorToast).toHaveBeenCalledTimes(1); }); it('should make a delete request and show the overlay for removing all parcels', async () => { @@ -147,7 +127,7 @@ describe('NoticeOfIntentParcelService', () => { mockHttpClient.delete.mockReturnValue( throwError(() => { new Error(''); - }) + }), ); await service.deleteMany([]); diff --git a/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.ts b/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.ts index d7ab1c5f42..2a00ea82f1 100644 --- a/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.ts +++ b/portal-frontend/src/app/services/notice-of-intent-parcel/notice-of-intent-parcel.service.ts @@ -73,24 +73,13 @@ export class NoticeOfIntentParcelService { } async attachCertificateOfTitle(fileId: string, parcelUuid: string, file: File) { - try { - const document = await this.documentService.uploadFile( - fileId, - file, - DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, - DOCUMENT_SOURCE.APPLICANT, - `${this.serviceUrl}/${parcelUuid}/attachCertificateOfTitle` - ); - this.toastService.showSuccessToast('Document uploaded'); - return document; - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document to Parcel, please try again'); - } - return undefined; + return await this.documentService.uploadFile( + fileId, + file, + DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + DOCUMENT_SOURCE.APPLICANT, + `${this.serviceUrl}/${parcelUuid}/attachCertificateOfTitle`, + ); } async deleteMany(parcelUuids: string[]) { diff --git a/portal-frontend/src/app/services/notice-of-intent-submission/notice-of-intent-submission.service.ts b/portal-frontend/src/app/services/notice-of-intent-submission/notice-of-intent-submission.service.ts index 23a81bb717..dbce73ce0f 100644 --- a/portal-frontend/src/app/services/notice-of-intent-submission/notice-of-intent-submission.service.ts +++ b/portal-frontend/src/app/services/notice-of-intent-submission/notice-of-intent-submission.service.ts @@ -114,7 +114,9 @@ export class NoticeOfIntentSubmissionService { this.toastService.showSuccessToast('Notice of Intent Submitted'); } catch (e) { console.error(e); - this.toastService.showErrorToast('Failed to submit Notice of Intent, please try again'); + this.toastService.showErrorToast( + 'Failed to submit Notice of Intent, please try again. If the problem persists, contact ALC.Portal@gov.bc.ca.', + ); } finally { this.overlayService.hideSpinner(); } diff --git a/portal-frontend/src/app/services/notification-document/notification-document.service.ts b/portal-frontend/src/app/services/notification-document/notification-document.service.ts index 698ecbae19..5a462832a6 100644 --- a/portal-frontend/src/app/services/notification-document/notification-document.service.ts +++ b/portal-frontend/src/app/services/notification-document/notification-document.service.ts @@ -27,26 +27,13 @@ export class NotificationDocumentService { documentType: DOCUMENT_TYPE | null, source = DOCUMENT_SOURCE.APPLICANT ) { - try { - const res = await this.documentService.uploadFile( - fileNumber, - file, - documentType, - source, - `${this.serviceUrl}/notification/${fileNumber}/attachExternal` - ); - if (res) { - this.toastService.showSuccessToast('Document uploaded'); - } - return res; - } catch (e) { - if (e instanceof HttpErrorResponse && e.status === 403) { - throw e; - } - console.error(e); - this.toastService.showErrorToast('Failed to attach document, please try again'); - } - return undefined; + return await this.documentService.uploadFile( + fileNumber, + file, + documentType, + source, + `${this.serviceUrl}/notification/${fileNumber}/attachExternal`, + ); } async openFile(fileUuid: string) { diff --git a/portal-frontend/src/app/services/notification-submission/notification-submission.service.ts b/portal-frontend/src/app/services/notification-submission/notification-submission.service.ts index 088aa3c689..c7bb6e29ae 100644 --- a/portal-frontend/src/app/services/notification-submission/notification-submission.service.ts +++ b/portal-frontend/src/app/services/notification-submission/notification-submission.service.ts @@ -108,7 +108,9 @@ export class NotificationSubmissionService { this.toastService.showSuccessToast('SRW Submitted'); } catch (e) { console.error(e); - this.toastService.showErrorToast('Failed to submit SRW, please try again'); + this.toastService.showErrorToast( + 'Failed to submit SRW, please try again. If the problem persists, contact ALC.Portal@gov.bc.ca.', + ); } finally { this.overlayService.hideSpinner(); } diff --git a/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.html b/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.html index 6a2efffad4..13dd3e1010 100644 --- a/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.html +++ b/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.html @@ -4,7 +4,7 @@
    {{ file.fileName }} @@ -27,8 +27,10 @@ *ngIf="!uploadedFiles.length || allowMultiple" [ngClass]="{ 'desktop-file-drag-drop': true, - 'error-outline': !disabled && ((isRequired && showErrors && !uploadedFiles.length) || showVirusError), - disabled: disabled + 'error-outline': + !disabled && + ((isRequired && showErrors && !uploadedFiles.length) || showHasVirusError || showVirusScanFailedError), + disabled: disabled, }" dragDropFile (files)="filesDropped($event)" @@ -55,7 +57,10 @@ This file upload is required - + A virus was detected in the file. Choose another file and try again. + + A problem occurred while scanning for viruses. Please try again. + Maximum file size: 100 MB diff --git a/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.ts b/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.ts index 0acc17688c..2dae680114 100644 --- a/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.ts +++ b/portal-frontend/src/app/shared/file-drag-drop/file-drag-drop.component.ts @@ -19,7 +19,8 @@ export class FileDragDropComponent implements OnInit { @Input() uploadedFiles: (ApplicationDocumentDto & { errorMessage?: string })[] = []; @Input() isRequired = false; @Input() showErrors = false; - @Input() showVirusError = false; + @Input() showHasVirusError = false; + @Input() showVirusScanFailedError = false; private uploadClicked = false; diff --git a/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.html b/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.html index aec322f1b2..22671f2f9e 100644 --- a/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.html +++ b/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.html @@ -46,7 +46,8 @@
    id="fileUpload" [showErrors]="showFileErrors" [isRequired]="true" - [showVirusError]="showVirusError" + [showHasVirusError]="showHasVirusError" + [showVirusScanFailedError]="showVirusScanFailedError" > diff --git a/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.ts b/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.ts index 5946c65b8e..237573d808 100644 --- a/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.ts +++ b/portal-frontend/src/app/shared/owner-dialogs/owner-dialog/owner-dialog.component.ts @@ -21,6 +21,8 @@ import { OWNER_TYPE } from '../../dto/owner.dto'; import { FileHandle } from '../../file-drag-drop/drag-drop.directive'; import { openFileInline } from '../../utils/file'; import { strictEmailValidator } from '../../validators/email-validator'; +import { ToastService } from '../../../services/toast/toast.service'; +import { HttpErrorResponse } from '@angular/common/http'; @Component({ selector: 'app-owner-dialog', @@ -38,7 +40,8 @@ export class OwnerDialogComponent { corporateSummary = new FormControl(null); isEdit = false; - showVirusError = false; + showHasVirusError = false; + showVirusScanFailedError = false; existingUuid: string | undefined; files: ApplicationDocumentDto[] = []; showFileErrors = false; @@ -71,6 +74,7 @@ export class OwnerDialogComponent { documentService: ApplicationDocumentService | NoticeOfIntentDocumentService; ownerService: ApplicationOwnerService | NoticeOfIntentOwnerService; }, + private toastService: ToastService, ) { if (data && data.existingOwner) { this.onChangeType({ @@ -98,7 +102,7 @@ export class OwnerDialogComponent { this.corporateSummary.setValidators([Validators.required]); } else { this.organizationName.setValidators([]); - this.corporateSummary.setValidators([]); + this.corporateSummary.setValidators([]); } this.corporateSummary.updateValueAndValidity(); this.organizationName.updateValueAndValidity(); @@ -181,7 +185,7 @@ export class OwnerDialogComponent { let document; if (this.pendingFile) { document = await this.uploadPendingFile(this.pendingFile); - } else { + } else { document = this.type.value === OWNER_TYPE.ORGANIZATION ? this.data.existingOwner?.corporateSummary : null; } @@ -214,7 +218,7 @@ export class OwnerDialogComponent { this.corporateSummary.setValue('pending'); const corporateSummaryType = this.documentCodes.find((code) => code.code === DOCUMENT_TYPE.CORPORATE_SUMMARY); if (corporateSummaryType) { - this.showVirusError = false; + this.showHasVirusError = false; this.files = [ { type: corporateSummaryType, @@ -267,19 +271,25 @@ export class OwnerDialogComponent { } private async uploadPendingFile(file?: File) { - let documentUuid; if (file) { try { - documentUuid = await this.data.ownerService.uploadCorporateSummary(this.data.fileId, file); - } catch (e) { - this.showVirusError = true; + const documentUuid = await this.data.ownerService.uploadCorporateSummary(this.data.fileId, file); + this.toastService.showSuccessToast('Document uploaded'); + this.showHasVirusError = false; + this.showVirusScanFailedError = false; + return documentUuid; + } catch (err) { + if (err instanceof HttpErrorResponse) { + this.showHasVirusError = err.status === 400 && err.error.name === 'VirusDetected'; + this.showVirusScanFailedError = err.status === 500 && err.error.name === 'VirusScanFailed'; + } this.pendingFile = undefined; this.corporateSummary.setValue(null); this.files = []; - return; + this.toastService.showErrorToast('Document upload failed'); } } - return documentUuid; + return; } private async loadDocumentCodes() { diff --git a/services/Dockerfile b/services/Dockerfile index 040ba3d09d..d68aea7bf4 100644 --- a/services/Dockerfile +++ b/services/Dockerfile @@ -3,7 +3,7 @@ # https://www.tomray.dev/nestjs-docker-production ################### -FROM node:20-alpine As development +FROM node:20-alpine AS development WORKDIR /opt/app-root/ @@ -19,7 +19,7 @@ USER node # BUILD FOR PRODUCTION ################### -FROM node:20-alpine As build +FROM node:20-alpine AS build ARG NEST_APP=alcs WORKDIR /opt/app-root/ @@ -40,7 +40,7 @@ USER node # PRODUCTION ################### -FROM node:20-alpine As production +FROM node:20-alpine AS production ARG NEST_APP=alcs # Init diff --git a/services/Dockerfile.local b/services/Dockerfile.local index 8bc479a0d1..25844be06a 100644 --- a/services/Dockerfile.local +++ b/services/Dockerfile.local @@ -1,4 +1,4 @@ -FROM node:20-alpine As development +FROM node:20-alpine AS development WORKDIR /opt/app-root/ RUN chown node:node /opt/app-root/ diff --git a/services/Dockerfile.migrate b/services/Dockerfile.migrate index 401ce620ff..5ebb2fbfe7 100644 --- a/services/Dockerfile.migrate +++ b/services/Dockerfile.migrate @@ -2,13 +2,13 @@ # DATABASE MIGRATION ##################### -FROM node:20-alpine As development +FROM node:20-alpine AS development WORKDIR /opt/app-root/ # OpenShift fixes RUN chmod og+rwx /opt/app-root/ /var/run -ENV NPM_CONFIG_USERCONFIG=/opt/app-root/.npmrc +ENV NPM_CONFIG_USERCONFIG /opt/app-root/.npmrc RUN npm config set cache $/opt/app-root/.npm COPY package*.json ./ @@ -20,7 +20,7 @@ ARG environment=production ENV NODE_ENV ${environment} ARG NEST_APP=alcs -ENV NEST_APP=${NEST_APP} +ENV NEST_APP ${NEST_APP} COPY . . diff --git a/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.spec.ts b/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.spec.ts index edd61b7423..45d9c63a54 100644 --- a/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.spec.ts +++ b/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.spec.ts @@ -11,6 +11,8 @@ import { NoticeOfIntentService } from '../../notice-of-intent/notice-of-intent.s import { NotificationService } from '../../notification/notification.service'; import { PlanningReferralService } from '../../planning-review/planning-referral/planning-referral.service'; import { UnarchiveCardService } from './unarchive-card.service'; +import { ApplicationDecisionConditionCardService } from '../../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; describe('UnarchiveCardService', () => { let service: UnarchiveCardService; @@ -23,6 +25,8 @@ describe('UnarchiveCardService', () => { let mockNOIModificationService: DeepMocked; let mockNotificationService: DeepMocked; let mockInquiryService: DeepMocked; + let mockApplicationDecisionConditionCardService: DeepMocked; + let mockNoticeOfIntentDecisionConditionCardService: DeepMocked; beforeEach(async () => { mockApplicationService = createMock(); @@ -33,6 +37,8 @@ describe('UnarchiveCardService', () => { mockNOIModificationService = createMock(); mockNotificationService = createMock(); mockInquiryService = createMock(); + mockApplicationDecisionConditionCardService = createMock(); + mockNoticeOfIntentDecisionConditionCardService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -74,6 +80,14 @@ describe('UnarchiveCardService', () => { provide: InquiryService, useValue: mockInquiryService, }, + { + provide: ApplicationDecisionConditionCardService, + useValue: mockApplicationDecisionConditionCardService, + }, + { + provide: NoticeOfIntentDecisionConditionCardService, + useValue: mockNoticeOfIntentDecisionConditionCardService, + }, ], }).compile(); @@ -93,18 +107,20 @@ describe('UnarchiveCardService', () => { mockNOIModificationService.getDeletedCards.mockResolvedValue([]); mockNotificationService.getDeletedCards.mockResolvedValue([]); mockInquiryService.getDeletedCards.mockResolvedValue([]); + mockApplicationDecisionConditionCardService.getDeletedCards.mockResolvedValue([]); + mockNoticeOfIntentDecisionConditionCardService.getDeletedCards.mockResolvedValue([]); await service.fetchByFileId('uuid'); expect(mockApplicationService.getDeletedCard).toHaveBeenCalledTimes(1); expect(mockReconsiderationService.getDeletedCards).toHaveBeenCalledTimes(1); - expect(mockPlanningReferralService.getDeletedCards).toHaveBeenCalledTimes( - 1, - ); + expect(mockPlanningReferralService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockModificationService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockNOIService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockNOIModificationService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockNotificationService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockInquiryService.getDeletedCards).toHaveBeenCalledTimes(1); + expect(mockApplicationDecisionConditionCardService.getDeletedCards).toHaveBeenCalledTimes(1); + expect(mockNoticeOfIntentDecisionConditionCardService.getDeletedCards).toHaveBeenCalledTimes(1); }); }); diff --git a/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.ts b/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.ts index d0b3589801..ade7ac2c67 100644 --- a/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.ts +++ b/services/apps/alcs/src/alcs/admin/unarchive-card/unarchive-card.service.ts @@ -8,6 +8,8 @@ import { NoticeOfIntentModificationService } from '../../notice-of-intent-decisi import { NoticeOfIntentService } from '../../notice-of-intent/notice-of-intent.service'; import { NotificationService } from '../../notification/notification.service'; import { PlanningReferralService } from '../../planning-review/planning-referral/planning-referral.service'; +import { ApplicationDecisionConditionCardService } from '../../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; @Injectable() export class UnarchiveCardService { @@ -20,6 +22,8 @@ export class UnarchiveCardService { private notificationService: NotificationService, private planningReferralService: PlanningReferralService, private inquiryService: InquiryService, + private applicationDecisionConditionCardService: ApplicationDecisionConditionCardService, + private noticeOfIntentDecisionConditionCardService: NoticeOfIntentDecisionConditionCardService, ) {} async fetchByFileId(fileId: string) { @@ -37,6 +41,8 @@ export class UnarchiveCardService { await this.fetchAndMapNOIs(fileId, result); await this.fetchAndMapNotifications(fileId, result); await this.fetchAndMapInquiries(fileId, result); + await this.fetchAndMapApplicationDecisionConditionCards(fileId, result); + await this.fetchAndMapNoticeOfIntentDecisionConditionCards(fileId, result); return result; } @@ -70,8 +76,7 @@ export class UnarchiveCardService { createdAt: number; }[], ) { - const modifications = - await this.modificationService.getDeletedCards(fileId); + const modifications = await this.modificationService.getDeletedCards(fileId); for (const modification of modifications) { result.push({ cardUuid: modification.cardUuid ?? '', @@ -91,8 +96,7 @@ export class UnarchiveCardService { createdAt: number; }[], ) { - const reconsiderations = - await this.reconsiderationService.getDeletedCards(fileId); + const reconsiderations = await this.reconsiderationService.getDeletedCards(fileId); for (const reconsideration of reconsiderations) { result.push({ cardUuid: reconsideration.cardUuid ?? '', @@ -112,8 +116,7 @@ export class UnarchiveCardService { createdAt: number; }[], ) { - const noticeOfIntents = - await this.noticeOfIntentService.getDeletedCards(fileId); + const noticeOfIntents = await this.noticeOfIntentService.getDeletedCards(fileId); for (const noi of noticeOfIntents) { result.push({ cardUuid: noi.cardUuid, @@ -123,8 +126,7 @@ export class UnarchiveCardService { }); } - const modificationNOIs = - await this.noticeOfIntentModificationService.getDeletedCards(fileId); + const modificationNOIs = await this.noticeOfIntentModificationService.getDeletedCards(fileId); for (const noi of modificationNOIs) { result.push({ @@ -145,8 +147,7 @@ export class UnarchiveCardService { createdAt: number; }[], ) { - const notifications = - await this.notificationService.getDeletedCards(fileId); + const notifications = await this.notificationService.getDeletedCards(fileId); for (const notification of notifications) { result.push({ cardUuid: notification.cardUuid, @@ -166,8 +167,7 @@ export class UnarchiveCardService { createdAt: number; }[], ) { - const planningReferrals = - await this.planningReferralService.getDeletedCards(fileId); + const planningReferrals = await this.planningReferralService.getDeletedCards(fileId); for (const referral of planningReferrals) { result.push({ cardUuid: referral.cardUuid, @@ -197,4 +197,46 @@ export class UnarchiveCardService { }); } } + + private async fetchAndMapApplicationDecisionConditionCards( + fileId: string, + result: { + cardUuid: string; + type: string; + status: string; + createdAt: number; + }[], + ) { + const conditionCards = await this.applicationDecisionConditionCardService.getDeletedCards(fileId); + + for (const conditionCard of conditionCards) { + result.push({ + cardUuid: conditionCard.cardUuid, + createdAt: conditionCard.auditCreatedAt.getTime(), + type: CARD_TYPE.APP_CON, + status: conditionCard.card!.status.label, + }); + } + } + + private async fetchAndMapNoticeOfIntentDecisionConditionCards( + fileId: string, + result: { + cardUuid: string; + type: string; + status: string; + createdAt: number; + }[], + ) { + const conditionCards = await this.noticeOfIntentDecisionConditionCardService.getDeletedCards(fileId); + + for (const conditionCard of conditionCards) { + result.push({ + cardUuid: conditionCard.cardUuid, + createdAt: conditionCard.auditCreatedAt.getTime(), + type: CARD_TYPE.NOI_CON, + status: conditionCard.card!.status.label, + }); + } + } } diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller.spec.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller.spec.ts new file mode 100644 index 0000000000..ff8d46bcc4 --- /dev/null +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller.spec.ts @@ -0,0 +1,147 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { ApplicationDecisionConditionCardController } from './application-decision-condition-card.controller'; +import { ApplicationDecisionConditionCardService } from './application-decision-condition-card.service'; +import { ApplicationModificationService } from '../../application-modification/application-modification.service'; +import { ApplicationReconsiderationService } from '../../application-reconsideration/application-reconsideration.service'; +import { ApplicationDecisionConditionCard } from './application-decision-condition-card.entity'; +import { + ApplicationDecisionConditionCardBoardDto, + ApplicationDecisionConditionCardDto, + CreateApplicationDecisionConditionCardDto, + UpdateApplicationDecisionConditionCardDto, +} from './application-decision-condition-card.dto'; +import { AutomapperModule } from 'automapper-nestjs'; +import { classes } from 'automapper-classes'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../../test/mocks/mockTypes'; +import { ApplicationDecisionProfile } from '../../../../common/automapper/application-decision-v2.automapper.profile'; +import { ApplicationDecisionV2Service } from '../../application-decision-v2/application-decision/application-decision-v2.service'; + +describe('ApplicationDecisionConditionCardController', () => { + let controller: ApplicationDecisionConditionCardController; + let mockService: DeepMocked; + let mockModificationService: DeepMocked; + let mockReconsiderationService: DeepMocked; + let mockApplicationDecisionService: DeepMocked; + + beforeEach(async () => { + mockService = createMock(); + mockModificationService = createMock(); + mockReconsiderationService = createMock(); + mockApplicationDecisionService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [ApplicationDecisionConditionCardController], + providers: [ + { + provide: ApplicationDecisionConditionCardService, + useValue: mockService, + }, + { + provide: ApplicationModificationService, + useValue: mockModificationService, + }, + { + provide: ApplicationReconsiderationService, + useValue: mockReconsiderationService, + }, + { + provide: ApplicationDecisionV2Service, + useValue: mockApplicationDecisionService, + }, + { + provide: ClsService, + useValue: {}, + }, + ApplicationDecisionProfile, + ...mockKeyCloakProviders, + ], + }).compile(); + + controller = module.get(ApplicationDecisionConditionCardController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return a condition card', async () => { + const uuid = 'example-uuid'; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockService.get.mockResolvedValue(conditionCard); + + const result = await controller.get(uuid); + + expect(mockService.get).toHaveBeenCalledWith(uuid); + expect(result).toBeInstanceOf(ApplicationDecisionConditionCardDto); + }); + + it('should create a new condition card', async () => { + const dto: CreateApplicationDecisionConditionCardDto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockService.create.mockResolvedValue(conditionCard); + + const result = await controller.create(dto); + + expect(mockService.create).toHaveBeenCalledWith(dto); + expect(result).toBeInstanceOf(ApplicationDecisionConditionCardDto); + }); + + it('should update the condition card and return updated card', async () => { + const uuid = 'example-uuid'; + const dto: UpdateApplicationDecisionConditionCardDto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockService.update.mockResolvedValue(conditionCard); + + const result = await controller.update(uuid, dto); + + expect(mockService.update).toHaveBeenCalledWith(uuid, dto); + expect(result).toBeInstanceOf(ApplicationDecisionConditionCardDto); + }); + + it('should return a condition card by board card uuid', async () => { + const uuid = 'example-uuid'; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid', application: { fileNumber: 'file-number' } } as any; + + mockService.getByBoardCard.mockResolvedValue(conditionCard); + mockReconsiderationService.getByApplicationDecisionUuid.mockResolvedValue([]); + mockModificationService.getByApplicationDecisionUuid.mockResolvedValue([]); + mockApplicationDecisionService.getDecisionOrder.mockResolvedValue(1); + + const result = await controller.getByCardUuid(uuid); + + expect(mockService.getByBoardCard).toHaveBeenCalledWith(uuid); + expect(result).toBeInstanceOf(ApplicationDecisionConditionCardBoardDto); + expect(result.fileNumber).toEqual('file-number'); + }); + + it('should return condition cards by application file number', async () => { + const fileNumber = 'example-file-number'; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockApplicationDecisionService.getForDecisionConditionCardsByFileNumber.mockResolvedValue([conditionCard]); + + const result = await controller.getByApplicationFileNumber(fileNumber); + + expect(mockApplicationDecisionService.getForDecisionConditionCardsByFileNumber).toHaveBeenCalledWith(fileNumber); + expect(result).toBeInstanceOf(Array); + expect(result[0]).toBeInstanceOf(ApplicationDecisionConditionCardDto); + }); +}); diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller.ts new file mode 100644 index 0000000000..0b728caeaf --- /dev/null +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller.ts @@ -0,0 +1,103 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import * as config from 'config'; +import { RolesGuard } from '../../../../common/authorization/roles-guard.service'; +import { ApplicationDecisionConditionCardService } from './application-decision-condition-card.service'; +import { InjectMapper } from 'automapper-nestjs'; +import { Mapper } from 'automapper-core'; +import { UserRoles } from '../../../../common/authorization/roles.decorator'; +import { ROLES_ALLOWED_APPLICATIONS } from '../../../../common/authorization/roles'; +import { + ApplicationDecisionConditionCardBoardDto, + ApplicationDecisionConditionCardDto, + CreateApplicationDecisionConditionCardDto, + UpdateApplicationDecisionConditionCardDto, +} from './application-decision-condition-card.dto'; +import { ApplicationDecisionConditionCard } from './application-decision-condition-card.entity'; +import { ApplicationModificationService } from '../../application-modification/application-modification.service'; +import { ApplicationReconsiderationService } from '../../application-reconsideration/application-reconsideration.service'; +import { ApplicationDecisionV2Service } from '../../application-decision-v2/application-decision/application-decision-v2.service'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@Controller('application-decision-condition-card') +@UseGuards(RolesGuard) +export class ApplicationDecisionConditionCardController { + constructor( + private service: ApplicationDecisionConditionCardService, + private applicationModificationService: ApplicationModificationService, + private applicationReconsiderationService: ApplicationReconsiderationService, + private applicationDecisionService: ApplicationDecisionV2Service, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/:uuid') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async get(@Param('uuid') uuid: string): Promise { + const result = await this.service.get(uuid); + + return await this.mapper.map(result, ApplicationDecisionConditionCard, ApplicationDecisionConditionCardDto); + } + + @Post('') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async create(@Body() dto: CreateApplicationDecisionConditionCardDto): Promise { + const result = await this.service.create(dto); + + return await this.mapper.map(result, ApplicationDecisionConditionCard, ApplicationDecisionConditionCardDto); + } + + @Patch('/:uuid') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async update( + @Param('uuid') uuid: string, + @Body() dto: UpdateApplicationDecisionConditionCardDto, + ): Promise { + const result = await this.service.update(uuid, dto); + + return await this.mapper.map(result, ApplicationDecisionConditionCard, ApplicationDecisionConditionCardDto); + } + + @Get('/board-card/:uuid') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async getByCardUuid(@Param('uuid') uuid: string): Promise { + const result = await this.service.getByBoardCard(uuid); + const dto = await this.mapper.map( + result, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionCardBoardDto, + ); + dto.fileNumber = result.decision.application.fileNumber; + + const appModifications = await this.applicationModificationService.getByApplicationDecisionUuid( + result.decision.uuid, + ); + const appReconsiderations = await this.applicationReconsiderationService.getByApplicationDecisionUuid( + result.decision.uuid, + ); + + dto.isModification = appModifications.length > 0; + dto.isReconsideration = appReconsiderations.length > 0; + + const decisionOrder = await this.applicationDecisionService.getDecisionOrder( + result.decision.application.fileNumber, + result.decision.uuid, + ); + dto.decisionOrder = decisionOrder; + + return dto; + } + + @Get('/application/:fileNumber') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async getByApplicationFileNumber( + @Param('fileNumber') fileNumber: string, + ): Promise { + const conditionCards = await this.applicationDecisionService.getForDecisionConditionCardsByFileNumber(fileNumber); + const dtos: ApplicationDecisionConditionCardDto[] = []; + for (const card of conditionCards) { + const dto = await this.mapper.map(card, ApplicationDecisionConditionCard, ApplicationDecisionConditionCardDto); + dtos.push(dto); + } + return dtos; + } +} diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.dto.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.dto.ts new file mode 100644 index 0000000000..db10de15ba --- /dev/null +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.dto.ts @@ -0,0 +1,123 @@ +import { AutoMap } from 'automapper-classes'; +import { ApplicationDecisionConditionDto } from '../application-decision-condition.dto'; +import { IsArray, IsBoolean, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { CardDto } from '../../../card/card.dto'; +import { ApplicationTypeDto } from '../../../code/application-code/application-type/application-type.dto'; + +export class ApplicationDecisionConditionCardDto { + @AutoMap() + @IsUUID() + uuid: string; + + @IsArray() + conditions: ApplicationDecisionConditionDto[]; + + @AutoMap() + @IsString() + cardUuid: string; + + @AutoMap() + card: CardDto; + + @IsString() + decisionUuid?: string; + + @IsString() + @IsOptional() + applicationFileNumber?: string | null; +} + +export class ApplicationDecisionConditionHomeCardDto { + @AutoMap() + @IsUUID() + uuid: string; + + @IsArray() + conditions: ApplicationDecisionConditionDto[]; + + @AutoMap() + @IsString() + cardUuid: string; + + @AutoMap() + card: CardDto; + + @IsString() + @IsOptional() + applicationFileNumber?: string | null; +} + +export class CreateApplicationDecisionConditionCardDto { + @AutoMap() + @IsArray() + conditionsUuids: string[]; + + @AutoMap() + @IsString() + decisionUuid: string; + + @AutoMap() + @IsString() + cardStatusCode: string; +} + +export class UpdateApplicationDecisionConditionCardDto { + @AutoMap() + @IsArray() + @IsOptional() + conditionsUuids?: string[] | null; + + @AutoMap() + @IsString() + @IsOptional() + cardStatusCode?: string | null; +} + +export class ApplicationDecisionConditionCardUuidDto { + @AutoMap() + @IsUUID() + uuid: string; +} + +export class ApplicationDecisionConditionCardBoardDto { + @AutoMap() + @IsUUID() + uuid: string; + + @IsArray() + conditions: ApplicationDecisionConditionDto[]; + + @AutoMap() + card: CardDto; + + @IsString() + decisionUuid: string; + + @IsNumber() + decisionOrder: number; + + @IsBoolean() + decisionIsFlagged: boolean; + + @IsString() + fileNumber: string; + + @IsString() + @IsOptional() + applicant?: string | null; + + @IsOptional() + type?: ApplicationTypeDto | null; + + @IsBoolean() + isReconsideration?: boolean; + + @IsBoolean() + isModification?: boolean; +} + +export class UpdateApplicationDecisionConditionBoardCardDto { + @AutoMap() + @IsString() + cardStatusCode: string; +} diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity.ts new file mode 100644 index 0000000000..355eb0149e --- /dev/null +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity.ts @@ -0,0 +1,36 @@ +import { Base } from '../../../../common/entities/base.entity'; +import { AutoMap } from 'automapper-classes'; +import { Column, Entity, ManyToOne, OneToOne, JoinColumn, OneToMany } from 'typeorm'; +import { ApplicationDecisionCondition } from '../application-decision-condition.entity'; +import { Card } from '../../../card/card.entity'; +import { ApplicationDecision } from '../../application-decision.entity'; + +@Entity({ comment: 'Links application decision conditions with cards' }) +export class ApplicationDecisionConditionCard extends Base { + constructor(data?: Partial) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @OneToMany(() => ApplicationDecisionCondition, (condition) => condition.conditionCard, { + nullable: false, + cascade: true, + }) + conditions: ApplicationDecisionCondition[]; + + @OneToOne(() => Card, { nullable: false }) + @JoinColumn() + card: Card; + + @AutoMap() + @Column({ type: 'uuid' }) + cardUuid: string; + + @AutoMap(() => ApplicationDecision) + @ManyToOne(() => ApplicationDecision, (decision) => decision.uuid, { + nullable: false, + }) + decision: ApplicationDecision; +} diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service.spec.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service.spec.ts new file mode 100644 index 0000000000..51ee2af237 --- /dev/null +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service.spec.ts @@ -0,0 +1,431 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { ApplicationDecisionConditionCardService } from './application-decision-condition-card.service'; +import { ApplicationDecisionConditionService } from '../application-decision-condition.service'; +import { ApplicationDecisionV2Service } from '../../application-decision-v2/application-decision/application-decision-v2.service'; +import { BoardService } from '../../../board/board.service'; +import { CardService } from '../../../card/card.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ApplicationDecisionConditionCard } from './application-decision-condition-card.entity'; +import { IsNull, Not, Repository } from 'typeorm'; +import { AutomapperModule } from 'automapper-nestjs'; +import { classes } from 'automapper-classes'; +import { Mapper } from 'automapper-core'; +import { ApplicationDecisionProfile } from '../../../../common/automapper/application-decision-v2.automapper.profile'; +import { + ServiceNotFoundException, + ServiceValidationException, +} from '../../../../../../../libs/common/src/exceptions/base.exception'; +import { Card } from '../../../card/card.entity'; +import { ApplicationProfile } from '../../../../common/automapper/application.automapper.profile'; +import { ApplicationModificationService } from '../../application-modification/application-modification.service'; +import { ApplicationReconsiderationService } from '../../application-reconsideration/application-reconsideration.service'; + +describe('ApplicationDecisionConditionCardService', () => { + let service: ApplicationDecisionConditionCardService; + let mockRepository: DeepMocked>; + let mockConditionService: DeepMocked; + let mockDecisionService: DeepMocked; + let mockBoardService: DeepMocked; + let mockCardService: DeepMocked; + let mockApplicationModificationService: DeepMocked; + let mockApplicationReconsiderationService: DeepMocked; + let mockMapper: DeepMocked; + + const CARD_RELATIONS = { + board: true, + type: true, + status: true, + assignee: true, + }; + + const BOARD_CARD_RELATIONS = { + card: CARD_RELATIONS, + conditions: true, + decision: { + application: { + type: true, + }, + }, + }; + + const DEFAULT_RELATIONS = { + conditions: true, + card: CARD_RELATIONS, + decision: { + application: true, + }, + }; + + beforeEach(async () => { + mockRepository = createMock(); + mockConditionService = createMock(); + mockDecisionService = createMock(); + mockBoardService = createMock(); + mockCardService = createMock(); + mockApplicationModificationService = createMock(); + mockApplicationReconsiderationService = createMock(); + mockMapper = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + ApplicationDecisionConditionCardService, + { + provide: getRepositoryToken(ApplicationDecisionConditionCard), + useValue: mockRepository, + }, + { + provide: ApplicationDecisionConditionService, + useValue: mockConditionService, + }, + { + provide: ApplicationDecisionV2Service, + useValue: mockDecisionService, + }, + { + provide: BoardService, + useValue: mockBoardService, + }, + { + provide: CardService, + useValue: mockCardService, + }, + { + provide: ApplicationModificationService, + useValue: mockApplicationModificationService, + }, + { + provide: ApplicationReconsiderationService, + useValue: mockApplicationReconsiderationService, + }, + ApplicationDecisionProfile, + ApplicationProfile, + ], + }).compile(); + + service = module.get(ApplicationDecisionConditionCardService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new condition card', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'status-code' }] } as any; + const decision = { uuid: 'decision-uuid' } as any; + const card = { uuid: 'card-uuid' } as any; + const conditions = [{ uuid: 'condition-uuid-1' }, { uuid: 'condition-uuid-2' }] as any; + + mockBoardService.getApplicationDecisionConditionBoard.mockResolvedValue(board); + mockDecisionService.get.mockResolvedValue(decision); + mockCardService.save.mockResolvedValue(card); + mockConditionService.findByUuids.mockResolvedValue(conditions); + mockRepository.save.mockResolvedValue({ uuid: 'new-card-uuid' } as any); + + const result = await service.create(dto); + + expect(mockBoardService.getApplicationDecisionConditionBoard).toHaveBeenCalled(); + expect(mockDecisionService.get).toHaveBeenCalledWith(dto.decisionUuid); + expect(mockCardService.save).toHaveBeenCalled(); + expect(mockConditionService.findByUuids).toHaveBeenCalledWith(dto.conditionsUuids); + expect(mockRepository.save).toHaveBeenCalled(); + expect(result.uuid).toEqual('new-card-uuid'); + }); + + it('should throw an error if board is not found', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + + mockBoardService.getApplicationDecisionConditionBoard.mockRejectedValue( + new ServiceNotFoundException('Board not found'), + ); + + await expect(service.create(dto)).rejects.toThrow(ServiceNotFoundException); + }); + + it('should throw an error if decision is not found', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'status-code' }] } as any; + + mockBoardService.getApplicationDecisionConditionBoard.mockResolvedValue(board); + mockDecisionService.get.mockRejectedValue( + new ServiceNotFoundException('Failed to fetch decision with uuid decision-uuid'), + ); + + await expect(service.create(dto)).rejects.toThrow(ServiceNotFoundException); + }); + + it('should throw an error if conditions are not found', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'status-code' }] } as any; + const decision = { uuid: 'decision-uuid' } as any; + const card = { uuid: 'card-uuid' } as any; + + mockBoardService.getApplicationDecisionConditionBoard.mockResolvedValue(board); + mockDecisionService.get.mockResolvedValue(decision); + mockCardService.save.mockResolvedValue(card); + mockConditionService.findByUuids.mockResolvedValue([]); + + await expect(service.create(dto)).rejects.toThrow(ServiceValidationException); + }); + }); + + describe('get', () => { + it('should return a condition card', async () => { + const uuid = 'example-uuid'; + const conditionCard = new ApplicationDecisionConditionCard(); + mockRepository.findOne.mockResolvedValue(conditionCard); + + const result = await service.get(uuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { uuid }, + relations: DEFAULT_RELATIONS, + }); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const uuid = 'example-uuid'; + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.get(uuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('update', () => { + it('should update the condition card', async () => { + const uuid = 'example-uuid'; + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.card = new Card(); + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'updated-status-code' }] } as any; + const conditions = [{ uuid: 'condition-uuid-1' }, { uuid: 'condition-uuid-2' }] as any; + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockBoardService.getApplicationDecisionConditionBoard.mockResolvedValue(board); + mockConditionService.findByUuids.mockResolvedValue(conditions); + mockRepository.save.mockResolvedValue(conditionCard); + + const result = await service.update(uuid, dto); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { uuid }, + relations: DEFAULT_RELATIONS, + }); + expect(mockBoardService.getApplicationDecisionConditionBoard).toHaveBeenCalled(); + expect(mockConditionService.findByUuids).toHaveBeenCalledWith(dto.conditionsUuids); + expect(mockRepository.save).toHaveBeenCalledWith(conditionCard); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const uuid = 'example-uuid'; + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.update(uuid, dto)).rejects.toThrow(ServiceNotFoundException); + }); + + it('should throw an error if conditions are not found', async () => { + const uuid = 'example-uuid'; + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + const conditionCard = new ApplicationDecisionConditionCard(); + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'updated-status-code' }] } as any; + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockBoardService.getApplicationDecisionConditionBoard.mockResolvedValue(board); + mockConditionService.findByUuids.mockResolvedValue([]); + + await expect(service.update(uuid, dto)).rejects.toThrow(ServiceValidationException); + }); + }); + + describe('getByBoard', () => { + it('should return condition cards by board uuid', async () => { + const boardUuid = 'board-uuid'; + const conditionCards = [new ApplicationDecisionConditionCard()]; + mockRepository.find.mockResolvedValue(conditionCards); + + const result = await service.getByBoard(boardUuid); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { card: { boardUuid } }, + relations: service.BOARD_CARD_RELATIONS, + }); + expect(result).toEqual(conditionCards); + }); + }); + + describe('getByBoardCard', () => { + it('should return a condition card by board card uuid', async () => { + const uuid = 'example-uuid'; + const conditionCard = new ApplicationDecisionConditionCard(); + mockRepository.findOne.mockResolvedValue(conditionCard); + + const result = await service.getByBoardCard(uuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { cardUuid: uuid }, + relations: service.BOARD_CARD_RELATIONS, + }); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const uuid = 'example-uuid'; + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.getByBoardCard(uuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('softRemove', () => { + it('should soft remove a condition card', async () => { + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.cardUuid = 'card-uuid'; + const card = new Card(); + + mockCardService.get.mockResolvedValue(card); + mockCardService.softRemoveByUuid.mockResolvedValue(); + mockRepository.softRemove.mockResolvedValue(conditionCard); + + const result = await service.softRemove(conditionCard); + + expect(mockCardService.get).toHaveBeenCalledWith(conditionCard.cardUuid); + expect(mockCardService.softRemoveByUuid).toHaveBeenCalledWith(card.uuid); + expect(mockRepository.softRemove).toHaveBeenCalledWith(conditionCard); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if card is not found', async () => { + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.cardUuid = 'card-uuid'; + + mockCardService.get.mockResolvedValue(null); + + await expect(service.softRemove(conditionCard)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('archiveByBoardCard', () => { + it('should archive a condition card by board card uuid', async () => { + const boardCardUuid = 'example-uuid'; + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.conditions = []; + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockRepository.save.mockResolvedValue(conditionCard); + mockRepository.softRemove.mockResolvedValue(conditionCard); + + await service.archiveByBoardCard(boardCardUuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { cardUuid: boardCardUuid }, + relations: service.BOARD_CARD_RELATIONS, + }); + expect(mockRepository.save).toHaveBeenCalledWith(conditionCard); + expect(mockRepository.softRemove).toHaveBeenCalledWith(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const boardCardUuid = 'example-uuid'; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.archiveByBoardCard(boardCardUuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('recoverByBoardCard', () => { + it('should recover a condition card by board card uuid', async () => { + const boardCardUuid = 'example-uuid'; + const conditionCard = new ApplicationDecisionConditionCard(); + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockRepository.recover.mockResolvedValue(conditionCard); + + await service.recoverByBoardCard(boardCardUuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { cardUuid: boardCardUuid }, + withDeleted: true, + relations: service.DEFAULT_RELATIONS, + }); + expect(mockRepository.recover).toHaveBeenCalledWith(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const boardCardUuid = 'example-uuid'; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.recoverByBoardCard(boardCardUuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('getDeletedCards', () => { + it('should return deleted condition cards by file number', async () => { + const fileNumber = 'example-file-number'; + const conditionCards = [new ApplicationDecisionConditionCard()]; + + mockRepository.find.mockResolvedValue(conditionCards); + + const result = await service.getDeletedCards(fileNumber); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + decision: { + application: { + fileNumber, + }, + auditDeletedDateAt: IsNull(), + }, + card: { + auditDeletedDateAt: Not(IsNull()), + }, + }, + withDeleted: true, + relations: { + decision: { + application: true, + }, + card: service.CARD_RELATIONS, + }, + }); + expect(result).toEqual(conditionCards); + }); + }); +}); diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service.ts new file mode 100644 index 0000000000..71975b13c0 --- /dev/null +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service.ts @@ -0,0 +1,269 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { + ApplicationDecisionConditionCardBoardDto, + CreateApplicationDecisionConditionCardDto, + UpdateApplicationDecisionConditionCardDto, +} from './application-decision-condition-card.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ApplicationDecisionConditionCard } from './application-decision-condition-card.entity'; +import { FindOptionsOrder, FindOptionsWhere, IsNull, Not, Repository } from 'typeorm'; +import { CardService } from '../../../card/card.service'; +import { ApplicationDecisionConditionService } from '../application-decision-condition.service'; +import { BoardService } from '../../../board/board.service'; +import { + ServiceInternalErrorException, + ServiceNotFoundException, + ServiceValidationException, +} from '../../../../../../../libs/common/src/exceptions/base.exception'; +import { Card } from '../../../card/card.entity'; +import { CARD_TYPE } from '../../../card/card-type/card-type.entity'; +import { ApplicationDecisionV2Service } from '../../application-decision-v2/application-decision/application-decision-v2.service'; +import { Board } from '../../../board/board.entity'; +import { InjectMapper } from 'automapper-nestjs'; +import { Mapper } from 'automapper-core'; +import { ApplicationType } from '../../../code/application-code/application-type/application-type.entity'; +import { ApplicationTypeDto } from '../../../code/application-code/application-type/application-type.dto'; +import { ApplicationDecision } from '../../application-decision.entity'; +import { ApplicationModificationService } from '../../application-modification/application-modification.service'; +import { ApplicationReconsiderationService } from '../../application-reconsideration/application-reconsideration.service'; + +@Injectable() +export class ApplicationDecisionConditionCardService { + CARD_RELATIONS = { + board: true, + type: true, + status: true, + assignee: true, + }; + + BOARD_CARD_RELATIONS = { + card: this.CARD_RELATIONS, + conditions: true, + decision: { + application: { + type: true, + }, + }, + }; + + DEFAULT_RELATIONS = { + conditions: true, + card: this.CARD_RELATIONS, + decision: { + application: true, + }, + }; + + constructor( + @InjectRepository(ApplicationDecisionConditionCard) + private repository: Repository, + private applicationDecisionConditionService: ApplicationDecisionConditionService, + @Inject(forwardRef(() => ApplicationDecisionV2Service)) + private applicationDecisionService: ApplicationDecisionV2Service, + private boardService: BoardService, + @Inject(forwardRef(() => CardService)) + private cardService: CardService, + private applicationModificationService: ApplicationModificationService, + private applicationReconsiderationService: ApplicationReconsiderationService, + @InjectMapper() private mapper: Mapper, + ) {} + + async create(dto: CreateApplicationDecisionConditionCardDto) { + let board: Board; + try { + board = await this.boardService.getApplicationDecisionConditionBoard(); + } catch (error) { + throw new ServiceNotFoundException('Failed to fetch Application Decision Condition Board'); + } + + if (!board.statuses.find((status) => status.statusCode === dto.cardStatusCode)) { + throw new ServiceValidationException(`Invalid card status code: ${dto.cardStatusCode}`); + } + + let decision: ApplicationDecision; + try { + decision = await this.applicationDecisionService.get(dto.decisionUuid); + } catch (error) { + throw new ServiceNotFoundException(`Failed to fetch decision with uuid ${dto.decisionUuid}`); + } + + const card = new Card(); + card.typeCode = CARD_TYPE.APP_CON; + card.statusCode = dto.cardStatusCode; + card.boardUuid = board.uuid; + const newCard = await this.cardService.save(card); + + if (!newCard) { + throw new ServiceInternalErrorException('Failed to create card'); + } + + const conditions = await this.applicationDecisionConditionService.findByUuids(dto.conditionsUuids); + + if (conditions.length !== dto.conditionsUuids.length) { + throw new ServiceValidationException('Failed to fetch all conditions'); + } + + const applicationDecisionConditionCard = new ApplicationDecisionConditionCard(); + applicationDecisionConditionCard.cardUuid = newCard.uuid; + applicationDecisionConditionCard.conditions = conditions; + applicationDecisionConditionCard.decision = decision; + + return this.repository.save(applicationDecisionConditionCard); + } + + async get(uuid: string): Promise { + const applicationDecisionConditionCard = await this.repository.findOne({ + where: { uuid }, + relations: this.DEFAULT_RELATIONS, + }); + + if (!applicationDecisionConditionCard) { + throw new ServiceNotFoundException(`ApplicationDecisionConditionCard with uuid ${uuid} not found`); + } + + return applicationDecisionConditionCard; + } + + async update(uuid: string, dto: UpdateApplicationDecisionConditionCardDto) { + const applicationDecisionConditionCard = await this.get(uuid); + + if (dto.conditionsUuids && dto.conditionsUuids.length > 0) { + const conditions = await this.applicationDecisionConditionService.findByUuids(dto.conditionsUuids); + + if (conditions.length !== dto.conditionsUuids.length) { + throw new ServiceValidationException('Failed to fetch all conditions'); + } + + applicationDecisionConditionCard.conditions = conditions; + } + + if (dto.cardStatusCode) { + let board: Board; + try { + board = await this.boardService.getApplicationDecisionConditionBoard(); + } catch (error) { + throw new ServiceNotFoundException('Failed to fetch Application Decision Condition Board'); + } + + if (!board.statuses.find((status) => status.statusCode === dto.cardStatusCode)) { + throw new ServiceValidationException(`Invalid card status code: ${dto.cardStatusCode}`); + } + + applicationDecisionConditionCard.card.statusCode = dto.cardStatusCode; + } + + return this.repository.save(applicationDecisionConditionCard); + } + + async softRemove(decisionConditionCard: ApplicationDecisionConditionCard) { + const card = await this.cardService.get(decisionConditionCard.cardUuid); + if (!card) { + throw new ServiceNotFoundException(`Card with uuid ${decisionConditionCard.cardUuid} not found`); + } + + await this.cardService.softRemoveByUuid(card.uuid); + return this.repository.softRemove(decisionConditionCard); + } + + async getByBoard(boardUuid: string): Promise { + return await this.repository.find({ + where: { card: { boardUuid } }, + relations: this.BOARD_CARD_RELATIONS, + }); + } + + async getByBoardCard(uuid: string): Promise { + const res = await this.repository.findOne({ where: { cardUuid: uuid }, relations: this.BOARD_CARD_RELATIONS }); + if (!res) { + throw new ServiceNotFoundException(`Could not find card with UUID ${uuid}`); + } + + return res; + } + + async mapToBoardDtos(applicationDecisionConditionCards: ApplicationDecisionConditionCard[]) { + const dtos = applicationDecisionConditionCards.map((card) => { + const dto = this.mapper.map(card, ApplicationDecisionConditionCard, ApplicationDecisionConditionCardBoardDto); + dto.applicant = card.decision.application.applicant; + dto.fileNumber = card.decision.application.fileNumber; + dto.type = this.mapper.map(card.decision.application.type, ApplicationType, ApplicationTypeDto); + return dto; + }); + + for (const dto of dtos) { + const appModifications = await this.applicationModificationService.getByApplicationDecisionUuid(dto.decisionUuid); + const appReconsiderations = await this.applicationReconsiderationService.getByApplicationDecisionUuid( + dto.decisionUuid, + ); + + dto.isModification = appModifications.length > 0; + dto.isReconsideration = appReconsiderations.length > 0; + + for (const condition of dto.conditions) { + const status = await this.applicationDecisionService.getDecisionConditionStatus(condition.uuid); + condition.status = status !== '' ? status : undefined; + } + } + return dtos; + } + + async archiveByBoardCard(boardCardUuid: string) { + const decisionConditionCard = await this.getByBoardCard(boardCardUuid); + + if (!decisionConditionCard) { + throw new ServiceNotFoundException(`Card with uuid ${boardCardUuid} not found`); + } + decisionConditionCard.conditions = []; + await this.repository.save(decisionConditionCard); + + await this.repository.softRemove(decisionConditionCard); + } + + async recoverByBoardCard(boardCardUuid: string) { + const decisionConditionCard = await this.repository.findOne({ + where: { cardUuid: boardCardUuid }, + withDeleted: true, + relations: this.DEFAULT_RELATIONS, + }); + + if (!decisionConditionCard) { + throw new ServiceNotFoundException(`Card with uuid ${boardCardUuid} not found`); + } + + await this.repository.recover(decisionConditionCard); + } + + async getDeletedCards(fileNumber: string) { + return this.repository.find({ + where: { + decision: { + application: { + fileNumber, + }, + auditDeletedDateAt: IsNull(), + }, + card: { + auditDeletedDateAt: Not(IsNull()), + }, + }, + withDeleted: true, + relations: { + decision: { + application: true, + }, + card: this.CARD_RELATIONS, + }, + }); + } + + async getMany( + findOptions?: FindOptionsWhere, + sortOptions?: FindOptionsOrder, + ): Promise { + return await this.repository.find({ + where: findOptions, + relations: this.DEFAULT_RELATIONS, + order: sortOptions, + }); + } +} diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.service.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.service.ts index 9d917a91c0..3175d2fdb9 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.service.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.service.ts @@ -82,12 +82,16 @@ export class ApplicationDecisionConditionDateService { throw new ServiceNotFoundException(`Condition ${createDto.conditionUuid} was not found.`); } - if (condition.type.dateType !== DateType.MULTIPLE) { + if (!condition.type.isDateChecked) { throw new ServiceValidationException( `Creating a new date is not supported for condition ${createDto.conditionUuid}`, ); } + if (condition.type.dateType !== DateType.MULTIPLE && condition.dates?.length > 0) { + throw new ServiceValidationException(`Cannot create more than one date for condition ${createDto.conditionUuid}`); + } + const newDate = new ApplicationDecisionConditionDate(); newDate.date = null; newDate.completedDate = null; @@ -97,7 +101,7 @@ export class ApplicationDecisionConditionDateService { return await this.repository.save(newDate); } - async delete(dateUuid: string) { + async delete(dateUuid: string, forceSingleDateDeletion: boolean = false) { const conditionDate = await this.repository.findOne({ where: { uuid: dateUuid }, relations: ['condition', 'condition.type'], @@ -107,7 +111,7 @@ export class ApplicationDecisionConditionDateService { throw new ServiceNotFoundException(`Condition Date ${dateUuid} was not found`); } - if (conditionDate.condition.type.dateType !== DateType.MULTIPLE) { + if (!forceSingleDateDeletion && conditionDate.condition.type.dateType !== DateType.MULTIPLE) { throw new ServiceValidationException(`Deleting the date ${dateUuid} is not permitted on single date type`); } diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.dto.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.dto.ts index ff390865da..cb228640a2 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.dto.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.dto.ts @@ -5,6 +5,11 @@ import { ApplicationDecisionComponentDto } from '../application-decision-v2/appl import { DateLabel, DateType } from './application-decision-condition-code.entity'; import { Type } from 'class-transformer'; import { ApplicationDecisionConditionDateDto } from './application-decision-condition-date/application-decision-condition-date.dto'; +import { + ApplicationDecisionConditionCardUuidDto, + ApplicationDecisionConditionHomeCardDto, +} from './application-decision-condition-card/application-decision-condition-card.dto'; +import { ApplicationTypeDto } from '../../code/application-code/application-type/application-type.dto'; export class ApplicationDecisionConditionTypeDto extends BaseCodeDto { @IsBoolean() @@ -88,6 +93,49 @@ export class ApplicationDecisionConditionDto { @AutoMap() dates: ApplicationDecisionConditionDateDto[]; + + @AutoMap(() => ApplicationDecisionConditionCardUuidDto) + conditionCard: ApplicationDecisionConditionCardUuidDto | null; + + status?: string | null; +} + +export class ApplicationHomeDto { + @AutoMap() + uuid: string; + + @AutoMap() + applicant: string; + + @AutoMap() + fileNumber: string; + + @AutoMap(() => ApplicationTypeDto) + type: ApplicationTypeDto; + + activeDays?: number; + paused: boolean; + pausedDays: number; +} + +export class ApplicationDecisionHomeDto { + @AutoMap() + uuid: string; + + @AutoMap() + application: ApplicationHomeDto; +} + +export class ApplicationDecisionConditionHomeDto { + @AutoMap(() => ApplicationDecisionConditionHomeCardDto) + conditionCard: ApplicationDecisionConditionHomeCardDto | null; + + status?: string | null; + isReconsideration: boolean; + isModification: boolean; + + @AutoMap() + decision?: ApplicationDecisionHomeDto; } export class ComponentToConditionDto { @@ -132,6 +180,10 @@ export class UpdateApplicationDecisionConditionDto { @IsOptional() @IsArray() dates?: ApplicationDecisionConditionDateDto[]; + + @IsOptional() + @IsUUID() + conditionCardUuid?: string; } export class UpdateApplicationDecisionConditionServiceDto { @@ -142,6 +194,7 @@ export class UpdateApplicationDecisionConditionServiceDto { administrativeFee?: number; description?: string; dates?: ApplicationDecisionConditionDateDto[]; + conditionCardUuid?: string; } export class ApplicationDecisionConditionComponentDto { diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.entity.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.entity.ts index 86f678d39c..455e7aca11 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.entity.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.entity.ts @@ -7,6 +7,7 @@ import { ApplicationDecisionComponent } from '../application-decision-v2/applica import { ApplicationDecision } from '../application-decision.entity'; import { ApplicationDecisionConditionType } from './application-decision-condition-code.entity'; import { ApplicationDecisionConditionDate } from './application-decision-condition-date/application-decision-condition-date.entity'; +import { ApplicationDecisionConditionCard } from './application-decision-condition-card/application-decision-condition-card.entity'; @Entity({ comment: 'Fields present on the application decision conditions' }) export class ApplicationDecisionCondition extends Base { @@ -81,4 +82,9 @@ export class ApplicationDecisionCondition extends Base { @OneToMany(() => ApplicationDecisionConditionDate, (d) => d.condition, { cascade: ['insert', 'update'] }) dates: ApplicationDecisionConditionDate[]; + + @ManyToOne(() => ApplicationDecisionConditionCard, (conditionCard) => conditionCard.conditions, { + nullable: true, + }) + conditionCard: ApplicationDecisionConditionCard | null; } diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.spec.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.spec.ts index 9ccc4e8e6f..f73ebf16c4 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.spec.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.spec.ts @@ -8,27 +8,53 @@ import { ApplicationDecisionConditionType } from './application-decision-conditi import { UpdateApplicationDecisionConditionDto } from './application-decision-condition.dto'; import { ApplicationDecisionCondition } from './application-decision-condition.entity'; import { ApplicationDecisionConditionService } from './application-decision-condition.service'; +import { Mapper } from 'automapper-core'; +import { AutomapperModule } from 'automapper-nestjs'; +import { classes } from 'automapper-classes'; +import { ApplicationDecisionConditionDate } from './application-decision-condition-date/application-decision-condition-date.entity'; +import { ApplicationTimeTrackingService } from '../../application/application-time-tracking.service'; +import { ApplicationPaused } from '../../application/application-paused.entity'; +import { ApplicationModification } from '../application-modification/application-modification.entity'; +import { ApplicationReconsideration } from '../application-reconsideration/application-reconsideration.entity'; describe('ApplicationDecisionConditionService', () => { let service: ApplicationDecisionConditionService; + let timeTrackingService: ApplicationTimeTrackingService; let mockApplicationDecisionConditionRepository: DeepMocked>; let mockAppDecCondTypeRepository: DeepMocked>; + let mockApplicationModificationRepository: DeepMocked>; + let mockApplicationReconsiderationRepository: DeepMocked>; + let mockApplicationDecisionConditionComponentPlanNumber: DeepMocked< Repository >; let mockApplicationDecisionConditionToComponentLot: DeepMocked< Repository >; + let mockApplicationPausedRepository: DeepMocked>; + let mockMapper: DeepMocked; + let mockApplicationDecisionConditionDate: DeepMocked>; beforeEach(async () => { mockApplicationDecisionConditionRepository = createMock(); mockAppDecCondTypeRepository = createMock(); mockApplicationDecisionConditionComponentPlanNumber = createMock(); mockApplicationDecisionConditionToComponentLot = createMock(); + mockMapper = createMock(); + mockApplicationDecisionConditionDate = createMock(); + mockApplicationPausedRepository = createMock(); + mockApplicationModificationRepository = createMock(); + mockApplicationReconsiderationRepository = createMock(); const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], providers: [ ApplicationDecisionConditionService, + ApplicationTimeTrackingService, { provide: getRepositoryToken(ApplicationDecisionCondition), useValue: mockApplicationDecisionConditionRepository, @@ -45,10 +71,27 @@ describe('ApplicationDecisionConditionService', () => { provide: getRepositoryToken(ApplicationDecisionConditionToComponentLot), useValue: mockApplicationDecisionConditionToComponentLot, }, + { + provide: getRepositoryToken(ApplicationPaused), + useValue: mockApplicationPausedRepository, + }, + { + provide: getRepositoryToken(ApplicationDecisionConditionDate), + useValue: mockApplicationDecisionConditionDate, + }, + { + provide: getRepositoryToken(ApplicationModification), + useValue: mockApplicationModificationRepository, + }, + { + provide: getRepositoryToken(ApplicationReconsideration), + useValue: mockApplicationReconsiderationRepository, + }, ], }).compile(); service = module.get(ApplicationDecisionConditionService); + timeTrackingService = module.get(ApplicationTimeTrackingService); }); it('should be defined', () => { diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.ts index 34ac222f79..4d06eabcb1 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-condition/application-decision-condition.service.ts @@ -1,20 +1,37 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, Repository } from 'typeorm'; import { ServiceValidationException } from '../../../../../../libs/common/src/exceptions/base.exception'; import { ApplicationDecisionConditionToComponentLot } from '../application-condition-to-component-lot/application-decision-condition-to-component-lot.entity'; import { ApplicationDecisionConditionComponentPlanNumber } from '../application-decision-component-to-condition/application-decision-component-to-condition-plan-number.entity'; import { ApplicationDecisionComponent } from '../application-decision-v2/application-decision/component/application-decision-component.entity'; import { ApplicationDecisionConditionType } from './application-decision-condition-code.entity'; import { + ApplicationDecisionConditionHomeDto, + ApplicationDecisionHomeDto, + ApplicationHomeDto, UpdateApplicationDecisionConditionDto, UpdateApplicationDecisionConditionServiceDto, } from './application-decision-condition.dto'; import { ApplicationDecisionCondition } from './application-decision-condition.entity'; import { ApplicationDecisionConditionDate } from './application-decision-condition-date/application-decision-condition-date.entity'; +import { InjectMapper } from 'automapper-nestjs'; +import { Mapper } from 'automapper-core'; +import { ApplicationTimeTrackingService } from '../../application/application-time-tracking.service'; +import { ApplicationDecision } from '../application-decision.entity'; +import { Application } from '../../application/application.entity'; +import { ApplicationModification } from '../application-modification/application-modification.entity'; +import { ApplicationReconsideration } from '../application-reconsideration/application-reconsideration.entity'; @Injectable() export class ApplicationDecisionConditionService { + private CARD_RELATIONS = { + board: true, + type: true, + status: true, + assignee: true, + }; + constructor( @InjectRepository(ApplicationDecisionCondition) private repository: Repository, @@ -24,6 +41,12 @@ export class ApplicationDecisionConditionService { private conditionComponentPlanNumbersRepository: Repository, @InjectRepository(ApplicationDecisionConditionToComponentLot) private conditionComponentLotRepository: Repository, + @InjectRepository(ApplicationModification) + private modificationRepository: Repository, + @InjectRepository(ApplicationReconsideration) + private reconsiderationRepository: Repository, + @InjectMapper() private mapper: Mapper, + private applicationTimeTrackingService: ApplicationTimeTrackingService, ) {} async getByTypeCode(typeCode: string): Promise { @@ -47,6 +70,124 @@ export class ApplicationDecisionConditionService { }); } + async findByUuids(uuids: string[]): Promise { + return this.repository.find({ + where: { + uuid: In(uuids), + }, + }); + } + + getBy(findOptions: FindOptionsWhere) { + return this.repository.find({ + where: findOptions, + relations: { + decision: { + reconsiders: true, + modifies: true, + application: { + decisionMeetings: true, + type: true, + }, + }, + conditionCard: { + card: this.CARD_RELATIONS, + }, + }, + }); + } + + async mapToDtos(conditions: ApplicationDecisionCondition[]): Promise { + const appTimeMap = await this.applicationTimeTrackingService.fetchActiveTimes( + conditions.map((c) => c.decision.application), + ); + const appPausedMap = await this.applicationTimeTrackingService.getPausedStatus( + conditions.map((c) => c.decision.application), + ); + const c = Promise.all( + conditions.map(async (c) => { + const condition = this.mapper.map(c, ApplicationDecisionCondition, ApplicationDecisionConditionHomeDto); + const decision = this.mapper.map(c.decision, ApplicationDecision, ApplicationDecisionHomeDto); + const application = this.mapper.map(c.decision.application, Application, ApplicationHomeDto); + const appModifications = await this.modificationRepository.find({ + where: { + modifiesDecisions: { + uuid: c.decision?.uuid, + }, + }, + }); + const appReconsiderations = await this.reconsiderationRepository.find({ + where: { + reconsidersDecisions: { + uuid: c.decision?.uuid, + }, + }, + }); + + return { + ...condition, + isModification: appModifications.length > 0, + isReconsideration: appReconsiderations.length > 0, + decision: { + ...decision, + application: { + ...application, + activeDays: undefined, + pausedDays: appTimeMap.get(application.uuid)!.pausedDays || 0, + paused: appPausedMap.get(application.uuid) || false, + }, + }, + }; + }), + ); + return (await c).reduce((res: ApplicationDecisionConditionHomeDto[], curr: ApplicationDecisionConditionHomeDto) => { + const existing = res.find((e) => e.conditionCard?.cardUuid === curr.conditionCard?.cardUuid); + if (!existing) { + res.push(curr); + } + return res; + }, []); + } + + async getWithIncompleteSubtaskByType(subtaskType: string) { + return this.repository.find({ + where: { + conditionCard: { + card: { + subtasks: { + completedAt: IsNull(), + type: { + code: subtaskType, + }, + }, + }, + }, + }, + relations: { + decision: { + reconsiders: true, + modifies: true, + application: { + decisionMeetings: true, + type: true, + }, + }, + conditionCard: { + card: { + board: true, + type: true, + status: true, + assignee: true, + subtasks: { + card: true, + type: true, + }, + }, + }, + }, + }); + } + async createOrUpdate( updateDtos: UpdateApplicationDecisionConditionDto[], allComponents: ApplicationDecisionComponent[], diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision-v2.module.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision-v2.module.ts index 66aef3127a..e06f83986f 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision-v2.module.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision-v2.module.ts @@ -50,6 +50,10 @@ import { ApplicationDecisionComponentService } from './application-decision/comp import { ApplicationDecisionConditionDateService } from '../application-decision-condition/application-decision-condition-date/application-decision-condition-date.service'; import { ApplicationDecisionConditionDate } from '../application-decision-condition/application-decision-condition-date/application-decision-condition-date.entity'; import { ApplicationDecisionConditionDateController } from '../application-decision-condition/application-decision-condition-date/application-decision-condition-date.controller'; +import { ApplicationDecisionConditionCardController } from '../application-decision-condition/application-decision-condition-card/application-decision-condition-card.controller'; +import { ApplicationDecisionConditionCard } from '../application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; +import { ApplicationDecisionConditionCardService } from '../application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { User } from 'apps/alcs/src/user/user.entity'; @Module({ imports: [ @@ -78,6 +82,8 @@ import { ApplicationDecisionConditionDateController } from '../application-decis ApplicationDecisionConditionToComponentLot, ApplicationDecisionConditionComponentPlanNumber, ApplicationBoundaryAmendment, + ApplicationDecisionConditionCard, + User, ]), forwardRef(() => BoardModule), forwardRef(() => ApplicationModule), @@ -98,6 +104,7 @@ import { ApplicationDecisionConditionDateController } from '../application-decis ApplicationDecisionComponentLotService, ApplicationConditionToComponentLotService, ApplicationBoundaryAmendmentService, + ApplicationDecisionConditionCardService, ], controllers: [ ApplicationDecisionV2Controller, @@ -109,11 +116,13 @@ import { ApplicationDecisionConditionDateController } from '../application-decis ApplicationBoundaryAmendmentController, ApplicationReconsiderationController, ApplicationModificationController, + ApplicationDecisionConditionCardController, ], exports: [ ApplicationDecisionV2Service, ApplicationModificationService, ApplicationReconsiderationService, + ApplicationDecisionConditionCardService, ], }) export class ApplicationDecisionV2Module {} diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.controller.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.controller.ts index 96602a9857..c45a20d62a 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.controller.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.controller.ts @@ -1,4 +1,16 @@ -import { BadRequestException, Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiOAuth2 } from '@nestjs/swagger'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; @@ -30,6 +42,10 @@ import { ApplicationDecisionComponentType } from './component/application-decisi import { ApplicationDecisionComponentTypeDto } from './component/application-decision-component.dto'; import { ApplicationConditionStatus } from './application-condition-status.dto'; +export enum IncludeQueryParam { + CONDITION_STATUS = 'conditionStatus', +} + @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @Controller('application-decision') @UseGuards(RolesGuard) @@ -44,7 +60,7 @@ export class ApplicationDecisionV2Controller { @Get('/application/:fileNumber') @UserRoles(...ANY_AUTH_ROLE) - async getByFileNumber(@Param('fileNumber') fileNumber): Promise { + async getByFileNumber(@Param('fileNumber') fileNumber: string): Promise { const decisions = await this.appDecisionService.getByAppFileNumber(fileNumber); return await this.mapper.mapArrayAsync(decisions, ApplicationDecision, ApplicationDecisionDto); } @@ -55,7 +71,7 @@ export class ApplicationDecisionV2Controller { const status = await this.appDecisionService.getDecisionConditionStatus(uuid); return { uuid: uuid, - status: status && status.length > 0 ? status[0]['get_current_status_for_application_condition'] : '', + status: status, }; } @@ -95,10 +111,22 @@ export class ApplicationDecisionV2Controller { @Get('/:uuid') @UserRoles(...ANY_AUTH_ROLE) - async get(@Param('uuid') uuid: string): Promise { + async get( + @Param('uuid') uuid: string, + @Query('include') include?: IncludeQueryParam, + ): Promise { const decision = await this.appDecisionService.get(uuid); - return this.mapper.mapAsync(decision, ApplicationDecision, ApplicationDecisionDto); + const decisionDto = await this.mapper.mapAsync(decision, ApplicationDecision, ApplicationDecisionDto); + + if (include === IncludeQueryParam.CONDITION_STATUS) { + for (const condition of decisionDto.conditions!) { + const status = await this.appDecisionService.getDecisionConditionStatus(condition.uuid); + condition.status = status !== '' ? status : undefined; + } + } + + return decisionDto; } @Post() diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.spec.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.spec.ts index 9e59866ceb..6d513f2bd0 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.spec.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.spec.ts @@ -1,17 +1,11 @@ -import { - ServiceNotFoundException, - ServiceValidationException, -} from '@app/common/exceptions/base.exception'; +import { ServiceNotFoundException, ServiceValidationException } from '@app/common/exceptions/base.exception'; import { classes } from 'automapper-classes'; import { AutomapperModule } from 'automapper-nestjs'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; -import { - initApplicationDecisionMock, - initApplicationMockEntity, -} from '../../../../../test/mocks/mockEntities'; +import { initApplicationDecisionMock, initApplicationMockEntity } from '../../../../../test/mocks/mockEntities'; import { DocumentService } from '../../../../document/document.service'; import { NaruSubtype } from '../../../../portal/application-submission/naru-subtype/naru-subtype.entity'; import { ApplicationSubmissionStatusService } from '../../../application/application-submission-status/application-submission-status.service'; @@ -26,39 +20,32 @@ import { ApplicationDecisionMakerCode } from '../../application-decision-maker/a import { ApplicationDecisionOutcomeCode } from '../../application-decision-outcome.entity'; import { ApplicationDecision } from '../../application-decision.entity'; import { ApplicationDecisionV2Service } from './application-decision-v2.service'; -import { - CreateApplicationDecisionDto, - UpdateApplicationDecisionDto, -} from './application-decision.dto'; +import { CreateApplicationDecisionDto, UpdateApplicationDecisionDto } from './application-decision.dto'; import { ApplicationDecisionComponentType } from './component/application-decision-component-type.entity'; import { ApplicationDecisionComponentDto } from './component/application-decision-component.dto'; import { ApplicationDecisionComponent } from './component/application-decision-component.entity'; import { ApplicationDecisionComponentService } from './component/application-decision-component.service'; +import { ApplicationDecisionConditionCardService } from '../../application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { User } from '../../../../user/user.entity'; +import { ApplicationDecisionConditionDateService } from '../../application-decision-condition/application-decision-condition-date/application-decision-condition-date.service'; describe('ApplicationDecisionV2Service', () => { let service: ApplicationDecisionV2Service; let mockDecisionRepository: DeepMocked>; - let mockDecisionDocumentRepository: DeepMocked< - Repository - >; - let mockDecisionOutcomeRepository: DeepMocked< - Repository - >; - let mockDecisionMakerCodeRepository: DeepMocked< - Repository - >; - let mockCeoCriterionCodeRepository: DeepMocked< - Repository - >; + let mockDecisionDocumentRepository: DeepMocked>; + let mockDecisionOutcomeRepository: DeepMocked>; + let mockDecisionMakerCodeRepository: DeepMocked>; + let mockCeoCriterionCodeRepository: DeepMocked>; let mockApplicationService: DeepMocked; let mockDocumentService: DeepMocked; - let mockApplicationDecisionComponentTypeRepository: DeepMocked< - Repository - >; + let mockApplicationDecisionComponentTypeRepository: DeepMocked>; let mockDecisionComponentService: DeepMocked; let mockDecisionConditionService: DeepMocked; let mockNaruSubtypeRepository: DeepMocked>; + let mockUserRepository: DeepMocked>; let mockApplicationSubmissionStatusService: DeepMocked; + let mockApplicationDecisionConditionCardService: DeepMocked; + let mockApplicationDecisionConditionDateService: DeepMocked; let mockdataSource: DeepMocked; let mockApplication; @@ -68,21 +55,19 @@ describe('ApplicationDecisionV2Service', () => { mockApplicationService = createMock(); mockDocumentService = createMock(); mockDecisionRepository = createMock>(); - mockDecisionDocumentRepository = - createMock>(); - mockDecisionOutcomeRepository = - createMock>(); - mockDecisionMakerCodeRepository = - createMock>(); - mockCeoCriterionCodeRepository = - createMock>(); + mockDecisionDocumentRepository = createMock>(); + mockDecisionOutcomeRepository = createMock>(); + mockDecisionMakerCodeRepository = createMock>(); + mockCeoCriterionCodeRepository = createMock>(); + mockUserRepository = createMock>(); mockApplicationDecisionComponentTypeRepository = createMock(); mockDecisionComponentService = createMock(); mockDecisionConditionService = createMock(); mockNaruSubtypeRepository = createMock(); mockApplicationSubmissionStatusService = createMock(); + mockApplicationDecisionConditionCardService = createMock(); + mockApplicationDecisionConditionDateService = createMock(); mockdataSource = createMock(); - const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -116,6 +101,10 @@ describe('ApplicationDecisionV2Service', () => { provide: getRepositoryToken(NaruSubtype), useValue: mockNaruSubtypeRepository, }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, { provide: ApplicationService, useValue: mockApplicationService, @@ -144,6 +133,14 @@ describe('ApplicationDecisionV2Service', () => { provide: ApplicationSubmissionStatusService, useValue: mockApplicationSubmissionStatusService, }, + { + provide: ApplicationDecisionConditionCardService, + useValue: mockApplicationDecisionConditionCardService, + }, + { + provide: ApplicationDecisionConditionDateService, + useValue: mockApplicationDecisionConditionDateService, + }, { provide: DataSource, useValue: mockdataSource, @@ -151,9 +148,7 @@ describe('ApplicationDecisionV2Service', () => { ], }).compile(); - service = module.get( - ApplicationDecisionV2Service, - ); + service = module.get(ApplicationDecisionV2Service); mockApplication = initApplicationMockEntity(); mockDecision = initApplicationDecisionMock(mockApplication); @@ -181,9 +176,7 @@ describe('ApplicationDecisionV2Service', () => { mockDecisionConditionService.remove.mockResolvedValue({} as any); - mockApplicationSubmissionStatusService.setStatusDateByFileNumber.mockResolvedValue( - {} as any, - ); + mockApplicationSubmissionStatusService.setStatusDateByFileNumber.mockResolvedValue({} as any); }); describe('ApplicationDecisionService Core Tests', () => { @@ -192,9 +185,7 @@ describe('ApplicationDecisionV2Service', () => { }); it('should get decisions by application', async () => { - const result = await service.getByAppFileNumber( - mockApplication.fileNumber, - ); + const result = await service.getByAppFileNumber(mockApplication.fileNumber); expect(result).toStrictEqual([mockDecision]); }); @@ -218,23 +209,14 @@ describe('ApplicationDecisionV2Service', () => { await service.delete(mockDecision.uuid); expect(mockDecisionRepository.save.mock.calls[0][0].modifies).toBeNull(); - expect( - mockDecisionRepository.save.mock.calls[0][0].reconsiders, - ).toBeNull(); + expect(mockDecisionRepository.save.mock.calls[0][0].reconsiders).toBeNull(); expect(mockDecisionRepository.softRemove).toBeCalledTimes(1); expect(mockApplicationService.updateByUuid).toHaveBeenCalledTimes(1); - expect(mockApplicationService.updateByUuid).toHaveBeenCalledWith( - mockApplication.uuid, - { - decisionDate: null, - }, - ); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledWith( + expect(mockApplicationService.updateByUuid).toHaveBeenCalledWith(mockApplication.uuid, { + decisionDate: null, + }); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).toBeCalledWith( mockApplication.fileNumber, SUBMISSION_STATUS.ALC_DECISION, null, @@ -256,12 +238,7 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, } as CreateApplicationDecisionDto; - await service.create( - decisionToCreate, - mockApplication, - undefined, - undefined, - ); + await service.create(decisionToCreate, mockApplication, undefined, undefined); expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockApplicationService.update).toHaveBeenCalledTimes(0); @@ -300,33 +277,21 @@ describe('ApplicationDecisionV2Service', () => { decisionToCopy: 'existing-decision-uuid', }; - await service.create( - decisionToCreate, - mockApplication, - undefined, - undefined, - 'mock-uuid', - ); + await service.create(decisionToCreate, mockApplication, undefined, undefined, 'mock-uuid'); expect(mockDecisionRepository.save).toBeCalledTimes(3); expect(mockApplicationService.update).toHaveBeenCalledTimes(0); const finalDecision = mockDecisionRepository.save.mock.calls[2][0]; - expect(finalDecision.decisionMakerCode).toEqual( - decision.decisionMakerCode, - ); + expect(finalDecision.decisionMakerCode).toEqual(decision.decisionMakerCode); expect(finalDecision.outcomeCode).toEqual(decision.outcomeCode); - expect(finalDecision.isSubjectToConditions).toEqual( - decision.isSubjectToConditions, - ); + expect(finalDecision.isSubjectToConditions).toEqual(decision.isSubjectToConditions); expect(finalDecision.components?.length).toEqual(1); expect(finalDecision.conditions?.length).toEqual(1); }); it('should fail create a decision if the resolution number is already in use', async () => { - mockDecisionRepository.findOne.mockResolvedValue( - {} as ApplicationDecision, - ); + mockDecisionRepository.findOne.mockResolvedValue({} as ApplicationDecision); mockDecisionRepository.exist.mockResolvedValueOnce(false); const decisionDate = new Date(2022, 2, 2, 2, 2, 2, 2); @@ -339,9 +304,7 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, } as CreateApplicationDecisionDto; - await expect( - service.create(decisionToCreate, mockApplication, undefined, undefined), - ).rejects.toMatchObject( + await expect(service.create(decisionToCreate, mockApplication, undefined, undefined)).rejects.toMatchObject( new ServiceValidationException( `Resolution number #${decisionToCreate.resolutionNumber}/${decisionToCreate.resolutionYear} is already in use`, ), @@ -367,12 +330,8 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, } as CreateApplicationDecisionDto; - await expect( - service.create(decisionToCreate, mockApplication, undefined, undefined), - ).rejects.toMatchObject( - new ServiceValidationException( - 'Draft decision already exists for this application.', - ), + await expect(service.create(decisionToCreate, mockApplication, undefined, undefined)).rejects.toMatchObject( + new ServiceValidationException('Draft decision already exists for this application.'), ); expect(mockDecisionRepository.save).toBeCalledTimes(0); @@ -392,18 +351,11 @@ describe('ApplicationDecisionV2Service', () => { outcomeCode: 'Outcome', } as CreateApplicationDecisionDto; - await service.create( - decisionToCreate, - mockApplication, - undefined, - undefined, - ); + await service.create(decisionToCreate, mockApplication, undefined, undefined); expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockApplicationService.update).not.toHaveBeenCalled(); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).not.toHaveBeenCalled(); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).not.toHaveBeenCalled(); }); it('should update the decision and update the application and submission status if it was the only decision', async () => { @@ -431,28 +383,16 @@ describe('ApplicationDecisionV2Service', () => { mockDecisionRepository.find.mockResolvedValue([createdDecision]); - await service.update( - mockDecision.uuid, - decisionUpdate, - undefined, - undefined, - ); + await service.update(mockDecision.uuid, decisionUpdate, undefined, undefined); expect(mockDecisionRepository.findOne).toBeCalledTimes(2); expect(mockDecisionRepository.save).toHaveBeenCalledTimes(1); expect(mockApplicationService.updateByUuid).toHaveBeenCalledTimes(1); - expect(mockApplicationService.updateByUuid).toHaveBeenCalledWith( - mockApplication.uuid, - { - decisionDate, - }, - ); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledWith( + expect(mockApplicationService.updateByUuid).toHaveBeenCalledWith(mockApplication.uuid, { + decisionDate, + }); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).toBeCalledWith( mockApplication.fileNumber, SUBMISSION_STATUS.ALC_DECISION, decisionDate, @@ -467,28 +407,16 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, }; - await service.update( - mockDecision.uuid, - decisionUpdate, - undefined, - undefined, - ); + await service.update(mockDecision.uuid, decisionUpdate, undefined, undefined); expect(mockDecisionRepository.findOne).toBeCalledTimes(2); expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockApplicationService.updateByUuid).toHaveBeenCalledTimes(1); - expect(mockApplicationService.updateByUuid).toBeCalledWith( - '1111-1111-1111-1111', - { - decisionDate: null, - }, - ); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledWith( + expect(mockApplicationService.updateByUuid).toBeCalledWith('1111-1111-1111-1111', { + decisionDate: null, + }); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).toBeCalledWith( mockApplication.fileNumber, SUBMISSION_STATUS.ALC_DECISION, null, @@ -499,10 +427,7 @@ describe('ApplicationDecisionV2Service', () => { const secondDecision = initApplicationDecisionMock(mockApplication); secondDecision.isDraft = true; secondDecision.uuid = 'second-uuid'; - mockDecisionRepository.find.mockResolvedValue([ - secondDecision, - mockDecision, - ]); + mockDecisionRepository.find.mockResolvedValue([secondDecision, mockDecision]); mockDecisionRepository.findOne.mockResolvedValue(secondDecision); const decisionUpdate: UpdateApplicationDecisionDto = { @@ -510,19 +435,12 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, }; - await service.update( - mockDecision.uuid, - decisionUpdate, - undefined, - undefined, - ); + await service.update(mockDecision.uuid, decisionUpdate, undefined, undefined); expect(mockDecisionRepository.findOne).toBeCalledTimes(2); expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockApplicationService.update).not.toHaveBeenCalled(); - expect( - mockApplicationSubmissionStatusService.setStatusDateByFileNumber, - ).not.toHaveBeenCalled(); + expect(mockApplicationSubmissionStatusService.setStatusDateByFileNumber).not.toHaveBeenCalled(); }); it('should fail on update if the decision is not found', async () => { @@ -533,17 +451,10 @@ describe('ApplicationDecisionV2Service', () => { outcomeCode: 'New Outcome', isDraft: true, }; - const promise = service.update( - nonExistantUuid, - decisionUpdate, - undefined, - undefined, - ); + const promise = service.update(nonExistantUuid, decisionUpdate, undefined, undefined); await expect(promise).rejects.toMatchObject( - new ServiceNotFoundException( - `Decision with UUID ${nonExistantUuid} not found`, - ), + new ServiceNotFoundException(`Decision with UUID ${nonExistantUuid} not found`), ); expect(mockDecisionRepository.save).toBeCalledTimes(0); }); @@ -555,17 +466,10 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, }; - const promise = service.update( - uuid, - decisionUpdate, - undefined, - undefined, - ); + const promise = service.update(uuid, decisionUpdate, undefined, undefined); await expect(promise).rejects.toMatchObject( - new ServiceValidationException( - `Cannot set ceo criterion code unless ceo the decision maker`, - ), + new ServiceValidationException(`Cannot set ceo criterion code unless ceo the decision maker`), ); expect(mockDecisionRepository.save).toBeCalledTimes(0); }); @@ -591,17 +495,10 @@ describe('ApplicationDecisionV2Service', () => { isDraft: true, }; - const promise = service.update( - uuid, - decisionUpdate, - undefined, - undefined, - ); + const promise = service.update(uuid, decisionUpdate, undefined, undefined); await expect(promise).rejects.toMatchObject( - new ServiceValidationException( - `Cannot set time extension unless ceo criterion is modification`, - ), + new ServiceValidationException(`Cannot set time extension unless ceo criterion is modification`), ); expect(mockDecisionRepository.save).toBeCalledTimes(0); }); @@ -639,9 +536,7 @@ describe('ApplicationDecisionV2Service', () => { 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( + await expect(service.attachDocument('uuid', {} as any, {} as any)).rejects.toMatchObject( new ServiceNotFoundException(`Decision with UUID uuid not found`), ); expect(mockDocumentService.create).not.toHaveBeenCalled(); @@ -651,15 +546,11 @@ describe('ApplicationDecisionV2Service', () => { mockDecisionDocumentRepository.softRemove.mockResolvedValue({} as any); await service.deleteDocument('fake-uuid'); - expect(mockDecisionDocumentRepository.softRemove).toHaveBeenCalledTimes( - 1, - ); + expect(mockDecisionDocumentRepository.softRemove).toHaveBeenCalledTimes(1); }); it('should call the repository to check if portal user can download document', async () => { - mockDecisionDocumentRepository.findOne.mockResolvedValue( - new ApplicationDecisionDocument(), - ); + mockDecisionDocumentRepository.findOne.mockResolvedValue(new ApplicationDecisionDocument()); mockDocumentService.getDownloadUrl.mockResolvedValue(''); await service.getDownloadForPortal('fake-uuid'); @@ -670,9 +561,7 @@ describe('ApplicationDecisionV2Service', () => { 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`, - ), + new ServiceNotFoundException(`Failed to find document with uuid fake-uuid`), ); expect(mockDocumentService.softRemove).not.toHaveBeenCalled(); }); @@ -697,9 +586,7 @@ describe('ApplicationDecisionV2Service', () => { 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`, - ), + new ServiceNotFoundException(`Failed to find document with uuid fake-uuid`), ); }); }); diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.ts index 90a7637a89..48c1babb94 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision-v2.service.ts @@ -1,5 +1,5 @@ import { MultipartFile } from '@fastify/multipart'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, LessThan, Repository, DataSource } from 'typeorm'; import { v4 } from 'uuid'; @@ -32,6 +32,8 @@ import { ApplicationDecisionComponentType } from './component/application-decisi import { ApplicationDecisionComponent } from './component/application-decision-component.entity'; import { ApplicationDecisionComponentService } from './component/application-decision-component.service'; import { ApplicationDecisionConditionDate } from '../../application-decision-condition/application-decision-condition-date/application-decision-condition-date.entity'; +import { ApplicationDecisionConditionDateService } from '../../application-decision-condition/application-decision-condition-date/application-decision-condition-date.service'; +import { ApplicationDecisionConditionCardService } from '../../application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; @Injectable() export class ApplicationDecisionV2Service { @@ -50,14 +52,21 @@ export class ApplicationDecisionV2Service { private decisionComponentTypeRepository: Repository, @InjectRepository(ApplicationDecisionConditionType) private decisionConditionTypeRepository: Repository, + @InjectRepository(ApplicationDecisionDocument) + private applicationDecisionDocumentRepository: Repository, @InjectRepository(NaruSubtype) private naruNaruSubtypeRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, private applicationService: ApplicationService, private documentService: DocumentService, private decisionComponentService: ApplicationDecisionComponentService, private decisionConditionService: ApplicationDecisionConditionService, + private dateService: ApplicationDecisionConditionDateService, private applicationSubmissionStatusService: ApplicationSubmissionStatusService, private dataSource: DataSource, + @Inject(forwardRef(() => ApplicationDecisionConditionCardService)) + private applicationDecisionConditionCardService: ApplicationDecisionConditionCardService, ) {} async getForPortal(fileNumber: string) { @@ -116,7 +125,9 @@ export class ApplicationDecisionV2Service { lots: true, }, dates: true, + conditionCard: true, }, + conditionCards: true, }, }); @@ -205,7 +216,9 @@ export class ApplicationDecisionV2Service { type: true, components: true, dates: true, + conditionCard: true, }, + conditionCards: true, chairReviewOutcome: true, }, }); @@ -216,6 +229,10 @@ export class ApplicationDecisionV2Service { decision.documents = decision.documents.filter((document) => !!document.document); + if (decision.conditions) { + decision.conditions.sort((a, b) => a.auditCreatedAt.getTime() - b.auditCreatedAt.getTime()); + } + return decision; } @@ -270,6 +287,32 @@ export class ApplicationDecisionV2Service { existingDecision.linkedResolutionOutcomeCode = updateDto.linkedResolutionOutcomeCode; existingDecision.emailSent = updateDto.emailSent; existingDecision.ccEmails = filterUndefined(updateDto.ccEmails, existingDecision.ccEmails); + existingDecision.isFlagged = updateDto.isFlagged; + existingDecision.reasonFlagged = updateDto.reasonFlagged; + existingDecision.followUpAt = formatIncomingDate(updateDto.followUpAt); + existingDecision.flagEditedAt = formatIncomingDate(updateDto.flagEditedAt); + + if (updateDto.flaggedByUuid !== undefined) { + existingDecision.flaggedBy = + updateDto.flaggedByUuid === null + ? null + : await this.userRepository.findOne({ + where: { + uuid: updateDto.flaggedByUuid, + }, + }); + } + + if (updateDto.flagEditedByUuid !== undefined) { + existingDecision.flagEditedBy = + updateDto.flagEditedByUuid === null + ? null + : await this.userRepository.findOne({ + where: { + uuid: updateDto.flagEditedByUuid, + }, + }); + } if (updateDto.outcomeCode) { existingDecision.outcome = await this.getOutcomeByCode(updateDto.outcomeCode); @@ -429,9 +472,7 @@ export class ApplicationDecisionV2Service { }); if (existingDecision) { - throw new ServiceValidationException( - `Resolution number #${number}/${year} is already in use`, - ); + throw new ServiceValidationException(`Resolution number #${number}/${year} is already in use`); } } @@ -439,12 +480,16 @@ export class ApplicationDecisionV2Service { const applicationDecision = await this.appDecisionRepository.findOne({ where: { uuid }, relations: { + conditions: { + dates: true, + }, outcome: true, documents: { document: true, }, application: true, components: true, + conditionCards: true, }, }); @@ -452,12 +497,22 @@ export class ApplicationDecisionV2Service { throw new ServiceNotFoundException(`Failed to find decision with uuid ${uuid}`); } + await this.decisionConditionService.remove(applicationDecision.conditions); + applicationDecision.conditions = []; + for (const document of applicationDecision.documents) { + await this.applicationDecisionDocumentRepository.softRemove(document); await this.documentService.softRemove(document.document); } await this.decisionComponentService.softRemove(applicationDecision.components); + if (applicationDecision.conditionCards && applicationDecision.conditionCards.length > 0) { + for (const conditionCard of applicationDecision.conditionCards) { + await this.applicationDecisionConditionCardService.softRemove(conditionCard); + } + } + //Clear potential links applicationDecision.reconsiders = null; applicationDecision.modifies = null; @@ -486,21 +541,16 @@ export class ApplicationDecisionV2Service { } async deleteDocument(decisionDocumentUuid: string) { - const decisionDocument = - await this.getDecisionDocumentOrFail(decisionDocumentUuid); + 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); + const decisionDocument = await this.getDecisionDocumentOrFail(decisionDocumentUuid); - return this.documentService.getDownloadUrl( - decisionDocument.document, - openInline, - ); + return this.documentService.getDownloadUrl(decisionDocument.document, openInline); } async getDownloadForPortal(decisionDocumentUuid: string) { @@ -591,35 +641,23 @@ export class ApplicationDecisionV2Service { const outcomeCode = updateDto.outcomeCode ?? existingDecision.outcomeCode; if (updateDto.decisionComponents) { if (outcomeCode && outcomeCode === 'APPR') { - this.decisionComponentService.validate( - updateDto.decisionComponents, - updateDto.isDraft, - ); + this.decisionComponentService.validate(updateDto.decisionComponents, updateDto.isDraft); } if (existingDecision.components) { const componentsToRemove = existingDecision.components.filter( - (component) => - !updateDto.decisionComponents?.some( - (componentDto) => componentDto.uuid === component.uuid, - ), + (component) => !updateDto.decisionComponents?.some((componentDto) => componentDto.uuid === component.uuid), ); await this.decisionComponentService.softRemove(componentsToRemove); } - existingDecision.components = - await this.decisionComponentService.createOrUpdate( - updateDto.decisionComponents, - false, - ); - } else if ( - updateDto.decisionComponents === null && - existingDecision.components - ) { - await this.decisionComponentService.softRemove( - existingDecision.components, + existingDecision.components = await this.decisionComponentService.createOrUpdate( + updateDto.decisionComponents, + false, ); + } else if (updateDto.decisionComponents === null && existingDecision.components) { + await this.decisionComponentService.softRemove(existingDecision.components); } } @@ -666,52 +704,31 @@ export class ApplicationDecisionV2Service { }); if (!existingDecision) { - throw new ServiceNotFoundException( - `Decision with UUID ${uuid} not found`, - ); + throw new ServiceNotFoundException(`Decision with UUID ${uuid} not found`); } return existingDecision; } - private async validateDecisionChanges( - updateData: UpdateApplicationDecisionDto, - ) { - if ( - updateData.ceoCriterionCode && - updateData.decisionMakerCode !== 'CEOP' - ) { - throw new ServiceValidationException( - 'Cannot set ceo criterion code unless ceo the decision maker', - ); + private async validateDecisionChanges(updateData: UpdateApplicationDecisionDto) { + if (updateData.ceoCriterionCode && updateData.decisionMakerCode !== 'CEOP') { + throw new ServiceValidationException('Cannot set ceo criterion code unless ceo the decision maker'); } if ( updateData.ceoCriterionCode !== 'MODI' && - (updateData.isTimeExtension === true || - updateData.isTimeExtension === false) + (updateData.isTimeExtension === true || updateData.isTimeExtension === false) ) { - throw new ServiceValidationException( - 'Cannot set time extension unless ceo criterion is modification', - ); + throw new ServiceValidationException('Cannot set time extension unless ceo criterion is modification'); } } - private async updateApplicationDecisionDates( - applicationDecision: ApplicationDecision, - ) { - const existingDecisions = await this.getByAppFileNumber( - applicationDecision.application.fileNumber, - ); - const releasedDecisions = existingDecisions.filter( - (decision) => !decision.isDraft, - ); + private async updateApplicationDecisionDates(applicationDecision: ApplicationDecision) { + const existingDecisions = await this.getByAppFileNumber(applicationDecision.application.fileNumber); + const releasedDecisions = existingDecisions.filter((decision) => !decision.isDraft); if (releasedDecisions.length === 0) { - await this.applicationService.updateByUuid( - applicationDecision.application.uuid, - { - decisionDate: null, - }, - ); + await this.applicationService.updateByUuid(applicationDecision.application.uuid, { + decisionDate: null, + }); await this.applicationSubmissionStatusService.setStatusDateByFileNumber( applicationDecision.application.fileNumber, @@ -720,21 +737,15 @@ export class ApplicationDecisionV2Service { ); } else { const decisionDate = existingDecisions[existingDecisions.length - 1].date; - await this.applicationService.updateByUuid( - applicationDecision.application.uuid, - { - decisionDate, - }, - ); + await this.applicationService.updateByUuid(applicationDecision.application.uuid, { + decisionDate, + }); await this.setDecisionReleasedStatus(decisionDate, applicationDecision); } } - private async setDecisionReleasedStatus( - decisionDate: Date | null, - applicationDecision: ApplicationDecision, - ) { + private async setDecisionReleasedStatus(decisionDate: Date | null, applicationDecision: ApplicationDecision) { await this.applicationSubmissionStatusService.setStatusDateByFileNumber( applicationDecision.application.fileNumber, SUBMISSION_STATUS.ALC_DECISION, @@ -753,9 +764,7 @@ export class ApplicationDecisionV2Service { }); if (!decisionDocument) { - throw new ServiceNotFoundException( - `Failed to find document with uuid ${decisionDocumentUuid}`, - ); + throw new ServiceNotFoundException(`Failed to find document with uuid ${decisionDocumentUuid}`); } return decisionDocument; } @@ -781,7 +790,54 @@ export class ApplicationDecisionV2Service { }); } - async getDecisionConditionStatus(uuid: string) { - return await this.dataSource.query('SELECT alcs.get_current_status_for_application_condition($1)', [uuid]); + async getDecisionConditionStatus(uuid: string): Promise { + const res = await this.dataSource.query('SELECT alcs.get_current_status_for_application_condition($1)', [uuid]); + return res.length > 0 ? res[0]['get_current_status_for_application_condition'] : ''; + } + + async getForDecisionConditionCardsByFileNumber(fileNumber: string) { + const application = await this.applicationService.getOrFail(fileNumber); + + const decisions = await this.appDecisionRepository.find({ + where: { + applicationUuid: application.uuid, + }, + order: { + createdAt: 'DESC', + }, + relations: { + conditionCards: { + card: { + board: true, + status: true, + type: true, + }, + decision: {}, + }, + }, + }); + + return decisions.flatMap((decision) => decision.conditionCards); + } + + async getDecisionOrder(fileNumber: string, decisionUuid: string): Promise { + const application = await this.applicationService.getOrFail(fileNumber); + + const decisions = await this.appDecisionRepository.find({ + where: { + applicationUuid: application.uuid, + }, + order: { + createdAt: 'ASC', + }, + }); + + const decisionOrder = decisions.findIndex((decision) => decision.uuid === decisionUuid); + + if (decisionOrder === -1) { + throw new ServiceNotFoundException(`Decision with UUID ${decisionUuid} not found`); + } + + return decisionOrder + 1; } } diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision.dto.ts b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision.dto.ts index 02b5b121d2..91851478f8 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision.dto.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision-v2/application-decision/application-decision.dto.ts @@ -1,13 +1,5 @@ import { AutoMap } from 'automapper-classes'; -import { - IsArray, - IsBoolean, - IsDate, - IsNumber, - IsOptional, - IsString, - IsUUID, -} from 'class-validator'; +import { IsArray, IsBoolean, IsDate, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; import { BaseCodeDto } from '../../../../common/dtos/base.dto'; import { ApplicationDecisionConditionDto, @@ -19,6 +11,8 @@ import { ApplicationDecisionComponentDto, UpdateApplicationDecisionComponentDto, } from './component/application-decision-component.dto'; +import { ApplicationDecisionConditionCardUuidDto } from '../../application-decision-condition/application-decision-condition-card/application-decision-condition-card.dto'; +import { UserDto } from '../../../../user/user.dto'; export class UpdateApplicationDecisionDto { @IsNumber() @@ -114,6 +108,30 @@ export class UpdateApplicationDecisionDto { @IsOptional() @IsArray() ccEmails?: string[]; + + @IsOptional() + @IsBoolean() + isFlagged?: boolean; + + @IsOptional() + @IsString() + reasonFlagged?: string | null; + + @IsOptional() + @IsNumber() + followUpAt?: number | null; + + @IsOptional() + @IsString() + flaggedByUuid?: string | null; + + @IsOptional() + @IsString() + flagEditedByUuid?: string | null; + + @IsOptional() + @IsNumber() + flagEditedAt?: number | null; } export class CreateApplicationDecisionDto extends UpdateApplicationDecisionDto { @@ -229,6 +247,27 @@ export class ApplicationDecisionDto { @AutoMap(() => [ApplicationDecisionConditionDto]) conditions?: ApplicationDecisionConditionDto[]; + + @AutoMap(() => [ApplicationDecisionConditionCardUuidDto]) + conditionCards?: ApplicationDecisionConditionCardUuidDto[]; + + @AutoMap(() => Boolean) + isFlagged: boolean; + + @AutoMap(() => String) + reasonFlagged: string | null; + + @AutoMap(() => Number) + followUpAt: number | null; + + @AutoMap(() => UserDto) + flaggedBy: UserDto | null; + + @AutoMap(() => UserDto) + flagEditedBy: UserDto | null; + + @AutoMap(() => Number) + flagEditedAt: number | null; } export class LinkedResolutionDto { diff --git a/services/apps/alcs/src/alcs/application-decision/application-decision.entity.ts b/services/apps/alcs/src/alcs/application-decision/application-decision.entity.ts index 2e152fd409..f657dfe4f7 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-decision.entity.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-decision.entity.ts @@ -21,10 +21,11 @@ import { ApplicationDecisionOutcomeCode } from './application-decision-outcome.e import { ApplicationDecisionComponent } from './application-decision-v2/application-decision/component/application-decision-component.entity'; import { ApplicationModification } from './application-modification/application-modification.entity'; import { ApplicationReconsideration } from './application-reconsideration/application-reconsideration.entity'; +import { ApplicationDecisionConditionCard } from './application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; +import { User } from '../../user/user.entity'; @Entity({ - comment: - 'Decisions saved to applications, incl. those linked to the recon/modification request', + comment: 'Decisions saved to applications, incl. those linked to the recon/modification request', }) @Index(['resolutionNumber', 'resolutionYear'], { unique: true, @@ -143,8 +144,7 @@ export class ApplicationDecision extends Base { type: 'timestamptz', nullable: false, update: false, - comment: - 'Date that indicates when decision was created. It is not editable by user.', + comment: 'Date that indicates when decision was created. It is not editable by user.', }) createdAt: Date; @@ -192,50 +192,32 @@ export class ApplicationDecision extends Base { @OneToMany(() => ApplicationDecisionDocument, (document) => document.decision) documents: ApplicationDecisionDocument[]; - @ManyToMany( - () => ApplicationReconsideration, - (reconsideration) => reconsideration.reconsidersDecisions, - ) + @ManyToMany(() => ApplicationReconsideration, (reconsideration) => reconsideration.reconsidersDecisions) reconsideredBy: ApplicationReconsideration[]; - @ManyToMany( - () => ApplicationModification, - (modification) => modification.modifiesDecisions, - ) + @ManyToMany(() => ApplicationModification, (modification) => modification.modifiesDecisions) modifiedBy: ApplicationModification[]; @AutoMap(() => ApplicationModification) - @OneToOne( - () => ApplicationModification, - (modification) => modification.resultingDecision, - { nullable: true }, - ) + @OneToOne(() => ApplicationModification, (modification) => modification.resultingDecision, { nullable: true }) @JoinColumn() modifies?: ApplicationModification | null; @AutoMap(() => ApplicationReconsideration) - @OneToOne( - () => ApplicationReconsideration, - (reconsideration) => reconsideration.resultingDecision, - { nullable: true }, - ) + @OneToOne(() => ApplicationReconsideration, (reconsideration) => reconsideration.resultingDecision, { + nullable: true, + }) @JoinColumn() reconsiders?: ApplicationReconsideration | null; @AutoMap(() => [ApplicationDecisionComponent]) - @OneToMany( - () => ApplicationDecisionComponent, - (component) => component.applicationDecision, - { cascade: ['insert', 'update'] }, - ) + @OneToMany(() => ApplicationDecisionComponent, (component) => component.applicationDecision, { + cascade: ['insert', 'update'], + }) components: ApplicationDecisionComponent[]; @AutoMap(() => [ApplicationDecisionCondition]) - @OneToMany( - () => ApplicationDecisionCondition, - (component) => component.decision, - { cascade: ['insert', 'update'] }, - ) + @OneToMany(() => ApplicationDecisionCondition, (component) => component.decision, { cascade: ['insert', 'update'] }) conditions: ApplicationDecisionCondition[]; @Column({ @@ -246,4 +228,34 @@ export class ApplicationDecision extends Base { 'This column is NOT related to any functionality in ALCS. It is only used for ETL and backtracking of imported data from OATS. It links oats.oats_alr_appl_decisions to alcs.application_decisions.', }) oatsAlrApplDecisionId: number; + + @AutoMap(() => [ApplicationDecisionConditionCard]) + @OneToMany(() => ApplicationDecisionConditionCard, (conditionCard) => conditionCard.decision, { + cascade: ['insert', 'update'], + }) + conditionCards: ApplicationDecisionConditionCard[]; + + @AutoMap() + @Column({ default: false }) + isFlagged: boolean; + + @AutoMap(() => String) + @Column({ type: 'text', nullable: true }) + reasonFlagged: string | null; + + @AutoMap(() => Date) + @Column({ type: 'timestamptz', nullable: true }) + followUpAt: Date | null; + + @AutoMap(() => User) + @ManyToOne(() => User, { nullable: true, eager: true }) + flaggedBy: User | null; + + @AutoMap(() => User) + @ManyToOne(() => User, { nullable: true, eager: true }) + flagEditedBy: User | null; + + @AutoMap(() => Date) + @Column({ type: 'timestamptz', nullable: true }) + flagEditedAt: Date | null; } diff --git a/services/apps/alcs/src/alcs/application-decision/application-modification/application-modification.service.ts b/services/apps/alcs/src/alcs/application-decision/application-modification/application-modification.service.ts index 3715a053fa..85d3afb040 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-modification/application-modification.service.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-modification/application-modification.service.ts @@ -1,15 +1,9 @@ import { ServiceNotFoundException } from '@app/common/exceptions/base.exception'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; -import { - FindOptionsRelations, - FindOptionsWhere, - IsNull, - Not, - Repository, -} from 'typeorm'; +import { FindOptionsRelations, FindOptionsWhere, IsNull, Not, Repository } from 'typeorm'; import { filterUndefined } from '../../../utils/undefined'; import { ApplicationService } from '../../application/application.service'; import { Board } from '../../board/board.entity'; @@ -32,6 +26,7 @@ export class ApplicationModificationService { @InjectMapper() private mapper: Mapper, private applicationService: ApplicationService, private applicationDecisionV2Service: ApplicationDecisionV2Service, + @Inject(forwardRef(() => CardService)) private cardService: CardService, ) {} @@ -42,19 +37,18 @@ export class ApplicationModificationService { assignee: true, }; - private BOARD_MODIFICATION_RELATIONS: FindOptionsRelations = - { - application: { - type: true, - region: true, - localGovernment: true, - decisionMeetings: true, - }, - card: { - ...this.CARD_RELATIONS, - board: false, - }, - }; + private BOARD_MODIFICATION_RELATIONS: FindOptionsRelations = { + application: { + type: true, + region: true, + localGovernment: true, + decisionMeetings: true, + }, + card: { + ...this.CARD_RELATIONS, + board: false, + }, + }; private DEFAULT_RELATIONS: FindOptionsRelations = { application: { @@ -107,14 +101,8 @@ export class ApplicationModificationService { }); } - mapToDtos( - modifications: ApplicationModification[], - ): Promise { - return this.mapper.mapArrayAsync( - modifications, - ApplicationModification, - ApplicationModificationDto, - ); + mapToDtos(modifications: ApplicationModification[]): Promise { + return this.mapper.mapArrayAsync(modifications, ApplicationModification, ApplicationModificationDto); } async create(createDto: ApplicationModificationCreateDto, board: Board) { @@ -124,28 +112,16 @@ export class ApplicationModificationService { description: createDto.description, }); - modification.card = await this.cardService.create( - CARD_TYPE.APP_MODI, - board, - false, - ); + modification.card = await this.cardService.create(CARD_TYPE.APP_MODI, board, false); modification.application = await this.getOrCreateApplication(createDto); - modification.modifiesDecisions = - await this.applicationDecisionV2Service.getMany( - createDto.modifiesDecisionUuids, - ); + modification.modifiesDecisions = await this.applicationDecisionV2Service.getMany(createDto.modifiesDecisionUuids); - const mockModifications = - await this.modificationRepository.save(modification); + const mockModifications = await this.modificationRepository.save(modification); return this.getByUuid(mockModifications.uuid); } - private async getOrCreateApplication( - createDto: ApplicationModificationCreateDto, - ) { - const existingApplication = await this.applicationService.get( - createDto.applicationFileNumber, - ); + private async getOrCreateApplication(createDto: ApplicationModificationCreateDto) { + const existingApplication = await this.applicationService.get(createDto.applicationFileNumber); if (existingApplication) { return existingApplication; @@ -178,16 +154,10 @@ export class ApplicationModificationService { modification.isTimeExtension = updateDto.isTimeExtension; } - modification.description = filterUndefined( - updateDto.description, - modification.description, - ); + modification.description = filterUndefined(updateDto.description, modification.description); if (updateDto.modifiesDecisionUuids) { - modification.modifiesDecisions = - await this.applicationDecisionV2Service.getMany( - updateDto.modifiesDecisionUuids, - ); + modification.modifiesDecisions = await this.applicationDecisionV2Service.getMany(updateDto.modifiesDecisionUuids); } await this.modificationRepository.save(modification); @@ -209,9 +179,7 @@ export class ApplicationModificationService { }); if (!modification) { - throw new ServiceNotFoundException( - `Modification with uuid ${uuid} not found`, - ); + throw new ServiceNotFoundException(`Modification with uuid ${uuid} not found`); } return modification; @@ -259,4 +227,15 @@ export class ApplicationModificationService { }, }); } + + async getByApplicationDecisionUuid(decisionUuid: string): Promise { + return this.modificationRepository.find({ + where: { + modifiesDecisions: { + uuid: decisionUuid, + }, + }, + relations: this.DEFAULT_RELATIONS, + }); + } } diff --git a/services/apps/alcs/src/alcs/application-decision/application-reconsideration/application-reconsideration.service.ts b/services/apps/alcs/src/alcs/application-decision/application-reconsideration/application-reconsideration.service.ts index 94e0d50025..aab1a51ed2 100644 --- a/services/apps/alcs/src/alcs/application-decision/application-reconsideration/application-reconsideration.service.ts +++ b/services/apps/alcs/src/alcs/application-decision/application-reconsideration/application-reconsideration.service.ts @@ -1,16 +1,9 @@ import { ServiceNotFoundException } from '@app/common/exceptions/base.exception'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; -import { - FindOptionsRelations, - FindOptionsWhere, - In, - IsNull, - Not, - Repository, -} from 'typeorm'; +import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; import { ApplicationService } from '../../application/application.service'; import { Board } from '../../board/board.entity'; import { CARD_TYPE } from '../../card/card-type/card-type.entity'; @@ -39,6 +32,7 @@ export class ApplicationReconsiderationService { @InjectRepository(ApplicationReconsiderationType) private reconsiderationTypeRepository: Repository, private applicationService: ApplicationService, + @Inject(forwardRef(() => CardService)) private cardService: CardService, private applicationDecisionService: ApplicationDecisionV2Service, ) {} @@ -50,38 +44,36 @@ export class ApplicationReconsiderationService { assignee: true, }; - private BOARD_RECONSIDERATION_RELATIONS: FindOptionsRelations = - { - application: { - type: true, - region: true, - localGovernment: true, - decisionMeetings: true, - }, - card: { ...this.DEFAULT_CARD_RELATIONS, board: false }, + private BOARD_RECONSIDERATION_RELATIONS: FindOptionsRelations = { + application: { type: true, - }; + region: true, + localGovernment: true, + decisionMeetings: true, + }, + card: { ...this.DEFAULT_CARD_RELATIONS, board: false }, + type: true, + }; - private DEFAULT_RECONSIDERATION_RELATIONS: FindOptionsRelations = - { - application: { - type: true, - region: true, - localGovernment: true, - decisionMeetings: true, - }, - card: { - board: true, - type: true, - status: true, - assignee: true, - }, + private DEFAULT_RECONSIDERATION_RELATIONS: FindOptionsRelations = { + application: { + type: true, + region: true, + localGovernment: true, + decisionMeetings: true, + }, + card: { + board: true, type: true, - reconsidersDecisions: true, - resultingDecision: true, - reviewOutcome: true, - decisionOutcome: true, - }; + status: true, + assignee: true, + }, + type: true, + reconsidersDecisions: true, + resultingDecision: true, + reviewOutcome: true, + decisionOutcome: true, + }; getByBoard(boardUuid: string) { return this.reconsiderationRepository.find({ @@ -116,14 +108,8 @@ export class ApplicationReconsiderationService { }); } - mapToDtos( - reconsiderations: ApplicationReconsideration[], - ): Promise { - return this.mapper.mapArrayAsync( - reconsiderations, - ApplicationReconsideration, - ApplicationReconsiderationDto, - ); + mapToDtos(reconsiderations: ApplicationReconsideration[]): Promise { + return this.mapper.mapArrayAsync(reconsiderations, ApplicationReconsideration, ApplicationReconsiderationDto); } async create(createDto: ApplicationReconsiderationCreateDto, board: Board) { @@ -138,18 +124,13 @@ export class ApplicationReconsiderationService { type, }); - reconsideration.card = await this.cardService.create( - CARD_TYPE.RECON, - board, - false, - ); + reconsideration.card = await this.cardService.create(CARD_TYPE.RECON, board, false); reconsideration.application = await this.getOrCreateApplication(createDto); - reconsideration.reconsidersDecisions = - await this.applicationDecisionService.getMany( - createDto.reconsideredDecisionUuids, - ); + reconsideration.reconsidersDecisions = await this.applicationDecisionService.getMany( + createDto.reconsideredDecisionUuids, + ); if (createDto.reconTypeCode === RECONSIDERATION_TYPE.T_33) { reconsideration.reviewOutcomeCode = 'PEN'; @@ -159,12 +140,8 @@ export class ApplicationReconsiderationService { return this.getByUuid(recon.uuid); } - private async getOrCreateApplication( - createDto: ApplicationReconsiderationCreateDto, - ) { - const existingApplication = await this.applicationService.get( - createDto.applicationFileNumber, - ); + private async getOrCreateApplication(createDto: ApplicationReconsiderationCreateDto) { + const existingApplication = await this.applicationService.get(createDto.applicationFileNumber); if (existingApplication) { return existingApplication; @@ -186,9 +163,7 @@ export class ApplicationReconsiderationService { const reconsideration = await this.fetchAndValidateReconsideration(uuid); if (updateDto.reviewDate !== undefined) { - reconsideration.reviewDate = updateDto.reviewDate - ? new Date(updateDto.reviewDate) - : null; + reconsideration.reviewDate = updateDto.reviewDate ? new Date(updateDto.reviewDate) : null; } if (updateDto.submittedDate) { @@ -196,18 +171,13 @@ export class ApplicationReconsiderationService { } if (updateDto.typeCode) { - reconsideration.type = await this.getReconsiderationType( - updateDto.typeCode, - ); + reconsideration.type = await this.getReconsiderationType(updateDto.typeCode); } reconsideration.reviewOutcomeCode = updateDto.reviewOutcomeCode; reconsideration.decisionOutcomeCode = updateDto.decisionOutcomeCode; - if ( - reconsideration.type.code === RECONSIDERATION_TYPE.T_33 && - updateDto.reviewOutcomeCode === null - ) { + if (reconsideration.type.code === RECONSIDERATION_TYPE.T_33 && updateDto.reviewOutcomeCode === null) { reconsideration.reviewOutcomeCode = 'PEN'; } @@ -217,10 +187,9 @@ export class ApplicationReconsiderationService { } if (updateDto.reconsideredDecisionUuids) { - reconsideration.reconsidersDecisions = - await this.applicationDecisionService.getMany( - updateDto.reconsideredDecisionUuids, - ); + reconsideration.reconsidersDecisions = await this.applicationDecisionService.getMany( + updateDto.reconsideredDecisionUuids, + ); } reconsideration.description = updateDto.description; @@ -251,9 +220,7 @@ export class ApplicationReconsiderationService { }); if (!recon) { - throw new ServiceNotFoundException( - `Reconsideration with uuid ${uuid} not found`, - ); + throw new ServiceNotFoundException(`Reconsideration with uuid ${uuid} not found`); } return recon; @@ -306,16 +273,10 @@ export class ApplicationReconsiderationService { const codes = await this.reconsiderationTypeRepository.find({ order: { label: 'ASC' }, }); - return this.mapper.mapArrayAsync( - codes, - ApplicationReconsiderationType, - ReconsiderationTypeDto, - ); + return this.mapper.mapArrayAsync(codes, ApplicationReconsiderationType, ReconsiderationTypeDto); } - async getReconsiderationType( - code: string, - ): Promise { + async getReconsiderationType(code: string): Promise { return this.reconsiderationTypeRepository.findOneByOrFail({ code, }); @@ -329,4 +290,15 @@ export class ApplicationReconsiderationService { relations: this.DEFAULT_RECONSIDERATION_RELATIONS, }); } + + async getByApplicationDecisionUuid(decisionUuid: string): Promise { + return this.reconsiderationRepository.find({ + where: { + reconsidersDecisions: { + uuid: decisionUuid, + }, + }, + relations: this.DEFAULT_RECONSIDERATION_RELATIONS, + }); + } } diff --git a/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.spec.ts b/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.spec.ts index 30cb06f555..aa9b63e10a 100644 --- a/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.spec.ts +++ b/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.spec.ts @@ -3,22 +3,19 @@ import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { - initApplicationDecisionMeetingMock, - initApplicationMockEntity, -} from '../../../../test/mocks/mockEntities'; +import { initApplicationDecisionMeetingMock, initApplicationMockEntity } from '../../../../test/mocks/mockEntities'; import { ApplicationSubmissionStatusService } from '../application-submission-status/application-submission-status.service'; import { SUBMISSION_STATUS } from '../application-submission-status/submission-status.dto'; import { ApplicationSubmissionToSubmissionStatus } from '../application-submission-status/submission-status.entity'; import { ApplicationService } from '../application.service'; import { ApplicationDecisionMeeting } from './application-decision-meeting.entity'; import { ApplicationDecisionMeetingService } from './application-decision-meeting.service'; +import { ApplicationDecisionConditionCard } from '../../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; describe('ApplicationDecisionMeetingService', () => { let service: ApplicationDecisionMeetingService; - let mockAppDecisionMeetingRepository: DeepMocked< - Repository - >; + let mockAppDecisionMeetingRepository: DeepMocked>; + let mockAppDecisionConditionCardRepository: DeepMocked>; let mockApplicationService: DeepMocked; let mockApplicationSubmissionStatusService: DeepMocked; @@ -28,8 +25,8 @@ describe('ApplicationDecisionMeetingService', () => { beforeEach(async () => { mockApplicationService = createMock(); - mockAppDecisionMeetingRepository = - createMock>(); + mockAppDecisionMeetingRepository = createMock>(); + mockAppDecisionConditionCardRepository = createMock>(); mockApplicationSubmissionStatusService = createMock(); const module: TestingModule = await Test.createTestingModule({ @@ -39,6 +36,10 @@ describe('ApplicationDecisionMeetingService', () => { provide: getRepositoryToken(ApplicationDecisionMeeting), useValue: mockAppDecisionMeetingRepository, }, + { + provide: getRepositoryToken(ApplicationDecisionConditionCard), + useValue: mockAppDecisionConditionCardRepository, + }, { provide: ApplicationService, useValue: mockApplicationService, @@ -50,9 +51,7 @@ describe('ApplicationDecisionMeetingService', () => { ], }).compile(); - service = module.get( - ApplicationDecisionMeetingService, - ); + service = module.get(ApplicationDecisionMeetingService); mockApplication = initApplicationMockEntity(); mockMeeting = initApplicationDecisionMeetingMock(mockApplication); @@ -61,25 +60,15 @@ describe('ApplicationDecisionMeetingService', () => { submissionUuid: 'fake', }); - mockAppDecisionMeetingRepository = module.get( - getRepositoryToken(ApplicationDecisionMeeting), - ); + mockAppDecisionMeetingRepository = module.get(getRepositoryToken(ApplicationDecisionMeeting)); mockAppDecisionMeetingRepository.find.mockResolvedValue([mockMeeting]); - mockAppDecisionMeetingRepository.findOneOrFail.mockResolvedValue( - mockMeeting, - ); + mockAppDecisionMeetingRepository.findOneOrFail.mockResolvedValue(mockMeeting); mockAppDecisionMeetingRepository.findOne.mockResolvedValue(mockMeeting); - mockAppDecisionMeetingRepository.findOneOrFail.mockResolvedValue( - mockMeeting, - ); + mockAppDecisionMeetingRepository.findOneOrFail.mockResolvedValue(mockMeeting); mockApplicationService.getOrFail.mockResolvedValue(mockApplication); mockApplicationService.getByUuidOrFail.mockResolvedValue(mockApplication); - mockApplicationSubmissionStatusService.setStatusDateByFileNumber.mockResolvedValue( - {} as any, - ); - mockApplicationSubmissionStatusService.getStatusesByFileNumber.mockResolvedValue( - [mockSubmissionStatus], - ); + mockApplicationSubmissionStatusService.setStatusDateByFileNumber.mockResolvedValue({} as any); + mockApplicationSubmissionStatusService.getStatusesByFileNumber.mockResolvedValue([mockSubmissionStatus]); }); it('should be defined', () => { @@ -124,15 +113,9 @@ describe('ApplicationDecisionMeetingService', () => { expect(mockAppDecisionMeetingRepository.findOne).toBeCalledTimes(0); expect(mockAppDecisionMeetingRepository.save).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.getStatusesByFileNumber, - ).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.getStatusesByFileNumber, - ).toBeCalledWith(mockApplication.fileNumber); - expect( - mockApplicationSubmissionStatusService.setStatusDate, - ).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.getStatusesByFileNumber).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.getStatusesByFileNumber).toBeCalledWith(mockApplication.fileNumber); + expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledTimes(1); expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledWith( mockSubmissionStatus.submissionUuid, SUBMISSION_STATUS.IN_REVIEW_BY_ALC, @@ -155,15 +138,9 @@ describe('ApplicationDecisionMeetingService', () => { where: { uuid: meetingToUpdate.uuid }, }); expect(mockAppDecisionMeetingRepository.save).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.getStatusesByFileNumber, - ).toBeCalledTimes(1); - expect( - mockApplicationSubmissionStatusService.getStatusesByFileNumber, - ).toBeCalledWith(mockApplication.fileNumber); - expect( - mockApplicationSubmissionStatusService.setStatusDate, - ).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.getStatusesByFileNumber).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.getStatusesByFileNumber).toBeCalledWith(mockApplication.fileNumber); + expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledTimes(1); expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledWith( mockSubmissionStatus.submissionUuid, SUBMISSION_STATUS.IN_REVIEW_BY_ALC, @@ -179,9 +156,7 @@ describe('ApplicationDecisionMeetingService', () => { expect(mockAppDecisionMeetingRepository.save).toBeCalledTimes(0); await expect(service.createOrUpdate(meetingToUpdate)).rejects.toMatchObject( - new ServiceNotFoundException( - `Decision meeting not found ${meetingToUpdate.uuid}`, - ), + new ServiceNotFoundException(`Decision meeting not found ${meetingToUpdate.uuid}`), ); }); }); diff --git a/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts b/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts index a3a8c32022..28a1c70f4c 100644 --- a/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts +++ b/services/apps/alcs/src/alcs/application/application-decision-meeting/application-decision-meeting.service.ts @@ -7,6 +7,7 @@ import { SUBMISSION_STATUS } from '../application-submission-status/submission-s import { ApplicationService } from '../application.service'; import { CARD_STATUS } from '../../card/card-status/card-status.entity'; import { ApplicationDecisionMeeting } from './application-decision-meeting.entity'; +import { ApplicationDecisionConditionCard } from '../../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; @Injectable() export class ApplicationDecisionMeetingService { @@ -15,6 +16,8 @@ export class ApplicationDecisionMeetingService { constructor( @InjectRepository(ApplicationDecisionMeeting) private appDecisionMeetingRepository: Repository, + @InjectRepository(ApplicationDecisionConditionCard) + private applicationDecisionConditionCardRepository: Repository, private applicationService: ApplicationService, private applicationSubmissionStatusService: ApplicationSubmissionStatusService, ) {} @@ -176,4 +179,33 @@ export class ApplicationDecisionMeetingService { .groupBy('application.uuid') .getRawMany(); } + + async getUpcomingApplicationDecisionConditionCards(): Promise< + { uuid: string; next_meeting: string; condition_card_uuid: string }[] + > { + return await this.applicationDecisionConditionCardRepository + .createQueryBuilder('conditionCard') + .select('application.uuid', 'uuid') + .addSelect('conditionCard.uuid', 'condition_card_uuid') + .addSelect( + ` + CASE + WHEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE AT TIME ZONE 'America/Vancouver') THEN meeting.date END) is NOT NULL + THEN MIN(CASE WHEN meeting.date >= (CURRENT_DATE AT TIME ZONE 'America/Vancouver') THEN meeting.date END) + ELSE MAX(CASE WHEN meeting.date < (CURRENT_DATE AT TIME ZONE 'America/Vancouver') THEN meeting.date END) + END + `, + 'next_meeting', + ) + .innerJoin('conditionCard.card', 'card') + .innerJoin('conditionCard.decision', 'decision') + .innerJoin('decision.application', 'application') + .innerJoin('application.decisionMeetings', 'meeting') + .where('card.status_code NOT IN (:...values)', { + values: [CARD_STATUS.DECISION_RELEASED, CARD_STATUS.CANCELLED], + }) + .groupBy('application.uuid') + .addGroupBy('conditionCard.uuid') + .getRawMany(); + } } diff --git a/services/apps/alcs/src/alcs/application/application-document/application-document.service.spec.ts b/services/apps/alcs/src/alcs/application/application-document/application-document.service.spec.ts index 590e597272..358d5c1d7c 100644 --- a/services/apps/alcs/src/alcs/application/application-document/application-document.service.spec.ts +++ b/services/apps/alcs/src/alcs/application/application-document/application-document.service.spec.ts @@ -287,9 +287,17 @@ describe('ApplicationDocumentService', () => { }); it('should create a record for external documents', async () => { - mockRepository.save.mockResolvedValue(new ApplicationDocument()); + mockRepository.save.mockResolvedValue( + new ApplicationDocument({ + document: new Document(), + }), + ); mockApplicationService.getUuid.mockResolvedValueOnce('app-uuid'); - mockRepository.findOne.mockResolvedValue(new ApplicationDocument()); + mockRepository.findOne.mockResolvedValue( + new ApplicationDocument({ + document: new Document(), + }), + ); const res = await service.attachExternalDocument( '', diff --git a/services/apps/alcs/src/alcs/application/application-document/application-document.service.ts b/services/apps/alcs/src/alcs/application/application-document/application-document.service.ts index 8674d59f98..995c80087f 100644 --- a/services/apps/alcs/src/alcs/application/application-document/application-document.service.ts +++ b/services/apps/alcs/src/alcs/application/application-document/application-document.service.ts @@ -137,7 +137,7 @@ export class ApplicationDocumentService { }, relations: this.DEFAULT_RELATIONS, }); - if (!document) { + if (!document || !document.document) { throw new NotFoundException(`Failed to find document ${uuid}`); } return document; @@ -158,15 +158,17 @@ export class ApplicationDocumentService { if (visibilityFlags) { where.visibilityFlags = ArrayOverlap(visibilityFlags); } - return this.applicationDocumentRepository.find({ - where, - order: { - document: { - uploadedAt: 'DESC', + return ( + await this.applicationDocumentRepository.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, }, - }, - relations: this.DEFAULT_RELATIONS, - }); + relations: this.DEFAULT_RELATIONS, + }) + ).filter((document) => document.document); } async getInlineUrl(document: ApplicationDocument) { diff --git a/services/apps/alcs/src/alcs/application/application-tag/application-tag.controller.ts b/services/apps/alcs/src/alcs/application/application-tag/application-tag.controller.ts index c8d508e500..9d22779048 100644 --- a/services/apps/alcs/src/alcs/application/application-tag/application-tag.controller.ts +++ b/services/apps/alcs/src/alcs/application/application-tag/application-tag.controller.ts @@ -16,7 +16,7 @@ import * as config from 'config'; import { ApplicationTagService } from './application-tag.service'; import { ApplicationTagDto } from './application-tag.dto'; import { UserRoles } from '../../../common/authorization/roles.decorator'; -import { ROLES_ALLOWED_APPLICATIONS } from '../../../common/authorization/roles'; +import { ANY_AUTH_ROLE, ROLES_ALLOWED_APPLICATIONS } from '../../../common/authorization/roles'; @Controller('application/:fileNumber/tag') @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @@ -25,7 +25,7 @@ export class ApplicationTagController { constructor(private service: ApplicationTagService) {} @Get('') - @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + @UserRoles(...ANY_AUTH_ROLE) async getApplicationTags(@Param('fileNumber') fileNumber: string) { return await this.service.getApplicationTags(fileNumber); } diff --git a/services/apps/alcs/src/alcs/application/application.module.ts b/services/apps/alcs/src/alcs/application/application.module.ts index d610e59ef2..41e988bda9 100644 --- a/services/apps/alcs/src/alcs/application/application.module.ts +++ b/services/apps/alcs/src/alcs/application/application.module.ts @@ -48,6 +48,7 @@ import { ApplicationService } from './application.service'; import { ApplicationTagService } from './application-tag/application-tag.service'; import { ApplicationTagController } from './application-tag/application-tag.controller'; import { TagModule } from '../tag/tag.module'; +import { ApplicationDecisionConditionCard } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; @Module({ imports: [ @@ -68,6 +69,7 @@ import { TagModule } from '../tag/tag.module'; LocalGovernment, CovenantTransferee, ApplicationOwner, + ApplicationDecisionConditionCard, ]), MessageModule, DocumentModule, diff --git a/services/apps/alcs/src/alcs/board/board.controller.spec.ts b/services/apps/alcs/src/alcs/board/board.controller.spec.ts index a249173413..a8ca66be8a 100644 --- a/services/apps/alcs/src/alcs/board/board.controller.spec.ts +++ b/services/apps/alcs/src/alcs/board/board.controller.spec.ts @@ -20,6 +20,8 @@ import { BoardController } from './board.controller'; import { BOARD_CODES } from './board.dto'; import { Board } from './board.entity'; import { BoardService } from './board.service'; +import { ApplicationDecisionConditionCardService } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; describe('BoardController', () => { let controller: BoardController; @@ -34,6 +36,8 @@ describe('BoardController', () => { let noiModificationService: DeepMocked; let notificationService: DeepMocked; let inquiryService: DeepMocked; + let applicationDecisionConditionCardService: DeepMocked; + let noticeOfIntentDecisionConditionCardService: DeepMocked; let mockBoard; beforeEach(async () => { @@ -47,6 +51,8 @@ describe('BoardController', () => { noiModificationService = createMock(); notificationService = createMock(); inquiryService = createMock(); + applicationDecisionConditionCardService = createMock(); + noticeOfIntentDecisionConditionCardService = createMock(); mockBoard = new Board({ allowedCardTypes: [], @@ -72,6 +78,10 @@ describe('BoardController', () => { notificationService.mapToDtos.mockResolvedValue([]); inquiryService.getByBoard.mockResolvedValue([]); inquiryService.mapToDtos.mockResolvedValue([]); + applicationDecisionConditionCardService.getByBoard.mockResolvedValue([]); + applicationDecisionConditionCardService.mapToBoardDtos.mockResolvedValue([]); + noticeOfIntentDecisionConditionCardService.getByBoard.mockResolvedValue([]); + noticeOfIntentDecisionConditionCardService.mapToBoardDtos.mockResolvedValue([]); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -111,6 +121,14 @@ describe('BoardController', () => { provide: InquiryService, useValue: inquiryService, }, + { + provide: ApplicationDecisionConditionCardService, + useValue: applicationDecisionConditionCardService, + }, + { + provide: NoticeOfIntentDecisionConditionCardService, + useValue: noticeOfIntentDecisionConditionCardService, + }, { provide: ClsService, useValue: {}, @@ -153,6 +171,8 @@ describe('BoardController', () => { expect(modificationService.mapToDtos).toHaveBeenCalledTimes(1); expect(planningReferralService.getByBoard).toHaveBeenCalledTimes(0); expect(planningReferralService.mapToDtos).toHaveBeenCalledTimes(1); + expect(applicationDecisionConditionCardService.getByBoard).toHaveBeenCalledTimes(0); + expect(noticeOfIntentDecisionConditionCardService.getByBoard).toHaveBeenCalledTimes(0); }); it('should call through to planning review service if board supports planning reviews', async () => { diff --git a/services/apps/alcs/src/alcs/board/board.controller.ts b/services/apps/alcs/src/alcs/board/board.controller.ts index 448b438cc0..d1643f7c50 100644 --- a/services/apps/alcs/src/alcs/board/board.controller.ts +++ b/services/apps/alcs/src/alcs/board/board.controller.ts @@ -4,10 +4,7 @@ import { ApiOAuth2 } from '@nestjs/swagger'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; import * as config from 'config'; -import { - ANY_AUTH_ROLE, - ROLES_ALLOWED_BOARDS, -} from '../../common/authorization/roles'; +import { ANY_AUTH_ROLE, ROLES_ALLOWED_BOARDS } from '../../common/authorization/roles'; import { RolesGuard } from '../../common/authorization/roles-guard.service'; import { UserRoles } from '../../common/authorization/roles.decorator'; import { ApplicationModificationService } from '../application-decision/application-modification/application-modification.service'; @@ -24,6 +21,8 @@ import { PlanningReferralService } from '../planning-review/planning-referral/pl import { BoardDto, MinimalBoardDto } from './board.dto'; import { Board } from './board.entity'; import { BoardService } from './board.service'; +import { ApplicationDecisionConditionCardService } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @Controller('board') @@ -40,6 +39,8 @@ export class BoardController { private noticeOfIntentService: NoticeOfIntentService, private notificationService: NotificationService, private inquiryService: InquiryService, + private applicationDecisionConditionCardService: ApplicationDecisionConditionCardService, + private noticeOfIntentDecisionConditionCardService: NoticeOfIntentDecisionConditionCardService, @InjectMapper() private autoMapper: Mapper, ) {} @@ -97,23 +98,31 @@ export class BoardController { ? await this.notificationService.getByBoard(board.uuid) : []; - const inquiries = allowedCodes.includes(CARD_TYPE.INQUIRY) - ? await this.inquiryService.getByBoard(board.uuid) + const inquiries = allowedCodes.includes(CARD_TYPE.INQUIRY) ? await this.inquiryService.getByBoard(board.uuid) : []; + + const applicationDecisionConditions = allowedCodes.includes(CARD_TYPE.APP_CON) + ? await this.applicationDecisionConditionCardService.getByBoard(board.uuid) + : []; + + const noticeOfIntentDecisionConditions = allowedCodes.includes(CARD_TYPE.NOI_CON) + ? await this.noticeOfIntentDecisionConditionCardService.getByBoard(board.uuid) : []; return { board: await this.autoMapper.mapAsync(board, Board, BoardDto), applications: await this.applicationService.mapToDtos(applications), reconsiderations: await this.reconsiderationService.mapToDtos(recons), - planningReferrals: - await this.planningReferralService.mapToDtos(planningReferrals), + planningReferrals: await this.planningReferralService.mapToDtos(planningReferrals), modifications: await this.appModificationService.mapToDtos(modifications), - noticeOfIntents: - await this.noticeOfIntentService.mapToDtos(noticeOfIntents), - noiModifications: - await this.noiModificationService.mapToDtos(noiModifications), + noticeOfIntents: await this.noticeOfIntentService.mapToDtos(noticeOfIntents), + noiModifications: await this.noiModificationService.mapToDtos(noiModifications), notifications: await this.notificationService.mapToDtos(notifications), inquiries: await this.inquiryService.mapToDtos(inquiries), + applicationDecisionConditions: + await this.applicationDecisionConditionCardService.mapToBoardDtos(applicationDecisionConditions), + noticeOfIntentDecisionConditions: await this.noticeOfIntentDecisionConditionCardService.mapToBoardDtos( + noticeOfIntentDecisionConditions, + ), }; } diff --git a/services/apps/alcs/src/alcs/board/board.service.ts b/services/apps/alcs/src/alcs/board/board.service.ts index f5dc165def..6afa27ef26 100644 --- a/services/apps/alcs/src/alcs/board/board.service.ts +++ b/services/apps/alcs/src/alcs/board/board.service.ts @@ -1,5 +1,5 @@ import { ServiceNotFoundException, ServiceValidationException } from '@app/common/exceptions/base.exception'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations'; @@ -24,6 +24,7 @@ export class BoardService { private boardRepository: Repository, @InjectRepository(BoardStatus) private boardStatusRepository: Repository, + @Inject(forwardRef(() => CardService)) private cardService: CardService, ) {} @@ -159,4 +160,24 @@ export class BoardService { await this.boardStatusRepository.save(newStatuses); } + + async getApplicationDecisionConditionBoard() { + const board = await this.boardRepository.findOne({ where: { code: 'appcon' }, relations: ['statuses'] }); + + if (!board) { + throw new ServiceNotFoundException('Application Condition Board not found'); + } + + return board; + } + + async getNoticeOfIntentDecisionConditionBoard() { + const board = await this.boardRepository.findOne({ where: { code: 'noicon' }, relations: ['statuses'] }); + + if (!board) { + throw new ServiceNotFoundException('NOI Condition Board not found'); + } + + return board; + } } diff --git a/services/apps/alcs/src/alcs/card/card-subtask/card-subtask.dto.ts b/services/apps/alcs/src/alcs/card/card-subtask/card-subtask.dto.ts index 99c2dc11d8..6fa7562ea7 100644 --- a/services/apps/alcs/src/alcs/card/card-subtask/card-subtask.dto.ts +++ b/services/apps/alcs/src/alcs/card/card-subtask/card-subtask.dto.ts @@ -64,6 +64,9 @@ export class HomepageSubtaskDTO extends CardSubtaskDto { activeDays?: number; paused: boolean; subtaskDays?: number; + isCondition: boolean; + isConditionRecon: boolean; + isConditionModi: boolean; } export enum CARD_SUBTASK_TYPE { diff --git a/services/apps/alcs/src/alcs/card/card-type/card-type.entity.ts b/services/apps/alcs/src/alcs/card/card-type/card-type.entity.ts index fce2922919..60919179bb 100644 --- a/services/apps/alcs/src/alcs/card/card-type/card-type.entity.ts +++ b/services/apps/alcs/src/alcs/card/card-type/card-type.entity.ts @@ -12,6 +12,8 @@ export enum CARD_TYPE { NOI_MODI = 'NOIM', NOTIFICATION = 'NOTI', INQUIRY = 'INQR', + APP_CON = 'APPCON', + NOI_CON = 'NOICON', } @Entity({ diff --git a/services/apps/alcs/src/alcs/card/card.module.ts b/services/apps/alcs/src/alcs/card/card.module.ts index 027116f491..a4fbe67d35 100644 --- a/services/apps/alcs/src/alcs/card/card.module.ts +++ b/services/apps/alcs/src/alcs/card/card.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CodeModule } from '../code/code.module'; import { CardProfile } from '../../common/automapper/card.automapper.profile'; @@ -15,6 +15,9 @@ import { CardType } from './card-type/card-type.entity'; import { CardController } from './card.controller'; import { Card } from './card.entity'; import { CardService } from './card.service'; +import { ApplicationDecisionCondition } from '../application-decision/application-decision-condition/application-decision-condition.entity'; +import { ApplicationDecisionModule } from '../application-decision/application-decision.module'; +import { NoticeOfIntentDecisionModule } from '../notice-of-intent-decision/notice-of-intent-decision.module'; @Module({ imports: [ @@ -25,18 +28,15 @@ import { CardService } from './card.service'; CardSubtaskType, CardSubtask, CardHistory, + ApplicationDecisionCondition, ]), CodeModule, MessageModule, + forwardRef(() => ApplicationDecisionModule), + forwardRef(() => NoticeOfIntentDecisionModule), ], controllers: [CardSubtaskController, CardController], - providers: [ - CardStatusService, - CardService, - CardSubtaskService, - CardSubscriber, - CardProfile, - ], + providers: [CardStatusService, CardService, CardSubtaskService, CardSubscriber, CardProfile], exports: [CardStatusService, CardService, CardSubtaskService], }) export class CardModule {} diff --git a/services/apps/alcs/src/alcs/card/card.service.spec.ts b/services/apps/alcs/src/alcs/card/card.service.spec.ts index 4969138dab..e2b90bb490 100644 --- a/services/apps/alcs/src/alcs/card/card.service.spec.ts +++ b/services/apps/alcs/src/alcs/card/card.service.spec.ts @@ -7,10 +7,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import * as config from 'config'; import { Repository } from 'typeorm'; -import { - initBoardMockEntity, - initCardMockEntity, -} from '../../../test/mocks/mockEntities'; +import { initBoardMockEntity, initCardMockEntity } from '../../../test/mocks/mockEntities'; import { User } from '../../user/user.entity'; import { Board } from '../board/board.entity'; import { MessageService } from '../message/message.service'; @@ -20,21 +17,30 @@ import { CARD_TYPE, CardType } from './card-type/card-type.entity'; import { CardUpdateServiceDto } from './card.dto'; import { Card } from './card.entity'; import { CardService } from './card.service'; +import { ApplicationDecisionCondition } from '../application-decision/application-decision-condition/application-decision-condition.entity'; +import { ApplicationDecisionConditionCardService } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; describe('CardService', () => { let service: CardService; let cardRepositoryMock: DeepMocked>; let cardTypeRepositoryMock: DeepMocked>; + let applicationConditionRepositoryMock: DeepMocked>; let mockCardEntity; let mockSubtaskService: DeepMocked; let mockNotificationService: DeepMocked; + let mockApplicationDecisionConditionCardService: DeepMocked; + let mockNoticeOfIntentDecisionConditionCardService: DeepMocked; beforeEach(async () => { cardRepositoryMock = createMock>(); cardTypeRepositoryMock = createMock>(); + applicationConditionRepositoryMock = createMock>(); mockCardEntity = initCardMockEntity(); mockSubtaskService = createMock(); mockNotificationService = createMock(); + mockApplicationDecisionConditionCardService = createMock(); + mockNoticeOfIntentDecisionConditionCardService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -52,6 +58,10 @@ describe('CardService', () => { provide: getRepositoryToken(CardType), useValue: cardTypeRepositoryMock, }, + { + provide: getRepositoryToken(ApplicationDecisionCondition), + useValue: applicationConditionRepositoryMock, + }, { provide: CONFIG_TOKEN, useValue: config, @@ -64,6 +74,14 @@ describe('CardService', () => { provide: MessageService, useValue: mockNotificationService, }, + { + provide: ApplicationDecisionConditionCardService, + useValue: mockApplicationDecisionConditionCardService, + }, + { + provide: NoticeOfIntentDecisionConditionCardService, + useValue: mockNoticeOfIntentDecisionConditionCardService, + }, ], }).compile(); @@ -71,6 +89,8 @@ describe('CardService', () => { cardRepositoryMock.findOne.mockResolvedValue(mockCardEntity); cardRepositoryMock.save.mockResolvedValue(mockCardEntity); + applicationConditionRepositoryMock.save.mockResolvedValue(new ApplicationDecisionCondition()); + applicationConditionRepositoryMock.find.mockResolvedValue([new ApplicationDecisionCondition()]); cardTypeRepositoryMock = module.get(getRepositoryToken(CardType)); }); @@ -89,11 +109,7 @@ describe('CardService', () => { boardUuid: mockCardEntity.boardUuid, }; - const result = await service.update( - new User(), - mockCardEntity.uuid, - payload, - ); + const result = await service.update(new User(), mockCardEntity.uuid, payload); expect(result).toStrictEqual(mockCardEntity); expect(cardRepositoryMock.save).toHaveBeenCalledTimes(1); expect(cardRepositoryMock.save).toHaveBeenCalledWith(mockCardEntity); @@ -108,12 +124,8 @@ describe('CardService', () => { cardRepositoryMock.findOne.mockResolvedValue(null); - await expect( - service.update(new User(), mockCardEntity.uuid, payload), - ).rejects.toMatchObject( - new ServiceValidationException( - `Card for with ${mockCardEntity.uuid} not found`, - ), + await expect(service.update(new User(), mockCardEntity.uuid, payload)).rejects.toMatchObject( + new ServiceValidationException(`Card for with ${mockCardEntity.uuid} not found`), ); expect(cardRepositoryMock.save).toBeCalledTimes(0); }); @@ -145,9 +157,7 @@ describe('CardService', () => { cardTypeRepositoryMock.findOne.mockResolvedValue(null); await expect(service.create(fakeType, board)).rejects.toMatchObject( - new ServiceValidationException( - `Provided type does not exist ${fakeType}`, - ), + new ServiceValidationException(`Provided type does not exist ${fakeType}`), ); expect(cardRepositoryMock.save).toBeCalledTimes(0); @@ -219,15 +229,10 @@ describe('CardService', () => { expect(mockNotificationService.create).toHaveBeenCalledTimes(1); - const createNotificationServiceDto = - mockNotificationService.create.mock.calls[0][0]; + const createNotificationServiceDto = mockNotificationService.create.mock.calls[0][0]; expect(createNotificationServiceDto.actor).toStrictEqual(fakeAuthor); - expect(createNotificationServiceDto.receiverUuid).toStrictEqual( - mockUserUuid, - ); - expect(createNotificationServiceDto.title).toStrictEqual( - "You've been assigned", - ); + expect(createNotificationServiceDto.receiverUuid).toStrictEqual(mockUserUuid); + expect(createNotificationServiceDto.title).toStrictEqual("You've been assigned"); expect(createNotificationServiceDto.targetType).toStrictEqual('card'); }); diff --git a/services/apps/alcs/src/alcs/card/card.service.ts b/services/apps/alcs/src/alcs/card/card.service.ts index e9edac6480..808eac0022 100644 --- a/services/apps/alcs/src/alcs/card/card.service.ts +++ b/services/apps/alcs/src/alcs/card/card.service.ts @@ -1,8 +1,8 @@ import { CONFIG_TOKEN } from '@app/common/config/config.module'; import { ServiceValidationException } from '@app/common/exceptions/base.exception'; -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Mapper } from 'automapper-core'; +import { condition, Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; import { IConfig } from 'config'; import { FindOptionsRelations, Not, Repository } from 'typeorm'; @@ -13,6 +13,9 @@ import { CardSubtaskService } from './card-subtask/card-subtask.service'; import { CARD_TYPE, CardType } from './card-type/card-type.entity'; import { CardDetailedDto, CardDto, CardUpdateServiceDto } from './card.dto'; import { Card } from './card.entity'; +import { ApplicationDecisionCondition } from '../application-decision/application-decision-condition/application-decision-condition.entity'; +import { ApplicationDecisionConditionCardService } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; @Injectable() export class CardService { @@ -32,6 +35,10 @@ export class CardService { @Inject(CONFIG_TOKEN) private config: IConfig, private subtaskService: CardSubtaskService, private notificationService: MessageService, + @Inject(forwardRef(() => ApplicationDecisionConditionCardService)) + private applicationDecisionConditionCardService: ApplicationDecisionConditionCardService, + @Inject(forwardRef(() => NoticeOfIntentDecisionConditionCardService)) + private noticeOfIntentDecisionConditionCardService: NoticeOfIntentDecisionConditionCardService, ) {} async getCardTypes() { @@ -108,9 +115,7 @@ export class CardService { }); if (!existingCard) { - throw new ServiceValidationException( - `Card for with ${cardUuid} not found`, - ); + throw new ServiceValidationException(`Card for with ${cardUuid} not found`); } const shouldCreateNotification = @@ -144,15 +149,11 @@ export class CardService { }); if (!type) { - throw new ServiceValidationException( - `Provided type does not exist ${typeCode}`, - ); + throw new ServiceValidationException(`Provided type does not exist ${typeCode}`); } const newCard = new Card(); - newCard.statusCode = board.statuses.reduce((prev, curr) => - prev.order < curr.order ? prev : curr, - )?.status.code; + newCard.statusCode = board.statuses.reduce((prev, curr) => (prev.order < curr.order ? prev : curr))?.status.code; newCard.typeCode = typeCode; newCard.boardUuid = board.uuid; @@ -191,6 +192,14 @@ export class CardService { const subtaskUuids = card.subtasks.map((subtask) => subtask.uuid); await this.subtaskService.deleteMany(subtaskUuids); + if (card.typeCode === CARD_TYPE.APP_CON) { + await this.applicationDecisionConditionCardService.archiveByBoardCard(card.uuid); + } + + if (card.typeCode === CARD_TYPE.NOI_CON) { + await this.noticeOfIntentDecisionConditionCardService.archiveByBoardCard(card.uuid); + } + card.archived = true; await this.cardRepository.save(card); await this.cardRepository.softRemove(card); @@ -215,6 +224,14 @@ export class CardService { const subtaskUuids = card.subtasks.map((subtask) => subtask.uuid); await this.subtaskService.recoverMany(subtaskUuids); + if (card.typeCode === CARD_TYPE.APP_CON) { + await this.applicationDecisionConditionCardService.recoverByBoardCard(card.uuid); + } + + if (card.typeCode === CARD_TYPE.NOI_CON) { + await this.noticeOfIntentDecisionConditionCardService.recoverByBoardCard(card.uuid); + } + card.archived = false; await this.cardRepository.save(card); await this.cardRepository.recover(card); @@ -226,4 +243,11 @@ export class CardService { relations: this.DEFAULT_RELATIONS, }); } + + async softRemoveByUuid(uuid: string) { + const card = await this.cardRepository.findOneOrFail({ + where: { uuid }, + }); + await this.cardRepository.softRemove(card); + } } diff --git a/services/apps/alcs/src/alcs/home/home.controller.spec.ts b/services/apps/alcs/src/alcs/home/home.controller.spec.ts index 5bb2a8a4c8..faa4feb93e 100644 --- a/services/apps/alcs/src/alcs/home/home.controller.spec.ts +++ b/services/apps/alcs/src/alcs/home/home.controller.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { classes } from 'automapper-classes'; import { AutomapperModule } from 'automapper-nestjs'; import { ClsService } from 'nestjs-cls'; -import { In, Not } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { initApplicationMockEntity, initApplicationModificationMockEntity, @@ -37,6 +37,11 @@ import { NotificationService } from '../notification/notification.service'; import { PlanningReferralService } from '../planning-review/planning-referral/planning-referral.service'; import { HomeController } from './home.controller'; import { HolidayService } from '../admin/holiday/holiday.service'; +import { ApplicationDecisionConditionService } from '../application-decision/application-decision-condition/application-decision-condition.service'; +import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service'; +import { ApplicationModification } from '../application-decision/application-modification/application-modification.entity'; +import { ApplicationReconsideration } from '../application-decision/application-reconsideration/application-reconsideration.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; describe('HomeController', () => { let controller: HomeController; @@ -51,6 +56,11 @@ describe('HomeController', () => { let mockPlanningReferralService: DeepMocked; let mockInquiryService: DeepMocked; let mockHolidayService: DeepMocked; + let mockApplicationDecisionConditionService: DeepMocked; + let mockNoticeOfIntentDecisionConditionService: DeepMocked; + let mockModificationApplicationRepository: DeepMocked>; + let mockReconsiderationApplicationRepository: DeepMocked>; + let mockModificationNoticeOfIntentRepository: DeepMocked>; beforeEach(async () => { mockApplicationService = createMock(); @@ -64,6 +74,11 @@ describe('HomeController', () => { mockPlanningReferralService = createMock(); mockInquiryService = createMock(); mockHolidayService = createMock(); + mockApplicationDecisionConditionService = createMock(); + mockNoticeOfIntentDecisionConditionService = createMock(); + mockModificationApplicationRepository = createMock(); + mockReconsiderationApplicationRepository = createMock(); + mockModificationNoticeOfIntentRepository = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -125,6 +140,26 @@ describe('HomeController', () => { provide: HolidayService, useValue: mockHolidayService, }, + { + provide: ApplicationDecisionConditionService, + useValue: mockApplicationDecisionConditionService, + }, + { + provide: NoticeOfIntentDecisionConditionService, + useValue: mockNoticeOfIntentDecisionConditionService, + }, + { + provide: getRepositoryToken(ApplicationModification), + useValue: mockModificationApplicationRepository, + }, + { + provide: getRepositoryToken(ApplicationReconsideration), + useValue: mockReconsiderationApplicationRepository, + }, + { + provide: getRepositoryToken(NoticeOfIntentModification), + useValue: mockModificationNoticeOfIntentRepository, + }, ApplicationProfile, ApplicationSubtaskProfile, UserProfile, @@ -153,35 +188,25 @@ describe('HomeController', () => { mockPlanningReferralService.mapToDtos.mockResolvedValue([]); mockInquiryService.getBy.mockResolvedValue([]); mockInquiryService.mapToDtos.mockResolvedValue([]); + mockApplicationDecisionConditionService.getBy.mockResolvedValue([]); + mockApplicationDecisionConditionService.mapToDtos.mockResolvedValue([]); + mockNoticeOfIntentDecisionConditionService.getBy.mockResolvedValue([]); + mockNoticeOfIntentDecisionConditionService.mapToDtos.mockResolvedValue([]); mockNoticeOfIntentService.getTimes.mockResolvedValue(new Map()); - mockApplicationTimeTrackingService.fetchActiveTimes.mockResolvedValue( - new Map(), - ); - mockApplicationTimeTrackingService.getPausedStatus.mockResolvedValue( - new Map(), - ); - - mockApplicationReconsiderationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); - mockApplicationModificationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); + mockApplicationTimeTrackingService.fetchActiveTimes.mockResolvedValue(new Map()); + mockApplicationTimeTrackingService.getPausedStatus.mockResolvedValue(new Map()); + + mockApplicationReconsiderationService.getWithIncompleteSubtaskByType.mockResolvedValue([]); + mockApplicationModificationService.getWithIncompleteSubtaskByType.mockResolvedValue([]); mockApplicationService.getWithIncompleteSubtaskByType.mockResolvedValue([]); - mockNoticeOfIntentService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); - mockNoticeOfIntentModificationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); - mockNotificationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); - mockPlanningReferralService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); + mockNoticeOfIntentService.getWithIncompleteSubtaskByType.mockResolvedValue([]); + mockNoticeOfIntentModificationService.getWithIncompleteSubtaskByType.mockResolvedValue([]); + mockNotificationService.getWithIncompleteSubtaskByType.mockResolvedValue([]); + mockPlanningReferralService.getWithIncompleteSubtaskByType.mockResolvedValue([]); mockInquiryService.getWithIncompleteSubtaskByType.mockResolvedValue([]); + mockApplicationDecisionConditionService.getWithIncompleteSubtaskByType.mockResolvedValue([]); + mockNoticeOfIntentDecisionConditionService.getWithIncompleteSubtaskByType.mockResolvedValue([]); }); it('should be defined', () => { @@ -202,40 +227,24 @@ describe('HomeController', () => { card: { assigneeUuid: userId, status: { - code: Not( - In([CARD_STATUS.CANCELLED, CARD_STATUS.DECISION_RELEASED]), - ), + code: Not(In([CARD_STATUS.CANCELLED, CARD_STATUS.DECISION_RELEASED])), }, }, }; expect(mockApplicationService.getMany).toHaveBeenCalledTimes(1); - expect(mockApplicationService.getMany.mock.calls[0][0]).toEqual( - filterCondition, - ); + expect(mockApplicationService.getMany.mock.calls[0][0]).toEqual(filterCondition); - expect(mockApplicationReconsiderationService.getBy).toHaveBeenCalledTimes( - 1, - ); - expect( - mockApplicationReconsiderationService.getBy.mock.calls[0][0], - ).toEqual(filterCondition); + expect(mockApplicationReconsiderationService.getBy).toHaveBeenCalledTimes(1); + expect(mockApplicationReconsiderationService.getBy.mock.calls[0][0]).toEqual(filterCondition); expect(mockNoticeOfIntentService.getBy).toHaveBeenCalledTimes(1); - expect(mockNoticeOfIntentService.getBy.mock.calls[0][0]).toEqual( - filterCondition, - ); + expect(mockNoticeOfIntentService.getBy.mock.calls[0][0]).toEqual(filterCondition); - expect(mockNoticeOfIntentModificationService.getBy).toHaveBeenCalledTimes( - 1, - ); - expect( - mockNoticeOfIntentModificationService.getBy.mock.calls[0][0], - ).toEqual(filterCondition); + expect(mockNoticeOfIntentModificationService.getBy).toHaveBeenCalledTimes(1); + expect(mockNoticeOfIntentModificationService.getBy.mock.calls[0][0]).toEqual(filterCondition); expect(mockNotificationService.getBy).toHaveBeenCalledTimes(1); - expect(mockNotificationService.getBy.mock.calls[0][0]).toEqual( - filterCondition, - ); + expect(mockNotificationService.getBy.mock.calls[0][0]).toEqual(filterCondition); }); }); @@ -243,12 +252,8 @@ describe('HomeController', () => { it('should call ApplicationService and map an Application', async () => { const mockApplication = initApplicationMockEntity(); const activeDays = 5; - mockApplicationService.getWithIncompleteSubtaskByType.mockResolvedValue([ - mockApplication, - ]); - mockApplicationTimeTrackingService.getPausedStatus.mockResolvedValue( - new Map([[mockApplication.uuid, true]]), - ); + mockApplicationService.getWithIncompleteSubtaskByType.mockResolvedValue([mockApplication]); + mockApplicationTimeTrackingService.getPausedStatus.mockResolvedValue(new Map([[mockApplication.uuid, true]])); mockApplicationTimeTrackingService.fetchActiveTimes.mockResolvedValue( new Map([ [ @@ -262,14 +267,10 @@ describe('HomeController', () => { ); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.GIS, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.GIS); expect(res.length).toEqual(1); - expect( - mockApplicationService.getWithIncompleteSubtaskByType, - ).toBeCalledTimes(1); + expect(mockApplicationService.getWithIncompleteSubtaskByType).toBeCalledTimes(1); expect(res[0].title).toContain(mockApplication.fileNumber); expect(res[0].title).toContain(mockApplication.applicant); expect(res[0].activeDays).toBe(activeDays); @@ -279,16 +280,10 @@ describe('HomeController', () => { it('should call ApplicationService and map an Application and calculate GIS subtask days', async () => { const mockApplication = initApplicationMockEntity(); - mockApplication.card!.subtasks = [ - initCardGISSubtaskMockEntity(mockApplication.card!), - ]; + mockApplication.card!.subtasks = [initCardGISSubtaskMockEntity(mockApplication.card!)]; const activeDays = 5; - mockApplicationService.getWithIncompleteSubtaskByType.mockResolvedValue([ - mockApplication, - ]); - mockApplicationTimeTrackingService.getPausedStatus.mockResolvedValue( - new Map([[mockApplication.uuid, true]]), - ); + mockApplicationService.getWithIncompleteSubtaskByType.mockResolvedValue([mockApplication]); + mockApplicationTimeTrackingService.getPausedStatus.mockResolvedValue(new Map([[mockApplication.uuid, true]])); mockApplicationTimeTrackingService.fetchActiveTimes.mockResolvedValue( new Map([ [ @@ -303,14 +298,10 @@ describe('HomeController', () => { mockHolidayService.fetchAllHolidays.mockResolvedValue([]); mockHolidayService.calculateBusinessDays.mockReturnValue(0); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.GIS, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.GIS); expect(res.length).toEqual(1); - expect( - mockApplicationService.getWithIncompleteSubtaskByType, - ).toBeCalledTimes(1); + expect(mockApplicationService.getWithIncompleteSubtaskByType).toBeCalledTimes(1); expect(res[0].title).toContain(mockApplication.fileNumber); expect(res[0].title).toContain(mockApplication.applicant); expect(res[0].activeDays).toBe(activeDays); @@ -321,22 +312,14 @@ describe('HomeController', () => { it('should call Reconsideration Service and map it', async () => { const mockReconsideration = initApplicationReconsiderationMockEntity(); - mockApplicationReconsiderationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [mockReconsideration], - ); + mockApplicationReconsiderationService.getWithIncompleteSubtaskByType.mockResolvedValue([mockReconsideration]); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.GIS, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.GIS); expect(res.length).toEqual(1); - expect( - mockApplicationReconsiderationService.getWithIncompleteSubtaskByType, - ).toBeCalledTimes(1); - expect(res[0].title).toContain( - mockReconsideration.application.fileNumber, - ); + expect(mockApplicationReconsiderationService.getWithIncompleteSubtaskByType).toBeCalledTimes(1); + expect(res[0].title).toContain(mockReconsideration.application.fileNumber); expect(res[0].title).toContain(mockReconsideration.application.applicant); expect(res[0].activeDays).toBeUndefined(); expect(res[0].paused).toBeFalsy(); @@ -371,19 +354,13 @@ describe('HomeController', () => { it('should call Modification Service and map it', async () => { const mockModification = initApplicationModificationMockEntity(); - mockApplicationModificationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [mockModification], - ); + mockApplicationModificationService.getWithIncompleteSubtaskByType.mockResolvedValue([mockModification]); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.GIS, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.GIS); expect(res.length).toEqual(1); - expect( - mockApplicationModificationService.getWithIncompleteSubtaskByType, - ).toHaveBeenCalledTimes(1); + expect(mockApplicationModificationService.getWithIncompleteSubtaskByType).toHaveBeenCalledTimes(1); expect(res[0].title).toContain(mockModification.application.fileNumber); expect(res[0].title).toContain(mockModification.application.applicant); @@ -399,9 +376,7 @@ describe('HomeController', () => { fileNumber: 'fileNumber', card: initCardMockEntity('222'), }); - mockNoticeOfIntentService.getWithIncompleteSubtaskByType.mockResolvedValue( - [mockNoi], - ); + mockNoticeOfIntentService.getWithIncompleteSubtaskByType.mockResolvedValue([mockNoi]); mockNoticeOfIntentService.getTimes.mockResolvedValue( new Map([ [ @@ -415,14 +390,10 @@ describe('HomeController', () => { ); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.PEER_REVIEW, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.PEER_REVIEW); expect(res.length).toEqual(1); - expect( - mockNoticeOfIntentService.getWithIncompleteSubtaskByType, - ).toHaveBeenCalledTimes(1); + expect(mockNoticeOfIntentService.getWithIncompleteSubtaskByType).toHaveBeenCalledTimes(1); expect(res[0].title).toContain(mockNoi.fileNumber); expect(res[0].title).toContain(mockNoi.applicant); @@ -438,26 +409,16 @@ describe('HomeController', () => { }), card: initCardMockEntity('222'), }); - mockNoticeOfIntentModificationService.getWithIncompleteSubtaskByType.mockResolvedValue( - [mockNoiModification], - ); + mockNoticeOfIntentModificationService.getWithIncompleteSubtaskByType.mockResolvedValue([mockNoiModification]); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.PEER_REVIEW, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.PEER_REVIEW); expect(res.length).toEqual(1); - expect( - mockNoticeOfIntentModificationService.getWithIncompleteSubtaskByType, - ).toHaveBeenCalledTimes(1); + expect(mockNoticeOfIntentModificationService.getWithIncompleteSubtaskByType).toHaveBeenCalledTimes(1); - expect(res[0].title).toContain( - mockNoiModification.noticeOfIntent.fileNumber, - ); - expect(res[0].title).toContain( - mockNoiModification.noticeOfIntent.applicant, - ); + expect(res[0].title).toContain(mockNoiModification.noticeOfIntent.fileNumber); + expect(res[0].title).toContain(mockNoiModification.noticeOfIntent.applicant); expect(mockHolidayService.fetchAllHolidays).toHaveBeenCalled(); }); @@ -467,19 +428,13 @@ describe('HomeController', () => { fileNumber: 'fileNumber', card: initCardMockEntity('222'), }); - mockNotificationService.getWithIncompleteSubtaskByType.mockResolvedValue([ - mockNotification, - ]); + mockNotificationService.getWithIncompleteSubtaskByType.mockResolvedValue([mockNotification]); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.PEER_REVIEW, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.PEER_REVIEW); expect(res.length).toEqual(1); - expect( - mockNotificationService.getWithIncompleteSubtaskByType, - ).toHaveBeenCalledTimes(1); + expect(mockNotificationService.getWithIncompleteSubtaskByType).toHaveBeenCalledTimes(1); expect(res[0].title).toContain(mockNotification.fileNumber); expect(res[0].title).toContain(mockNotification.applicant); @@ -492,20 +447,14 @@ describe('HomeController', () => { card: initCardMockEntity('222'), inquirerLastName: 'lastName', }); - mockInquiryService.getWithIncompleteSubtaskByType.mockResolvedValue([ - mockInquiry, - ]); + mockInquiryService.getWithIncompleteSubtaskByType.mockResolvedValue([mockInquiry]); mockHolidayService.fetchAllHolidays.mockResolvedValue([]); - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.PEER_REVIEW, - ); + const res = await controller.getIncompleteSubtasksByType(CARD_SUBTASK_TYPE.PEER_REVIEW); expect(res.length).toEqual(1); - expect( - mockInquiryService.getWithIncompleteSubtaskByType, - ).toHaveBeenCalledTimes(1); + expect(mockInquiryService.getWithIncompleteSubtaskByType).toHaveBeenCalledTimes(1); expect(res[0].title).toContain(mockInquiry.fileNumber); expect(res[0].title).toContain(mockInquiry.inquirerLastName); diff --git a/services/apps/alcs/src/alcs/home/home.controller.ts b/services/apps/alcs/src/alcs/home/home.controller.ts index 785b74f76f..cf8e141240 100644 --- a/services/apps/alcs/src/alcs/home/home.controller.ts +++ b/services/apps/alcs/src/alcs/home/home.controller.ts @@ -3,7 +3,7 @@ import { ApiOAuth2 } from '@nestjs/swagger'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; import * as config from 'config'; -import { In, Not } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { ANY_AUTH_ROLE } from '../../common/authorization/roles'; import { RolesGuard } from '../../common/authorization/roles-guard.service'; import { UserRoles } from '../../common/authorization/roles.decorator'; @@ -20,11 +20,7 @@ import { ApplicationDto } from '../application/application.dto'; import { Application } from '../application/application.entity'; import { ApplicationService } from '../application/application.service'; import { CARD_STATUS } from '../card/card-status/card-status.entity'; -import { - CARD_SUBTASK_TYPE, - HomepageSubtaskDTO, - PARENT_TYPE, -} from '../card/card-subtask/card-subtask.dto'; +import { CARD_SUBTASK_TYPE, HomepageSubtaskDTO, PARENT_TYPE } from '../card/card-subtask/card-subtask.dto'; import { CardDto } from '../card/card.dto'; import { Card } from '../card/card.entity'; import { InquiryDto } from '../inquiry/inquiry.dto'; @@ -43,11 +39,15 @@ import { PlanningReferral } from '../planning-review/planning-referral/planning- import { PlanningReferralService } from '../planning-review/planning-referral/planning-referral.service'; import { PlanningReferralDto } from '../planning-review/planning-review.dto'; import { HolidayService } from '../admin/holiday/holiday.service'; +import { ApplicationDecisionConditionHomeDto } from '../application-decision/application-decision-condition/application-decision-condition.dto'; +import { ApplicationDecisionConditionService } from '../application-decision/application-decision-condition/application-decision-condition.service'; +import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service'; +import { NoticeOfIntentDecisionConditionHomeDto } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto'; +import { ApplicationDecisionCondition } from '../application-decision/application-decision-condition/application-decision-condition.entity'; +import { NoticeOfIntentDecisionCondition } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity'; +import { InjectRepository } from '@nestjs/typeorm'; -const HIDDEN_CARD_STATUSES = [ - CARD_STATUS.CANCELLED, - CARD_STATUS.DECISION_RELEASED, -]; +const HIDDEN_CARD_STATUSES = [CARD_STATUS.CANCELLED, CARD_STATUS.DECISION_RELEASED]; @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @Controller('home') @@ -65,6 +65,14 @@ export class HomeController { private planningReferralService: PlanningReferralService, private inquiryService: InquiryService, private holidayService: HolidayService, + private applicationDecisionConditionService: ApplicationDecisionConditionService, + private noticeOfIntentDecisionConditionService: NoticeOfIntentDecisionConditionService, + @InjectRepository(ApplicationModification) + private modificationApplicationRepository: Repository, + @InjectRepository(ApplicationReconsideration) + private reconsiderationApplicationRepository: Repository, + @InjectRepository(NoticeOfIntentModification) + private modificationNoticeOfIntentRepository: Repository, ) {} @Get('/assigned') @@ -78,6 +86,8 @@ export class HomeController { modifications: ApplicationModificationDto[]; notifications: NotificationDto[]; inquiries: InquiryDto[]; + applicationsConditions: ApplicationDecisionConditionHomeDto[]; + noticeOfIntentsConditions: NoticeOfIntentDecisionConditionHomeDto[]; }> { const userId = req.user.entity.uuid; const assignedFindOptions = { @@ -88,45 +98,49 @@ export class HomeController { }, }, }; + const assignedConditionFindOptions = { + conditionCard: { + card: { + assigneeUuid: userId, + status: { + code: Not(In(HIDDEN_CARD_STATUSES)), + }, + }, + }, + }; if (userId) { - const applications = - await this.applicationService.getMany(assignedFindOptions); - const reconsiderations = - await this.reconsiderationService.getBy(assignedFindOptions); + const applications = await this.applicationService.getMany(assignedFindOptions); + const reconsiderations = await this.reconsiderationService.getBy(assignedFindOptions); - const planningReviews = - await this.planningReferralService.getBy(assignedFindOptions); + const planningReviews = await this.planningReferralService.getBy(assignedFindOptions); - const modifications = - await this.modificationService.getBy(assignedFindOptions); + const modifications = await this.modificationService.getBy(assignedFindOptions); - const noticeOfIntents = - await this.noticeOfIntentService.getBy(assignedFindOptions); + const noticeOfIntents = await this.noticeOfIntentService.getBy(assignedFindOptions); - const noticeOfIntentModifications = - await this.noticeOfIntentModificationService.getBy(assignedFindOptions); + const noticeOfIntentModifications = await this.noticeOfIntentModificationService.getBy(assignedFindOptions); - const notifications = - await this.notificationService.getBy(assignedFindOptions); + const notifications = await this.notificationService.getBy(assignedFindOptions); const inquiries = await this.inquiryService.getBy(assignedFindOptions); + const appConditions = await this.applicationDecisionConditionService.getBy(assignedConditionFindOptions); + + const noiConditions = await this.noticeOfIntentDecisionConditionService.getBy(assignedConditionFindOptions); + return { - noticeOfIntents: - await this.noticeOfIntentService.mapToDtos(noticeOfIntents), + noticeOfIntents: await this.noticeOfIntentService.mapToDtos(noticeOfIntents), noticeOfIntentModifications: - await this.noticeOfIntentModificationService.mapToDtos( - noticeOfIntentModifications, - ), + await this.noticeOfIntentModificationService.mapToDtos(noticeOfIntentModifications), applications: await this.applicationService.mapToDtos(applications), - reconsiderations: - await this.reconsiderationService.mapToDtos(reconsiderations), - planningReferrals: - await this.planningReferralService.mapToDtos(planningReviews), + reconsiderations: await this.reconsiderationService.mapToDtos(reconsiderations), + planningReferrals: await this.planningReferralService.mapToDtos(planningReviews), modifications: await this.modificationService.mapToDtos(modifications), notifications: await this.notificationService.mapToDtos(notifications), inquiries: await this.inquiryService.mapToDtos(inquiries), + applicationsConditions: await this.applicationDecisionConditionService.mapToDtos(appConditions), + noticeOfIntentsConditions: await this.noticeOfIntentDecisionConditionService.mapToDtos(noiConditions), }; } else { return { @@ -138,6 +152,8 @@ export class HomeController { modifications: [], notifications: [], inquiries: [], + applicationsConditions: [], + noticeOfIntentsConditions: [], }; } } @@ -147,71 +163,52 @@ export class HomeController { async getIncompleteSubtasksByType( @Param('subtaskType') subtaskType: CARD_SUBTASK_TYPE, ): Promise { - const applicationsWithSubtasks = - await this.applicationService.getWithIncompleteSubtaskByType(subtaskType); - const applicationSubtasks = await this.mapApplicationsToDtos( - applicationsWithSubtasks, - ); + const applicationsWithSubtasks = await this.applicationService.getWithIncompleteSubtaskByType(subtaskType); + const applicationSubtasks = await this.mapApplicationsToDtos(applicationsWithSubtasks); - const reconsiderationWithSubtasks = - await this.reconsiderationService.getWithIncompleteSubtaskByType( - subtaskType, - ); + const reconsiderationWithSubtasks = await this.reconsiderationService.getWithIncompleteSubtaskByType(subtaskType); const reconSubtasks = await this.mapReconToDto(reconsiderationWithSubtasks); const planningReferralsWithSubtasks = - await this.planningReferralService.getWithIncompleteSubtaskByType( - subtaskType, - ); - const planningReferralSubtasks = await this.mapPlanningReferralsToDtos( - planningReferralsWithSubtasks, - ); + await this.planningReferralService.getWithIncompleteSubtaskByType(subtaskType); + const planningReferralSubtasks = await this.mapPlanningReferralsToDtos(planningReferralsWithSubtasks); - const modificationsWithSubtasks = - await this.modificationService.getWithIncompleteSubtaskByType( - subtaskType, - ); - const modificationSubtasks = await this.mapModificationsToDtos( - modificationsWithSubtasks, - ); + const modificationsWithSubtasks = await this.modificationService.getWithIncompleteSubtaskByType(subtaskType); + const modificationSubtasks = await this.mapModificationsToDtos(modificationsWithSubtasks); - const noiSubtasks = - await this.noticeOfIntentService.getWithIncompleteSubtaskByType( - subtaskType, - ); - const noticeOfIntentSubtasks = - await this.mapNoticeOfIntentToDtos(noiSubtasks); + const noiSubtasks = await this.noticeOfIntentService.getWithIncompleteSubtaskByType(subtaskType); + const noticeOfIntentSubtasks = await this.mapNoticeOfIntentToDtos(noiSubtasks); const noiModificationsWithSubtasks = - await this.noticeOfIntentModificationService.getWithIncompleteSubtaskByType( - subtaskType, - ); - const noiModificationsSubtasks = await this.mapNoiModificationsToDtos( - noiModificationsWithSubtasks, - ); + await this.noticeOfIntentModificationService.getWithIncompleteSubtaskByType(subtaskType); + const noiModificationsSubtasks = await this.mapNoiModificationsToDtos(noiModificationsWithSubtasks); - const notificationsWithSubtasks = - await this.notificationService.getWithIncompleteSubtaskByType( - subtaskType, - ); + const notificationsWithSubtasks = await this.notificationService.getWithIncompleteSubtaskByType(subtaskType); - const notificationSubtasks = await this.mapNotificationsToDtos( - notificationsWithSubtasks, - ); + const notificationSubtasks = await this.mapNotificationsToDtos(notificationsWithSubtasks); + + const inquiriesWIthSubtasks = await this.inquiryService.getWithIncompleteSubtaskByType(subtaskType); + + const inquirySubtasks = await this.mapInquiriesToDtos(inquiriesWIthSubtasks); - const inquiriesWIthSubtasks = - await this.inquiryService.getWithIncompleteSubtaskByType(subtaskType); + const applicationConditionsWithSubtasks = + await this.applicationDecisionConditionService.getWithIncompleteSubtaskByType(subtaskType); + const applicationConditionsSubtasks = await this.mapApplicationConditionsToDtos(applicationConditionsWithSubtasks); - const inquirySubtasks = await this.mapInquiriesToDtos( - inquiriesWIthSubtasks, + const noticeOfIntentConditionsWithSubtasks = + await this.noticeOfIntentDecisionConditionService.getWithIncompleteSubtaskByType(subtaskType); + const noticeOfIntentConditionsSubtasks = await this.mapNoticeOfIntentConditionsToDtos( + noticeOfIntentConditionsWithSubtasks, ); return [ ...noticeOfIntentSubtasks, ...applicationSubtasks, + ...applicationConditionsSubtasks, ...reconSubtasks, ...modificationSubtasks, ...noiModificationsSubtasks, + ...noticeOfIntentConditionsSubtasks, ...planningReferralSubtasks, ...notificationSubtasks, ...inquirySubtasks, @@ -228,6 +225,9 @@ export class HomeController { for (const subtask of recon.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -240,11 +240,7 @@ export class HomeController { parentType: PARENT_TYPE.RECONSIDERATION, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -253,8 +249,7 @@ export class HomeController { } private async mapApplicationsToDtos(applications: Application[]) { - const applicationTimes = - await this.timeService.fetchActiveTimes(applications); + const applicationTimes = await this.timeService.fetchActiveTimes(applications); const appPausedMap = await this.timeService.getPausedStatus(applications); const holidays = await this.holidayService.fetchAllHolidays(); @@ -267,6 +262,9 @@ export class HomeController { application.decisionMeetings = []; for (const subtask of application.card?.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -280,11 +278,123 @@ export class HomeController { parentType: PARENT_TYPE.APPLICATION, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) + : 0, + }); + } + } + return result; + } + + private async mapApplicationConditionsToDtos(applicationConditions: ApplicationDecisionCondition[]) { + const applications = applicationConditions.map((c) => c.decision.application); + + const appPausedMap = await this.timeService.getPausedStatus(applications); + const holidays = await this.holidayService.fetchAllHolidays(); + const result: HomepageSubtaskDTO[] = []; + + const reducedConditions = applicationConditions.reduce( + (res: ApplicationDecisionCondition[], curr: ApplicationDecisionCondition) => { + const existing = res.find((e) => e.conditionCard?.cardUuid === curr.conditionCard?.cardUuid); + if (!existing) { + res.push(curr); + } + return res; + }, + [], + ); + + for (const condition of reducedConditions) { + if (!condition.conditionCard?.card) { + continue; + } + const appModifications = await this.modificationApplicationRepository.find({ + where: { + modifiesDecisions: { + uuid: condition.decision?.uuid, + }, + }, + }); + const appReconsiderations = await this.reconsiderationApplicationRepository.find({ + where: { + reconsidersDecisions: { + uuid: condition.decision?.uuid, + }, + }, + }); + for (const subtask of condition.conditionCard?.card?.subtasks) { + result.push({ + isCondition: true, + isConditionRecon: appReconsiderations.length > 0, + isConditionModi: appModifications.length > 0, + type: subtask.type, + createdAt: subtask.createdAt.getTime(), + assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), + uuid: subtask.uuid, + card: this.mapper.map(condition.conditionCard?.card, Card, CardDto), + completedAt: subtask.completedAt?.getTime(), + activeDays: undefined, + paused: appPausedMap.get(condition.decision.uuid) || false, + title: `${condition.decision.application.fileNumber} (${condition.decision.application.applicant})`, + appType: condition.decision.application.type, + parentType: PARENT_TYPE.APPLICATION, + subtaskDays: + subtask.type.code === CARD_SUBTASK_TYPE.GIS + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) + : 0, + }); + } + } + return result; + } + + private async mapNoticeOfIntentConditionsToDtos(noticeOfIntestConditions: NoticeOfIntentDecisionCondition[]) { + const noticeOfIntents = noticeOfIntestConditions.map((c) => c.decision.noticeOfIntent); + const uuids = noticeOfIntents.map((noi) => noi.uuid); + const timeMap = await this.noticeOfIntentService.getTimes(uuids); + const holidays = await this.holidayService.fetchAllHolidays(); + const result: HomepageSubtaskDTO[] = []; + + const reducedConditions = noticeOfIntestConditions.reduce( + (res: NoticeOfIntentDecisionCondition[], curr: NoticeOfIntentDecisionCondition) => { + const existing = res.find((e) => e.conditionCard?.cardUuid === curr.conditionCard?.cardUuid); + if (!existing) { + res.push(curr); + } + return res; + }, + [], + ); + + for (const condition of reducedConditions) { + if (!condition.conditionCard?.card) { + continue; + } + const noiModifications = await this.modificationNoticeOfIntentRepository.find({ + where: { + modifiesDecisions: { + uuid: condition.decision?.uuid, + }, + }, + }); + for (const subtask of condition.conditionCard?.card?.subtasks) { + result.push({ + isCondition: true, + isConditionModi: noiModifications.length > 0, + isConditionRecon: false, + activeDays: undefined, + type: subtask.type, + createdAt: subtask.createdAt.getTime(), + assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), + uuid: subtask.uuid, + card: this.mapper.map(condition.conditionCard.card, Card, CardDto), + completedAt: subtask.completedAt?.getTime(), + paused: false, + title: `${condition.decision.noticeOfIntent.fileNumber} (${condition.decision.noticeOfIntent.applicant})`, + parentType: PARENT_TYPE.NOTICE_OF_INTENT, + subtaskDays: + subtask.type.code === CARD_SUBTASK_TYPE.GIS + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -292,14 +402,15 @@ export class HomeController { return result; } - private async mapPlanningReferralsToDtos( - planningReferrals: PlanningReferral[], - ) { + private async mapPlanningReferralsToDtos(planningReferrals: PlanningReferral[]) { const result: HomepageSubtaskDTO[] = []; const holidays = await this.holidayService.fetchAllHolidays(); for (const planningReferral of planningReferrals) { for (const subtask of planningReferral.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -312,11 +423,7 @@ export class HomeController { appType: planningReferral.planningReview.type, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -336,6 +443,9 @@ export class HomeController { if (noticeOfIntent.card) { for (const subtask of noticeOfIntent.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, activeDays: activeDays ?? undefined, type: subtask.type, createdAt: subtask.createdAt.getTime(), @@ -348,11 +458,7 @@ export class HomeController { parentType: PARENT_TYPE.NOTICE_OF_INTENT, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -361,9 +467,7 @@ export class HomeController { return result; } - private async mapModificationsToDtos( - modifications: ApplicationModification[], - ) { + private async mapModificationsToDtos(modifications: ApplicationModification[]) { const result: HomepageSubtaskDTO[] = []; const holidays = await this.holidayService.fetchAllHolidays(); for (const modification of modifications) { @@ -372,6 +476,9 @@ export class HomeController { } for (const subtask of modification.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -384,11 +491,7 @@ export class HomeController { parentType: PARENT_TYPE.MODIFICATION, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -396,9 +499,7 @@ export class HomeController { return result; } - private async mapNoiModificationsToDtos( - modifications: NoticeOfIntentModification[], - ) { + private async mapNoiModificationsToDtos(modifications: NoticeOfIntentModification[]) { const result: HomepageSubtaskDTO[] = []; const holidays = await this.holidayService.fetchAllHolidays(); for (const modification of modifications) { @@ -407,6 +508,9 @@ export class HomeController { } for (const subtask of modification.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -418,11 +522,7 @@ export class HomeController { parentType: PARENT_TYPE.MODIFICATION, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -437,6 +537,9 @@ export class HomeController { if (notification.card) { for (const subtask of notification.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -449,11 +552,7 @@ export class HomeController { appType: notification.type, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } @@ -469,6 +568,9 @@ export class HomeController { if (inquiry.card) { for (const subtask of inquiry.card.subtasks) { result.push({ + isCondition: false, + isConditionModi: false, + isConditionRecon: false, type: subtask.type, createdAt: subtask.createdAt.getTime(), assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), @@ -476,18 +578,12 @@ export class HomeController { card: this.mapper.map(inquiry.card, Card, CardDto), completedAt: subtask.completedAt?.getTime(), paused: false, - title: `${inquiry.fileNumber} (${ - inquiry.inquirerLastName ?? 'Unknown' - })`, + title: `${inquiry.fileNumber} (${inquiry.inquirerLastName ?? 'Unknown'})`, parentType: PARENT_TYPE.INQUIRY, appType: inquiry.type, subtaskDays: subtask.type.code === CARD_SUBTASK_TYPE.GIS - ? this.holidayService.calculateBusinessDays( - subtask.createdAt, - new Date(), - holidays, - ) + ? this.holidayService.calculateBusinessDays(subtask.createdAt, new Date(), holidays) : 0, }); } diff --git a/services/apps/alcs/src/alcs/home/home.module.ts b/services/apps/alcs/src/alcs/home/home.module.ts index 0dc9da6ae5..2697900dc6 100644 --- a/services/apps/alcs/src/alcs/home/home.module.ts +++ b/services/apps/alcs/src/alcs/home/home.module.ts @@ -10,9 +10,35 @@ import { NotificationModule } from '../notification/notification.module'; import { PlanningReviewModule } from '../planning-review/planning-review.module'; import { HomeController } from './home.controller'; import { AdminModule } from '../admin/admin.module'; +import { CardModule } from '../card/card.module'; +import { ApplicationDecisionConditionService } from '../application-decision/application-decision-condition/application-decision-condition.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationDecisionConditionToComponentLot } from '../application-decision/application-condition-to-component-lot/application-decision-condition-to-component-lot.entity'; +import { ApplicationDecisionConditionComponentPlanNumber } from '../application-decision/application-decision-component-to-condition/application-decision-component-to-condition-plan-number.entity'; +import { ApplicationDecisionConditionType } from '../application-decision/application-decision-condition/application-decision-condition-code.entity'; +import { ApplicationDecisionCondition } from '../application-decision/application-decision-condition/application-decision-condition.entity'; +import { ApplicationDecisionConditionDate } from '../application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.entity'; +import { ApplicationModification } from '../application-decision/application-modification/application-modification.entity'; +import { ApplicationReconsideration } from '../application-decision/application-reconsideration/application-reconsideration.entity'; +import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service'; +import { NoticeOfIntentDecisionCondition } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity'; +import { NoticeOfIntentDecisionConditionType } from '../notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-code.entity'; +import { NoticeOfIntentModification } from '../notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.entity'; @Module({ imports: [ + TypeOrmModule.forFeature([ + ApplicationDecisionCondition, + ApplicationDecisionConditionType, + ApplicationDecisionConditionComponentPlanNumber, + ApplicationDecisionConditionToComponentLot, + ApplicationDecisionConditionDate, + ApplicationModification, + ApplicationReconsideration, + NoticeOfIntentDecisionCondition, + NoticeOfIntentDecisionConditionType, + NoticeOfIntentModification, + ]), ApplicationModule, UserModule, PlanningReviewModule, @@ -22,8 +48,9 @@ import { AdminModule } from '../admin/admin.module'; NotificationModule, InquiryModule, AdminModule, + CardModule, ], - providers: [ApplicationSubtaskProfile], + providers: [ApplicationSubtaskProfile, ApplicationDecisionConditionService, NoticeOfIntentDecisionConditionService], controllers: [HomeController], }) export class HomeModule {} diff --git a/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.spec.ts b/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.spec.ts index 179d0e8948..37081d1748 100644 --- a/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.spec.ts +++ b/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.spec.ts @@ -217,9 +217,17 @@ describe('InquiryDocumentService', () => { }); it('should create a record for external documents', async () => { - mockRepository.save.mockResolvedValue(new InquiryDocument()); + mockRepository.save.mockResolvedValue( + new InquiryDocument({ + document: new Document(), + }), + ); mockInquiryService.getUuid.mockResolvedValueOnce('app-uuid'); - mockRepository.findOne.mockResolvedValue(new InquiryDocument()); + mockRepository.findOne.mockResolvedValue( + new InquiryDocument({ + document: new Document(), + }), + ); const res = await service.attachExternalDocument('', { type: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, diff --git a/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.ts b/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.ts index 83da9f1d67..024c61c4d8 100644 --- a/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.ts +++ b/services/apps/alcs/src/alcs/inquiry/inquiry-document/inquiry-document.service.ts @@ -114,7 +114,7 @@ export class InquiryDocumentService { }, relations: this.DEFAULT_RELATIONS, }); - if (!document) { + if (!document || !document.document) { throw new NotFoundException(`Failed to find document ${uuid}`); } return document; @@ -132,15 +132,17 @@ export class InquiryDocumentService { fileNumber, }, }; - return this.inquiryDocumentRepository.find({ - where, - order: { - document: { - uploadedAt: 'DESC', + return ( + await this.inquiryDocumentRepository.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, }, - }, - relations: this.DEFAULT_RELATIONS, - }); + relations: this.DEFAULT_RELATIONS, + }) + ).filter((document) => document.document); } async getInlineUrl(document: InquiryDocument) { diff --git a/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.spec.ts b/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.spec.ts index b2a5d8a3d4..c2bbcc8d29 100644 --- a/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.spec.ts +++ b/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.spec.ts @@ -7,6 +7,7 @@ import { initApplicationDecisionMeetingMock, initApplicationMockEntity, initApplicationReconsiderationMockEntity, + initMockApplicationDecisionConditionCard, } from '../../../test/mocks/mockEntities'; import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; import { ApplicationDecisionProfile } from '../../common/automapper/application-decision-v2.automapper.profile'; @@ -20,11 +21,10 @@ import { Board } from '../board/board.entity'; import { PlanningReferralService } from '../planning-review/planning-referral/planning-referral.service'; import { PlanningReviewMeetingService } from '../planning-review/planning-review-meeting/planning-review-meeting.service'; import { DecisionMeetingController } from './decision-meeting.controller'; -import { - CreateApplicationDecisionMeetingDto, - DecisionMeetingDto, -} from './decision-meeting.dto'; +import { CreateApplicationDecisionMeetingDto, DecisionMeetingDto } from './decision-meeting.dto'; import { ApplicationTimeTrackingService } from '../application/application-time-tracking.service'; +import { ApplicationDecisionConditionCardService } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; +import { Any } from 'typeorm'; describe('DecisionMeetingController', () => { let controller: DecisionMeetingController; @@ -35,8 +35,11 @@ describe('DecisionMeetingController', () => { let mockPlanningReferralService: DeepMocked; let mockPlanningReviewMeetingService: DeepMocked; let mockApplicationTimeTrackingService: DeepMocked; + let mockApplicationDecisionConditionCardService: DeepMocked; let mockApplication; let mockMeeting; + let mockConditionCardApplication; + let mockConditionCard; let mockedApplicationsPausedStatuses: Map = new Map(); let mockedReconsiderationsPausedStatuses: Map = new Map(); @@ -49,6 +52,7 @@ describe('DecisionMeetingController', () => { mockEmailService = createMock(); mockPlanningReviewMeetingService = createMock(); mockApplicationTimeTrackingService = createMock(); + mockApplicationDecisionConditionCardService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -88,6 +92,10 @@ describe('DecisionMeetingController', () => { provide: ApplicationTimeTrackingService, useValue: mockApplicationTimeTrackingService, }, + { + provide: ApplicationDecisionConditionCardService, + useValue: mockApplicationDecisionConditionCardService, + }, { provide: ClsService, useValue: {}, @@ -96,17 +104,18 @@ describe('DecisionMeetingController', () => { ], }).compile(); - controller = module.get( - DecisionMeetingController, - ); + controller = module.get(DecisionMeetingController); mockApplication = initApplicationMockEntity(); mockMeeting = initApplicationDecisionMeetingMock(mockApplication); + mockConditionCardApplication = initApplicationMockEntity('10000', '1111-1111-1111-1112'); + mockConditionCard = initMockApplicationDecisionConditionCard(mockConditionCardApplication); mockMeetingService.createOrUpdate.mockResolvedValue(mockMeeting); mockMeetingService.getByAppFileNumber.mockResolvedValue([mockMeeting]); mockMeetingService.get.mockResolvedValue(mockMeeting); mockMeetingService.getUpcomingReconsiderationMeetings.mockResolvedValue([]); mockMeetingService.getUpcomingApplicationMeetings.mockResolvedValue([]); + mockMeetingService.getUpcomingApplicationDecisionConditionCards.mockResolvedValue([]); mockPlanningReviewMeetingService.getUpcomingMeetings.mockResolvedValue([]); mockPlanningReferralService.getManyByPlanningReview.mockResolvedValue([]); }); @@ -174,9 +183,8 @@ describe('DecisionMeetingController', () => { it('should load and map application meetings', async () => { mockApplicationService.getMany.mockResolvedValue([mockApplication]); mockReconsiderationService.getMany.mockResolvedValue([]); - mockApplicationTimeTrackingService.getPausedStatusByUuid.mockResolvedValue( - mockedApplicationsPausedStatuses, - ); + mockApplicationDecisionConditionCardService.getMany.mockResolvedValue([]); + mockApplicationTimeTrackingService.getPausedStatusByUuid.mockResolvedValue(mockedApplicationsPausedStatuses); mockApplication.card!.board = { code: 'CODE', } as Board; @@ -197,11 +205,9 @@ describe('DecisionMeetingController', () => { it('should load and map reconsideration meetings', async () => { mockApplicationService.getMany.mockResolvedValue([]); - + mockApplicationDecisionConditionCardService.getMany.mockResolvedValue([]); const reconMock = initApplicationReconsiderationMockEntity(mockApplication); - mockApplicationTimeTrackingService.getPausedStatusByUuid.mockResolvedValue( - mockedApplicationsPausedStatuses, - ); + mockApplicationTimeTrackingService.getPausedStatusByUuid.mockResolvedValue(mockedApplicationsPausedStatuses); reconMock.card!.board = { code: 'CODE', } as Board; @@ -220,4 +226,45 @@ describe('DecisionMeetingController', () => { expect(res.CODE[0].meetingDate).toEqual(mockMeeting.date.getTime()); expect(res.CODE[0].fileNumber).toEqual(mockApplication.fileNumber); }); + + it('should load and map application decision condition cards', async () => { + mockConditionCard.card!.board = { + code: 'CODE', + } as Board; + mockApplicationService.getMany.mockImplementation((query) => { + if (query && query.uuid) { + const uuidValues = query.uuid!['_value']; // Access the private _value property + if (uuidValues.includes(mockConditionCardApplication.uuid)) { + return Promise.resolve([mockConditionCardApplication]); + } + } + return Promise.resolve([]); + }); + mockReconsiderationService.getMany.mockResolvedValue([]); + mockPlanningReferralService.getManyByPlanningReview.mockResolvedValue([]); + mockApplicationDecisionConditionCardService.getMany.mockResolvedValue([mockConditionCard]); + + mockMeetingService.getUpcomingApplicationMeetings.mockResolvedValue([]); + mockMeetingService.getUpcomingReconsiderationMeetings.mockResolvedValue([]); + mockPlanningReviewMeetingService.getUpcomingMeetings.mockResolvedValue([]); + mockApplicationTimeTrackingService.getPausedStatusByUuid.mockResolvedValue(mockedApplicationsPausedStatuses); + mockMeetingService.getUpcomingApplicationDecisionConditionCards.mockResolvedValue([ + { + uuid: mockConditionCardApplication.uuid, + condition_card_uuid: mockConditionCard.uuid, + next_meeting: mockMeeting.date.toISOString(), + }, + ]); + + const res = await controller.getMeetings(); + + console.log('result'); + console.log(res); + + expect(res.CODE).toBeDefined(); + expect(res.CODE.length).toEqual(1); + expect(res.CODE[0].meetingDate).toEqual(mockMeeting.date.getTime()); + expect(res.CODE[0].fileNumber).toEqual(mockConditionCardApplication.fileNumber); + expect(res.CODE[0].type).toEqual('APPCON'); + }); }); diff --git a/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.ts b/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.ts index 90c4e478aa..52878aac68 100644 --- a/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.ts +++ b/services/apps/alcs/src/alcs/meetings/decision-meeting.controller.ts @@ -1,13 +1,4 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Patch, - Post, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; import { ApiOAuth2 } from '@nestjs/swagger'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; @@ -33,6 +24,7 @@ import { UpcomingMeetingDto, } from './decision-meeting.dto'; import { ApplicationTimeTrackingService } from '../application/application-time-tracking.service'; +import { ApplicationDecisionConditionCardService } from '../application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.service'; @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @Controller('decision-meeting') @@ -45,6 +37,7 @@ export class DecisionMeetingController { private planningReferralService: PlanningReferralService, private planningReviewMeetingService: PlanningReviewMeetingService, private applicationTimeTrackingService: ApplicationTimeTrackingService, + private applicationDecisionConditionCardService: ApplicationDecisionConditionCardService, @InjectMapper() private mapper: Mapper, ) {} @@ -54,9 +47,10 @@ export class DecisionMeetingController { const mappedApps = await this.getMappedApplicationMeetings(); const mappedRecons = await this.getMappedReconsiderationMeetings(); const mappedReviews = await this.getMappedPlanningReviewMeetings(); + const mappedConditionCards = await this.getMappedApplicationConditionCards(); const boardCodeToApps: UpcomingMeetingBoardMapDto = {}; - [...mappedApps, ...mappedRecons, ...mappedReviews].forEach((mappedApp) => { + [...mappedApps, ...mappedRecons, ...mappedReviews, ...mappedConditionCards].forEach((mappedApp) => { const boardMeetings = boardCodeToApps[mappedApp.boardCode] || []; boardMeetings.push(mappedApp); boardCodeToApps[mappedApp.boardCode] = boardMeetings; @@ -67,27 +61,16 @@ export class DecisionMeetingController { @Get('/:fileNumber') @UserRoles(...ANY_AUTH_ROLE) - async getAllForApplication( - @Param('fileNumber') fileNumber, - ): Promise { - const meetings = - await this.appDecisionMeetingService.getByAppFileNumber(fileNumber); - return this.mapper.mapArrayAsync( - meetings, - ApplicationDecisionMeeting, - DecisionMeetingDto, - ); + async getAllForApplication(@Param('fileNumber') fileNumber): Promise { + const meetings = await this.appDecisionMeetingService.getByAppFileNumber(fileNumber); + return this.mapper.mapArrayAsync(meetings, ApplicationDecisionMeeting, DecisionMeetingDto); } @Get('/meeting/:uuid') @UserRoles(...ANY_AUTH_ROLE) async get(@Param('uuid') uuid: string): Promise { const meeting = await this.appDecisionMeetingService.get(uuid); - return this.mapper.mapAsync( - meeting, - ApplicationDecisionMeeting, - DecisionMeetingDto, - ); + return this.mapper.mapAsync(meeting, ApplicationDecisionMeeting, DecisionMeetingDto); } @Delete('/:uuid') @@ -98,58 +81,37 @@ export class DecisionMeetingController { @Post() @UserRoles(...ANY_AUTH_ROLE) - async create( - @Body() meeting: CreateApplicationDecisionMeetingDto, - ): Promise { - const application = await this.applicationService.getOrFail( - meeting.applicationFileNumber, - ); + async create(@Body() meeting: CreateApplicationDecisionMeetingDto): Promise { + const application = await this.applicationService.getOrFail(meeting.applicationFileNumber); const newMeeting = await this.appDecisionMeetingService.createOrUpdate({ date: formatIncomingDate(meeting.date) ?? new Date(), applicationUuid: application.uuid, }); - return this.mapper.map( - newMeeting, - ApplicationDecisionMeeting, - DecisionMeetingDto, - ); + return this.mapper.map(newMeeting, ApplicationDecisionMeeting, DecisionMeetingDto); } @Patch() @UserRoles(...ANY_AUTH_ROLE) - async update( - @Body() updateDto: DecisionMeetingDto, - ): Promise { + async update(@Body() updateDto: DecisionMeetingDto): Promise { const appDecEntity = new ApplicationDecisionMeeting({ uuid: updateDto.uuid, date: formatIncomingDate(updateDto.date)!, }); - const updatedMeeting = - await this.appDecisionMeetingService.createOrUpdate(appDecEntity); - return this.mapper.map( - updatedMeeting, - ApplicationDecisionMeeting, - DecisionMeetingDto, - ); + const updatedMeeting = await this.appDecisionMeetingService.createOrUpdate(appDecEntity); + return this.mapper.map(updatedMeeting, ApplicationDecisionMeeting, DecisionMeetingDto); } private async getMappedApplicationMeetings() { - const upcomingApplicationMeetings = - await this.appDecisionMeetingService.getUpcomingApplicationMeetings(); + const upcomingApplicationMeetings = await this.appDecisionMeetingService.getUpcomingApplicationMeetings(); const allAppIds = upcomingApplicationMeetings.map((a) => a.uuid); - const pausedStatuses = - await this.applicationTimeTrackingService.getPausedStatusByUuid( - allAppIds, - ); + const pausedStatuses = await this.applicationTimeTrackingService.getPausedStatusByUuid(allAppIds); const allApps = await this.applicationService.getMany({ uuid: Any(allAppIds), }); return allApps.map((app): UpcomingMeetingDto => { - const meetingDate = upcomingApplicationMeetings.find( - (meeting) => meeting.uuid === app.uuid, - ); + const meetingDate = upcomingApplicationMeetings.find((meeting) => meeting.uuid === app.uuid); return { meetingDate: new Date(meetingDate!.next_meeting).getTime(), fileNumber: app.fileNumber, @@ -163,29 +125,19 @@ export class DecisionMeetingController { } private async getMappedReconsiderationMeetings() { - const upcomingReconsiderationMeetings = - await this.appDecisionMeetingService.getUpcomingReconsiderationMeetings(); + const upcomingReconsiderationMeetings = await this.appDecisionMeetingService.getUpcomingReconsiderationMeetings(); const reconIds = upcomingReconsiderationMeetings.map((a) => a.uuid); - const pausedStatuses = - await this.applicationTimeTrackingService.getPausedStatusByUuid(reconIds); - const reconsiderations = - await this.reconsiderationService.getMany(reconIds); + const pausedStatuses = await this.applicationTimeTrackingService.getPausedStatusByUuid(reconIds); + const reconsiderations = await this.reconsiderationService.getMany(reconIds); return reconsiderations .filter((recon) => { - const meetingDate = upcomingReconsiderationMeetings.find( - (meeting) => meeting.uuid === recon.uuid, - ); - - return ( - new Date(meetingDate!.next_meeting).getTime() > - new Date(recon.submittedDate).getTime() - ); + const meetingDate = upcomingReconsiderationMeetings.find((meeting) => meeting.uuid === recon.uuid); + + return new Date(meetingDate!.next_meeting).getTime() > new Date(recon.submittedDate).getTime(); }) .map((recon): UpcomingMeetingDto => { - const meetingDate = upcomingReconsiderationMeetings.find( - (meeting) => meeting.uuid === recon.uuid, - ); + const meetingDate = upcomingReconsiderationMeetings.find((meeting) => meeting.uuid === recon.uuid); return { meetingDate: new Date(meetingDate!.next_meeting).getTime(), @@ -200,39 +152,57 @@ export class DecisionMeetingController { } private async getMappedPlanningReviewMeetings() { - const upcomingMeetings = - await this.planningReviewMeetingService.getUpcomingMeetings(); + const upcomingMeetings = await this.planningReviewMeetingService.getUpcomingMeetings(); const planningReviewIds = upcomingMeetings.map((a) => a.uuid); - const planningReferrals = - await this.planningReferralService.getManyByPlanningReview( - planningReviewIds, + const planningReferrals = await this.planningReferralService.getManyByPlanningReview(planningReviewIds); + return planningReferrals.flatMap((planningReferral): UpcomingMeetingDto[] => { + const meetingDate = upcomingMeetings.find((meeting) => meeting.uuid === planningReferral.planningReview.uuid); + + if (!meetingDate || !planningReferral.card) { + return []; + } + + return [ + { + meetingDate: new Date(meetingDate.next_meeting).getTime(), + fileNumber: planningReferral.planningReview.fileNumber, + applicant: planningReferral.planningReview.documentName, + boardCode: planningReferral.card.board.code, + type: CARD_TYPE.PLAN, + assignee: this.mapper.map(planningReferral.card.assignee, User, UserDto), + isPaused: false, + }, + ]; + }); + } + + private async getMappedApplicationConditionCards() { + const upcomingConditionCards = await this.appDecisionMeetingService.getUpcomingApplicationDecisionConditionCards(); + const allAppIds = upcomingConditionCards.map((a) => a.uuid); + const allConditionCardIds = upcomingConditionCards.map((a) => a.condition_card_uuid); + const pausedStatuses = await this.applicationTimeTrackingService.getPausedStatusByUuid(allAppIds); + const allApps = await this.applicationService.getMany({ + uuid: Any(allAppIds), + }); + const allConditionCards = await this.applicationDecisionConditionCardService.getMany({ + uuid: Any(allConditionCardIds), + }); + + return allConditionCards.map((conditionCard): UpcomingMeetingDto => { + const meetingDate = upcomingConditionCards.find((meeting) => meeting.condition_card_uuid === conditionCard.uuid); + const app = allApps.find((a) => + upcomingConditionCards.some((card) => card.condition_card_uuid === conditionCard.uuid && card.uuid === a.uuid), ); - return planningReferrals.flatMap( - (planningReferral): UpcomingMeetingDto[] => { - const meetingDate = upcomingMeetings.find( - (meeting) => meeting.uuid === planningReferral.planningReview.uuid, - ); - - if (!meetingDate || !planningReferral.card) { - return []; - } - - return [ - { - meetingDate: new Date(meetingDate.next_meeting).getTime(), - fileNumber: planningReferral.planningReview.fileNumber, - applicant: planningReferral.planningReview.documentName, - boardCode: planningReferral.card.board.code, - type: CARD_TYPE.PLAN, - assignee: this.mapper.map( - planningReferral.card.assignee, - User, - UserDto, - ), - isPaused: false, - }, - ]; - }, - ); + + return { + meetingDate: new Date(meetingDate!.next_meeting).getTime(), + fileNumber: app!.fileNumber, + applicant: app!.applicant, + boardCode: conditionCard.card!.board.code, + type: CARD_TYPE.APP_CON, + assignee: this.mapper.map(conditionCard.card!.assignee, User, UserDto), + isPaused: pausedStatuses.get(conditionCard.uuid)!, + }; + }); } } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller.spec.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller.spec.ts new file mode 100644 index 0000000000..131f2f970e --- /dev/null +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { NoticeOfIntentDecisionConditionCardController } from './notice-of-intent-decision-condition-card.controller'; +import { NoticeOfIntentDecisionConditionCardService } from './notice-of-intent-decision-condition-card.service'; +import { NoticeOfIntentModificationService } from '../../notice-of-intent-modification/notice-of-intent-modification.service'; +import { NoticeOfIntentDecisionV2Service } from '../../notice-of-intent-decision-v2/notice-of-intent-decision-v2.service'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition-card.entity'; +import { classes } from 'automapper-classes'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../../test/mocks/mockTypes'; +import { AutomapperModule } from 'automapper-nestjs'; +import { NoticeOfIntentDecisionProfile } from '../../../../common/automapper/notice-of-intent-decision.automapper.profile'; +import { + CreateNoticeOfIntentDecisionConditionCardDto, + NoticeOfIntentDecisionConditionCardBoardDto, + NoticeOfIntentDecisionConditionCardDto, + UpdateNoticeOfIntentDecisionConditionCardDto, +} from './notice-of-intent-decision-condition-card.dto'; + +describe('NoticeOfIntentDecisionConditionCardController', () => { + let controller: NoticeOfIntentDecisionConditionCardController; + let mockService: DeepMocked; + let mockModificationService: DeepMocked; + let mockDecisionService: DeepMocked; + + beforeEach(async () => { + mockService = createMock(); + mockModificationService = createMock(); + mockDecisionService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [NoticeOfIntentDecisionConditionCardController], + providers: [ + { + provide: NoticeOfIntentDecisionConditionCardService, + useValue: mockService, + }, + { + provide: NoticeOfIntentModificationService, + useValue: mockModificationService, + }, + { + provide: NoticeOfIntentDecisionV2Service, + useValue: mockDecisionService, + }, + { + provide: ClsService, + useValue: {}, + }, + NoticeOfIntentDecisionProfile, + ...mockKeyCloakProviders, + ], + }).compile(); + + controller = module.get( + NoticeOfIntentDecisionConditionCardController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return a condition card', async () => { + const uuid = 'example-uuid'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockService.get.mockResolvedValue(conditionCard); + + const result = await controller.get(uuid); + + expect(mockService.get).toHaveBeenCalledWith(uuid); + expect(result).toBeInstanceOf(NoticeOfIntentDecisionConditionCardDto); + }); + + it('should create a new condition card', async () => { + const dto: CreateNoticeOfIntentDecisionConditionCardDto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockService.create.mockResolvedValue(conditionCard); + + const result = await controller.create(dto); + + expect(mockService.create).toHaveBeenCalledWith(dto); + expect(result).toBeInstanceOf(NoticeOfIntentDecisionConditionCardDto); + }); + + it('should update the condition card and return updated card', async () => { + const uuid = 'example-uuid'; + const dto: UpdateNoticeOfIntentDecisionConditionCardDto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockService.update.mockResolvedValue(conditionCard); + + const result = await controller.update(uuid, dto); + + expect(mockService.update).toHaveBeenCalledWith(uuid, dto); + expect(result).toBeInstanceOf(NoticeOfIntentDecisionConditionCardDto); + }); + + it('should return a condition card by board card uuid', async () => { + const uuid = 'example-uuid'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid', noticeOfIntent: { fileNumber: 'file-number' } } as any; + + mockService.getByBoardCard.mockResolvedValue(conditionCard); + mockModificationService.getByNoticeOfIntentDecisionUuid.mockResolvedValue([]); + mockDecisionService.getDecisionOrder.mockResolvedValue(1); + + const result = await controller.getByCardUuid(uuid); + + expect(mockService.getByBoardCard).toHaveBeenCalledWith(uuid); + expect(result).toBeInstanceOf(NoticeOfIntentDecisionConditionCardBoardDto); + expect(result.fileNumber).toEqual('file-number'); + }); + + it('should return condition cards by application file number', async () => { + const fileNumber = 'example-file-number'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.decision = { uuid: 'decision-uuid' } as any; + mockDecisionService.getForDecisionConditionCardsByFileNumber.mockResolvedValue([conditionCard]); + + const result = await controller.getByApplicationFileNumber(fileNumber); + + expect(mockDecisionService.getForDecisionConditionCardsByFileNumber).toHaveBeenCalledWith(fileNumber); + expect(result).toBeInstanceOf(Array); + expect(result[0]).toBeInstanceOf(NoticeOfIntentDecisionConditionCardDto); + }); +}); diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller.ts new file mode 100644 index 0000000000..bb3ac2b147 --- /dev/null +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller.ts @@ -0,0 +1,104 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import * as config from 'config'; +import { RolesGuard } from '../../../../common/authorization/roles-guard.service'; +import { NoticeOfIntentDecisionConditionCardService } from './notice-of-intent-decision-condition-card.service'; +import { InjectMapper } from 'automapper-nestjs'; +import { Mapper } from 'automapper-core'; +import { UserRoles } from '../../../../common/authorization/roles.decorator'; +import { ROLES_ALLOWED_APPLICATIONS } from '../../../../common/authorization/roles'; +import { + NoticeOfIntentDecisionConditionCardBoardDto, + NoticeOfIntentDecisionConditionCardDto, + CreateNoticeOfIntentDecisionConditionCardDto, + UpdateNoticeOfIntentDecisionConditionCardDto, +} from './notice-of-intent-decision-condition-card.dto'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition-card.entity'; +import { NoticeOfIntentModificationService } from '../../notice-of-intent-modification/notice-of-intent-modification.service'; +import { NoticeOfIntentDecisionV2Service } from '../../notice-of-intent-decision-v2/notice-of-intent-decision-v2.service'; + +@ApiOAuth2(config.get('KEYCLOAK.SCOPES')) +@Controller('notice-of-intent-decision-condition-card') +@UseGuards(RolesGuard) +export class NoticeOfIntentDecisionConditionCardController { + constructor( + private service: NoticeOfIntentDecisionConditionCardService, + private noticeOfIntentModificationService: NoticeOfIntentModificationService, + private noticeOfIntentDecisionService: NoticeOfIntentDecisionV2Service, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/:uuid') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async get(@Param('uuid') uuid: string): Promise { + const result = await this.service.get(uuid); + + return await this.mapper.map(result, NoticeOfIntentDecisionConditionCard, NoticeOfIntentDecisionConditionCardDto); + } + + @Post('') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async create( + @Body() dto: CreateNoticeOfIntentDecisionConditionCardDto, + ): Promise { + const result = await this.service.create(dto); + + return await this.mapper.map(result, NoticeOfIntentDecisionConditionCard, NoticeOfIntentDecisionConditionCardDto); + } + + @Patch('/:uuid') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async update( + @Param('uuid') uuid: string, + @Body() dto: UpdateNoticeOfIntentDecisionConditionCardDto, + ): Promise { + const result = await this.service.update(uuid, dto); + + return await this.mapper.map(result, NoticeOfIntentDecisionConditionCard, NoticeOfIntentDecisionConditionCardDto); + } + + @Get('/board-card/:uuid') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async getByCardUuid(@Param('uuid') uuid: string): Promise { + const result = await this.service.getByBoardCard(uuid); + const dto = await this.mapper.map( + result, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardBoardDto, + ); + dto.fileNumber = result.decision.noticeOfIntent.fileNumber; + + const appModifications = await this.noticeOfIntentModificationService.getByNoticeOfIntentDecisionUuid( + result.decision.uuid, + ); + + dto.isModification = appModifications.length > 0; + + const decisionOrder = await this.noticeOfIntentDecisionService.getDecisionOrder( + result.decision.noticeOfIntent.fileNumber, + result.decision.uuid, + ); + dto.decisionOrder = decisionOrder; + + return dto; + } + + @Get('/noi/:fileNumber') + @UserRoles(...ROLES_ALLOWED_APPLICATIONS) + async getByApplicationFileNumber( + @Param('fileNumber') fileNumber: string, + ): Promise { + const conditionCards = + await this.noticeOfIntentDecisionService.getForDecisionConditionCardsByFileNumber(fileNumber); + const dtos: NoticeOfIntentDecisionConditionCardDto[] = []; + for (const card of conditionCards) { + const dto = await this.mapper.map( + card, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardDto, + ); + dtos.push(dto); + } + return dtos; + } +} diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.dto.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.dto.ts new file mode 100644 index 0000000000..728c81a312 --- /dev/null +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.dto.ts @@ -0,0 +1,120 @@ +import { AutoMap } from 'automapper-classes'; +import { NoticeOfIntentDecisionConditionDto } from '../notice-of-intent-decision-condition.dto'; +import { IsArray, IsBoolean, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { CardDto } from '../../../card/card.dto'; +import { NoticeOfIntentTypeDto } from '../../../notice-of-intent/notice-of-intent-type/notice-of-intent-type.dto'; + +export class NoticeOfIntentDecisionConditionCardDto { + @AutoMap() + @IsUUID() + uuid: string; + + @IsArray() + conditions: NoticeOfIntentDecisionConditionDto[]; + + @AutoMap() + @IsString() + cardUuid: string; + + @AutoMap() + card: CardDto; + + @IsString() + decisionUuid?: string; + + @IsString() + @IsOptional() + noticeOfIntent?: string | null; +} + +export class NoticeOfIntentDecisionConditionHomeCardDto { + @AutoMap() + @IsUUID() + uuid: string; + + @IsArray() + conditions: NoticeOfIntentDecisionConditionDto[]; + + @AutoMap() + @IsString() + cardUuid: string; + + @AutoMap() + card: CardDto; + + @IsString() + @IsOptional() + noticeOfIntentFileNumber?: string | null; +} + +export class CreateNoticeOfIntentDecisionConditionCardDto { + @AutoMap() + @IsArray() + conditionsUuids: string[]; + + @AutoMap() + @IsString() + decisionUuid: string; + + @AutoMap() + @IsString() + cardStatusCode: string; +} + +export class UpdateNoticeOfIntentDecisionConditionCardDto { + @AutoMap() + @IsArray() + @IsOptional() + conditionsUuids?: string[] | null; + + @AutoMap() + @IsString() + @IsOptional() + cardStatusCode?: string | null; +} + +export class NoticeOfIntentDecisionConditionCardUuidDto { + @AutoMap() + @IsUUID() + uuid: string; +} + +export class NoticeOfIntentDecisionConditionCardBoardDto { + @AutoMap() + @IsUUID() + uuid: string; + + @IsArray() + conditions: NoticeOfIntentDecisionConditionDto[]; + + @AutoMap() + card: CardDto; + + @IsString() + decisionUuid: string; + + @IsNumber() + decisionOrder: number; + + @IsBoolean() + decisionIsFlagged: boolean; + + @IsString() + fileNumber: string; + + @IsString() + @IsOptional() + applicant?: string | null; + + @IsOptional() + type?: NoticeOfIntentTypeDto | null; + + @IsBoolean() + isModification?: boolean; +} + +export class UpdateNoticeOfIntentDecisionConditionBoardCardDto { + @AutoMap() + @IsString() + cardStatusCode: string; +} diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity.ts new file mode 100644 index 0000000000..bb6f3a173e --- /dev/null +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity.ts @@ -0,0 +1,36 @@ +import { Base } from '../../../../common/entities/base.entity'; +import { AutoMap } from 'automapper-classes'; +import { Column, Entity, ManyToOne, OneToOne, JoinColumn, OneToMany } from 'typeorm'; +import { NoticeOfIntentDecisionCondition } from '../notice-of-intent-decision-condition.entity'; +import { Card } from '../../../card/card.entity'; +import { NoticeOfIntentDecision } from '../../notice-of-intent-decision.entity'; + +@Entity({ comment: 'Links notice of intent decision conditions with cards' }) +export class NoticeOfIntentDecisionConditionCard extends Base { + constructor(data?: Partial) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @OneToMany(() => NoticeOfIntentDecisionCondition, (condition) => condition.conditionCard, { + nullable: false, + cascade: true, + }) + conditions: NoticeOfIntentDecisionCondition[]; + + @OneToOne(() => Card, { nullable: false }) + @JoinColumn() + card: Card; + + @AutoMap() + @Column({ type: 'uuid' }) + cardUuid: string; + + @AutoMap(() => NoticeOfIntentDecision) + @ManyToOne(() => NoticeOfIntentDecision, (decision) => decision.uuid, { + nullable: false, + }) + decision: NoticeOfIntentDecision; +} diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service.spec.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service.spec.ts new file mode 100644 index 0000000000..b7c16931dd --- /dev/null +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service.spec.ts @@ -0,0 +1,424 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { NoticeOfIntentDecisionConditionCardService } from './notice-of-intent-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision-condition.service'; +import { NoticeOfIntentDecisionV2Service } from '../../notice-of-intent-decision-v2/notice-of-intent-decision-v2.service'; +import { BoardService } from '../../../board/board.service'; +import { CardService } from '../../../card/card.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition-card.entity'; +import { IsNull, Not, Repository } from 'typeorm'; +import { AutomapperModule } from 'automapper-nestjs'; +import { classes } from 'automapper-classes'; +import { Mapper } from 'automapper-core'; +import { + ServiceNotFoundException, + ServiceValidationException, +} from '../../../../../../../libs/common/src/exceptions/base.exception'; +import { Card } from '../../../card/card.entity'; +import { NoticeOfIntentModificationService } from '../../notice-of-intent-modification/notice-of-intent-modification.service'; +import { NoticeOfIntentDecisionProfile } from '../../../../common/automapper/notice-of-intent-decision.automapper.profile'; +import { NoticeOfIntentProfile } from '../../../../common/automapper/notice-of-intent.automapper.profile'; + +describe('NoticeOfIntentDecisionConditionCardService', () => { + let service: NoticeOfIntentDecisionConditionCardService; + let mockRepository: DeepMocked>; + let mockConditionService: DeepMocked; + let mockDecisionService: DeepMocked; + let mockBoardService: DeepMocked; + let mockCardService: DeepMocked; + let mockModificationService: DeepMocked; + let mockMapper: DeepMocked; + + const CARD_RELATIONS = { + board: true, + type: true, + status: true, + assignee: true, + }; + + const BOARD_CARD_RELATIONS = { + card: CARD_RELATIONS, + conditions: true, + decision: { + noticeOfIntent: { + type: true, + }, + }, + }; + + const DEFAULT_RELATIONS = { + conditions: true, + card: CARD_RELATIONS, + decision: { + noticeOfIntent: true, + }, + }; + + beforeEach(async () => { + mockRepository = createMock(); + mockConditionService = createMock(); + mockDecisionService = createMock(); + mockBoardService = createMock(); + mockCardService = createMock(); + mockModificationService = createMock(); + mockMapper = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + NoticeOfIntentDecisionConditionCardService, + { + provide: getRepositoryToken(NoticeOfIntentDecisionConditionCard), + useValue: mockRepository, + }, + { + provide: NoticeOfIntentDecisionConditionService, + useValue: mockConditionService, + }, + { + provide: NoticeOfIntentDecisionV2Service, + useValue: mockDecisionService, + }, + { + provide: BoardService, + useValue: mockBoardService, + }, + { + provide: CardService, + useValue: mockCardService, + }, + { + provide: NoticeOfIntentModificationService, + useValue: mockModificationService, + }, + NoticeOfIntentDecisionProfile, + NoticeOfIntentProfile, + ], + }).compile(); + + service = module.get(NoticeOfIntentDecisionConditionCardService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new condition card', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'status-code' }] } as any; + const decision = { uuid: 'decision-uuid' } as any; + const card = { uuid: 'card-uuid' } as any; + const conditions = [{ uuid: 'condition-uuid-1' }, { uuid: 'condition-uuid-2' }] as any; + + mockBoardService.getNoticeOfIntentDecisionConditionBoard.mockResolvedValue(board); + mockDecisionService.get.mockResolvedValue(decision); + mockCardService.save.mockResolvedValue(card); + mockConditionService.findByUuids.mockResolvedValue(conditions); + mockRepository.save.mockResolvedValue({ uuid: 'new-card-uuid' } as any); + + const result = await service.create(dto); + + expect(mockBoardService.getNoticeOfIntentDecisionConditionBoard).toHaveBeenCalled(); + expect(mockDecisionService.get).toHaveBeenCalledWith(dto.decisionUuid); + expect(mockCardService.save).toHaveBeenCalled(); + expect(mockConditionService.findByUuids).toHaveBeenCalledWith(dto.conditionsUuids); + expect(mockRepository.save).toHaveBeenCalled(); + expect(result.uuid).toEqual('new-card-uuid'); + }); + + it('should throw an error if board is not found', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + + mockBoardService.getNoticeOfIntentDecisionConditionBoard.mockRejectedValue( + new ServiceNotFoundException('Board not found'), + ); + + await expect(service.create(dto)).rejects.toThrow(ServiceNotFoundException); + }); + + it('should throw an error if decision is not found', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'status-code' }] } as any; + + mockBoardService.getNoticeOfIntentDecisionConditionBoard.mockResolvedValue(board); + mockDecisionService.get.mockRejectedValue( + new ServiceNotFoundException('Failed to fetch decision with uuid decision-uuid'), + ); + + await expect(service.create(dto)).rejects.toThrow(ServiceNotFoundException); + }); + + it('should throw an error if conditions are not found', async () => { + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + decisionUuid: 'decision-uuid', + cardStatusCode: 'status-code', + }; + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'status-code' }] } as any; + const decision = { uuid: 'decision-uuid' } as any; + const card = { uuid: 'card-uuid' } as any; + + mockBoardService.getNoticeOfIntentDecisionConditionBoard.mockResolvedValue(board); + mockDecisionService.get.mockResolvedValue(decision); + mockCardService.save.mockResolvedValue(card); + mockConditionService.findByUuids.mockResolvedValue([]); + + await expect(service.create(dto)).rejects.toThrow(ServiceValidationException); + }); + }); + + describe('get', () => { + it('should return a condition card', async () => { + const uuid = 'example-uuid'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + mockRepository.findOne.mockResolvedValue(conditionCard); + + const result = await service.get(uuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { uuid }, + relations: DEFAULT_RELATIONS, + }); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const uuid = 'example-uuid'; + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.get(uuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('update', () => { + it('should update the condition card', async () => { + const uuid = 'example-uuid'; + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.card = new Card(); + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'updated-status-code' }] } as any; + const conditions = [{ uuid: 'condition-uuid-1' }, { uuid: 'condition-uuid-2' }] as any; + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockBoardService.getNoticeOfIntentDecisionConditionBoard.mockResolvedValue(board); + mockConditionService.findByUuids.mockResolvedValue(conditions); + mockRepository.save.mockResolvedValue(conditionCard); + + const result = await service.update(uuid, dto); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { uuid }, + relations: DEFAULT_RELATIONS, + }); + expect(mockBoardService.getNoticeOfIntentDecisionConditionBoard).toHaveBeenCalled(); + expect(mockConditionService.findByUuids).toHaveBeenCalledWith(dto.conditionsUuids); + expect(mockRepository.save).toHaveBeenCalledWith(conditionCard); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const uuid = 'example-uuid'; + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.update(uuid, dto)).rejects.toThrow(ServiceNotFoundException); + }); + + it('should throw an error if conditions are not found', async () => { + const uuid = 'example-uuid'; + const dto = { + conditionsUuids: ['condition-uuid-1', 'condition-uuid-2'], + cardStatusCode: 'updated-status-code', + }; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + const board = { uuid: 'board-uuid', statuses: [{ statusCode: 'updated-status-code' }] } as any; + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockBoardService.getNoticeOfIntentDecisionConditionBoard.mockResolvedValue(board); + mockConditionService.findByUuids.mockResolvedValue([]); + + await expect(service.update(uuid, dto)).rejects.toThrow(ServiceValidationException); + }); + }); + + describe('getByBoard', () => { + it('should return condition cards by board uuid', async () => { + const boardUuid = 'board-uuid'; + const conditionCards = [new NoticeOfIntentDecisionConditionCard()]; + mockRepository.find.mockResolvedValue(conditionCards); + + const result = await service.getByBoard(boardUuid); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { card: { boardUuid } }, + relations: service.BOARD_CARD_RELATIONS, + }); + expect(result).toEqual(conditionCards); + }); + }); + + describe('getByBoardCard', () => { + it('should return a condition card by board card uuid', async () => { + const uuid = 'example-uuid'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + mockRepository.findOne.mockResolvedValue(conditionCard); + + const result = await service.getByBoardCard(uuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { cardUuid: uuid }, + relations: service.BOARD_CARD_RELATIONS, + }); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const uuid = 'example-uuid'; + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.getByBoardCard(uuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('softRemove', () => { + it('should soft remove a condition card', async () => { + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.cardUuid = 'card-uuid'; + const card = new Card(); + + mockCardService.get.mockResolvedValue(card); + mockCardService.softRemoveByUuid.mockResolvedValue(); + mockRepository.softRemove.mockResolvedValue(conditionCard); + + const result = await service.softRemove(conditionCard); + + expect(mockCardService.get).toHaveBeenCalledWith(conditionCard.cardUuid); + expect(mockCardService.softRemoveByUuid).toHaveBeenCalledWith(card.uuid); + expect(mockRepository.softRemove).toHaveBeenCalledWith(conditionCard); + expect(result).toEqual(conditionCard); + }); + + it('should throw an error if card is not found', async () => { + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.cardUuid = 'card-uuid'; + + mockCardService.get.mockResolvedValue(null); + + await expect(service.softRemove(conditionCard)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('archiveByBoardCard', () => { + it('should archive a condition card by board card uuid', async () => { + const boardCardUuid = 'example-uuid'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + conditionCard.conditions = []; + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockRepository.save.mockResolvedValue(conditionCard); + mockRepository.softRemove.mockResolvedValue(conditionCard); + + await service.archiveByBoardCard(boardCardUuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { cardUuid: boardCardUuid }, + relations: service.BOARD_CARD_RELATIONS, + }); + expect(mockRepository.save).toHaveBeenCalledWith(conditionCard); + expect(mockRepository.softRemove).toHaveBeenCalledWith(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const boardCardUuid = 'example-uuid'; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.archiveByBoardCard(boardCardUuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('recoverByBoardCard', () => { + it('should recover a condition card by board card uuid', async () => { + const boardCardUuid = 'example-uuid'; + const conditionCard = new NoticeOfIntentDecisionConditionCard(); + + mockRepository.findOne.mockResolvedValue(conditionCard); + mockRepository.recover.mockResolvedValue(conditionCard); + + await service.recoverByBoardCard(boardCardUuid); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { cardUuid: boardCardUuid }, + withDeleted: true, + relations: service.DEFAULT_RELATIONS, + }); + expect(mockRepository.recover).toHaveBeenCalledWith(conditionCard); + }); + + it('should throw an error if condition card is not found', async () => { + const boardCardUuid = 'example-uuid'; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.recoverByBoardCard(boardCardUuid)).rejects.toThrow(ServiceNotFoundException); + }); + }); + + describe('getDeletedCards', () => { + it('should return deleted condition cards by file number', async () => { + const fileNumber = 'example-file-number'; + const conditionCards = [new NoticeOfIntentDecisionConditionCard()]; + + mockRepository.find.mockResolvedValue(conditionCards); + + const result = await service.getDeletedCards(fileNumber); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + decision: { + noticeOfIntent: { + fileNumber, + }, + auditDeletedDateAt: IsNull(), + }, + card: { + auditDeletedDateAt: Not(IsNull()), + }, + }, + withDeleted: true, + relations: { + decision: { + noticeOfIntent: true, + }, + card: service.CARD_RELATIONS, + }, + }); + expect(result).toEqual(conditionCards); + }); + }); +}); diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service.ts new file mode 100644 index 0000000000..66db9db75f --- /dev/null +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service.ts @@ -0,0 +1,259 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { + NoticeOfIntentDecisionConditionCardBoardDto, + CreateNoticeOfIntentDecisionConditionCardDto, + UpdateNoticeOfIntentDecisionConditionCardDto, +} from './notice-of-intent-decision-condition-card.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition-card.entity'; +import { IsNull, Not, Repository } from 'typeorm'; +import { CardService } from '../../../card/card.service'; +import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision-condition.service'; +import { BoardService } from '../../../board/board.service'; +import { + ServiceInternalErrorException, + ServiceNotFoundException, + ServiceValidationException, +} from '../../../../../../../libs/common/src/exceptions/base.exception'; +import { Card } from '../../../card/card.entity'; +import { CARD_TYPE } from '../../../card/card-type/card-type.entity'; +import { Board } from '../../../board/board.entity'; +import { InjectMapper } from 'automapper-nestjs'; +import { Mapper } from 'automapper-core'; +import { ApplicationType } from '../../../code/application-code/application-type/application-type.entity'; +import { ApplicationTypeDto } from '../../../code/application-code/application-type/application-type.dto'; +import { NoticeOfIntentDecision } from '../../notice-of-intent-decision.entity'; +import { NoticeOfIntentModificationService } from '../../notice-of-intent-modification/notice-of-intent-modification.service'; +import { NoticeOfIntentDecisionV2Service } from '../../notice-of-intent-decision-v2/notice-of-intent-decision-v2.service'; + +@Injectable() +export class NoticeOfIntentDecisionConditionCardService { + CARD_RELATIONS = { + board: true, + type: true, + status: true, + assignee: true, + }; + + BOARD_CARD_RELATIONS = { + card: this.CARD_RELATIONS, + conditions: true, + decision: { + noticeOfIntent: { + type: true, + }, + }, + }; + + DEFAULT_RELATIONS = { + conditions: true, + card: this.CARD_RELATIONS, + decision: { + noticeOfIntent: true, + }, + }; + + constructor( + @InjectRepository(NoticeOfIntentDecisionConditionCard) + private repository: Repository, + private noticeOfIntentDecisionConditionService: NoticeOfIntentDecisionConditionService, + @Inject(forwardRef(() => NoticeOfIntentDecisionV2Service)) + private noticeOfIntentDecisionService: NoticeOfIntentDecisionV2Service, + private boardService: BoardService, + @Inject(forwardRef(() => CardService)) + private cardService: CardService, + @Inject(forwardRef(() => NoticeOfIntentModificationService)) + private noticeOfIntentModificationService: NoticeOfIntentModificationService, + @InjectMapper() private mapper: Mapper, + ) {} + + async create(dto: CreateNoticeOfIntentDecisionConditionCardDto) { + let board: Board; + try { + board = await this.boardService.getNoticeOfIntentDecisionConditionBoard(); + } catch (error) { + throw new ServiceNotFoundException('Failed to fetch Notice of Intent Decision Condition Board'); + } + + if (!board.statuses.find((status) => status.statusCode === dto.cardStatusCode)) { + throw new ServiceValidationException(`Invalid card status code: ${dto.cardStatusCode}`); + } + + let decision: NoticeOfIntentDecision; + try { + decision = await this.noticeOfIntentDecisionService.get(dto.decisionUuid); + } catch (error) { + throw new ServiceNotFoundException(`Failed to fetch decision with uuid ${dto.decisionUuid}`); + } + + const card = new Card(); + card.typeCode = CARD_TYPE.NOI_CON; + card.statusCode = dto.cardStatusCode; + card.boardUuid = board.uuid; + const newCard = await this.cardService.save(card); + + if (!newCard) { + throw new ServiceInternalErrorException('Failed to create card'); + } + + const conditions = await this.noticeOfIntentDecisionConditionService.findByUuids(dto.conditionsUuids); + + if (conditions.length !== dto.conditionsUuids.length) { + throw new ServiceValidationException('Failed to fetch all conditions'); + } + + const noticeOfIntentDecisionConditionCard = new NoticeOfIntentDecisionConditionCard(); + noticeOfIntentDecisionConditionCard.cardUuid = newCard.uuid; + noticeOfIntentDecisionConditionCard.conditions = conditions; + noticeOfIntentDecisionConditionCard.decision = decision; + + return this.repository.save(noticeOfIntentDecisionConditionCard); + } + + async get(uuid: string): Promise { + const noticeOfIntentDecisionConditionCard = await this.repository.findOne({ + where: { uuid }, + relations: this.DEFAULT_RELATIONS, + }); + + if (!noticeOfIntentDecisionConditionCard) { + throw new ServiceNotFoundException(`NoticeOfIntentDecisionConditionCard with uuid ${uuid} not found`); + } + + return noticeOfIntentDecisionConditionCard; + } + + async update(uuid: string, dto: UpdateNoticeOfIntentDecisionConditionCardDto) { + const noticeOfIntentDecisionConditionCard = await this.get(uuid); + + if (dto.conditionsUuids && dto.conditionsUuids.length > 0) { + const conditions = await this.noticeOfIntentDecisionConditionService.findByUuids(dto.conditionsUuids); + + if (conditions.length !== dto.conditionsUuids.length) { + throw new ServiceValidationException('Failed to fetch all conditions'); + } + + noticeOfIntentDecisionConditionCard.conditions = conditions; + } + + if (dto.cardStatusCode) { + let board: Board; + try { + board = await this.boardService.getNoticeOfIntentDecisionConditionBoard(); + } catch (error) { + throw new ServiceNotFoundException('Failed to fetch Notice of Intent Decision Condition Board'); + } + + if (!board.statuses.find((status) => status.statusCode === dto.cardStatusCode)) { + throw new ServiceValidationException(`Invalid card status code: ${dto.cardStatusCode}`); + } + + noticeOfIntentDecisionConditionCard.card.statusCode = dto.cardStatusCode; + } + + return this.repository.save(noticeOfIntentDecisionConditionCard); + } + + async softRemove(decisionConditionCard: NoticeOfIntentDecisionConditionCard) { + const card = await this.cardService.get(decisionConditionCard.cardUuid); + if (!card) { + throw new ServiceNotFoundException(`Card with uuid ${decisionConditionCard.cardUuid} not found`); + } + + await this.cardService.softRemoveByUuid(card.uuid); + return this.repository.softRemove(decisionConditionCard); + } + + async getByBoard(boardUuid: string): Promise { + return await this.repository.find({ + where: { card: { boardUuid } }, + relations: this.BOARD_CARD_RELATIONS, + }); + } + + async getByBoardCard(uuid: string): Promise { + const res = await this.repository.findOne({ where: { cardUuid: uuid }, relations: this.BOARD_CARD_RELATIONS }); + if (!res) { + throw new ServiceNotFoundException(`Could not find card with UUID ${uuid}`); + } + + return res; + } + + async mapToBoardDtos(noticeOfIntentDecisionConditionCards: NoticeOfIntentDecisionConditionCard[]) { + const dtos = noticeOfIntentDecisionConditionCards.map((card) => { + const dto = this.mapper.map( + card, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardBoardDto, + ); + dto.applicant = card.decision.noticeOfIntent.applicant; + dto.fileNumber = card.decision.noticeOfIntent.fileNumber; + dto.type = this.mapper.map(card.decision.noticeOfIntent.type, ApplicationType, ApplicationTypeDto); + return dto; + }); + + for (const dto of dtos) { + const appModifications = await this.noticeOfIntentModificationService.getByNoticeOfIntentDecisionUuid( + dto.decisionUuid, + ); + + dto.isModification = appModifications.length > 0; + + for (const condition of dto.conditions) { + const status = await this.noticeOfIntentDecisionService.getDecisionConditionStatus(condition.uuid); + condition.status = status !== '' ? status : undefined; + } + } + return dtos; + } + + async archiveByBoardCard(boardCardUuid: string) { + const decisionConditionCard = await this.getByBoardCard(boardCardUuid); + + if (!decisionConditionCard) { + throw new ServiceNotFoundException(`Card with uuid ${boardCardUuid} not found`); + } + decisionConditionCard.conditions = []; + await this.repository.save(decisionConditionCard); + + await this.repository.softRemove(decisionConditionCard); + } + + async recoverByBoardCard(boardCardUuid: string) { + const decisionConditionCard = await this.repository.findOne({ + where: { cardUuid: boardCardUuid }, + withDeleted: true, + relations: this.DEFAULT_RELATIONS, + }); + + if (!decisionConditionCard) { + throw new ServiceNotFoundException(`Card with uuid ${boardCardUuid} not found`); + } + + await this.repository.recover(decisionConditionCard); + } + + async getDeletedCards(fileNumber: string) { + return this.repository.find({ + where: { + decision: { + noticeOfIntent: { + fileNumber, + }, + auditDeletedDateAt: IsNull(), + }, + card: { + auditDeletedDateAt: Not(IsNull()), + }, + }, + withDeleted: true, + relations: { + decision: { + noticeOfIntent: true, + }, + card: this.CARD_RELATIONS, + }, + }); + } +} diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service.ts index f5e2782809..3583d430dc 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service.ts @@ -85,12 +85,16 @@ export class NoticeOfIntentDecisionConditionDateService { throw new ServiceNotFoundException(`Condition ${createDto.conditionUuid} was not found.`); } - if (condition.type.dateType !== DateType.MULTIPLE) { + if (!condition.type.isDateChecked) { throw new ServiceValidationException( `Creating a new date is not supported for condition ${createDto.conditionUuid}`, ); } + if (condition.type.dateType !== DateType.MULTIPLE && condition.dates?.length > 0) { + throw new ServiceValidationException(`Cannot create more than one date for condition ${createDto.conditionUuid}`); + } + const newDate = new NoticeOfIntentDecisionConditionDate(); newDate.date = null; newDate.completedDate = null; @@ -100,7 +104,7 @@ export class NoticeOfIntentDecisionConditionDateService { return await this.repository.save(newDate); } - async delete(dateUuid: string) { + async delete(dateUuid: string, forceSingleDateDeletion: boolean = false) { const conditionDate = await this.repository.findOne({ where: { uuid: dateUuid }, relations: ['condition', 'condition.type'], @@ -110,7 +114,7 @@ export class NoticeOfIntentDecisionConditionDateService { throw new ServiceNotFoundException(`Condition Date ${dateUuid} was not found`); } - if (conditionDate.condition.type.dateType !== DateType.MULTIPLE) { + if (!forceSingleDateDeletion && conditionDate.condition.type.dateType !== DateType.MULTIPLE) { throw new ServiceValidationException(`Deleting the date ${dateUuid} is not permitted on single date type`); } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto.ts index 9e291226cf..30399bbcbf 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto.ts @@ -8,6 +8,11 @@ import { } from '../../application-decision/application-decision-condition/application-decision-condition-code.entity'; import { Type } from 'class-transformer'; import { NoticeOfIntentDecisionConditionDateDto } from './notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.dto'; +import { + NoticeOfIntentDecisionConditionCardUuidDto, + NoticeOfIntentDecisionConditionHomeCardDto, +} from './notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.dto'; +import { NoticeOfIntentTypeDto } from '../../notice-of-intent/notice-of-intent-type/notice-of-intent-type.dto'; export class NoticeOfIntentDecisionConditionTypeDto extends BaseCodeDto { @IsBoolean() @@ -91,6 +96,49 @@ export class NoticeOfIntentDecisionConditionDto { @AutoMap() dates?: NoticeOfIntentDecisionConditionDateDto[]; + + @AutoMap(() => NoticeOfIntentDecisionConditionCardUuidDto) + conditionCard: NoticeOfIntentDecisionConditionCardUuidDto | null; + + status?: string | null; +} + +export class NoticeOfIntentHomeDto { + @AutoMap() + uuid: string; + + @AutoMap() + applicant: string; + + @AutoMap() + fileNumber: string; + + @AutoMap(() => NoticeOfIntentTypeDto) + type: NoticeOfIntentTypeDto; + + activeDays: number; + paused: boolean; + pausedDays: number; +} + +export class NoticeOfIntentDecisionHomeDto { + @AutoMap() + uuid: string; + + @AutoMap() + application: NoticeOfIntentHomeDto; +} + +export class NoticeOfIntentDecisionConditionHomeDto { + @AutoMap(() => NoticeOfIntentDecisionConditionHomeCardDto) + conditionCard: NoticeOfIntentDecisionConditionHomeCardDto | null; + + status?: string | null; + isReconsideration: boolean; + isModification: boolean; + + @AutoMap() + decision?: NoticeOfIntentDecisionHomeDto; } export class ComponentToConditionDto { @@ -135,6 +183,10 @@ export class UpdateNoticeOfIntentDecisionConditionDto { @IsOptional() @AutoMap() dates?: NoticeOfIntentDecisionConditionDateDto[]; + + @IsOptional() + @IsUUID() + conditionCardUuid?: string; } export class UpdateNoticeOfIntentDecisionConditionServiceDto { @@ -145,4 +197,5 @@ export class UpdateNoticeOfIntentDecisionConditionServiceDto { administrativeFee?: number; description?: string; dates?: NoticeOfIntentDecisionConditionDateDto[]; + conditionCardUuid?: string; } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity.ts index fe5c2ac063..061941e0f2 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity.ts @@ -6,6 +6,7 @@ import { NoticeOfIntentDecisionComponent } from '../notice-of-intent-decision-co import { NoticeOfIntentDecision } from '../notice-of-intent-decision.entity'; import { NoticeOfIntentDecisionConditionType } from './notice-of-intent-decision-condition-code.entity'; import { NoticeOfIntentDecisionConditionDate } from './notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.entity'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity'; @Entity({ comment: 'Decision Conditions for Notice of Intents', @@ -79,4 +80,9 @@ export class NoticeOfIntentDecisionCondition extends Base { cascade: ['insert', 'update'], }) dates: NoticeOfIntentDecisionConditionDate[]; + + @ManyToOne(() => NoticeOfIntentDecisionConditionCard, (conditionCard) => conditionCard.conditions, { + nullable: true, + }) + conditionCard: NoticeOfIntentDecisionConditionCard | null; } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.spec.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.spec.ts index e9abad2064..6bb71926a1 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.spec.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.spec.ts @@ -6,21 +6,30 @@ import { NoticeOfIntentDecisionConditionType } from './notice-of-intent-decision import { UpdateNoticeOfIntentDecisionConditionDto } from './notice-of-intent-decision-condition.dto'; import { NoticeOfIntentDecisionCondition } from './notice-of-intent-decision-condition.entity'; import { NoticeOfIntentDecisionConditionService } from './notice-of-intent-decision-condition.service'; +import { NoticeOfIntentModification } from '../notice-of-intent-modification/notice-of-intent-modification.entity'; +import { Mapper } from 'automapper-core'; +import { AutomapperModule } from 'automapper-nestjs'; +import { classes } from 'automapper-classes'; describe('NoticeOfIntentDecisionConditionService', () => { let service: NoticeOfIntentDecisionConditionService; - let mockNOIDecisionConditionRepository: DeepMocked< - Repository - >; - let mockNOIDecisionConditionTypeRepository: DeepMocked< - Repository - >; + let mockNOIDecisionConditionRepository: DeepMocked>; + let mockNOIDecisionConditionTypeRepository: DeepMocked>; + let mockNoticeOfIntentModificationRepository: DeepMocked>; + let mockMapper: DeepMocked; beforeEach(async () => { mockNOIDecisionConditionRepository = createMock(); mockNOIDecisionConditionTypeRepository = createMock(); + mockNoticeOfIntentModificationRepository = createMock(); + mockMapper = createMock(); const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], providers: [ NoticeOfIntentDecisionConditionService, { @@ -31,12 +40,14 @@ describe('NoticeOfIntentDecisionConditionService', () => { provide: getRepositoryToken(NoticeOfIntentDecisionConditionType), useValue: mockNOIDecisionConditionTypeRepository, }, + { + provide: getRepositoryToken(NoticeOfIntentModification), + useValue: mockNoticeOfIntentModificationRepository, + }, ], }).compile(); - service = module.get( - NoticeOfIntentDecisionConditionService, - ); + service = module.get(NoticeOfIntentDecisionConditionService); }); it('should be defined', () => { @@ -44,9 +55,7 @@ describe('NoticeOfIntentDecisionConditionService', () => { }); it('should call repo to get one or fails with correct parameters', async () => { - mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue( - new NoticeOfIntentDecisionCondition(), - ); + mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue(new NoticeOfIntentDecisionCondition()); const result = await service.getOneOrFail('fake'); @@ -59,14 +68,9 @@ describe('NoticeOfIntentDecisionConditionService', () => { }); it('calls remove method for deleted conditions', async () => { - const conditions = [ - new NoticeOfIntentDecisionCondition(), - new NoticeOfIntentDecisionCondition(), - ]; + const conditions = [new NoticeOfIntentDecisionCondition(), new NoticeOfIntentDecisionCondition()]; - mockNOIDecisionConditionRepository.remove.mockResolvedValue( - {} as NoticeOfIntentDecisionCondition, - ); + mockNOIDecisionConditionRepository.remove.mockResolvedValue({} as NoticeOfIntentDecisionCondition); await service.remove(conditions); @@ -74,9 +78,7 @@ describe('NoticeOfIntentDecisionConditionService', () => { }); it('should create new components when given a DTO without a UUID', async () => { - mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue( - new NoticeOfIntentDecisionCondition(), - ); + mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue(new NoticeOfIntentDecisionCondition()); const updateDtos: UpdateNoticeOfIntentDecisionConditionDto[] = [{}, {}]; @@ -111,12 +113,8 @@ describe('NoticeOfIntentDecisionConditionService', () => { }); it('should persist entity if persist flag is true', async () => { - mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue( - new NoticeOfIntentDecisionCondition(), - ); - mockNOIDecisionConditionRepository.save.mockResolvedValue( - new NoticeOfIntentDecisionCondition(), - ); + mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue(new NoticeOfIntentDecisionCondition()); + mockNOIDecisionConditionRepository.save.mockResolvedValue(new NoticeOfIntentDecisionCondition()); const updateDtos: UpdateNoticeOfIntentDecisionConditionDto[] = [{}]; @@ -128,12 +126,8 @@ describe('NoticeOfIntentDecisionConditionService', () => { }); it('should not persist entity if persist flag is false', async () => { - mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue( - new NoticeOfIntentDecisionCondition(), - ); - mockNOIDecisionConditionRepository.save.mockResolvedValue( - new NoticeOfIntentDecisionCondition(), - ); + mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue(new NoticeOfIntentDecisionCondition()); + mockNOIDecisionConditionRepository.save.mockResolvedValue(new NoticeOfIntentDecisionCondition()); const updateDtos: UpdateNoticeOfIntentDecisionConditionDto[] = [{}]; @@ -147,9 +141,7 @@ describe('NoticeOfIntentDecisionConditionService', () => { it('should update on the repo for update', async () => { const existingCondition = new NoticeOfIntentDecisionCondition(); mockNOIDecisionConditionRepository.update.mockResolvedValue({} as any); - mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue( - existingCondition, - ); + mockNOIDecisionConditionRepository.findOneOrFail.mockResolvedValue(existingCondition); const result = await service.update(existingCondition, { administrativeFee: 50, @@ -157,11 +149,7 @@ describe('NoticeOfIntentDecisionConditionService', () => { expect(result).toBeDefined(); expect(mockNOIDecisionConditionRepository.update).toBeCalledTimes(1); - expect( - mockNOIDecisionConditionRepository.update.mock.calls[0][1][ - 'administrativeFee' - ], - ).toEqual(50); + expect(mockNOIDecisionConditionRepository.update.mock.calls[0][1]['administrativeFee']).toEqual(50); expect(mockNOIDecisionConditionRepository.findOneOrFail).toBeCalledTimes(1); }); }); diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.ts index 76be466c4a..80fdbe900d 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.service.ts @@ -1,23 +1,42 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, Repository } from 'typeorm'; import { ServiceValidationException } from '../../../../../../libs/common/src/exceptions/base.exception'; import { NoticeOfIntentDecisionComponent } from '../notice-of-intent-decision-component/notice-of-intent-decision-component.entity'; import { NoticeOfIntentDecisionConditionType } from './notice-of-intent-decision-condition-code.entity'; import { + NoticeOfIntentDecisionConditionHomeDto, + NoticeOfIntentDecisionHomeDto, + NoticeOfIntentHomeDto, UpdateNoticeOfIntentDecisionConditionDto, UpdateNoticeOfIntentDecisionConditionServiceDto, } from './notice-of-intent-decision-condition.dto'; import { NoticeOfIntentDecisionCondition } from './notice-of-intent-decision-condition.entity'; import { NoticeOfIntentDecisionConditionDate } from './notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.entity'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; +import { NoticeOfIntentDecision } from '../notice-of-intent-decision.entity'; +import { NoticeOfIntent } from '../../notice-of-intent/notice-of-intent.entity'; +import { NoticeOfIntentModification } from '../notice-of-intent-modification/notice-of-intent-modification.entity'; +import { ApplicationTimeData } from '../../application/application-time-tracking.service'; @Injectable() export class NoticeOfIntentDecisionConditionService { + CARD_RELATIONS = { + board: true, + type: true, + status: true, + assignee: true, + }; + constructor( @InjectRepository(NoticeOfIntentDecisionCondition) private repository: Repository, @InjectRepository(NoticeOfIntentDecisionConditionType) private typeRepository: Repository, + @InjectRepository(NoticeOfIntentModification) + private modificationRepository: Repository, + @InjectMapper() private mapper: Mapper, ) {} async getByTypeCode(typeCode: string): Promise { @@ -40,6 +59,113 @@ export class NoticeOfIntentDecisionConditionService { }); } + async findByUuids(uuids: string[]): Promise { + return this.repository.find({ + where: { + uuid: In(uuids), + }, + }); + } + + async getWithIncompleteSubtaskByType(subtaskType: string) { + return this.repository.find({ + where: { + conditionCard: { + card: { + subtasks: { + completedAt: IsNull(), + type: { + code: subtaskType, + }, + }, + }, + }, + }, + relations: { + decision: { + modifies: true, + noticeOfIntent: { + type: true, + }, + }, + conditionCard: { + card: { + board: true, + type: true, + status: true, + assignee: true, + subtasks: { + card: true, + type: true, + }, + }, + }, + }, + }); + } + + getBy(findOptions: FindOptionsWhere) { + return this.repository.find({ + where: findOptions, + relations: { + decision: { + modifies: true, + noticeOfIntent: { + type: true, + }, + }, + conditionCard: { + card: this.CARD_RELATIONS, + }, + }, + }); + } + + async mapToDtos( + noticeOfIntents: NoticeOfIntentDecisionCondition[], + ): Promise { + const uuids = noticeOfIntents.map((noi) => noi.decision.noticeOfIntent.uuid); + const timeMap = await this.getTimes(uuids); + const c = Promise.all( + noticeOfIntents.map(async (c) => { + const condition = this.mapper.map(c, NoticeOfIntentDecisionCondition, NoticeOfIntentDecisionConditionHomeDto); + const decision = this.mapper.map(c.decision, NoticeOfIntentDecision, NoticeOfIntentDecisionHomeDto); + const noticeOfIntent = this.mapper.map(c.decision.noticeOfIntent, NoticeOfIntent, NoticeOfIntentHomeDto); + const appModifications = await this.modificationRepository.find({ + where: { + modifiesDecisions: { + uuid: c.decision?.uuid, + }, + }, + }); + + return { + ...condition, + isModification: appModifications.length > 0, + decision: { + ...decision, + noticeOfIntent: { + ...noticeOfIntent, + activeDays: undefined, + pausedDays: timeMap.get(noticeOfIntent.uuid)?.pausedDays ?? null, + paused: timeMap.get(noticeOfIntent.uuid)?.pausedDays !== null, + }, + }, + }; + }), + ); + return (await c).reduce( + (res: NoticeOfIntentDecisionConditionHomeDto[], curr: NoticeOfIntentDecisionConditionHomeDto) => { + const existing = res.find((e) => e.conditionCard?.cardUuid === curr.conditionCard?.cardUuid); + if (!existing) { + res.push(curr); + } + return res; + }, + [], + ); + } + async createOrUpdate( updateDtos: UpdateNoticeOfIntentDecisionConditionDto[], allComponents: NoticeOfIntentDecisionComponent[], @@ -139,4 +265,31 @@ export class NoticeOfIntentDecisionConditionService { await this.repository.update(existingCondition.uuid, updates); return await this.getOneOrFail(existingCondition.uuid); } + + async getTimes(uuids: string[]) { + const activeCounts = (await this.repository.query( + ` + SELECT * from alcs.calculate_noi_active_days($1)`, + [`{${uuids.join(', ')}}`], + )) as { + noi_uuid: string; + paused_days: number; + active_days: number; + }[]; + + const results = new Map(); + uuids.forEach((appUuid) => { + results.set(appUuid, { + pausedDays: null, + activeDays: null, + }); + }); + activeCounts.forEach((time) => { + results.set(time.noi_uuid, { + activeDays: time.active_days, + pausedDays: time.paused_days, + }); + }); + return results; + } } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.controller.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.controller.ts index a0b7b4d496..da436e37ce 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.controller.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.controller.ts @@ -9,6 +9,7 @@ import { Param, Patch, Post, + Query, Req, UseGuards, } from '@nestjs/common'; @@ -34,6 +35,10 @@ import { NoticeOfIntentModificationService } from '../notice-of-intent-modificat import { NoticeOfIntentDecisionV2Service } from './notice-of-intent-decision-v2.service'; import { NoticeOfIntentConditionStatus } from './notice-of-intent-condition-status.dto'; +export enum IncludeQueryParam { + CONDITION_STATUS = 'conditionStatus', +} + @ApiOAuth2(config.get('KEYCLOAK.SCOPES')) @Controller('notice-of-intent-decision/v2') @UseGuards(RolesGuard) @@ -47,17 +52,10 @@ export class NoticeOfIntentDecisionV2Controller { @Get('/notice-of-intent/:fileNumber') @UserRoles(...ANY_AUTH_ROLE) - async getByFileNumber( - @Param('fileNumber') fileNumber, - ): Promise { - const decisions = - await this.noticeOfIntentDecisionV2Service.getByFileNumber(fileNumber); - - return await this.mapper.mapArrayAsync( - decisions, - NoticeOfIntentDecision, - NoticeOfIntentDecisionDto, - ); + async getByFileNumber(@Param('fileNumber') fileNumber): Promise { + const decisions = await this.noticeOfIntentDecisionV2Service.getByFileNumber(fileNumber); + + return await this.mapper.mapArrayAsync(decisions, NoticeOfIntentDecision, NoticeOfIntentDecisionDto); } @Get('/condition/:uuid/status') @@ -66,7 +64,7 @@ export class NoticeOfIntentDecisionV2Controller { const status = await this.noticeOfIntentDecisionV2Service.getDecisionConditionStatus(uuid); return { uuid: uuid, - status: status && status.length > 0 ? status[0]['get_current_status_for_noi_condition'] : '', + status: status, }; } @@ -95,40 +93,36 @@ export class NoticeOfIntentDecisionV2Controller { @Get('/:uuid') @UserRoles(...ANY_AUTH_ROLE) - async get(@Param('uuid') uuid: string): Promise { + async get( + @Param('uuid') uuid: string, + @Query('include') include?: IncludeQueryParam, + ): Promise { const decision = await this.noticeOfIntentDecisionV2Service.get(uuid); - return this.mapper.mapAsync( - decision, - NoticeOfIntentDecision, - NoticeOfIntentDecisionDto, - ); + const decisionDto = await this.mapper.mapAsync(decision, NoticeOfIntentDecision, NoticeOfIntentDecisionDto); + + if (include === IncludeQueryParam.CONDITION_STATUS) { + for (const condition of decisionDto.conditions!) { + const status = await this.noticeOfIntentDecisionV2Service.getDecisionConditionStatus(condition.uuid); + condition.status = status !== '' ? status : undefined; + } + } + + return decisionDto; } @Post() @UserRoles(...ANY_AUTH_ROLE) - async create( - @Body() createDto: CreateNoticeOfIntentDecisionDto, - ): Promise { - const noticeOfIntent = await this.noticeOfIntentService.getByFileNumber( - createDto.fileNumber, - ); + async create(@Body() createDto: CreateNoticeOfIntentDecisionDto): Promise { + const noticeOfIntent = await this.noticeOfIntentService.getByFileNumber(createDto.fileNumber); const modification = createDto.modifiesUuid ? await this.modificationService.getByUuid(createDto.modifiesUuid) : undefined; - const newDecision = await this.noticeOfIntentDecisionV2Service.create( - createDto, - noticeOfIntent, - modification, - ); - - return this.mapper.mapAsync( - newDecision, - NoticeOfIntentDecision, - NoticeOfIntentDecisionDto, - ); + const newDecision = await this.noticeOfIntentDecisionV2Service.create(createDto, noticeOfIntent, modification); + + return this.mapper.mapAsync(newDecision, NoticeOfIntentDecision, NoticeOfIntentDecisionDto); } @Patch('/:uuid') @@ -139,24 +133,14 @@ export class NoticeOfIntentDecisionV2Controller { ): Promise { let modifies; if (updateDto.modifiesUuid) { - modifies = await this.modificationService.getByUuid( - updateDto.modifiesUuid, - ); + modifies = await this.modificationService.getByUuid(updateDto.modifiesUuid); } else if (updateDto.modifiesUuid === null) { modifies = null; } - const updatedDecision = await this.noticeOfIntentDecisionV2Service.update( - uuid, - updateDto, - modifies, - ); - - return this.mapper.mapAsync( - updatedDecision, - NoticeOfIntentDecision, - NoticeOfIntentDecisionDto, - ); + const updatedDecision = await this.noticeOfIntentDecisionV2Service.update(uuid, updateDto, modifies); + + return this.mapper.mapAsync(updatedDecision, NoticeOfIntentDecision, NoticeOfIntentDecisionDto); } @Delete('/:uuid') @@ -173,11 +157,7 @@ export class NoticeOfIntentDecisionV2Controller { } const file = req.body.file; - await this.noticeOfIntentDecisionV2Service.attachDocument( - decisionUuid, - file, - req.user.entity, - ); + await this.noticeOfIntentDecisionV2Service.attachDocument(decisionUuid, file, req.user.entity); return { uploaded: true, }; @@ -190,10 +170,7 @@ export class NoticeOfIntentDecisionV2Controller { @Param('documentUuid') documentUuid: string, @Body() body: { fileName: string }, ) { - await this.noticeOfIntentDecisionV2Service.updateDocument( - documentUuid, - body.fileName, - ); + await this.noticeOfIntentDecisionV2Service.updateDocument(documentUuid, body.fileName); return { uploaded: true, }; @@ -201,12 +178,8 @@ export class NoticeOfIntentDecisionV2Controller { @Get('/:uuid/file/:fileUuid/download') @UserRoles(...ANY_AUTH_ROLE) - async getDownloadUrl( - @Param('uuid') decisionUuid: string, - @Param('fileUuid') documentUuid: string, - ) { - const downloadUrl = - await this.noticeOfIntentDecisionV2Service.getDownloadUrl(documentUuid); + async getDownloadUrl(@Param('uuid') decisionUuid: string, @Param('fileUuid') documentUuid: string) { + const downloadUrl = await this.noticeOfIntentDecisionV2Service.getDownloadUrl(documentUuid); return { url: downloadUrl, }; @@ -214,15 +187,8 @@ export class NoticeOfIntentDecisionV2Controller { @Get('/:uuid/file/:fileUuid/open') @UserRoles(...ANY_AUTH_ROLE) - async getOpenUrl( - @Param('uuid') decisionUuid: string, - @Param('fileUuid') documentUuid: string, - ) { - const downloadUrl = - await this.noticeOfIntentDecisionV2Service.getDownloadUrl( - documentUuid, - true, - ); + async getOpenUrl(@Param('uuid') decisionUuid: string, @Param('fileUuid') documentUuid: string) { + const downloadUrl = await this.noticeOfIntentDecisionV2Service.getDownloadUrl(documentUuid, true); return { url: downloadUrl, }; @@ -230,21 +196,14 @@ export class NoticeOfIntentDecisionV2Controller { @Delete('/:uuid/file/:fileUuid') @UserRoles(...ANY_AUTH_ROLE) - async deleteDocument( - @Param('uuid') decisionUuid: string, - @Param('fileUuid') documentUuid: string, - ) { + async deleteDocument(@Param('uuid') decisionUuid: string, @Param('fileUuid') documentUuid: string) { await this.noticeOfIntentDecisionV2Service.deleteDocument(documentUuid); return {}; } @Get('next-resolution-number/:resolutionYear') @UserRoles(...ANY_AUTH_ROLE) - async getNextAvailableResolutionNumber( - @Param('resolutionYear') resolutionYear: number, - ) { - return this.noticeOfIntentDecisionV2Service.generateResolutionNumber( - resolutionYear, - ); + async getNextAvailableResolutionNumber(@Param('resolutionYear') resolutionYear: number) { + return this.noticeOfIntentDecisionV2Service.generateResolutionNumber(resolutionYear); } } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.spec.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.spec.ts index 6dc21dc9ce..cde33cdb49 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.spec.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.spec.ts @@ -1,7 +1,4 @@ -import { - ServiceNotFoundException, - ServiceValidationException, -} from '@app/common/exceptions/base.exception'; +import { ServiceNotFoundException, ServiceValidationException } from '@app/common/exceptions/base.exception'; import { classes } from 'automapper-classes'; import { AutomapperModule } from 'automapper-nestjs'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; @@ -22,30 +19,27 @@ import { NoticeOfIntentDecisionCondition } from '../notice-of-intent-decision-co import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition.service'; import { NoticeOfIntentDecisionDocument } from '../notice-of-intent-decision-document/notice-of-intent-decision-document.entity'; import { NoticeOfIntentDecisionOutcome } from '../notice-of-intent-decision-outcome.entity'; -import { - CreateNoticeOfIntentDecisionDto, - UpdateNoticeOfIntentDecisionDto, -} from '../notice-of-intent-decision.dto'; +import { CreateNoticeOfIntentDecisionDto, UpdateNoticeOfIntentDecisionDto } from '../notice-of-intent-decision.dto'; import { NoticeOfIntentDecision } from '../notice-of-intent-decision.entity'; import { NoticeOfIntentDecisionV2Service } from './notice-of-intent-decision-v2.service'; +import { User } from '../../../user/user.entity'; +import { NoticeOfIntentDecisionConditionCardService } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionDateService } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service'; describe('NoticeOfIntentDecisionV2Service', () => { let service: NoticeOfIntentDecisionV2Service; let mockDecisionRepository: DeepMocked>; - let mockDecisionDocumentRepository: DeepMocked< - Repository - >; - let mockDecisionOutcomeRepository: DeepMocked< - Repository - >; + let mockDecisionDocumentRepository: DeepMocked>; + let mockDecisionOutcomeRepository: DeepMocked>; let mockNoticeOfIntentService: DeepMocked; let mockDocumentService: DeepMocked; - let mockNoticeOfIntentDecisionComponentTypeRepository: DeepMocked< - Repository - >; + let mockNoticeOfIntentDecisionComponentTypeRepository: DeepMocked>; + let mockUserRepository: DeepMocked>; let mockDecisionComponentService: DeepMocked; let mockDecisionConditionService: DeepMocked; let mockNoticeOfIntentSubmissionStatusService: DeepMocked; + let mockNoticeOfIntentDecisionConditionCardService: DeepMocked; + let mockNoticeOfIntentDecisionConditionDateService: DeepMocked; let mockdataSource: DeepMocked; let mockNoticeOfIntent; @@ -55,14 +49,15 @@ describe('NoticeOfIntentDecisionV2Service', () => { mockNoticeOfIntentService = createMock(); mockDocumentService = createMock(); mockDecisionRepository = createMock>(); - mockDecisionDocumentRepository = - createMock>(); - mockDecisionOutcomeRepository = - createMock>(); + mockDecisionDocumentRepository = createMock>(); + mockDecisionOutcomeRepository = createMock>(); + mockUserRepository = createMock>(); mockNoticeOfIntentDecisionComponentTypeRepository = createMock(); mockDecisionComponentService = createMock(); mockDecisionConditionService = createMock(); mockNoticeOfIntentSubmissionStatusService = createMock(); + mockNoticeOfIntentDecisionConditionCardService = createMock(); + mockNoticeOfIntentDecisionConditionDateService = createMock(); mockdataSource = createMock(); const module: TestingModule = await Test.createTestingModule({ @@ -97,6 +92,10 @@ describe('NoticeOfIntentDecisionV2Service', () => { provide: getRepositoryToken(NoticeOfIntentDecisionComponentType), useValue: mockNoticeOfIntentDecisionComponentTypeRepository, }, + { + provide: getRepositoryToken(User), + useValue: mockUserRepository, + }, { provide: NoticeOfIntentDecisionComponentService, useValue: mockDecisionComponentService, @@ -113,6 +112,14 @@ describe('NoticeOfIntentDecisionV2Service', () => { provide: NoticeOfIntentSubmissionStatusService, useValue: mockNoticeOfIntentSubmissionStatusService, }, + { + provide: NoticeOfIntentDecisionConditionCardService, + useValue: mockNoticeOfIntentDecisionConditionCardService, + }, + { + provide: NoticeOfIntentDecisionConditionDateService, + useValue: mockNoticeOfIntentDecisionConditionDateService, + }, { provide: DataSource, useValue: mockdataSource, @@ -120,9 +127,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { ], }).compile(); - service = module.get( - NoticeOfIntentDecisionV2Service, - ); + service = module.get(NoticeOfIntentDecisionV2Service); mockNoticeOfIntent = new NoticeOfIntent({ uuid: '1111-1111-1111-1111', @@ -130,6 +135,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { mockDecision = new NoticeOfIntentDecision({ noticeOfIntent: mockNoticeOfIntent, documents: [], + conditions: [], }); mockDecisionRepository.find.mockResolvedValue([mockDecision]); @@ -138,9 +144,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { mockDecisionDocumentRepository.find.mockResolvedValue([]); - mockNoticeOfIntentService.getByFileNumber.mockResolvedValue( - mockNoticeOfIntent, - ); + mockNoticeOfIntentService.getByFileNumber.mockResolvedValue(mockNoticeOfIntent); mockNoticeOfIntentService.update.mockResolvedValue({} as any); mockNoticeOfIntentService.updateByUuid.mockResolvedValue({} as any); mockNoticeOfIntentService.getUuid.mockResolvedValue('uuid'); @@ -148,14 +152,10 @@ describe('NoticeOfIntentDecisionV2Service', () => { mockDecisionOutcomeRepository.find.mockResolvedValue([]); mockDecisionOutcomeRepository.findOneOrFail.mockResolvedValue({} as any); - mockNoticeOfIntentDecisionComponentTypeRepository.find.mockResolvedValue( - [], - ); + mockNoticeOfIntentDecisionComponentTypeRepository.find.mockResolvedValue([]); mockDecisionComponentService.createOrUpdate.mockResolvedValue([]); mockDecisionConditionService.remove.mockResolvedValue({} as any); - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber.mockResolvedValue( - {} as any, - ); + mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber.mockResolvedValue({} as any); }); describe('NoticeOfIntentDecisionService Core Tests', () => { @@ -164,9 +164,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { }); it('should get decisions by notice of intent', async () => { - const result = await service.getByFileNumber( - mockNoticeOfIntent.fileNumber, - ); + const result = await service.getByFileNumber(mockNoticeOfIntent.fileNumber); expect(result).toStrictEqual([mockDecision]); }); @@ -192,18 +190,11 @@ describe('NoticeOfIntentDecisionV2Service', () => { expect(mockDecisionRepository.save.mock.calls[0][0].modifies).toBeNull(); expect(mockDecisionRepository.softRemove).toBeCalledTimes(1); expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledTimes(1); - expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledWith( - mockNoticeOfIntent.uuid, - { - decisionDate: null, - }, - ); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledTimes(1); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledWith( + expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledWith(mockNoticeOfIntent.uuid, { + decisionDate: null, + }); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).toBeCalledTimes(1); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).toBeCalledWith( mockNoticeOfIntent.fileNumber, NOI_SUBMISSION_STATUS.ALC_DECISION, null, @@ -270,20 +261,14 @@ describe('NoticeOfIntentDecisionV2Service', () => { expect(finalDecision.decisionMaker).toEqual(decision.decisionMaker); expect(finalDecision.outcomeCode).toEqual(decision.outcomeCode); - expect(finalDecision.decisionMakerName).toEqual( - decision.decisionMakerName, - ); - expect(finalDecision.isSubjectToConditions).toEqual( - decision.isSubjectToConditions, - ); + expect(finalDecision.decisionMakerName).toEqual(decision.decisionMakerName); + expect(finalDecision.isSubjectToConditions).toEqual(decision.isSubjectToConditions); expect(finalDecision.components?.length).toEqual(1); expect(finalDecision.conditions?.length).toEqual(1); }); it('should fail create a decision if the resolution number is already in use', async () => { - mockDecisionRepository.findOne.mockResolvedValue( - {} as NoticeOfIntentDecision, - ); + mockDecisionRepository.findOne.mockResolvedValue({} as NoticeOfIntentDecision); mockDecisionRepository.exist.mockResolvedValueOnce(false); const decisionDate = new Date(2022, 2, 2, 2, 2, 2, 2); @@ -296,9 +281,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { isDraft: true, } as CreateNoticeOfIntentDecisionDto; - await expect( - service.create(decisionToCreate, mockNoticeOfIntent, undefined), - ).rejects.toMatchObject( + await expect(service.create(decisionToCreate, mockNoticeOfIntent, undefined)).rejects.toMatchObject( new ServiceValidationException( `Resolution number #${decisionToCreate.resolutionNumber}/${decisionToCreate.resolutionYear} is already in use`, ), @@ -324,12 +307,8 @@ describe('NoticeOfIntentDecisionV2Service', () => { isDraft: true, } as CreateNoticeOfIntentDecisionDto; - await expect( - service.create(decisionToCreate, mockNoticeOfIntent, undefined), - ).rejects.toMatchObject( - new ServiceValidationException( - 'Draft decision already exists for this notice of intent.', - ), + await expect(service.create(decisionToCreate, mockNoticeOfIntent, undefined)).rejects.toMatchObject( + new ServiceValidationException('Draft decision already exists for this notice of intent.'), ); expect(mockDecisionRepository.save).toBeCalledTimes(0); @@ -353,9 +332,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockNoticeOfIntentService.update).not.toHaveBeenCalled(); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).not.toHaveBeenCalled(); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).not.toHaveBeenCalled(); }); it('should update the decision and update the notice of intent and submission status if it was the only decision', async () => { @@ -387,18 +364,11 @@ describe('NoticeOfIntentDecisionV2Service', () => { expect(mockDecisionRepository.findOne).toBeCalledTimes(2); expect(mockDecisionRepository.save).toHaveBeenCalledTimes(1); expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledTimes(1); - expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledWith( - mockNoticeOfIntent.uuid, - { - decisionDate, - }, - ); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledTimes(1); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledWith( + expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledWith(mockNoticeOfIntent.uuid, { + decisionDate, + }); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).toBeCalledTimes(1); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).toBeCalledWith( mockNoticeOfIntent.fileNumber, NOI_SUBMISSION_STATUS.ALC_DECISION, decisionDate, @@ -418,18 +388,11 @@ describe('NoticeOfIntentDecisionV2Service', () => { expect(mockDecisionRepository.findOne).toBeCalledTimes(2); expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockNoticeOfIntentService.updateByUuid).toHaveBeenCalledTimes(1); - expect(mockNoticeOfIntentService.updateByUuid).toBeCalledWith( - '1111-1111-1111-1111', - { - decisionDate: null, - }, - ); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledTimes(1); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).toBeCalledWith( + expect(mockNoticeOfIntentService.updateByUuid).toBeCalledWith('1111-1111-1111-1111', { + decisionDate: null, + }); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).toBeCalledTimes(1); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).toBeCalledWith( mockNoticeOfIntent.fileNumber, NOI_SUBMISSION_STATUS.ALC_DECISION, null, @@ -443,10 +406,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { }); secondDecision.isDraft = true; secondDecision.uuid = 'second-uuid'; - mockDecisionRepository.find.mockResolvedValue([ - secondDecision, - mockDecision, - ]); + mockDecisionRepository.find.mockResolvedValue([secondDecision, mockDecision]); mockDecisionRepository.findOne.mockResolvedValue(secondDecision); const decisionUpdate: UpdateNoticeOfIntentDecisionDto = { @@ -459,9 +419,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { expect(mockDecisionRepository.findOne).toBeCalledTimes(2); expect(mockDecisionRepository.save).toBeCalledTimes(1); expect(mockNoticeOfIntentService.update).not.toHaveBeenCalled(); - expect( - mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber, - ).not.toHaveBeenCalled(); + expect(mockNoticeOfIntentSubmissionStatusService.setStatusDateByFileNumber).not.toHaveBeenCalled(); }); it('should fail on update if the decision is not found', async () => { @@ -472,16 +430,10 @@ describe('NoticeOfIntentDecisionV2Service', () => { outcomeCode: 'New Outcome', isDraft: true, }; - const promise = service.update( - nonExistantUuid, - decisionUpdate, - undefined, - ); + const promise = service.update(nonExistantUuid, decisionUpdate, undefined); await expect(promise).rejects.toMatchObject( - new ServiceNotFoundException( - `Decision with UUID ${nonExistantUuid} not found`, - ), + new ServiceNotFoundException(`Decision with UUID ${nonExistantUuid} not found`), ); expect(mockDecisionRepository.save).toBeCalledTimes(0); }); @@ -519,9 +471,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { }); it('should call the repository to check if portal user can download document', async () => { - mockDecisionDocumentRepository.findOne.mockResolvedValue( - new NoticeOfIntentDecisionDocument(), - ); + mockDecisionDocumentRepository.findOne.mockResolvedValue(new NoticeOfIntentDecisionDocument()); mockDocumentService.getDownloadUrl.mockResolvedValue(''); await service.getDownloadForPortal('fake-uuid'); @@ -531,9 +481,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { 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( + await expect(service.attachDocument('uuid', {} as any, {} as any)).rejects.toMatchObject( new ServiceNotFoundException(`Decision with UUID uuid not found`), ); expect(mockDocumentService.create).not.toHaveBeenCalled(); @@ -543,17 +491,13 @@ describe('NoticeOfIntentDecisionV2Service', () => { mockDecisionDocumentRepository.softRemove.mockResolvedValue({} as any); await service.deleteDocument('fake-uuid'); - expect(mockDecisionDocumentRepository.softRemove).toHaveBeenCalledTimes( - 1, - ); + expect(mockDecisionDocumentRepository.softRemove).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`, - ), + new ServiceNotFoundException(`Failed to find document with uuid fake-uuid`), ); expect(mockDocumentService.softRemove).not.toHaveBeenCalled(); }); @@ -578,9 +522,7 @@ describe('NoticeOfIntentDecisionV2Service', () => { 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`, - ), + new ServiceNotFoundException(`Failed to find document with uuid fake-uuid`), ); }); }); diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.ts index 72dbdf0b4a..fb11643fc8 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision-v2/notice-of-intent-decision-v2.service.ts @@ -1,16 +1,10 @@ -import { - ServiceNotFoundException, - ServiceValidationException, -} from '@app/common/exceptions/base.exception'; +import { ServiceNotFoundException, ServiceValidationException } from '@app/common/exceptions/base.exception'; import { MultipartFile } from '@fastify/multipart'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, LessThan, Repository, DataSource } from 'typeorm'; import { v4 } from 'uuid'; -import { - DOCUMENT_SOURCE, - DOCUMENT_SYSTEM, -} from '../../../document/document.dto'; +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'; @@ -26,13 +20,12 @@ import { NoticeOfIntentDecisionCondition } from '../notice-of-intent-decision-co import { NoticeOfIntentDecisionConditionService } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition.service'; import { NoticeOfIntentDecisionDocument } from '../notice-of-intent-decision-document/notice-of-intent-decision-document.entity'; import { NoticeOfIntentDecisionOutcome } from '../notice-of-intent-decision-outcome.entity'; -import { - CreateNoticeOfIntentDecisionDto, - UpdateNoticeOfIntentDecisionDto, -} from '../notice-of-intent-decision.dto'; +import { CreateNoticeOfIntentDecisionDto, UpdateNoticeOfIntentDecisionDto } from '../notice-of-intent-decision.dto'; import { NoticeOfIntentDecision } from '../notice-of-intent-decision.entity'; import { NoticeOfIntentModification } from '../notice-of-intent-modification/notice-of-intent-modification.entity'; import { NoticeOfIntentDecisionConditionDate } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.entity'; +import { NoticeOfIntentDecisionConditionCardService } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionDateService } from '../notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service'; @Injectable() export class NoticeOfIntentDecisionV2Service { @@ -47,11 +40,19 @@ export class NoticeOfIntentDecisionV2Service { private decisionComponentTypeRepository: Repository, @InjectRepository(NoticeOfIntentDecisionConditionType) private decisionConditionTypeRepository: Repository, + @InjectRepository(NoticeOfIntentDecisionDocument) + private noticeOfIntentDecisionDocumentRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + @Inject(forwardRef(() => NoticeOfIntentService)) private noticeOfIntentService: NoticeOfIntentService, private documentService: DocumentService, private decisionComponentService: NoticeOfIntentDecisionComponentService, private decisionConditionService: NoticeOfIntentDecisionConditionService, private noticeOfIntentSubmissionStatusService: NoticeOfIntentSubmissionStatusService, + private dateService: NoticeOfIntentDecisionConditionDateService, + @Inject(forwardRef(() => NoticeOfIntentDecisionConditionCardService)) + private noticeOfIntentDecisionConditionCardService: NoticeOfIntentDecisionConditionCardService, private dataSource: DataSource, ) {} @@ -97,33 +98,32 @@ export class NoticeOfIntentDecisionV2Service { type: true, components: true, dates: true, + conditionCard: true, }, + conditionCards: true, }, }); // do not place modifiedBy into query above, it will kill performance - const decisionsWithModifiedBy = - await this.noticeOfIntentDecisionRepository.find({ - where: { - noticeOfIntentUuid, - modifiedBy: { - resultingDecision: { - isDraft: false, - }, + const decisionsWithModifiedBy = await this.noticeOfIntentDecisionRepository.find({ + where: { + noticeOfIntentUuid, + modifiedBy: { + resultingDecision: { + isDraft: false, }, }, - relations: { - modifiedBy: { - resultingDecision: true, - reviewOutcome: true, - }, + }, + relations: { + modifiedBy: { + resultingDecision: true, + reviewOutcome: true, }, - }); + }, + }); for (const decision of decisions) { - decision.modifiedBy = - decisionsWithModifiedBy.find((r) => r.uuid === decision.uuid) - ?.modifiedBy || []; + decision.modifiedBy = decisionsWithModifiedBy.find((r) => r.uuid === decision.uuid)?.modifiedBy || []; } //Query Documents separately as when added to the above joins caused performance issues @@ -163,19 +163,21 @@ export class NoticeOfIntentDecisionV2Service { type: true, components: true, dates: true, + conditionCard: true, }, + conditionCards: true, }, }); if (!decision) { - throw new ServiceNotFoundException( - `Failed to load decision with uuid ${uuid}`, - ); + throw new ServiceNotFoundException(`Failed to load decision with uuid ${uuid}`); } - decision.documents = decision.documents.filter( - (document) => !!document.document, - ); + decision.documents = decision.documents.filter((document) => !!document.document); + + if (decision.conditions) { + decision.conditions.sort((a, b) => a.auditCreatedAt.getTime() - b.auditCreatedAt.getTime()); + } return decision; } @@ -185,8 +187,7 @@ export class NoticeOfIntentDecisionV2Service { updateDto: UpdateNoticeOfIntentDecisionDto, modifies: NoticeOfIntentModification | undefined | null, ) { - const existingDecision: Partial = - await this.getOrFail(uuid); + const existingDecision: Partial = await this.getOrFail(uuid); // resolution number is int64 in postgres, which means it is a string in JS if ( @@ -195,14 +196,10 @@ export class NoticeOfIntentDecisionV2Service { (existingDecision.resolutionNumber !== updateDto.resolutionNumber || existingDecision.resolutionYear !== updateDto.resolutionYear) ) { - await this.validateResolutionNumber( - updateDto.resolutionNumber, - updateDto.resolutionYear, - ); + await this.validateResolutionNumber(updateDto.resolutionNumber, updateDto.resolutionYear); } - const isChangingDraftStatus = - existingDecision.isDraft !== updateDto.isDraft; + const isChangingDraftStatus = existingDecision.isDraft !== updateDto.isDraft; existingDecision.auditDate = formatIncomingDate(updateDto.auditDate); existingDecision.modifies = modifies; @@ -213,26 +210,44 @@ export class NoticeOfIntentDecisionV2Service { existingDecision.isSubjectToConditions = updateDto.isSubjectToConditions; existingDecision.decisionDescription = updateDto.decisionDescription; existingDecision.isDraft = updateDto.isDraft; - existingDecision.rescindedDate = formatIncomingDate( - updateDto.rescindedDate, - ); + existingDecision.rescindedDate = formatIncomingDate(updateDto.rescindedDate); existingDecision.rescindedComment = updateDto.rescindedComment; - existingDecision.wasReleased = - existingDecision.wasReleased || !updateDto.isDraft; + existingDecision.wasReleased = existingDecision.wasReleased || !updateDto.isDraft; existingDecision.emailSent = updateDto.emailSent; existingDecision.ccEmails = updateDto.ccEmails; + existingDecision.isFlagged = updateDto.isFlagged; + existingDecision.reasonFlagged = updateDto.reasonFlagged; + existingDecision.followUpAt = formatIncomingDate(updateDto.followUpAt); + existingDecision.flagEditedAt = formatIncomingDate(updateDto.flagEditedAt); + + if (updateDto.flaggedByUuid !== undefined) { + existingDecision.flaggedBy = + updateDto.flaggedByUuid === null + ? null + : await this.userRepository.findOne({ + where: { + uuid: updateDto.flaggedByUuid, + }, + }); + } + + if (updateDto.flagEditedByUuid !== undefined) { + existingDecision.flagEditedBy = + updateDto.flagEditedByUuid === null + ? null + : await this.userRepository.findOne({ + where: { + uuid: updateDto.flagEditedByUuid, + }, + }); + } if (updateDto.outcomeCode) { - existingDecision.outcome = await this.getOutcomeByCode( - updateDto.outcomeCode, - ); + existingDecision.outcome = await this.getOutcomeByCode(updateDto.outcomeCode); } let dateHasChanged = false; - if ( - updateDto.date !== undefined && - existingDecision.date !== formatIncomingDate(updateDto.date) - ) { + if (updateDto.date !== undefined && existingDecision.date !== formatIncomingDate(updateDto.date)) { dateHasChanged = true; existingDecision.date = formatIncomingDate(updateDto.date); } @@ -242,8 +257,7 @@ export class NoticeOfIntentDecisionV2Service { //Must be called after update components await this.updateConditions(updateDto, existingDecision); - const updatedDecision = - await this.noticeOfIntentDecisionRepository.save(existingDecision); + const updatedDecision = await this.noticeOfIntentDecisionRepository.save(existingDecision); if (dateHasChanged || isChangingDraftStatus) { await this.updateDecisionDates(updatedDecision); @@ -358,56 +372,60 @@ export class NoticeOfIntentDecisionV2Service { } // we do not need to include deleted items since there may be multiple deleted draft decision wih the same or different numbers - const existingDecision = - await this.noticeOfIntentDecisionRepository.findOne({ - where: { - resolutionNumber: number, - resolutionYear: year ?? IsNull(), - }, - }); + const existingDecision = await this.noticeOfIntentDecisionRepository.findOne({ + where: { + resolutionNumber: number, + resolutionYear: year ?? IsNull(), + }, + }); if (existingDecision) { - throw new ServiceValidationException( - `Resolution number #${number}/${year} is already in use`, - ); + throw new ServiceValidationException(`Resolution number #${number}/${year} is already in use`); } } async delete(uuid) { - const noticeOfIntentDecision = - await this.noticeOfIntentDecisionRepository.findOne({ - where: { uuid }, - relations: { - outcome: true, - documents: { - document: true, - }, - noticeOfIntent: true, - components: true, + const noticeOfIntentDecision = await this.noticeOfIntentDecisionRepository.findOne({ + where: { uuid }, + relations: { + conditions: { + dates: true, }, - }); + outcome: true, + documents: { + document: true, + }, + noticeOfIntent: true, + components: true, + conditionCards: true, + }, + }); if (!noticeOfIntentDecision) { - throw new ServiceNotFoundException( - `Failed to find decision with uuid ${uuid}`, - ); + throw new ServiceNotFoundException(`Failed to find decision with uuid ${uuid}`); } + await this.decisionConditionService.remove(noticeOfIntentDecision.conditions); + noticeOfIntentDecision.conditions = []; + for (const document of noticeOfIntentDecision.documents) { + await this.noticeOfIntentDecisionDocumentRepository.softRemove(document); await this.documentService.softRemove(document.document); } - await this.decisionComponentService.softRemove( - noticeOfIntentDecision.components, - ); + await this.decisionComponentService.softRemove(noticeOfIntentDecision.components); + + if (noticeOfIntentDecision.conditionCards && noticeOfIntentDecision.conditionCards.length > 0) { + for (const conditionCard of noticeOfIntentDecision.conditionCards) { + await this.noticeOfIntentDecisionConditionCardService.softRemove(conditionCard); + } + } //Clear potential links noticeOfIntentDecision.modifies = null; await this.noticeOfIntentDecisionRepository.save(noticeOfIntentDecision); - await this.noticeOfIntentDecisionRepository.softRemove([ - noticeOfIntentDecision, - ]); + await this.noticeOfIntentDecisionRepository.softRemove([noticeOfIntentDecision]); await this.updateDecisionDates(noticeOfIntentDecision); } @@ -430,21 +448,16 @@ export class NoticeOfIntentDecisionV2Service { } async deleteDocument(decisionDocumentUuid: string) { - const decisionDocument = - await this.getDecisionDocumentOrFail(decisionDocumentUuid); + 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); + const decisionDocument = await this.getDecisionDocumentOrFail(decisionDocumentUuid); - return this.documentService.getDownloadUrl( - decisionDocument.document, - openInline, - ); + return this.documentService.getDownloadUrl(decisionDocument.document, openInline); } async getDownloadForPortal(decisionDocumentUuid: string) { @@ -517,39 +530,24 @@ export class NoticeOfIntentDecisionV2Service { existingDecision: Partial, ) { if (updateDto.decisionComponents) { - if ( - existingDecision.outcomeCode && - ['APPA', 'APPR'].includes(existingDecision.outcomeCode) - ) { - this.decisionComponentService.validate( - updateDto.decisionComponents, - updateDto.isDraft, - ); + if (existingDecision.outcomeCode && ['APPA', 'APPR'].includes(existingDecision.outcomeCode)) { + this.decisionComponentService.validate(updateDto.decisionComponents, updateDto.isDraft); } if (existingDecision.components) { const componentsToRemove = existingDecision.components.filter( - (component) => - !updateDto.decisionComponents?.some( - (componentDto) => componentDto.uuid === component.uuid, - ), + (component) => !updateDto.decisionComponents?.some((componentDto) => componentDto.uuid === component.uuid), ); await this.decisionComponentService.softRemove(componentsToRemove); } - existingDecision.components = - await this.decisionComponentService.createOrUpdate( - updateDto.decisionComponents, - false, - ); - } else if ( - updateDto.decisionComponents === null && - existingDecision.components - ) { - await this.decisionComponentService.softRemove( - existingDecision.components, + existingDecision.components = await this.decisionComponentService.createOrUpdate( + updateDto.decisionComponents, + false, ); + } else if (updateDto.decisionComponents === null && existingDecision.components) { + await this.decisionComponentService.softRemove(existingDecision.components); } } @@ -560,27 +558,22 @@ export class NoticeOfIntentDecisionV2Service { if (updateDto.conditions) { if (existingDecision.noticeOfIntentUuid && existingDecision.conditions) { const conditionsToRemove = existingDecision.conditions.filter( - (condition) => - !updateDto.conditions?.some( - (conditionDto) => conditionDto.uuid === condition.uuid, - ), + (condition) => !updateDto.conditions?.some((conditionDto) => conditionDto.uuid === condition.uuid), ); await this.decisionConditionService.remove(conditionsToRemove); } - const existingComponents = - await this.decisionComponentService.getAllByNoticeOfIntentUUID( - existingDecision.noticeOfIntentUuid!, - ); + const existingComponents = await this.decisionComponentService.getAllByNoticeOfIntentUUID( + existingDecision.noticeOfIntentUuid!, + ); - existingDecision.conditions = - await this.decisionConditionService.createOrUpdate( - updateDto.conditions, - existingComponents, - existingDecision.components ?? [], - false, - ); + existingDecision.conditions = await this.decisionConditionService.createOrUpdate( + updateDto.conditions, + existingComponents, + existingDecision.components ?? [], + false, + ); } else if (updateDto.conditions === null && existingDecision.conditions) { await this.decisionConditionService.remove(existingDecision.conditions); } @@ -601,29 +594,18 @@ export class NoticeOfIntentDecisionV2Service { }); if (!existingDecision) { - throw new ServiceNotFoundException( - `Decision with UUID ${uuid} not found`, - ); + throw new ServiceNotFoundException(`Decision with UUID ${uuid} not found`); } return existingDecision; } - private async updateDecisionDates( - noticeOfIntentDecision: NoticeOfIntentDecision, - ) { - const existingDecisions = await this.getByFileNumber( - noticeOfIntentDecision.noticeOfIntent.fileNumber, - ); - const releasedDecisions = existingDecisions.filter( - (decision) => !decision.isDraft, - ); + private async updateDecisionDates(noticeOfIntentDecision: NoticeOfIntentDecision) { + const existingDecisions = await this.getByFileNumber(noticeOfIntentDecision.noticeOfIntent.fileNumber); + const releasedDecisions = existingDecisions.filter((decision) => !decision.isDraft); if (releasedDecisions.length === 0) { - await this.noticeOfIntentService.updateByUuid( - noticeOfIntentDecision.noticeOfIntent.uuid, - { - decisionDate: null, - }, - ); + await this.noticeOfIntentService.updateByUuid(noticeOfIntentDecision.noticeOfIntent.uuid, { + decisionDate: null, + }); await this.noticeOfIntentSubmissionStatusService.setStatusDateByFileNumber( noticeOfIntentDecision.noticeOfIntent.fileNumber, @@ -632,24 +614,15 @@ export class NoticeOfIntentDecisionV2Service { ); } else { const decisionDate = existingDecisions[existingDecisions.length - 1].date; - await this.noticeOfIntentService.updateByUuid( - noticeOfIntentDecision.noticeOfIntent.uuid, - { - decisionDate, - }, - ); - - await this.setDecisionReleasedStatus( + await this.noticeOfIntentService.updateByUuid(noticeOfIntentDecision.noticeOfIntent.uuid, { decisionDate, - noticeOfIntentDecision, - ); + }); + + await this.setDecisionReleasedStatus(decisionDate, noticeOfIntentDecision); } } - private async setDecisionReleasedStatus( - decisionDate: Date | null, - noticeOfIntentDecision: NoticeOfIntentDecision, - ) { + private async setDecisionReleasedStatus(decisionDate: Date | null, noticeOfIntentDecision: NoticeOfIntentDecision) { await this.noticeOfIntentSubmissionStatusService.setStatusDateByFileNumber( noticeOfIntentDecision.noticeOfIntent.fileNumber, NOI_SUBMISSION_STATUS.ALC_DECISION, @@ -668,9 +641,7 @@ export class NoticeOfIntentDecisionV2Service { }); if (!decisionDocument) { - throw new ServiceNotFoundException( - `Failed to find document with uuid ${decisionDocumentUuid}`, - ); + throw new ServiceNotFoundException(`Failed to find document with uuid ${decisionDocumentUuid}`); } return decisionDocument; } @@ -697,6 +668,53 @@ export class NoticeOfIntentDecisionV2Service { } async getDecisionConditionStatus(uuid: string) { - return await this.dataSource.query('SELECT alcs.get_current_status_for_noi_condition($1)', [uuid]); + const res = await this.dataSource.query('SELECT alcs.get_current_status_for_noi_condition($1)', [uuid]); + return res.length > 0 ? res[0]['get_current_status_for_noi_condition'] : ''; + } + + async getForDecisionConditionCardsByFileNumber(fileNumber: string) { + const noticeOfIntent = await this.noticeOfIntentService.getByFileNumber(fileNumber); + + const decisions = await this.noticeOfIntentDecisionRepository.find({ + where: { + noticeOfIntentUuid: noticeOfIntent.uuid, + }, + order: { + createdAt: 'DESC', + }, + relations: { + conditionCards: { + card: { + board: true, + status: true, + type: true, + }, + decision: {}, + }, + }, + }); + + return decisions.flatMap((decision) => decision.conditionCards); + } + + async getDecisionOrder(fileNumber: string, decisionUuid: string): Promise { + const noticeOfIntent = await this.noticeOfIntentService.getByFileNumber(fileNumber); + + const decisions = await this.noticeOfIntentDecisionRepository.find({ + where: { + noticeOfIntentUuid: noticeOfIntent.uuid, + }, + order: { + createdAt: 'ASC', + }, + }); + + const decisionOrder = decisions.findIndex((decision) => decision.uuid === decisionUuid); + + if (decisionOrder === -1) { + throw new ServiceNotFoundException(`Decision with UUID ${decisionUuid} not found`); + } + + return decisionOrder + 1; } } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.dto.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.dto.ts index dffb2fa524..9a71143099 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.dto.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.dto.ts @@ -1,13 +1,5 @@ import { AutoMap } from 'automapper-classes'; -import { - IsArray, - IsBoolean, - IsDate, - IsNumber, - IsOptional, - IsString, - IsUUID, -} from 'class-validator'; +import { IsArray, IsBoolean, IsDate, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; import { BaseCodeDto } from '../../common/dtos/base.dto'; import { NoticeOfIntentDecisionComponentDto, @@ -17,6 +9,9 @@ import { NoticeOfIntentDecisionConditionDto, UpdateNoticeOfIntentDecisionConditionDto, } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto'; +import { UserDto } from '../../user/user.dto'; +import { Type } from 'class-transformer'; +import { NoticeOfIntentDecisionConditionCardUuidDto } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.dto'; export class NoticeOfIntentDecisionOutcomeCodeDto extends BaseCodeDto {} @@ -87,6 +82,30 @@ export class UpdateNoticeOfIntentDecisionDto { @IsOptional() @IsArray() ccEmails?: string[]; + + @IsOptional() + @IsBoolean() + isFlagged?: boolean; + + @IsOptional() + @IsString() + reasonFlagged?: string | null; + + @IsOptional() + @IsNumber() + followUpAt?: number | null; + + @IsOptional() + @IsString() + flaggedByUuid?: string | null; + + @IsOptional() + @IsString() + flagEditedByUuid?: string | null; + + @IsOptional() + @IsNumber() + flagEditedAt?: number | null; } export class CreateNoticeOfIntentDecisionDto extends UpdateNoticeOfIntentDecisionDto { @@ -160,6 +179,27 @@ export class NoticeOfIntentDecisionDto { @AutoMap(() => [NoticeOfIntentDecisionConditionDto]) conditions?: NoticeOfIntentDecisionConditionDto[]; + + @AutoMap(() => [NoticeOfIntentDecisionConditionCardUuidDto]) + conditionCards?: NoticeOfIntentDecisionConditionCardUuidDto[]; + + @AutoMap(() => Boolean) + isFlagged: boolean; + + @AutoMap(() => String) + reasonFlagged: string | null; + + @AutoMap(() => Number) + followUpAt: number | null; + + @AutoMap(() => UserDto) + flaggedBy: UserDto | null; + + @AutoMap(() => UserDto) + flagEditedBy: UserDto | null; + + @AutoMap(() => Number) + flagEditedAt: number | null; } export class NoticeOfIntentDecisionDocumentDto { diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.entity.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.entity.ts index cae8d3168e..4e5f140c03 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.entity.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.entity.ts @@ -17,6 +17,8 @@ import { NoticeOfIntentDecisionCondition } from './notice-of-intent-decision-con import { NoticeOfIntentDecisionDocument } from './notice-of-intent-decision-document/notice-of-intent-decision-document.entity'; import { NoticeOfIntentDecisionOutcome } from './notice-of-intent-decision-outcome.entity'; import { NoticeOfIntentModification } from './notice-of-intent-modification/notice-of-intent-modification.entity'; +import { User } from '../../user/user.entity'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity'; @Entity({ comment: 'Decisions saved to NOIs, linked to the modification request', @@ -112,16 +114,12 @@ export class NoticeOfIntentDecision extends Base { type: 'timestamptz', nullable: false, update: false, - comment: - 'Date that indicates when decision was created. It is not editable by user.', + comment: 'Date that indicates when decision was created. It is not editable by user.', }) createdAt: Date; @AutoMap(() => [NoticeOfIntentDecisionDocument]) - @OneToMany( - () => NoticeOfIntentDecisionDocument, - (document) => document.decision, - ) + @OneToMany(() => NoticeOfIntentDecisionDocument, (document) => document.decision) documents: NoticeOfIntentDecisionDocument[]; @AutoMap() @@ -150,35 +148,24 @@ export class NoticeOfIntentDecision extends Base { }) ccEmails: string[]; - @ManyToMany( - () => NoticeOfIntentModification, - (modification) => modification.modifiesDecisions, - ) + @ManyToMany(() => NoticeOfIntentModification, (modification) => modification.modifiesDecisions) modifiedBy: NoticeOfIntentModification[]; @AutoMap(() => NoticeOfIntentModification) - @OneToOne( - () => NoticeOfIntentModification, - (modification) => modification.resultingDecision, - { nullable: true }, - ) + @OneToOne(() => NoticeOfIntentModification, (modification) => modification.resultingDecision, { nullable: true }) @JoinColumn() modifies?: NoticeOfIntentModification | null; @AutoMap(() => [NoticeOfIntentDecisionComponent]) - @OneToMany( - () => NoticeOfIntentDecisionComponent, - (component) => component.noticeOfIntentDecision, - { cascade: ['insert', 'update'] }, - ) + @OneToMany(() => NoticeOfIntentDecisionComponent, (component) => component.noticeOfIntentDecision, { + cascade: ['insert', 'update'], + }) components: NoticeOfIntentDecisionComponent[]; @AutoMap(() => [NoticeOfIntentDecisionCondition]) - @OneToMany( - () => NoticeOfIntentDecisionCondition, - (component) => component.decision, - { cascade: ['insert', 'update'] }, - ) + @OneToMany(() => NoticeOfIntentDecisionCondition, (component) => component.decision, { + cascade: ['insert', 'update'], + }) conditions: NoticeOfIntentDecisionCondition[]; @Column({ @@ -189,4 +176,34 @@ export class NoticeOfIntentDecision extends Base { 'This column is NOT related to any functionality in ALCS. It is only used for ETL and backtracking of imported data from OATS. It links oats.oats_alr_appl_decisions to alcs.notice_of_intent_decisions.', }) oatsAlrApplDecisionId: number; + + @AutoMap() + @Column({ default: false }) + isFlagged: boolean; + + @AutoMap(() => String) + @Column({ type: 'text', nullable: true }) + reasonFlagged: string | null; + + @AutoMap(() => Date) + @Column({ type: 'timestamptz', nullable: true }) + followUpAt: Date | null; + + @AutoMap(() => User) + @ManyToOne(() => User, { nullable: true, eager: true }) + flaggedBy: User | null; + + @AutoMap(() => User) + @ManyToOne(() => User, { nullable: true, eager: true }) + flagEditedBy: User | null; + + @AutoMap(() => Date) + @Column({ type: 'timestamptz', nullable: true }) + flagEditedAt: Date | null; + + @AutoMap(() => [NoticeOfIntentDecisionConditionCard]) + @OneToMany(() => NoticeOfIntentDecisionConditionCard, (conditionCard) => conditionCard.decision, { + cascade: ['insert', 'update'], + }) + conditionCards: NoticeOfIntentDecisionConditionCard[]; } diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.module.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.module.ts index 1361b06718..8c0e79d1ab 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.module.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-decision.module.ts @@ -27,6 +27,10 @@ import { NoticeOfIntentModificationService } from './notice-of-intent-modificati import { NoticeOfIntentDecisionConditionDate } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.entity'; import { NoticeOfIntentDecisionConditionDateService } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.service'; import { NoticeOfIntentDecisionConditionDateController } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.controller'; +import { User } from '../../user/user.entity'; +import { NoticeOfIntentDecisionConditionCard } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity'; +import { NoticeOfIntentDecisionConditionCardService } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.service'; +import { NoticeOfIntentDecisionConditionCardController } from './notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.controller'; @Module({ imports: [ @@ -41,9 +45,11 @@ import { NoticeOfIntentDecisionConditionDateController } from './notice-of-inten NoticeOfIntentDecisionCondition, NoticeOfIntentDecisionConditionType, NoticeOfIntentDecisionConditionDate, + NoticeOfIntentDecisionConditionCard, + User, ]), forwardRef(() => BoardModule), - CardModule, + forwardRef(() => CardModule), DocumentModule, forwardRef(() => NoticeOfIntentModule), NoticeOfIntentSubmissionStatusModule, @@ -56,6 +62,7 @@ import { NoticeOfIntentDecisionConditionDateController } from './notice-of-inten NoticeOfIntentDecisionConditionDateService, NoticeOfIntentDecisionProfile, NoticeOfIntentModificationService, + NoticeOfIntentDecisionConditionCardService, ], controllers: [ NoticeOfIntentDecisionV2Controller, @@ -63,7 +70,14 @@ import { NoticeOfIntentDecisionConditionDateController } from './notice-of-inten NoticeOfIntentDecisionComponentController, NoticeOfIntentDecisionConditionController, NoticeOfIntentDecisionConditionDateController, + NoticeOfIntentDecisionConditionCardController, + ], + exports: [ + NoticeOfIntentModificationService, + NoticeOfIntentDecisionV2Service, + NoticeOfIntentDecisionConditionCardService, + NoticeOfIntentModificationService, + NoticeOfIntentDecisionConditionCardService, ], - exports: [NoticeOfIntentModificationService, NoticeOfIntentDecisionV2Service], }) export class NoticeOfIntentDecisionModule {} diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.spec.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.spec.ts index 64104d33d4..df06806e73 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.spec.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.spec.ts @@ -93,9 +93,7 @@ describe('NoticeOfIntentModificationService', () => { ModificationProfile, ], }).compile(); - service = module.get( - NoticeOfIntentModificationService, - ); + service = module.get(NoticeOfIntentModificationService); mockModification = new NoticeOfIntentModification({ cardUuid: 'mockUuid' }); modificationRepoMock.findOneOrFail.mockResolvedValue(mockModification); @@ -103,9 +101,7 @@ describe('NoticeOfIntentModificationService', () => { modificationRepoMock.find.mockResolvedValue([mockModification]); modificationRepoMock.save.mockResolvedValue({} as any); - noticeOfIntentServiceMock.getByFileNumber.mockResolvedValue( - new NoticeOfIntent(), - ); + noticeOfIntentServiceMock.getByFileNumber.mockResolvedValue(new NoticeOfIntent()); mockModificationCreateDto = { fileNumber: 'fake-app-number', @@ -142,11 +138,7 @@ describe('NoticeOfIntentModificationService', () => { await service.create(mockModificationCreateDto, {} as Board); expect(modificationRepoMock.save).toHaveBeenCalledTimes(1); - expect(cardServiceMock.create).toBeCalledWith( - CARD_TYPE.NOI_MODI, - {} as Board, - false, - ); + expect(cardServiceMock.create).toBeCalledWith(CARD_TYPE.NOI_MODI, {} as Board, false); expect(noticeOfIntentServiceMock.create).toBeCalledTimes(0); }); @@ -157,9 +149,7 @@ describe('NoticeOfIntentModificationService', () => { uuid: decisionUuid, }); decisionServiceMock.getMany.mockResolvedValue([mockDecision]); - noticeOfIntentServiceMock.getByFileNumber.mockResolvedValue( - new NoticeOfIntent(), - ); + noticeOfIntentServiceMock.getByFileNumber.mockResolvedValue(new NoticeOfIntent()); await service.create( { @@ -170,17 +160,11 @@ describe('NoticeOfIntentModificationService', () => { ); expect(modificationRepoMock.save).toHaveBeenCalledTimes(1); - expect(cardServiceMock.create).toBeCalledWith( - CARD_TYPE.NOI_MODI, - {} as Board, - false, - ); + expect(cardServiceMock.create).toBeCalledWith(CARD_TYPE.NOI_MODI, {} as Board, false); expect(noticeOfIntentServiceMock.create).toBeCalledTimes(0); expect(decisionServiceMock.getMany).toHaveBeenCalledTimes(1); expect(decisionServiceMock.getMany).toHaveBeenCalledWith([decisionUuid]); - expect( - modificationRepoMock.save.mock.calls[0][0].modifiesDecisions, - ).toEqual([mockDecision]); + expect(modificationRepoMock.save.mock.calls[0][0].modifiesDecisions).toEqual([mockDecision]); }); it('should successfully update modification', async () => { @@ -201,9 +185,7 @@ describe('NoticeOfIntentModificationService', () => { const uuid = 'fake'; modificationRepoMock.findOneBy.mockResolvedValue(null); - await expect( - service.update(uuid, {} as NoticeOfIntentModificationUpdateDto), - ).rejects.toMatchObject( + await expect(service.update(uuid, {} as NoticeOfIntentModificationUpdateDto)).rejects.toMatchObject( new ServiceNotFoundException(`Modification with uuid ${uuid} not found`), ); expect(modificationRepoMock.findOneBy).toBeCalledWith({ @@ -228,9 +210,7 @@ describe('NoticeOfIntentModificationService', () => { it('should not call archive card if modification does not have card attached (only modifications imported from OATS) on delete', async () => { const uuid = 'fake'; - modificationRepoMock.findOneBy.mockResolvedValue( - new NoticeOfIntentModification(), - ); + modificationRepoMock.findOneBy.mockResolvedValue(new NoticeOfIntentModification()); modificationRepoMock.softRemove.mockResolvedValue({} as any); cardServiceMock.archive.mockResolvedValue(); @@ -316,8 +296,6 @@ describe('NoticeOfIntentModificationService', () => { await service.getDeletedCards('file-number'); expect(modificationRepoMock.find).toHaveBeenCalledTimes(1); - expect(modificationRepoMock.find.mock.calls[0][0]!.withDeleted).toEqual( - true, - ); + expect(modificationRepoMock.find.mock.calls[0][0]!.withDeleted).toEqual(true); }); }); diff --git a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.ts b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.ts index 01bd12bfac..df8ffe09eb 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service.ts @@ -1,15 +1,9 @@ import { ServiceNotFoundException } from '@app/common/exceptions/base.exception'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; -import { - FindOptionsRelations, - FindOptionsWhere, - IsNull, - Not, - Repository, -} from 'typeorm'; +import { FindOptionsRelations, FindOptionsWhere, IsNull, Not, Repository } from 'typeorm'; import { filterUndefined } from '../../../utils/undefined'; import { Board } from '../../board/board.entity'; import { CARD_TYPE } from '../../card/card-type/card-type.entity'; @@ -25,42 +19,42 @@ import { NoticeOfIntentModification } from './notice-of-intent-modification.enti @Injectable() export class NoticeOfIntentModificationService { - private BOARD_RECONSIDERATION_RELATIONS: FindOptionsRelations = - { - noticeOfIntent: { - region: true, - localGovernment: true, - }, - card: { - board: false, - type: true, - status: true, - assignee: true, - }, - }; - private DEFAULT_RELATIONS: FindOptionsRelations = - { - noticeOfIntent: { - region: true, - localGovernment: true, - }, - card: { - board: true, - type: true, - status: true, - assignee: true, - }, - modifiesDecisions: true, - resultingDecision: true, - reviewOutcome: true, - }; + private BOARD_RECONSIDERATION_RELATIONS: FindOptionsRelations = { + noticeOfIntent: { + region: true, + localGovernment: true, + }, + card: { + board: false, + type: true, + status: true, + assignee: true, + }, + }; + private DEFAULT_RELATIONS: FindOptionsRelations = { + noticeOfIntent: { + region: true, + localGovernment: true, + }, + card: { + board: true, + type: true, + status: true, + assignee: true, + }, + modifiesDecisions: true, + resultingDecision: true, + reviewOutcome: true, + }; constructor( @InjectRepository(NoticeOfIntentModification) private modificationRepository: Repository, @InjectMapper() private mapper: Mapper, private noticeOfIntentService: NoticeOfIntentService, + @Inject(forwardRef(() => NoticeOfIntentDecisionV2Service)) private noticeOfIntentDecisionService: NoticeOfIntentDecisionV2Service, + @Inject(forwardRef(() => CardService)) private cardService: CardService, ) {} @@ -97,14 +91,8 @@ export class NoticeOfIntentModificationService { }); } - mapToDtos( - modifications: NoticeOfIntentModification[], - ): Promise { - return this.mapper.mapArrayAsync( - modifications, - NoticeOfIntentModification, - NoticeOfIntentModificationDto, - ); + mapToDtos(modifications: NoticeOfIntentModification[]): Promise { + return this.mapper.mapArrayAsync(modifications, NoticeOfIntentModification, NoticeOfIntentModificationDto); } async create(createDto: NoticeOfIntentModificationCreateDto, board: Board) { @@ -113,20 +101,11 @@ export class NoticeOfIntentModificationService { description: createDto.description, }); - modification.card = await this.cardService.create( - CARD_TYPE.NOI_MODI, - board, - false, - ); - modification.noticeOfIntent = - await this.noticeOfIntentService.getByFileNumber(createDto.fileNumber); - modification.modifiesDecisions = - await this.noticeOfIntentDecisionService.getMany( - createDto.modifiesDecisionUuids, - ); + modification.card = await this.cardService.create(CARD_TYPE.NOI_MODI, board, false); + modification.noticeOfIntent = await this.noticeOfIntentService.getByFileNumber(createDto.fileNumber); + modification.modifiesDecisions = await this.noticeOfIntentDecisionService.getMany(createDto.modifiesDecisionUuids); - const mockModifications = - await this.modificationRepository.save(modification); + const mockModifications = await this.modificationRepository.save(modification); return this.getByUuid(mockModifications.uuid); } @@ -141,16 +120,12 @@ export class NoticeOfIntentModificationService { modification.reviewOutcomeCode = updateDto.reviewOutcomeCode; } - modification.description = filterUndefined( - updateDto.description, - modification.description, - ); + modification.description = filterUndefined(updateDto.description, modification.description); if (updateDto.modifiesDecisionUuids) { - modification.modifiesDecisions = - await this.noticeOfIntentDecisionService.getMany( - updateDto.modifiesDecisionUuids, - ); + modification.modifiesDecisions = await this.noticeOfIntentDecisionService.getMany( + updateDto.modifiesDecisionUuids, + ); } await this.modificationRepository.save(modification); @@ -213,11 +188,20 @@ export class NoticeOfIntentModificationService { }); if (!modification) { - throw new ServiceNotFoundException( - `Modification with uuid ${uuid} not found`, - ); + throw new ServiceNotFoundException(`Modification with uuid ${uuid} not found`); } return modification; } + + async getByNoticeOfIntentDecisionUuid(decisionUuid: string): Promise { + return this.modificationRepository.find({ + where: { + modifiesDecisions: { + uuid: decisionUuid, + }, + }, + relations: this.DEFAULT_RELATIONS, + }); + } } diff --git a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.spec.ts b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.spec.ts index cfe0ad2768..17fd71546d 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.spec.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.spec.ts @@ -287,9 +287,17 @@ describe('NoticeOfIntentDocumentService', () => { }); it('should create a record for external documents', async () => { - mockRepository.save.mockResolvedValue(new NoticeOfIntentDocument()); + mockRepository.save.mockResolvedValue( + new NoticeOfIntentDocument({ + document: new Document(), + }), + ); mockNOIService.getUuid.mockResolvedValueOnce('app-uuid'); - mockRepository.findOne.mockResolvedValue(new NoticeOfIntentDocument()); + mockRepository.findOne.mockResolvedValue( + new NoticeOfIntentDocument({ + document: new Document(), + }), + ); const res = await service.attachExternalDocument( '', diff --git a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.ts b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.ts index 1fcbd1a212..1d977dbf9e 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.service.ts @@ -138,7 +138,7 @@ export class NoticeOfIntentDocumentService { }, relations: this.DEFAULT_RELATIONS, }); - if (!document) { + if (!document || !document.document) { throw new NotFoundException(`Failed to find document ${uuid}`); } return document; @@ -174,15 +174,17 @@ export class NoticeOfIntentDocumentService { if (visibilityFlags) { where.visibilityFlags = ArrayOverlap(visibilityFlags); } - return this.noticeOfIntentDocumentRepository.find({ - where, - order: { - document: { - uploadedAt: 'DESC', + return ( + await this.noticeOfIntentDocumentRepository.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, }, - }, - relations: this.DEFAULT_RELATIONS, - }); + relations: this.DEFAULT_RELATIONS, + }) + ).filter((document) => document.document); } async getInlineUrl(document: NoticeOfIntentDocument) { diff --git a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.service.ts b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.service.ts index fecbc5c525..d40986aea3 100644 --- a/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.service.ts +++ b/services/apps/alcs/src/alcs/notice-of-intent/notice-of-intent.service.ts @@ -1,11 +1,10 @@ import { ServiceNotFoundException, ServiceValidationException } from '@app/common/exceptions/base.exception'; -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Mapper } from 'automapper-core'; import { InjectMapper } from 'automapper-nestjs'; import { FindOptionsRelations, FindOptionsWhere, IsNull, Like, Not, Repository } from 'typeorm'; import { FileNumberService } from '../../file-number/file-number.service'; -import { PORTAL_TO_ALCS_STRUCTURE_MAP } from '../../portal/notice-of-intent-submission/notice-of-intent-submission.entity'; import { formatIncomingDate } from '../../utils/incoming-date.formatter'; import { filterUndefined } from '../../utils/undefined'; import { ApplicationTimeData } from '../application/application-time-tracking.service'; @@ -44,6 +43,7 @@ export class NoticeOfIntentService { }; constructor( + @Inject(forwardRef(() => CardService)) private cardService: CardService, @InjectRepository(NoticeOfIntent) private repository: Repository, diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts index cc44d8acfd..c5fd0359e7 100644 --- a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.spec.ts @@ -287,9 +287,17 @@ describe('NotificationDocumentService', () => { }); it('should create a record for external documents', async () => { - mockRepository.save.mockResolvedValue(new NotificationDocument()); + mockRepository.save.mockResolvedValue( + new NotificationDocument({ + document: new Document(), + }), + ); mockNotificationService.getUuid.mockResolvedValueOnce('app-uuid'); - mockRepository.findOne.mockResolvedValue(new NotificationDocument()); + mockRepository.findOne.mockResolvedValue( + new NotificationDocument({ + document: new Document(), + }), + ); const res = await service.attachExternalDocument( '', diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts index b3dbc7d5d4..26c3d29676 100644 --- a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.service.ts @@ -137,7 +137,7 @@ export class NotificationDocumentService { }, relations: this.DEFAULT_RELATIONS, }); - if (!document) { + if (!document || !document.document) { throw new NotFoundException(`Failed to find document ${uuid}`); } return document; @@ -158,15 +158,17 @@ export class NotificationDocumentService { if (visibilityFlags) { where.visibilityFlags = ArrayOverlap(visibilityFlags); } - return this.notificationDocumentRepository.find({ - where, - order: { - document: { - uploadedAt: 'DESC', + return ( + await this.notificationDocumentRepository.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, }, - }, - relations: this.DEFAULT_RELATIONS, - }); + relations: this.DEFAULT_RELATIONS, + }) + ).filter((document) => document.document); } async getInlineUrl(document: NotificationDocument) { diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts index 74805b78a3..8231cfbc0a 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts @@ -203,11 +203,17 @@ describe('PlanningReviewDocumentService', () => { }); it('should create a record for external documents', async () => { - mockRepository.save.mockResolvedValue(new PlanningReviewDocument()); - mockPlanningReviewService.getDetailedReview.mockResolvedValueOnce( - mockPlanningReview, + mockRepository.save.mockResolvedValue( + new PlanningReviewDocument({ + document: new Document(), + }), + ); + mockPlanningReviewService.getDetailedReview.mockResolvedValueOnce(mockPlanningReview); + mockRepository.findOne.mockResolvedValue( + new PlanningReviewDocument({ + document: new Document(), + }), ); - mockRepository.findOne.mockResolvedValue(new PlanningReviewDocument()); const res = await service.attachExternalDocument( '', diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts index d029a129de..e16e1a9645 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts @@ -86,7 +86,7 @@ export class PlanningReviewDocumentService { }, relations: this.DEFAULT_RELATIONS, }); - if (!document) { + if (!document || !document.document) { throw new NotFoundException(`Failed to find document ${uuid}`); } return document; @@ -107,15 +107,17 @@ export class PlanningReviewDocumentService { if (visibilityFlags) { where.visibilityFlags = ArrayOverlap(visibilityFlags); } - return this.planningReviewDocumentRepo.find({ - where, - order: { - document: { - uploadedAt: 'DESC', + return ( + await this.planningReviewDocumentRepo.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, }, - }, - relations: this.DEFAULT_RELATIONS, - }); + relations: this.DEFAULT_RELATIONS, + }) + ).filter((document) => document.document); } async getInlineUrl(document: PlanningReviewDocument) { diff --git a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts index a4631ca6aa..fa420a98ec 100644 --- a/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts +++ b/services/apps/alcs/src/alcs/tag/tag-category/tag-category.controller.ts @@ -3,7 +3,7 @@ import { ApiOAuth2 } from '@nestjs/swagger'; import * as config from 'config'; import { RolesGuard } from '../../../common/authorization/roles-guard.service'; import { UserRoles } from '../../../common/authorization/roles.decorator'; -import { ANY_ROLE_BUT_COMMISSIONER, AUTH_ROLE } from '../../../common/authorization/roles'; +import { ANY_AUTH_ROLE, AUTH_ROLE } from '../../../common/authorization/roles'; import { TagCategoryDto } from './tag-category.dto'; import { TagCategoryService } from './tag-category.service'; @@ -14,7 +14,7 @@ export class TagCategoryController { constructor(private service: TagCategoryService) {} @Get('') - @UserRoles(...ANY_ROLE_BUT_COMMISSIONER) + @UserRoles(...ANY_AUTH_ROLE) async fetch( @Query('pageIndex') pageIndex: number, @Query('itemsPerPage') itemsPerPage: number, diff --git a/services/apps/alcs/src/alcs/tag/tag.controller.ts b/services/apps/alcs/src/alcs/tag/tag.controller.ts index 8d9df46e34..e28d824084 100644 --- a/services/apps/alcs/src/alcs/tag/tag.controller.ts +++ b/services/apps/alcs/src/alcs/tag/tag.controller.ts @@ -4,7 +4,7 @@ import * as config from 'config'; import { RolesGuard } from '../../common/authorization/roles-guard.service'; import { UserRoles } from '../../common/authorization/roles.decorator'; import { TagService } from './tag.service'; -import { ANY_ROLE_BUT_COMMISSIONER, AUTH_ROLE } from '../../common/authorization/roles'; +import { ANY_AUTH_ROLE, ANY_ROLE_BUT_COMMISSIONER, AUTH_ROLE } from '../../common/authorization/roles'; import { TagDto } from './tag.dto'; @Controller('tag') @@ -14,7 +14,7 @@ export class TagController { constructor(private service: TagService) {} @Get('') - @UserRoles(...ANY_ROLE_BUT_COMMISSIONER) + @UserRoles(...ANY_AUTH_ROLE) async fetch( @Query('pageIndex') pageIndex: number, @Query('itemsPerPage') itemsPerPage: number, diff --git a/services/apps/alcs/src/clamav/clamav.service.ts b/services/apps/alcs/src/clamav/clamav.service.ts index 8d8ba4e348..e8210c66e2 100644 --- a/services/apps/alcs/src/clamav/clamav.service.ts +++ b/services/apps/alcs/src/clamav/clamav.service.ts @@ -1,4 +1,5 @@ import { CONFIG_TOKEN, IConfig } from '@app/common/config/config.module'; +import { BaseServiceException } from '@app/common/exceptions/base.exception'; import { HttpService } from '@nestjs/axios'; import { Inject, Injectable, Logger } from '@nestjs/common'; import * as NodeClam from 'clamscan'; diff --git a/services/apps/alcs/src/common/authorization/authorization.controller.ts b/services/apps/alcs/src/common/authorization/authorization.controller.ts index 3c6535437c..dc636a6f4c 100644 --- a/services/apps/alcs/src/common/authorization/authorization.controller.ts +++ b/services/apps/alcs/src/common/authorization/authorization.controller.ts @@ -29,36 +29,31 @@ export class AuthorizationController { @Get() @Public() - async handleAuth( - @Query('code') authCode: string, - @Query('state') sessionId: string, - @Res() res: FastifyReply, - ) { + async handleAuth(@Query('code') authCode: string, @Query('state') sessionId: string, @Res() res: FastifyReply) { + let isPortal = true; + let path = ''; + try { const redis = this.redisService.getClient(); const sessionData = await redis.get(`session_${sessionId}`); - let isPortal = true; if (sessionData) { const parsedSession = JSON.parse(sessionData); isPortal = parsedSession.source === 'portal'; } - const token = await this.authorizationService.exchangeCodeForToken( - authCode, - isPortal, - ); - - const frontEndUrl = isPortal - ? this.config.get('PORTAL.FRONTEND_ROOT') - : this.config.get('ALCS.FRONTEND_ROOT'); + const token = await this.authorizationService.exchangeCodeForToken(authCode, isPortal); - res.status(302); - res.redirect( - `${frontEndUrl}/authorized?t=${token.access_token}&r=${token.refresh_token}`, - ); + path = `authorized?t=${token.access_token}&r=${token.refresh_token}`; } catch (e) { console.log(e); + + path = 'login?login_failed=true'; + } finally { + const frontEndUrl = isPortal ? this.config.get('PORTAL.FRONTEND_ROOT') : this.config.get('ALCS.FRONTEND_ROOT'); + + res.status(302); + res.redirect(`${frontEndUrl}/${path}`); } } diff --git a/services/apps/alcs/src/common/authorization/roles.ts b/services/apps/alcs/src/common/authorization/roles.ts index b42e744783..3f7a0a1930 100644 --- a/services/apps/alcs/src/common/authorization/roles.ts +++ b/services/apps/alcs/src/common/authorization/roles.ts @@ -18,7 +18,7 @@ export const ROLES_ALLOWED_APPLICATIONS = [ ]; export const ROLES_ALLOWED_BOARDS = ROLES_ALLOWED_APPLICATIONS; -export const ROLES_ALLOWED_ARCHIVE = [AUTH_ROLE.ADMIN, AUTH_ROLE.APP_SPECIALIST]; +export const ROLES_ALLOWED_ARCHIVE = ROLES_ALLOWED_BOARDS; export const ANY_AUTH_ROLE = Object.values(AUTH_ROLE); export const ROLES_ALLOWED_SEARCH = [...ROLES_ALLOWED_APPLICATIONS, AUTH_ROLE.COMMISSIONER]; export const ANY_ROLE_BUT_COMMISSIONER = Object.values(AUTH_ROLE).filter((role) => role !== AUTH_ROLE.COMMISSIONER); diff --git a/services/apps/alcs/src/common/automapper/application-decision-v2.automapper.profile.ts b/services/apps/alcs/src/common/automapper/application-decision-v2.automapper.profile.ts index 0fda93762a..7563183359 100644 --- a/services/apps/alcs/src/common/automapper/application-decision-v2.automapper.profile.ts +++ b/services/apps/alcs/src/common/automapper/application-decision-v2.automapper.profile.ts @@ -8,7 +8,10 @@ import { ApplicationDecisionConditionType } from '../../alcs/application-decisio import { ApplicationDecisionConditionComponentDto, ApplicationDecisionConditionDto, + ApplicationDecisionConditionHomeDto, ApplicationDecisionConditionTypeDto, + ApplicationDecisionHomeDto, + ApplicationHomeDto, } from '../../alcs/application-decision/application-decision-condition/application-decision-condition.dto'; import { ApplicationDecisionCondition } from '../../alcs/application-decision/application-decision-condition/application-decision-condition.entity'; import { ApplicationDecisionDocument } from '../../alcs/application-decision/application-decision-document/application-decision-document.entity'; @@ -42,6 +45,16 @@ import { ApplicationDecisionConditionComponentPlanNumber } from '../../alcs/appl import { CommissionerDecisionDto } from '../../alcs/commissioner/commissioner.dto'; import { ApplicationDecisionConditionDate } from '../../alcs/application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.entity'; import { ApplicationDecisionConditionDateDto } from '../../alcs/application-decision/application-decision-condition/application-decision-condition-date/application-decision-condition-date.dto'; +import { ApplicationDecisionConditionCard } from '../../alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; +import { + ApplicationDecisionConditionCardBoardDto, + ApplicationDecisionConditionCardDto, + ApplicationDecisionConditionCardUuidDto, + ApplicationDecisionConditionHomeCardDto, +} from '../../alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.dto'; +import { UserDto } from '../../user/user.dto'; +import { User } from '../../user/user.entity'; +import { Application } from '../../alcs/application/application.entity'; @Injectable() export class ApplicationDecisionProfile extends AutomapperProfile { @@ -141,6 +154,34 @@ export class ApplicationDecisionProfile extends AutomapperProfile { } }), ), + forMember( + (dto) => dto.conditionCards, + mapFrom((entity) => + entity.conditionCards + ? this.mapper.mapArray( + entity.conditionCards, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionCardUuidDto, + ) + : [], + ), + ), + forMember( + (ad) => ad.followUpAt, + mapFrom((a) => a.followUpAt?.getTime()), + ), + forMember( + (ad) => ad.flaggedBy, + mapFrom((a) => (a.flaggedBy ? this.mapper.map(a.flaggedBy, User, UserDto) : a.flaggedBy)), + ), + forMember( + (ad) => ad.flagEditedBy, + mapFrom((a) => (a.flagEditedBy ? this.mapper.map(a.flagEditedBy, User, UserDto) : a.flagEditedBy)), + ), + forMember( + (ad) => ad.flagEditedAt, + mapFrom((a) => a.flagEditedAt?.getTime()), + ), ); createMap(mapper, ApplicationDecisionOutcomeCode, ApplicationDecisionOutcomeCodeDto); @@ -230,6 +271,36 @@ export class ApplicationDecisionProfile extends AutomapperProfile { : [], ), ), + forMember( + (dto) => dto.conditionCard, + mapFrom((entity) => + entity.conditionCard + ? this.mapper.map( + entity.conditionCard, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionCardUuidDto, + ) + : null, + ), + ), + ); + + createMap( + mapper, + ApplicationDecisionCondition, + ApplicationDecisionConditionHomeDto, + forMember( + (dto) => dto.conditionCard, + mapFrom((entity) => + entity.conditionCard + ? this.mapper.map( + entity.conditionCard, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionHomeCardDto, + ) + : null, + ), + ), ); createMap( @@ -360,6 +431,78 @@ export class ApplicationDecisionProfile extends AutomapperProfile { ), ), ); + + createMap( + mapper, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionCardDto, + forMember( + (dto) => dto.conditions, + mapFrom((entity) => + entity.conditions + ? this.mapper.mapArray(entity.conditions, ApplicationDecisionCondition, ApplicationDecisionConditionDto) + : [], + ), + ), + forMember( + (dto) => dto.decisionUuid, + mapFrom((entity) => (entity.decision.uuid ? entity.decision.uuid : undefined)), + ), + ); + + createMap( + mapper, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionCardBoardDto, + forMember( + (dto) => dto.conditions, + mapFrom((entity) => + entity.conditions + ? this.mapper.mapArray(entity.conditions, ApplicationDecisionCondition, ApplicationDecisionConditionDto) + : [], + ), + ), + forMember( + (dto) => dto.decisionUuid, + mapFrom((entity) => entity.decision.uuid), + ), + forMember( + (dto) => dto.decisionIsFlagged, + mapFrom((entity) => entity.decision.isFlagged), + ), + ); + + createMap( + mapper, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionCardUuidDto, + forMember( + (dto) => dto.uuid, + mapFrom((entity) => entity.uuid), + ), + ); + + createMap( + mapper, + ApplicationDecisionConditionCard, + ApplicationDecisionConditionHomeCardDto, + forMember( + (dto) => dto.uuid, + mapFrom((entity) => entity.uuid), + ), + ); + + createMap(mapper, ApplicationDecision, ApplicationDecisionHomeDto); + + createMap( + mapper, + Application, + ApplicationHomeDto, + forMember( + (a) => a.type, + mapFrom((ac) => ac.type), + ), + ); }; } } diff --git a/services/apps/alcs/src/common/automapper/notice-of-intent-decision.automapper.profile.ts b/services/apps/alcs/src/common/automapper/notice-of-intent-decision.automapper.profile.ts index 4dcf538aac..6136f49486 100644 --- a/services/apps/alcs/src/common/automapper/notice-of-intent-decision.automapper.profile.ts +++ b/services/apps/alcs/src/common/automapper/notice-of-intent-decision.automapper.profile.ts @@ -14,7 +14,10 @@ import { NoticeOfIntentDecisionComponent } from '../../alcs/notice-of-intent-dec import { NoticeOfIntentDecisionConditionType } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-code.entity'; import { NoticeOfIntentDecisionConditionDto, + NoticeOfIntentDecisionConditionHomeDto, NoticeOfIntentDecisionConditionTypeDto, + NoticeOfIntentDecisionHomeDto, + NoticeOfIntentHomeDto, } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.dto'; import { NoticeOfIntentDecisionCondition } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition.entity'; import { NoticeOfIntentDecisionDocument } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-document/notice-of-intent-decision-document.entity'; @@ -38,6 +41,15 @@ import { NoticeOfIntent } from '../../alcs/notice-of-intent/notice-of-intent.ent import { NoticeOfIntentPortalDecisionDto } from '../../portal/public/notice-of-intent/notice-of-intent-decision.dto'; import { NoticeOfIntentDecisionConditionDate } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.entity'; import { NoticeOfIntentDecisionConditionDateDto } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-date/notice-of-intent-decision-condition-date.dto'; +import { User } from '../../user/user.entity'; +import { UserDto } from '../../user/user.dto'; +import { NoticeOfIntentDecisionConditionCard } from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.entity'; +import { + NoticeOfIntentDecisionConditionCardBoardDto, + NoticeOfIntentDecisionConditionCardDto, + NoticeOfIntentDecisionConditionCardUuidDto, + NoticeOfIntentDecisionConditionHomeCardDto, +} from '../../alcs/notice-of-intent-decision/notice-of-intent-decision-condition/notice-of-intent-decision-condition-card/notice-of-intent-decision-condition-card.dto'; @Injectable() export class NoticeOfIntentDecisionProfile extends AutomapperProfile { @@ -115,6 +127,34 @@ export class NoticeOfIntentDecisionProfile extends AutomapperProfile { } }), ), + forMember( + (ad) => ad.followUpAt, + mapFrom((a) => a.followUpAt?.getTime()), + ), + forMember( + (ad) => ad.flaggedBy, + mapFrom((a) => (a.flaggedBy ? this.mapper.map(a.flaggedBy, User, UserDto) : a.flaggedBy)), + ), + forMember( + (ad) => ad.flagEditedBy, + mapFrom((a) => (a.flagEditedBy ? this.mapper.map(a.flagEditedBy, User, UserDto) : a.flagEditedBy)), + ), + forMember( + (ad) => ad.flagEditedAt, + mapFrom((a) => a.flagEditedAt?.getTime()), + ), + forMember( + (dto) => dto.conditionCards, + mapFrom((entity) => + entity.conditionCards + ? this.mapper.mapArray( + entity.conditionCards, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardUuidDto, + ) + : [], + ), + ), ); createMap(mapper, NoticeOfIntentSubmissionStatusType, NoticeOfIntentStatusDto); @@ -163,6 +203,18 @@ export class NoticeOfIntentDecisionProfile extends AutomapperProfile { : [], ), ), + forMember( + (dto) => dto.conditionCard, + mapFrom((entity) => + entity.conditionCard + ? this.mapper.map( + entity.conditionCard, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardUuidDto, + ) + : null, + ), + ), ); createMap( @@ -311,6 +363,100 @@ export class NoticeOfIntentDecisionProfile extends AutomapperProfile { ), ), ); + + createMap( + mapper, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardDto, + forMember( + (dto) => dto.conditions, + mapFrom((entity) => + entity.conditions + ? this.mapper.mapArray( + entity.conditions, + NoticeOfIntentDecisionCondition, + NoticeOfIntentDecisionConditionDto, + ) + : [], + ), + ), + forMember( + (dto) => dto.decisionUuid, + mapFrom((entity) => (entity.decision.uuid ? entity.decision.uuid : undefined)), + ), + ); + + createMap( + mapper, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardBoardDto, + forMember( + (dto) => dto.conditions, + mapFrom((entity) => + entity.conditions + ? this.mapper.mapArray( + entity.conditions, + NoticeOfIntentDecisionCondition, + NoticeOfIntentDecisionConditionDto, + ) + : [], + ), + ), + forMember( + (dto) => dto.decisionUuid, + mapFrom((entity) => entity.decision.uuid), + ), + ); + + createMap( + mapper, + NoticeOfIntentDecisionCondition, + NoticeOfIntentDecisionConditionHomeDto, + forMember( + (dto) => dto.conditionCard, + mapFrom((entity) => + entity.conditionCard + ? this.mapper.map( + entity.conditionCard, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionHomeCardDto, + ) + : null, + ), + ), + ); + + createMap( + mapper, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionCardUuidDto, + forMember( + (dto) => dto.uuid, + mapFrom((entity) => entity.uuid), + ), + ); + + createMap( + mapper, + NoticeOfIntentDecisionConditionCard, + NoticeOfIntentDecisionConditionHomeCardDto, + forMember( + (dto) => dto.uuid, + mapFrom((entity) => entity.uuid), + ), + ); + + createMap(mapper, NoticeOfIntentDecision, NoticeOfIntentDecisionHomeDto); + + createMap( + mapper, + NoticeOfIntent, + NoticeOfIntentHomeDto, + forMember( + (a) => a.type, + mapFrom((ac) => ac.type), + ), + ); }; } } diff --git a/services/apps/alcs/src/document/document.service.ts b/services/apps/alcs/src/document/document.service.ts index 33c028a2b5..70d3bfb741 100644 --- a/services/apps/alcs/src/document/document.service.ts +++ b/services/apps/alcs/src/document/document.service.ts @@ -1,11 +1,6 @@ import { CONFIG_TOKEN, IConfig } from '@app/common/config/config.module'; -import { BaseServiceException } from '@app/common/exceptions/base.exception'; -import { - DeleteObjectCommand, - GetObjectCommand, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; +import { BaseServiceException, ServiceValidationException } from '@app/common/exceptions/base.exception'; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { MultipartFile } from '@fastify/multipart'; import { Inject, Injectable, Logger } from '@nestjs/common'; @@ -167,15 +162,23 @@ export class DocumentService { }); const isInfected = await this.clamAvService.scanFile(fileUrl); - if (isInfected) { + + if (isInfected === null || isInfected === undefined) { await this.deleteDocument(data.fileKey); - this.logger.warn(`Deleted malicious file ${data.fileKey}`); + this.logger.warn(`Deleted unscanned file ${data.fileKey}`); throw new BaseServiceException( - 'File may contain malicious data, upload blocked', - 403, + 'Virus scan failed, cannot determine if infected, upload blocked', + undefined, + 'VirusScanFailed', ); } + if (isInfected) { + await this.deleteDocument(data.fileKey); + this.logger.warn(`Deleted malicious file ${data.fileKey}`); + throw new ServiceValidationException('File may contain malicious data, upload blocked', 'VirusDetected'); + } + return this.documentRepository.save( new Document({ mimeType: data.mimeType, diff --git a/services/apps/alcs/src/portal/application-submission/application-submission.service.spec.ts b/services/apps/alcs/src/portal/application-submission/application-submission.service.spec.ts index 9de498b6b6..b8a08e80fa 100644 --- a/services/apps/alcs/src/portal/application-submission/application-submission.service.spec.ts +++ b/services/apps/alcs/src/portal/application-submission/application-submission.service.spec.ts @@ -379,16 +379,22 @@ describe('ApplicationSubmissionService', () => { localGovernmentUuid, }); + mockGenerateSubmissionDocumentService.generateAndAttach.mockRejectedValue(undefined); + mockAppDocService.list.mockResolvedValue([ + new ApplicationDocument({ + typeCode: DOCUMENT_TYPE.ORIGINAL_SUBMISSION, + }), + ]); mockApplicationService.submit.mockRejectedValue(new Error()); - await expect( - service.submitToAlcs( - applicationSubmission as ValidatedApplicationSubmission, - mockUser, - ), - ).rejects.toMatchObject( - new BaseServiceException(`Failed to submit application: ${fileNumber}`), - ); + try { + await service.submitToAlcs(applicationSubmission as ValidatedApplicationSubmission, mockUser); + } catch (err) { + await expect([ + new BaseServiceException(`Failed to submit application: ${fileNumber}`), + new BaseServiceException('A document failed to generate', undefined, 'DocumentGenerationError'), + ]).toContainEqual(err); + } }); it('should call out to service on submitToAlcs', async () => { @@ -410,47 +416,16 @@ describe('ApplicationSubmissionService', () => { dateSubmittedToAlc: new Date(), }); + mockGenerateSubmissionDocumentService.generateAndAttach.mockRejectedValue(undefined); + mockAppDocService.list.mockResolvedValue([ + { + typeCode: DOCUMENT_TYPE.ORIGINAL_SUBMISSION, + }, + { + typeCode: DOCUMENT_TYPE.ORIGINAL_SUBMISSION, + }, + ] as any); mockApplicationService.submit.mockResolvedValue(mockSubmittedApp); - await service.submitToAlcs( - mockApplication as ValidatedApplicationSubmission, - mockUser, - ); - - expect(mockApplicationService.submit).toBeCalledTimes(1); - - expect( - mockApplicationSubmissionStatusService.setStatusDate, - ).toBeCalledTimes(1); - expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledWith( - mockApplication.uuid, - SUBMISSION_STATUS.SUBMITTED_TO_ALC, - mockSubmittedApp.dateSubmittedToAlc, - ); - }); - - it('should submit to alcs even if document generation fails', async () => { - const applicant = 'Bruce Wayne'; - const typeCode = 'fake-code'; - const fileNumber = 'fake'; - const localGovernmentUuid = 'fake-uuid'; - const mockApplication = new ApplicationSubmission({ - fileNumber, - applicant, - typeCode, - localGovernmentUuid, - status: new ApplicationSubmissionToSubmissionStatus({ - statusTypeCode: 'status-code', - submissionUuid: 'fake', - }), - }); - - const mockSubmittedApp = new Application({ - dateSubmittedToAlc: new Date(), - }); - mockApplicationService.submit.mockResolvedValue(mockSubmittedApp); - mockGenerateSubmissionDocumentService.generateAndAttach.mockRejectedValue( - new Error('fake'), - ); await service.submitToAlcs( mockApplication as ValidatedApplicationSubmission, @@ -458,64 +433,8 @@ describe('ApplicationSubmissionService', () => { ); expect(mockApplicationService.submit).toBeCalledTimes(1); - expect( - mockGenerateSubmissionDocumentService.generateAndAttach, - ).toBeCalledTimes(1); - expect( - mockGenerateSubmissionDocumentService.generateAndAttach, - ).rejects.toMatchObject(new Error('fake')); - expect( - mockApplicationSubmissionStatusService.setStatusDate, - ).toBeCalledTimes(1); - expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledWith( - mockApplication.uuid, - SUBMISSION_STATUS.SUBMITTED_TO_ALC, - mockSubmittedApp.dateSubmittedToAlc, - ); - }); - - it('should submit to alcs even if document attachment to application fails', async () => { - const applicant = 'Bruce Wayne'; - const typeCode = 'fake-code'; - const fileNumber = 'fake'; - const localGovernmentUuid = 'fake-uuid'; - const mockApplication = new ApplicationSubmission({ - fileNumber, - applicant, - typeCode, - localGovernmentUuid, - status: new ApplicationSubmissionToSubmissionStatus({ - statusTypeCode: 'status-code', - submissionUuid: 'fake', - }), - }); - - const mockSubmittedApp = new Application({ - dateSubmittedToAlc: new Date(), - }); - - mockApplicationService.submit.mockResolvedValue(mockSubmittedApp); - mockGenerateSubmissionDocumentService.generateAndAttach.mockRejectedValue( - new Error('fake'), - ); - await service.submitToAlcs( - mockApplication as ValidatedApplicationSubmission, - mockUser, - ); - - await new Promise((r) => setTimeout(r, 100)); - - expect(mockApplicationService.submit).toBeCalledTimes(1); - expect( - mockGenerateSubmissionDocumentService.generateAndAttach, - ).toBeCalledTimes(1); - expect( - mockGenerateSubmissionDocumentService.generateAndAttach, - ).toBeCalledWith(fileNumber, mockUser); - expect( - mockApplicationSubmissionStatusService.setStatusDate, - ).toBeCalledTimes(1); + expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledTimes(1); expect(mockApplicationSubmissionStatusService.setStatusDate).toBeCalledWith( mockApplication.uuid, SUBMISSION_STATUS.SUBMITTED_TO_ALC, diff --git a/services/apps/alcs/src/portal/application-submission/application-submission.service.ts b/services/apps/alcs/src/portal/application-submission/application-submission.service.ts index 51f758303f..085b2060c3 100644 --- a/services/apps/alcs/src/portal/application-submission/application-submission.service.ts +++ b/services/apps/alcs/src/portal/application-submission/application-submission.service.ts @@ -231,6 +231,16 @@ export class ApplicationSubmissionService { user: User, applicationReview?: ApplicationSubmissionReview, ) { + const hasReview: boolean = !!applicationReview; + await this.generateAndAttachPdfs(application.fileNumber, user, hasReview); + + const documents = await this.applicationDocumentService.list(application.fileNumber); + const submissionDocs = documents.filter((document) => document.typeCode === DOCUMENT_TYPE.ORIGINAL_SUBMISSION); + + if (!((hasReview && submissionDocs.length >= 2) || (!hasReview && submissionDocs.length >= 1))) { + throw new BaseServiceException('A document failed to generate', undefined, 'DocumentGenerationError'); + } + let submittedApp: Application | null = null; const shouldCreateCard = applicationReview?.isAuthorized ?? true; @@ -247,8 +257,6 @@ export class ApplicationSubmissionService { ); await this.updateStatus(application, SUBMISSION_STATUS.SUBMITTED_TO_ALC, submittedApp.dateSubmittedToAlc); - - this.generateAndAttachPdfs(application.fileNumber, user, !!applicationReview); } catch (ex) { this.logger.error(ex); throw new BaseServiceException(`Failed to submit application: ${application.fileNumber}`); diff --git a/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.spec.ts b/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.spec.ts index ec04ec5d5d..85007e2d4f 100644 --- a/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.spec.ts +++ b/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.spec.ts @@ -29,6 +29,7 @@ import { STRUCTURE_TYPES, } from './notice-of-intent-submission.entity'; import { NoticeOfIntentSubmissionService } from './notice-of-intent-submission.service'; +import { DOCUMENT_TYPE } from '../../document/document-code.entity'; describe('NoticeOfIntentSubmissionService', () => { let service: NoticeOfIntentSubmissionService; @@ -225,21 +226,38 @@ describe('NoticeOfIntentSubmissionService', () => { localGovernmentUuid, }); + mockGenerateNoiSubmissionDocumentService.generateAndAttach.mockResolvedValue(undefined); + mockNoiDocService.list.mockResolvedValue([ + new NoticeOfIntentDocument({ + typeCode: DOCUMENT_TYPE.ORIGINAL_SUBMISSION, + }), + ]); mockNoiService.submit.mockRejectedValue(new Error()); - await expect( - service.submitToAlcs(noticeOfIntentSubmission as ValidatedNoticeOfIntentSubmission, mockUser), - ).rejects.toMatchObject(new BaseServiceException(`Failed to submit notice of intent: ${fileNumber}`)); + try { + await service.submitToAlcs(noticeOfIntentSubmission as ValidatedNoticeOfIntentSubmission, mockUser); + } catch (err) { + await expect([ + new BaseServiceException(`Failed to submit notice of intent: ${fileNumber}`), + new BaseServiceException('A document failed to generate', undefined, 'DocumentGenerationError'), + ]).toContainEqual(err); + } }); it('should call out to service on submitToAlcs', async () => { const mockNoticeOfIntent = new NoticeOfIntent({ dateSubmittedToAlc: new Date(), }); + + mockNoiDocService.list.mockResolvedValue([ + { + typeCode: DOCUMENT_TYPE.ORIGINAL_SUBMISSION, + }, + ] as any); mockNoiStatusService.setStatusDate.mockResolvedValue(new NoticeOfIntentSubmissionToSubmissionStatus()); mockGenerateNoiSubmissionDocumentService.generateAndAttach.mockResolvedValue(); - mockNoiService.submit.mockResolvedValue(mockNoticeOfIntent); + await service.submitToAlcs(mockNoiSubmission as ValidatedNoticeOfIntentSubmission, mockUser); expect(mockNoiService.submit).toBeCalledTimes(1); @@ -310,6 +328,8 @@ describe('NoticeOfIntentSubmissionService', () => { }); it('should populate noi tags', async () => { + mockNoiDocService.list.mockResolvedValue([] as any); + const applicant = 'Bruce Wayne'; const typeCode = 'fake-code'; const fileNumber = 'fake'; @@ -346,6 +366,11 @@ describe('NoticeOfIntentSubmissionService', () => { dateSubmittedToAlc: new Date(), }); mockNoiStatusService.setStatusDate.mockResolvedValue(new NoticeOfIntentSubmissionToSubmissionStatus()); + mockNoiDocService.list.mockResolvedValue([ + { + typeCode: DOCUMENT_TYPE.ORIGINAL_SUBMISSION, + } as any, + ]); mockNoiService.submit.mockResolvedValue(mockNoticeOfIntent); await service.submitToAlcs(mockNoiSubmission as ValidatedNoticeOfIntentSubmission, mockUser); diff --git a/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.ts b/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.ts index aaa874bd4c..a05e3570c3 100644 --- a/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.ts +++ b/services/apps/alcs/src/portal/notice-of-intent-submission/notice-of-intent-submission.service.ts @@ -30,6 +30,7 @@ import { PORTAL_TO_ALCS_STRUCTURE_MAP, PORTAL_TO_ALCS_TAGS_MAP, } from './notice-of-intent-submission.entity'; +import { NoticeOfIntent } from '../../alcs/notice-of-intent/notice-of-intent.entity'; @Injectable() export class NoticeOfIntentSubmissionService { @@ -273,10 +274,21 @@ export class NoticeOfIntentSubmissionService { } async submitToAlcs(noticeOfIntentSubmission: ValidatedNoticeOfIntentSubmission, user: User) { + await this.generateNoiSubmissionDocumentService.generateAndAttach(noticeOfIntentSubmission.fileNumber, user); + + const documents = await this.noticeOfIntentDocumentService.list(noticeOfIntentSubmission.fileNumber); + const submissionDocs = documents.filter((document) => document.typeCode === DOCUMENT_TYPE.ORIGINAL_SUBMISSION); + + if (submissionDocs.length < 1) { + throw new BaseServiceException('A document failed to generate', undefined, 'DocumentGenerationError'); + } + + let submittedNoi: NoticeOfIntent | null = null; + try { const tags = this.populateNoiTags(noticeOfIntentSubmission); - const submittedNoi = await this.noticeOfIntentService.submit({ + submittedNoi = await this.noticeOfIntentService.submit({ fileNumber: noticeOfIntentSubmission.fileNumber, applicant: noticeOfIntentSubmission.applicant, localGovernmentUuid: noticeOfIntentSubmission.localGovernmentUuid, @@ -290,14 +302,12 @@ export class NoticeOfIntentSubmissionService { NOI_SUBMISSION_STATUS.SUBMITTED_TO_ALC, submittedNoi.dateSubmittedToAlc, ); - - await this.generateNoiSubmissionDocumentService.generateAndAttach(submittedNoi.fileNumber, user); - - return submittedNoi; } catch (ex) { this.logger.error(ex); throw new BaseServiceException(`Failed to submit notice of intent: ${noticeOfIntentSubmission.fileNumber}`); } + + return submittedNoi; } /** diff --git a/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.ts b/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.ts index bc98b4f124..b419a23e16 100644 --- a/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.ts +++ b/services/apps/alcs/src/portal/notification-submission/notification-submission.controller.ts @@ -20,6 +20,7 @@ import { NotificationSubmissionValidatorService } from './notification-submissio import { NotificationSubmissionUpdateDto } from './notification-submission.dto'; import { NotificationSubmission } from './notification-submission.entity'; import { NotificationSubmissionService } from './notification-submission.service'; +import { BaseServiceException } from '@app/common/exceptions/base.exception'; @Controller('notification-submission') @UseGuards(PortalAuthGuard) @@ -153,20 +154,13 @@ export class NotificationSubmissionController { ); if (validationResult.noticeOfIntentSubmission) { - const validatedApplicationSubmission = - validationResult.noticeOfIntentSubmission; - - await this.notificationSubmissionService.submitToAlcs( - validatedApplicationSubmission, - ); + const validatedApplicationSubmission = validationResult.noticeOfIntentSubmission; await this.generatePdf(notificationSubmission, req.user.entity); - const finalSubmission = - await this.notificationSubmissionService.getByUuid( - uuid, - req.user.entity, - ); + await this.notificationSubmissionService.submitToAlcs(validatedApplicationSubmission); + + const finalSubmission = await this.notificationSubmissionService.getByUuid(uuid, req.user.entity); return await this.notificationSubmissionService.mapToDetailedDTO( finalSubmission, @@ -186,11 +180,9 @@ export class NotificationSubmissionController { ); if (savedDocument) { - await this.notificationSubmissionService.sendAndRecordLTSAPackage( - submission, - savedDocument, - user, - ); + await this.notificationSubmissionService.sendAndRecordLTSAPackage(submission, savedDocument, user); + } else { + throw new BaseServiceException('A document failed to generate', undefined, 'DocumentGenerationError'); } } } diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1734644882049-create_bond_conditions_where_missing.ts b/services/apps/alcs/src/providers/typeorm/migrations/1734644882049-create_bond_conditions_where_missing.ts index 63a5c3d8e9..8abb283b72 100644 --- a/services/apps/alcs/src/providers/typeorm/migrations/1734644882049-create_bond_conditions_where_missing.ts +++ b/services/apps/alcs/src/providers/typeorm/migrations/1734644882049-create_bond_conditions_where_missing.ts @@ -4,72 +4,82 @@ export class CreateBondConditionsWhereMissing1734644882049 implements MigrationI public async up(queryRunner: QueryRunner): Promise { // Applications queryRunner.query(` - insert into - alcs.application_decision_condition ( - audit_created_by, - decision_uuid, - security_amount, - type_code - ) - select - distinct 'oats_etl' as audit_created_by, - ad."uuid" as decision_uuid, - oaad.security_amt as security_amount, - 'BOND' as type_code - from - alcs.application_decision ad - join alcs.application_decision_condition adc on ad.uuid = adc.decision_uuid - right join oats.oats_alr_appl_decisions oaad on oaad.alr_appl_decision_id = ad.oats_alr_appl_decision_id - where - ad."uuid" not in ( + do $$ + begin + if exists (select schema_name from information_schema.schemata where schema_name = 'oats') then + insert into + alcs.application_decision_condition ( + audit_created_by, + decision_uuid, + security_amount, + type_code + ) select - adc.decision_uuid + distinct 'oats_etl' as audit_created_by, + ad."uuid" as decision_uuid, + oaad.security_amt as security_amount, + 'BOND' as type_code from - alcs.application_decision_condition adc + alcs.application_decision ad + join alcs.application_decision_condition adc on ad.uuid = adc.decision_uuid + right join oats.oats_alr_appl_decisions oaad on oaad.alr_appl_decision_id = ad.oats_alr_appl_decision_id where - adc.type_code = 'BOND' - ) - and adc.security_amount > 0 - and ad.audit_created_by = 'oats_etl' - and ( - oaad.security_amt = adc.security_amount - or adc.security_amount is null - ) on conflict ("uuid") do nothing; + ad."uuid" not in ( + select + adc.decision_uuid + from + alcs.application_decision_condition adc + where + adc.type_code = 'BOND' + ) + and adc.security_amount > 0 + and ad.audit_created_by = 'oats_etl' + and ( + oaad.security_amt = adc.security_amount + or adc.security_amount is null + ) on conflict ("uuid") do nothing; + end if; + end $$; `); // NOI's queryRunner.query(` - insert into - alcs.notice_of_intent_decision_condition ( - audit_created_by, - decision_uuid, - security_amount, - type_code - ) - select - distinct 'oats_etl' as audit_created_by, - noid."uuid" as decision_uuid, - oaad.security_amt as security_amount, - 'BOND' as type_code - from - alcs.notice_of_intent_decision noid - join alcs.notice_of_intent_decision_condition noidc on noid.uuid = noidc.decision_uuid - right join oats.oats_alr_appl_decisions oaad on oaad.alr_appl_decision_id = noid.oats_alr_appl_decision_id - where - noid."uuid" not in ( + do $$ + begin + if exists (select schema_name from information_schema.schemata where schema_name = 'oats') then + insert into + alcs.notice_of_intent_decision_condition ( + audit_created_by, + decision_uuid, + security_amount, + type_code + ) select - noidc.decision_uuid + distinct 'oats_etl' as audit_created_by, + noid."uuid" as decision_uuid, + oaad.security_amt as security_amount, + 'BOND' as type_code from - alcs.notice_of_intent_decision_condition noidc + alcs.notice_of_intent_decision noid + join alcs.notice_of_intent_decision_condition noidc on noid.uuid = noidc.decision_uuid + right join oats.oats_alr_appl_decisions oaad on oaad.alr_appl_decision_id = noid.oats_alr_appl_decision_id where - noidc.type_code = 'BOND' - ) - and noidc.security_amount > 0 - and noid.audit_created_by = 'oats_etl' - and ( - oaad.security_amt = noidc.security_amount - or noidc.security_amount is null - ) on conflict ("uuid") do nothing; + noid."uuid" not in ( + select + noidc.decision_uuid + from + alcs.notice_of_intent_decision_condition noidc + where + noidc.type_code = 'BOND' + ) + and noidc.security_amount > 0 + and noid.audit_created_by = 'oats_etl' + and ( + oaad.security_amt = noidc.security_amount + or noidc.security_amount is null + ) on conflict ("uuid") do nothing; + end if; + end $$; `); } diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1736280895137-create_application_conditions_board_and_columns.ts b/services/apps/alcs/src/providers/typeorm/migrations/1736280895137-create_application_conditions_board_and_columns.ts new file mode 100644 index 0000000000..24f53ddae4 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1736280895137-create_application_conditions_board_and_columns.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateApplicationConditionsBoardAndColumns1736280895137 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Insert the new board + await queryRunner.query(` + INSERT INTO alcs.board (code, title, show_on_schedule, audit_created_by) + VALUES ('appcon', 'Application Conditions', true, 'migration_seed') + `); + + // Get the UUID of the newly inserted board + const boardUuidResult = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'appcon' + `); + const boardUuid = boardUuidResult[0].uuid; + + // Get the code of the card type with code 'APP' + const cardTypeCodeResult = await queryRunner.query(` + SELECT code FROM alcs.card_type WHERE code = 'APP' + `); + const cardTypeCode = cardTypeCodeResult[0].code; + + // Insert the allowed card type for the new board + await queryRunner.query(` + INSERT INTO alcs.board_allowed_card_types_card_type (board_uuid, card_type_code) + VALUES ('${boardUuid}', '${cardTypeCode}') + `); + + // Insert new card statuses + await queryRunner.query(` + INSERT INTO alcs.card_status (code, label, description, audit_created_by) + VALUES + ('FOLL', 'Follow-up Required', 'Follow-up required', 'migration_seed'), + ('MATR', 'Material Received', 'Material received', 'migration_seed'), + ('CPRP', 'Prep', 'Prep', 'migration_seed'), + ('WAIT', 'Waiting for Applicant', 'Waiting for applicant', 'migration_seed'), + ('CPND', 'Pending Sign-off', 'Pending sign-off', 'migration_seed') + `); + + // Insert new board statuses + await queryRunner.query(` + INSERT INTO alcs.board_status (board_uuid, "order", status_code, audit_created_by) + VALUES + ('${boardUuid}', 0, 'FOLL', 'migration_seed'), + ('${boardUuid}', 1, 'MATR', 'migration_seed'), + ('${boardUuid}', 2, 'CPRP', 'migration_seed'), + ('${boardUuid}', 3, 'WAIT', 'migration_seed'), + ('${boardUuid}', 4, 'CPND', 'migration_seed') + `); + + // Check if the 'COMP' card status exists + const compStatusResult = await queryRunner.query(` + SELECT code FROM alcs.card_status WHERE code = 'COMP' + `); + + // If 'COMP' status exists, add it to the board statuses + if (compStatusResult.length > 0) { + await queryRunner.query(` + INSERT INTO alcs.board_status (board_uuid, "order", status_code, audit_created_by) + VALUES ('${boardUuid}', 5, 'COMP', 'migration_seed') + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Get the UUID of the board with code 'appcon' + const boardUuidResult = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'appcon' + `); + const boardUuid = boardUuidResult[0].uuid; + + // Delete the board statuses + await queryRunner.query(` + DELETE FROM alcs.board_status WHERE board_uuid = '${boardUuid}' AND status_code IN ('FOLL', 'MATR', 'CPRP', 'WAIT', 'CPND', 'COMP') + `); + + // Delete the card statuses + await queryRunner.query(` + DELETE FROM alcs.card_status WHERE code IN ('FOLL', 'MATR', 'CPRP', 'WAIT', 'CPND') + `); + + // Delete the allowed card type for the board + await queryRunner.query(` + DELETE FROM alcs.board_allowed_card_types_card_type WHERE board_uuid = '${boardUuid}' + `); + + // Delete the board + await queryRunner.query(` + DELETE FROM alcs.board WHERE code = 'appcon' + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1736448476896-add_application_decision_condition_card.ts b/services/apps/alcs/src/providers/typeorm/migrations/1736448476896-add_application_decision_condition_card.ts new file mode 100644 index 0000000000..8b4bb81484 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1736448476896-add_application_decision_condition_card.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddApplicationDecisionConditionCard1736448476896 implements MigrationInterface { + name = 'AddApplicationDecisionConditionCard1736448476896' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "alcs"."application_decision_condition_card" ("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(), "card_uuid" uuid NOT NULL, "decision_uuid" uuid NOT NULL, CONSTRAINT "REL_0fe3f690fa9452bf4b66c61408" UNIQUE ("card_uuid"), CONSTRAINT "PK_e4ba515d1fc9c054f566ea71846" PRIMARY KEY ("uuid"))`); + await queryRunner.query(`COMMENT ON TABLE "alcs"."application_decision_condition_card" IS 'Links application decision conditions with cards'`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition" ADD "condition_card_uuid" uuid`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition_card" ADD CONSTRAINT "FK_0fe3f690fa9452bf4b66c614083" FOREIGN KEY ("card_uuid") REFERENCES "alcs"."card"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition_card" ADD CONSTRAINT "FK_479c2feaef6520e2a351f3e7e57" FOREIGN KEY ("decision_uuid") REFERENCES "alcs"."application_decision"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition" ADD CONSTRAINT "FK_ae868c3100cf95c50d1052a3923" FOREIGN KEY ("condition_card_uuid") REFERENCES "alcs"."application_decision_condition_card"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition" DROP CONSTRAINT "FK_ae868c3100cf95c50d1052a3923"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition_card" DROP CONSTRAINT "FK_479c2feaef6520e2a351f3e7e57"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition_card" DROP CONSTRAINT "FK_0fe3f690fa9452bf4b66c614083"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision_condition" DROP COLUMN "condition_card_uuid"`); + await queryRunner.query(`COMMENT ON TABLE "alcs"."application_decision_condition_card" IS NULL`); + await queryRunner.query(`DROP TABLE "alcs"."application_decision_condition_card"`); + } + +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1736464717386-add_application_condition_card_type.ts b/services/apps/alcs/src/providers/typeorm/migrations/1736464717386-add_application_condition_card_type.ts new file mode 100644 index 0000000000..e082a970bf --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1736464717386-add_application_condition_card_type.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApplicationConditionCardType1736464717386 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Insert the new card type into the alcs.card_type table + await queryRunner.query(` + INSERT INTO alcs.card_type (code, label, description, portal_html_description, audit_created_by) + VALUES ('APPCON', 'Application Condition', 'Card type for application conditions', '', 'migration_seed'); + `); + + // Step 2: Find the board by code 'appcon' + const board = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'appcon'; + `); + + if (board.length > 0) { + const boardUuid = board[0].uuid; + + // Step 3: Add the new card type to the alcs.board_allowed_card_types_card_type table + await queryRunner.query(` + INSERT INTO alcs.board_allowed_card_types_card_type (board_uuid, card_type_code) + VALUES ('${boardUuid}', 'APPCON'); + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Step 1: Find the board by code 'appcon' + const board = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'appcon'; + `); + + if (board.length > 0) { + const boardUuid = board[0].uuid; + + // Step 2: Remove the new card type from the alcs.board_allowed_card_types_card_type table + await queryRunner.query(` + DELETE FROM alcs.board_allowed_card_types_card_type + WHERE board_uuid = '${boardUuid}' AND card_type_code = 'APPCON'; + `); + } + + // Step 3: Delete the new card type from the alcs.card_type table + await queryRunner.query(` + DELETE FROM alcs.card_type WHERE code = 'APPCON'; + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1737148712969-add_flag_fields_to_app_noi_decisions.ts b/services/apps/alcs/src/providers/typeorm/migrations/1737148712969-add_flag_fields_to_app_noi_decisions.ts new file mode 100644 index 0000000000..e1199a24e4 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1737148712969-add_flag_fields_to_app_noi_decisions.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddFlagFieldsToAppNoiDecisions1737148712969 implements MigrationInterface { + name = 'AddFlagFieldsToAppNoiDecisions1737148712969' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD "is_flagged" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD "reason_flagged" text`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD "follow_up_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD "flag_edited_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD "flagged_by_uuid" uuid`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD "flag_edited_by_uuid" uuid`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD "is_flagged" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD "reason_flagged" text`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD "follow_up_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD "flag_edited_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD "flagged_by_uuid" uuid`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD "flag_edited_by_uuid" uuid`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD CONSTRAINT "FK_ba6fa8d1851029a9859afc35b03" FOREIGN KEY ("flagged_by_uuid") REFERENCES "alcs"."user"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" ADD CONSTRAINT "FK_93cde17558333a6f39d089928de" FOREIGN KEY ("flag_edited_by_uuid") REFERENCES "alcs"."user"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD CONSTRAINT "FK_9b50f52d4c843ff2656ce04e575" FOREIGN KEY ("flagged_by_uuid") REFERENCES "alcs"."user"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" ADD CONSTRAINT "FK_8b3ab9ae1ef21da9ebe8b358a39" FOREIGN KEY ("flag_edited_by_uuid") REFERENCES "alcs"."user"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP CONSTRAINT "FK_8b3ab9ae1ef21da9ebe8b358a39"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP CONSTRAINT "FK_9b50f52d4c843ff2656ce04e575"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP CONSTRAINT "FK_93cde17558333a6f39d089928de"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP CONSTRAINT "FK_ba6fa8d1851029a9859afc35b03"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP COLUMN "flag_edited_by_uuid"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP COLUMN "flagged_by_uuid"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP COLUMN "flag_edited_at"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP COLUMN "follow_up_at"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP COLUMN "reason_flagged"`); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision" DROP COLUMN "is_flagged"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP COLUMN "flag_edited_by_uuid"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP COLUMN "flagged_by_uuid"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP COLUMN "flag_edited_at"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP COLUMN "follow_up_at"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP COLUMN "reason_flagged"`); + await queryRunner.query(`ALTER TABLE "alcs"."application_decision" DROP COLUMN "is_flagged"`); + } + +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1737402613673-turp_label_fix.ts b/services/apps/alcs/src/providers/typeorm/migrations/1737402613673-turp_label_fix.ts new file mode 100644 index 0000000000..d9baba94d3 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1737402613673-turp_label_fix.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TurpLabelFix1737402613673 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE alcs.application_decision_component_type SET + label = 'Transportation, Utility, or Recreational Trail', + description = 'Transportation, Utility, or Recreational Trail' + WHERE code = 'TURP'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE alcs.application_decision_component_type SET + label = 'Transportation, Utility, and Recreational Trail', + description = 'Transportation, Utility, and Recreational Trail' + WHERE code = 'TURP'; + `); + } + +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1737750628423-add_application_condition_card_type_to_new_boards.ts b/services/apps/alcs/src/providers/typeorm/migrations/1737750628423-add_application_condition_card_type_to_new_boards.ts new file mode 100644 index 0000000000..12423478d4 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1737750628423-add_application_condition_card_type_to_new_boards.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApplicationConditionCardTypeToNewBoards1737750628423 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO alcs.board_allowed_card_types_card_type (board_uuid, card_type_code) + SELECT b.uuid, 'APPCON' + FROM alcs.board b + WHERE b.code IN ('ceo', 'exec', 'film', 'island', 'inte', 'soil', 'okan', 'koot', 'north', 'south'); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM alcs.board_allowed_card_types_card_type + WHERE card_type_code = 'APPCON' + AND alcs.board_uuid IN (SELECT uuid FROM alcs.board WHERE code IN ('ceo', 'exec', 'film', 'island', 'inte', 'soil', 'okan', 'koot', 'north', 'south')); + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1738089637628-turn_off_application_condition_board_show_on_schedule.ts b/services/apps/alcs/src/providers/typeorm/migrations/1738089637628-turn_off_application_condition_board_show_on_schedule.ts new file mode 100644 index 0000000000..1e60ec5f24 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1738089637628-turn_off_application_condition_board_show_on_schedule.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class TurnOffApplicationConditionBoardShowOnSchedule1738089637628 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE alcs.board + SET show_on_schedule = false + WHERE code = 'appcon' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE alcs.board + SET show_on_schedule = true + WHERE code = 'appcon' + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1738196945240-remove_application_card_type_from_condition_board.ts b/services/apps/alcs/src/providers/typeorm/migrations/1738196945240-remove_application_card_type_from_condition_board.ts new file mode 100644 index 0000000000..57b8458307 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1738196945240-remove_application_card_type_from_condition_board.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveApplicationCardTypeFromConditionBoard1738196945240 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM alcs.board_allowed_card_types_card_type + WHERE board_uuid = ( + SELECT uuid FROM alcs.board WHERE code = 'appcon' + ) + AND card_type_code = 'APP'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO alcs.board_allowed_card_types_card_type (board_uuid, card_type_code) + VALUES ( + (SELECT uuid FROM alcs.board WHERE code = 'appcon'), + 'APP' + ); + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1738277452499-create_noi_conditions_board_and_columns.ts b/services/apps/alcs/src/providers/typeorm/migrations/1738277452499-create_noi_conditions_board_and_columns.ts new file mode 100644 index 0000000000..55a0a3bd3c --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1738277452499-create_noi_conditions_board_and_columns.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateNoiConditionsBoardAndColumns1738277452499 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Insert the new board + await queryRunner.query(` + INSERT INTO alcs.board (code, title, show_on_schedule, audit_created_by) + VALUES ('noicon', 'NOI Conditions', false, 'migration_seed') + `); + + // Get the UUID of the newly inserted board + const boardUuidResult = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'noicon' + `); + const boardUuid = boardUuidResult[0].uuid; + + // Insert new board statuses + await queryRunner.query(` + INSERT INTO alcs.board_status (board_uuid, "order", status_code, audit_created_by) + VALUES + ('${boardUuid}', 0, 'FOLL', 'migration_seed'), + ('${boardUuid}', 1, 'MATR', 'migration_seed'), + ('${boardUuid}', 2, 'CPRP', 'migration_seed'), + ('${boardUuid}', 3, 'WAIT', 'migration_seed'), + ('${boardUuid}', 4, 'CPND', 'migration_seed'), + ('${boardUuid}', 5, 'COMP', 'migration_seed') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Get the UUID of the board with code 'appcon' + const boardUuidResult = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'appcon' + `); + const boardUuid = boardUuidResult[0].uuid; + + // Delete the board statuses + await queryRunner.query(` + DELETE FROM alcs.board_status WHERE board_uuid = '${boardUuid}' AND status_code IN ('FOLL', 'MATR', 'CPRP', 'WAIT', 'CPND', 'COMP') + `); + + // Delete the board + await queryRunner.query(` + DELETE FROM alcs.board WHERE code = 'noicon' + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1738278602878-add_noi_condition_card_type.ts b/services/apps/alcs/src/providers/typeorm/migrations/1738278602878-add_noi_condition_card_type.ts new file mode 100644 index 0000000000..e4d6537d04 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1738278602878-add_noi_condition_card_type.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNoiConditionCardType1738278602878 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Insert the new card type into the alcs.card_type table + await queryRunner.query(` + INSERT INTO alcs.card_type (code, label, description, portal_html_description, audit_created_by) + VALUES ('NOICON', 'NOI Condition', 'Card type for NOI conditions', '', 'migration_seed'); + `); + + // Step 2: Find the board by code 'appcon' + const board = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'noicon'; + `); + + if (board.length > 0) { + const boardUuid = board[0].uuid; + + // Step 3: Add the new card type to the alcs.board_allowed_card_types_card_type table + await queryRunner.query(` + INSERT INTO alcs.board_allowed_card_types_card_type (board_uuid, card_type_code) + VALUES ('${boardUuid}', 'NOICON'); + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Step 1: Find the board by code 'appcon' + const board = await queryRunner.query(` + SELECT uuid FROM alcs.board WHERE code = 'noicon'; + `); + + if (board.length > 0) { + const boardUuid = board[0].uuid; + + // Step 2: Remove the new card type from the alcs.board_allowed_card_types_card_type table + await queryRunner.query(` + DELETE FROM alcs.board_allowed_card_types_card_type + WHERE board_uuid = '${boardUuid}' AND card_type_code = 'NOICON'; + `); + } + + // Step 3: Delete the new card type from the alcs.card_type table + await queryRunner.query(` + DELETE FROM alcs.card_type WHERE code = 'NOICON'; + `); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1738278849708-add_noi_decision_condition_card.ts b/services/apps/alcs/src/providers/typeorm/migrations/1738278849708-add_noi_decision_condition_card.ts new file mode 100644 index 0000000000..ad7483a1d1 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1738278849708-add_noi_decision_condition_card.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNoiDecisionConditionCard1738278849708 implements MigrationInterface { + name = 'AddNoiDecisionConditionCard1738278849708'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "alcs"."notice_of_intent_decision_condition_card" ("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(), "card_uuid" uuid NOT NULL, "decision_uuid" uuid NOT NULL, CONSTRAINT "REL_f5487b5d4edd09dba2417b266c" UNIQUE ("card_uuid"), CONSTRAINT "PK_f8b06cd95cf71b53a6da5bfbb7d" PRIMARY KEY ("uuid"))`, + ); + await queryRunner.query( + `COMMENT ON TABLE "alcs"."notice_of_intent_decision_condition_card" IS 'Links notice of intent decision conditions with cards'`, + ); + await queryRunner.query(`ALTER TABLE "alcs"."notice_of_intent_decision_condition" ADD "condition_card_uuid" uuid`); + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition_card" ADD CONSTRAINT "FK_f5487b5d4edd09dba2417b266c6" FOREIGN KEY ("card_uuid") REFERENCES "alcs"."card"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition_card" ADD CONSTRAINT "FK_7cf55f3865a915283960cae1eb0" FOREIGN KEY ("decision_uuid") REFERENCES "alcs"."notice_of_intent_decision"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition" ADD CONSTRAINT "FK_a7c48dd8395ba0555ae90dca0cf" FOREIGN KEY ("condition_card_uuid") REFERENCES "alcs"."notice_of_intent_decision_condition_card"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition" DROP CONSTRAINT "FK_a7c48dd8395ba0555ae90dca0cf"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition_card" DROP CONSTRAINT "FK_7cf55f3865a915283960cae1eb0"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition_card" DROP CONSTRAINT "FK_f5487b5d4edd09dba2417b266c6"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notice_of_intent_decision_condition" DROP COLUMN "condition_card_uuid"`, + ); + await queryRunner.query(`COMMENT ON TABLE "alcs"."notice_of_intent_decision_condition_card" IS NULL`); + await queryRunner.query(`DROP TABLE "alcs"."notice_of_intent_decision_condition_card"`); + } +} diff --git a/services/apps/alcs/src/utils/incoming-date.formatter.ts b/services/apps/alcs/src/utils/incoming-date.formatter.ts index 0d74ab7c25..6ef039558e 100644 --- a/services/apps/alcs/src/utils/incoming-date.formatter.ts +++ b/services/apps/alcs/src/utils/incoming-date.formatter.ts @@ -1,5 +1,5 @@ export const formatIncomingDate = (date?: number | null) => { - if (date) { + if (date !== undefined && date !== null) { return new Date(date); } else if (date === null) { return null; diff --git a/services/apps/alcs/test/mocks/mockEntities.ts b/services/apps/alcs/test/mocks/mockEntities.ts index 6906650d9d..026063495f 100644 --- a/services/apps/alcs/test/mocks/mockEntities.ts +++ b/services/apps/alcs/test/mocks/mockEntities.ts @@ -26,6 +26,7 @@ import { TagCategory } from '../../src/alcs/tag/tag-category/tag-category.entity import { Tag } from '../../src/alcs/tag/tag.entity'; import { NoticeOfIntent } from '../../src/alcs/notice-of-intent/notice-of-intent.entity'; import { NoticeOfIntentType } from '../../src/alcs/notice-of-intent/notice-of-intent-type/notice-of-intent-type.entity'; +import { ApplicationDecisionConditionCard } from '../../src/alcs/application-decision/application-decision-condition/application-decision-condition-card/application-decision-condition-card.entity'; const initCardStatusMockEntity = (): CardStatus => { const cardStatus = new CardStatus(); @@ -198,11 +199,11 @@ const initApplicationModificationMockEntity = (application?: Application, card?: return modification; }; -const initApplicationMockEntity = (fileNumber?: string): Application => { +const initApplicationMockEntity = (fileNumber?: string, uuid?: string): Application => { const applicationEntity = new Application(); applicationEntity.fileNumber = fileNumber ?? 'app_1'; applicationEntity.applicant = 'applicant 1'; - applicationEntity.uuid = '1111-1111-1111-1111'; + applicationEntity.uuid = uuid ?? '1111-1111-1111-1111'; applicationEntity.auditDeletedDateAt = new Date(1, 1, 1, 1, 1, 1, 1); applicationEntity.auditCreatedAt = new Date(1, 1, 1, 1, 1, 1, 1); applicationEntity.auditUpdatedAt = new Date(1, 1, 1, 1, 1, 1, 1); @@ -392,6 +393,24 @@ const initTagMockEntity = (): Tag => { return tag; }; +const initMockApplicationDecisionConditionCard = ( + application?: Application, + applicationDecision?: ApplicationDecision, + card?: Card, +): ApplicationDecisionConditionCard => { + const conditionCard = new ApplicationDecisionConditionCard(); + conditionCard.uuid = 'condition-card-uuid'; + if (application) { + conditionCard.decision = initApplicationDecisionMock(application); + } else { + conditionCard.decision = applicationDecision ?? initApplicationDecisionMock(); + } + conditionCard.card = card ?? initCardMockEntity(); + conditionCard.cardUuid = conditionCard.card.uuid; + + return conditionCard; +}; + export { initCardStatusMockEntity, initApplicationMockEntity, @@ -416,4 +435,5 @@ export { initApplicationWithTagsMockEntity, initNoticeOfIntentMockEntity, initNoticeOfIntentWithTagsMockEntity, + initMockApplicationDecisionConditionCard, }; diff --git a/services/libs/common/src/exceptions/base.exception.ts b/services/libs/common/src/exceptions/base.exception.ts index 67eb664186..d07c5907c1 100644 --- a/services/libs/common/src/exceptions/base.exception.ts +++ b/services/libs/common/src/exceptions/base.exception.ts @@ -1,32 +1,37 @@ import { HttpException, HttpStatus } from '@nestjs/common'; export class BaseErrorResponseModel { - constructor(statusCode: number, message: string, path?: string) { + constructor(statusCode: number, name: string, message: string, path?: string) { this.statusCode = statusCode; + this.name = name; this.message = message; this.path = path; } statusCode: number; - path: string | undefined; + name: string; message: string; + path: string | undefined; } export class BaseServiceException extends HttpException { - constructor(error: string | Record, status?: number) { + constructor(error: string | Record, status?: number, name?: string) { super(error, status ?? HttpStatus.INTERNAL_SERVER_ERROR); + if (name) { + this.name = name; + } } } export class ServiceValidationException extends BaseServiceException { - constructor(error: string | Record) { - super(error, HttpStatus.BAD_REQUEST); + constructor(error: string | Record, name?: string) { + super(error, HttpStatus.BAD_REQUEST, name); } } export class ServiceNotFoundException extends BaseServiceException { - constructor(error: string | Record) { - super(error, HttpStatus.NOT_FOUND); + constructor(error: string | Record, name?: string) { + super(error, HttpStatus.NOT_FOUND, name); } } @@ -35,3 +40,9 @@ export class ServiceConflictException extends BaseServiceException { super(error, HttpStatus.CONFLICT); } } + +export class ServiceInternalErrorException extends BaseServiceException { + constructor(error: string | Record) { + super(error, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/services/libs/common/src/exceptions/exception.filter.spec.ts b/services/libs/common/src/exceptions/exception.filter.spec.ts index 5642d588e5..8ce23bb451 100644 --- a/services/libs/common/src/exceptions/exception.filter.spec.ts +++ b/services/libs/common/src/exceptions/exception.filter.spec.ts @@ -35,10 +35,7 @@ describe('HttpExceptionFilter', () => { }); it('should call global HttpExceptionFilter', () => { - const mockHttpException = new HttpException( - { message: 'Sample Exception' }, - HttpStatus.BAD_REQUEST, - ); + const mockHttpException = new HttpException({ message: 'Sample Exception' }, HttpStatus.BAD_REQUEST); service.catch(mockHttpException, mockArgumentsHost); expect(mockHttpArgumentsHost).toBeCalledTimes(1); @@ -51,6 +48,7 @@ describe('HttpExceptionFilter', () => { expect(mockSend).toBeCalledWith( new BaseErrorResponseModel( mockHttpException.getStatus(), + mockHttpException.name, mockHttpException.message, mockGetRequest().url, ), diff --git a/services/libs/common/src/exceptions/exception.filter.ts b/services/libs/common/src/exceptions/exception.filter.ts index dd2f50a04c..02bf8d3577 100644 --- a/services/libs/common/src/exceptions/exception.filter.ts +++ b/services/libs/common/src/exceptions/exception.filter.ts @@ -14,8 +14,6 @@ export class HttpExceptionFilter implements ExceptionFilter { this.logger.error(exception); - response - .status(status) - .send(new BaseErrorResponseModel(status, exception.message, request.url)); + response.status(status).send(new BaseErrorResponseModel(status, exception.name, exception.message, request.url)); } } diff --git a/services/templates/emails/submitted-to-alc/tur-applicant.template.ts b/services/templates/emails/submitted-to-alc/tur-applicant.template.ts index 8a23ae5e1c..37063b5932 100644 --- a/services/templates/emails/submitted-to-alc/tur-applicant.template.ts +++ b/services/templates/emails/submitted-to-alc/tur-applicant.template.ts @@ -3,7 +3,7 @@ import { feesTable } from '../partials/fees-table.template'; import { notificationOnly } from '../partials/notification-only.template'; const turFees = [ - { type: 'Transportation, Utility, and Recreational Trail Uses', fee: 1500 }, + { type: 'Transportation, Utility, or Recreational Trail Uses', fee: 1500 }, ]; export const template = build(
    #{{ i + 1 }} + {{ i + 1 }} + Type + Additional Proposal Information Total Floor Area + Additional Proposal Information Action + @@ -165,12 +183,15 @@

    Additional Proposal Information

    - The proposed residential structure and its total floor area must be allowed under the ALC Act and/or ALR Use Regulation. - If not, you may require a 'Non-Adhering Residential Use' application instead. For more info, please see - Housing in the ALR + The proposed residential structure and its total floor area must be allowed under the ALC Act and/or + ALR Use Regulation. If not, you may require a 'Non-Adhering Residential Use' application instead. For + more info, please see + Housing in the ALR on the ALC website.
    -
    +