diff --git a/alcs-frontend/src/app/app-routing.module.ts b/alcs-frontend/src/app/app-routing.module.ts index 94d71b7f2f..ae6b1aa322 100644 --- a/alcs-frontend/src/app/app-routing.module.ts +++ b/alcs-frontend/src/app/app-routing.module.ts @@ -53,6 +53,14 @@ const routes: Routes = [ }, loadChildren: () => import('./features/notification/notification.module').then((m) => m.NotificationModule), }, + { + path: 'planning-review', + canActivate: [HasRolesGuard], + data: { + roles: ROLES_ALLOWED_APPLICATIONS, + }, + loadChildren: () => import('./features/planning-review/planning-review.module').then((m) => m.PlanningReviewModule), + }, { path: 'schedule', canActivate: [HasRolesGuard], diff --git a/alcs-frontend/src/app/features/board/board.component.html b/alcs-frontend/src/app/features/board/board.component.html index a47d05b873..1a432a257c 100644 --- a/alcs-frontend/src/app/features/board/board.component.html +++ b/alcs-frontend/src/app/features/board/board.component.html @@ -7,34 +7,17 @@

- + + + + - - - - - - -
diff --git a/alcs-frontend/src/app/features/board/board.component.spec.ts b/alcs-frontend/src/app/features/board/board.component.spec.ts index e095242b07..4c1c21ad08 100644 --- a/alcs-frontend/src/app/features/board/board.component.spec.ts +++ b/alcs-frontend/src/app/features/board/board.component.spec.ts @@ -23,7 +23,8 @@ import { NoticeOfIntentModificationService } from '../../services/notice-of-inte import { NoticeOfIntentService } from '../../services/notice-of-intent/notice-of-intent.service'; import { NotificationDto } from '../../services/notification/notification.dto'; import { NotificationService } from '../../services/notification/notification.service'; -import { PlanningReviewDto } from '../../services/planning-review/planning-review.dto'; +import { PlanningReferralService } from '../../services/planning-review/planning-referral.service'; +import { PlanningReferralDto, PlanningReviewDto } from '../../services/planning-review/planning-review.dto'; import { PlanningReviewService } from '../../services/planning-review/planning-review.service'; import { ToastService } from '../../services/toast/toast.service'; import { CardType } from '../../shared/card/card.component'; @@ -40,7 +41,7 @@ describe('BoardComponent', () => { let router: DeepMocked; let cardService: DeepMocked; let reconsiderationService: DeepMocked; - let planningReviewService: DeepMocked; + let planningReferralService: DeepMocked; let modificationService: DeepMocked; let covenantService: DeepMocked; let titleService: DeepMocked; @@ -96,7 +97,7 @@ describe('BoardComponent', () => { applications: [], covenants: [], modifications: [], - planningReviews: [], + planningReferrals: [], reconsiderations: [], noticeOfIntents: [], noiModifications: [], @@ -109,7 +110,7 @@ describe('BoardComponent', () => { router = createMock(); cardService = createMock(); reconsiderationService = createMock(); - planningReviewService = createMock(); + planningReferralService = createMock(); modificationService = createMock(); covenantService = createMock(); titleService = createMock(); @@ -162,8 +163,8 @@ describe('BoardComponent', () => { useValue: reconsiderationService, }, { - provide: PlanningReviewService, - useValue: planningReviewService, + provide: PlanningReferralService, + useValue: planningReferralService, }, { provide: ApplicationModificationService, @@ -223,7 +224,7 @@ describe('BoardComponent', () => { applications: [mockApplication], covenants: [], modifications: [], - planningReviews: [], + planningReferrals: [], reconsiderations: [], noticeOfIntents: [], noiModifications: [], @@ -244,7 +245,7 @@ describe('BoardComponent', () => { applications: [], covenants: [], modifications: [], - planningReviews: [], + planningReferrals: [], reconsiderations: [mockRecon], noticeOfIntents: [], noiModifications: [], @@ -287,7 +288,7 @@ describe('BoardComponent', () => { applications: [mockApplication, highPriorityApplication, highActiveDays], covenants: [], modifications: [], - planningReviews: [], + planningReferrals: [], reconsiderations: [], noticeOfIntents: [], noiModifications: [], @@ -311,7 +312,7 @@ describe('BoardComponent', () => { new Map([ ['card', 'app-id'], ['type', CardType.APP], - ]) + ]), ); await sleep(1); @@ -327,7 +328,7 @@ describe('BoardComponent', () => { new Map([ ['card', 'app-id'], ['type', CardType.RECON], - ]) + ]), ); await sleep(1); @@ -337,18 +338,18 @@ describe('BoardComponent', () => { }); it('should load planning review and open dialog when url is set', async () => { - planningReviewService.fetchByCardUuid.mockResolvedValue({} as PlanningReviewDto); + planningReferralService.fetchByCardUuid.mockResolvedValue({} as PlanningReferralDto); queryParamMapEmitter.next( new Map([ ['card', 'app-id'], ['type', CardType.PLAN], - ]) + ]), ); await sleep(1); - expect(planningReviewService.fetchByCardUuid).toHaveBeenCalledTimes(1); + expect(planningReferralService.fetchByCardUuid).toHaveBeenCalledTimes(1); expect(dialog.open).toHaveBeenCalledTimes(1); }); @@ -359,7 +360,7 @@ describe('BoardComponent', () => { new Map([ ['card', 'app-id'], ['type', CardType.COV], - ]) + ]), ); await sleep(1); @@ -375,7 +376,7 @@ describe('BoardComponent', () => { new Map([ ['card', 'app-id'], ['type', CardType.NOTIFICATION], - ]) + ]), ); await sleep(1); @@ -391,7 +392,7 @@ describe('BoardComponent', () => { new Map([ ['card', 'app-id'], ['type', CardType.COV], - ]) + ]), ); await sleep(1); diff --git a/alcs-frontend/src/app/features/board/board.component.ts b/alcs-frontend/src/app/features/board/board.component.ts index 33e83eea8e..2b536d9300 100644 --- a/alcs-frontend/src/app/features/board/board.component.ts +++ b/alcs-frontend/src/app/features/board/board.component.ts @@ -23,13 +23,12 @@ import { NoticeOfIntentDto } from '../../services/notice-of-intent/notice-of-int import { NoticeOfIntentService } from '../../services/notice-of-intent/notice-of-intent.service'; import { NotificationDto } from '../../services/notification/notification.dto'; import { NotificationService } from '../../services/notification/notification.service'; -import { PlanningReviewDto } from '../../services/planning-review/planning-review.dto'; -import { PlanningReviewService } from '../../services/planning-review/planning-review.service'; +import { PlanningReferralService } from '../../services/planning-review/planning-referral.service'; +import { PlanningReferralDto } from '../../services/planning-review/planning-review.dto'; import { ToastService } from '../../services/toast/toast.service'; import { COVENANT_TYPE_LABEL, MODIFICATION_TYPE_LABEL, - PLANNING_TYPE_LABEL, RECON_TYPE_LABEL, RETROACTIVE_TYPE_LABEL, } from '../../shared/application-type-pill/application-type-pill.constants'; @@ -38,12 +37,9 @@ import { DragDropColumn } from '../../shared/drag-drop-board/drag-drop-column.in import { AppModificationDialogComponent } from './dialogs/app-modification/app-modification-dialog.component'; import { CreateAppModificationDialogComponent } from './dialogs/app-modification/create/create-app-modification-dialog.component'; import { ApplicationDialogComponent } from './dialogs/application/application-dialog.component'; -import { CreateApplicationDialogComponent } from './dialogs/application/create/create-application-dialog.component'; import { CovenantDialogComponent } from './dialogs/covenant/covenant-dialog.component'; -import { CreateCovenantDialogComponent } from './dialogs/covenant/create/create-covenant-dialog.component'; import { CreateNoiModificationDialogComponent } from './dialogs/noi-modification/create/create-noi-modification-dialog.component'; import { NoiModificationDialogComponent } from './dialogs/noi-modification/noi-modification-dialog.component'; -import { CreateNoticeOfIntentDialogComponent } from './dialogs/notice-of-intent/create/create-notice-of-intent-dialog.component'; import { NoticeOfIntentDialogComponent } from './dialogs/notice-of-intent/notice-of-intent-dialog.component'; import { NotificationDialogComponent } from './dialogs/notification/notification-dialog.component'; import { CreatePlanningReviewDialogComponent } from './dialogs/planning-review/create/create-planning-review-dialog.component'; @@ -67,19 +63,52 @@ export class BoardComponent implements OnInit, OnDestroy { $destroy = new Subject<void>(); cards: CardData[] = []; columns: DragDropColumn[] = []; + boards: BoardWithFavourite[] = []; boardTitle = ''; boardIsFavourite = false; - boardHasCreateApplication = false; - boardHasCreatePlanningReview = false; - boardHasCreateReconsideration = false; - boardHasCreateAppModification = false; - boardHasCreateCovenant = false; - boardHasCreateNOI = false; - boardHasCreateNOIModification = false; currentBoardCode = ''; - selectedBoardCode?: string; - boards: BoardWithFavourite[] = []; + creatableCards: { + label: string; + dialog: ComponentType<any>; + }[] = []; + + private createCardMap = new Map< + CardType, + { + label: string; + dialog: ComponentType<any>; + } + >([ + [ + CardType.RECON, + { + label: 'Reconsideration', + dialog: CreateReconsiderationDialogComponent, + }, + ], + [ + CardType.MODI, + { + label: 'Application Modification', + dialog: CreateAppModificationDialogComponent, + }, + ], + [ + CardType.NOI_MODI, + { + label: 'NOI Modification', + dialog: CreateNoiModificationDialogComponent, + }, + ], + [ + CardType.PLAN, + { + label: 'Planning Review', + dialog: CreatePlanningReviewDialogComponent, + }, + ], + ]); constructor( private applicationService: ApplicationService, @@ -90,13 +119,13 @@ export class BoardComponent implements OnInit, OnDestroy { private router: Router, private cardService: CardService, private reconsiderationService: ApplicationReconsiderationService, - private planningReviewService: PlanningReviewService, + private planningReferralService: PlanningReferralService, private modificationService: ApplicationModificationService, private covenantService: CovenantService, private noticeOfIntentService: NoticeOfIntentService, private noiModificationService: NoticeOfIntentModificationService, private notificationService: NotificationService, - private titleService: Title + private titleService: Title, ) {} ngOnInit() { @@ -140,44 +169,8 @@ export class BoardComponent implements OnInit, OnDestroy { this.setUrl(card.uuid, card.cardType); } - onApplicationCreate() { - this.openDialog(CreateApplicationDialogComponent, { - currentBoardCode: this.selectedBoardCode, - }); - } - - onReconsiderationCreate() { - this.openDialog(CreateReconsiderationDialogComponent, { - currentBoardCode: this.selectedBoardCode, - }); - } - - onCreatePlanningReview() { - this.openDialog(CreatePlanningReviewDialogComponent, { - currentBoardCode: this.selectedBoardCode, - }); - } - - onCreateAppModification() { - this.openDialog(CreateAppModificationDialogComponent, { - currentBoardCode: this.selectedBoardCode, - }); - } - - onCreateCovenant() { - this.openDialog(CreateCovenantDialogComponent, { - currentBoardCode: this.selectedBoardCode, - }); - } - - onCreateNoticeOfIntent() { - this.openDialog(CreateNoticeOfIntentDialogComponent, { - currentBoardCode: this.selectedBoardCode, - }); - } - - onCreateNoiModifications() { - this.openDialog(CreateNoiModificationDialogComponent, { + onOpenCreateDialog(component: ComponentType<any>) { + this.openDialog(component, { currentBoardCode: this.selectedBoardCode, }); } @@ -228,13 +221,18 @@ export class BoardComponent implements OnInit, OnDestroy { const board = response.board; this.boardTitle = board.title; - this.boardHasCreateApplication = board.createCardTypes.includes(CardType.APP); - this.boardHasCreatePlanningReview = board.createCardTypes.includes(CardType.PLAN); - this.boardHasCreateReconsideration = board.createCardTypes.includes(CardType.RECON); - this.boardHasCreateAppModification = board.createCardTypes.includes(CardType.MODI); - this.boardHasCreateCovenant = board.createCardTypes.includes(CardType.COV); - this.boardHasCreateNOI = board.createCardTypes.includes(CardType.NOI); - this.boardHasCreateNOIModification = board.createCardTypes.includes(CardType.NOI_MODI); + + const creatableCards: { + label: string; + dialog: ComponentType<any>; + }[] = []; + for (const cardType of board.createCardTypes) { + const creator = this.createCardMap.get(cardType); + if (creator) { + creatableCards.push(creator); + } + } + this.creatableCards = creatableCards; const allStatuses = board.statuses.map((status) => status.statusCode); @@ -249,12 +247,12 @@ export class BoardComponent implements OnInit, OnDestroy { private mapAndSortCards(response: CardsDto, boardCode: string) { const mappedApps = response.applications.map(this.mapApplicationDtoToCard.bind(this)); const mappedRecons = response.reconsiderations.map(this.mapReconsiderationDtoToCard.bind(this)); - const mappedReviewMeetings = response.planningReviews.map(this.mapPlanningReviewToCard.bind(this)); + const mappedPlanningReferrals = response.planningReferrals.map(this.mapPlanningReferralToCard.bind(this)); const mappedModifications = response.modifications.map(this.mapModificationToCard.bind(this)); const mappedCovenants = response.covenants.map(this.mapCovenantToCard.bind(this)); const mappedNoticeOfIntents = response.noticeOfIntents.map(this.mapNoticeOfIntentToCard.bind(this)); const mappedNoticeOfIntentModifications = response.noiModifications.map( - this.mapNoticeOfIntentModificationToCard.bind(this) + this.mapNoticeOfIntentModificationToCard.bind(this), ); const mappedNotifications = response.notifications.map(this.mapNotificationToCard.bind(this)); if (boardCode === BOARD_TYPE_CODES.VETT) { @@ -267,7 +265,7 @@ export class BoardComponent implements OnInit, OnDestroy { this.cards = [ ...[...mappedNoticeOfIntents, ...mappedNoticeOfIntentModifications].sort(vettingSort), ...[...mappedApps, ...mappedRecons, ...mappedModifications].sort(vettingSort), - ...[...mappedReviewMeetings, ...mappedCovenants].sort(vettingSort), + ...[...mappedPlanningReferrals, ...mappedCovenants].sort(vettingSort), ...mappedNotifications, ]; } else if (boardCode === BOARD_TYPE_CODES.NOI) { @@ -291,7 +289,7 @@ export class BoardComponent implements OnInit, OnDestroy { ...mappedApps, ...mappedRecons, ...mappedModifications, - ...mappedReviewMeetings, + ...mappedPlanningReferrals, ...mappedCovenants, ...mappedNotifications, ].sort(noiSort); @@ -306,7 +304,7 @@ export class BoardComponent implements OnInit, OnDestroy { ...mappedApps.filter((a) => a.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), ...mappedModifications.filter((r) => r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), ...mappedRecons.filter((r) => r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), - ...mappedReviewMeetings.filter((r) => r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), + ...mappedPlanningReferrals.filter((r) => r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), ...mappedCovenants.filter((r) => r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), ...mappedNotifications.filter((r) => r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), // non-high priority @@ -319,9 +317,9 @@ export class BoardComponent implements OnInit, OnDestroy { ...mappedApps.filter((a) => !a.highPriority).sort((a, b) => b.activeDays! - a.activeDays!), ...mappedModifications.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), ...mappedRecons.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), - ...mappedReviewMeetings.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), + ...mappedPlanningReferrals.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), ...mappedCovenants.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), - ...mappedNotifications.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived) + ...mappedNotifications.filter((r) => !r.highPriority).sort((a, b) => a.dateReceived - b.dateReceived), ); this.cards = sorted; } @@ -383,20 +381,22 @@ export class BoardComponent implements OnInit, OnDestroy { }; } - private mapPlanningReviewToCard(meeting: PlanningReviewDto): CardData { + private mapPlanningReferralToCard(referral: PlanningReferralDto): CardData { return { - status: meeting.card.status.code, - typeLabel: 'Non-Application', - title: `${meeting.fileNumber} (${meeting.type})`, - titleTooltip: meeting.type, - assignee: meeting.card.assignee, - id: meeting.card.uuid, - labels: [PLANNING_TYPE_LABEL], + status: referral.card.status.code, + typeLabel: 'Planning Review', + title: `${referral.planningReview.fileNumber} (${referral.planningReview.documentName})`, + titleTooltip: referral.planningReview.type.label, + assignee: referral.card.assignee, + id: referral.card.uuid, + labels: [referral.planningReview.type], cardType: CardType.PLAN, paused: false, - highPriority: meeting.card.highPriority, - cardUuid: meeting.card.uuid, - dateReceived: meeting.card.createdAt, + highPriority: referral.card.highPriority, + cardUuid: referral.card.uuid, + dateReceived: referral.card.createdAt, + dueDate: referral.dueDate ? new Date(referral.dueDate) : undefined, + showDueDate: true, }; } @@ -517,7 +517,7 @@ export class BoardComponent implements OnInit, OnDestroy { this.openDialog(ReconsiderationDialogComponent, recon); break; case CardType.PLAN: - const planningReview = await this.planningReviewService.fetchByCardUuid(card.uuid); + const planningReview = await this.planningReferralService.fetchByCardUuid(card.uuid); this.openDialog(PlanningReviewDialogComponent, planningReview); break; case CardType.MODI: 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 626f270139..7338251c87 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 @@ -39,7 +39,7 @@ export class CardDialogComponent implements OnInit, OnDestroy { protected confirmationDialogService: ConfirmationDialogService, protected toastService: ToastService, protected userService: UserService, - protected boardService: BoardService + protected boardService: BoardService, ) {} ngOnInit(): void { diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.html b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.html index 923c72b159..afadf47230 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.html +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.html @@ -4,61 +4,17 @@ <h2 class="card-title">Create Planning Review</h2> <form class="content" [formGroup]="createForm" (ngSubmit)="onSubmit()"> <mat-dialog-content> <div class="two-item-row"> - <mat-form-field appearance="outline"> - <mat-label>File ID</mat-label> - <input - id="fileNumber" - matInput - placeholder="791262" - formControlName="fileNumber" - [class.valid]=" - createForm.get('fileNumber')!.valid && - (createForm.get('fileNumber')!.dirty || createForm.get('fileNumber')!.touched) - " - [class.invalid]=" - createForm.get('fileNumber')!.invalid && - (createForm.get('fileNumber')!.dirty || createForm.get('fileNumber')!.touched) - " - /> - <mat-error - class="text-danger" - *ngIf="createForm.get('fileNumber')!.touched && createForm.get('fileNumber')!.hasError('required')" - > - This field is required. - </mat-error> - </mat-form-field> - <mat-form-field appearance="outline"> - <mat-label>Type</mat-label> - <input - id="type" - matInput - maxlength="40" - formControlName="type" - [class.valid]=" - createForm.get('type')!.valid && (createForm.get('type')!.dirty || createForm.get('type')!.touched) - " - [class.invalid]=" - createForm.get('type')!.invalid && (createForm.get('type')!.dirty || createForm.get('type')!.touched) - " - /> - <mat-error - class="text-danger" - *ngIf="createForm.get('type')!.touched && createForm.get('type')!.hasError('required')" - > - This field is required. - </mat-error> - </mat-form-field> <div> <ng-select appearance="outline" class="card-local-government" [items]="localGovernments" appendTo="body" - placeholder="Local Government *" + placeholder="Local/First Nation Government*" bindLabel="name" bindValue="uuid" [clearable]="false" - formControlName="localGovernment" + [formControl]="localGovernmentControl" (change)="onSelectGovernment($event)" > <ng-template ng-option-tmp let-item="item" let-search="searchTerm"> @@ -69,24 +25,71 @@ <h2 class="card-title">Create Planning Review</h2> <div> <ng-select appearance="outline" - class="card-region" [items]="regions" appendTo="body" - placeholder="Region *" + placeholder="Region*" bindLabel="label" bindValue="code" [clearable]="false" - formControlName="region" + [formControl]="regionControl" > </ng-select> </div> </div> + <div class="two-item-row"> + <mat-form-field appearance="outline"> + <mat-label>Submitted to ALC</mat-label> + <input + matInput + (click)="submissionDate.open()" + [matDatepicker]="submissionDate" + [formControl]="submissionDateControl" + name="date" + id="date" + required + /> + <mat-datepicker-toggle matSuffix [for]="submissionDate"></mat-datepicker-toggle> + <mat-datepicker #submissionDate type="date"> </mat-datepicker> + </mat-form-field> + + <mat-form-field appearance="outline"> + <mat-label>Document Name</mat-label> + <input matInput placeholder="Document Name*" [formControl]="documentNameControl" required /> + </mat-form-field> + </div> + <div class="two-item-row"> + <div> + <ng-select + id="type" + appearance="outline" + [items]="types" + appendTo="body" + placeholder="Planning Review Type*" + bindLabel="label" + bindValue="code" + [clearable]="false" + [formControl]="typeControl" + > + </ng-select> + </div> + + <mat-form-field appearance="outline"> + <mat-label>Due Date</mat-label> + <input matInput (click)="dueDate.open()" [matDatepicker]="dueDate" [formControl]="dueDateControl" /> + <mat-datepicker-toggle matSuffix [for]="dueDate"></mat-datepicker-toggle> + <mat-datepicker #dueDate type="date"> </mat-datepicker> + </mat-form-field> + </div> + <mat-form-field class="description" appearance="outline"> + <mat-label>Description</mat-label> + <input matInput [formControl]="descriptionControl" required /> + </mat-form-field> </mat-dialog-content> <mat-dialog-actions align="end"> <div class="button-container"> <button mat-stroked-button color="primary" [mat-dialog-close]="false">Cancel</button> <button [loading]="isLoading" mat-flat-button color="primary" type="submit" [disabled]="!createForm.valid"> - Save + Create </button> </div> </mat-dialog-actions> diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.scss b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.scss index 82f3eb6a10..50fe910c77 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.scss +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.scss @@ -9,3 +9,7 @@ grid-row-gap: 24px; margin-bottom: 24px; } + +.description { + width: 100%; +} diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.spec.ts b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.spec.ts index 038ef87ba2..4aa416f11e 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.spec.ts +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.spec.ts @@ -52,7 +52,6 @@ describe('CreatePlanningReviewDialogComponent', () => { fixture.detectChanges(); const compiled = fixture.debugElement.nativeElement; - expect(compiled.querySelector('#fileNumber')).toBeTruthy(); expect(compiled.querySelector('#type')).toBeTruthy(); }); }); diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.ts index 5e7b0444b4..c23c38c897 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.ts +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/create/create-planning-review-dialog.component.ts @@ -2,13 +2,17 @@ import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; +import { Moment } from 'moment'; import { Subject, takeUntil } from 'rxjs'; import { ApplicationRegionDto } from '../../../../../services/application/application-code.dto'; import { ApplicationLocalGovernmentDto } from '../../../../../services/application/application-local-government/application-local-government.dto'; import { ApplicationLocalGovernmentService } from '../../../../../services/application/application-local-government/application-local-government.service'; import { ApplicationService } from '../../../../../services/application/application.service'; import { CardService } from '../../../../../services/card/card.service'; -import { CreatePlanningReviewDto } from '../../../../../services/planning-review/planning-review.dto'; +import { + CreatePlanningReviewDto, + PlanningReviewTypeDto, +} from '../../../../../services/planning-review/planning-review.dto'; import { PlanningReviewService } from '../../../../../services/planning-review/planning-review.service'; @Component({ @@ -20,18 +24,25 @@ export class CreatePlanningReviewDialogComponent implements OnInit, OnDestroy { $destroy = new Subject<void>(); regions: ApplicationRegionDto[] = []; localGovernments: ApplicationLocalGovernmentDto[] = []; + types: PlanningReviewTypeDto[] = []; isLoading = false; - fileNumberControl = new FormControl<string | any>('', [Validators.required]); regionControl = new FormControl<string | null>(null, [Validators.required]); localGovernmentControl = new FormControl<string | null>(null, [Validators.required]); typeControl = new FormControl<string | null>(null, [Validators.required]); + documentNameControl = new FormControl<string | null>(null, [Validators.required]); + descriptionControl = new FormControl<string | null>(null, [Validators.required]); + submissionDateControl = new FormControl<Moment | null>(null, [Validators.required]); + dueDateControl = new FormControl<Moment | null>(null); createForm = new FormGroup({ - fileNumber: this.fileNumberControl, region: this.regionControl, localGovernment: this.localGovernmentControl, type: this.typeControl, + documentName: this.documentNameControl, + description: this.descriptionControl, + submissionDate: this.submissionDateControl, + dueDate: this.dueDateControl, }); constructor( @@ -58,6 +69,8 @@ export class CreatePlanningReviewDialogComponent implements OnInit, OnDestroy { this.applicationService.$applicationRegions.pipe(takeUntil(this.$destroy)).subscribe((regions) => { this.regions = regions; }); + + this.loadTypes(); } async onSubmit() { @@ -65,11 +78,13 @@ export class CreatePlanningReviewDialogComponent implements OnInit, OnDestroy { this.isLoading = true; const formValues = this.createForm.getRawValue(); const planningReview: CreatePlanningReviewDto = { - fileNumber: formValues.fileNumber!.trim(), regionCode: formValues.region!, localGovernmentUuid: formValues.localGovernment!, - type: formValues.type!, - boardCode: this.data.currentBoardCode, + typeCode: formValues.type!, + submissionDate: formValues.submissionDate!.valueOf(), + description: formValues.description!, + documentName: formValues.documentName!, + dueDate: formValues.dueDate?.valueOf(), }; const res = await this.planningReviewService.create(planningReview); @@ -95,4 +110,11 @@ export class CreatePlanningReviewDialogComponent implements OnInit, OnDestroy { this.$destroy.next(); this.$destroy.complete(); } + + private async loadTypes() { + const types = await this.planningReviewService.fetchTypes(); + if (types) { + this.types = types; + } + } } diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html index df9bac4e20..6fbed0a354 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.html @@ -1,6 +1,6 @@ <div mat-dialog-title> <div class="close"> - <h6 class="card-type-label">Non-Application</h6> + <h6 class="card-type-label">Planning Review</h6> <button mat-icon-button [mat-dialog-close]="isDirty"> <mat-icon>close</mat-icon> </button> @@ -9,12 +9,30 @@ <h6 class="card-type-label">Non-Application</h6> <div class="left"> <h3 class="card-title center"> <span class="margin-right">{{ cardTitle }}</span> - <app-application-type-pill [type]="planningType"></app-application-type-pill> + <app-application-legacy-id *ngIf="planningReview.legacyId" [legacyId]="planningReview.legacyId"></app-application-legacy-id> + <app-application-type-pill *ngIf="planningType" [type]="planningType"></app-application-type-pill> </h3> </div> + <div class="center"> + <button + color="accent" + mat-flat-button + [mat-dialog-close]="isDirty" + [routerLink]="['planning-review', planningReview.fileNumber]" + > + View Detail + </button> + </div> </div> - <div class="split"> + <div> <span class="region">{{ planningReview.localGovernment.name }} - {{ planningReview.region.label }} Region</span> + </div> + <div class="split"> + <div class="body-text"> + <app-application-type-pill *ngIf="planningReview.open" [type]="OPEN_TYPE"></app-application-type-pill> + <app-application-type-pill *ngIf="!planningReview.open" [type]="CLOSED_TYPE"></app-application-type-pill> + <span>Due Date: {{ planningReferral.dueDate | momentFormat }}</span> + </div> <div class="right"> <button matTooltip="Move Board" [matMenuTriggerFor]="moveMenu" mat-icon-button> <mat-icon>move_down</mat-icon> diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.spec.ts b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.spec.ts index 90fb212325..cfa0e51be6 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.spec.ts +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.spec.ts @@ -11,12 +11,12 @@ import { BehaviorSubject } from 'rxjs'; import { BoardService, BoardWithFavourite } from '../../../../services/board/board.service'; import { CardDto } from '../../../../services/card/card.dto'; import { CardService } from '../../../../services/card/card.service'; -import { PlanningReviewDto } from '../../../../services/planning-review/planning-review.dto'; +import { PlanningReferralDto, PlanningReviewDto } from '../../../../services/planning-review/planning-review.dto'; import { ToastService } from '../../../../services/toast/toast.service'; -import { AssigneeDto, UserDto } from '../../../../services/user/user.dto'; +import { AssigneeDto } from '../../../../services/user/user.dto'; import { UserService } from '../../../../services/user/user.service'; import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; -import { SharedModule } from '../../../../shared/shared.module'; +import { MomentPipe } from '../../../../shared/pipes/moment.pipe'; import { PlanningReviewDialogComponent } from './planning-review-dialog.component'; describe('PlanningReviewDialogComponent', () => { @@ -25,8 +25,12 @@ describe('PlanningReviewDialogComponent', () => { let mockUserService: DeepMocked<UserService>; let mockBoardService: DeepMocked<BoardService>; - const mockReconDto: PlanningReviewDto = { - type: 'fake-type', + const mockPlanningReviewDto: PlanningReviewDto = { + uuid: '', + legacyId: '', + documentName: '', + type: {} as any, + open: true, region: { code: 'region-code', label: 'region', @@ -39,12 +43,19 @@ describe('PlanningReviewDialogComponent', () => { isFirstNation: false, }, fileNumber: 'file-number', + }; + + const mockReferralDto: PlanningReferralDto = { card: { status: { code: 'FAKE_STATUS', }, boardCode: 'FAKE_BOARD', } as CardDto, + planningReview: mockPlanningReviewDto, + referralDescription: '', + submissionDate: 0, + uuid: '', }; beforeEach(async () => { @@ -62,7 +73,7 @@ describe('PlanningReviewDialogComponent', () => { mockBoardService.$boards = new BehaviorSubject<BoardWithFavourite[]>([]); await TestBed.configureTestingModule({ - declarations: [PlanningReviewDialogComponent], + declarations: [PlanningReviewDialogComponent, MomentPipe], providers: [ { provide: MAT_DIALOG_DATA, @@ -108,7 +119,7 @@ describe('PlanningReviewDialogComponent', () => { fixture = TestBed.createComponent(PlanningReviewDialogComponent); component = fixture.componentInstance; - component.data = mockReconDto; + component.data = mockReferralDto; fixture.detectChanges(); }); diff --git a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.ts b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.ts index 9a45cdee10..3da9313146 100644 --- a/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.ts +++ b/alcs-frontend/src/app/features/board/dialogs/planning-review/planning-review-dialog.component.ts @@ -1,14 +1,18 @@ -import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { Component, Inject, OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { AuthenticationService } from '../../../../services/authentication/authentication.service'; import { BoardService, BoardWithFavourite } from '../../../../services/board/board.service'; import { CardService } from '../../../../services/card/card.service'; -import { PlanningReviewDto } from '../../../../services/planning-review/planning-review.dto'; -import { PlanningReviewService } from '../../../../services/planning-review/planning-review.service'; +import { PlanningReferralService } from '../../../../services/planning-review/planning-referral.service'; +import { PlanningReferralDto, PlanningReviewDto } from '../../../../services/planning-review/planning-review.dto'; import { ToastService } from '../../../../services/toast/toast.service'; import { UserService } from '../../../../services/user/user.service'; -import { PLANNING_TYPE_LABEL } from '../../../../shared/application-type-pill/application-type-pill.constants'; +import { ApplicationPill } from '../../../../shared/application-type-pill/application-type-pill.component'; +import { + CLOSED_PR_LABEL, + OPEN_PR_LABEL, +} from '../../../../shared/application-type-pill/application-type-pill.constants'; import { ConfirmationDialogService } from '../../../../shared/confirmation-dialog/confirmation-dialog.service'; import { CardDialogComponent } from '../card-dialog/card-dialog.component'; @@ -20,22 +24,25 @@ import { CardDialogComponent } from '../card-dialog/card-dialog.component'; export class PlanningReviewDialogComponent extends CardDialogComponent implements OnInit { selectedRegion?: string; title?: string; - planningType = PLANNING_TYPE_LABEL; + planningType?: ApplicationPill; cardTitle = ''; + OPEN_TYPE = OPEN_PR_LABEL; + CLOSED_TYPE = CLOSED_PR_LABEL; - planningReview: PlanningReviewDto = this.data; + planningReview: PlanningReviewDto = this.data.planningReview; + planningReferral: PlanningReferralDto = this.data; constructor( - @Inject(MAT_DIALOG_DATA) public data: PlanningReviewDto, - private dialogRef: MatDialogRef<PlanningReviewDialogComponent>, + @Inject(MAT_DIALOG_DATA) public data: PlanningReferralDto, + dialogRef: MatDialogRef<PlanningReviewDialogComponent>, boardService: BoardService, userService: UserService, authService: AuthenticationService, toastService: ToastService, - private planningReviewService: PlanningReviewService, + private planningReferralService: PlanningReferralService, confirmationDialogService: ConfirmationDialogService, cardService: CardService, - private router: Router + private router: Router, ) { super(authService, dialogRef, cardService, confirmationDialogService, toastService, userService, boardService); } @@ -43,26 +50,30 @@ export class PlanningReviewDialogComponent extends CardDialogComponent implement override ngOnInit(): void { super.ngOnInit(); - this.planningReview = this.data; + this.planningReview = this.data.planningReview; + this.planningType = { + ...this.data.planningReview.type, + borderColor: this.data.planningReview.type.backgroundColor, + }; this.populateCardData(this.data.card); - this.selectedRegion = this.data.region.code; - this.cardTitle = `${this.data.fileNumber} (${this.data.type})`; + this.selectedRegion = this.data.planningReview.region.code; + this.cardTitle = `${this.data.planningReview.fileNumber} (${this.data.planningReview.documentName})`; this.title = this.planningReview.fileNumber; } private async reload() { - const planningReview = await this.planningReviewService.fetchByCardUuid(this.planningReview.card.uuid); - if (planningReview) { - this.populateCardData(planningReview.card); + const planningReferral = await this.planningReferralService.fetchByCardUuid(this.planningReferral.card.uuid); + if (planningReferral) { + await this.populateCardData(planningReferral.card); } } async onBoardSelected(board: BoardWithFavourite) { this.selectedBoard = board.code; try { - await this.boardService.changeBoard(this.planningReview.card.uuid, board.code); + await this.boardService.changeBoard(this.planningReferral.card.uuid, board.code); const loadedBoard = await this.boardService.fetchBoardDetail(board.code); if (loadedBoard) { this.boardStatuses = loadedBoard.statuses; 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 faaed9663b..2d24740773 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 @@ -39,7 +39,7 @@ describe('AssignedComponent', () => { covenants: [], modifications: [], noticeOfIntentModifications: [], - planningReviews: [], + planningReferrals: [], reconsiderations: [], noticeOfIntents: [], notifications: [], 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 d899e8b003..ad2e3fd15b 100644 --- a/alcs-frontend/src/app/features/home/assigned/assigned.component.ts +++ b/alcs-frontend/src/app/features/home/assigned/assigned.component.ts @@ -8,7 +8,7 @@ import { HomeService } from '../../../services/home/home.service'; 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 { NotificationDto } from '../../../services/notification/notification.dto'; -import { PlanningReviewDto } from '../../../services/planning-review/planning-review.dto'; +import { PlanningReferralDto, PlanningReviewDto } from '../../../services/planning-review/planning-review.dto'; import { COVENANT_TYPE_LABEL, MODIFICATION_TYPE_LABEL, @@ -31,7 +31,10 @@ export class AssignedComponent implements OnInit { notifications: AssignedToMeFile[] = []; totalFiles = 0; - constructor(private homeService: HomeService, private applicationService: ApplicationService) {} + constructor( + private homeService: HomeService, + private applicationService: ApplicationService, + ) {} ngOnInit(): void { this.applicationService.setup(); @@ -42,7 +45,7 @@ export class AssignedComponent implements OnInit { const { applications, reconsiderations, - planningReviews, + planningReferrals, modifications, covenants, noticeOfIntents, @@ -96,7 +99,7 @@ export class AssignedComponent implements OnInit { ]; this.nonApplications = [ - ...planningReviews + ...planningReferrals .filter((r) => r.card.highPriority) .map((r) => this.mapPlanning(r)) .sort((a, b) => a.date! - b.date!), @@ -104,7 +107,7 @@ export class AssignedComponent implements OnInit { .filter((r) => r.card.highPriority) .map((r) => this.mapCovenant(r)) .sort((a, b) => a.date! - b.date!), - ...planningReviews + ...planningReferrals .filter((r) => !r.card.highPriority) .map((r) => this.mapPlanning(r)) .sort((a, b) => a.date! - b.date!), @@ -140,9 +143,9 @@ export class AssignedComponent implements OnInit { }; } - private mapPlanning(p: PlanningReviewDto): AssignedToMeFile { + private mapPlanning(p: PlanningReferralDto): AssignedToMeFile { return { - title: `${p.fileNumber} (${p.type})`, + title: `${p.planningReview.fileNumber} (${p.planningReview.documentName})`, type: p.card.type, date: p.card.createdAt, card: p.card, diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/decision-component.component.html b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/decision-component.component.html index dcbf2561f5..109e704f00 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/decision-component.component.html +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/decision-component.component.html @@ -9,10 +9,10 @@ <h5>{{ data.noticeOfIntentDecisionComponentType?.label }}</h5> </div> <div *ngIf="data.noticeOfIntentDecisionComponentTypeCode === COMPONENT_TYPE.ROSO" class="row-no-flex"> - <app-roso-input [form]="form"></app-roso-input> + <app-noi-roso-input [form]="form"></app-noi-roso-input> </div> <div *ngIf="data.noticeOfIntentDecisionComponentTypeCode === COMPONENT_TYPE.PFRS" class="row-no-flex"> - <app-pfrs-input [form]="form"></app-pfrs-input> + <app-noi-pfrs-input [form]="form"></app-noi-pfrs-input> </div> <div class="grid-2"> diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/pfrs-input/pfrs-input.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/pfrs-input/pfrs-input.component.ts index b1f91bb0b9..bc9ce51e14 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/pfrs-input/pfrs-input.component.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/pfrs-input/pfrs-input.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup } from '@angular/forms'; @Component({ - selector: 'app-pfrs-input', + selector: 'app-noi-pfrs-input', templateUrl: './pfrs-input.component.html', styleUrls: ['./pfrs-input.component.scss'], }) diff --git a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/roso-input/roso-input.component.ts b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/roso-input/roso-input.component.ts index cb18c63a8e..b36986b7f1 100644 --- a/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/roso-input/roso-input.component.ts +++ b/alcs-frontend/src/app/features/notice-of-intent/decision/decision-v2/decision-input/decision-components/decision-component/roso-input/roso-input.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { FormGroup } from '@angular/forms'; @Component({ - selector: 'app-roso-input', + selector: 'app-noi-roso-input', templateUrl: './roso-input.component.html', styleUrls: ['./roso-input.component.scss'], }) diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html new file mode 100644 index 0000000000..9c81054cd2 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.html @@ -0,0 +1,131 @@ +<div mat-dialog-title> + <h4>{{ title }} Document</h4> + <span class="superseded-warning" *ngIf="showSupersededWarning" + >Superseded - Not associated with Applicant Submission in Portal</span + > +</div> +<div mat-dialog-content> + <form class="form" [formGroup]="form"> + <div class="double"> + <div> + <mat-label>Document Upload*</mat-label> + </div> + <input hidden type="file" #fileInput (change)="uploadFile($event)" placeholder="Upload file" /> + <button + *ngIf="!pendingFile && !existingFile" + class="full-width upload-button" + mat-flat-button + color="accent" + [ngClass]="{ + error: showVirusError + }" + (click)="fileInput.click()" + > + Upload + </button> + <div class="file" *ngIf="pendingFile"> + <div> + <a (click)="openFile()">{{ pendingFile.name }}</a> +  ({{ pendingFile.size | filesize }}) + </div> + <button [disabled]="!allowsFileEdit" (click)="onRemoveFile()" mat-button> + <mat-icon>close</mat-icon> + Remove + </button> + </div> + <div class="file" *ngIf="existingFile"> + <div> + <a (click)="openExistingFile()">{{ existingFile.name }}</a> +  ({{ existingFile.size | filesize }}) + </div> + <button [disabled]="!allowsFileEdit" (click)="onRemoveFile()" mat-button> + <mat-icon>close</mat-icon> + Remove + </button> + </div> + <mat-error class="left" style="display: flex" *ngIf="showVirusError"> + <mat-icon>warning</mat-icon> A virus was detected in the file. Choose another file and try again. + </mat-error> + </div> + + <div class="double"> + <mat-form-field class="full-width" appearance="outline"> + <mat-label>Document Name</mat-label> + <input required matInput id="name" [formControl]="name" name="name" /> + </mat-form-field> + </div> + + <div> + <ng-select + appearance="outline" + required + [items]="documentTypes" + placeholder="Document Type*" + bindLabel="label" + bindValue="code" + [ngModelOptions]="{ standalone: true }" + [(ngModel)]="documentTypeAhead" + [searchFn]="filterDocumentTypes" + (change)="onDocTypeSelected($event)" + appendTo="body" + > + </ng-select> + </div> + <div> + <mat-form-field class="full-width" appearance="outline"> + <mat-label>Source</mat-label> + <mat-select [formControl]="source"> + <mat-option *ngFor="let source of documentSources" [value]="source">{{ source }}</mat-option> + </mat-select> + </mat-form-field> + </div> + <div *ngIf="type.value === DOCUMENT_TYPE.CERTIFICATE_OF_TITLE"> + <mat-form-field class="full-width" appearance="outline"> + <mat-label>Associated Parcel</mat-label> + <mat-select [formControl]="parcelId"> + <mat-option *ngFor="let parcel of selectableParcels" [value]="parcel.uuid"> + #{{ parcel.index + 1 }} PID: + <span *ngIf="parcel.pid">{{ parcel.pid | mask : '000-000-000' }}</span> + <span *ngIf="!parcel.pid">No Data</span></mat-option + > + </mat-select> + </mat-form-field> + </div> + <div *ngIf="type.value === DOCUMENT_TYPE.CORPORATE_SUMMARY"> + <mat-form-field class="full-width" appearance="outline"> + <mat-label>Associated Organization</mat-label> + <mat-select [formControl]="ownerId"> + <mat-option *ngFor="let owner of selectableOwners" [value]="owner.uuid"> + {{ owner.label }} + </mat-option> + </mat-select> + </mat-form-field> + </div> + <div class="double"> + <mat-label>Visible To:</mat-label> + <div> + <mat-checkbox [formControl]="visibleToCommissioner">Commissioner</mat-checkbox> + </div> + </div> + </form> + + <mat-dialog-actions align="end"> + <div class="button-container"> + <button type="button" mat-stroked-button color="primary" [mat-dialog-close]="false">Close</button> + <button + *ngIf="!isSaving" + type="button" + mat-flat-button + color="primary" + [disabled]="!form.valid || (!pendingFile && !existingFile)" + (click)="onSubmit()" + > + Save + </button> + <button *ngIf="isSaving" type="button" mat-flat-button color="primary" [disabled]="true"> + <mat-spinner class="spinner" diameter="20"></mat-spinner> + Uploading + </button> + </div> + </mat-dialog-actions> +</div> diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss new file mode 100644 index 0000000000..e4fa72a650 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.scss @@ -0,0 +1,55 @@ +@use '../../../../../styles/colors'; + +.form { + display: grid; + grid-template-columns: 1fr 1fr; + row-gap: 32px; + column-gap: 32px; + + .double { + grid-column: 1/3; + } +} + +.full-width { + width: 100%; +} + +a { + word-break: break-all; +} + +.file { + border: 1px solid #000; + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; +} + +.upload-button { + margin-top: 6px !important; + + &.error { + border: 2px solid colors.$error-color; + } +} + +.spinner { + display: inline-block; + margin-right: 4px; +} + +:host::ng-deep { + .mdc-button__label { + display: flex; + align-items: center; + } +} + +.superseded-warning { + background-color: colors.$secondary-color-dark; + color: #fff; + padding: 0 4px; +} diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts new file mode 100644 index 0000000000..614aa11ccc --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.spec.ts @@ -0,0 +1,49 @@ +import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { PlanningReviewDocumentService } from '../../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { ToastService } from '../../../../services/toast/toast.service'; + +import { DocumentUploadDialogComponent } from './document-upload-dialog.component'; + +describe('DocumentUploadDialogComponent', () => { + let component: DocumentUploadDialogComponent; + let fixture: ComponentFixture<DocumentUploadDialogComponent>; + + let mockAppDocService: DeepMocked<PlanningReviewDocumentService>; + + beforeEach(async () => { + mockAppDocService = createMock(); + + const mockDialogRef = { + close: jest.fn(), + afterClosed: jest.fn(), + subscribe: jest.fn(), + backdropClick: () => new EventEmitter(), + }; + + await TestBed.configureTestingModule({ + declarations: [DocumentUploadDialogComponent], + providers: [ + { + provide: PlanningReviewDocumentService, + useValue: mockAppDocService, + }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: ToastService, useValue: {} }, + ], + imports: [MatDialogModule], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DocumentUploadDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts new file mode 100644 index 0000000000..779cdab8a1 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/document-upload-dialog/document-upload-dialog.component.ts @@ -0,0 +1,190 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { + PlanningReviewDocumentDto, + UpdateDocumentDto, +} from '../../../../services/planning-review/planning-review-document/planning-review-document.dto'; +import { PlanningReviewDocumentService } from '../../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { ToastService } from '../../../../services/toast/toast.service'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, + DOCUMENT_TYPE, + DocumentTypeDto, +} from '../../../../shared/document/document.dto'; + +@Component({ + selector: 'app-document-upload-dialog', + templateUrl: './document-upload-dialog.component.html', + styleUrls: ['./document-upload-dialog.component.scss'], +}) +export class DocumentUploadDialogComponent implements OnInit, OnDestroy { + $destroy = new Subject<void>(); + DOCUMENT_TYPE = DOCUMENT_TYPE; + + title = 'Create'; + isDirty = false; + isSaving = false; + allowsFileEdit = true; + documentTypeAhead: string | undefined = undefined; + + name = new FormControl<string>('', [Validators.required]); + type = new FormControl<string | undefined>(undefined, [Validators.required]); + source = new FormControl<string>('', [Validators.required]); + + parcelId = new FormControl<string | null>(null); + ownerId = new FormControl<string | null>(null); + + visibleToCommissioner = new FormControl<boolean>(false, [Validators.required]); + + documentTypes: DocumentTypeDto[] = []; + documentSources = Object.values(DOCUMENT_SOURCE); + selectableParcels: { uuid: string; index: number; pid?: string }[] = []; + selectableOwners: { uuid: string; label: string }[] = []; + + form = new FormGroup({ + name: this.name, + type: this.type, + source: this.source, + visibleToCommissioner: this.visibleToCommissioner, + parcelId: this.parcelId, + ownerId: this.ownerId, + }); + + pendingFile: File | undefined; + existingFile: { name: string; size: number } | undefined; + showSupersededWarning = false; + showVirusError = false; + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { fileId: string; existingDocument?: PlanningReviewDocumentDto }, + protected dialog: MatDialogRef<any>, + private planningReviewDocumentService: PlanningReviewDocumentService, + private toastService: ToastService, + ) {} + + ngOnInit(): void { + this.loadDocumentTypes(); + + if (this.data.existingDocument) { + const document = this.data.existingDocument; + this.title = 'Edit'; + this.allowsFileEdit = document.system === DOCUMENT_SYSTEM.ALCS; + this.form.patchValue({ + name: document.fileName, + type: document.type?.code, + source: document.source, + visibleToCommissioner: document.visibilityFlags.includes('C'), + }); + this.documentTypeAhead = document.type!.code; + this.existingFile = { + name: document.fileName, + size: 0, + }; + } + } + + async onSubmit() { + const visibilityFlags: 'C'[] = []; + + if (this.visibleToCommissioner.getRawValue()) { + visibilityFlags.push('C'); + } + + const dto: UpdateDocumentDto = { + fileName: this.name.value!, + source: this.source.value as DOCUMENT_SOURCE, + typeCode: this.type.value as DOCUMENT_TYPE, + visibilityFlags, + parcelUuid: this.parcelId.value ?? undefined, + ownerUuid: this.ownerId.value ?? undefined, + }; + + const file = this.pendingFile; + this.isSaving = true; + if (this.data.existingDocument) { + await this.planningReviewDocumentService.update(this.data.existingDocument.uuid, dto); + } else if (file !== undefined) { + try { + await this.planningReviewDocumentService.upload(this.data.fileId, { + ...dto, + file, + }); + } catch (err) { + this.toastService.showErrorToast('Document upload failed'); + if (err instanceof HttpErrorResponse && err.status === 403) { + this.showVirusError = true; + this.isSaving = false; + this.pendingFile = undefined; + return; + } + } + this.showVirusError = false; + } + + this.dialog.close(true); + this.isSaving = false; + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + filterDocumentTypes(term: string, item: DocumentTypeDto) { + const termLower = term.toLocaleLowerCase(); + return ( + item.label.toLocaleLowerCase().indexOf(termLower) > -1 || + item.oatsCode.toLocaleLowerCase().indexOf(termLower) > -1 + ); + } + + async onDocTypeSelected($event?: DocumentTypeDto) { + if ($event) { + this.type.setValue($event.code); + } else { + this.type.setValue(undefined); + } + } + + uploadFile(event: Event) { + const element = event.target as HTMLInputElement; + const selectedFiles = element.files; + if (selectedFiles && selectedFiles[0]) { + this.pendingFile = selectedFiles[0]; + this.name.setValue(selectedFiles[0].name); + this.showVirusError = false; + } + } + + onRemoveFile() { + this.pendingFile = undefined; + this.existingFile = undefined; + } + + openFile() { + if (this.pendingFile) { + const fileURL = URL.createObjectURL(this.pendingFile); + window.open(fileURL, '_blank'); + } + } + + async openExistingFile() { + if (this.data.existingDocument) { + await this.planningReviewDocumentService.download( + this.data.existingDocument.uuid, + this.data.existingDocument.fileName, + ); + } + } + + private async loadDocumentTypes() { + const docTypes = await this.planningReviewDocumentService.fetchTypes(); + docTypes.sort((a, b) => (a.label > b.label ? 1 : -1)); + this.documentTypes = docTypes.filter((type) => type.code !== DOCUMENT_TYPE.ORIGINAL_APPLICATION); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.html b/alcs-frontend/src/app/features/planning-review/documents/documents.component.html new file mode 100644 index 0000000000..6a55826bb8 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.html @@ -0,0 +1,83 @@ +<div class="header"> + <h3>Documents</h3> + <button (click)="onUploadFile()" mat-flat-button color="primary">+ Add Document</button> +</div> +<table mat-table [dataSource]="dataSource" matSort class="mat-elevation-z3 documents"> + <ng-container matColumnDef="type"> + <th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by type">Type</th> + <td mat-cell *matCellDef="let element" [matTooltip]="element.type.label"> + {{ element.type.oatsCode }} + </td> + </ng-container> + + <ng-container matColumnDef="fileName"> + <th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by name">Document Name</th> + <td mat-cell *matCellDef="let element"> + <a (click)="openFile(element.uuid, element.fileName)">{{ element.fileName }}</a> + </td> + </ng-container> + + <ng-container matColumnDef="source"> + <th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by source">Source - System</th> + <td mat-cell *matCellDef="let element">{{ element.source }} - {{ element.system }}</td> + </ng-container> + + <ng-container matColumnDef="visibilityFlags"> + <th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by visibility"> + Visibility + <div class="subheading">* = Pending</div> + </th> + <td mat-cell *matCellDef="let element"> + <ng-container *ngIf="element.visibilityFlags.includes('A')"> + <span matTooltip="Applicant">A<span *ngIf="hiddenFromPortal">*</span></span> + <ng-container + *ngIf=" + element.visibilityFlags.includes('C') || + element.visibilityFlags.includes('G') || + element.visibilityFlags.includes('P') + " + >, + </ng-container> + </ng-container> + <ng-container *ngIf="element.visibilityFlags.includes('C') && !element.visibilityFlags.includes('A')"> + <span matTooltip="Commissioner">C<span *ngIf="!hasBeenSetForDiscussion">*</span></span> + <ng-container *ngIf="element.visibilityFlags.includes('G') || element.visibilityFlags.includes('P')" + >, + </ng-container> + </ng-container> + <ng-container *ngIf="element.visibilityFlags.includes('G')"> + <span matTooltip="L/FNG">G<span *ngIf="hiddenFromPortal">*</span></span> + <ng-container *ngIf="element.visibilityFlags.includes('P')">, </ng-container> + </ng-container> + <ng-container *ngIf="element.visibilityFlags.includes('P')"> + <span matTooltip="Public">P<span *ngIf="hiddenFromPortal || !hasBeenReceived">*</span></span> + </ng-container> + </td> + </ng-container> + + <ng-container matColumnDef="uploadedAt"> + <th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by date">Upload Date</th> + <td mat-cell *matCellDef="let element">{{ element.uploadedAt | date }}</td> + </ng-container> + + <ng-container matColumnDef="actions"> + <th mat-header-cell *matHeaderCellDef>Actions</th> + <td mat-cell *matCellDef="let element"> + <button mat-icon-button (click)="downloadFile(element.uuid, element.fileName)"> + <mat-icon>file_download</mat-icon> + </button> + <button mat-icon-button (click)="onEditFile(element)"> + <mat-icon>edit</mat-icon> + </button> + <button *ngIf="element.system === DOCUMENT_SYSTEM.ALCS" mat-icon-button (click)="onDeleteFile(element)"> + <mat-icon color="warn">delete</mat-icon> + </button> + </td> + </ng-container> + + <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> + <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> + <tr class="mat-row" *matNoDataRow> + <td class="text-center" colspan="6">No Documents</td> + </tr> +</table> diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.scss b/alcs-frontend/src/app/features/planning-review/documents/documents.component.scss new file mode 100644 index 0000000000..dadc053396 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.scss @@ -0,0 +1,44 @@ +@use '../../../../styles/colors'; + +:host { + display: block; + padding-bottom: 48px; +} + +.header { + display: flex; + justify-content: space-between; +} + +.documents { + margin-top: 64px; +} + +.mat-mdc-no-data-row { + height: 56px; + color: colors.$grey-dark; +} + +a { + word-break: break-all; +} + +table { + position: relative; + + th mat-header-cell { + position: relative; + } + + .subheading { + font-size: 11px; + line-height: 16px; + font-weight: 400; + position: absolute; + top: 100%; /* Position it below the header text */ + left: 0; /* Align it to the left edge of the header cell */ + } +} + + + diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts b/alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts new file mode 100644 index 0000000000..801b897e8f --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.spec.ts @@ -0,0 +1,59 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDocumentService } from '../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { PlanningReviewDetailedDto } from '../../../services/planning-review/planning-review.dto'; +import { ToastService } from '../../../services/toast/toast.service'; + +import { DocumentsComponent } from './documents.component'; + +describe('DocumentsComponent', () => { + let component: DocumentsComponent; + let fixture: ComponentFixture<DocumentsComponent>; + let mockPRDocService: DeepMocked<PlanningReviewDocumentService>; + let mockPRDetailService: DeepMocked<PlanningReviewDetailService>; + let mockDialog: DeepMocked<MatDialog>; + let mockToastService: DeepMocked<ToastService>; + + beforeEach(async () => { + mockPRDocService = createMock(); + mockPRDetailService = createMock(); + mockDialog = createMock(); + mockToastService = createMock(); + mockPRDetailService.$planningReview = new BehaviorSubject<PlanningReviewDetailedDto | undefined>(undefined); + + await TestBed.configureTestingModule({ + declarations: [DocumentsComponent], + providers: [ + { + provide: PlanningReviewDocumentService, + useValue: mockPRDocService, + }, + { + provide: PlanningReviewDetailService, + useValue: mockPRDetailService, + }, + { + provide: MatDialog, + useValue: mockDialog, + }, + { + provide: ToastService, + useValue: mockToastService, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DocumentsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/documents/documents.component.ts b/alcs-frontend/src/app/features/planning-review/documents/documents.component.ts new file mode 100644 index 0000000000..be582c6df5 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/documents/documents.component.ts @@ -0,0 +1,121 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDocumentDto } from '../../../services/planning-review/planning-review-document/planning-review-document.dto'; +import { PlanningReviewDocumentService } from '../../../services/planning-review/planning-review-document/planning-review-document.service'; +import { ToastService } from '../../../services/toast/toast.service'; +import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { DOCUMENT_SYSTEM } from '../../../shared/document/document.dto'; +import { DocumentUploadDialogComponent } from './document-upload-dialog/document-upload-dialog.component'; + +@Component({ + selector: 'app-documents', + templateUrl: './documents.component.html', + styleUrls: ['./documents.component.scss'], +}) +export class DocumentsComponent implements OnInit { + displayedColumns: string[] = ['type', 'fileName', 'source', 'visibilityFlags', 'uploadedAt', 'actions']; + documents: PlanningReviewDocumentDto[] = []; + private fileId = ''; + + DOCUMENT_SYSTEM = DOCUMENT_SYSTEM; + + hasBeenReceived = false; + hasBeenSetForDiscussion = false; + hiddenFromPortal = false; + + @ViewChild(MatSort) sort!: MatSort; + dataSource: MatTableDataSource<PlanningReviewDocumentDto> = new MatTableDataSource<PlanningReviewDocumentDto>(); + + constructor( + private planningReviewDocumentService: PlanningReviewDocumentService, + private planningReviewDetailService: PlanningReviewDetailService, + private confirmationDialogService: ConfirmationDialogService, + private toastService: ToastService, + public dialog: MatDialog, + ) {} + + ngOnInit(): void { + this.planningReviewDetailService.$planningReview.subscribe((planningReview) => { + if (planningReview) { + this.fileId = planningReview.fileNumber; + this.loadDocuments(planningReview.fileNumber); + } + }); + } + + async onUploadFile() { + this.dialog + .open(DocumentUploadDialogComponent, { + minWidth: '600px', + maxWidth: '800px', + width: '70%', + data: { + fileId: this.fileId, + }, + }) + .beforeClosed() + .subscribe((isDirty) => { + if (isDirty) { + this.loadDocuments(this.fileId); + } + }); + } + + async openFile(uuid: string, fileName: string) { + await this.planningReviewDocumentService.download(uuid, fileName); + } + + async downloadFile(uuid: string, fileName: string) { + await this.planningReviewDocumentService.download(uuid, fileName, false); + } + + private async loadDocuments(fileNumber: string) { + this.documents = await this.planningReviewDocumentService.listAll(fileNumber); + this.dataSource = new MatTableDataSource(this.documents); + this.dataSource.sortingDataAccessor = (item, property) => { + switch (property) { + case 'type': + return item.type?.oatsCode; + default: // @ts-ignore Does not like using String for Key access, but that's what Angular provides + return item[property]; + } + }; + this.dataSource.sort = this.sort; + } + + onEditFile(element: PlanningReviewDocumentDto) { + this.dialog + .open(DocumentUploadDialogComponent, { + minWidth: '600px', + maxWidth: '800px', + width: '70%', + data: { + fileId: this.fileId, + existingDocument: element, + }, + }) + .beforeClosed() + .subscribe((isDirty: boolean) => { + if (isDirty) { + this.loadDocuments(this.fileId); + } + }); + } + + onDeleteFile(element: PlanningReviewDocumentDto) { + this.confirmationDialogService + .openDialog({ + body: 'Are you sure you want to delete the selected file?', + }) + .subscribe(async (accepted) => { + if (accepted) { + await this.planningReviewDocumentService.delete(element.uuid); + this.loadDocuments(this.fileId); + this.toastService.showSuccessToast('Document deleted'); + } + }); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/header/header.component.html b/alcs-frontend/src/app/features/planning-review/header/header.component.html new file mode 100644 index 0000000000..8c46b06e83 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/header/header.component.html @@ -0,0 +1,48 @@ +<div class="header"> + <div> + <span class="subtext heading">Planning Review</span> + </div> + <div class="first-row"> + <div class="title"> + <h5>{{ planningReview.fileNumber }} ({{ planningReview.documentName }})</h5> + <app-application-legacy-id *ngIf="planningReview.legacyId" [legacyId]="planningReview.legacyId"></app-application-legacy-id> + <div class="labels"> + <app-application-type-pill [type]="planningReview.type"></app-application-type-pill> + </div> + </div> + <div class="center"> + <button + *ngIf="linkedCards.length === 1" + class="menu-item" + mat-flat-button + color="accent" + (click)="onGoToCard(linkedCards[0])" + > + <div class="center"> + Go to card + <mat-icon style="transform: scale(1.1)">arrow_right_alt</mat-icon> + </div> + </button> + <ng-container *ngIf="linkedCards.length > 1"> + <button class="menu-item center" mat-flat-button color="accent" [matMenuTriggerFor]="goToMenu"> + Go to card ▾ + </button> + <mat-menu class="move-board-menu" xPosition="before" #goToMenu="matMenu"> + <button *ngFor="let card of linkedCards" mat-menu-item (click)="onGoToCard(card)"> + {{ card.displayName }} + </button> + </mat-menu> + </ng-container> + </div> + </div> + <div class="sub-heading"> + <div> + <div class="subheading2">Local/First Nation Government:</div> + <div class="body-text"> + {{ planningReview.localGovernment.name }} + <app-no-data *ngIf="!planningReview.localGovernment"></app-no-data> + </div> + </div> + <div class="status-wrapper"><app-application-submission-status-type-pill [type]="statusPill"></app-application-submission-status-type-pill></div> + </div> +</div> diff --git a/alcs-frontend/src/app/features/planning-review/header/header.component.scss b/alcs-frontend/src/app/features/planning-review/header/header.component.scss new file mode 100644 index 0000000000..9c6cbbe865 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/header/header.component.scss @@ -0,0 +1,58 @@ +@use '../../../../styles/colors'; + +.heading { + color: colors.$primary-color-dark; + margin-bottom: 8px; +} + +.header { + padding: 16px 80px; + border-bottom: 1px solid colors.$primary-color-dark; + + .first-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + .title { + display: flex; + flex-wrap: wrap; + align-items: center; + line-height: 32px; + + h5 { + margin-right: 8px !important; + } + } + + .labels { + display: flex; + flex-wrap: wrap; + margin-top: -8px; + margin-right: 8px; + + app-application-type-pill { + margin-top: 8px; + } + } + + .sub-heading { + margin-top: 16px; + margin-bottom: 8px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .subheading2 { + margin-bottom: 6px !important; + } +} + +.status-wrapper { + display: flex; + flex-direction: row-reverse; + align-items: flex-end; +} diff --git a/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts b/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts new file mode 100644 index 0000000000..252adb9bbc --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/header/header.component.spec.ts @@ -0,0 +1,54 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { HeaderComponent } from './header.component'; + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let fixture: ComponentFixture<HeaderComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule], + declarations: [HeaderComponent], + providers: [], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + + component.planningReview = { + legacyId: '', + documentName: '', + fileNumber: '', + localGovernment: { + uuid: '', + name: '', + isFirstNation: false, + preferredRegionCode: '', + }, + open: false, + referrals: [], + region: { + label: '', + code: '', + description: '', + }, + type: { + code: '', + description: '', + label: '', + backgroundColor: '', + shortLabel: '', + textColor: '', + }, + uuid: '', + }; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/header/header.component.ts b/alcs-frontend/src/app/features/planning-review/header/header.component.ts new file mode 100644 index 0000000000..ae0807ddbe --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/header/header.component.ts @@ -0,0 +1,43 @@ +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { CardDto } from '../../../services/card/card.dto'; +import { PlanningReviewDetailedDto } from '../../../services/planning-review/planning-review.dto'; +import { CLOSED_PR_LABEL, OPEN_PR_LABEL } from '../../../shared/application-type-pill/application-type-pill.constants'; + +@Component({ + selector: 'app-header[planningReview]', + templateUrl: './header.component.html', + styleUrls: ['./header.component.scss'], +}) +export class HeaderComponent implements OnChanges { + destroy = new Subject<void>(); + + @Input() planningReview!: PlanningReviewDetailedDto; + + linkedCards: (CardDto & { displayName: string })[] = []; + statusPill = OPEN_PR_LABEL; + + constructor(private router: Router) {} + + async onGoToCard(card: CardDto) { + const boardCode = card.boardCode; + const cardUuid = card.uuid; + const cardTypeCode = card.type; + await this.router.navigateByUrl(`/board/${boardCode}?card=${cardUuid}&type=${cardTypeCode}`); + } + + async setupLinkedCards() { + for (const [index, referral] of this.planningReview.referrals.entries()) { + this.linkedCards.push({ + ...referral.card, + displayName: `Referral ${index}`, + }); + } + } + + ngOnChanges(changes: SimpleChanges): void { + this.setupLinkedCards(); + this.statusPill = this.planningReview.open ? OPEN_PR_LABEL : CLOSED_PR_LABEL; + } +} diff --git a/alcs-frontend/src/app/features/planning-review/overview/overview.component.html b/alcs-frontend/src/app/features/planning-review/overview/overview.component.html new file mode 100644 index 0000000000..c534794c8c --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/overview/overview.component.html @@ -0,0 +1,31 @@ +<div class="split"> + <h3>Overview</h3> +</div> +<section> + <app-staff-journal *ngIf="planningReview" parentType="Planning Review" [parentUuid]="planningReview.uuid" /> +</section> +<section *ngIf="planningReview"> + <h5>Planning Review Type</h5> + <div> + <app-inline-dropdown (save)="onSaveType($event)" [options]="types" [value]="planningReview.type.code" /> + </div> +</section> +<section *ngIf="planningReview"> + <h5>Status</h5> + <div> + <app-inline-button-toggle + (save)="onSaveStatus($event)" + [selectedValue]="planningReview.open ? 'Open' : 'Closed'" + [options]="[ + { + label: 'Open', + value: 'Open' + }, + { + label: 'Closed', + value: 'Closed' + } + ]" + /> + </div> +</section> diff --git a/alcs-frontend/src/app/features/planning-review/overview/overview.component.scss b/alcs-frontend/src/app/features/planning-review/overview/overview.component.scss new file mode 100644 index 0000000000..439e3e77c2 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/overview/overview.component.scss @@ -0,0 +1,7 @@ +h5 { + margin: 16px 0 !important; +} + +section { + margin: 32px 0; +} diff --git a/alcs-frontend/src/app/features/planning-review/overview/overview.component.spec.ts b/alcs-frontend/src/app/features/planning-review/overview/overview.component.spec.ts new file mode 100644 index 0000000000..19932568db --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/overview/overview.component.spec.ts @@ -0,0 +1,52 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { NoticeOfIntentDecisionService } from '../../../services/notice-of-intent/decision/notice-of-intent-decision.service'; +import { NoticeOfIntentDetailService } from '../../../services/notice-of-intent/notice-of-intent-detail.service'; +import { NoticeOfIntentTimelineService } from '../../../services/notice-of-intent/notice-of-intent-timeline/notice-of-intent-timeline.service'; +import { NoticeOfIntentDto } from '../../../services/notice-of-intent/notice-of-intent.dto'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDetailedDto } from '../../../services/planning-review/planning-review.dto'; +import { PlanningReviewService } from '../../../services/planning-review/planning-review.service'; +import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; + +import { OverviewComponent } from './overview.component'; +import { NoticeOfIntentSubmissionStatusService } from '../../../services/notice-of-intent/notice-of-intent-submission-status/notice-of-intent-submission-status.service'; + +describe('OverviewComponent', () => { + let component: OverviewComponent; + let fixture: ComponentFixture<OverviewComponent>; + let mockPRDetailService: DeepMocked<PlanningReviewDetailService>; + let mockPRService: DeepMocked<PlanningReviewService>; + + beforeEach(async () => { + mockPRService = createMock(); + + mockPRDetailService = createMock(); + mockPRDetailService.$planningReview = new BehaviorSubject<PlanningReviewDetailedDto | undefined>(undefined); + await TestBed.configureTestingModule({ + providers: [ + { + provide: PlanningReviewDetailService, + useValue: mockPRDetailService, + }, + { + provide: PlanningReviewService, + useValue: mockPRService, + }, + ], + declarations: [OverviewComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/overview/overview.component.ts b/alcs-frontend/src/app/features/planning-review/overview/overview.component.ts new file mode 100644 index 0000000000..6afb4dcb09 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/overview/overview.component.ts @@ -0,0 +1,59 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Subject, takeUntil } from 'rxjs'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDto } from '../../../services/planning-review/planning-review.dto'; +import { PlanningReviewService } from '../../../services/planning-review/planning-review.service'; + +@Component({ + selector: 'app-overview', + templateUrl: './overview.component.html', + styleUrls: ['./overview.component.scss'], +}) +export class OverviewComponent implements OnInit, OnDestroy { + $destroy = new Subject<void>(); + planningReview?: PlanningReviewDto; + types: { label: string; value: string }[] = []; + + constructor( + private planningReviewDetailService: PlanningReviewDetailService, + private planningReviewService: PlanningReviewService, + ) {} + + ngOnInit(): void { + this.planningReviewDetailService.$planningReview.pipe(takeUntil(this.$destroy)).subscribe((review) => { + this.planningReview = review; + }); + this.loadTypes(); + } + + private async loadTypes() { + const types = await this.planningReviewService.fetchTypes(); + if (types) { + this.types = types.map((type) => ({ + label: type.label, + value: type.code, + })); + } + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + async onSaveType($event: string | string[] | null) { + if ($event && !Array.isArray($event) && this.planningReview) { + await this.planningReviewDetailService.update(this.planningReview.fileNumber, { + typeCode: $event, + }); + } + } + + async onSaveStatus($event: string) { + if (this.planningReview) { + await this.planningReviewDetailService.update(this.planningReview.fileNumber, { + open: $event === 'Open', + }); + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.component.html b/alcs-frontend/src/app/features/planning-review/planning-review.component.html new file mode 100644 index 0000000000..dbc7fd31ce --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/planning-review.component.html @@ -0,0 +1,23 @@ +<div class="layout"> + <div class="application"> + <app-header *ngIf="planningReview" [planningReview]="planningReview"></app-header> + <div class="content"> + <div class="nav"> + <div *ngFor="let route of childRoutes" class="nav-link"> + <div + [routerLink]="route.path ? route.path : './'" + routerLinkActive="active" + [routerLinkActiveOptions]="{ exact: route.path === '' }" + class="nav-item nav-text" + > + <mat-icon>{{ route.icon }}</mat-icon> + {{ route.menuTitle }} + </div> + </div> + </div> + <div class="child-content"> + <router-outlet></router-outlet> + </div> + </div> + </div> +</div> diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.component.scss b/alcs-frontend/src/app/features/planning-review/planning-review.component.scss new file mode 100644 index 0000000000..55dfeb4d75 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/planning-review.component.scss @@ -0,0 +1,78 @@ +@use '../../../styles/colors'; + +.layout { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + justify-content: center; +} + +.application { + width: 100%; + display: flex; + flex-direction: column; +} + +.content { + display: flex; + flex-grow: 1; + padding-right: 80px; +} + +.child-content { + margin: 24px 0 0 48px; + flex-grow: 1; +} + +.nav { + background-color: colors.$bg-color; + min-width: 240px; + width: 240px; + height: 100%; +} + +.nav-link { + + div { + padding: 12px 24px; + border: 2px solid transparent; + } + + div.active { + font-weight: bold; + background-color: colors.$primary-color-dark; + color: colors.$white; + border-color: colors.$primary-color-dark; + + &:hover { + cursor: default; + background-color: colors.$primary-color-dark; + color: colors.$white; + } + } + + div:not(.disabled):hover { + cursor: pointer; + border-color: colors.$primary-color-dark; + color: colors.$dark-contrast-text; + } + + div.active:not(.disabled):hover { + color: colors.$white; + } +} + +.nav-item { + display: flex; + align-items: center; + white-space: pre-wrap; + + .mat-icon { + padding-right: 32px; + } + + &.disabled { + opacity: 0.5; + } +} diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts b/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts new file mode 100644 index 0000000000..6d3d24740c --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/planning-review.component.spec.ts @@ -0,0 +1,48 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { PlanningReviewDetailService } from '../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDetailedDto } from '../../services/planning-review/planning-review.dto'; + +import { PlanningReviewComponent } from './planning-review.component'; + +describe('PlanningReviewComponent', () => { + let component: PlanningReviewComponent; + let fixture: ComponentFixture<PlanningReviewComponent>; + let mockPlanningReviewDetailService: DeepMocked<PlanningReviewDetailService>; + let mockActivateRoute: DeepMocked<ActivatedRoute>; + + beforeEach(() => { + mockPlanningReviewDetailService = createMock(); + mockActivateRoute = createMock(); + + Object.assign(mockActivateRoute, { params: new Observable<ParamMap>() }); + mockPlanningReviewDetailService.$planningReview = new BehaviorSubject<PlanningReviewDetailedDto | undefined>( + undefined, + ); + + TestBed.configureTestingModule({ + declarations: [PlanningReviewComponent], + providers: [ + { + provide: PlanningReviewDetailService, + useValue: mockPlanningReviewDetailService, + }, + { + provide: ActivatedRoute, + useValue: mockActivateRoute, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }); + fixture = TestBed.createComponent(PlanningReviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.component.ts b/alcs-frontend/src/app/features/planning-review/planning-review.component.ts new file mode 100644 index 0000000000..fdd55b1b22 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/planning-review.component.ts @@ -0,0 +1,70 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subject, takeUntil } from 'rxjs'; +import { PlanningReviewDetailService } from '../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDetailedDto } from '../../services/planning-review/planning-review.dto'; +import { DocumentsComponent } from './documents/documents.component'; +import { OverviewComponent } from './overview/overview.component'; +import { ReferralComponent } from './referrals/referral.component'; + +export const childRoutes = [ + { + path: '', + menuTitle: 'Overview', + icon: 'summarize', + component: OverviewComponent, + }, + { + path: 'referrals', + menuTitle: 'Referrals', + icon: 'edit_note', + component: ReferralComponent, + }, + { + path: 'documents', + menuTitle: 'Documents', + icon: 'description', + component: DocumentsComponent, + }, +]; + +@Component({ + selector: 'app-planning-review', + templateUrl: './planning-review.component.html', + styleUrls: ['./planning-review.component.scss'], +}) +export class PlanningReviewComponent implements OnInit, OnDestroy { + $destroy = new Subject<void>(); + + planningReview?: PlanningReviewDetailedDto; + fileNumber?: string; + childRoutes = childRoutes; + + constructor( + private planningReviewService: PlanningReviewDetailService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + this.route.params.pipe(takeUntil(this.$destroy)).subscribe(async (routeParams) => { + const { fileNumber } = routeParams; + this.fileNumber = fileNumber; + await this.loadReview(); + }); + + this.planningReviewService.$planningReview.pipe(takeUntil(this.$destroy)).subscribe((planningReview) => { + this.planningReview = planningReview; + }); + } + + private async loadReview() { + if (this.fileNumber) { + await this.planningReviewService.loadReview(this.fileNumber); + } + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } +} diff --git a/alcs-frontend/src/app/features/planning-review/planning-review.module.ts b/alcs-frontend/src/app/features/planning-review/planning-review.module.ts new file mode 100644 index 0000000000..2590d6c981 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/planning-review.module.ts @@ -0,0 +1,35 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { PlanningReviewDetailService } from '../../services/planning-review/planning-review-detail.service'; +import { SharedModule } from '../../shared/shared.module'; +import { DocumentUploadDialogComponent } from './documents/document-upload-dialog/document-upload-dialog.component'; +import { DocumentsComponent } from './documents/documents.component'; +import { HeaderComponent } from './header/header.component'; +import { OverviewComponent } from './overview/overview.component'; +import { childRoutes, PlanningReviewComponent } from './planning-review.component'; +import { CreatePlanningReferralDialogComponent } from './referrals/create/create-planning-referral-dialog.component'; +import { ReferralComponent } from './referrals/referral.component'; + +const routes: Routes = [ + { + path: ':fileNumber', + component: PlanningReviewComponent, + children: childRoutes, + }, +]; + +@NgModule({ + providers: [PlanningReviewDetailService], + declarations: [ + PlanningReviewComponent, + OverviewComponent, + HeaderComponent, + DocumentsComponent, + DocumentUploadDialogComponent, + ReferralComponent, + CreatePlanningReferralDialogComponent, + ], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], +}) +export class PlanningReviewModule {} diff --git a/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.html b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.html new file mode 100644 index 0000000000..eb7cbf6e2e --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.html @@ -0,0 +1,43 @@ +<div mat-dialog-title> + <h2 class="card-title">Create Planning Referral</h2> +</div> +<form class="content" [formGroup]="createForm" (ngSubmit)="onSubmit()"> + <mat-dialog-content> + <div class="two-item-row"> + <mat-form-field appearance="outline"> + <mat-label>Referral Submission Date</mat-label> + <input + matInput + (click)="submissionDate.open()" + [matDatepicker]="submissionDate" + [formControl]="submissionDateControl" + [min]="minimumDate" + name="date" + id="date" + required + /> + <mat-datepicker-toggle matSuffix [for]="submissionDate"></mat-datepicker-toggle> + <mat-datepicker #submissionDate type="date"> </mat-datepicker> + </mat-form-field> + + <mat-form-field appearance="outline"> + <mat-label>Due Date</mat-label> + <input matInput (click)="dueDate.open()" [matDatepicker]="dueDate" [formControl]="dueDateControl" /> + <mat-datepicker-toggle matSuffix [for]="dueDate"></mat-datepicker-toggle> + <mat-datepicker #dueDate type="date"> </mat-datepicker> + </mat-form-field> + </div> + <mat-form-field class="description" appearance="outline"> + <mat-label>Description</mat-label> + <input matInput [formControl]="descriptionControl" required /> + </mat-form-field> + </mat-dialog-content> + <mat-dialog-actions align="end"> + <div class="button-container"> + <button mat-stroked-button color="primary" [mat-dialog-close]="false">Cancel</button> + <button [loading]="isLoading" mat-flat-button color="primary" type="submit" [disabled]="!createForm.valid"> + Create + </button> + </div> + </mat-dialog-actions> +</form> diff --git a/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.scss b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.scss new file mode 100644 index 0000000000..08cdde4787 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.scss @@ -0,0 +1,16 @@ +.content { + padding: 0 16px; + min-width: 800px; +} + +.two-item-row { + display: grid; + grid-template-columns: 1fr 1fr; + grid-column-gap: 24px; + grid-row-gap: 24px; + margin-bottom: 24px; +} + +.description { + width: 100%; +} diff --git a/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.spec.ts b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.spec.ts new file mode 100644 index 0000000000..d01ff2972c --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.spec.ts @@ -0,0 +1,50 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { createMock } from '@golevelup/ts-jest'; +import { CreatePlanningReferralDialogComponent } from './create-planning-referral-dialog.component'; + +describe('CreatePlanningReviewDialogComponent', () => { + let component: CreatePlanningReferralDialogComponent; + let fixture: ComponentFixture<CreatePlanningReferralDialogComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [CreatePlanningReferralDialogComponent], + imports: [ + MatDialogModule, + HttpClientTestingModule, + MatFormFieldModule, + MatDividerModule, + MatInputModule, + MatSelectModule, + BrowserAnimationsModule, + MatSnackBarModule, + MatAutocompleteModule, + ], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: ActivatedRoute, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(CreatePlanningReferralDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.ts b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.ts new file mode 100644 index 0000000000..b36be71c5e --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/create/create-planning-referral-dialog.component.ts @@ -0,0 +1,61 @@ +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Moment } from 'moment'; +import { Subject } from 'rxjs'; +import { PlanningReferralService } from '../../../../services/planning-review/planning-referral.service'; +import { + CreatePlanningReferralDto, + PlanningReferralDto, +} from '../../../../services/planning-review/planning-review.dto'; + +@Component({ + selector: 'app-create', + templateUrl: './create-planning-referral-dialog.component.html', + styleUrls: ['./create-planning-referral-dialog.component.scss'], +}) +export class CreatePlanningReferralDialogComponent { + isLoading = false; + minimumDate = new Date(0); + + descriptionControl = new FormControl<string | null>(null, [Validators.required]); + submissionDateControl = new FormControl<Moment | null>(null, [Validators.required]); + dueDateControl = new FormControl<Moment | null>(null); + + createForm = new FormGroup({ + description: this.descriptionControl, + submissionDate: this.submissionDateControl, + dueDate: this.dueDateControl, + }); + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { + planningReviewUuid: string; + minReceivedDate: number; + }, + private dialogRef: MatDialogRef<CreatePlanningReferralDialogComponent>, + private planningReferralService: PlanningReferralService, + ) { + this.minimumDate = new Date(this.data.minReceivedDate); + } + + async onSubmit() { + try { + this.isLoading = true; + const formValues = this.createForm.getRawValue(); + const planningReview: CreatePlanningReferralDto = { + planningReviewUuid: this.data.planningReviewUuid, + submissionDate: formValues.submissionDate!.valueOf(), + referralDescription: formValues.description!, + dueDate: formValues.dueDate?.valueOf(), + }; + + await this.planningReferralService.create(planningReview); + this.dialogRef.close(true); + } finally { + this.isLoading = false; + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/referrals/referral.component.html b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.html new file mode 100644 index 0000000000..fe1570dfe2 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.html @@ -0,0 +1,75 @@ +<div class="split"> + <h3>Referrals</h3> + <button mat-flat-button color="primary" (click)="onCreate()">+ Add Referral</button> +</div> +<section> + <div class="referral mat-elevation-z3" *ngFor="let referral of planingReferrals; let i = index"> + <div class="split"> + <h5>Referral #{{ planingReferrals.length - i }}</h5> + <button + *ngIf="i !== planingReferrals.length - 1" + mat-stroked-button + color="warn" + (click)="onDelete(referral.uuid)" + > + Delete + </button> + </div> + <div class="two-columns"> + <div> + <div> + <div class="subheading2">Referral Submission Date</div> + <div> + <app-inline-datepicker + [value]="referral.submissionDate" + [required]="true" + (save)="updateReferralField(referral.uuid, 'submissionDate', $event)" + /> + </div> + </div> + <div> + <div class="subheading2">Due Date</div> + <div> + <app-inline-datepicker + [value]="referral.dueDate" + (save)="updateReferralField(referral.uuid, 'dueDate', $event)" + /> + </div> + </div> + <div> + <div class="subheading2">Referral Description</div> + <div> + <app-inline-text + [value]="referral.referralDescription" + [required]="true" + (save)="updateReferralField(referral.uuid, 'referralDescription', $event)" + /> + </div> + </div> + </div> + <div> + <div> + <div> + <div class="subheading2">ALC Staff Response Date</div> + <div> + <app-inline-datepicker + [value]="referral.responseDate" + (save)="updateReferralField(referral.uuid, 'responseDate', $event)" + /> + </div> + </div> + <div> + <div class="subheading2">Response Description</div> + <div> + <app-inline-text + [value]="referral.responseDescription" + [required]="true" + (save)="updateReferralField(referral.uuid, 'responseDescription', $event)" + /> + </div> + </div> + </div> + </div> + </div> + </div> +</section> diff --git a/alcs-frontend/src/app/features/planning-review/referrals/referral.component.scss b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.scss new file mode 100644 index 0000000000..bc32538cb7 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.scss @@ -0,0 +1,36 @@ +@use '../../../../styles/colors'; + +h5 { + margin: 16px 0 !important; +} + +section { + margin: 32px 0; +} + +.referral { + border-radius: 4px; + padding: 24px 16px 12px 16px; + margin-bottom: 48px; + + .two-columns { + display: grid; + grid-template-columns: 1fr 1fr; + grid-column-gap: 24px; + grid-row-gap: 24px; + + & > div { + margin-top: 24px; + padding: 24px 16px 0; + background-color: colors.$grey-light; + + div { + margin-bottom: 36px; + } + + div.subheading2 { + margin-bottom: 2px !important; + } + } + } +} diff --git a/alcs-frontend/src/app/features/planning-review/referrals/referral.component.spec.ts b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.spec.ts new file mode 100644 index 0000000000..d1c29bb20e --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.spec.ts @@ -0,0 +1,50 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BehaviorSubject } from 'rxjs'; +import { PlanningReferralService } from '../../../services/planning-review/planning-referral.service'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { PlanningReviewDetailedDto } from '../../../services/planning-review/planning-review.dto'; + +import { ReferralComponent } from './referral.component'; + +describe('OverviewComponent', () => { + let component: ReferralComponent; + let fixture: ComponentFixture<ReferralComponent>; + let mockPRDetailService: DeepMocked<PlanningReviewDetailService>; + let mockPRService: DeepMocked<PlanningReferralService>; + + beforeEach(async () => { + mockPRService = createMock(); + + mockPRDetailService = createMock(); + mockPRDetailService.$planningReview = new BehaviorSubject<PlanningReviewDetailedDto | undefined>(undefined); + await TestBed.configureTestingModule({ + providers: [ + { + provide: PlanningReviewDetailService, + useValue: mockPRDetailService, + }, + { + provide: PlanningReferralService, + useValue: mockPRService, + }, + { + provide: MatDialog, + useValue: {}, + }, + ], + declarations: [ReferralComponent], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(ReferralComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/features/planning-review/referrals/referral.component.ts b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.ts new file mode 100644 index 0000000000..3c5cdc0234 --- /dev/null +++ b/alcs-frontend/src/app/features/planning-review/referrals/referral.component.ts @@ -0,0 +1,84 @@ +import { Dialog } from '@angular/cdk/dialog'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Subject, takeUntil } from 'rxjs'; +import { PlanningReferralService } from '../../../services/planning-review/planning-referral.service'; +import { PlanningReviewDetailService } from '../../../services/planning-review/planning-review-detail.service'; +import { + PlanningReferralDto, + PlanningReviewDto, + UpdatePlanningReferralDto, +} from '../../../services/planning-review/planning-review.dto'; +import { PlanningReviewService } from '../../../services/planning-review/planning-review.service'; +import { CreatePlanningReferralDialogComponent } from './create/create-planning-referral-dialog.component'; + +@Component({ + selector: 'app-overview', + templateUrl: './referral.component.html', + styleUrls: ['./referral.component.scss'], +}) +export class ReferralComponent implements OnInit, OnDestroy { + $destroy = new Subject<void>(); + planningReview?: PlanningReviewDto; + planingReferrals: PlanningReferralDto[] = []; + types: { label: string; value: string }[] = []; + + minReceivedDate = 0; + + constructor( + private planningReviewDetailService: PlanningReviewDetailService, + private planningReferralService: PlanningReferralService, + private dialog: MatDialog, + ) {} + + ngOnInit(): void { + this.planningReviewDetailService.$planningReview.pipe(takeUntil(this.$destroy)).subscribe((review) => { + if (review) { + this.planningReview = review; + this.planingReferrals = review.referrals; + + for (const review of this.planingReferrals) { + this.minReceivedDate = Math.max(review.submissionDate, this.minReceivedDate); + } + } + }); + } + + ngOnDestroy(): void { + this.$destroy.next(); + this.$destroy.complete(); + } + + onCreate() { + if (this.planningReview) { + const dialog = this.dialog.open(CreatePlanningReferralDialogComponent, { + data: { + planningReviewUuid: this.planningReview?.uuid, + minReceivedDate: this.minReceivedDate, + }, + }); + + dialog.beforeClosed().subscribe((didSave) => { + if (didSave && this.planningReview) { + this.planningReviewDetailService.loadReview(this.planningReview.fileNumber); + } + }); + } + } + + async updateReferralField(uuid: string, fieldKey: keyof UpdatePlanningReferralDto, $event: string | number | null) { + if (this.planningReview) { + await this.planningReferralService.update(uuid, { + [fieldKey]: $event, + }); + this.planningReviewDetailService.loadReview(this.planningReview.fileNumber); + } + } + + async onDelete(uuid: string) { + if (this.planningReview) { + await this.planningReferralService.delete(uuid); + this.planningReviewDetailService.loadReview(this.planningReview.fileNumber); + } + } +} diff --git a/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.dto.ts b/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.dto.ts index ee49144c3d..d7928b25ec 100644 --- a/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.dto.ts +++ b/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.dto.ts @@ -22,6 +22,11 @@ export interface CreateNotificationStaffJournalDto { body: string; } +export interface CreatePlanningReviewStaffJournalDto { + planningReviewUuid: string; + body: string; +} + export interface UpdateStaffJournalDto { uuid: string; body: string; diff --git a/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.service.ts b/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.service.ts index b23283707d..eb15da98e1 100644 --- a/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.service.ts +++ b/alcs-frontend/src/app/services/application/application-staff-journal/staff-journal.service.ts @@ -9,6 +9,7 @@ import { UpdateStaffJournalDto, CreateNoticeOfIntentStaffJournalDto, CreateNotificationStaffJournalDto, + CreatePlanningReviewStaffJournalDto, } from './staff-journal.dto'; @Injectable({ @@ -16,7 +17,10 @@ import { }) export class StaffJournalService { baseUrl = `${environment.apiUrl}/application-staff-journal`; - constructor(private http: HttpClient, private toastService: ToastService) {} + constructor( + private http: HttpClient, + private toastService: ToastService, + ) {} async fetchNotes(applicationUuid: string) { return firstValueFrom(this.http.get<StaffJournalDto[]>(`${this.baseUrl}/${applicationUuid}`)); @@ -40,6 +44,12 @@ export class StaffJournalService { return createdNote; } + async createNoteForPlanningReview(note: CreatePlanningReviewStaffJournalDto) { + const createdNote = firstValueFrom(this.http.post<StaffJournalDto>(`${this.baseUrl}/planning-review`, note)); + this.toastService.showSuccessToast('Journal note created'); + return createdNote; + } + async updateNote(note: UpdateStaffJournalDto) { const updatedNote = firstValueFrom(this.http.patch<StaffJournalDto>(`${this.baseUrl}`, note)); this.toastService.showSuccessToast('Journal note updated'); diff --git a/alcs-frontend/src/app/services/authentication/authentication.service.ts b/alcs-frontend/src/app/services/authentication/authentication.service.ts index c8178d90e7..6d125a0a26 100644 --- a/alcs-frontend/src/app/services/authentication/authentication.service.ts +++ b/alcs-frontend/src/app/services/authentication/authentication.service.ts @@ -83,11 +83,10 @@ export class AuthenticationService { async refreshTokens() { if (this.refreshToken) { - if (this.expires && this.expires < Date.now()) { + if (this.refreshExpires && this.refreshExpires < Date.now()) { await this.router.navigateByUrl('/login'); return; } - const newTokens = await this.getNewTokens(this.refreshToken); await this.setTokens(newTokens.token, newTokens.refresh_token); } diff --git a/alcs-frontend/src/app/services/board/board.dto.ts b/alcs-frontend/src/app/services/board/board.dto.ts index 082e631b4e..5d44e29719 100644 --- a/alcs-frontend/src/app/services/board/board.dto.ts +++ b/alcs-frontend/src/app/services/board/board.dto.ts @@ -6,7 +6,7 @@ import { CovenantDto } from '../covenant/covenant.dto'; import { NoticeOfIntentModificationDto } from '../notice-of-intent/notice-of-intent-modification/notice-of-intent-modification.dto'; import { NoticeOfIntentDto } from '../notice-of-intent/notice-of-intent.dto'; import { NotificationDto } from '../notification/notification.dto'; -import { PlanningReviewDto } from '../planning-review/planning-review.dto'; +import { PlanningReferralDto, PlanningReviewDto } from '../planning-review/planning-review.dto'; export interface MinimalBoardDto { code: string; @@ -30,7 +30,7 @@ export interface CardsDto { board: BoardDto; applications: ApplicationDto[]; reconsiderations: ApplicationReconsiderationDto[]; - planningReviews: PlanningReviewDto[]; + planningReferrals: PlanningReferralDto[]; modifications: ApplicationModificationDto[]; covenants: CovenantDto[]; noticeOfIntents: NoticeOfIntentDto[]; diff --git a/alcs-frontend/src/app/services/home/home.service.ts b/alcs-frontend/src/app/services/home/home.service.ts index 4fb684b6f9..0a0c3384bc 100644 --- a/alcs-frontend/src/app/services/home/home.service.ts +++ b/alcs-frontend/src/app/services/home/home.service.ts @@ -10,7 +10,7 @@ import { CovenantDto } from '../covenant/covenant.dto'; import { NoticeOfIntentModificationDto } from '../notice-of-intent/notice-of-intent-modification/notice-of-intent-modification.dto'; import { NoticeOfIntentDto } from '../notice-of-intent/notice-of-intent.dto'; import { NotificationDto } from '../notification/notification.dto'; -import { PlanningReviewDto } from '../planning-review/planning-review.dto'; +import { PlanningReferralDto } from '../planning-review/planning-review.dto'; @Injectable({ providedIn: 'root', @@ -23,19 +23,19 @@ export class HomeService { this.http.get<{ applications: ApplicationDto[]; reconsiderations: ApplicationReconsiderationDto[]; - planningReviews: PlanningReviewDto[]; + planningReferrals: PlanningReferralDto[]; modifications: ApplicationModificationDto[]; covenants: CovenantDto[]; noticeOfIntents: NoticeOfIntentDto[]; noticeOfIntentModifications: NoticeOfIntentModificationDto[]; notifications: NotificationDto[]; - }>(`${environment.apiUrl}/home/assigned`) + }>(`${environment.apiUrl}/home/assigned`), ); } async fetchSubtasks(subtaskType: CARD_SUBTASK_TYPE) { return await firstValueFrom( - this.http.get<HomepageSubtaskDto[]>(`${environment.apiUrl}/home/subtask/${subtaskType}`) + this.http.get<HomepageSubtaskDto[]>(`${environment.apiUrl}/home/subtask/${subtaskType}`), ); } } diff --git a/alcs-frontend/src/app/services/planning-review/planning-referral.service.ts b/alcs-frontend/src/app/services/planning-review/planning-referral.service.ts new file mode 100644 index 0000000000..1a219a9cdc --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-referral.service.ts @@ -0,0 +1,65 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { ToastService } from '../toast/toast.service'; +import { + CreatePlanningReferralDto, + CreatePlanningReviewDto, + PlanningReferralDto, + PlanningReviewDto, + PlanningReviewTypeDto, + UpdatePlanningReferralDto, +} from './planning-review.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class PlanningReferralService { + private url = `${environment.apiUrl}/planning-referral`; + + constructor( + private http: HttpClient, + private toastService: ToastService, + ) {} + + async fetchByCardUuid(id: string) { + try { + return await firstValueFrom(this.http.get<PlanningReferralDto>(`${this.url}/card/${id}`)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to fetch planning review'); + } + return; + } + + async create(createDto: CreatePlanningReferralDto) { + try { + return await firstValueFrom(this.http.post<PlanningReferralDto>(`${this.url}`, createDto)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to create planning review'); + } + return; + } + + async update(uuid: string, updateDto: UpdatePlanningReferralDto) { + try { + return await firstValueFrom(this.http.patch<PlanningReferralDto>(`${this.url}/${uuid}`, updateDto)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to update planning review'); + } + return; + } + + async delete(uuid: string) { + try { + return await firstValueFrom(this.http.delete<PlanningReferralDto>(`${this.url}/${uuid}`)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to delete planning review'); + } + return; + } +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.spec.ts b/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.spec.ts new file mode 100644 index 0000000000..7337cac282 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { firstValueFrom } from 'rxjs'; +import { PlanningReviewDetailService } from './planning-review-detail.service'; +import { PlanningReviewDetailedDto } from './planning-review.dto'; +import { PlanningReviewService } from './planning-review.service'; + +describe('PlanningReviewDetailService', () => { + let service: PlanningReviewDetailService; + let mockPlanningReviewService: DeepMocked<PlanningReviewService>; + + beforeEach(() => { + mockPlanningReviewService = createMock(); + + TestBed.configureTestingModule({ + providers: [ + PlanningReviewDetailService, + { + provide: PlanningReviewService, + useValue: mockPlanningReviewService, + }, + ], + }); + service = TestBed.inject(PlanningReviewDetailService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should publish the loaded application', async () => { + mockPlanningReviewService.fetchDetailedByFileNumber.mockResolvedValue({ + fileNumber: '1', + } as PlanningReviewDetailedDto); + + await service.loadReview('1'); + const res = await firstValueFrom(service.$planningReview); + + expect(mockPlanningReviewService.fetchDetailedByFileNumber).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + expect(res!.fileNumber).toEqual('1'); + }); + + it('should publish the updated application for update', async () => { + mockPlanningReviewService.update.mockResolvedValue({ + fileNumber: '1', + } as PlanningReviewDetailedDto); + + await service.update('1', {}); + const res = await firstValueFrom(service.$planningReview); + + expect(mockPlanningReviewService.update).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + expect(res!.fileNumber).toEqual('1'); + }); +}); diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts b/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts new file mode 100644 index 0000000000..e2aeb87e96 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-detail.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { PlanningReviewDetailedDto, PlanningReviewDto, UpdatePlanningReviewDto } from './planning-review.dto'; +import { PlanningReviewService } from './planning-review.service'; + +@Injectable() +export class PlanningReviewDetailService { + $planningReview = new BehaviorSubject<PlanningReviewDetailedDto | undefined>(undefined); + + private selectedFileNumber: string | undefined; + + constructor(private planningReviewService: PlanningReviewService) {} + + async loadReview(fileNumber: string) { + this.clearReview(); + + this.selectedFileNumber = fileNumber; + const planningReview = await this.planningReviewService.fetchDetailedByFileNumber(fileNumber); + this.$planningReview.next(planningReview); + } + + async clearReview() { + this.$planningReview.next(undefined); + } + + async update(fileNumber: string, updateDto: UpdatePlanningReviewDto) { + const updatedApp = await this.planningReviewService.update(fileNumber, updateDto); + if (updatedApp) { + this.$planningReview.next(updatedApp); + } + return updatedApp; + } +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts new file mode 100644 index 0000000000..29bedaf646 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.dto.ts @@ -0,0 +1,35 @@ +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, + DOCUMENT_TYPE, + DocumentTypeDto, +} from '../../../shared/document/document.dto'; + +export interface PlanningReviewDocumentDto { + uuid: string; + documentUuid: string; + type?: DocumentTypeDto; + description?: string; + visibilityFlags: string[]; + source: DOCUMENT_SOURCE; + system: DOCUMENT_SYSTEM; + fileName: string; + mimeType: string; + uploadedBy: string; + uploadedAt: number; + evidentiaryRecordSorting?: number; +} + +export interface UpdateDocumentDto { + file?: File; + parcelUuid?: string; + ownerUuid?: string; + fileName: string; + typeCode: DOCUMENT_TYPE; + source: DOCUMENT_SOURCE; + visibilityFlags: 'C'[]; +} + +export interface CreateDocumentDto extends UpdateDocumentDto { + file: File; +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts new file mode 100644 index 0000000000..c79917d60c --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.spec.ts @@ -0,0 +1,111 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { of } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../shared/document/document.dto'; +import { ToastService } from '../../toast/toast.service'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +describe('PlanningReviewDocumentService', () => { + let service: PlanningReviewDocumentService; + let httpClient: DeepMocked<HttpClient>; + let toastService: DeepMocked<ToastService>; + + beforeEach(() => { + httpClient = createMock(); + toastService = createMock(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: HttpClient, + useValue: httpClient, + }, + { + provide: ToastService, + useValue: toastService, + }, + ], + }); + service = TestBed.inject(PlanningReviewDocumentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should make a get call for list', async () => { + httpClient.get.mockReturnValue( + of([ + { + uuid: '1', + }, + ]), + ); + + const res = await service.listByVisibility('1', []); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(res.length).toEqual(1); + expect(res[0].uuid).toEqual('1'); + }); + + it('should make a delete call for delete', async () => { + httpClient.delete.mockReturnValue( + of({ + uuid: '1', + }), + ); + + const res = await service.delete('1'); + + expect(httpClient.delete).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + expect(res.uuid).toEqual('1'); + }); + + it('should show a toast warning when uploading a file thats too large', async () => { + const file = createMock<File>(); + Object.defineProperty(file, 'size', { value: environment.maxFileSize + 1 }); + + await service.upload('', { + file, + fileName: '', + typeCode: DOCUMENT_TYPE.AUTHORIZATION_LETTER, + source: DOCUMENT_SOURCE.APPLICANT, + visibilityFlags: [], + }); + + expect(toastService.showWarningToast).toHaveBeenCalledTimes(1); + expect(httpClient.post).toHaveBeenCalledTimes(0); + }); + + it('should make a get call for list review documents', async () => { + httpClient.get.mockReturnValue( + of([ + { + uuid: '1', + }, + ]), + ); + + const res = await service.getReviewDocuments('1'); + + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(res.length).toEqual(1); + expect(res[0].uuid).toEqual('1'); + }); + + it('should make a post call for sort', async () => { + httpClient.post.mockReturnValue( + of({ + uuid: '1', + }), + ); + + await service.updateSort([]); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts new file mode 100644 index 0000000000..bfafc6d8d1 --- /dev/null +++ b/alcs-frontend/src/app/services/planning-review/planning-review-document/planning-review-document.service.ts @@ -0,0 +1,101 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { DocumentTypeDto } from '../../../shared/document/document.dto'; +import { downloadFileFromUrl, openFileInline } from '../../../shared/utils/file'; +import { verifyFileSize } from '../../../shared/utils/file-size-checker'; +import { ToastService } from '../../toast/toast.service'; +import { PlanningReviewDocumentDto, CreateDocumentDto, UpdateDocumentDto } from './planning-review-document.dto'; + +@Injectable({ + providedIn: 'root', +}) +export class PlanningReviewDocumentService { + private url = `${environment.apiUrl}/planning-review-document`; + + constructor( + private http: HttpClient, + private toastService: ToastService, + ) {} + + async listAll(fileNumber: string) { + return firstValueFrom(this.http.get<PlanningReviewDocumentDto[]>(`${this.url}/planning-review/${fileNumber}`)); + } + + async listByVisibility(fileNumber: string, visibilityFlags: string[]) { + return firstValueFrom( + this.http.get<PlanningReviewDocumentDto[]>(`${this.url}/planning-review/${fileNumber}/${visibilityFlags.join()}`), + ); + } + + async upload(fileNumber: string, createDto: CreateDocumentDto) { + const file = createDto.file; + const isValidSize = verifyFileSize(file, this.toastService); + if (!isValidSize) { + return; + } + let formData = this.convertDtoToFormData(createDto); + + const res = await firstValueFrom(this.http.post(`${this.url}/planning-review/${fileNumber}`, formData)); + this.toastService.showSuccessToast('Document uploaded'); + return res; + } + + async delete(uuid: string) { + return firstValueFrom(this.http.delete<PlanningReviewDocumentDto>(`${this.url}/${uuid}`)); + } + + async download(uuid: string, fileName: string, isInline = true) { + const url = isInline ? `${this.url}/${uuid}/open` : `${this.url}/${uuid}/download`; + const data = await firstValueFrom(this.http.get<{ url: string }>(url)); + if (isInline) { + openFileInline(data.url, fileName); + } else { + downloadFileFromUrl(data.url, fileName); + } + } + + async getReviewDocuments(fileNumber: string) { + return firstValueFrom( + this.http.get<PlanningReviewDocumentDto[]>(`${this.url}/planning-review/${fileNumber}/reviewDocuments`), + ); + } + + async fetchTypes() { + return firstValueFrom(this.http.get<DocumentTypeDto[]>(`${this.url}/types`)); + } + + async update(uuid: string, updateDto: UpdateDocumentDto) { + let formData = this.convertDtoToFormData(updateDto); + const res = await firstValueFrom(this.http.post(`${this.url}/${uuid}`, formData)); + this.toastService.showSuccessToast('Document uploaded'); + return res; + } + + async updateSort(sortOrder: { uuid: string; order: number }[]) { + try { + await firstValueFrom(this.http.post<DocumentTypeDto[]>(`${this.url}/sort`, sortOrder)); + } catch (e) { + this.toastService.showErrorToast(`Failed to save document order`); + } + } + + private convertDtoToFormData(dto: UpdateDocumentDto) { + let formData: FormData = new FormData(); + formData.append('documentType', dto.typeCode); + formData.append('source', dto.source); + formData.append('visibilityFlags', dto.visibilityFlags.join(', ')); + formData.append('fileName', dto.fileName); + if (dto.file) { + formData.append('file', dto.file, dto.file.name); + } + if (dto.parcelUuid) { + formData.append('parcelUuid', dto.parcelUuid); + } + if (dto.ownerUuid) { + formData.append('ownerUuid', dto.ownerUuid); + } + return formData; + } +} diff --git a/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts b/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts index ec601f1510..0871f29396 100644 --- a/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts +++ b/alcs-frontend/src/app/services/planning-review/planning-review.dto.ts @@ -1,19 +1,66 @@ +import { BaseCodeDto } from '../../shared/dto/base.dto'; import { ApplicationRegionDto } from '../application/application-code.dto'; import { ApplicationLocalGovernmentDto } from '../application/application-local-government/application-local-government.dto'; import { CardDto } from '../card/card.dto'; export interface CreatePlanningReviewDto { - fileNumber: string; - type: string; + description: string; + documentName: string; + submissionDate: number; + dueDate?: number; localGovernmentUuid: string; + typeCode: string; regionCode: string; - boardCode: string; } export interface PlanningReviewDto { + uuid: string; fileNumber: string; - card: CardDto; + legacyId: string | null; + open: boolean; localGovernment: ApplicationLocalGovernmentDto; region: ApplicationRegionDto; - type: string; + type: PlanningReviewTypeDto; + documentName: string; +} + +export interface PlanningReviewDetailedDto extends PlanningReviewDto { + referrals: PlanningReferralDto[]; +} + +export interface PlanningReviewTypeDto extends BaseCodeDto { + shortLabel: string; + backgroundColor: string; + textColor: string; +} + +export interface CreatePlanningReferralDto { + planningReviewUuid: string; + referralDescription: string; + submissionDate: number; + dueDate?: number; +} + +export interface UpdatePlanningReferralDto { + referralDescription?: string; + submissionDate?: number; + dueDate?: number; + responseDate?: number; + responseDescription?: string; +} + +export interface PlanningReferralDto { + uuid: string; + referralDescription: string; + dueDate?: number; + responseDate?: number; + responseDescription?: string; + submissionDate: number; + planningReview: PlanningReviewDto; + card: CardDto; +} + +export interface UpdatePlanningReviewDto { + open?: boolean; + typeCode?: string; } diff --git a/alcs-frontend/src/app/services/planning-review/planning-review.service.spec.ts b/alcs-frontend/src/app/services/planning-review/planning-review.service.spec.ts index a3bf674166..bc1f21e9ea 100644 --- a/alcs-frontend/src/app/services/planning-review/planning-review.service.spec.ts +++ b/alcs-frontend/src/app/services/planning-review/planning-review.service.spec.ts @@ -37,15 +37,16 @@ describe('PlanningReviewService', () => { httpClient.post.mockReturnValue( of({ fileNumber: '1', - }) + }), ); await service.create({ - fileNumber: '1', + description: '', + documentName: '', + submissionDate: 0, + typeCode: '', localGovernmentUuid: '', regionCode: '', - type: '', - boardCode: '', }); expect(httpClient.post).toHaveBeenCalledTimes(1); @@ -55,15 +56,16 @@ describe('PlanningReviewService', () => { httpClient.post.mockReturnValue( throwError(() => { new Error(''); - }) + }), ); const res = await service.create({ - fileNumber: '', + description: '', + documentName: '', + submissionDate: 0, + typeCode: '', localGovernmentUuid: '', regionCode: '', - type: '', - boardCode: '', }); expect(httpClient.post).toHaveBeenCalledTimes(1); @@ -75,7 +77,7 @@ describe('PlanningReviewService', () => { httpClient.get.mockReturnValue( of({ fileNumber: '1', - }) + }), ); const res = await service.fetchByCardUuid('1'); @@ -89,7 +91,7 @@ describe('PlanningReviewService', () => { httpClient.get.mockReturnValue( throwError(() => { new Error(''); - }) + }), ); const res = await service.fetchByCardUuid('1'); diff --git a/alcs-frontend/src/app/services/planning-review/planning-review.service.ts b/alcs-frontend/src/app/services/planning-review/planning-review.service.ts index cbacb7936b..9618ee7464 100644 --- a/alcs-frontend/src/app/services/planning-review/planning-review.service.ts +++ b/alcs-frontend/src/app/services/planning-review/planning-review.service.ts @@ -3,7 +3,14 @@ import { Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../../environments/environment'; import { ToastService } from '../toast/toast.service'; -import { CreatePlanningReviewDto, PlanningReviewDto } from './planning-review.dto'; +import { + CreatePlanningReviewDto, + PlanningReferralDto, + PlanningReviewDetailedDto, + PlanningReviewDto, + PlanningReviewTypeDto, + UpdatePlanningReviewDto, +} from './planning-review.dto'; @Injectable({ providedIn: 'root', @@ -18,7 +25,7 @@ export class PlanningReviewService { async create(meeting: CreatePlanningReviewDto) { try { - const res = await firstValueFrom(this.http.post<PlanningReviewDto>(`${this.url}`, meeting)); + const res = await firstValueFrom(this.http.post<PlanningReferralDto>(`${this.url}`, meeting)); this.toastService.showSuccessToast('Planning meeting card created'); return res; } catch (err) { @@ -37,4 +44,34 @@ export class PlanningReviewService { } return; } + + async fetchTypes() { + try { + return await firstValueFrom(this.http.get<PlanningReviewTypeDto[]>(`${this.url}/types`)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to fetch planning review types'); + } + return; + } + + async fetchDetailedByFileNumber(fileNumber: string) { + try { + return await firstValueFrom(this.http.get<PlanningReviewDetailedDto>(`${this.url}/${fileNumber}`)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to fetch planning review'); + } + return; + } + + async update(fileNumber: string, updateDto: UpdatePlanningReviewDto) { + try { + return await firstValueFrom(this.http.post<PlanningReviewDetailedDto>(`${this.url}/${fileNumber}`, updateDto)); + } catch (err) { + console.error(err); + this.toastService.showErrorToast('Failed to update planning review'); + } + return; + } } diff --git a/alcs-frontend/src/app/shared/application-type-pill/application-type-pill.constants.ts b/alcs-frontend/src/app/shared/application-type-pill/application-type-pill.constants.ts index 0644c4d584..f6cff1d23a 100644 --- a/alcs-frontend/src/app/shared/application-type-pill/application-type-pill.constants.ts +++ b/alcs-frontend/src/app/shared/application-type-pill/application-type-pill.constants.ts @@ -97,3 +97,19 @@ export const NOTIFICATION_LABEL = { borderColor: '#59ADFA', textColor: '#313132', }; + +export const OPEN_PR_LABEL = { + label: 'Open', + shortLabel: 'Open', + backgroundColor: '#94c6ac', + borderColor: '#94c6ac', + textColor: '#313132', +}; + +export const CLOSED_PR_LABEL = { + label: 'Closed', + shortLabel: 'Closed', + backgroundColor: '#C6242A', + borderColor: '#C6242A', + textColor: '#313132', +}; diff --git a/alcs-frontend/src/app/shared/card/card.component.html b/alcs-frontend/src/app/shared/card/card.component.html index 6f69d471b5..ed1aefbbd4 100644 --- a/alcs-frontend/src/app/shared/card/card.component.html +++ b/alcs-frontend/src/app/shared/card/card.component.html @@ -40,10 +40,10 @@ > <mat-icon>calendar_month</mat-icon> <span *ngIf="!cardData.maxActiveDays || cardData.activeDays < cardData.maxActiveDays" class="center"> - {{ cardData.activeDays }} + {{ cardData.activeDays }} </span> <span *ngIf="cardData.maxActiveDays && cardData.activeDays >= cardData.maxActiveDays" class="center"> - {{ cardData.maxActiveDays }}+ + {{ cardData.maxActiveDays }}+ </span> </div> <div @@ -54,6 +54,9 @@ <mat-icon>pause</mat-icon> <span class="center">{{ cardData.pausedDays }}</span> </div> + <div *ngIf="cardData.showDueDate && cardData.dueDate" class="due-date center"> + <span class="center">Due: {{ cardData.dueDate | momentFormat }}</span> + </div> <div *ngIf="cardData.activeDays && cardData.dueDate !== undefined" class="due-date center"> <span *ngIf="!cardData.maxActiveDays || cardData.activeDays < cardData.maxActiveDays" class="center" >Due: {{ cardData.dueDate | momentFormat }}</span diff --git a/alcs-frontend/src/app/shared/card/card.component.ts b/alcs-frontend/src/app/shared/card/card.component.ts index 92f84aa816..940f4349b2 100644 --- a/alcs-frontend/src/app/shared/card/card.component.ts +++ b/alcs-frontend/src/app/shared/card/card.component.ts @@ -25,6 +25,7 @@ export interface CardData { dueDate?: Date; maxActiveDays?: number; legacyId?: string; + showDueDate?: boolean; } export interface CardSelectedEvent { @@ -67,8 +68,8 @@ export class CardComponent implements OnInit { Math.max( ...meetings.map((element) => { return new Date(element.date).valueOf(); - }) - ) + }), + ), ); } diff --git a/alcs-frontend/src/app/shared/details-header/details-header.component.html b/alcs-frontend/src/app/shared/details-header/details-header.component.html index 60bf4353e0..d4abe30132 100644 --- a/alcs-frontend/src/app/shared/details-header/details-header.component.html +++ b/alcs-frontend/src/app/shared/details-header/details-header.component.html @@ -4,7 +4,7 @@ </div> <div *ngIf="_application" class="first-row"> <div class="title"> - <h5>{{ _application.fileNumber }} ({{ _application.applicant }})</h5> + <h5>{{ _application.fileNumber }} ({{ applicant }})</h5> <app-application-legacy-id [legacyId]="legacyId"></app-application-legacy-id> <div class="labels"> <app-application-type-pill *ngFor="let type of types" [type]="type"></app-application-type-pill> diff --git a/alcs-frontend/src/app/shared/details-header/details-header.component.ts b/alcs-frontend/src/app/shared/details-header/details-header.component.ts index 5d99f8128e..6be9dfb213 100644 --- a/alcs-frontend/src/app/shared/details-header/details-header.component.ts +++ b/alcs-frontend/src/app/shared/details-header/details-header.component.ts @@ -10,6 +10,7 @@ import { ApplicationDto } from '../../services/application/application.dto'; import { CardDto } from '../../services/card/card.dto'; import { CommissionerApplicationDto } from '../../services/commissioner/commissioner.dto'; import { NoticeOfIntentModificationDto } from '../../services/notice-of-intent/notice-of-intent-modification/notice-of-intent-modification.dto'; +import { NoticeOfIntentSubmissionStatusService } from '../../services/notice-of-intent/notice-of-intent-submission-status/notice-of-intent-submission-status.service'; import { NoticeOfIntentDto, NoticeOfIntentTypeDto } from '../../services/notice-of-intent/notice-of-intent.dto'; import { NotificationSubmissionStatusService } from '../../services/notification/notification-submission-status/notification-submission-status.service'; import { NotificationDto } from '../../services/notification/notification.dto'; @@ -19,7 +20,6 @@ import { RECON_TYPE_LABEL, RETROACTIVE_TYPE_LABEL, } from '../application-type-pill/application-type-pill.constants'; -import { NoticeOfIntentSubmissionStatusService } from '../../services/notice-of-intent/notice-of-intent-submission-status/notice-of-intent-submission-status.service'; import { TimeTrackable } from '../time-tracker/time-tracker.component'; @Component({ @@ -43,13 +43,18 @@ export class DetailsHeaderComponent { _application: ApplicationDto | CommissionerApplicationDto | NoticeOfIntentDto | NotificationDto | undefined; types: ApplicationTypeDto[] | NoticeOfIntentTypeDto[] = []; timeTrackable?: TimeTrackable; + applicant?: string; @Input() set application( - application: ApplicationDto | CommissionerApplicationDto | NoticeOfIntentDto | NotificationDto | undefined + application: ApplicationDto | CommissionerApplicationDto | NoticeOfIntentDto | NotificationDto | undefined, ) { if (application) { this._application = application; + if ('applicant' in application) { + this.applicant = application.applicant; + } + if ('retroactive' in application) { this.isNOI = true; } diff --git a/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.html b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.html new file mode 100644 index 0000000000..3ed252eb39 --- /dev/null +++ b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.html @@ -0,0 +1,27 @@ +<div class="inline-button-toggle"> + <span class="left" *ngIf="!isEditing"> + <a (click)="toggleEdit()" class="add-date" *ngIf="!selectedValue"> Select Option </a> + <span *ngIf="selectedValue"> + {{ selectedValue }} + </span> + <button *ngIf="selectedValue !== null" class="edit-button" mat-icon-button (click)="toggleEdit()"> + <mat-icon class="edit-icon">edit</mat-icon> + </button> + </span> + <div + class="editing" + [ngClass]="{ + hidden: !isEditing + }" + > + <form [formGroup]="form"> + <mat-button-toggle-group [formControl]="selectFormControl"> + <mat-button-toggle *ngFor="let option of options" [value]="option.value">{{ option.label }}</mat-button-toggle> + </mat-button-toggle-group> + </form> + <div class="button-container"> + <button mat-button (click)="toggleEdit()">CANCEL</button> + <button mat-button class="save" (click)="onSave()">SAVE</button> + </div> + </div> +</div> diff --git a/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.scss b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.scss new file mode 100644 index 0000000000..8d84e8b809 --- /dev/null +++ b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.scss @@ -0,0 +1,47 @@ +@use '../../../../styles/colors'; + +.editing.hidden { + display: none; +} + +.edit-button { + height: 24px; + width: 24px; + display: flex; + align-items: center; +} + +.edit-icon { + font-size: inherit; + line-height: 22px; +} + +.inline-button-toggle { + max-width: 500px; + padding-top: 4px; +} + +.button-container { + button:not(:last-child) { + margin-right: 2px !important; + } + + .save { + color: colors.$primary-color; + } +} + +:host::ng-deep { + .mat-form-field-wrapper { + padding: 0 !important; + margin: 0 !important; + } + + button mat-icon { + overflow: visible; + } + + .mat-mdc-icon-button.mat-mdc-button-base { + padding: 0 !important; + } +} diff --git a/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.spec.ts b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.spec.ts new file mode 100644 index 0000000000..e44962f7a6 --- /dev/null +++ b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { SharedModule } from '../../shared.module'; + +import { InlineButtonToggleComponent } from './inline-button-toggle.component'; + +describe('InlineButtonToggleComponent', () => { + let component: InlineButtonToggleComponent; + let fixture: ComponentFixture<InlineButtonToggleComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SharedModule, FormsModule, ReactiveFormsModule, MatButtonToggleModule], + declarations: [InlineButtonToggleComponent], + providers: [], + }).compileComponents(); + + fixture = TestBed.createComponent(InlineButtonToggleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.ts b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.ts new file mode 100644 index 0000000000..9c9f05d423 --- /dev/null +++ b/alcs-frontend/src/app/shared/inline-editors/inline-button-toggle/inline-button-toggle.component.ts @@ -0,0 +1,40 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; + +@Component({ + selector: 'app-inline-button-toggle[selectedValue][options]', + templateUrl: './inline-button-toggle.component.html', + styleUrls: ['./inline-button-toggle.component.scss'], +}) +export class InlineButtonToggleComponent implements OnInit { + @Input() selectedValue?: string | null; + @Input() options: { label: string; value: string }[] = []; + + @Output() save = new EventEmitter<string>(); + + selectFormControl = new FormControl(); + + form!: FormGroup; + isEditing = false; + + constructor(private fb: FormBuilder) {} + + ngOnInit(): void { + this.selectFormControl.setValue(this.selectedValue); + this.form = this.fb.group({ + selectFormControl: this.selectFormControl, + }); + } + + toggleEdit() { + this.isEditing = !this.isEditing; + this.form = this.fb.group({ + selectedValue: this.selectedValue, + }); + } + + onSave() { + this.save.emit(this.selectFormControl.value); + this.isEditing = false; + } +} diff --git a/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.html b/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.html index 142c0b9f0c..f1997963cb 100644 --- a/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.html +++ b/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.html @@ -20,6 +20,7 @@ matInput class="editable" name="value" + [required]="required" [placeholder]="placeholder" #editInput [(ngModel)]="pendingValue" diff --git a/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.ts b/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.ts index cf659714ba..5b18d7bb92 100644 --- a/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.ts +++ b/alcs-frontend/src/app/shared/inline-editors/inline-text/inline-text.component.ts @@ -1,4 +1,13 @@ -import { AfterContentChecked, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { + AfterContentChecked, + Component, + ElementRef, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core'; @Component({ selector: 'app-inline-text[value]', diff --git a/alcs-frontend/src/app/shared/shared.module.ts b/alcs-frontend/src/app/shared/shared.module.ts index 3cbdc88bc4..5a4dce9a07 100644 --- a/alcs-frontend/src/app/shared/shared.module.ts +++ b/alcs-frontend/src/app/shared/shared.module.ts @@ -44,6 +44,7 @@ import { ErrorMessageComponent } from './error-message/error-message.component'; import { FavoriteButtonComponent } from './favorite-button/favorite-button.component'; import { InlineApplicantTypeComponent } from './inline-applicant-type/inline-applicant-type.component'; import { InlineBooleanComponent } from './inline-editors/inline-boolean/inline-boolean.component'; +import { InlineButtonToggleComponent } from './inline-editors/inline-button-toggle/inline-button-toggle.component'; import { InlineChairReviewOutcomeComponent } from './inline-editors/inline-chair-review-outcome/inline-chair-review-outcome.component'; import { InlineDatepickerComponent } from './inline-editors/inline-datepicker/inline-datepicker.component'; import { InlineDropdownComponent } from './inline-editors/inline-dropdown/inline-dropdown.component'; @@ -109,6 +110,7 @@ import { WarningBannerComponent } from './warning-banner/warning-banner.componen ApplicationLegacyIdComponent, TableColumnNoDataPipe, InlineChairReviewOutcomeComponent, + InlineButtonToggleComponent, ], imports: [ CommonModule, @@ -205,6 +207,7 @@ import { WarningBannerComponent } from './warning-banner/warning-banner.componen TableColumnNoDataPipe, InlineChairReviewOutcomeComponent, MatSlideToggleModule, + InlineButtonToggleComponent, ], }) export class SharedModule { diff --git a/alcs-frontend/src/app/shared/staff-journal/staff-journal.component.ts b/alcs-frontend/src/app/shared/staff-journal/staff-journal.component.ts index a1e52d7651..3187274382 100644 --- a/alcs-frontend/src/app/shared/staff-journal/staff-journal.component.ts +++ b/alcs-frontend/src/app/shared/staff-journal/staff-journal.component.ts @@ -14,7 +14,7 @@ import { ConfirmationDialogService } from '../confirmation-dialog/confirmation-d }) export class StaffJournalComponent implements OnChanges { @Input() parentUuid: string = ''; - @Input() parentType: 'Application' | 'NOI' | 'Notification' = 'Application'; + @Input() parentType: 'Application' | 'NOI' | 'Notification' | 'Planning Review' = 'Application'; labelText = 'Add a journal note'; @@ -35,7 +35,7 @@ export class StaffJournalComponent implements OnChanges { constructor( private staffJournalService: StaffJournalService, private confirmationDialogService: ConfirmationDialogService, - private toastService: ToastService + private toastService: ToastService, ) {} ngOnChanges(changes: SimpleChanges): void { @@ -68,6 +68,11 @@ export class StaffJournalComponent implements OnChanges { notificationUuid: this.parentUuid, body: note, }); + } else if (this.parentType === 'Planning Review') { + await this.staffJournalService.createNoteForPlanningReview({ + planningReviewUuid: this.parentUuid, + body: note, + }); } else { await this.staffJournalService.createNoteForNoticeOfIntent({ noticeOfIntentUuid: this.parentUuid, diff --git a/bin/migrate-files/README.md b/bin/migrate-files/README.md index 0cc24f9f40..34e45f4036 100644 --- a/bin/migrate-files/README.md +++ b/bin/migrate-files/README.md @@ -10,6 +10,8 @@ The files are uploaded in the format `/migrate/application||issue||planning_revi - `document_id` is the primary key from the documents table - `filename` is the filename metadata from the documents table +Note: SRWs are stored in the application folder but imported separately + ## Libraries Used os: used to interact with the file system @@ -72,6 +74,7 @@ To run the script, run the following command: python migrate-files.py application python migrate-files.py application --start_document_id=500240 --end_document_id=505260 --last_imported_document_id=500475 ``` +Note: SRWs are stored in the application folder but imported separately Application document import supports running multiple terminals at the same time with specifying baches of data to import. @@ -117,6 +120,11 @@ python migrate-files.py planning python migrate-files.py issue ``` +```sh +# to start srw document import +python migrate-files.py srw +``` + M1: ```sh @@ -134,6 +142,11 @@ python3-intel64 migrate-files.py planning python3-intel64 migrate-files.py issue ``` +```sh +# to start srw document import +python3-intel64 migrate-files.py srw +``` + The script will start uploading files from the Oracle database to DELL ECS. The upload progress will be displayed in a progress bar. For Planning and Issues documents the script will also save the last uploaded document id, so the upload process can be resumed from where it left off in case of any interruption. For Application documents import it is responsibility of whoever is running the process to specify "last_imported_document_id" ## Windows diff --git a/bin/migrate-files/application_docs/__init__.py b/bin/migrate-files/application_docs/__init__.py index ed8598d5a6..5f7e54aa25 100644 --- a/bin/migrate-files/application_docs/__init__.py +++ b/bin/migrate-files/application_docs/__init__.py @@ -1 +1,2 @@ from .application_docs_import import import_application_docs +from .srw_docs_import import import_srw_docs diff --git a/bin/migrate-files/application_docs/srw_docs_import.py b/bin/migrate-files/application_docs/srw_docs_import.py new file mode 100644 index 0000000000..b2be930cbb --- /dev/null +++ b/bin/migrate-files/application_docs/srw_docs_import.py @@ -0,0 +1,232 @@ +from tqdm import tqdm +import cx_Oracle +from common import ( + LAST_IMPORTED_APPLICATION_FILE, + DocumentUploadBasePath, + upload_file_to_s3, + get_starting_document_id, + get_max_file_size, + EntityType, + handle_document_processing_error, + fetch_data_from_oracle, + process_results, + log_last_imported_file, + generate_log_file_name, +) + +log_file_name = generate_log_file_name(LAST_IMPORTED_APPLICATION_FILE) + + +def import_srw_docs( + batch, + cursor, + conn, + s3, + start_document_id_arg, + end_document_id_arg, + last_imported_document_id_arg, +): + # Get total number of files + application_count = _get_total_number_of_files( + cursor, start_document_id_arg, end_document_id_arg + ) + last_imported_document_id_arg = last_imported_document_id_arg + offset = ( + last_imported_document_id_arg + if last_imported_document_id_arg == 0 + else _get_total_number_of_transferred_files( + cursor, start_document_id_arg, last_imported_document_id_arg + ) + ) + print( + f"{EntityType.APPLICATION.value} count = {application_count} offset = {offset}" + ) + starting_document_id = last_imported_document_id_arg + + # Track progress + documents_processed = 0 + last_document_id = starting_document_id + + try: + with tqdm( + total=application_count, + initial=offset, + unit="file", + desc=f"Uploading {EntityType.APPLICATION.value} files to S3", + ) as documents_upload_progress_bar: + max_file_size = get_max_file_size(cursor) + + while True: + starting_document_id = get_starting_document_id( + starting_document_id, last_document_id, EntityType.APPLICATION.value + ) + + params = { + "starting_document_id": starting_document_id, + "end_document_id": end_document_id_arg, + "max_file_size": max_file_size, + "batch_size": batch, + } + data = fetch_data_from_oracle(_document_query, cursor, params) + + if not data: + break + # Upload the batch to S3 with a progress bar + for ( + file_size, + document_id, + application_id, + filename, + file, + ) in data: + tqdm.write(f"{application_id}/{document_id}_{filename}") + + upload_file_to_s3( + s3, + DocumentUploadBasePath.APPLICATION.value, + file_size, + document_id, + application_id, + filename, + file, + ) + + documents_upload_progress_bar.update(1) + last_document_id = document_id + documents_processed += 1 + log_last_imported_file(last_document_id, log_file_name) + + except Exception as error: + handle_document_processing_error( + cursor, + conn, + error, + EntityType.APPLICATION.value, + documents_processed, + last_document_id, + log_file_name, + ) + + # Display results + process_results( + EntityType.APPLICATION.value, + application_count, + documents_processed, + last_document_id, + log_file_name, + ) + + return + + +_document_query = """ + WITH app_docs_srw AS ( + + SELECT document_id FROM oats.oats_documents od + LEFT JOIN oats.oats_alr_appl_components oaac ON oaac.alr_application_id = od.alr_application_id + WHERE oaac.alr_change_code = 'SRW' + GROUP BY od.document_id + + ), + documents_with_cumulative_file_size AS ( + SELECT + ROW_NUMBER() OVER( + ORDER BY od.DOCUMENT_ID ASC + ) row_num, + dbms_lob.getLength(DOCUMENT_BLOB) file_size, + SUM(dbms_lob.getLength(DOCUMENT_BLOB)) OVER (ORDER BY od.DOCUMENT_ID ASC ROWS UNBOUNDED PRECEDING) AS cumulative_file_size, + od.DOCUMENT_ID, + ALR_APPLICATION_ID, + FILE_NAME, + DOCUMENT_BLOB, + DOCUMENT_CODE, + DESCRIPTION, + DOCUMENT_SOURCE_CODE, + UPLOADED_DATE, + WHEN_UPDATED, + REVISION_COUNT + FROM + OATS.OATS_DOCUMENTS od + JOIN app_docs_srw appds ON appds.document_id = od.document_id -- this will filter out all non SRW related documents + WHERE + dbms_lob.getLength(DOCUMENT_BLOB) > 0 + AND od.DOCUMENT_ID > :starting_document_id + AND (:end_document_id = 0 OR od.DOCUMENT_ID <= :end_document_id) + AND ALR_APPLICATION_ID IS NOT NULL + ORDER BY + DOCUMENT_ID ASC + ) + SELECT + file_size, + docwc.DOCUMENT_ID, + ALR_APPLICATION_ID, + FILE_NAME, + DOCUMENT_BLOB + FROM + documents_with_cumulative_file_size docwc + WHERE + cumulative_file_size < :max_file_size + AND row_num < :batch_size + ORDER BY + docwc.DOCUMENT_ID ASC + """ + + +def _get_total_number_of_files(cursor, start_document_id, end_document_id): + try: + cursor.execute( + """ + WITH app_docs_srw AS ( + + SELECT document_id FROM oats.oats_documents od + LEFT JOIN oats.oats_alr_appl_components oaac ON oaac.alr_application_id = od.alr_application_id + WHERE oaac.alr_change_code = 'SRW' + GROUP BY od.document_id + + ) + SELECT COUNT(*) + FROM OATS.OATS_DOCUMENTS od + JOIN app_docs_srw ON app_docs_srw.document_id = od.document_id + WHERE dbms_lob.getLength(DOCUMENT_BLOB) > 0 + AND ALR_APPLICATION_ID IS NOT NULL + AND (:start_document_id = 0 OR od.DOCUMENT_ID > :start_document_id) + AND (:end_document_id = 0 OR od.DOCUMENT_ID <= :end_document_id) + """, + { + "start_document_id": start_document_id, + "end_document_id": end_document_id, + }, + ) + return cursor.fetchone()[0] + except cx_Oracle.Error as e: + raise Exception("Oracle Error: {}".format(e)) + + +def _get_total_number_of_transferred_files(cursor, start_document_id, end_document_id): + try: + cursor.execute( + """ + WITH app_docs_srw AS ( + + SELECT document_id FROM oats.oats_documents od + LEFT JOIN oats.oats_alr_appl_components oaac ON oaac.alr_application_id = od.alr_application_id + WHERE oaac.alr_change_code = 'SRW' + GROUP BY od.document_id + + ) + SELECT COUNT(*) + FROM OATS.OATS_DOCUMENTS od + JOIN app_docs_srw ON app_docs_srw.document_id = od.document_id + WHERE dbms_lob.getLength(DOCUMENT_BLOB) > 0 + AND ALR_APPLICATION_ID IS NOT NULL + AND od.DOCUMENT_ID > :start_document_id + AND (:end_document_id = 0 OR od.DOCUMENT_ID <= :end_document_id) + """, + { + "start_document_id": start_document_id, + "end_document_id": end_document_id, + }, + ) + return cursor.fetchone()[0] + except cx_Oracle.Error as e: + raise Exception("Oracle Error: {}".format(e)) diff --git a/bin/migrate-files/migrate-files.py b/bin/migrate-files/migrate-files.py index fe6b62f1e8..113271166b 100644 --- a/bin/migrate-files/migrate-files.py +++ b/bin/migrate-files/migrate-files.py @@ -10,7 +10,7 @@ ecs_access_key, ecs_secret_key, ) -from application_docs import import_application_docs +from application_docs import import_application_docs, import_srw_docs from planning_docs import import_planning_review_docs from issue_docs import import_issue_docs import argparse @@ -61,6 +61,16 @@ def main(args): import_planning_review_docs(batch_size, cursor, conn, s3) elif args.document_type == "issue": import_issue_docs(batch_size, cursor, conn, s3) + elif args.document_type == "srw": + import_srw_docs( + batch_size, + cursor, + conn, + s3, + start_document_id, + end_document_id, + last_imported_document_id, + ) print("File upload complete, closing connection") @@ -73,7 +83,7 @@ def _parse_command_line_args(args): parser = argparse.ArgumentParser() parser.add_argument( "document_type", - choices=["application", "planning", "issue"], + choices=["application", "planning", "issue", "srw"], help="Document type to be processed", ) parser.add_argument( diff --git a/bin/migrate-oats-data/common/constants.py b/bin/migrate-oats-data/common/constants.py index a6d58ea21a..11e749c3b8 100644 --- a/bin/migrate-oats-data/common/constants.py +++ b/bin/migrate-oats-data/common/constants.py @@ -1,3 +1,4 @@ OATS_ETL_USER = "oats_etl" BATCH_UPLOAD_SIZE = 1000 NO_DATA_IN_OATS = "No data found in OATS" +DEFAULT_ETL_USER_UUID = "ca8e91dc-cfb0-45c3-a443-8e47e44591df" diff --git a/bin/migrate-oats-data/documents/__init__.py b/bin/migrate-oats-data/documents/__init__.py index bf7f554300..971ed10847 100644 --- a/bin/migrate-oats-data/documents/__init__.py +++ b/bin/migrate-oats-data/documents/__init__.py @@ -2,4 +2,5 @@ from .oats_documents_to_alcs_documents_app import * from .alcs_documents_to_noi_documents import * from .oats_documents_to_alcs_documents_noi import * -from .document_source_update import update_document_source \ No newline at end of file +from .document_source_update import update_document_source +from .post_launch import * diff --git a/bin/migrate-oats-data/documents/post_launch/__init__.py b/bin/migrate-oats-data/documents/post_launch/__init__.py new file mode 100644 index 0000000000..2f850f8e42 --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/__init__.py @@ -0,0 +1 @@ +from .migrate_documents import * diff --git a/bin/migrate-oats-data/documents/post_launch/alcs_documents_to_notification_documents.py b/bin/migrate-oats-data/documents/post_launch/alcs_documents_to_notification_documents.py new file mode 100644 index 0000000000..609b979906 --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/alcs_documents_to_notification_documents.py @@ -0,0 +1,144 @@ +from common import ( + setup_and_get_logger, + BATCH_UPLOAD_SIZE, + OATS_ETL_USER, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor + +etl_name = "link_srw_documents_from_alcs" +logger = setup_and_get_logger(etl_name) + +""" + This script connects to postgress version of OATS DB and links data from ALCS documents to ALCS notification_document table. + + NOTE: + Before performing document import you need to import SRWs and SRW documents. +""" + + +@inject_conn_pool +def link_srw_documents(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + function uses a decorator pattern @inject_conn_pool to inject a database connection pool to the function. It fetches the total count of documents and prints it to the console. Then, it fetches the documents to insert in batches using document IDs, constructs an insert query, and processes them. + """ + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "documents/post_launch/sql/alcs_documents_to_notification_documents_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + total_count = dict(cursor.fetchone())["count"] + logger.info(f"Total count of documents to transfer: {total_count}") + + failed_inserts_count = 0 + successful_inserts_count = 0 + last_document_id = 0 + + with open( + "documents/post_launch/sql/alcs_documents_to_notification_documents.sql", + "r", + encoding="utf-8", + ) as sql_file: + documents_to_insert_sql = sql_file.read() + while True: + cursor.execute( + f"{documents_to_insert_sql} WHERE oats_document_id > {last_document_id} ORDER BY oats_document_id;" + ) + rows = cursor.fetchmany(batch_size) + if not rows: + break + try: + documents_to_be_inserted_count = len(rows) + + _insert_records(conn, cursor, rows) + + last_document_id = dict(rows[-1])["oats_document_id"] + successful_inserts_count = ( + successful_inserts_count + documents_to_be_inserted_count + ) + + logger.debug( + f"retrieved/inserted items count: {documents_to_be_inserted_count}; total successfully inserted/updated documents so far {successful_inserts_count}; last inserted oats_document_id: {last_document_id}" + ) + except Exception as e: + conn.rollback() + logger.exception(f"Error {e}") + failed_inserts_count += len(rows) + last_document_id = last_document_id + 1 + + logger.info(f"Total amount of successful inserts: {successful_inserts_count}") + logger.info(f"Total amount of failed inserts: {failed_inserts_count}") + + +def _insert_records(conn, cursor, rows): + number_of_rows_to_insert = len(rows) + + if number_of_rows_to_insert > 0: + insert_query = _compile_insert_query(number_of_rows_to_insert) + rows_to_insert = _prepare_data_to_insert(rows) + cursor.execute(insert_query, rows_to_insert) + conn.commit() + + +def _compile_insert_query(number_of_rows_to_insert): + documents_to_insert = ",".join(["%s"] * number_of_rows_to_insert) + return f""" + INSERT INTO alcs.notification_document( + notification_uuid, + document_uuid, + type_code, + visibility_flags, + oats_document_id, + oats_application_id, + audit_created_by, + survey_plan_number, + control_number + ) + VALUES{documents_to_insert} + ON CONFLICT (oats_document_id, oats_application_id) DO UPDATE SET + notification_uuid = EXCLUDED.notification_uuid, + document_uuid = EXCLUDED.document_uuid, + type_code = EXCLUDED.type_code, + visibility_flags = EXCLUDED.visibility_flags, + audit_created_by = EXCLUDED.audit_created_by, + survey_plan_number = EXCLUDED.survey_plan_number, + control_number = EXCLUDED.control_number; + """ + + +def _prepare_data_to_insert(rows): + row_without_last_element = [] + for row in rows: + mapped_row = _map_data(row) + row_without_last_element.append(tuple(mapped_row.values())) + + return row_without_last_element + + +def _map_data(row): + return { + "notification_uuid": row["notification_uuid"], + "document_uuid": row["document_uuid"], + "type_code": row["type_code"], + "visibility_flags": row["visibility_flags"], + "oats_document_id": row["oats_document_id"], + "oats_application_id": row["oats_application_id"], + "audit_created_by": OATS_ETL_USER, + "plan_number": row["plan_no"], + "control_number": row["control_no"], + } + + +@inject_conn_pool +def clean_notification_documents(conn=None): + logger.info("Start documents cleaning") + with conn.cursor() as cursor: + cursor.execute( + f"DELETE FROM alcs.notification_document WHERE audit_created_by = '{OATS_ETL_USER}';" + ) + conn.commit() + logger.info(f"Deleted items count = {cursor.rowcount}") diff --git a/bin/migrate-oats-data/documents/post_launch/migrate_documents.py b/bin/migrate-oats-data/documents/post_launch/migrate_documents.py new file mode 100644 index 0000000000..08d6181d6a --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/migrate_documents.py @@ -0,0 +1,18 @@ +from .oats_documents_to_alcs_documents_srw import ( + import_oats_srw_documents, + document_clean, +) +from .alcs_documents_to_notification_documents import ( + link_srw_documents, + clean_notification_documents, +) + + +def import_documents(batch_size): + import_oats_srw_documents(batch_size) + link_srw_documents(batch_size) + + +def clean_documents(): + clean_notification_documents() + document_clean() diff --git a/bin/migrate-oats-data/documents/post_launch/oats_documents_to_alcs_documents_srw.py b/bin/migrate-oats-data/documents/post_launch/oats_documents_to_alcs_documents_srw.py new file mode 100644 index 0000000000..10f1c932eb --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/oats_documents_to_alcs_documents_srw.py @@ -0,0 +1,189 @@ +from common import ( + setup_and_get_logger, + BATCH_UPLOAD_SIZE, + OATS_ETL_USER, + add_timezone_and_keep_date_part, + OatsToAlcsDocumentSourceCode, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor +import os + +etl_name = "import_srw_documents_from_oats" +logger = setup_and_get_logger(etl_name) + +""" + This script connects to postgress version of OATS DB and transfers data from OATS documents table to ALCS documents table. + + NOTE: + Before performing document import you need to import SRWs from oats. +""" + + +@inject_conn_pool +def import_oats_srw_documents(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + function uses a decorator pattern @inject_conn_pool to inject a database connection pool to the function. It fetches the total count of documents and prints it to the console. Then, it fetches the documents to insert in batches using document IDs, constructs an insert query, and processes them. + """ + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "documents/post_launch/sql/oats_documents_to_alcs_documents_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + total_count = dict(cursor.fetchone())["count"] + logger.info(f"Total count of documents to transfer: {total_count}") + + failed_inserts_count = 0 + successful_inserts_count = 0 + last_document_id = 0 + + with open( + "documents/post_launch/sql/oats_documents_to_alcs_documents.sql", + "r", + encoding="utf-8", + ) as sql_file: + documents_to_insert_sql = sql_file.read() + while True: + cursor.execute( + f"{documents_to_insert_sql} WHERE document_id > {last_document_id} ORDER BY document_id;" + ) + rows = cursor.fetchmany(batch_size) + if not rows: + break + try: + documents_to_be_inserted_count = len(rows) + + _insert_records(conn, cursor, rows) + + last_document_id = dict(rows[-1])["document_id"] + successful_inserts_count = ( + successful_inserts_count + documents_to_be_inserted_count + ) + + logger.debug( + f"retrieved/inserted items count: {documents_to_be_inserted_count}; total successfully inserted/updated documents so far {successful_inserts_count}; last inserted oats_document_id: {last_document_id}" + ) + except Exception as e: + conn.rollback() + logger.exception(f"Error {e}") + failed_inserts_count += len(rows) + last_document_id = last_document_id + 1 + + logger.info(f"Total amount of successful inserts: {successful_inserts_count}") + logger.info(f"Total amount of failed inserts: {failed_inserts_count}") + + +def _insert_records(conn, cursor, rows): + number_of_rows_to_insert = len(rows) + + if number_of_rows_to_insert > 0: + insert_query = _compile_insert_query(number_of_rows_to_insert) + rows_to_insert = _prepare_data_to_insert(rows) + cursor.execute(insert_query, rows_to_insert) + conn.commit() + + +def _compile_insert_query(number_of_rows_to_insert): + documents_to_insert = ",".join(["%s"] * number_of_rows_to_insert) + return f""" + INSERT INTO alcs."document"( + oats_document_id, + file_name, + oats_application_id, + audit_created_by, + file_key, + mime_type, + tags, + "system", + uploaded_at, + source + ) + VALUES{documents_to_insert} + ON CONFLICT (oats_document_id) DO UPDATE SET + oats_document_id = EXCLUDED.oats_document_id, + file_name = EXCLUDED.file_name, + oats_application_id = EXCLUDED.oats_application_id, + audit_created_by = EXCLUDED.audit_created_by, + file_key = EXCLUDED.file_key, + mime_type = EXCLUDED.mime_type, + tags = EXCLUDED.tags, + "system" = EXCLUDED."system", + uploaded_at = EXCLUDED.uploaded_at, + source = EXCLUDED.source; + """ + + +def _prepare_data_to_insert(rows): + row_without_last_element = [] + for row in rows: + mapped_row = _map_data(row) + row_without_last_element.append(tuple(mapped_row.values())) + + return row_without_last_element + + +def _map_data(row): + return { + "oats_document_id": row["oats_document_id"], + "file_name": row["file_name"], + "oats_application_id": row["oats_application_id"], + "audit_created_by": OATS_ETL_USER, + "file_key": row["file_key"], + "mime_type": _get_mime_type(row), + "tags": row["tags"], + "system": _map_system(row), + "file_upload_date": _get_upload_date(row), + "file_source": _get_document_source(row), + } + + +def _map_system(row): + who_created = row["who_created"] + if who_created in ("PROXY_OATS_LOCGOV", "PROXY_OATS_APPLICANT"): + sys = "OATS_P" + else: + sys = "OATS" + return sys + + +def _get_upload_date(data): + upload_date = data.get("uploaded_date", "") + created_date = data.get("when_created", "") + if upload_date: + return add_timezone_and_keep_date_part(upload_date) + else: + return add_timezone_and_keep_date_part(created_date) + + +def _get_document_source(data): + source = data.get("document_source_code", "") + if source: + source = str(OatsToAlcsDocumentSourceCode[source].value) + + return source + + +def _get_mime_type(data): + file_name = data.get("file_name", "") + extension = os.path.splitext(file_name)[-1].lower().strip() + if extension == ".pdf": + return "application/pdf" + else: + return "application/octet-stream" + + +@inject_conn_pool +def document_clean(conn=None): + logger.info("Start documents cleaning") + with conn.cursor() as cursor: + cursor.execute( + f"DELETE FROM alcs.document WHERE audit_created_by = '{OATS_ETL_USER}' AND audit_created_at > '2024-02-08';" + ) + conn.commit() + logger.info(f"Deleted items count = {cursor.rowcount}") + + conn.commit() diff --git a/bin/migrate-oats-data/documents/post_launch/sql/alcs_documents_to_notification_documents.sql b/bin/migrate-oats-data/documents/post_launch/sql/alcs_documents_to_notification_documents.sql new file mode 100644 index 0000000000..126d746295 --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/sql/alcs_documents_to_notification_documents.sql @@ -0,0 +1,33 @@ +with oats_documents_to_map as ( + select n.uuid as notification_uuid, + d.uuid as document_uuid, + adc.code, + publicly_viewable_ind as is_public, + app_lg_viewable_ind as is_app_lg, + od.document_id as oats_document_id, + od.alr_application_id as oats_application_id, + oaa.plan_no, + oaa.control_no + from oats.oats_documents od + join alcs."document" d on d.oats_document_id = od.document_id::text + join alcs.document_code adc on adc.oats_code = od.document_code + join alcs.notification n on n.file_number = od.alr_application_id::text + JOIN oats.oats_alr_applications oaa ON od.alr_application_id = oaa.alr_application_id +) +select otm.notification_uuid, + otm.document_uuid, + otm.code as type_code, + ( + case + when is_public = 'Y' + and is_app_lg = 'Y' then '{P, A, C, G}'::text [] + when is_public = 'Y' then '{P}'::text [] + when is_app_lg = 'Y' then '{A, C, G}'::text [] + else '{}'::text [] + end + ) as visibility_flags, + oats_document_id, + oats_application_id, + plan_no, + control_no +from oats_documents_to_map otm \ No newline at end of file diff --git a/bin/migrate-oats-data/documents/post_launch/sql/alcs_documents_to_notification_documents_count.sql b/bin/migrate-oats-data/documents/post_launch/sql/alcs_documents_to_notification_documents_count.sql new file mode 100644 index 0000000000..d6c3931ba5 --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/sql/alcs_documents_to_notification_documents_count.sql @@ -0,0 +1,23 @@ +with oats_documents_to_map as ( + select + n.uuid as application_uuid, + d.uuid as document_uuid, + adc.code, + publicly_viewable_ind as is_public, + app_lg_viewable_ind as is_app_lg, + od.document_id as oats_document_id, + od.alr_application_id as oats_application_id + from oats.oats_documents od + + join alcs."document" d + on d.oats_document_id = od.document_id::text + + join alcs.document_code adc + on adc.oats_code = od.document_code + + join alcs.notification n + on n.file_number = od.alr_application_id::text +) +select + count(*) +from oats_documents_to_map otm \ No newline at end of file diff --git a/bin/migrate-oats-data/documents/post_launch/sql/oats_documents_to_alcs_documents.sql b/bin/migrate-oats-data/documents/post_launch/sql/oats_documents_to_alcs_documents.sql new file mode 100644 index 0000000000..571d7d6963 --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/sql/oats_documents_to_alcs_documents.sql @@ -0,0 +1,30 @@ +with oats_documents_to_insert as ( + select od.alr_application_id, + document_id, + document_code, + file_name, + od.who_created, + od.document_source_code, + od.uploaded_date, + od.when_created + from oats.oats_documents od + left join oats.oats_subject_properties osp on osp.subject_property_id = od.subject_property_id + and osp.alr_application_id = od.alr_application_id + where od.alr_application_id is not null + and document_code is not null + and od.issue_id is null + and od.planning_review_id is null +) +SELECT document_id::text AS oats_document_id, + file_name, + alr_application_id::text AS oats_application_id, + 'migrate/application/' || alr_application_id || '/' || document_id || '_' || file_name AS file_key, + 'pdf' AS mime_type, + '{"ORCS Classification: 85100-20"}'::text [] as tags, + who_created, + document_source_code, + uploaded_date, + when_created, + document_id +FROM oats_documents_to_insert oti + JOIN alcs.notification n ON n.file_number = oti.alr_application_id::text \ No newline at end of file diff --git a/bin/migrate-oats-data/documents/post_launch/sql/oats_documents_to_alcs_documents_count.sql b/bin/migrate-oats-data/documents/post_launch/sql/oats_documents_to_alcs_documents_count.sql new file mode 100644 index 0000000000..623197ce92 --- /dev/null +++ b/bin/migrate-oats-data/documents/post_launch/sql/oats_documents_to_alcs_documents_count.sql @@ -0,0 +1,21 @@ + with oats_documents_to_insert as ( + select + od.alr_application_id , + document_id , + document_code , + file_name + + from oats.oats_documents od + left join oats.oats_subject_properties osp + on osp.subject_property_id = od.subject_property_id + and osp.alr_application_id = od.alr_application_id + where od.alr_application_id is not null + and document_code is not null + and od.issue_id is null + and od.planning_review_id is null +) + SELECT + count(*) + FROM + oats_documents_to_insert oti + JOIN alcs.notification n ON n.file_number = oti.alr_application_id::text \ No newline at end of file diff --git a/bin/migrate-oats-data/menu/post_launch_commands/__init__.py b/bin/migrate-oats-data/menu/post_launch_commands/__init__.py index 30ca959d00..238f74542c 100644 --- a/bin/migrate-oats-data/menu/post_launch_commands/__init__.py +++ b/bin/migrate-oats-data/menu/post_launch_commands/__init__.py @@ -3,3 +3,4 @@ from .applications import * from .notice_of_intents import * from .srws import * +from .documents import * diff --git a/bin/migrate-oats-data/menu/post_launch_commands/clean_all.py b/bin/migrate-oats-data/menu/post_launch_commands/clean_all.py index 4e1e71f0ee..7204891f36 100644 --- a/bin/migrate-oats-data/menu/post_launch_commands/clean_all.py +++ b/bin/migrate-oats-data/menu/post_launch_commands/clean_all.py @@ -3,11 +3,13 @@ ) from noi.post_launch import clean_notice_of_intent from srw.post_launch import clean_srw +from documents.post_launch import clean_documents def clean_all(console, args): with console.status("[bold green]Cleaning previous ETL...\n") as status: console.log("Cleaning data:") + clean_documents() clean_alcs_applications() clean_notice_of_intent() clean_srw() diff --git a/bin/migrate-oats-data/menu/post_launch_commands/documents.py b/bin/migrate-oats-data/menu/post_launch_commands/documents.py new file mode 100644 index 0000000000..b130239efb --- /dev/null +++ b/bin/migrate-oats-data/menu/post_launch_commands/documents.py @@ -0,0 +1,20 @@ +from documents.post_launch import import_documents, clean_documents + + +def document_import(console, args): + console.log("Beginning OATS -> ALCS document import process") + with console.status( + "[bold green]document import (Document related table update in ALCS)...\n" + ) as status: + if args.batch_size: + import_batch_size = args.batch_size + + console.log(f"Processing documents import in batch size = {import_batch_size}") + + import_documents(batch_size=import_batch_size) + + +def document_clean(console): + console.log("Beginning ALCS Document clean") + with console.status("[bold green]Cleaning ALCS Documents...\n") as status: + clean_documents() diff --git a/bin/migrate-oats-data/menu/post_launch_commands/import_all.py b/bin/migrate-oats-data/menu/post_launch_commands/import_all.py index 8e50944139..a0de0fffd6 100644 --- a/bin/migrate-oats-data/menu/post_launch_commands/import_all.py +++ b/bin/migrate-oats-data/menu/post_launch_commands/import_all.py @@ -3,6 +3,7 @@ process_notice_of_intent, ) from srw.post_launch.srw_migration import process_srw +from documents.post_launch.migrate_documents import import_documents def import_all(console, args): @@ -22,4 +23,7 @@ def import_all(console, args): console.log("Processing SRW") process_srw(batch_size=import_batch_size) + console.log("Processing Documents") + import_documents(batch_size=import_batch_size) + console.log("Done") diff --git a/bin/migrate-oats-data/migrate.py b/bin/migrate-oats-data/migrate.py index 5157e3f5e9..0691d069ba 100644 --- a/bin/migrate-oats-data/migrate.py +++ b/bin/migrate-oats-data/migrate.py @@ -14,6 +14,8 @@ notice_of_intent_clean, srw_import, srw_clean, + document_import, + document_clean, ) from db import connection_pool from common import BATCH_UPLOAD_SIZE, setup_and_get_logger @@ -47,6 +49,10 @@ srw_import(console, args) case "srw-clean": srw_clean(console) + case "document-import": + document_import(console, args) + case "document-clean": + document_clean(console) finally: if connection_pool: diff --git a/bin/migrate-oats-data/srw/__init__.py b/bin/migrate-oats-data/srw/__init__.py index e2ce0f6054..13737d032e 100644 --- a/bin/migrate-oats-data/srw/__init__.py +++ b/bin/migrate-oats-data/srw/__init__.py @@ -1,2 +1,3 @@ from .srw_base import init_srw_base, clean_initial_srw from .srw_base_update import update_srw_base_fields +from .srw_staff_journal import process_srw_staff_journal, clean_srw_staff_journal diff --git a/bin/migrate-oats-data/srw/post_launch/srw_migration.py b/bin/migrate-oats-data/srw/post_launch/srw_migration.py index 90c054fab0..6e000d2ece 100644 --- a/bin/migrate-oats-data/srw/post_launch/srw_migration.py +++ b/bin/migrate-oats-data/srw/post_launch/srw_migration.py @@ -8,6 +8,18 @@ clean_transferees, ) from ..applicant.srw_process_applicant import update_srw_base_applicant +from ..srw_staff_journal import process_srw_staff_journal, clean_srw_staff_journal +from ..submission.primary_contact.srw_process_primary_contact import ( + process_alcs_srw_primary_contact, +) +from ..submission.statuses import ( + init_srw_statuses, + clean_srw_submission_statuses, + process_alcs_srw_cancelled_status, + process_alcs_srw_in_progress_status, + process_alcs_srw_response_sent_status, + process_alcs_srw_submitted_to_alc_status, +) def process_srw(batch_size): @@ -19,6 +31,7 @@ def init_srw(batch_size): update_srw_base_fields(batch_size) _process_srw_submission(batch_size) update_srw_base_applicant(batch_size) + process_srw_staff_journal(batch_size) def _process_srw_submission(batch_size): @@ -26,10 +39,22 @@ def _process_srw_submission(batch_size): process_alcs_srw_proposal_fields(batch_size) init_srw_parcels(batch_size) init_srw_parcel_transferee(batch_size) + process_alcs_srw_primary_contact(batch_size) + _process_srw_submission_statuses(batch_size) + + +def _process_srw_submission_statuses(batch_size): + init_srw_statuses() + process_alcs_srw_cancelled_status(batch_size) + process_alcs_srw_in_progress_status(batch_size) + process_alcs_srw_response_sent_status(batch_size) + process_alcs_srw_submitted_to_alc_status(batch_size) def clean_srw(): + clean_srw_staff_journal() clean_transferees() clean_parcels() + clean_srw_submission_statuses() clean_srw_submissions() clean_initial_srw() diff --git a/bin/migrate-oats-data/srw/sql/srw_staff_journal.sql b/bin/migrate-oats-data/srw/sql/srw_staff_journal.sql new file mode 100644 index 0000000000..7d765e5b17 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/srw_staff_journal.sql @@ -0,0 +1,4 @@ +SELECT osj.journal_date, osj.journal_text, osj.revision_count, osj.staff_journal_entry_id, an."uuid" +FROM oats.oats_staff_journal_entries osj +JOIN alcs.notification an ON an.file_number = osj.alr_application_id::TEXT +WHERE an.type_code = 'SRW' \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/srw_staff_journal_count.sql b/bin/migrate-oats-data/srw/sql/srw_staff_journal_count.sql new file mode 100644 index 0000000000..692ed2a0a7 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/srw_staff_journal_count.sql @@ -0,0 +1,4 @@ +SELECT count (*) +FROM oats.oats_staff_journal_entries osj +JOIN alcs.notification an ON an.file_number = osj.alr_application_id::TEXT +WHERE an.type_code = 'SRW'; \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/primary_contact/srw_primary_contact.sql b/bin/migrate-oats-data/srw/sql/submission/primary_contact/srw_primary_contact.sql new file mode 100644 index 0000000000..c33aa9ee29 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/primary_contact/srw_primary_contact.sql @@ -0,0 +1,32 @@ +WITH ranked_contacts AS ( + SELECT oaap.alr_application_id, + oaap.alr_application_party_id, + op.person_id, + op.first_name, + op.last_name, + op.middle_name, + op.title, + oo.organization_id, + oo.organization_name, + oo.alias_name, + opo.phone_number, + opo.cell_phone_number, + opo.email_address, + oaap.alr_appl_role_code, + ns."uuid" AS notification_submission_uuid, + ROW_NUMBER() OVER ( + PARTITION BY alr_application_id + ORDER BY alr_appl_role_code, + oaap.when_created + ) AS rn + FROM oats.oats_alr_application_parties oaap + JOIN oats.oats_person_organizations opo ON opo.person_organization_id = oaap.person_organization_id + LEFT JOIN oats.oats_organizations oo ON oo.organization_id = opo.organization_id + LEFT JOIN oats.oats_persons op ON op.person_id = opo.person_id + JOIN alcs.notification_submission ns ON ns.file_number = oaap.alr_application_id::TEXT + AND ns.type_code = 'SRW' + WHERE oaap.alr_appl_role_code in ('AGENT', 'APPL') +) +SELECT * +FROM ranked_contacts +WHERE rn = 1 \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/primary_contact/srw_primary_contact_count.sql b/bin/migrate-oats-data/srw/sql/submission/primary_contact/srw_primary_contact_count.sql new file mode 100644 index 0000000000..010550a465 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/primary_contact/srw_primary_contact_count.sql @@ -0,0 +1,31 @@ +WITH ranked_contacts AS ( + SELECT oaap.alr_application_id, + oaap.alr_application_party_id, + op.person_id, + op.first_name, + op.last_name, + op.middle_name, + oo.organization_id, + oo.organization_name, + oo.alias_name, + opo.phone_number, + opo.cell_phone_number, + opo.email_address, + oaap.alr_appl_role_code, + ns."uuid" AS notification_submission_uuid, + ROW_NUMBER() OVER ( + PARTITION BY alr_application_id + ORDER BY alr_appl_role_code, + oaap.when_created + ) AS rn + FROM oats.oats_alr_application_parties oaap + JOIN oats.oats_person_organizations opo ON opo.person_organization_id = oaap.person_organization_id + LEFT JOIN oats.oats_organizations oo ON oo.organization_id = opo.organization_id + LEFT JOIN oats.oats_persons op ON op.person_id = opo.person_id + JOIN alcs.notification_submission ns ON ns.file_number = oaap.alr_application_id::TEXT + AND ns.type_code = 'SRW' + WHERE oaap.alr_appl_role_code in ('AGENT', 'APPL') +) +SELECT count(*) +FROM ranked_contacts +WHERE rn = 1 \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/init_srw_status.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/init_srw_status.sql new file mode 100644 index 0000000000..373971c721 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/init_srw_status.sql @@ -0,0 +1,27 @@ +INSERT INTO alcs.notification_submission_to_submission_status(submission_uuid, status_type_code) -- retrieve applications from OATS that have only 1 proposal component + WITH components_grouped AS ( + SELECT aes.alr_application_id + FROM oats.alcs_etl_srw aes + WHERE aes.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') + and aes.alr_change_code = 'SRW' + ), + alcs_submissions_with_statuses AS ( + SELECT not_sub.file_number + FROM alcs.notification_submission not_sub + JOIN alcs.notification_submission_to_submission_status notss ON not_sub.uuid = notss.submission_uuid + GROUP BY not_sub.file_number + ), + -- retrieve submission_uuid from ALCS that were imported with ETL + alcs_submission_uuids_to_populate AS ( + SELECT oaa.alr_application_id, + notss.uuid + FROM components_grouped + JOIN oats.oats_alr_applications oaa ON components_grouped.alr_application_id = oaa.alr_application_id + JOIN alcs.notification_submission notss ON oaa.alr_application_id::TEXT = notss.file_number -- make sure TO WORK ONLY with the ones that were imported TO ALCS + LEFT JOIN alcs_submissions_with_statuses ON alcs_submissions_with_statuses.file_number = notss.file_number + WHERE alcs_submissions_with_statuses.file_number IS NULL -- filter out all submissions that have statuses populated before the ETL; + ) +SELECT uuid, + notsst.code +FROM alcs_submission_uuids_to_populate + CROSS JOIN alcs.notification_submission_status_type notsst \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/init_srw_status_count.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/init_srw_status_count.sql new file mode 100644 index 0000000000..a0105962a6 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/init_srw_status_count.sql @@ -0,0 +1,25 @@ +WITH components_grouped AS ( + SELECT aes.alr_application_id + FROM oats.alcs_etl_srw aes + WHERE aes.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') + and aes.alr_change_code = 'SRW' +), +alcs_submissions_with_statuses AS ( + SELECT nots.file_number + FROM alcs.notification_submission nots + JOIN alcs.notification_submission_to_submission_status notss ON nots.uuid = notss.submission_uuid + GROUP BY nots.file_number +), +-- alcs_submissions_with_statuses is required for development environment only. Production environment does not have submissions. +alcs_submission_uuids_to_populate AS ( + SELECT oaa.alr_application_id, + nots.uuid + FROM components_grouped + JOIN oats.oats_alr_applications oaa ON components_grouped.alr_application_id = oaa.alr_application_id + JOIN alcs.notification_submission nots ON oaa.alr_application_id::TEXT = nots.file_number -- make sure TO WORK ONLY with the ones that were imported TO ALCS + LEFT JOIN alcs_submissions_with_statuses ON alcs_submissions_with_statuses.file_number = nots.file_number + WHERE alcs_submissions_with_statuses.file_number IS NULL -- filter out all submissions that have statuses populated before the ETL; +) +SELECT count(*) +FROM alcs_submission_uuids_to_populate + CROSS JOIN alcs.notification_submission_status_type notsst; \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_cancelled.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_cancelled.sql new file mode 100644 index 0000000000..d478b2eef5 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_cancelled.sql @@ -0,0 +1,45 @@ +WITH first_cancelled_accomplishment_per_file_number AS ( + SELECT alr_application_id, + accomplishment_code, + min(oa.completion_date) AS completion_date + FROM oats.oats_accomplishments oa + WHERE accomplishment_code = 'CAN' + GROUP BY alr_application_id, + accomplishment_code +), +cancelled_accomplishments_for_srw_only AS ( + SELECT DISTINCT ON ( + first_cancelled.accomplishment_code, + first_cancelled.completion_date, + oaa.alr_application_id, + oaa.cancelled_date + ) first_cancelled.accomplishment_code, + first_cancelled.completion_date, + oaa.alr_application_id, + oaa.cancelled_date + FROM oats.oats_alr_applications oaa + LEFT JOIN first_cancelled_accomplishment_per_file_number AS first_cancelled ON first_cancelled.alr_application_id = oaa.alr_application_id + JOIN oats.oats_alr_appl_components oaac ON oaa.alr_application_id = oaac.alr_application_id + WHERE oaa.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') AND oaac.alr_change_code IN('SRW') +) +SELECT oats_cancelled.alr_application_id, + oats_cancelled.accomplishment_code, + oats_cancelled.completion_date, + oats_cancelled.cancelled_date, + LEAST( + COALESCE( + oats_cancelled.completion_date, + oats_cancelled.cancelled_date + ), + COALESCE( + oats_cancelled.cancelled_date, + oats_cancelled.completion_date + ) + ) AS min_date, + nots.uuid +FROM cancelled_accomplishments_for_srw_only oats_cancelled + JOIN alcs.notification_submission nots ON nots.file_number = oats_cancelled.alr_application_id::TEXT +WHERE ( + oats_cancelled.completion_date IS NOT NULL + OR oats_cancelled.cancelled_date IS NOT NULL + ) \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_cancelled_count.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_cancelled_count.sql new file mode 100644 index 0000000000..e70a4949c2 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_cancelled_count.sql @@ -0,0 +1,35 @@ +WITH first_cancelled_accomplishment_per_file_number AS ( + SELECT alr_application_id, + accomplishment_code, + min(oa.completion_date) AS completion_date + FROM oats.oats_accomplishments oa + WHERE accomplishment_code = 'CAN' + GROUP BY alr_application_id, + accomplishment_code +), +cancelled_accomplishments_for_srw_only AS ( + SELECT DISTINCT ON ( + first_cancelled.accomplishment_code, + first_cancelled.completion_date, + oaa.alr_application_id, + oaa.cancelled_date + ) first_cancelled.accomplishment_code, + first_cancelled.completion_date, + oaa.alr_application_id, + oaa.cancelled_date + FROM oats.oats_alr_applications oaa + LEFT JOIN first_cancelled_accomplishment_per_file_number AS first_cancelled ON first_cancelled.alr_application_id = oaa.alr_application_id + JOIN oats.oats_alr_appl_components oaac ON oaa.alr_application_id = oaac.alr_application_id + WHERE oaa.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') AND oaac.alr_change_code IN('SRW') +), +all_nots_with_cancelled_status AS ( + SELECT oats_cancelled.alr_application_id + FROM cancelled_accomplishments_for_srw_only oats_cancelled + JOIN alcs.notification_submission nots ON nots.file_number = oats_cancelled.alr_application_id::TEXT + JOIN alcs.notification_submission_to_submission_status notstss ON notstss.submission_uuid = nots.uuid + WHERE oats_cancelled.completion_date IS NOT NULL + OR oats_cancelled.cancelled_date IS NOT NULL + GROUP BY oats_cancelled.alr_application_id +) +SELECT count(*) +FROM all_nots_with_cancelled_status; \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_in_progress.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_in_progress.sql new file mode 100644 index 0000000000..7cfbe82cdb --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_in_progress.sql @@ -0,0 +1,38 @@ +WITH earliest_in_progress_accomplishment_per_file_number AS ( + SELECT alr_application_id, + accomplishment_code, + min(oa.completion_date) AS completion_date + FROM oats.oats_accomplishments oa + WHERE accomplishment_code = 'INP' + GROUP BY alr_application_id, + accomplishment_code +), +earliest_in_progress_accomplishments_for_srw_only AS ( + SELECT DISTINCT ON ( + earliest_in_prog.accomplishment_code, + earliest_in_prog.completion_date, + oaa.alr_application_id, + oaa.created_date, + oaa.submitted_to_alc_date, + oaa.when_created + ) earliest_in_prog.accomplishment_code, + earliest_in_prog.completion_date, + oaa.alr_application_id, + oaa.created_date, + oaa.submitted_to_alc_date, + oaa.when_created + FROM oats.oats_alr_applications oaa + LEFT JOIN earliest_in_progress_accomplishment_per_file_number AS earliest_in_prog ON earliest_in_prog.alr_application_id = oaa.alr_application_id + JOIN oats.oats_alr_appl_components oaac ON oaa.alr_application_id = oaac.alr_application_id + WHERE oaa.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') AND oaac.alr_change_code IN('SRW') +) +SELECT DISTINCT ON (oats_in_prog.alr_application_id) oats_in_prog.alr_application_id, + oats_in_prog.accomplishment_code, + oats_in_prog.completion_date, + oats_in_prog.created_date, + oats_in_prog.submitted_to_alc_date, + oats_in_prog.when_created, + nots.uuid +FROM alcs.notification_submission_to_submission_status notss + JOIN alcs.notification_submission nots ON nots.uuid = notss.submission_uuid + JOIN earliest_in_progress_accomplishments_for_srw_only AS oats_in_prog ON oats_in_prog.alr_application_id::TEXT = nots.file_number \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_in_progress_count.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_in_progress_count.sql new file mode 100644 index 0000000000..c15430ea55 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_in_progress_count.sql @@ -0,0 +1,37 @@ +WITH latest_in_progress_accomplishment_per_file_number AS ( + SELECT alr_application_id, + accomplishment_code, + min(oa.completion_date) AS completion_date + FROM oats.oats_accomplishments oa + WHERE accomplishment_code = 'INP' + GROUP BY alr_application_id, + accomplishment_code +), +latest_in_progress_accomplishments_for_srw_only AS ( + SELECT DISTINCT ON ( + latest_in_prog.accomplishment_code, + latest_in_prog.completion_date, + oaa.alr_application_id, + oaa.created_date, + oaa.submitted_to_alc_date, + oaa.when_created + ) latest_in_prog.accomplishment_code, + latest_in_prog.completion_date, + oaa.alr_application_id, + oaa.created_date, + oaa.submitted_to_alc_date, + oaa.when_created + FROM oats.oats_alr_applications oaa + LEFT JOIN latest_in_progress_accomplishment_per_file_number AS latest_in_prog ON latest_in_prog.alr_application_id = oaa.alr_application_id + JOIN oats.oats_alr_appl_components oaac ON oaa.alr_application_id = oaac.alr_application_id + WHERE oaa.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') AND oaac.alr_change_code IN('SRW') +), +submission_statuses_to_update AS ( + SELECT count(*) + FROM alcs.notification_submission_to_submission_status notstss + JOIN alcs.notification_submission nots ON nots.uuid = notstss.submission_uuid + JOIN latest_in_progress_accomplishments_for_srw_only AS oats_in_prog ON oats_in_prog.alr_application_id::TEXT = nots.file_number + GROUP BY notstss.submission_uuid +) +SELECT count(*) +FROM submission_statuses_to_update; \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_response_sent.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_response_sent.sql new file mode 100644 index 0000000000..ed831158d2 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_response_sent.sql @@ -0,0 +1,17 @@ +WITH components_grouped AS ( + SELECT aes.alr_application_id + FROM oats.alcs_etl_srw aes + WHERE aes.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') + and aes.alr_change_code = 'SRW' +) +SELECT oaa2.alr_application_id, + oaa2.email_response_date, + oaa2.when_created, + oaa2.cancelled_date, + oen.sent_date, + oen.email_type_code, + nots.uuid +FROM oats.oats_alr_applications oaa2 + JOIN components_grouped cg ON cg.alr_application_id = oaa2.alr_application_id + JOIN alcs.notification_submission nots ON nots.file_number = oaa2.alr_application_id::TEXT + LEFT JOIN oats.oats_email_notifications oen ON oaa2.alr_application_id = oen.alr_application_id \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_response_sent_count.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_response_sent_count.sql new file mode 100644 index 0000000000..151fb9c8e5 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_response_sent_count.sql @@ -0,0 +1,11 @@ +WITH components_grouped AS ( + SELECT aes.alr_application_id + FROM oats.alcs_etl_srw aes + WHERE aes.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') + and aes.alr_change_code = 'SRW' +) +SELECT count (*) +FROM oats.oats_alr_applications oaa2 + JOIN components_grouped cg ON cg.alr_application_id = oaa2.alr_application_id + JOIN alcs.notification_submission nots ON nots.file_number = oaa2.alr_application_id::TEXT + LEFT JOIN oats.oats_email_notifications oen ON oaa2.alr_application_id = oen.alr_application_id; \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_submitted_alc.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_submitted_alc.sql new file mode 100644 index 0000000000..f0bc1b0258 --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_submitted_alc.sql @@ -0,0 +1,12 @@ +WITH components_grouped AS ( + SELECT aes.alr_application_id + FROM oats.alcs_etl_srw aes + WHERE aes.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') + and aes.alr_change_code = 'SRW' +) +SELECT oaa2.alr_application_id, + oaa2.submitted_to_alc_date, + nots.uuid +FROM oats.oats_alr_applications oaa2 + JOIN components_grouped cg ON cg.alr_application_id = oaa2.alr_application_id + JOIN alcs.notification_submission nots ON nots.file_number = oaa2.alr_application_id::TEXT \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/statuses/srw_submitted_alc_count.sql b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_submitted_alc_count.sql new file mode 100644 index 0000000000..45f17d9dca --- /dev/null +++ b/bin/migrate-oats-data/srw/sql/submission/statuses/srw_submitted_alc_count.sql @@ -0,0 +1,9 @@ +WITH components_grouped AS ( + SELECT aes.alr_application_id + FROM oats.alcs_etl_srw aes + WHERE aes.application_class_code IN ('LOA', 'BLK', 'SCH', 'NAN') + and aes.alr_change_code = 'SRW' +) +SELECT count(*) +FROM components_grouped + JOIN alcs.notification_submission nots ON nots.file_number = alr_application_id::TEXT; \ No newline at end of file diff --git a/bin/migrate-oats-data/srw/sql/submission/transferee/srw_transferee.sql b/bin/migrate-oats-data/srw/sql/submission/transferee/srw_transferee.sql index 2ae4b45bcd..70462acbba 100644 --- a/bin/migrate-oats-data/srw/sql/submission/transferee/srw_transferee.sql +++ b/bin/migrate-oats-data/srw/sql/submission/transferee/srw_transferee.sql @@ -3,6 +3,7 @@ SELECT oaap.alr_application_party_id, op.first_name, op.last_name, op.middle_name, + op.title, oo.organization_id, oo.organization_name, oo.alias_name, diff --git a/bin/migrate-oats-data/srw/srw_staff_journal.py b/bin/migrate-oats-data/srw/srw_staff_journal.py new file mode 100644 index 0000000000..b607b02fdd --- /dev/null +++ b/bin/migrate-oats-data/srw/srw_staff_journal.py @@ -0,0 +1,150 @@ +from common import ( + BATCH_UPLOAD_SIZE, + OATS_ETL_USER, + setup_and_get_logger, + add_timezone_and_keep_date_part, + DEFAULT_ETL_USER_UUID +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "srw_staff_journal" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def process_srw_staff_journal(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + This function is responsible for initializing entries for notifications in staff_journal table in ALCS. + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/srw_staff_journal_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total staff journal entry data to insert: {count_total}") + + failed_inserts_count = 0 + successful_inserts_count = 0 + last_entry_id = 0 + with open( + "srw/sql/srw_staff_journal.sql", + "r", + encoding="utf-8", + ) as sql_file: + submission_sql = sql_file.read() + while True: + cursor.execute( + f"{submission_sql} AND osj.staff_journal_entry_id > '{last_entry_id}' ORDER BY osj.staff_journal_entry_id;" + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + users_to_be_inserted_count = len(rows) + + _insert_entries(conn, batch_size, cursor, rows) + + successful_inserts_count = ( + successful_inserts_count + users_to_be_inserted_count + ) + last_entry_id = dict(rows[-1])["staff_journal_entry_id"] + + logger.debug( + f"retrieved/inserted items count: {users_to_be_inserted_count}; total successfully inserted entries so far {successful_inserts_count}; last inserted journal_id: {last_entry_id}" + ) + except Exception as err: + logger.exception("") + conn.rollback() + failed_inserts_count = count_total - successful_inserts_count + last_entry_id = last_entry_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful inserts {successful_inserts_count}, total failed inserts {failed_inserts_count}" + ) + + +def _insert_entries(conn, batch_size, cursor, rows): + query = _get_insert_query() + parsed_data_list = _prepare_journal_data(rows) + + if len(parsed_data_list) > 0: + execute_batch(cursor, query, parsed_data_list, page_size=batch_size) + + conn.commit() + + +def _get_insert_query(): + query = f""" + INSERT INTO alcs.staff_journal ( + body, + edited, + notification_uuid, + created_at, + author_uuid, + audit_created_by + ) + VALUES ( + %(journal_text)s, + %(edit)s, + %(uuid)s, + %(journal_date)s, + %(user)s, + '{OATS_ETL_USER}' + ) + ON CONFLICT DO NOTHING; + """ + return query + + +def _prepare_journal_data(row_data_list): + data_list = [] + for row in row_data_list: + data = dict(row) + data = _map_revision(data) + data = _map_timezone(data) + data["user"] = DEFAULT_ETL_USER_UUID + data_list.append(dict(data)) + return data_list + + +def _map_revision(data): + revision = data.get("revision_count", "") + # check if edited + if revision == 0: + data["edit"] = False + else: + data["edit"] = True + return data + + +def _map_timezone(data): + date = data.get("journal_date", "") + journal_date = add_timezone_and_keep_date_part(date) + data["journal_date"] = journal_date + return data + + +@inject_conn_pool +def clean_srw_staff_journal(conn=None): + logger.info("Start staff journal cleaning") + # Only clean applications + with conn.cursor() as cursor: + cursor.execute( + f"DELETE FROM alcs.staff_journal asj WHERE asj.audit_created_by = '{OATS_ETL_USER}' AND asj.notification_uuid IS NOT NULL" + ) + logger.info(f"Deleted items count = {cursor.rowcount}") + + conn.commit() diff --git a/bin/migrate-oats-data/srw/submission/__init__.py b/bin/migrate-oats-data/srw/submission/__init__.py index ce795a0a78..e68657c6d2 100644 --- a/bin/migrate-oats-data/srw/submission/__init__.py +++ b/bin/migrate-oats-data/srw/submission/__init__.py @@ -1,2 +1,3 @@ from .srw_submission_init import init_srw_submissions, clean_srw_submissions from .srw_proposal_fields import process_alcs_srw_proposal_fields +from .statuses import * diff --git a/bin/migrate-oats-data/srw/submission/parcel/srw_parcel_init.py b/bin/migrate-oats-data/srw/submission/parcel/srw_parcel_init.py index e3902f0bb7..2e3e31aa6a 100644 --- a/bin/migrate-oats-data/srw/submission/parcel/srw_parcel_init.py +++ b/bin/migrate-oats-data/srw/submission/parcel/srw_parcel_init.py @@ -104,7 +104,7 @@ def _map_data(row, insert_index): "legal_description": row["legal_description"], "map_area_hectares": row["area_size"], "ownership_type_code": _map_ownership_type_code(row), - "pid": row["pid"], + "pid": str(row["pid"]).zfill(9) if row["pid"] is not None else None, "pin": row["pin"], "oats_subject_property_id": row["subject_property_id"], "oats_property_id": row["property_id"], diff --git a/bin/migrate-oats-data/srw/submission/primary_contact/__init__.py b/bin/migrate-oats-data/srw/submission/primary_contact/__init__.py new file mode 100644 index 0000000000..af4d087049 --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/primary_contact/__init__.py @@ -0,0 +1 @@ +from .srw_process_primary_contact import process_alcs_srw_primary_contact diff --git a/bin/migrate-oats-data/srw/submission/primary_contact/srw_process_primary_contact.py b/bin/migrate-oats-data/srw/submission/primary_contact/srw_process_primary_contact.py new file mode 100644 index 0000000000..2fdddab4b8 --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/primary_contact/srw_process_primary_contact.py @@ -0,0 +1,142 @@ +from common import BATCH_UPLOAD_SIZE, setup_and_get_logger +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "process_alcs_srw_primary_contact" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def process_alcs_srw_primary_contact(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + This function is responsible for populating the primary contact details notification_submission + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/submission/primary_contact/srw_primary_contact_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total SRW data to update: {count_total}") + + failed_inserts = 0 + successful_updates_count = 0 + last_application_id = 0 + + with open( + "srw/sql/submission/primary_contact/srw_primary_contact.sql", + "r", + encoding="utf-8", + ) as sql_file: + application_sql = sql_file.read() + while True: + cursor.execute( + f""" + {application_sql} + AND alr_application_id > {last_application_id} ORDER BY alr_application_id; + """ + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + records_to_be_updated_count = len(rows) + + _update_records(conn, batch_size, cursor, rows) + + successful_updates_count = ( + successful_updates_count + records_to_be_updated_count + ) + last_application_id = dict(rows[-1])["alr_application_id"] + + logger.debug( + f"retrieved/updated items count: {records_to_be_updated_count}; total successfully updated SRWs so far {successful_updates_count}; last updated alr_application_id: {last_application_id}" + ) + except Exception as err: + # this is NOT going to be caused by actual data update failure. This code is only executed when the code error appears or connection to DB is lost + logger.exception(err) + conn.rollback() + failed_inserts = count_total - successful_updates_count + last_application_id = last_application_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful updates {successful_updates_count}, total failed updates {failed_inserts}" + ) + + +def _update_records(conn, batch_size, cursor, rows): + parsed_data_list = _prepare_oats_data(rows) + + if len(parsed_data_list) > 0: + execute_batch( + cursor, + _update_query, + parsed_data_list, + page_size=batch_size, + ) + + conn.commit() + + +_update_query = """ + UPDATE + alcs.notification_submission + SET + contact_email = %(contact_email)s, + contact_first_name = %(contact_first_name)s, + contact_last_name= %(contact_last_name)s, + contact_organization = %(contact_organization)s, + contact_phone = %(contact_phone)s + WHERE + alcs.notification_submission.file_number = %(file_number)s::TEXT +""" + + +def _prepare_oats_data(row_data_list): + data_list = [] + for row in row_data_list: + data_list.append(_map_fields(dict(row))) + return data_list + + +def _map_fields(data): + return { + "contact_email": data["email_address"], + "contact_first_name": _get_name(data), + "contact_last_name": data["last_name"], + "contact_organization": _get_organization_name(data), + "contact_phone": data.get("phone_number", "cell_phone_number"), + "file_number": data["alr_application_id"], + } + + +def _get_organization_name(row): + organization_name = (row.get("organization_name") or "").strip() + alias_name = (row.get("alias_name") or "").strip() + + if not organization_name and not alias_name: + return row["title"] + + return f"{organization_name} {alias_name}".strip() + + +def _get_name(row): + first_name = row.get("first_name", None) + middle_name = row.get("middle_name", None) + + result = " ".join( + [name for name in (first_name, middle_name) if name is not None] + ).strip() + + return None if result == "" else result diff --git a/bin/migrate-oats-data/srw/submission/srw_proposal_fields.py b/bin/migrate-oats-data/srw/submission/srw_proposal_fields.py index 505eced71d..a5a3cd162b 100644 --- a/bin/migrate-oats-data/srw/submission/srw_proposal_fields.py +++ b/bin/migrate-oats-data/srw/submission/srw_proposal_fields.py @@ -1,4 +1,4 @@ -from common import BATCH_UPLOAD_SIZE, setup_and_get_logger +from common import BATCH_UPLOAD_SIZE, setup_and_get_logger, DEFAULT_ETL_USER_UUID from db import inject_conn_pool from psycopg2.extras import RealDictCursor, execute_batch @@ -112,7 +112,7 @@ def _prepare_oats_data(row_data_list): def _map_fields(data): return { - "created_by_uuid": data["uuid"], + "created_by_uuid": data["uuid"] if data["uuid"] else DEFAULT_ETL_USER_UUID, "has_survey_plan": data["has_survey_plan"], "purpose": data["summary"], "submitters_file_number": data["applicant_file_no"], diff --git a/bin/migrate-oats-data/srw/submission/statuses/__init__.py b/bin/migrate-oats-data/srw/submission/statuses/__init__.py new file mode 100644 index 0000000000..30d1a8eb36 --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/statuses/__init__.py @@ -0,0 +1,5 @@ +from .srw_status_base_insert import init_srw_statuses, clean_srw_submission_statuses +from .srw_status_cancelled import process_alcs_srw_cancelled_status +from .srw_status_in_progress import process_alcs_srw_in_progress_status +from .srw_status_response_sent import process_alcs_srw_response_sent_status +from .srw_status_submitted_alc import process_alcs_srw_submitted_to_alc_status diff --git a/bin/migrate-oats-data/srw/submission/statuses/srw_status_base_insert.py b/bin/migrate-oats-data/srw/submission/statuses/srw_status_base_insert.py new file mode 100644 index 0000000000..7a56a7cf2b --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/statuses/srw_status_base_insert.py @@ -0,0 +1,67 @@ +from common import OATS_ETL_USER, setup_and_get_logger, BATCH_UPLOAD_SIZE +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor + +etl_name = "init_srw_statuses" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def init_srw_statuses(conn=None): + """ + This function is responsible for initializing notification statuses. + Initializing means inserting notification_status_to_submission_status record without the effective_date. + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/submission/statuses/init_srw_status_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total Application data to insert: {count_total}") + failed_inserts_count = 0 + successful_inserts_count = 0 + last_application_id = 0 + + with open( + "srw/sql/submission/statuses/init_srw_status.sql", + "r", + encoding="utf-8", + ) as sql_file: + query = sql_file.read() + + try: + cursor.execute(query) + conn.commit() + successful_inserts_count = cursor.rowcount + except Exception as err: + logger.exception() + conn.rollback() + failed_inserts_count = count_total - successful_inserts_count + last_application_id = last_application_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful inserts {successful_inserts_count}, total failed updates {failed_inserts_count}" + ) + + +@inject_conn_pool +def clean_srw_submission_statuses(conn=None): + logger.debug("Start notification_statuses cleaning") + with conn.cursor() as cursor: + cursor.execute( + f"""DELETE FROM alcs.notification_submission_to_submission_status not_st + USING alcs.notification_submission ntss + WHERE not_st.submission_uuid = ntss.uuid AND ntss.audit_created_by = '{OATS_ETL_USER}';""" + ) + logger.info(f"Deleted items count = {cursor.rowcount}") + + conn.commit() diff --git a/bin/migrate-oats-data/srw/submission/statuses/srw_status_cancelled.py b/bin/migrate-oats-data/srw/submission/statuses/srw_status_cancelled.py new file mode 100644 index 0000000000..fa83f1c6f2 --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/statuses/srw_status_cancelled.py @@ -0,0 +1,120 @@ +from common import ( + BATCH_UPLOAD_SIZE, + setup_and_get_logger, + add_timezone_and_keep_date_part, + set_time, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "process_alcs_srw_cancelled_status" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def process_alcs_srw_cancelled_status(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + This function is responsible for populating Cancelled status of Notifications in ALCS. + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/submission/statuses/srw_cancelled_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total Notifications data to update: {count_total}") + + failed_updates_count = 0 + successful_updates_count = 0 + last_application_id = 0 + + with open( + "srw/sql/submission/statuses/srw_cancelled.sql", + "r", + encoding="utf-8", + ) as sql_file: + application_sql = sql_file.read() + while True: + cursor.execute( + f"{application_sql} AND oats_cancelled.alr_application_id > {last_application_id} ORDER BY oats_cancelled.alr_application_id;" + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + records_to_be_updated_count = len(rows) + + _update_records(conn, batch_size, cursor, rows) + + successful_updates_count = ( + successful_updates_count + records_to_be_updated_count + ) + last_application_id = dict(rows[-1])["alr_application_id"] + + logger.debug( + f"retrieved/updated items count: {records_to_be_updated_count}; total successfully updated notifications so far {successful_updates_count}; last updated alr_application_id: {last_application_id}" + ) + except Exception as err: + # this is NOT going to be caused by actual data update failure. This code is only executed when the code error appears or connection to DB is lost + logger.exception() + conn.rollback() + failed_updates_count = count_total - successful_updates_count + last_application_id = last_application_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful updates {successful_updates_count}, total failed updates {failed_updates_count}" + ) + + +def _update_records(conn, batch_size, cursor, rows): + parsed_data_list = _prepare_oats_data(rows) + + if len(parsed_data_list) > 0: + execute_batch( + cursor, + _update_query, + parsed_data_list, + page_size=batch_size, + ) + + conn.commit() + + +_update_query = """ + UPDATE alcs.notification_submission_to_submission_status + SET effective_date = %(date)s + WHERE submission_uuid = %(uuid)s and status_type_code = 'CANC' +""" + + +def _prepare_oats_data(row_data_list): + data_list = [] + for row in row_data_list: + data = _map_fields(dict(row)) + data_list.append(data) + return data_list + + +def _map_fields(data): + status_effective_date = None + data["date"] = None + + if data and data.get("min_date", None): + status_effective_date = data["min_date"] + + if status_effective_date: + date = add_timezone_and_keep_date_part(status_effective_date) + data["date"] = set_time(date) + + return data diff --git a/bin/migrate-oats-data/srw/submission/statuses/srw_status_in_progress.py b/bin/migrate-oats-data/srw/submission/statuses/srw_status_in_progress.py new file mode 100644 index 0000000000..9515eafde2 --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/statuses/srw_status_in_progress.py @@ -0,0 +1,126 @@ +from common import ( + BATCH_UPLOAD_SIZE, + setup_and_get_logger, + add_timezone_and_keep_date_part, + set_time, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "process_srw_in_progress_status" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def process_alcs_srw_in_progress_status(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + This function is responsible for populating In Progress status of Notifications in ALCS. + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/submission/statuses/srw_in_progress_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total notification data to update: {count_total}") + + failed_inserts_count = 0 + successful_updates_count = 0 + last_application_id = 0 + + with open( + "srw/sql/submission/statuses/srw_in_progress.sql", + "r", + encoding="utf-8", + ) as sql_file: + application_sql = sql_file.read() + while True: + cursor.execute( + f"{application_sql} WHERE oats_in_prog.alr_application_id > {last_application_id} ORDER BY oats_in_prog.alr_application_id;" + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + records_to_be_updated_count = len(rows) + + _update_records(conn, batch_size, cursor, rows) + + successful_updates_count = ( + successful_updates_count + records_to_be_updated_count + ) + last_application_id = dict(rows[-1])["alr_application_id"] + + logger.debug( + f"retrieved/updated items count: {records_to_be_updated_count}; total successfully updated notifications so far {successful_updates_count}; last updated alr_application_id: {last_application_id}" + ) + except Exception as err: + # this is NOT going to be caused by actual data update failure. This code is only executed when the code error appears or connection to DB is lost + logger.exception() + conn.rollback() + failed_inserts_count = count_total - successful_updates_count + last_application_id = last_application_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful updates {successful_updates_count}, total failed updates {failed_inserts_count}" + ) + + +def _update_records(conn, batch_size, cursor, rows): + parsed_data_list = _prepare_oats_data(rows) + + if len(parsed_data_list) > 0: + execute_batch( + cursor, + _update_query, + parsed_data_list, + page_size=batch_size, + ) + + conn.commit() + + +_update_query = """ + UPDATE alcs.notification_submission_to_submission_status + SET effective_date = %(date)s + WHERE submission_uuid = %(uuid)s and status_type_code = 'PROG' +""" + + +def _prepare_oats_data(row_data_list): + data_list = [] + for row in row_data_list: + data = _map_fields(dict(row)) + data_list.append(data) + return data_list + + +def _map_fields(data): + status_effective_date = None + + if data: + if data["completion_date"]: + status_effective_date = data["completion_date"] + elif data["created_date"]: + status_effective_date = data["created_date"] + elif data["submitted_to_alc_date"]: + status_effective_date = data["submitted_to_alc_date"] + else: + status_effective_date = data["when_created"] + + if status_effective_date: + date = add_timezone_and_keep_date_part(status_effective_date) + data["date"] = set_time(date) + + return data diff --git a/bin/migrate-oats-data/srw/submission/statuses/srw_status_response_sent.py b/bin/migrate-oats-data/srw/submission/statuses/srw_status_response_sent.py new file mode 100644 index 0000000000..ae9e251dba --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/statuses/srw_status_response_sent.py @@ -0,0 +1,123 @@ +from common import ( + BATCH_UPLOAD_SIZE, + setup_and_get_logger, + add_timezone_and_keep_date_part, + set_time, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "process_alcs_srw_response_sent_status" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def process_alcs_srw_response_sent_status(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + This function is responsible for populating Submitted to ALC status of Notifications in ALCS. + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/submission/statuses/srw_response_sent_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total Notification data to update: {count_total}") + + failed_inserts_count = 0 + successful_updates_count = 0 + last_application_id = 0 + + with open( + "srw/sql/submission/statuses/srw_response_sent.sql", + "r", + encoding="utf-8", + ) as sql_file: + application_sql = sql_file.read() + while True: + cursor.execute( + f"{application_sql} WHERE oaa2.alr_application_id > {last_application_id} ORDER BY oaa2.alr_application_id;" + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + records_to_be_updated_count = len(rows) + + _update_records(conn, batch_size, cursor, rows) + + successful_updates_count = ( + successful_updates_count + records_to_be_updated_count + ) + last_application_id = dict(rows[-1])["alr_application_id"] + + logger.debug( + f"retrieved/updated items count: {records_to_be_updated_count}; total successfully updated notifications so far {successful_updates_count}; last updated alr_application_id: {last_application_id}" + ) + except Exception as err: + # this is NOT going to be caused by actual data update failure. This code is only executed when the code error appears or connection to DB is lost + logger.exception(err) + conn.rollback() + failed_inserts_count = count_total - successful_updates_count + last_application_id = last_application_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful updates {successful_updates_count}, total failed updates {failed_inserts_count}" + ) + + +def _update_records(conn, batch_size, cursor, rows): + parsed_data_list = _prepare_oats_data(rows) + + if len(parsed_data_list) > 0: + execute_batch( + cursor, + _update_query, + parsed_data_list, + page_size=batch_size, + ) + + conn.commit() + + +_update_query = """ + UPDATE alcs.notification_submission_to_submission_status + SET effective_date = %(date)s + WHERE submission_uuid = %(uuid)s and status_type_code = 'ALCR' +""" + + +def _prepare_oats_data(row_data_list): + data_list = [] + for row in row_data_list: + data = _map_fields(dict(row)) + data_list.append(data) + return data_list + + +def _map_fields(data): + status_effective_date = None + data["date"] = None + + if data: + if data["email_type_code"] == "PRO_APP": + status_effective_date = data["sent_date"] + elif data["email_response_date"]: + status_effective_date = data["email_response_date"] + elif data["when_created"] and data["cancelled_date"] is None: + status_effective_date = data["when_created"] + if status_effective_date: + date = add_timezone_and_keep_date_part(status_effective_date) + data["date"] = set_time(date) + + return data diff --git a/bin/migrate-oats-data/srw/submission/statuses/srw_status_submitted_alc.py b/bin/migrate-oats-data/srw/submission/statuses/srw_status_submitted_alc.py new file mode 100644 index 0000000000..1659008968 --- /dev/null +++ b/bin/migrate-oats-data/srw/submission/statuses/srw_status_submitted_alc.py @@ -0,0 +1,118 @@ +from common import ( + BATCH_UPLOAD_SIZE, + setup_and_get_logger, + add_timezone_and_keep_date_part, + set_time, +) +from db import inject_conn_pool +from psycopg2.extras import RealDictCursor, execute_batch + +etl_name = "process_alcs_srw_submitted_to_alc_status" +logger = setup_and_get_logger(etl_name) + + +@inject_conn_pool +def process_alcs_srw_submitted_to_alc_status(conn=None, batch_size=BATCH_UPLOAD_SIZE): + """ + This function is responsible for populating Submitted to ALC status of Notifications in ALCS. + + Args: + conn (psycopg2.extensions.connection): PostgreSQL database connection. Provided by the decorator. + batch_size (int): The number of items to process at once. Defaults to BATCH_UPLOAD_SIZE. + """ + logger.info(f"Start {etl_name}") + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + with open( + "srw/sql/submission/statuses/srw_submitted_alc_count.sql", + "r", + encoding="utf-8", + ) as sql_file: + count_query = sql_file.read() + cursor.execute(count_query) + count_total = dict(cursor.fetchone())["count"] + logger.info(f"Total Notification data to update: {count_total}") + + failed_inserts_count = 0 + successful_updates_count = 0 + last_application_id = 0 + + with open( + "srw/sql/submission/statuses/srw_submitted_alc.sql", + "r", + encoding="utf-8", + ) as sql_file: + application_sql = sql_file.read() + while True: + cursor.execute( + f"{application_sql} WHERE oaa2.alr_application_id > {last_application_id} ORDER BY oaa2.alr_application_id;" + ) + + rows = cursor.fetchmany(batch_size) + + if not rows: + break + try: + records_to_be_updated_count = len(rows) + + _update_records(conn, batch_size, cursor, rows) + + successful_updates_count = ( + successful_updates_count + records_to_be_updated_count + ) + last_application_id = dict(rows[-1])["alr_application_id"] + + logger.debug( + f"retrieved/updated items count: {records_to_be_updated_count}; total successfully updated notifications so far {successful_updates_count}; last updated alr_application_id: {last_application_id}" + ) + except Exception as err: + # this is NOT going to be caused by actual data update failure. This code is only executed when the code error appears or connection to DB is lost + logger.exception(err) + conn.rollback() + failed_inserts_count = count_total - successful_updates_count + last_application_id = last_application_id + 1 + + logger.info( + f"Finished {etl_name}: total amount of successful updates {successful_updates_count}, total failed updates {failed_inserts_count}" + ) + + +def _update_records(conn, batch_size, cursor, rows): + parsed_data_list = _prepare_oats_data(rows) + + if len(parsed_data_list) > 0: + execute_batch( + cursor, + _update_query, + parsed_data_list, + page_size=batch_size, + ) + + conn.commit() + + +_update_query = """ + UPDATE alcs.notification_submission_to_submission_status + SET effective_date = %(date)s + WHERE submission_uuid = %(uuid)s and status_type_code = 'SUBM' +""" + + +def _prepare_oats_data(row_data_list): + data_list = [] + for row in row_data_list: + data = _map_fields(dict(row)) + data_list.append(data) + return data_list + + +def _map_fields(data): + status_effective_date = None + data["date"] = None + + if data and data["submitted_to_alc_date"]: + status_effective_date = data["submitted_to_alc_date"] + if status_effective_date: + date = add_timezone_and_keep_date_part(status_effective_date) + data["date"] = set_time(date) + + return data diff --git a/bin/migrate-oats-data/srw/submission/transferee/srw_init_transferee.py b/bin/migrate-oats-data/srw/submission/transferee/srw_init_transferee.py index 1e46d4f077..fe815109d0 100644 --- a/bin/migrate-oats-data/srw/submission/transferee/srw_init_transferee.py +++ b/bin/migrate-oats-data/srw/submission/transferee/srw_init_transferee.py @@ -136,7 +136,7 @@ def _get_organization_name(row): alias_name = (row.get("alias_name") or "").strip() if not organization_name and not alias_name: - return None + return row["title"] return f"{organization_name} {alias_name}".strip() @@ -145,10 +145,12 @@ def _get_name(row): first_name = row.get("first_name", None) middle_name = row.get("middle_name", None) - return " ".join( + result = " ".join( [name for name in (first_name, middle_name) if name is not None] ).strip() + return None if result == "" else result + def _map_owner_type(data): if data["organization_id"]: diff --git a/bin/migrate-oats-data/staff_journal_users/populate_staff_journal_users.py b/bin/migrate-oats-data/staff_journal_users/populate_staff_journal_users.py index 80dfbfcf23..69cbb6c127 100644 --- a/bin/migrate-oats-data/staff_journal_users/populate_staff_journal_users.py +++ b/bin/migrate-oats-data/staff_journal_users/populate_staff_journal_users.py @@ -1,11 +1,9 @@ -from common import OATS_ETL_USER, setup_and_get_logger +from common import OATS_ETL_USER, setup_and_get_logger, DEFAULT_ETL_USER_UUID from db import inject_conn_pool etl_name = "populate_default_staff_journal_user" logger = setup_and_get_logger(etl_name) -_new_oats_user_uuid = "ca8e91dc-cfb0-45c3-a443-8e47e44591df" - @inject_conn_pool def populate_default_staff_journal_user(conn=None): @@ -13,12 +11,12 @@ def populate_default_staff_journal_user(conn=None): insert_user_query = f""" INSERT INTO alcs."user" (uuid, audit_created_by,email,display_name,preferred_username,"name",given_name,family_name, identity_provider) - VALUES ('{_new_oats_user_uuid}', '{OATS_ETL_USER}','11@11','Oats ETL','Oats ETL','Oats ETL','Oats','ETL', 'etl') + VALUES ('{DEFAULT_ETL_USER_UUID}', '{OATS_ETL_USER}','11@11','Oats ETL','Oats ETL','Oats ETL','Oats','ETL', 'etl') ON CONFLICT DO NOTHING; """ set_user_to_journal_records_query = f""" UPDATE alcs.staff_journal - SET author_uuid = '{_new_oats_user_uuid}' + SET author_uuid = '{DEFAULT_ETL_USER_UUID}' WHERE author_uuid IS NULL AND staff_journal.audit_created_by = '{OATS_ETL_USER}'; """ try: @@ -44,7 +42,7 @@ def clean_staff_journal_users(conn=None): """ delete_query = f""" DELETE FROM alcs.staff_journal - WHERE uuid='{_new_oats_user_uuid}' + WHERE uuid='{DEFAULT_ETL_USER_UUID}' """ try: with conn.cursor() as cursor: diff --git a/e2e/.editorconfig b/e2e/.editorconfig new file mode 100644 index 0000000000..59d9a3a3e7 --- /dev/null +++ b/e2e/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000000..874208c189 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +.env +test-results/ diff --git a/e2e/.prettierrc b/e2e/.prettierrc new file mode 100644 index 0000000000..8d3dfb047c --- /dev/null +++ b/e2e/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 120, + "singleQuote": true, + "useTabs": false, + "tabWidth": 2, + "semi": true, + "bracketSpacing": true +} diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000000..86f069031d --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,87 @@ +# End-to-End Testing + +- [Writing Tests](#writing-tests) +- [Running Tests](#running-tests) +- [Local Setup](#local-setup) + - [Installation](#installation) + - [Configure secrets](#configure-secrets) + +E2E test automation is implemented using the [Playwright](https://playwright.dev/). + +> [!WARNING] +> When writing tests, make sure they do not contain any credentials _before_ committing to the repo. + +## Writing Tests + +- Write tests for a given project, i.e., tests for the portal go in `/e2e/tests/portal`. + +## Running Tests + +To run tests: + +```bash +$ npx playwright test +``` + +To run tests just for a specific browser: + +```bash +$ npx playwright test --project=[chromium] +``` + +To run tests just for a specific frontend, specify by directory: + +```bash +$ npx playwright test portal/ +``` + +These can be combined: + +````bash +$ npx playwright test --project=chromium portal/ +``` + +To run headed: + +```bash +$ npx playwright test --headed +```` + +To run in UI mode: + +```bash +$ npx playwright test --ui +``` + +To run in debug mode: + +```bash +$ npx playwright test --debug +``` + +To show a report: + +```bash +$ npx playwright show-report REPORT_DIR +``` + +## Local Setup + +### Installation + +Install package: + +```bash +$ npm i +``` + +Install browsers: + +```bash +$ npx playwright install +``` + +### Configure secrets + +1. Copy `template.env` --> `.env` +2. Fill in details diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000000..a1a0c57dfd --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,2971 @@ +{ + "name": "alcs-e2e-test", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "alcs-e2e-test", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@playwright/test": "^1.32.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "prettier": "^3.2.5" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@playwright/test": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", + "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", + "dependencies": { + "@types/node": "*", + "playwright-core": "1.32.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.11.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", + "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz", + "integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/type-utils": "7.2.0", + "@typescript-eslint/utils": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz", + "integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/utils": "7.2.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", + "integrity": "sha512-Z9Ij17X5Z3bjpp6XKujGBp9Gv4eViESac9aDmwgQFUEJBW0K80T21m/Z+XJQlu4cNsvPygw33b6V1Va6Bda5zQ==", + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + }, + "dependencies": { + "@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true + }, + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + } + }, + "@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true + }, + "@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + } + }, + "@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true + }, + "@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/object-schema": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "dev": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true + }, + "@playwright/test": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", + "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", + "requires": { + "@types/node": "*", + "fsevents": "2.3.2", + "playwright-core": "1.32.0" + } + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/node": { + "version": "20.11.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", + "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", + "requires": { + "undici-types": "~5.26.4" + } + }, + "@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "@typescript-eslint/eslint-plugin": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz", + "integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/type-utils": "7.2.0", + "@typescript-eslint/utils": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + } + }, + "@typescript-eslint/parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4" + } + }, + "@typescript-eslint/scope-manager": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" + } + }, + "@typescript-eslint/type-utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz", + "integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/utils": "7.2.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + } + }, + "@typescript-eslint/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "@typescript-eslint/utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "semver": "^7.5.4" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.2.0", + "eslint-visitor-keys": "^3.4.1" + } + }, + "@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + } + }, + "eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "requires": {} + }, + "eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + } + }, + "eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + }, + "espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "requires": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + } + }, + "esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "requires": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "optional": true + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "requires": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "playwright-core": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", + "integrity": "sha512-Z9Ij17X5Z3bjpp6XKujGBp9Gv4eViESac9aDmwgQFUEJBW0K80T21m/Z+XJQlu4cNsvPygw33b6V1Va6Bda5zQ==" + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true + }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "requires": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "requires": {} + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "typescript": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "dev": true, + "peer": true + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/portal-frontend/test/package.json b/e2e/package.json similarity index 63% rename from portal-frontend/test/package.json rename to e2e/package.json index 4140bd1006..93a3bc609d 100644 --- a/portal-frontend/test/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { - "name": "alcs", - "version": "1.0.0", + "name": "alcs-e2e-test", + "version": "0.1.0", "description": "[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) ![Lifecycle:Experimental](https://img.shields.io/badge/Lifecycle-Experimental-339999) [![codecov](https://img.shields.io/codeclimate/coverage/bcgov/alcs)](https://codeclimate.com/github/bcgov/alcs)", "main": "index.js", "directories": { @@ -18,7 +18,17 @@ "url": "https://github.com/bcgov/alcs/issues" }, "homepage": "https://github.com/bcgov/alcs#readme", + "dependencies": { + "@playwright/test": "^1.32.0", + "dotenv": "^16.4.5" + }, "devDependencies": { - "@playwright/test": "^1.32.0" + "@types/node": "^20.11.24", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "prettier": "^3.2.5" } } diff --git a/portal-frontend/test/playwright.config.ts b/e2e/playwright.config.ts similarity index 98% rename from portal-frontend/test/playwright.config.ts rename to e2e/playwright.config.ts index cdcccfe688..bb85faf430 100644 --- a/portal-frontend/test/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; +import 'dotenv/config'; /** * Read environment variables from file. @@ -36,12 +37,10 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, - { name: 'webkit', use: { ...devices['Desktop Safari'] }, diff --git a/e2e/template.env b/e2e/template.env new file mode 100644 index 0000000000..9feb6fb1f4 --- /dev/null +++ b/e2e/template.env @@ -0,0 +1,3 @@ +PORTAL_BASE_URL= +BCEID_BASIC_USERNAME= +BCEID_BASIC_PASSWORD= diff --git a/e2e/tests/portal/fixtures.ts b/e2e/tests/portal/fixtures.ts new file mode 100644 index 0000000000..b171c33a7f --- /dev/null +++ b/e2e/tests/portal/fixtures.ts @@ -0,0 +1,30 @@ +import { test as base, Page } from '@playwright/test'; +export { expect } from '@playwright/test'; + +export enum UserPrefix { + BceidBasic = 'BCEID_BASIC', +} + +interface FixtureOptions { + userPrefix: string; +} + +interface Fixtures { + inboxLoggedIn: Page; +} + +export const test = base.extend<FixtureOptions & Fixtures>({ + userPrefix: UserPrefix.BceidBasic, + inboxLoggedIn: async ({ page, userPrefix }, use) => { + await page.goto(process.env.PORTAL_BASE_URL); + await page.getByRole('button', { name: 'Portal Login' }).click(); + await page + .locator('#user') + .fill(process.env[userPrefix + '_USERNAME'] ?? ''); + await page + .getByLabel('Password') + .fill(process.env[userPrefix + '_PASSWORD'] ?? ''); + await page.getByRole('button', { name: /continue/i }).click(); + await use(page); + }, +}); diff --git a/e2e/tests/portal/login.spec.ts b/e2e/tests/portal/login.spec.ts new file mode 100644 index 0000000000..9689e5c839 --- /dev/null +++ b/e2e/tests/portal/login.spec.ts @@ -0,0 +1,9 @@ +import { test, expect, UserPrefix } from './fixtures'; + +test.use({ userPrefix: UserPrefix.BceidBasic }); + +test('test', async ({ inboxLoggedIn }) => { + await expect( + inboxLoggedIn.getByRole('heading', { name: 'Portal Inbox' }) + ).toBeVisible(); +}); diff --git a/portal-frontend/src/app/features/applications/application-details/application-details.component.ts b/portal-frontend/src/app/features/applications/application-details/application-details.component.ts index fb44fa59bf..5d832e1e89 100644 --- a/portal-frontend/src/app/features/applications/application-details/application-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/application-details.component.ts @@ -7,13 +7,13 @@ import { ApplicationOwnerDetailedDto, ApplicationOwnerDto, } from '../../../services/application-owner/application-owner.dto'; -import { PARCEL_OWNERSHIP_TYPE } from '../../../services/application-parcel/application-parcel.dto'; import { ApplicationParcelService } from '../../../services/application-parcel/application-parcel.service'; import { ApplicationSubmissionDetailedDto } from '../../../services/application-submission/application-submission.dto'; import { LocalGovernmentDto } from '../../../services/code/code.dto'; import { CodeService } from '../../../services/code/code.service'; import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { OWNER_TYPE } from '../../../shared/dto/owner.dto'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-application-details', @@ -85,7 +85,9 @@ export class ApplicationDetailsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } async onNavigateToStep(step: number) { diff --git a/portal-frontend/src/app/features/applications/application-details/cove-details/cove-details.component.ts b/portal-frontend/src/app/features/applications/application-details/cove-details/cove-details.component.ts index c53b94252c..26eb309cfc 100644 --- a/portal-frontend/src/app/features/applications/application-details/cove-details/cove-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/cove-details/cove-details.component.ts @@ -6,6 +6,7 @@ import { ApplicationSubmissionDetailedDto } from '../../../../services/applicati import { CovenantTransfereeDto } from '../../../../services/covenant-transferee/covenant-transferee.dto'; import { CovenantTransfereeService } from '../../../../services/covenant-transferee/covenant-transferee.service'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-cove-details', @@ -53,7 +54,9 @@ export class CoveDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } private async loadTransferees(uuid: string) { diff --git a/portal-frontend/src/app/features/applications/application-details/excl-details/excl-details.component.ts b/portal-frontend/src/app/features/applications/application-details/excl-details/excl-details.component.ts index 3ca125515e..ccae72c74b 100644 --- a/portal-frontend/src/app/features/applications/application-details/excl-details/excl-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/excl-details/excl-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentService } from '../../../../services/application-doc import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { ApplicationDocumentDto } from '../../../../services/application-document/application-document.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-excl-details', @@ -53,6 +54,8 @@ export class ExclDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/applications/application-details/incl-details/incl-details.component.ts b/portal-frontend/src/app/features/applications/application-details/incl-details/incl-details.component.ts index b303ec6af8..cda17dbd0d 100644 --- a/portal-frontend/src/app/features/applications/application-details/incl-details/incl-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/incl-details/incl-details.component.ts @@ -6,6 +6,7 @@ import { ApplicationSubmissionDetailedDto } from '../../../../services/applicati import { ApplicationDocumentDto } from '../../../../services/application-document/application-document.dto'; import { AuthenticationService } from '../../../../services/authentication/authentication.service'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-incl-details', @@ -73,7 +74,9 @@ export class InclDetailsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } ngOnDestroy(): void { diff --git a/portal-frontend/src/app/features/applications/application-details/naru-details/naru-details.component.ts b/portal-frontend/src/app/features/applications/application-details/naru-details/naru-details.component.ts index 104949081f..fc5e952eb0 100644 --- a/portal-frontend/src/app/features/applications/application-details/naru-details/naru-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/naru-details/naru-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentDto } from '../../../../services/application-documen import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-naru-details[applicationSubmission]', @@ -43,6 +44,8 @@ export class NaruDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/applications/application-details/nfu-details/nfu-details.component.ts b/portal-frontend/src/app/features/applications/application-details/nfu-details/nfu-details.component.ts index 8284bf74e9..4e9fdd0233 100644 --- a/portal-frontend/src/app/features/applications/application-details/nfu-details/nfu-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/nfu-details/nfu-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentDto } from '../../../../services/application-documen import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-nfu-details[applicationSubmission]', @@ -35,6 +36,8 @@ export class NfuDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.ts b/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.ts index af04c7693d..a588acd793 100644 --- a/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/parcel/parcel.component.ts @@ -4,7 +4,6 @@ import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; import { ApplicationDocumentDto } from '../../../../services/application-document/application-document.dto'; import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationOwnerDto } from '../../../../services/application-owner/application-owner.dto'; -import { ApplicationOwnerService } from '../../../../services/application-owner/application-owner.service'; import { ApplicationParcelDto, ApplicationParcelUpdateDto, @@ -14,6 +13,7 @@ import { ApplicationParcelService } from '../../../../services/application-parce import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { BaseCodeDto } from '../../../../shared/dto/base.dto'; import { formatBooleanToYesNoString } from '../../../../shared/utils/boolean-helper'; +import { openFileIframe } from '../../../../shared/utils/file'; export class ApplicationParcelBasicValidation { // indicates general validity check state, including owner related information @@ -101,7 +101,7 @@ export class ParcelComponent { async onOpenFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/applications/application-details/pfrs-details/pfrs-details.component.ts b/portal-frontend/src/app/features/applications/application-details/pfrs-details/pfrs-details.component.ts index f60af81230..9cc2770c6d 100644 --- a/portal-frontend/src/app/features/applications/application-details/pfrs-details/pfrs-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/pfrs-details/pfrs-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentDto } from '../../../../services/application-documen import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-pfrs-details[applicationSubmission]', @@ -49,6 +50,8 @@ export class PfrsDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/applications/application-details/pofo-details/pofo-details.component.ts b/portal-frontend/src/app/features/applications/application-details/pofo-details/pofo-details.component.ts index 9e2c20112c..274f2ad0fe 100644 --- a/portal-frontend/src/app/features/applications/application-details/pofo-details/pofo-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/pofo-details/pofo-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentDto } from '../../../../services/application-documen import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-pofo-details[applicationSubmission]', @@ -47,6 +48,8 @@ export class PofoDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/applications/application-details/roso-details/roso-details.component.ts b/portal-frontend/src/app/features/applications/application-details/roso-details/roso-details.component.ts index f543cfddf2..209bf3f994 100644 --- a/portal-frontend/src/app/features/applications/application-details/roso-details/roso-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/roso-details/roso-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentDto } from '../../../../services/application-documen import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-roso-details[applicationSubmission]', @@ -47,6 +48,8 @@ export class RosoDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/applications/application-details/subd-details/subd-details.component.ts b/portal-frontend/src/app/features/applications/application-details/subd-details/subd-details.component.ts index 62703a504e..9718f187ff 100644 --- a/portal-frontend/src/app/features/applications/application-details/subd-details/subd-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/subd-details/subd-details.component.ts @@ -5,6 +5,7 @@ import { ApplicationDocumentService } from '../../../../services/application-doc import { ApplicationParcelService } from '../../../../services/application-parcel/application-parcel.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-subd-details[applicationSubmission]', @@ -57,7 +58,9 @@ export class SubdDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } private async loadParcels(fileNumber: string) { diff --git a/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.ts b/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.ts index 229dab9f16..5f96e46a8e 100644 --- a/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.ts +++ b/portal-frontend/src/app/features/applications/application-details/tur-details/tur-details.component.ts @@ -4,6 +4,7 @@ import { ApplicationDocumentDto } from '../../../../services/application-documen import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; import { ApplicationSubmissionDetailedDto } from '../../../../services/application-submission/application-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-tur-details[applicationSubmission]', @@ -44,6 +45,8 @@ export class TurDetailsComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } 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 47f65a2227..6f26e899ba 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 @@ -9,6 +9,7 @@ import { DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { FileHandle } from '../../../shared/file-drag-drop/drag-drop.directive'; import { RemoveFileConfirmationDialogComponent } from '../alcs-edit-submission/remove-file-confirmation-dialog/remove-file-confirmation-dialog.component'; import { StepComponent } from './step.partial'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-file-step', @@ -85,7 +86,7 @@ export abstract class FilesStepComponent extends StepComponent { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } } 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 3620251972..0842b8d691 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 @@ -21,6 +21,7 @@ import { OwnerDialogComponent } from '../../../../../shared/owner-dialogs/owner- import { formatBooleanToString } from '../../../../../shared/utils/boolean-helper'; 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 { openFileIframe } from '../../../../../shared/utils/file'; export interface ParcelEntryFormData { uuid: string; @@ -345,7 +346,7 @@ export class ParcelEntryComponent implements OnInit { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } 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 86777d905d..9738bc07bd 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 @@ -1,5 +1,4 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { Router } from '@angular/router'; import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; import { ApplicationDocumentDto } from '../../../../services/application-document/application-document.dto'; import { ApplicationDocumentService } from '../../../../services/application-document/application-document.service'; @@ -8,6 +7,7 @@ import { ToastService } from '../../../../services/toast/toast.service'; import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; import { FileHandle } from '../../../../shared/file-drag-drop/drag-drop.directive'; import { ReviewApplicationFngSteps, ReviewApplicationSteps } from '../review-submission.component'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-review-attachments', @@ -128,7 +128,7 @@ export class ReviewAttachmentsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/applications/review-submission/review-submit-fng/review-submit-fng.component.ts b/portal-frontend/src/app/features/applications/review-submission/review-submit-fng/review-submit-fng.component.ts index 43544526ec..d151847d50 100644 --- a/portal-frontend/src/app/features/applications/review-submission/review-submit-fng/review-submit-fng.component.ts +++ b/portal-frontend/src/app/features/applications/review-submission/review-submit-fng/review-submit-fng.component.ts @@ -16,6 +16,7 @@ import { MOBILE_BREAKPOINT } from '../../../../shared/utils/breakpoints'; import { ReviewApplicationFngSteps } from '../review-submission.component'; import { ToastService } from '../../../../services/toast/toast.service'; import { SubmitConfirmationDialogComponent } from '../submit-confirmation-dialog/submit-confirmation-dialog.component'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-review-submit-fng[stepper]', @@ -123,7 +124,7 @@ export class ReviewSubmitFngComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/applications/review-submission/review-submit/review-submit.component.ts b/portal-frontend/src/app/features/applications/review-submission/review-submit/review-submit.component.ts index 971eeb4943..36cb5a6718 100644 --- a/portal-frontend/src/app/features/applications/review-submission/review-submit/review-submit.component.ts +++ b/portal-frontend/src/app/features/applications/review-submission/review-submit/review-submit.component.ts @@ -15,6 +15,7 @@ import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../../shared/dto/document. import { MOBILE_BREAKPOINT } from '../../../../shared/utils/breakpoints'; import { ReviewApplicationSteps } from '../review-submission.component'; import { SubmitConfirmationDialogComponent } from '../submit-confirmation-dialog/submit-confirmation-dialog.component'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-review-submit[stepper]', @@ -131,7 +132,7 @@ export class ReviewSubmitComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/applications/view-submission/alc-review/submission-documents/submission-documents.component.ts b/portal-frontend/src/app/features/applications/view-submission/alc-review/submission-documents/submission-documents.component.ts index 787f560c25..679fb8aa43 100644 --- a/portal-frontend/src/app/features/applications/view-submission/alc-review/submission-documents/submission-documents.component.ts +++ b/portal-frontend/src/app/features/applications/view-submission/alc-review/submission-documents/submission-documents.component.ts @@ -4,6 +4,7 @@ import { MatTableDataSource } from '@angular/material/table'; import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; import { ApplicationDocumentDto } from '../../../../../services/application-document/application-document.dto'; import { ApplicationDocumentService } from '../../../../../services/application-document/application-document.service'; +import { openFileIframe } from '../../../../../shared/utils/file'; @Component({ selector: 'app-submission-documents', @@ -32,7 +33,7 @@ export class SubmissionDocumentsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/applications/view-submission/lfng-review/lfng-review.component.ts b/portal-frontend/src/app/features/applications/view-submission/lfng-review/lfng-review.component.ts index a7b09050cb..ac6a36d33f 100644 --- a/portal-frontend/src/app/features/applications/view-submission/lfng-review/lfng-review.component.ts +++ b/portal-frontend/src/app/features/applications/view-submission/lfng-review/lfng-review.component.ts @@ -11,6 +11,7 @@ import { } from '../../../../services/application-submission/application-submission.dto'; import { PdfGenerationService } from '../../../../services/pdf-generation/pdf-generation.service'; import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-lfng-review', @@ -40,7 +41,7 @@ export class LfngReviewComponent implements OnInit, OnDestroy { private applicationReviewService: ApplicationSubmissionReviewService, private pdfGenerationService: PdfGenerationService, private applicationDocumentService: ApplicationDocumentService, - private router: Router, + private router: Router ) {} ngOnInit(): void { @@ -69,7 +70,7 @@ export class LfngReviewComponent implements OnInit, OnDestroy { this.$application.pipe(takeUntil(this.$destroy)).subscribe((application) => { this.application = application; this.submittedToAlcStatus = !!this.application?.submissionStatuses.find( - (s) => s.statusTypeCode === SUBMISSION_STATUS.SUBMITTED_TO_ALC && !!s.effectiveDate, + (s) => s.statusTypeCode === SUBMISSION_STATUS.SUBMITTED_TO_ALC && !!s.effectiveDate ); this.isTurOrCov = this.application?.typeCode === 'COVE' || this.application?.typeCode === 'TURP'; this.loadReview(); @@ -78,10 +79,10 @@ export class LfngReviewComponent implements OnInit, OnDestroy { this.$applicationDocuments.subscribe((documents) => { this.staffReport = documents.filter((document) => document.type?.code === DOCUMENT_TYPE.STAFF_REPORT); this.resolutionDocument = documents.filter( - (document) => document.type?.code === DOCUMENT_TYPE.RESOLUTION_DOCUMENT, + (document) => document.type?.code === DOCUMENT_TYPE.RESOLUTION_DOCUMENT ); this.governmentOtherAttachments = 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 ); }); } @@ -109,7 +110,7 @@ export class LfngReviewComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.ts b/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.ts index 0d214beaf0..a08e1fcaf1 100644 --- a/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.ts +++ b/portal-frontend/src/app/features/applications/view-submission/view-application-submission.component.ts @@ -12,6 +12,7 @@ import { import { ApplicationSubmissionService } from '../../../services/application-submission/application-submission.service'; import { PdfGenerationService } from '../../../services/pdf-generation/pdf-generation.service'; import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-view-application-submission', @@ -96,7 +97,7 @@ export class ViewApplicationSubmissionComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.applicationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } 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 e177bdf537..a407e19b5a 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 @@ -9,6 +9,7 @@ import { DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { FileHandle } from '../../../shared/file-drag-drop/drag-drop.directive'; import { RemoveFileConfirmationDialogComponent } from '../../applications/alcs-edit-submission/remove-file-confirmation-dialog/remove-file-confirmation-dialog.component'; import { StepComponent } from './step.partial'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-file-step', @@ -85,7 +86,7 @@ export abstract class FilesStepComponent extends StepComponent { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } } 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 ad45e840e0..bcbbe6636d 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 @@ -20,6 +20,7 @@ import { formatBooleanToString } from '../../../../../shared/utils/boolean-helpe 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 { scrollToElement } from '../../../../../shared/utils/scroll-helper'; +import { openFileIframe } from '../../../../../shared/utils/file'; export interface ParcelEntryFormData { uuid: string; @@ -343,7 +344,7 @@ export class ParcelEntryComponent implements OnInit { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/additional-information/additional-information.component.ts b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/additional-information/additional-information.component.ts index 680cd87210..f0f313b4b6 100644 --- a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/additional-information/additional-information.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/additional-information/additional-information.component.ts @@ -8,6 +8,7 @@ import { RESIDENTIAL_STRUCTURE_TYPES, STRUCTURE_TYPES, } from '../../edit-submission/additional-information/additional-information.component'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-additional-information', @@ -101,6 +102,8 @@ export class AdditionalInformationComponent { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/notice-of-intent-details.component.ts b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/notice-of-intent-details.component.ts index dc207c8289..ccf3768864 100644 --- a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/notice-of-intent-details.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/notice-of-intent-details.component.ts @@ -10,6 +10,7 @@ import { NoticeOfIntentParcelService } from '../../../services/notice-of-intent- import { NoticeOfIntentSubmissionDetailedDto } from '../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { OWNER_TYPE } from '../../../shared/dto/owner.dto'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-noi-details', @@ -83,7 +84,9 @@ export class NoticeOfIntentDetailsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } async onNavigateToStep(step: number) { diff --git a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/parcel/parcel.component.ts b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/parcel/parcel.component.ts index accce22e7d..aa4fa84fd4 100644 --- a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/parcel/parcel.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/parcel/parcel.component.ts @@ -14,6 +14,7 @@ import { NoticeOfIntentParcelService } from '../../../../services/notice-of-inte import { NoticeOfIntentSubmissionDetailedDto } from '../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { BaseCodeDto } from '../../../../shared/dto/base.dto'; import { formatBooleanToYesNoString } from '../../../../shared/utils/boolean-helper'; +import { openFileIframe } from '../../../../shared/utils/file'; export class NoticeOfIntentParcelBasicValidation { // indicates general validity check state, including owner related information @@ -99,7 +100,7 @@ export class ParcelComponent { async onOpenFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pfrs-details/pfrs-details.component.ts b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pfrs-details/pfrs-details.component.ts index d4bf554748..1729791499 100644 --- a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pfrs-details/pfrs-details.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pfrs-details/pfrs-details.component.ts @@ -4,6 +4,7 @@ import { NoticeOfIntentDocumentDto } from '../../../../services/notice-of-intent import { NoticeOfIntentDocumentService } from '../../../../services/notice-of-intent-document/notice-of-intent-document.service'; import { NoticeOfIntentSubmissionDetailedDto } from '../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-pfrs-details[noiSubmission]', @@ -49,6 +50,8 @@ export class PfrsDetailsComponent { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pofo-details/pofo-details.component.ts b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pofo-details/pofo-details.component.ts index 115a9bcaeb..bda8999026 100644 --- a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pofo-details/pofo-details.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/pofo-details/pofo-details.component.ts @@ -4,6 +4,7 @@ import { NoticeOfIntentDocumentDto } from '../../../../services/notice-of-intent import { NoticeOfIntentDocumentService } from '../../../../services/notice-of-intent-document/notice-of-intent-document.service'; import { NoticeOfIntentSubmissionDetailedDto } from '../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-pofo-details[noiSubmission]', @@ -49,6 +50,8 @@ export class PofoDetailsComponent { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/roso-details/roso-details.component.ts b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/roso-details/roso-details.component.ts index 4b352feab2..4edd97841c 100644 --- a/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/roso-details/roso-details.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/notice-of-intent-details/roso-details/roso-details.component.ts @@ -4,6 +4,7 @@ import { NoticeOfIntentDocumentDto } from '../../../../services/notice-of-intent import { NoticeOfIntentDocumentService } from '../../../../services/notice-of-intent-document/notice-of-intent-document.service'; import { NoticeOfIntentSubmissionDetailedDto } from '../../../../services/notice-of-intent-submission/notice-of-intent-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-roso-details[noiSubmission]', @@ -49,6 +50,8 @@ export class RosoDetailsComponent { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/notice-of-intents/view-submission/alc-review/submission-documents/submission-documents.component.ts b/portal-frontend/src/app/features/notice-of-intents/view-submission/alc-review/submission-documents/submission-documents.component.ts index 382a65b85a..0fb70ef5e1 100644 --- a/portal-frontend/src/app/features/notice-of-intents/view-submission/alc-review/submission-documents/submission-documents.component.ts +++ b/portal-frontend/src/app/features/notice-of-intents/view-submission/alc-review/submission-documents/submission-documents.component.ts @@ -4,6 +4,7 @@ import { MatTableDataSource } from '@angular/material/table'; import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; import { NoticeOfIntentDocumentDto } from '../../../../../services/notice-of-intent-document/notice-of-intent-document.dto'; import { NoticeOfIntentDocumentService } from '../../../../../services/notice-of-intent-document/notice-of-intent-document.service'; +import { openFileIframe } from '../../../../../shared/utils/file'; @Component({ selector: 'app-submission-documents', @@ -32,7 +33,7 @@ export class SubmissionDocumentsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.noticeOfIntentDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } 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 2a1fcd1328..817b3a513f 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 @@ -9,6 +9,7 @@ import { ToastService } from '../../../services/toast/toast.service'; import { DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { FileHandle } from '../../../shared/file-drag-drop/drag-drop.directive'; import { StepComponent } from './step.partial'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-file-step', @@ -71,7 +72,7 @@ export abstract class FilesStepComponent extends StepComponent { async openFile(uuid: string) { const res = await this.notificationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } } diff --git a/portal-frontend/src/app/features/notifications/notification-details/notification-details.component.ts b/portal-frontend/src/app/features/notifications/notification-details/notification-details.component.ts index f21c0a467d..75155e4402 100644 --- a/portal-frontend/src/app/features/notifications/notification-details/notification-details.component.ts +++ b/portal-frontend/src/app/features/notifications/notification-details/notification-details.component.ts @@ -8,6 +8,7 @@ import { NotificationDocumentService } from '../../../services/notification-docu import { NotificationSubmissionDetailedDto } from '../../../services/notification-submission/notification-submission.dto'; import { DOCUMENT_SOURCE, DOCUMENT_TYPE } from '../../../shared/dto/document.dto'; import { OWNER_TYPE } from '../../../shared/dto/owner.dto'; +import { openFileIframe } from '../../../shared/utils/file'; @Component({ selector: 'app-notification-details', @@ -65,7 +66,9 @@ export class NotificationDetailsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.notificationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } async onNavigateToStep(step: number) { diff --git a/portal-frontend/src/app/features/notifications/notification-details/proposal-details/proposal-details.component.ts b/portal-frontend/src/app/features/notifications/notification-details/proposal-details/proposal-details.component.ts index 457e1cf731..348c6a7024 100644 --- a/portal-frontend/src/app/features/notifications/notification-details/proposal-details/proposal-details.component.ts +++ b/portal-frontend/src/app/features/notifications/notification-details/proposal-details/proposal-details.component.ts @@ -4,6 +4,7 @@ import { NotificationDocumentDto } from '../../../../services/notification-docum import { NotificationDocumentService } from '../../../../services/notification-document/notification-document.service'; import { NotificationSubmissionDetailedDto } from '../../../../services/notification-submission/notification-submission.dto'; import { DOCUMENT_TYPE } from '../../../../shared/dto/document.dto'; +import { openFileIframe } from '../../../../shared/utils/file'; @Component({ selector: 'app-proposal-details[notificationSubmission]', @@ -38,6 +39,8 @@ export class ProposalDetailsComponent { async openFile(uuid: string) { const res = await this.notificationDocumentService.openFile(uuid); - window.open(res?.url, '_blank'); + if (res) { + openFileIframe(res); + } } } diff --git a/portal-frontend/src/app/features/notifications/view-submission/alc-review/submission-documents/submission-documents.component.ts b/portal-frontend/src/app/features/notifications/view-submission/alc-review/submission-documents/submission-documents.component.ts index 2088483b40..505cf75369 100644 --- a/portal-frontend/src/app/features/notifications/view-submission/alc-review/submission-documents/submission-documents.component.ts +++ b/portal-frontend/src/app/features/notifications/view-submission/alc-review/submission-documents/submission-documents.component.ts @@ -4,6 +4,7 @@ import { MatTableDataSource } from '@angular/material/table'; import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; import { NotificationDocumentDto } from '../../../../../services/notification-document/notification-document.dto'; import { NotificationDocumentService } from '../../../../../services/notification-document/notification-document.service'; +import { openFileIframe } from '../../../../../shared/utils/file'; @Component({ selector: 'app-submission-documents', @@ -32,7 +33,7 @@ export class SubmissionDocumentsComponent implements OnInit, OnDestroy { async openFile(uuid: string) { const res = await this.notificationDocumentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } diff --git a/portal-frontend/src/app/features/public/application/alc-review/decisions/decisions.component.ts b/portal-frontend/src/app/features/public/application/alc-review/decisions/decisions.component.ts index ebf506417a..2210cea9c3 100644 --- a/portal-frontend/src/app/features/public/application/alc-review/decisions/decisions.component.ts +++ b/portal-frontend/src/app/features/public/application/alc-review/decisions/decisions.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { ApplicationPortalDecisionDto } from '../../../../../services/application-decision/application-decision.dto'; import { ApplicationDecisionService } from '../../../../../services/application-decision/application-decision.service'; diff --git a/portal-frontend/src/app/features/public/notice-of-intent/alc-review/decisions/decisions.component.ts b/portal-frontend/src/app/features/public/notice-of-intent/alc-review/decisions/decisions.component.ts index a8b190adcd..32abf6af5e 100644 --- a/portal-frontend/src/app/features/public/notice-of-intent/alc-review/decisions/decisions.component.ts +++ b/portal-frontend/src/app/features/public/notice-of-intent/alc-review/decisions/decisions.component.ts @@ -1,6 +1,4 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; -import { ApplicationPortalDecisionDto } from '../../../../../services/application-decision/application-decision.dto'; -import { ApplicationDecisionService } from '../../../../../services/application-decision/application-decision.service'; +import { Component, Input } from '@angular/core'; import { NoticeOfIntentPortalDecisionDto } from '../../../../../services/notice-of-intent-decision/notice-of-intent-decision.dto'; import { NoticeOfIntentDecisionService } from '../../../../../services/notice-of-intent-decision/notice-of-intent-decision.service'; diff --git a/portal-frontend/src/app/features/public/search/public-search.component.html b/portal-frontend/src/app/features/public/search/public-search.component.html index 7db89e1d2d..341a983c1b 100644 --- a/portal-frontend/src/app/features/public/search/public-search.component.html +++ b/portal-frontend/src/app/features/public/search/public-search.component.html @@ -127,7 +127,7 @@ <h3>Search by one or more of the following fields:</h3> <div class="button-controls"> <button type="button" mat-stroked-button color="accent" (click)="onClear()">Clear</button> - <button type="submit" mat-flat-button color="primary" [disabled]="formEmpty || !searchForm.valid">Search</button> + <button type="submit" mat-flat-button color="primary" [disabled]="formEmpty || !searchForm.valid || isLoading">Search</button> </div> </form> @@ -135,7 +135,7 @@ <h3>Search by one or more of the following fields:</h3> <mat-spinner></mat-spinner> </div> <div class="search-fields-wrapper search-result-wrapper" *ngIf="!searchResultsHidden"> - <h3 class="search-title">Search Results</h3> + <h3 class="search-title" id="searchResults">Search Results</h3> <div *ngIf="isLoading && !searchResultsHidden" class="center"> <mat-spinner></mat-spinner> </div> diff --git a/portal-frontend/src/app/features/public/search/public-search.component.ts b/portal-frontend/src/app/features/public/search/public-search.component.ts index 0c83cc7057..9848c8f051 100644 --- a/portal-frontend/src/app/features/public/search/public-search.component.ts +++ b/portal-frontend/src/app/features/public/search/public-search.component.ts @@ -22,6 +22,7 @@ import { ToastService } from '../../../services/toast/toast.service'; import { MOBILE_BREAKPOINT } from '../../../shared/utils/breakpoints'; import { FileTypeFilterDropDownComponent } from './file-type-filter-drop-down/file-type-filter-drop-down.component'; import { TableChange } from './search.interface'; +import { scrollToElement } from '../../../shared/utils/scroll-helper'; const STATUS_MAP = { 'Received by ALC': 'RECA', @@ -193,6 +194,7 @@ export class PublicSearchComponent implements OnInit, OnDestroy { sessionStorage.setItem(SEARCH_SESSION_STORAGE_KEY, searchDto); this.isLoading = true; + scrollToElement({ id: `searchResults`, center: false }); const result = await this.searchService.search(searchParams); this.searchResultsHidden = false; this.isLoading = false; 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..e5dd88c9ae 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 @@ -51,7 +51,9 @@ export class ApplicationDocumentService { async openFile(fileUuid: string) { try { - return await firstValueFrom(this.httpClient.get<{ url: string }>(`${this.serviceUrl}/${fileUuid}/open`)); + return await firstValueFrom( + this.httpClient.get<{ url: string; fileName: string }>(`${this.serviceUrl}/${fileUuid}/open`) + ); } catch (e) { console.error(e); this.toastService.showErrorToast('Failed to open the document, please try again'); diff --git a/portal-frontend/src/app/services/authentication/authentication.service.ts b/portal-frontend/src/app/services/authentication/authentication.service.ts index cb5f636e2c..8b7c9dc393 100644 --- a/portal-frontend/src/app/services/authentication/authentication.service.ts +++ b/portal-frontend/src/app/services/authentication/authentication.service.ts @@ -79,13 +79,12 @@ export class AuthenticationService { async refreshTokens(redirect = true) { if (this.refreshToken) { - if (this.expires && this.expires < Date.now()) { + if (this.refreshExpires && this.refreshExpires < Date.now()) { if (redirect) { await this.router.navigateByUrl('/login'); } return; } - const newTokens = await this.getNewTokens(this.refreshToken); await this.setTokens(newTokens.token, newTokens.refresh_token); } 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..1860044eb3 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 @@ -51,7 +51,9 @@ export class NoticeOfIntentDocumentService { async openFile(fileUuid: string) { try { - return await firstValueFrom(this.httpClient.get<{ url: string }>(`${this.serviceUrl}/${fileUuid}/open`)); + return await firstValueFrom( + this.httpClient.get<{ url: string; fileName: string }>(`${this.serviceUrl}/${fileUuid}/open`) + ); } catch (e) { console.error(e); this.toastService.showErrorToast('Failed to open the document, please try again'); 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..e94af3b6d5 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 @@ -51,7 +51,9 @@ export class NotificationDocumentService { async openFile(fileUuid: string) { try { - return await firstValueFrom(this.httpClient.get<{ url: string }>(`${this.serviceUrl}/${fileUuid}/open`)); + return await firstValueFrom( + this.httpClient.get<{ url: string; fileName: string }>(`${this.serviceUrl}/${fileUuid}/open`) + ); } catch (e) { console.error(e); this.toastService.showErrorToast('Failed to open the document, please try again'); diff --git a/portal-frontend/src/app/shared/owner-dialogs/parcel-owners/parcel-owners.component.ts b/portal-frontend/src/app/shared/owner-dialogs/parcel-owners/parcel-owners.component.ts index 8cadb3bc21..bddf22ec3c 100644 --- a/portal-frontend/src/app/shared/owner-dialogs/parcel-owners/parcel-owners.component.ts +++ b/portal-frontend/src/app/shared/owner-dialogs/parcel-owners/parcel-owners.component.ts @@ -11,6 +11,7 @@ import { NoticeOfIntentOwnerService } from '../../../services/notice-of-intent-o import { OWNER_TYPE } from '../../dto/owner.dto'; import { CrownOwnerDialogComponent } from '../crown-owner-dialog/crown-owner-dialog.component'; import { OwnerDialogComponent } from '../owner-dialog/owner-dialog.component'; +import { openFileIframe } from '../../utils/file'; @Component({ selector: 'app-parcel-owners[owners][fileId][submissionUuid][ownerService]', @@ -107,7 +108,7 @@ export class ParcelOwnersComponent { async onOpenFile(uuid: string) { const res = await this.documentService.openFile(uuid); if (res) { - window.open(res.url, '_blank'); + openFileIframe(res); } } } diff --git a/portal-frontend/src/app/shared/utils/file.ts b/portal-frontend/src/app/shared/utils/file.ts index e13fc14fcc..5aaee70e01 100644 --- a/portal-frontend/src/app/shared/utils/file.ts +++ b/portal-frontend/src/app/shared/utils/file.ts @@ -11,3 +11,23 @@ export const openPdfFile = (fileName: string, data: any) => { } downloadLink.click(); }; + +export const openFileIframe = (data: { url: string; fileName: string }) => { + const newWindow = window.open('', '_blank'); + if (newWindow) { + newWindow.document.title = data.fileName; + + const object = newWindow.document.createElement('object'); + object.data = data.url; + object.style.borderWidth = '0'; + object.style.width = '100%'; + object.style.height = '100%'; + + newWindow.document.body.appendChild(object); + newWindow.document.body.style.backgroundColor = 'rgb(82, 86, 89)'; + newWindow.document.body.style.height = '100%'; + newWindow.document.body.style.width = '100%'; + newWindow.document.body.style.margin = '0'; + newWindow.document.body.style.overflow = 'hidden'; + } +}; diff --git a/portal-frontend/test/README.md b/portal-frontend/test/README.md deleted file mode 100644 index 460e39b2c3..0000000000 --- a/portal-frontend/test/README.md +++ /dev/null @@ -1,13 +0,0 @@ -!THIS IS NOT A FULL AUTOMATION. IT IS JUST TO REDUCE AMOUNT OF MANUAL CLICKS FOR DEVELOPERS WHEN THEY ARE WORKING ON LOCAL ENVIRONMENTS! - -The Automation is implemented using the playwright. https://playwright.dev/ - -Note: make sure you do not commit any credentials to the repo - -How to run: -Navigate to portal/test folder and from there perform following commands - -```bash -$ npm i -$ npx playwright test --headed --project=chromium -``` diff --git a/portal-frontend/test/config.ts b/portal-frontend/test/config.ts deleted file mode 100644 index 01d701114d..0000000000 --- a/portal-frontend/test/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const baseUrl = 'http://localhost:4201/login'; -export const userName = 'MekhtiHuseinov'; -export const password = 'UQ-ZCDHi.FiF6c'; -export const filePathToUseAsUpload = '/Users/mekhti/Desktop/test_upload_1.png'; diff --git a/portal-frontend/test/github.workflow.playwright.yml.template b/portal-frontend/test/github.workflow.playwright.yml.template deleted file mode 100644 index 041160cce3..0000000000 --- a/portal-frontend/test/github.workflow.playwright.yml.template +++ /dev/null @@ -1,27 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 16 - - name: Install dependencies - run: npm ci - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run Playwright tests - run: npx playwright test - - uses: actions/upload-artifact@v3 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/portal-frontend/test/package-lock.json b/portal-frontend/test/package-lock.json deleted file mode 100644 index 8a2b3fcae7..0000000000 --- a/portal-frontend/test/package-lock.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "name": "alcs", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "alcs", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@playwright/test": "^1.32.0" - } - }, - "node_modules/@playwright/test": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", - "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", - "dev": true, - "dependencies": { - "@types/node": "*", - "playwright-core": "1.32.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@types/node": { - "version": "18.15.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.7.tgz", - "integrity": "sha512-LFmUbFunqmBn26wJZgZPYZPrDR1RwGOu2v79Mgcka1ndO6V0/cwjivPTc4yoK6n9kmw4/ls1r8cLrvh2iMibFA==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright-core": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", - "integrity": "sha512-Z9Ij17X5Z3bjpp6XKujGBp9Gv4eViESac9aDmwgQFUEJBW0K80T21m/Z+XJQlu4cNsvPygw33b6V1Va6Bda5zQ==", - "dev": true, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=14" - } - } - }, - "dependencies": { - "@playwright/test": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", - "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", - "dev": true, - "requires": { - "@types/node": "*", - "fsevents": "2.3.2", - "playwright-core": "1.32.0" - } - }, - "@types/node": { - "version": "18.15.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.7.tgz", - "integrity": "sha512-LFmUbFunqmBn26wJZgZPYZPrDR1RwGOu2v79Mgcka1ndO6V0/cwjivPTc4yoK6n9kmw4/ls1r8cLrvh2iMibFA==", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "playwright-core": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", - "integrity": "sha512-Z9Ij17X5Z3bjpp6XKujGBp9Gv4eViESac9aDmwgQFUEJBW0K80T21m/Z+XJQlu4cNsvPygw33b6V1Va6Bda5zQ==", - "dev": true - } - } -} diff --git a/portal-frontend/test/tests/submissions/NFU_submission_creation.spec.ts b/portal-frontend/test/tests/submissions/NFU_submission_creation.spec.ts deleted file mode 100644 index 79b6c68164..0000000000 --- a/portal-frontend/test/tests/submissions/NFU_submission_creation.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { test } from '@playwright/test'; -import { baseUrl, filePathToUseAsUpload, password, userName } from '../../config'; - -test('test', async ({ page }) => { - await page.goto(baseUrl); - await page.getByRole('button', { name: 'Login with BCeID' }).click(); - await page.locator('#user').click(); - await page.locator('#user').fill(userName); - await page.getByLabel('Password').click(); - await page.getByLabel('Password').fill(password); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByRole('button', { name: 'Continue to inbox' }).click(); - await page.getByRole('button', { name: '+ Create New' }).click(); - await page.getByRole('dialog', { name: 'Create New' }).getByText('Application').click(); - await page.getByRole('button', { name: 'Next' }).click(); - await page.getByText('Non-Farm Uses within the ALR').click(); - await page.getByRole('button', { name: 'create' }).click(); - await page - .locator('section') - .filter({ hasText: 'Application Edit Application Download PDF 1. Identify Parcel(s) Under Applicatio' }) - .getByRole('button', { name: 'Edit Application' }) - .click(); - await page.getByRole('button', { name: 'Fee Simple' }).click(); - await page.getByPlaceholder('Type legal description').click(); - await page.getByPlaceholder('Type legal description').fill('some description here'); - await page.getByPlaceholder('Type parcel size').click(); - await page.getByPlaceholder('Type parcel size').fill('11'); - await page.getByPlaceholder('Type PID').click(); - await page.getByPlaceholder('Type PID').fill('111-111-111'); - await page.getByPlaceholder('YYYY-MMM-DD').click(); - await page.getByPlaceholder('YYYY-MMM-DD').fill('2023-Mar-12'); - await page.getByRole('button', { name: 'Yes' }).click(); - await page.getByPlaceholder('Type owner name').click(); - await page - .getByRole('option', { name: 'No owner matching search Add new owner' }) - .getByRole('button', { name: 'Add new owner' }) - .click(); - await page.getByRole('button', { name: 'Individual' }).click(); - await page.getByPlaceholder('Enter First Name').click(); - await page.getByPlaceholder('Enter First Name').fill('Test'); - await page.getByPlaceholder('Enter Last Name').click(); - await page.getByPlaceholder('Enter Last Name').fill('Individual'); - await page.getByPlaceholder('(555) 555-5555').click(); - await page.getByPlaceholder('(555) 555-5555').fill('(111) 111-11111'); - await page.getByPlaceholder('Enter Email').click(); - await page.getByPlaceholder('Enter Email').fill('11@11'); - await page.getByRole('button', { name: 'Add' }).click(); - await page.setInputFiles('input.file-input', filePathToUseAsUpload); - await page - .getByLabel( - 'I confirm that the owner information provided above matches the current Certificate of Title. Mismatched information can cause significant delays to processing time.' - ) - .check(); - await page.getByRole('button', { name: 'Add another parcel to the application' }).click(); - - await page - .getByRole('region', { name: 'Parcel #2 Details & Owner Information' }) - .getByRole('button', { name: 'Crown' }) - .click(); - await page.getByRole('button', { name: 'Crown' }).click(); - await page.getByRole('textbox', { name: 'Type legal description' }).click(); - await page.getByRole('textbox', { name: 'Type legal description' }).fill('another description'); - await page.getByRole('textbox', { name: 'Type parcel size' }).click(); - await page.getByRole('textbox', { name: 'Type parcel size' }).fill('22'); - await page.getByRole('button', { name: 'No', exact: true }).click(); - await page.getByLabel('Provincial Crown').check(); - await page.getByRole('button', { name: 'Add new government contact' }).click(); - await page.getByPlaceholder('Type ministry or department name').click(); - await page.getByPlaceholder('Type ministry or department name').fill('test ministry'); - await page.getByPlaceholder('Enter First Name').click(); - await page.getByPlaceholder('Enter First Name').fill('Ministry'); - await page.getByPlaceholder('Enter Last Name').click(); - await page.getByPlaceholder('Enter Last Name').fill('test'); - await page.getByPlaceholder('(555) 555-5555').click(); - await page.getByPlaceholder('(555) 555-5555').fill('(333) 333-33333'); - await page.getByPlaceholder('Enter Email').click(); - await page.getByPlaceholder('Enter Email').fill('33@33'); - await page.getByRole('button', { name: 'Add' }).click(); - await page - .getByRole('checkbox', { - name: 'I confirm that the owner information provided above matches the current Certificate of Title. Mismatched information can cause significant delays to processing time.', - }) - .last() - .check(); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.locator('#mat-button-toggle-16-button').click(); - await page.getByRole('button', { name: 'Fee Simple' }).click(); - await page.getByPlaceholder('Type legal description').click(); - await page.getByPlaceholder('Type legal description').fill('other parcels description'); - await page.getByPlaceholder('Type parcel size').click(); - await page.getByPlaceholder('Type parcel size').fill('45'); - await page.getByPlaceholder('Type PID').click(); - await page.getByPlaceholder('Type PID').fill('444-444-444'); - await page.getByRole('region', { name: 'Parcel A Details' }).getByRole('button', { name: 'No' }).click(); - await page.getByPlaceholder('Type owner name').click(); - await page.getByRole('option', { name: 'Test Individual Add' }).getByText('Test Individual').click(); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.waitForTimeout(1000); - await page.getByRole('button', { name: 'Make Primary Contact' }).first().click(); - await page.setInputFiles('input.file-input', filePathToUseAsUpload); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.getByPlaceholder('Type government').click(); - await page.getByPlaceholder('Type government').fill('Peace'); - await page.getByText('Peace River Regional District').click(); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').click(); - await page.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').fill('5'); - await page.getByLabel('Describe all agricultural improvements made to the parcel(s).').click(); - await page.getByLabel('Describe all agricultural improvements made to the parcel(s).').fill('5'); - await page.getByLabel('Describe all other uses that currently take place on the parcel(s).').click(); - await page.getByLabel('Describe all other uses that currently take place on the parcel(s).').fill('5'); - await page.locator('#northLandUseType svg').click(); - await page.getByText('Agricultural / Farm').click(); - await page.locator('#northLandUseTypeDescription').click(); - await page.locator('#northLandUseTypeDescription').fill('north farm'); - await page.locator('#eastLandUseType svg').click(); - await page.getByText('Civic / Institutional').click(); - await page.locator('#eastLandUseTypeDescription').click(); - await page.locator('#eastLandUseTypeDescription').fill('civic east'); - await page.locator('#southLandUseType svg').click(); - await page.getByText('Commercial / Retail').click(); - await page.locator('#southLandUseTypeDescription').click(); - await page.locator('#southLandUseTypeDescription').fill('commercial'); - await page.getByRole('combobox', { name: 'Main Land Use Type' }).locator('svg').click(); - await page.getByText('Industrial').click(); - await page.locator('#westLandUseTypeDescription').click(); - await page.locator('#westLandUseTypeDescription').fill('industrial west'); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.getByPlaceholder('Type size in hectares').click(); - await page.getByPlaceholder('Type size in hectares').fill('6'); - await page.getByLabel('What is the purpose of the proposal?').click(); - await page.getByLabel('What is the purpose of the proposal?').fill('no purpose'); - await page.getByLabel('Could this proposal be accommodated on lands outside of the ALR?').click(); - await page.getByLabel('Could this proposal be accommodated on lands outside of the ALR?').fill('nope'); - await page.getByLabel('Does the proposal support agriculture in the short or long term?').click(); - await page.getByLabel('Does the proposal support agriculture in the short or long term?').fill('nope'); - await page.getByRole('button', { name: 'Yes' }).click(); - await page.getByLabel('Describe the type and amount of fill proposed to be placed.').click(); - await page.getByLabel('Describe the type and amount of fill proposed to be placed.').fill('6'); - await page.getByLabel('Briefly describe the origin and quality of fill.').click(); - await page.getByLabel('Briefly describe the origin and quality of fill.').fill('very good'); - await page.getByPlaceholder('Type fill depth').click(); - await page.getByPlaceholder('Type fill depth').fill('6'); - await page.getByPlaceholder('Type placement area').click(); - await page.getByPlaceholder('Type placement area').fill('6'); - await page.getByPlaceholder('Type volume').click(); - await page.getByPlaceholder('Type volume').fill('6'); - await page.getByPlaceholder('Type length as a decimal number').click(); - await page.getByPlaceholder('Type length as a decimal number').fill('6'); - await page.getByText('UnitSelect one').click(); - await page.getByText('Months', { exact: true }).click(); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.getByRole('button', { name: 'Next Step' }).click(); -}); diff --git a/portal-frontend/test/tests/submissions/TUR_submission_creation.spec.ts b/portal-frontend/test/tests/submissions/TUR_submission_creation.spec.ts deleted file mode 100644 index 013ef67ba4..0000000000 --- a/portal-frontend/test/tests/submissions/TUR_submission_creation.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { test } from '@playwright/test'; -import { delay } from 'rxjs'; -import { baseUrl, filePathToUseAsUpload, password, userName } from '../../config'; - -test('test', async ({ page }) => { - await page.goto(baseUrl); - await page.getByRole('button', { name: 'Login with BCeID' }).click(); - await page.locator('#user').click(); - await page.locator('#user').fill(userName); - await page.getByLabel('Password').click(); - await page.getByLabel('Password').fill(password); - await page.getByRole('button', { name: 'Continue' }).click(); - await page.getByRole('button', { name: 'Continue to inbox' }).click(); - await page.getByRole('button', { name: '+ Create New' }).click(); - await page.getByRole('dialog', { name: 'Create New' }).getByText('Application').click(); - await page.getByRole('button', { name: 'Next' }).click(); - await page.getByText('Transportation, Utility, or Recreational Trail Uses within the ALR').click(); - await page.getByRole('button', { name: 'create' }).click(); - // await page.locator('.btns-wrapper > button:nth-child(1)').click(); - - await page.getByRole('button', { name: 'Fee Simple' }).click(); - await page.getByPlaceholder('Type legal description').click(); - await page.getByPlaceholder('Type legal description').fill('1'); - await page - .locator( - 'div:nth-child(3) > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .click(); - await page.getByPlaceholder('Type parcel size').fill('1'); - await page.getByPlaceholder('Type PID').click(); - await page.getByPlaceholder('Type PID').fill('111-111-111'); - await page.getByRole('button', { name: 'Open calendar' }).click(); - await page.locator('td:nth-child(3) > .mat-calendar-body-cell').first().click(); - await page.getByRole('button', { name: 'March 23, 2023' }).click(); - await page.getByRole('button', { name: 'Yes' }).click(); - await page.setInputFiles('input.file-input', filePathToUseAsUpload); - await page - .locator( - '.container > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .click(); - await page - .getByRole('option', { name: 'No owner matching search Add new owner' }) - .getByRole('button', { name: 'Add new owner' }) - .click(); - await page - .locator( - '.ng-untouched > .form-row > div:nth-child(2) > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .click(); - await page.getByPlaceholder('Enter First Name').fill('1'); - await page.getByPlaceholder('Enter Last Name').click(); - await page.getByPlaceholder('Enter Last Name').fill('1'); - await page - .locator( - '.mat-mdc-dialog-content > form > .form-row > div:nth-child(4) > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .click(); - await page.getByPlaceholder('(555) 555-5555').fill('(111) 111-11111'); - await page.getByPlaceholder('Enter Email').click(); - await page.getByPlaceholder('Enter Email').fill('11@11'); - await page.getByRole('button', { name: 'Add' }).click(); - await page - .getByLabel( - 'I confirm that the owner information provided above matches the current Certificate of Title. Mismatched information can cause significant delays to processing time.' - ) - .check(); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.locator('#mat-button-toggle-11-button').click(); - await page.getByText('Primary Contact').click(); - await delay(1000); - await page.locator('.contacts > button').first().click(); - await page.getByText('4').first().click(); - await page.getByPlaceholder('Type government').click(); - await page.getByPlaceholder('Type government').fill('peace'); - await page.getByText('Peace River Regional District').click(); - await page.getByText('5').first().click(); - await page.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').click(); - await page.getByLabel('Describe all agriculture that currently takes place on the parcel(s).').fill('1'); - await page - .locator( - 'div:nth-child(2) > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .first() - .click(); - await page.getByLabel('Describe all agricultural improvements made to the parcel(s).').fill('2'); - await page.getByLabel('Describe all other uses that currently take place on the parcel(s).').click(); - await page.getByLabel('Describe all other uses that currently take place on the parcel(s).').fill('3'); - await page - .locator( - '.land-use-type > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .first() - .click(); - await page.getByRole('option', { name: 'Other' }).click(); - await page.locator('#mat-select-value-5').getByText('Main Land Use Type').click(); - await page.getByText('Industrial').first().click(); - await page.locator('#mat-select-value-7').click(); - await page.getByText('Civic / Institutional').first().click(); - await page.locator('#mat-select-value-9').click(); - await page.getByRole('option', { name: 'Agricultural / Farm' }).first().click(); - await page.locator('#northLandUseTypeDescription').click(); - await page.locator('#northLandUseTypeDescription').fill('4'); - await page.locator('#eastLandUseTypeDescription').click(); - await page.locator('#eastLandUseTypeDescription').fill('5'); - await page - .locator( - 'div:nth-child(3) > .land-use-type-wrapper > .full-width-input > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .click(); - await page.locator('#southLandUseTypeDescription').click(); - await page.locator('#southLandUseTypeDescription').fill('5'); - await page - .locator( - 'div:nth-child(4) > .land-use-type-wrapper > .full-width-input > .mat-mdc-form-field > .mat-mdc-text-field-wrapper > .mat-mdc-form-field-flex > .mat-mdc-form-field-infix' - ) - .click(); - await page.locator('#westLandUseTypeDescription').fill('5'); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page.getByLabel('What is the purpose of the proposal?').click(); - await page.getByLabel('What is the purpose of the proposal?').fill('6'); - await page - .getByLabel( - 'Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.' - ) - .click(); - await page - .getByLabel( - 'Specify any agricultural activities such as livestock operations, greenhouses or horticultural activities in proximity to the proposal.' - ) - .fill('6'); - await page - .getByLabel('What steps will you take to reduce potential negative impacts on surrounding agricultural lands?') - .click(); - await page - .getByLabel('What steps will you take to reduce potential negative impacts on surrounding agricultural lands?') - .fill('6'); - await page.getByRole('textbox', { name: 'Type comment' }).click(); - await page.getByRole('textbox', { name: 'Type comment' }).fill('6'); - await page.getByPlaceholder('Type total area').click(); - await page.getByPlaceholder('Type total area').fill('6'); - await page.getByLabel('I confirm that all affected property owners with land in the ALR have been notified.').check(); - await page.setInputFiles('#proof-of-serving > input', filePathToUseAsUpload); - - await page.setInputFiles('#proposal-map > input', filePathToUseAsUpload); - await page.getByRole('button', { name: 'Next Step' }).click(); - await page - .locator('div') - .filter({ hasText: /^Review & Submit$/ }) - .click(); - await page.getByRole('button', { name: 'Save and Exit' }).click(); -}); 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 c274ff5526..f376177820 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 @@ -1,15 +1,15 @@ -import { classes } from 'automapper-classes'; -import { AutomapperModule } from 'automapper-nestjs'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; -import { ApplicationService } from '../../application/application.service'; -import { CovenantService } from '../../covenant/covenant.service'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; import { ApplicationModificationService } from '../../application-decision/application-modification/application-modification.service'; import { ApplicationReconsiderationService } from '../../application-decision/application-reconsideration/application-reconsideration.service'; +import { ApplicationService } from '../../application/application.service'; +import { CovenantService } from '../../covenant/covenant.service'; import { NoticeOfIntentModificationService } from '../../notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service'; import { NoticeOfIntentService } from '../../notice-of-intent/notice-of-intent.service'; import { NotificationService } from '../../notification/notification.service'; -import { PlanningReviewService } from '../../planning-review/planning-review.service'; +import { PlanningReferralService } from '../../planning-review/planning-referral/planning-referral.service'; import { UnarchiveCardService } from './unarchive-card.service'; describe('UnarchiveCardService', () => { @@ -17,7 +17,7 @@ describe('UnarchiveCardService', () => { let mockApplicationService: DeepMocked<ApplicationService>; let mockReconsiderationService: DeepMocked<ApplicationReconsiderationService>; - let mockPlanningReviewService: DeepMocked<PlanningReviewService>; + let mockPlanningReferralService: DeepMocked<PlanningReferralService>; let mockModificationService: DeepMocked<ApplicationModificationService>; let mockCovenantService: DeepMocked<CovenantService>; let mockNOIService: DeepMocked<NoticeOfIntentService>; @@ -27,7 +27,7 @@ describe('UnarchiveCardService', () => { beforeEach(async () => { mockApplicationService = createMock(); mockReconsiderationService = createMock(); - mockPlanningReviewService = createMock(); + mockPlanningReferralService = createMock(); mockModificationService = createMock(); mockCovenantService = createMock(); mockNOIService = createMock(); @@ -51,8 +51,8 @@ describe('UnarchiveCardService', () => { useValue: mockReconsiderationService, }, { - provide: PlanningReviewService, - useValue: mockPlanningReviewService, + provide: PlanningReferralService, + useValue: mockPlanningReferralService, }, { provide: ApplicationModificationService, @@ -87,18 +87,20 @@ describe('UnarchiveCardService', () => { it('should load from each service for fetch', async () => { mockApplicationService.getDeletedCard.mockResolvedValue(null); mockReconsiderationService.getDeletedCards.mockResolvedValue([]); - mockPlanningReviewService.getDeletedCards.mockResolvedValue([]); + mockPlanningReferralService.getDeletedCards.mockResolvedValue([]); mockModificationService.getDeletedCards.mockResolvedValue([]); mockCovenantService.getDeletedCards.mockResolvedValue([]); mockNOIService.getDeletedCards.mockResolvedValue([]); mockNOIModificationService.getDeletedCards.mockResolvedValue([]); mockNotificationService.getDeletedCards.mockResolvedValue([]); - const res = await service.fetchByFileId('uuid'); + await service.fetchByFileId('uuid'); expect(mockApplicationService.getDeletedCard).toHaveBeenCalledTimes(1); expect(mockReconsiderationService.getDeletedCards).toHaveBeenCalledTimes(1); - expect(mockPlanningReviewService.getDeletedCards).toHaveBeenCalledTimes(1); + expect(mockPlanningReferralService.getDeletedCards).toHaveBeenCalledTimes( + 1, + ); expect(mockModificationService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockCovenantService.getDeletedCards).toHaveBeenCalledTimes(1); expect(mockNOIService.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 734c465f43..84f3a15589 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 @@ -6,19 +6,19 @@ import { CovenantService } from '../../covenant/covenant.service'; import { NoticeOfIntentModificationService } from '../../notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service'; import { NoticeOfIntentService } from '../../notice-of-intent/notice-of-intent.service'; import { NotificationService } from '../../notification/notification.service'; -import { PlanningReviewService } from '../../planning-review/planning-review.service'; +import { PlanningReferralService } from '../../planning-review/planning-referral/planning-referral.service'; @Injectable() export class UnarchiveCardService { constructor( private applicationService: ApplicationService, private reconsiderationService: ApplicationReconsiderationService, - private planningReviewService: PlanningReviewService, private modificationService: ApplicationModificationService, private covenantService: CovenantService, private noticeOfIntentService: NoticeOfIntentService, private noticeOfIntentModificationService: NoticeOfIntentModificationService, private notificationService: NotificationService, + private planningReferralService: PlanningReferralService, ) {} async fetchByFileId(fileId: string) { @@ -39,7 +39,7 @@ export class UnarchiveCardService { } await this.fetchAndMapRecons(fileId, result); - await this.fetchAndMapPlanningReviews(fileId, result); + await this.fetchAndMapPlanningReferrals(fileId, result); await this.fetchAndMapModifications(fileId, result); await this.fetchAndMapCovenants(fileId, result); await this.fetchAndMapNOIs(fileId, result); @@ -89,27 +89,6 @@ export class UnarchiveCardService { } } - private async fetchAndMapPlanningReviews( - fileId: string, - result: { - cardUuid: string; - type: string; - status: string; - createdAt: number; - }[], - ) { - const planningReviews = - await this.planningReviewService.getDeletedCards(fileId); - for (const planningReview of planningReviews) { - result.push({ - cardUuid: planningReview.cardUuid, - createdAt: planningReview.auditCreatedAt.getTime(), - type: 'Planning Review', - status: planningReview.card!.status.label, - }); - } - } - private async fetchAndMapRecons( fileId: string, result: { @@ -184,4 +163,25 @@ export class UnarchiveCardService { }); } } + + private async fetchAndMapPlanningReferrals( + fileId: string, + result: { + cardUuid: string; + type: string; + status: string; + createdAt: number; + }[], + ) { + const notifications = + await this.planningReferralService.getDeletedCards(fileId); + for (const referral of notifications) { + result.push({ + cardUuid: referral.cardUuid, + createdAt: referral.auditCreatedAt.getTime(), + type: 'PLAN', + status: referral.card!.status.label, + }); + } + } } 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 d776352754..c302ca0b67 100644 --- a/services/apps/alcs/src/alcs/board/board.controller.spec.ts +++ b/services/apps/alcs/src/alcs/board/board.controller.spec.ts @@ -1,20 +1,21 @@ -import { classes } from 'automapper-classes'; -import { AutomapperModule } from 'automapper-nestjs'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; import { ClsService } from 'nestjs-cls'; import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; import { BoardAutomapperProfile } from '../../common/automapper/board.automapper.profile'; import { ApplicationModificationService } from '../application-decision/application-modification/application-modification.service'; import { ApplicationReconsiderationService } from '../application-decision/application-reconsideration/application-reconsideration.service'; import { ApplicationService } from '../application/application.service'; -import { CardType, CARD_TYPE } from '../card/card-type/card-type.entity'; +import { CARD_TYPE, CardType } from '../card/card-type/card-type.entity'; import { Card } from '../card/card.entity'; import { CardService } from '../card/card.service'; import { CovenantService } from '../covenant/covenant.service'; import { NoticeOfIntentModificationService } from '../notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service'; 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 { PlanningReviewService } from '../planning-review/planning-review.service'; import { BoardController } from './board.controller'; import { BOARD_CODES } from './board.dto'; @@ -29,7 +30,7 @@ describe('BoardController', () => { let appReconsiderationService: DeepMocked<ApplicationReconsiderationService>; let modificationService: DeepMocked<ApplicationModificationService>; let cardService: DeepMocked<CardService>; - let planningReviewService: DeepMocked<PlanningReviewService>; + let planningReferralService: DeepMocked<PlanningReferralService>; let covenantService: DeepMocked<CovenantService>; let noticeOfIntentService: DeepMocked<NoticeOfIntentService>; let noiModificationService: DeepMocked<NoticeOfIntentModificationService>; @@ -41,7 +42,7 @@ describe('BoardController', () => { appService = createMock(); appReconsiderationService = createMock(); modificationService = createMock(); - planningReviewService = createMock(); + planningReferralService = createMock(); cardService = createMock(); covenantService = createMock(); noticeOfIntentService = createMock(); @@ -60,8 +61,8 @@ describe('BoardController', () => { appService.mapToDtos.mockResolvedValue([]); appReconsiderationService.getByBoard.mockResolvedValue([]); appReconsiderationService.mapToDtos.mockResolvedValue([]); - planningReviewService.getByBoard.mockResolvedValue([]); - planningReviewService.mapToDtos.mockResolvedValue([]); + planningReferralService.getByBoard.mockResolvedValue([]); + planningReferralService.mapToDtos.mockResolvedValue([]); modificationService.getByBoard.mockResolvedValue([]); modificationService.mapToDtos.mockResolvedValue([]); covenantService.getByBoard.mockResolvedValue([]); @@ -92,8 +93,8 @@ describe('BoardController', () => { }, { provide: CardService, useValue: cardService }, { - provide: PlanningReviewService, - useValue: planningReviewService, + provide: PlanningReferralService, + useValue: planningReferralService, }, { provide: CovenantService, useValue: covenantService }, { @@ -148,8 +149,8 @@ describe('BoardController', () => { expect(appReconsiderationService.mapToDtos).toHaveBeenCalledTimes(1); expect(modificationService.getByBoard).toHaveBeenCalledTimes(0); expect(modificationService.mapToDtos).toHaveBeenCalledTimes(1); - expect(planningReviewService.getByBoard).toHaveBeenCalledTimes(0); - expect(planningReviewService.mapToDtos).toHaveBeenCalledTimes(1); + expect(planningReferralService.getByBoard).toHaveBeenCalledTimes(0); + expect(planningReferralService.mapToDtos).toHaveBeenCalledTimes(1); }); it('should call through to planning review service if board supports planning reviews', async () => { @@ -162,8 +163,8 @@ describe('BoardController', () => { await controller.getBoardWithCards(boardCode); - expect(planningReviewService.getByBoard).toHaveBeenCalledTimes(1); - expect(planningReviewService.mapToDtos).toHaveBeenCalledTimes(1); + expect(planningReferralService.getByBoard).toHaveBeenCalledTimes(1); + expect(planningReferralService.mapToDtos).toHaveBeenCalledTimes(1); }); it('should call through to modification service for boards that support it board', async () => { diff --git a/services/apps/alcs/src/alcs/board/board.controller.ts b/services/apps/alcs/src/alcs/board/board.controller.ts index b06ced459a..fe97ea2d39 100644 --- a/services/apps/alcs/src/alcs/board/board.controller.ts +++ b/services/apps/alcs/src/alcs/board/board.controller.ts @@ -20,6 +20,7 @@ import { CovenantService } from '../covenant/covenant.service'; import { NoticeOfIntentModificationService } from '../notice-of-intent-decision/notice-of-intent-modification/notice-of-intent-modification.service'; 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 { PlanningReviewService } from '../planning-review/planning-review.service'; import { BoardDto, MinimalBoardDto } from './board.dto'; import { Board } from './board.entity'; @@ -34,7 +35,7 @@ export class BoardController { private applicationService: ApplicationService, private cardService: CardService, private reconsiderationService: ApplicationReconsiderationService, - private planningReviewService: PlanningReviewService, + private planningReferralService: PlanningReferralService, private appModificationService: ApplicationModificationService, private noiModificationService: NoticeOfIntentModificationService, private covenantService: CovenantService, @@ -89,8 +90,8 @@ export class BoardController { ? await this.noticeOfIntentService.getByBoard(board.uuid) : []; - const planningReviews = allowedCodes.includes(CARD_TYPE.PLAN) - ? await this.planningReviewService.getByBoard(board.uuid) + const planningReferrals = allowedCodes.includes(CARD_TYPE.PLAN) + ? await this.planningReferralService.getByBoard(board.uuid) : []; const noiModifications = allowedCodes.includes(CARD_TYPE.NOI_MODI) @@ -105,8 +106,8 @@ export class BoardController { board: await this.autoMapper.mapAsync(board, Board, BoardDto), applications: await this.applicationService.mapToDtos(applications), reconsiderations: await this.reconsiderationService.mapToDtos(recons), - planningReviews: - await this.planningReviewService.mapToDtos(planningReviews), + planningReferrals: + await this.planningReferralService.mapToDtos(planningReferrals), modifications: await this.appModificationService.mapToDtos(modifications), covenants: await this.covenantService.mapToDtos(covenants), noticeOfIntents: diff --git a/services/apps/alcs/src/alcs/board/board.dto.ts b/services/apps/alcs/src/alcs/board/board.dto.ts index 422a13ce8e..4a281bc64e 100644 --- a/services/apps/alcs/src/alcs/board/board.dto.ts +++ b/services/apps/alcs/src/alcs/board/board.dto.ts @@ -5,6 +5,7 @@ export enum BOARD_CODES { CEO = 'ceo', SOIL = 'soil', EXECUTIVE_COMMITTEE = 'exec', + REGIONAL_PLANNING = 'rppp', } export class MinimalBoardDto { 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 d11864332a..478b071bbf 100644 --- a/services/apps/alcs/src/alcs/home/home.controller.spec.ts +++ b/services/apps/alcs/src/alcs/home/home.controller.spec.ts @@ -1,7 +1,7 @@ -import { classes } from 'automapper-classes'; -import { AutomapperModule } from 'automapper-nestjs'; import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; import { ClsService } from 'nestjs-cls'; import { In, Not } from 'typeorm'; import { @@ -33,8 +33,6 @@ import { NoticeOfIntent } from '../notice-of-intent/notice-of-intent.entity'; import { NoticeOfIntentService } from '../notice-of-intent/notice-of-intent.service'; import { Notification } from '../notification/notification.entity'; import { NotificationService } from '../notification/notification.service'; -import { PlanningReview } from '../planning-review/planning-review.entity'; -import { PlanningReviewService } from '../planning-review/planning-review.service'; import { HomeController } from './home.controller'; describe('HomeController', () => { @@ -43,7 +41,6 @@ describe('HomeController', () => { let mockApplicationSubtaskService: DeepMocked<CardSubtaskService>; let mockApplicationReconsiderationService: DeepMocked<ApplicationReconsiderationService>; let mockApplicationModificationService: DeepMocked<ApplicationModificationService>; - let mockPlanningReviewService: DeepMocked<PlanningReviewService>; let mockCovenantService: DeepMocked<CovenantService>; let mockApplicationTimeTrackingService: DeepMocked<ApplicationTimeTrackingService>; let mockNoticeOfIntentService: DeepMocked<NoticeOfIntentService>; @@ -54,7 +51,6 @@ describe('HomeController', () => { mockApplicationService = createMock(); mockApplicationSubtaskService = createMock(); mockApplicationReconsiderationService = createMock(); - mockPlanningReviewService = createMock(); mockApplicationTimeTrackingService = createMock(); mockApplicationModificationService = createMock(); mockCovenantService = createMock(); @@ -98,10 +94,6 @@ describe('HomeController', () => { provide: ApplicationTimeTrackingService, useValue: mockApplicationTimeTrackingService, }, - { - provide: PlanningReviewService, - useValue: mockPlanningReviewService, - }, { provide: CovenantService, useValue: mockCovenantService, @@ -136,8 +128,6 @@ describe('HomeController', () => { mockApplicationReconsiderationService.mapToDtos.mockResolvedValue([]); mockApplicationModificationService.getBy.mockResolvedValue([]); mockApplicationModificationService.mapToDtos.mockResolvedValue([]); - mockPlanningReviewService.getBy.mockResolvedValue([]); - mockPlanningReviewService.mapToDtos.mockResolvedValue([]); mockCovenantService.getBy.mockResolvedValue([]); mockCovenantService.mapToDtos.mockResolvedValue([]); mockNoticeOfIntentService.getBy.mockResolvedValue([]); @@ -158,9 +148,6 @@ describe('HomeController', () => { mockApplicationReconsiderationService.getWithIncompleteSubtaskByType.mockResolvedValue( [], ); - mockPlanningReviewService.getWithIncompleteSubtaskByType.mockResolvedValue( - [], - ); mockApplicationModificationService.getWithIncompleteSubtaskByType.mockResolvedValue( [], ); @@ -213,11 +200,6 @@ describe('HomeController', () => { mockApplicationReconsiderationService.getBy.mock.calls[0][0], ).toEqual(filterCondition); - expect(mockPlanningReviewService.getBy).toHaveBeenCalledTimes(1); - expect(mockPlanningReviewService.getBy.mock.calls[0][0]).toEqual( - filterCondition, - ); - expect(mockNoticeOfIntentService.getBy).toHaveBeenCalledTimes(1); expect(mockNoticeOfIntentService.getBy.mock.calls[0][0]).toEqual( filterCondition, @@ -295,30 +277,31 @@ describe('HomeController', () => { expect(res[0].paused).toBeFalsy(); }); - it('should call Reconsideration Service and map it', async () => { - const mockPlanningReview = { - type: 'fake-type', - fileNumber: 'fileNumber', - card: initCardMockEntity('222'), - } as PlanningReview; - mockPlanningReviewService.getWithIncompleteSubtaskByType.mockResolvedValue( - [mockPlanningReview], - ); - - const res = await controller.getIncompleteSubtasksByType( - CARD_SUBTASK_TYPE.GIS, - ); - - expect(res.length).toEqual(1); - expect( - mockPlanningReviewService.getWithIncompleteSubtaskByType, - ).toHaveBeenCalledTimes(1); - - expect(res[0].title).toContain(mockPlanningReview.fileNumber); - expect(res[0].title).toContain(mockPlanningReview.type); - expect(res[0].activeDays).toBeUndefined(); - expect(res[0].paused).toBeFalsy(); - }); + // TODO: Fix when finishing planning reviews + // it('should call Planning Referral Service and map it', async () => { + // const mockPlanningReview = { + // type: 'fake-type', + // fileNumber: 'fileNumber', + // card: initCardMockEntity('222'), + // } as PlanningReview; + // mockPlanningReviewService.getWithIncompleteSubtaskByType.mockResolvedValue( + // [mockPlanningReview], + // ); + // + // const res = await controller.getIncompleteSubtasksByType( + // CARD_SUBTASK_TYPE.GIS, + // ); + // + // expect(res.length).toEqual(1); + // expect( + // mockPlanningReviewService.getWithIncompleteSubtaskByType, + // ).toHaveBeenCalledTimes(1); + // + // expect(res[0].title).toContain(mockPlanningReview.fileNumber); + // expect(res[0].title).toContain(mockPlanningReview.type); + // expect(res[0].activeDays).toBeUndefined(); + // expect(res[0].paused).toBeFalsy(); + // }); it('should call Modification Service and map it', async () => { const mockModification = initApplicationModificationMockEntity(); @@ -332,7 +315,7 @@ describe('HomeController', () => { expect(res.length).toEqual(1); expect( - mockPlanningReviewService.getWithIncompleteSubtaskByType, + mockApplicationModificationService.getWithIncompleteSubtaskByType, ).toHaveBeenCalledTimes(1); expect(res[0].title).toContain(mockModification.application.fileNumber); @@ -357,7 +340,7 @@ describe('HomeController', () => { expect(res.length).toEqual(1); expect( - mockPlanningReviewService.getWithIncompleteSubtaskByType, + mockCovenantService.getWithIncompleteSubtaskByType, ).toHaveBeenCalledTimes(1); expect(res[0].title).toContain(mockCovenant.fileNumber); diff --git a/services/apps/alcs/src/alcs/home/home.controller.ts b/services/apps/alcs/src/alcs/home/home.controller.ts index 3fe9faac21..1de8cee4de 100644 --- a/services/apps/alcs/src/alcs/home/home.controller.ts +++ b/services/apps/alcs/src/alcs/home/home.controller.ts @@ -1,7 +1,7 @@ -import { Mapper } from 'automapper-core'; -import { InjectMapper } from 'automapper-nestjs'; import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import { ApiOAuth2 } from '@nestjs/swagger'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; import * as config from 'config'; import { In, Not } from 'typeorm'; import { ANY_AUTH_ROLE } from '../../common/authorization/roles'; @@ -36,11 +36,11 @@ import { NoticeOfIntentModificationService } from '../notice-of-intent-decision/ import { NoticeOfIntentDto } from '../notice-of-intent/notice-of-intent.dto'; import { NoticeOfIntent } from '../notice-of-intent/notice-of-intent.entity'; import { NoticeOfIntentService } from '../notice-of-intent/notice-of-intent.service'; +import { NotificationDto } from '../notification/notification.dto'; import { Notification } from '../notification/notification.entity'; import { NotificationService } from '../notification/notification.service'; import { PlanningReviewDto } from '../planning-review/planning-review.dto'; import { PlanningReview } from '../planning-review/planning-review.entity'; -import { PlanningReviewService } from '../planning-review/planning-review.service'; const HIDDEN_CARD_STATUSES = [ CARD_STATUS.CANCELLED, @@ -56,7 +56,6 @@ export class HomeController { private applicationService: ApplicationService, private timeService: ApplicationTimeTrackingService, private reconsiderationService: ApplicationReconsiderationService, - private planningReviewService: PlanningReviewService, private modificationService: ApplicationModificationService, private covenantService: CovenantService, private noticeOfIntentService: NoticeOfIntentService, @@ -71,9 +70,10 @@ export class HomeController { noticeOfIntentModifications: NoticeOfIntentModificationDto[]; applications: ApplicationDto[]; reconsiderations: ApplicationReconsiderationDto[]; - planningReviews: PlanningReviewDto[]; + planningReferrals: PlanningReviewDto[]; modifications: ApplicationModificationDto[]; covenants: CovenantDto[]; + notifications: NotificationDto[]; }> { const userId = req.user.entity.uuid; const assignedFindOptions = { @@ -91,8 +91,8 @@ export class HomeController { const reconsiderations = await this.reconsiderationService.getBy(assignedFindOptions); - const planningReviews = - await this.planningReviewService.getBy(assignedFindOptions); + // const planningReviews = + // await this.planningReviewService.getBy(assignedFindOptions); const modifications = await this.modificationService.getBy(assignedFindOptions); @@ -108,7 +108,7 @@ export class HomeController { const notifications = await this.notificationService.getBy(assignedFindOptions); - const result = { + return { noticeOfIntents: await this.noticeOfIntentService.mapToDtos(noticeOfIntents), noticeOfIntentModifications: @@ -118,23 +118,21 @@ export class HomeController { applications: await this.applicationService.mapToDtos(applications), reconsiderations: await this.reconsiderationService.mapToDtos(reconsiderations), - planningReviews: - await this.planningReviewService.mapToDtos(planningReviews), + planningReferrals: [], modifications: await this.modificationService.mapToDtos(modifications), covenants: await this.covenantService.mapToDtos(covenants), notifications: await this.notificationService.mapToDtos(notifications), }; - - return result; } else { return { noticeOfIntents: [], noticeOfIntentModifications: [], applications: [], reconsiderations: [], - planningReviews: [], + planningReferrals: [], modifications: [], covenants: [], + notifications: [], }; } } @@ -156,13 +154,13 @@ export class HomeController { ); const reconSubtasks = this.mapReconToDto(reconsiderationWithSubtasks); - const planningReviewsWithSubtasks = - await this.planningReviewService.getWithIncompleteSubtaskByType( - subtaskType, - ); - const planningReviewSubtasks = this.mapPlanningReviewsToDtos( - planningReviewsWithSubtasks, - ); + // const planningReviewsWithSubtasks = + // await this.planningReviewService.getWithIncompleteSubtaskByType( + // subtaskType, + // ); + // const planningReviewSubtasks = this.mapPlanningReviewsToDtos( + // planningReviewsWithSubtasks, + // ); const modificationsWithSubtasks = await this.modificationService.getWithIncompleteSubtaskByType( @@ -205,7 +203,6 @@ export class HomeController { ...applicationSubtasks, ...reconSubtasks, ...modificationSubtasks, - ...planningReviewSubtasks, ...covenantReviewSubtasks, ...noiModificationsSubtasks, ...notificationSubtasks, @@ -270,21 +267,22 @@ export class HomeController { private mapPlanningReviewsToDtos(planingReviews: PlanningReview[]) { const result: HomepageSubtaskDTO[] = []; - for (const planningReview of planingReviews) { - for (const subtask of planningReview.card.subtasks) { - result.push({ - type: subtask.type, - createdAt: subtask.createdAt.getTime(), - assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), - uuid: subtask.uuid, - card: this.mapper.map(planningReview.card, Card, CardDto), - completedAt: subtask.completedAt?.getTime(), - paused: false, - title: `${planningReview.fileNumber} (${planningReview.type})`, - parentType: PARENT_TYPE.PLANNING_REVIEW, - }); - } - } + // TODO + // for (const planningReview of planingReviews) { + // for (const subtask of planningReview.card.subtasks) { + // result.push({ + // type: subtask.type, + // createdAt: subtask.createdAt.getTime(), + // assignee: this.mapper.map(subtask.assignee, User, AssigneeDto), + // uuid: subtask.uuid, + // card: this.mapper.map(planningReview.card, Card, CardDto), + // completedAt: subtask.completedAt?.getTime(), + // paused: false, + // title: `${planningReview.fileNumber} (${planningReview.type})`, + // parentType: PARENT_TYPE.PLANNING_REVIEW, + // }); + // } + // } return result; } diff --git a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts index 087c56a4f2..98edea46a3 100644 --- a/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts +++ b/services/apps/alcs/src/alcs/notification/notification-document/notification-document.entity.ts @@ -56,6 +56,12 @@ export class NotificationDocument extends BaseEntity { @Column({ nullable: true, type: 'uuid' }) documentUuid?: string | null; + @Column({ type: 'text', nullable: true }) + oatsApplicationId?: string | null; + + @Column({ type: 'text', nullable: true }) + oatsDocumentId?: string | null; + @AutoMap(() => [String]) @Column({ default: [], array: true, type: 'text' }) visibilityFlags: VISIBILITY_FLAG[]; diff --git a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.controller.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.controller.spec.ts new file mode 100644 index 0000000000..bb8235e2b2 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.controller.spec.ts @@ -0,0 +1,102 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; +import { PlanningReviewProfile } from '../../../common/automapper/planning-review.automapper.profile'; +import { Board } from '../../board/board.entity'; +import { BoardService } from '../../board/board.service'; +import { PlanningReviewType } from '../planning-review-type.entity'; +import { PlanningReferralController } from './planning-referral.controller'; +import { PlanningReferral } from './planning-referral.entity'; +import { PlanningReferralService } from './planning-referral.service'; + +describe('PlanningReviewController', () => { + let controller: PlanningReferralController; + let mockService: DeepMocked<PlanningReferralService>; + let mockBoardService: DeepMocked<BoardService>; + + beforeEach(async () => { + mockService = createMock(); + mockBoardService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [PlanningReferralController], + providers: [ + PlanningReviewProfile, + { + provide: PlanningReferralService, + useValue: mockService, + }, + { + provide: BoardService, + useValue: mockBoardService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + + controller = module.get<PlanningReferralController>( + PlanningReferralController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should call through for fetchByCardUuid', async () => { + mockService.getByCardUuid.mockResolvedValue(new PlanningReferral()); + + const res = await controller.fetchByCardUuid('uuid'); + + expect(res).toBeDefined(); + expect(mockService.getByCardUuid).toHaveBeenCalledTimes(1); + }); + + it('should load the board then call through for create', async () => { + mockService.create.mockResolvedValue(new PlanningReferral()); + mockBoardService.getOneOrFail.mockResolvedValue(new Board()); + + const res = await controller.create({ + planningReviewUuid: '', + referralDescription: '', + submissionDate: 0, + }); + + expect(res).toBeDefined(); + expect(mockBoardService.getOneOrFail).toHaveBeenCalledTimes(1); + expect(mockService.create).toHaveBeenCalledTimes(1); + }); + + it('should call through for update', async () => { + mockService.update.mockResolvedValue(); + + const res = await controller.update('', { + referralDescription: '', + submissionDate: 0, + }); + + expect(res).toBeDefined(); + expect(mockService.update).toHaveBeenCalledTimes(1); + }); + + it('should call through for delete', async () => { + mockService.delete.mockResolvedValue(); + + const res = await controller.delete(''); + + expect(res).toBeDefined(); + expect(mockService.delete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.controller.ts b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.controller.ts new file mode 100644 index 0000000000..b7f51e0d3d --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.controller.ts @@ -0,0 +1,77 @@ +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'; +import * as config from 'config'; +import { ROLES_ALLOWED_BOARDS } from '../../../common/authorization/roles'; +import { RolesGuard } from '../../../common/authorization/roles-guard.service'; +import { UserRoles } from '../../../common/authorization/roles.decorator'; +import { BOARD_CODES } from '../../board/board.dto'; +import { BoardService } from '../../board/board.service'; +import { + CreatePlanningReferralDto, + PlanningReferralDto, + UpdatePlanningReferralDto, +} from '../planning-review.dto'; +import { PlanningReferral } from './planning-referral.entity'; +import { PlanningReferralService } from './planning-referral.service'; + +@Controller('planning-referral') +@ApiOAuth2(config.get<string[]>('KEYCLOAK.SCOPES')) +@UseGuards(RolesGuard) +export class PlanningReferralController { + constructor( + private planningReferralService: PlanningReferralService, + @InjectMapper() + private mapper: Mapper, + private boardService: BoardService, + ) {} + + @Get('/card/:uuid') + @UserRoles(...ROLES_ALLOWED_BOARDS) + async fetchByCardUuid(@Param('uuid') uuid: string) { + const review = await this.planningReferralService.getByCardUuid(uuid); + return this.mapper.map(review, PlanningReferral, PlanningReferralDto); + } + + @Post() + @UserRoles(...ROLES_ALLOWED_BOARDS) + async create(@Body() createDto: CreatePlanningReferralDto) { + const board = await this.boardService.getOneOrFail({ + code: BOARD_CODES.REGIONAL_PLANNING, + }); + + const review = await this.planningReferralService.create(createDto, board); + return this.mapper.map(review, PlanningReferral, PlanningReferralDto); + } + + @Patch(':uuid') + @UserRoles(...ROLES_ALLOWED_BOARDS) + async update( + @Param('uuid') uuid: string, + @Body() updateDto: UpdatePlanningReferralDto, + ) { + await this.planningReferralService.update(uuid, updateDto); + return { + success: true, + }; + } + + @Delete(':uuid') + @UserRoles(...ROLES_ALLOWED_BOARDS) + async delete(@Param('uuid') uuid: string) { + await this.planningReferralService.delete(uuid); + return { + success: true, + }; + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts new file mode 100644 index 0000000000..f2cc8777bd --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.entity.ts @@ -0,0 +1,49 @@ +import { AutoMap } from 'automapper-classes'; +import { Type } from 'class-transformer'; +import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; +import { Base } from '../../../common/entities/base.entity'; +import { Card } from '../../card/card.entity'; +import { PlanningReview } from '../planning-review.entity'; + +@Entity({ + comment: + 'Planning Referrals represent each pass of a Planning Review with their own cards', +}) +export class PlanningReferral extends Base { + constructor(data?: Partial<PlanningReferral>) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @Column({ type: 'timestamptz' }) + submissionDate: Date; + + @Column({ type: 'timestamptz', nullable: true }) + dueDate?: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + responseDate?: Date | null; + + @AutoMap(() => String) + @Column({ nullable: true, type: 'text' }) + referralDescription?: string | null; + + @AutoMap(() => String) + @Column({ nullable: true, type: 'text' }) + responseDescription?: string; + + @ManyToOne(() => PlanningReview) + @JoinColumn() + @Type(() => PlanningReview) + planningReview: PlanningReview; + + @Column({ type: 'uuid' }) + cardUuid: string; + + @OneToOne(() => Card, { cascade: true }) + @JoinColumn() + @Type(() => Card) + card: Card; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.service.spec.ts new file mode 100644 index 0000000000..c35f9cb3c2 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.service.spec.ts @@ -0,0 +1,126 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; +import { Repository } from 'typeorm'; +import { Board } from '../../board/board.entity'; +import { Card } from '../../card/card.entity'; +import { CardService } from '../../card/card.service'; +import { PlanningReview } from '../planning-review.entity'; +import { PlanningReferral } from './planning-referral.entity'; +import { PlanningReferralService } from './planning-referral.service'; + +describe('PlanningReferralService', () => { + let service: PlanningReferralService; + let mockRepository: DeepMocked<Repository<PlanningReferral>>; + let mockReviewRepository: DeepMocked<Repository<PlanningReview>>; + let mockCardService: DeepMocked<CardService>; + + beforeEach(async () => { + mockCardService = createMock(); + mockReviewRepository = createMock(); + mockRepository = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + { + provide: getRepositoryToken(PlanningReferral), + useValue: mockRepository, + }, + { + provide: getRepositoryToken(PlanningReview), + useValue: mockReviewRepository, + }, + { + provide: CardService, + useValue: mockCardService, + }, + PlanningReferralService, + ], + }).compile(); + + service = module.get<PlanningReferralService>(PlanningReferralService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should call through to the repo for get by card', async () => { + mockRepository.findOneOrFail.mockResolvedValue(new PlanningReferral()); + const cardUuid = 'fake-card-uuid'; + await service.getByCardUuid(cardUuid); + + expect(mockRepository.findOneOrFail).toHaveBeenCalledTimes(1); + }); + + it('should call through to the repo for get cards', async () => { + mockRepository.find.mockResolvedValue([]); + await service.getByBoard(''); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + }); + + it('should load deleted cards', async () => { + mockRepository.find.mockResolvedValue([]); + + await service.getDeletedCards('file-number'); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(mockRepository.find.mock.calls[0][0]!.withDeleted).toEqual(true); + }); + + it('should load the review then call save for create', async () => { + mockReviewRepository.findOneOrFail.mockResolvedValue(new PlanningReview()); + mockCardService.create.mockResolvedValue(new Card()); + mockRepository.save.mockResolvedValue(new PlanningReferral()); + mockRepository.findOneOrFail.mockResolvedValue(new PlanningReferral()); + + await service.create( + { + referralDescription: '', + submissionDate: 0, + planningReviewUuid: 'uuid', + }, + new Board(), + ); + + expect(mockReviewRepository.findOneOrFail).toHaveBeenCalledTimes(1); + expect(mockCardService.create).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + }); + + it('should load the review then update its values for update', async () => { + const mockReferral = new PlanningReferral(); + mockRepository.save.mockResolvedValue(mockReferral); + mockRepository.findOneOrFail.mockResolvedValue(mockReferral); + + const newDescription = 'newDescription'; + + await service.update('', { + referralDescription: newDescription, + submissionDate: 0, + }); + + expect(mockRepository.findOneOrFail).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockReferral.referralDescription).toEqual(newDescription); + }); + + it('should call through for delete', async () => { + const mockReferral = new PlanningReferral(); + mockRepository.softRemove.mockResolvedValue(mockReferral); + mockRepository.findOneOrFail.mockResolvedValue(mockReferral); + + await service.delete('mock-uuid'); + + expect(mockRepository.findOneOrFail).toHaveBeenCalledTimes(1); + expect(mockRepository.softRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.service.ts new file mode 100644 index 0000000000..da903ebb1c --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-referral/planning-referral.service.ts @@ -0,0 +1,153 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; +import { FindOptionsRelations, IsNull, Not, Repository } from 'typeorm'; +import { formatIncomingDate } from '../../../utils/incoming-date.formatter'; +import { filterUndefined } from '../../../utils/undefined'; +import { Board } from '../../board/board.entity'; +import { CARD_TYPE } from '../../card/card-type/card-type.entity'; +import { CardService } from '../../card/card.service'; +import { + CreatePlanningReferralDto, + PlanningReferralDto, + UpdatePlanningReferralDto, +} from '../planning-review.dto'; +import { PlanningReview } from '../planning-review.entity'; +import { PlanningReferral } from './planning-referral.entity'; + +@Injectable() +export class PlanningReferralService { + constructor( + @InjectRepository(PlanningReferral) + private referralRepository: Repository<PlanningReferral>, + @InjectRepository(PlanningReview) + private reviewRepository: Repository<PlanningReview>, + @InjectMapper() + private mapper: Mapper, + private cardService: CardService, + ) {} + + private DEFAULT_RELATIONS: FindOptionsRelations<PlanningReferral> = { + card: { + type: true, + status: true, + board: true, + }, + planningReview: { + localGovernment: true, + region: true, + type: true, + }, + }; + + async getByBoard(boardUuid: string) { + return this.referralRepository.find({ + where: { + card: { + boardUuid, + }, + }, + relations: this.DEFAULT_RELATIONS, + }); + } + + async mapToDtos(planningReferrals: PlanningReferral[]) { + return this.mapper.mapArray( + planningReferrals, + PlanningReferral, + PlanningReferralDto, + ); + } + + get(uuid: string) { + return this.referralRepository.findOneOrFail({ + where: { + uuid, + }, + relations: this.DEFAULT_RELATIONS, + }); + } + + async getByCardUuid(uuid: string) { + return this.referralRepository.findOneOrFail({ + where: { + cardUuid: uuid, + }, + relations: this.DEFAULT_RELATIONS, + }); + } + + getDeletedCards(fileNumber: string) { + return this.referralRepository.find({ + where: { + planningReview: { + fileNumber: fileNumber, + }, + card: { + auditDeletedDateAt: Not(IsNull()), + }, + }, + withDeleted: true, + relations: this.DEFAULT_RELATIONS, + }); + } + + async create(createDto: CreatePlanningReferralDto, board: Board) { + const review = await this.reviewRepository.findOneOrFail({ + where: { + uuid: createDto.planningReviewUuid, + }, + }); + + const referral = new PlanningReferral({ + planningReview: review, + dueDate: formatIncomingDate(createDto.dueDate), + submissionDate: formatIncomingDate(createDto.submissionDate)!, + referralDescription: createDto.referralDescription, + card: await this.cardService.create(CARD_TYPE.PLAN, board, false), + }); + + await this.referralRepository.save(referral); + + return this.get(referral.uuid); + } + + async update(uuid: string, updateDto: UpdatePlanningReferralDto) { + const existingReferral = await this.referralRepository.findOneOrFail({ + where: { + uuid, + }, + }); + + existingReferral.referralDescription = filterUndefined( + updateDto.referralDescription, + existingReferral.referralDescription, + ); + existingReferral.responseDescription = filterUndefined( + updateDto.responseDescription, + existingReferral.responseDescription, + ); + + existingReferral.responseDate = formatIncomingDate(updateDto.responseDate); + existingReferral.dueDate = formatIncomingDate(updateDto.dueDate); + + if (updateDto.submissionDate) { + existingReferral.submissionDate = <Date>( + formatIncomingDate(updateDto.submissionDate) + ); + } + + await this.referralRepository.save(existingReferral); + } + + async delete(uuid: string) { + const existingReferral = await this.referralRepository.findOneOrFail({ + where: { + uuid, + }, + }); + + await this.referralRepository.softRemove(existingReferral); + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts new file mode 100644 index 0000000000..8de38ef3fc --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.spec.ts @@ -0,0 +1,189 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; +import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../../test/mocks/mockTypes'; +import { PlanningReviewProfile } from '../../../common/automapper/planning-review.automapper.profile'; +import { DOCUMENT_TYPE } from '../../../document/document-code.entity'; +import { DOCUMENT_SOURCE } from '../../../document/document.dto'; +import { Document } from '../../../document/document.entity'; +import { User } from '../../../user/user.entity'; +import { CodeService } from '../../code/code.service'; +import { PlanningReviewDocumentController } from './planning-review-document.controller'; +import { PlanningReviewDocument } from './planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +describe('PlanningReviewDocumentController', () => { + let controller: PlanningReviewDocumentController; + let mockPlanningReviewDocumentService: DeepMocked<PlanningReviewDocumentService>; + + const mockDocument = new PlanningReviewDocument({ + document: new Document({ + mimeType: 'mimeType', + uploadedBy: new User(), + uploadedAt: new Date(), + }), + }); + + beforeEach(async () => { + mockPlanningReviewDocumentService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + controllers: [PlanningReviewDocumentController], + providers: [ + { + provide: CodeService, + useValue: {}, + }, + PlanningReviewProfile, + { + provide: PlanningReviewDocumentService, + useValue: mockPlanningReviewDocumentService, + }, + { + provide: ClsService, + useValue: {}, + }, + ...mockKeyCloakProviders, + ], + }).compile(); + controller = module.get<PlanningReviewDocumentController>( + PlanningReviewDocumentController, + ); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should return the attached document', async () => { + const mockFile = {}; + const mockUser = {}; + + mockPlanningReviewDocumentService.attachDocument.mockResolvedValue( + mockDocument, + ); + + const res = await controller.attachDocument('fileNumber', { + isMultipart: () => true, + body: { + documentType: { + value: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + }, + fileName: { + value: 'file', + }, + source: { + value: DOCUMENT_SOURCE.APPLICANT, + }, + visibilityFlags: { + value: '', + }, + file: mockFile, + }, + user: { + entity: mockUser, + }, + }); + + expect(res.mimeType).toEqual(mockDocument.document.mimeType); + + expect( + mockPlanningReviewDocumentService.attachDocument, + ).toHaveBeenCalledTimes(1); + const callData = + mockPlanningReviewDocumentService.attachDocument.mock.calls[0][0]; + expect(callData.fileName).toEqual('file'); + expect(callData.file).toEqual(mockFile); + expect(callData.user).toEqual(mockUser); + }); + + it('should throw an exception if request is not the right type', async () => { + const mockFile = {}; + const mockUser = {}; + + mockPlanningReviewDocumentService.attachDocument.mockResolvedValue( + mockDocument, + ); + + await expect( + controller.attachDocument('fileNumber', { + isMultipart: () => false, + file: () => mockFile, + user: { + entity: mockUser, + }, + }), + ).rejects.toMatchObject( + new BadRequestException('Request is not multipart'), + ); + }); + + it('should list documents', async () => { + mockPlanningReviewDocumentService.list.mockResolvedValue([mockDocument]); + + const res = await controller.listDocuments( + 'fake-number', + DOCUMENT_TYPE.DECISION_DOCUMENT, + ); + + expect(res[0].mimeType).toEqual(mockDocument.document.mimeType); + }); + + it('should call through to delete documents', async () => { + mockPlanningReviewDocumentService.delete.mockResolvedValue(mockDocument); + mockPlanningReviewDocumentService.get.mockResolvedValue(mockDocument); + + await controller.delete('fake-uuid'); + + expect(mockPlanningReviewDocumentService.get).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewDocumentService.delete).toHaveBeenCalledTimes(1); + }); + + it('should call through for open', async () => { + const fakeUrl = 'fake-url'; + mockPlanningReviewDocumentService.getInlineUrl.mockResolvedValue(fakeUrl); + mockPlanningReviewDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.open('fake-uuid'); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for download', async () => { + const fakeUrl = 'fake-url'; + mockPlanningReviewDocumentService.getDownloadUrl.mockResolvedValue(fakeUrl); + mockPlanningReviewDocumentService.get.mockResolvedValue(mockDocument); + + const res = await controller.download('fake-uuid'); + + expect(res.url).toEqual(fakeUrl); + }); + + it('should call through for list types', async () => { + mockPlanningReviewDocumentService.fetchTypes.mockResolvedValue([]); + + const res = await controller.listTypes(); + + expect(mockPlanningReviewDocumentService.fetchTypes).toHaveBeenCalledTimes( + 1, + ); + }); + + it('should call through for setting sort', async () => { + mockPlanningReviewDocumentService.setSorting.mockResolvedValue(); + + await controller.sortDocuments([]); + + expect(mockPlanningReviewDocumentService.setSorting).toHaveBeenCalledTimes( + 1, + ); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts new file mode 100644 index 0000000000..7cc91ebe29 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.controller.ts @@ -0,0 +1,206 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiOAuth2 } from '@nestjs/swagger'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; +import * as config from 'config'; +import { ANY_AUTH_ROLE } from '../../../common/authorization/roles'; +import { RolesGuard } from '../../../common/authorization/roles-guard.service'; +import { UserRoles } from '../../../common/authorization/roles.decorator'; +import { + DOCUMENT_TYPE, + DocumentCode, +} from '../../../document/document-code.entity'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, + DocumentTypeDto, +} from '../../../document/document.dto'; +import { PlanningReviewDocumentDto } from './planning-review-document.dto'; +import { + PlanningReviewDocument, + PR_VISIBILITY_FLAG, +} from './planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +@ApiOAuth2(config.get<string[]>('KEYCLOAK.SCOPES')) +@UseGuards(RolesGuard) +@Controller('planning-review-document') +export class PlanningReviewDocumentController { + constructor( + private planningReviewDocumentService: PlanningReviewDocumentService, + @InjectMapper() private mapper: Mapper, + ) {} + + @Get('/planning-review/:fileNumber') + @UserRoles(...ANY_AUTH_ROLE) + async listAll( + @Param('fileNumber') fileNumber: string, + ): Promise<PlanningReviewDocumentDto[]> { + const documents = await this.planningReviewDocumentService.list(fileNumber); + return this.mapper.mapArray( + documents, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Post('/planning-review/:fileNumber') + @UserRoles(...ANY_AUTH_ROLE) + async attachDocument( + @Param('fileNumber') fileNumber: string, + @Req() req, + ): Promise<PlanningReviewDocumentDto> { + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const savedDocument = await this.saveUploadedFile(req, fileNumber); + + return this.mapper.map( + savedDocument, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Post('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async updateDocument( + @Param('uuid') documentUuid: string, + @Req() req, + ): Promise<PlanningReviewDocumentDto> { + if (!req.isMultipart()) { + throw new BadRequestException('Request is not multipart'); + } + + const documentType = req.body.documentType.value as DOCUMENT_TYPE; + const file = req.body.file; + const fileName = req.body.fileName.value as string; + const documentSource = req.body.source.value as DOCUMENT_SOURCE; + const visibilityFlags = req.body.visibilityFlags.value.split(', '); + + const savedDocument = await this.planningReviewDocumentService.update({ + uuid: documentUuid, + fileName, + file, + documentType: documentType as DOCUMENT_TYPE, + source: documentSource, + visibilityFlags, + user: req.user.entity, + }); + + return this.mapper.map( + savedDocument, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Get('/planning-review/:fileNumber/reviewDocuments') + @UserRoles(...ANY_AUTH_ROLE) + async listReviewDocuments( + @Param('fileNumber') fileNumber: string, + ): Promise<PlanningReviewDocumentDto[]> { + const documents = await this.planningReviewDocumentService.list(fileNumber); + const reviewDocuments = documents.filter( + (doc) => doc.document.source === DOCUMENT_SOURCE.LFNG, + ); + + return this.mapper.mapArray( + reviewDocuments, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Get('/planning-review/:fileNumber/:visibilityFlags') + @UserRoles(...ANY_AUTH_ROLE) + async listDocuments( + @Param('fileNumber') fileNumber: string, + @Param('visibilityFlags') visibilityFlags: string, + ): Promise<PlanningReviewDocumentDto[]> { + const mappedFlags = visibilityFlags.split('') as PR_VISIBILITY_FLAG[]; + const documents = await this.planningReviewDocumentService.list( + fileNumber, + mappedFlags, + ); + return this.mapper.mapArray( + documents, + PlanningReviewDocument, + PlanningReviewDocumentDto, + ); + } + + @Get('/types') + @UserRoles(...ANY_AUTH_ROLE) + async listTypes() { + const types = await this.planningReviewDocumentService.fetchTypes(); + return this.mapper.mapArray(types, DocumentCode, DocumentTypeDto); + } + + @Get('/:uuid/open') + @UserRoles(...ANY_AUTH_ROLE) + async open(@Param('uuid') fileUuid: string) { + const document = await this.planningReviewDocumentService.get(fileUuid); + const url = await this.planningReviewDocumentService.getInlineUrl(document); + return { + url, + }; + } + + @Get('/:uuid/download') + @UserRoles(...ANY_AUTH_ROLE) + async download(@Param('uuid') fileUuid: string) { + const document = await this.planningReviewDocumentService.get(fileUuid); + const url = + await this.planningReviewDocumentService.getDownloadUrl(document); + return { + url, + }; + } + + @Delete('/:uuid') + @UserRoles(...ANY_AUTH_ROLE) + async delete(@Param('uuid') fileUuid: string) { + const document = await this.planningReviewDocumentService.get(fileUuid); + await this.planningReviewDocumentService.delete(document); + return {}; + } + + @Post('/sort') + @UserRoles(...ANY_AUTH_ROLE) + async sortDocuments( + @Body() data: { uuid: string; order: number }[], + ): Promise<void> { + await this.planningReviewDocumentService.setSorting(data); + } + + private async saveUploadedFile(req, fileNumber: string) { + const documentType = req.body.documentType.value as DOCUMENT_TYPE; + const file = req.body.file; + const fileName = req.body.fileName.value as string; + const documentSource = req.body.source.value as DOCUMENT_SOURCE; + const visibilityFlags = req.body.visibilityFlags.value.split(', '); + + return await this.planningReviewDocumentService.attachDocument({ + fileNumber, + fileName, + file, + user: req.user.entity, + documentType: documentType as DOCUMENT_TYPE, + source: documentSource, + visibilityFlags, + system: DOCUMENT_SYSTEM.ALCS, + }); + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.dto.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.dto.ts new file mode 100644 index 0000000000..14b50a1387 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.dto.ts @@ -0,0 +1,29 @@ +import { AutoMap } from 'automapper-classes'; +import { DocumentTypeDto } from '../../../document/document.dto'; + +export class PlanningReviewDocumentDto { + @AutoMap(() => String) + description?: string; + + @AutoMap() + uuid: string; + + @AutoMap(() => DocumentTypeDto) + type?: DocumentTypeDto; + + @AutoMap(() => [String]) + visibilityFlags: string[]; + + @AutoMap(() => [Number]) + evidentiaryRecordSorting?: number; + + //Document Fields + documentUuid: string; + fileName: string; + fileSize?: number; + source: string; + system: string; + mimeType: string; + uploadedBy: string; + uploadedAt: number; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.entity.ts new file mode 100644 index 0000000000..3ae3f2c7b2 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.entity.ts @@ -0,0 +1,64 @@ +import { AutoMap } from 'automapper-classes'; +import { + BaseEntity, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { DocumentCode } from '../../../document/document-code.entity'; +import { Document } from '../../../document/document.entity'; +import { PlanningReview } from '../planning-review.entity'; + +export enum PR_VISIBILITY_FLAG { + COMMISSIONER = 'C', +} + +@Entity({ + comment: 'Stores planning review documents', +}) +export class PlanningReviewDocument extends BaseEntity { + constructor(data?: Partial<PlanningReviewDocument>) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @AutoMap() + @PrimaryGeneratedColumn('uuid') + uuid: string; + + @ManyToOne(() => DocumentCode) + type?: DocumentCode; + + @Column({ nullable: true }) + typeCode?: string | null; + + @Column({ type: 'text', nullable: true }) + description?: string | null; + + @ManyToOne(() => PlanningReview, { nullable: false }) + planningReview: PlanningReview; + + @Column() + @Index() + planningReviewUuid: string; + + @Column({ nullable: true, type: 'uuid' }) + documentUuid?: string | null; + + @AutoMap(() => [String]) + @Column({ default: [], array: true, type: 'text' }) + visibilityFlags: PR_VISIBILITY_FLAG[]; + + @Column({ nullable: true, type: 'int' }) + evidentiaryRecordSorting?: number | null; + + @OneToOne(() => Document) + @JoinColumn() + document: Document; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts new file mode 100644 index 0000000000..74805b78a3 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.spec.ts @@ -0,0 +1,293 @@ +import { ServiceNotFoundException } from '@app/common/exceptions/base.exception'; +import { MultipartFile } from '@fastify/multipart'; +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + DOCUMENT_TYPE, + DocumentCode, +} from '../../../document/document-code.entity'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, +} from '../../../document/document.dto'; +import { Document } from '../../../document/document.entity'; +import { DocumentService } from '../../../document/document.service'; +import { User } from '../../../user/user.entity'; +import { PlanningReview } from '../planning-review.entity'; +import { PlanningReviewService } from '../planning-review.service'; +import { PlanningReviewDocument } from './planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document.service'; + +describe('PlanningReviewDocumentService', () => { + let service: PlanningReviewDocumentService; + let mockDocumentService: DeepMocked<DocumentService>; + let mockPlanningReviewService: DeepMocked<PlanningReviewService>; + let mockRepository: DeepMocked<Repository<PlanningReviewDocument>>; + let mockTypeRepository: DeepMocked<Repository<DocumentCode>>; + + let mockPlanningReview; + const fileNumber = '12345'; + + beforeEach(async () => { + mockDocumentService = createMock(); + mockPlanningReviewService = createMock(); + mockRepository = createMock(); + mockTypeRepository = createMock(); + + mockPlanningReview = new PlanningReview({ + fileNumber, + }); + mockPlanningReviewService.getDetailedReview.mockResolvedValue( + mockPlanningReview, + ); + mockDocumentService.create.mockResolvedValue({} as Document); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlanningReviewDocumentService, + { + provide: DocumentService, + useValue: mockDocumentService, + }, + { + provide: PlanningReviewService, + useValue: mockPlanningReviewService, + }, + { + provide: getRepositoryToken(DocumentCode), + useValue: mockTypeRepository, + }, + { + provide: getRepositoryToken(PlanningReviewDocument), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get<PlanningReviewDocumentService>( + PlanningReviewDocumentService, + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create a document in the happy path', async () => { + const mockUser = new User(); + const mockFile = {}; + const mockSavedDocument = {}; + + mockRepository.save.mockResolvedValue( + mockSavedDocument as PlanningReviewDocument, + ); + + const res = await service.attachDocument({ + fileNumber, + file: mockFile as MultipartFile, + user: mockUser, + documentType: DOCUMENT_TYPE.DECISION_DOCUMENT, + fileName: '', + source: DOCUMENT_SOURCE.APPLICANT, + system: DOCUMENT_SYSTEM.PORTAL, + visibilityFlags: [], + }); + + expect(mockPlanningReviewService.getDetailedReview).toHaveBeenCalledTimes( + 1, + ); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create.mock.calls[0][0]).toBe( + 'planning-review/12345', + ); + expect(mockDocumentService.create.mock.calls[0][2]).toBe(mockFile); + expect(mockDocumentService.create.mock.calls[0][3]).toBe(mockUser); + + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockRepository.save.mock.calls[0][0].planningReview).toBe( + mockPlanningReview, + ); + + expect(res).toBe(mockSavedDocument); + }); + + it('should delete document and planning review document when deleting', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as PlanningReviewDocument; + + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.remove.mockResolvedValue({} as any); + + await service.delete(mockAppDocument); + + expect(mockDocumentService.softRemove).toHaveBeenCalledTimes(1); + expect(mockDocumentService.softRemove.mock.calls[0][0]).toBe(mockDocument); + + expect(mockRepository.remove).toHaveBeenCalledTimes(1); + expect(mockRepository.remove.mock.calls[0][0]).toBe(mockAppDocument); + }); + + it('should call through for get', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as PlanningReviewDocument; + + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.findOne.mockResolvedValue(mockAppDocument); + + const res = await service.get('fake-uuid'); + expect(res).toBe(mockAppDocument); + }); + + it("should throw an exception when getting a document that doesn't exist", async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as PlanningReviewDocument; + + mockDocumentService.softRemove.mockResolvedValue(); + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.get(mockAppDocument.uuid)).rejects.toMatchObject( + new ServiceNotFoundException( + `Failed to find document ${mockAppDocument.uuid}`, + ), + ); + }); + + it('should call through for list', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as PlanningReviewDocument; + mockRepository.find.mockResolvedValue([mockAppDocument]); + + const res = await service.list(fileNumber); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(res[0]).toBe(mockAppDocument); + }); + + it('should call through for download', async () => { + const mockDocument = {}; + const mockAppDocument = { + uuid: '1', + document: mockDocument, + } as PlanningReviewDocument; + + const fakeUrl = 'mock-url'; + mockDocumentService.getDownloadUrl.mockResolvedValue(fakeUrl); + + const res = await service.getInlineUrl(mockAppDocument); + + expect(mockDocumentService.getDownloadUrl).toHaveBeenCalledTimes(1); + expect(res).toEqual(fakeUrl); + }); + + it('should call through for fetchTypes', async () => { + mockTypeRepository.find.mockResolvedValue([]); + + const res = await service.fetchTypes(); + + expect(mockTypeRepository.find).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + }); + + it('should create a record for external documents', async () => { + mockRepository.save.mockResolvedValue(new PlanningReviewDocument()); + mockPlanningReviewService.getDetailedReview.mockResolvedValueOnce( + mockPlanningReview, + ); + mockRepository.findOne.mockResolvedValue(new PlanningReviewDocument()); + + const res = await service.attachExternalDocument( + '', + { + type: DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + description: '', + documentUuid: 'fake-uuid', + }, + [], + ); + + expect(mockPlanningReviewService.getDetailedReview).toHaveBeenCalledTimes( + 1, + ); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockRepository.save.mock.calls[0][0].planningReview).toBe( + mockPlanningReview, + ); + expect(mockRepository.save.mock.calls[0][0].typeCode).toEqual( + DOCUMENT_TYPE.CERTIFICATE_OF_TITLE, + ); + expect(mockRepository.findOne).toHaveBeenCalledTimes(1); + expect(res).toBeDefined(); + }); + + it('should delete the existing file and create a new when updating', async () => { + mockRepository.findOne.mockResolvedValue( + new PlanningReviewDocument({ + document: new Document(), + }), + ); + mockPlanningReviewService.getFileNumber.mockResolvedValue( + mockPlanningReview, + ); + mockRepository.save.mockResolvedValue(new PlanningReviewDocument()); + mockDocumentService.create.mockResolvedValue(new Document()); + mockDocumentService.softRemove.mockResolvedValue(); + + await service.update({ + source: DOCUMENT_SOURCE.APPLICANT, + fileName: 'fileName', + user: new User(), + file: {} as File, + uuid: '', + documentType: DOCUMENT_TYPE.DECISION_DOCUMENT, + visibilityFlags: [], + }); + + expect(mockRepository.findOne).toHaveBeenCalledTimes(1); + expect(mockPlanningReviewService.getFileNumber).toHaveBeenCalledTimes(1); + expect(mockDocumentService.create).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + }); + + it('should load and save the documents with the new sort order', async () => { + const mockDoc1 = new PlanningReviewDocument({ + uuid: 'uuid-1', + evidentiaryRecordSorting: 5, + }); + const mockDoc2 = new PlanningReviewDocument({ + uuid: 'uuid-2', + evidentiaryRecordSorting: 6, + }); + mockRepository.find.mockResolvedValue([mockDoc1, mockDoc2]); + mockRepository.save.mockResolvedValue({} as any); + + await service.setSorting([ + { + uuid: mockDoc1.uuid, + order: 0, + }, + { + uuid: mockDoc2.uuid, + order: 1, + }, + ]); + + expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockDoc1.evidentiaryRecordSorting).toEqual(0); + expect(mockDoc2.evidentiaryRecordSorting).toEqual(1); + }); +}); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts new file mode 100644 index 0000000000..d029a129de --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-document/planning-review-document.service.ts @@ -0,0 +1,219 @@ +import { MultipartFile } from '@fastify/multipart'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + ArrayOverlap, + FindOptionsRelations, + FindOptionsWhere, + In, + Repository, +} from 'typeorm'; +import { + DOCUMENT_TYPE, + DocumentCode, +} from '../../../document/document-code.entity'; +import { + DOCUMENT_SOURCE, + DOCUMENT_SYSTEM, +} from '../../../document/document.dto'; +import { DocumentService } from '../../../document/document.service'; +import { User } from '../../../user/user.entity'; +import { PlanningReviewService } from '../planning-review.service'; +import { + PlanningReviewDocument, + PR_VISIBILITY_FLAG, +} from './planning-review-document.entity'; + +@Injectable() +export class PlanningReviewDocumentService { + private DEFAULT_RELATIONS: FindOptionsRelations<PlanningReviewDocument> = { + document: true, + type: true, + }; + + constructor( + private documentService: DocumentService, + private planningReviewService: PlanningReviewService, + @InjectRepository(PlanningReviewDocument) + private planningReviewDocumentRepo: Repository<PlanningReviewDocument>, + @InjectRepository(DocumentCode) + private documentCodeRepository: Repository<DocumentCode>, + ) {} + + async attachDocument({ + fileNumber, + fileName, + file, + documentType, + user, + system, + source = DOCUMENT_SOURCE.ALC, + visibilityFlags = [], + }: { + fileNumber: string; + fileName: string; + file: MultipartFile; + user: User; + documentType: DOCUMENT_TYPE; + source?: DOCUMENT_SOURCE; + system: DOCUMENT_SYSTEM; + visibilityFlags: PR_VISIBILITY_FLAG[]; + }) { + const planningReview = + await this.planningReviewService.getDetailedReview(fileNumber); + const document = await this.documentService.create( + `planning-review/${fileNumber}`, + fileName, + file, + user, + source, + system, + ); + const appDocument = new PlanningReviewDocument({ + typeCode: documentType, + planningReview, + document, + visibilityFlags, + }); + + return this.planningReviewDocumentRepo.save(appDocument); + } + + async get(uuid: string) { + const document = await this.planningReviewDocumentRepo.findOne({ + where: { + uuid: uuid, + }, + relations: this.DEFAULT_RELATIONS, + }); + if (!document) { + throw new NotFoundException(`Failed to find document ${uuid}`); + } + return document; + } + + async delete(document: PlanningReviewDocument) { + await this.planningReviewDocumentRepo.remove(document); + await this.documentService.softRemove(document.document); + return document; + } + + async list(fileNumber: string, visibilityFlags?: PR_VISIBILITY_FLAG[]) { + const where: FindOptionsWhere<PlanningReviewDocument> = { + planningReview: { + fileNumber, + }, + }; + if (visibilityFlags) { + where.visibilityFlags = ArrayOverlap(visibilityFlags); + } + return this.planningReviewDocumentRepo.find({ + where, + order: { + document: { + uploadedAt: 'DESC', + }, + }, + relations: this.DEFAULT_RELATIONS, + }); + } + + async getInlineUrl(document: PlanningReviewDocument) { + return this.documentService.getDownloadUrl(document.document, true); + } + + async getDownloadUrl(document: PlanningReviewDocument) { + return this.documentService.getDownloadUrl(document.document); + } + + async attachExternalDocument( + fileNumber: string, + data: { + type?: DOCUMENT_TYPE; + documentUuid: string; + description?: string; + }, + visibilityFlags: PR_VISIBILITY_FLAG[], + ) { + const planningReview = + await this.planningReviewService.getDetailedReview(fileNumber); + const document = new PlanningReviewDocument({ + planningReview, + typeCode: data.type, + documentUuid: data.documentUuid, + description: data.description, + visibilityFlags, + }); + + const savedDocument = await this.planningReviewDocumentRepo.save(document); + return this.get(savedDocument.uuid); + } + + async fetchTypes() { + return await this.documentCodeRepository.find(); + } + + async update({ + uuid, + documentType, + file, + fileName, + source, + visibilityFlags, + user, + }: { + uuid: string; + file?: any; + fileName: string; + documentType: DOCUMENT_TYPE; + visibilityFlags: PR_VISIBILITY_FLAG[]; + source: DOCUMENT_SOURCE; + user: User; + }) { + const appDocument = await this.get(uuid); + + if (file) { + const fileNumber = await this.planningReviewService.getFileNumber( + appDocument.planningReviewUuid, + ); + await this.documentService.softRemove(appDocument.document); + appDocument.document = await this.documentService.create( + `planning-review/${fileNumber}`, + fileName, + file, + user, + source, + appDocument.document.system as DOCUMENT_SYSTEM, + ); + } else { + await this.documentService.update(appDocument.document, { + fileName, + source, + }); + } + appDocument.type = undefined; + appDocument.typeCode = documentType; + appDocument.visibilityFlags = visibilityFlags; + return await this.planningReviewDocumentRepo.save(appDocument); + } + + async setSorting(data: { uuid: string; order: number }[]) { + const uuids = data.map((data) => data.uuid); + const documents = await this.planningReviewDocumentRepo.find({ + where: { + uuid: In(uuids), + }, + }); + + for (const document of data) { + const existingDocument = documents.find( + (doc) => doc.uuid === document.uuid, + ); + if (existingDocument) { + existingDocument.evidentiaryRecordSorting = document.order; + } + } + + await this.planningReviewDocumentRepo.save(documents); + } +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review-type.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review-type.entity.ts new file mode 100644 index 0000000000..471a88d482 --- /dev/null +++ b/services/apps/alcs/src/alcs/planning-review/planning-review-type.entity.ts @@ -0,0 +1,29 @@ +import { AutoMap } from 'automapper-classes'; +import { Column, Entity } from 'typeorm'; +import { BaseCodeEntity } from '../../common/entities/base.code.entity'; + +@Entity() +export class PlanningReviewType extends BaseCodeEntity { + constructor(data?: Partial<PlanningReviewType>) { + super(); + if (data) { + Object.assign(this, data); + } + } + + @AutoMap() + @Column() + shortLabel: string; + + @AutoMap() + @Column() + backgroundColor: string; + + @AutoMap() + @Column() + textColor: string; + + @AutoMap() + @Column({ type: 'text', default: '' }) + htmlDescription: string; +} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.controller.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.controller.spec.ts index 64126026ec..1004bf8ff6 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.controller.spec.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.controller.spec.ts @@ -1,9 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; +import { classes } from 'automapper-classes'; +import { AutomapperModule } from 'automapper-nestjs'; import { ClsService } from 'nestjs-cls'; +import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; +import { PlanningReviewProfile } from '../../common/automapper/planning-review.automapper.profile'; +import { FileNumberService } from '../../file-number/file-number.service'; import { Board } from '../board/board.entity'; import { BoardService } from '../board/board.service'; -import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; +import { PlanningReferral } from './planning-referral/planning-referral.entity'; +import { PlanningReferralService } from './planning-referral/planning-referral.service'; import { PlanningReviewController } from './planning-review.controller'; import { PlanningReview } from './planning-review.entity'; import { PlanningReviewService } from './planning-review.service'; @@ -11,19 +17,31 @@ import { PlanningReviewService } from './planning-review.service'; describe('PlanningReviewController', () => { let controller: PlanningReviewController; let mockService: DeepMocked<PlanningReviewService>; + let mockPlanningReferralService: DeepMocked<PlanningReferralService>; let mockBoardService: DeepMocked<BoardService>; beforeEach(async () => { - mockService = createMock<PlanningReviewService>(); - mockBoardService = createMock<BoardService>(); + mockService = createMock(); + mockBoardService = createMock(); + mockPlanningReferralService = createMock(); const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], controllers: [PlanningReviewController], providers: [ + PlanningReviewProfile, { provide: PlanningReviewService, useValue: mockService, }, + { + provide: PlanningReferralService, + useValue: mockPlanningReferralService, + }, { provide: BoardService, useValue: mockBoardService, @@ -45,29 +63,38 @@ describe('PlanningReviewController', () => { it('should call board service then main service for create', async () => { mockBoardService.getOneOrFail.mockResolvedValue({} as Board); - mockService.create.mockResolvedValue({} as PlanningReview); - mockService.mapToDtos.mockResolvedValue([]); + mockService.create.mockResolvedValue(new PlanningReferral()); + mockPlanningReferralService.get.mockResolvedValue(new PlanningReferral()); + mockPlanningReferralService.mapToDtos.mockResolvedValue([]); await controller.create({ - type: 'type', + description: 'description', + documentName: 'documentName', + submissionDate: 0, + typeCode: 'typeCode', localGovernmentUuid: 'local-gov-uuid', - fileNumber: 'file-number', regionCode: 'region-code', - boardCode: 'board-code', }); expect(mockBoardService.getOneOrFail).toHaveBeenCalledTimes(1); expect(mockService.create).toHaveBeenCalledTimes(1); - expect(mockService.mapToDtos).toHaveBeenCalledTimes(1); + expect(mockPlanningReferralService.get).toHaveBeenCalledTimes(1); + expect(mockPlanningReferralService.mapToDtos).toHaveBeenCalledTimes(1); + }); + + it('should call service for fetch types', async () => { + mockService.listTypes.mockResolvedValue([]); + + await controller.fetchTypes(); + + expect(mockService.listTypes).toHaveBeenCalledTimes(1); }); - it('should call through to service for get card', async () => { - mockService.getByCardUuid.mockResolvedValue({} as PlanningReview); - mockService.mapToDtos.mockResolvedValue([]); + it('should call service for fetch by file number', async () => { + mockService.getDetailedReview.mockResolvedValue(new PlanningReview()); - await controller.getByCard('uuid'); + await controller.fetchByFileNumber('file-number'); - expect(mockService.getByCardUuid).toHaveBeenCalledTimes(1); - expect(mockService.mapToDtos).toHaveBeenCalledTimes(1); + expect(mockService.getDetailedReview).toHaveBeenCalledTimes(1); }); }); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.controller.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.controller.ts index a1e7879159..374073a846 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.controller.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.controller.ts @@ -1,11 +1,22 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiOAuth2 } from '@nestjs/swagger'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; import * as config from 'config'; -import { BoardService } from '../board/board.service'; import { ROLES_ALLOWED_BOARDS } from '../../common/authorization/roles'; import { RolesGuard } from '../../common/authorization/roles-guard.service'; import { UserRoles } from '../../common/authorization/roles.decorator'; -import { CreatePlanningReviewDto } from './planning-review.dto'; +import { BOARD_CODES } from '../board/board.dto'; +import { BoardService } from '../board/board.service'; +import { PlanningReferralService } from './planning-referral/planning-referral.service'; +import { PlanningReviewType } from './planning-review-type.entity'; +import { + CreatePlanningReviewDto, + PlanningReviewDetailedDto, + PlanningReviewTypeDto, + UpdatePlanningReviewDto, +} from './planning-review.dto'; +import { PlanningReview } from './planning-review.entity'; import { PlanningReviewService } from './planning-review.service'; @Controller('planning-review') @@ -14,35 +25,64 @@ import { PlanningReviewService } from './planning-review.service'; export class PlanningReviewController { constructor( private planningReviewService: PlanningReviewService, + private planningReferralService: PlanningReferralService, private boardService: BoardService, + @InjectMapper() + private mapper: Mapper, ) {} + @Get('/types') + @UserRoles(...ROLES_ALLOWED_BOARDS) + async fetchTypes() { + const types = await this.planningReviewService.listTypes(); + + return this.mapper.mapArray( + types, + PlanningReviewType, + PlanningReviewTypeDto, + ); + } + @Post() @UserRoles(...ROLES_ALLOWED_BOARDS) async create(@Body() createDto: CreatePlanningReviewDto) { const board = await this.boardService.getOneOrFail({ - code: createDto.boardCode, + code: BOARD_CODES.REGIONAL_PLANNING, }); - if (!board) { - throw new Error('Failed to load executive board'); - } - - const createdReview = await this.planningReviewService.create( + const createdReferral = await this.planningReviewService.create( createDto, board, ); - const mapped = await this.planningReviewService.mapToDtos([createdReview]); + const referral = await this.planningReferralService.get( + createdReferral.uuid, + ); + + const mapped = await this.planningReferralService.mapToDtos([referral]); return mapped[0]; } - @Get('/card/:uuid') + @Get('/:fileNumber') @UserRoles(...ROLES_ALLOWED_BOARDS) - async getByCard(@Param('uuid') cardUuid: string) { - const planningReview = - await this.planningReviewService.getByCardUuid(cardUuid); - const mapped = await this.planningReviewService.mapToDtos([planningReview]); - return mapped[0]; + async fetchByFileNumber(@Param('fileNumber') fileNumber: string) { + const review = + await this.planningReviewService.getDetailedReview(fileNumber); + + return this.mapper.map(review, PlanningReview, PlanningReviewDetailedDto); + } + + @Post('/:fileNumber') + @UserRoles(...ROLES_ALLOWED_BOARDS) + async updateByFileNumber( + @Param('fileNumber') fileNumber: string, + @Body() updateDto: UpdatePlanningReviewDto, + ) { + const review = await this.planningReviewService.update( + fileNumber, + updateDto, + ); + + return this.mapper.map(review, PlanningReview, PlanningReviewDetailedDto); } } diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts index 1bd1e97c8f..39442427c9 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.dto.ts @@ -1,18 +1,44 @@ import { AutoMap } from 'automapper-classes'; -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; -import { LocalGovernmentDto } from '../local-government/local-government.dto'; +import { + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import { BaseCodeDto } from '../../common/dtos/base.dto'; import { CardDto } from '../card/card.dto'; import { ApplicationRegionDto } from '../code/application-code/application-region/application-region.dto'; +import { LocalGovernmentDto } from '../local-government/local-government.dto'; + +export class PlanningReviewTypeDto extends BaseCodeDto { + @AutoMap() + shortLabel: string; + + @AutoMap() + backgroundColor: string; + + @AutoMap() + textColor: string; +} export class CreatePlanningReviewDto { @IsString() @IsNotEmpty() - fileNumber: string; + description: string; @IsString() @IsNotEmpty() - @MaxLength(40) - type: string; + documentName: string; + + @IsNumber() + @IsNotEmpty() + submissionDate: number; + + @IsNumber() + @IsOptional() + dueDate?: number; @IsString() @IsNotEmpty() @@ -20,26 +46,120 @@ export class CreatePlanningReviewDto { @IsString() @IsNotEmpty() - regionCode: string; + typeCode: string; @IsString() @IsNotEmpty() - boardCode: string; + regionCode: string; } export class PlanningReviewDto { + @AutoMap() + uuid: string; + @AutoMap() fileNumber: string; + @AutoMap(() => String) + legacyId: string | null; + @AutoMap() - card: CardDto; + open: boolean; @AutoMap() - localGovernment: LocalGovernmentDto; + documentName: string; + + @AutoMap() + localGovernmentUuid: string; @AutoMap() + typeCode: string; + + @AutoMap() + regionCode: string; + + @AutoMap(() => LocalGovernmentDto) + localGovernment: LocalGovernmentDto; + + @AutoMap(() => ApplicationRegionDto) region: ApplicationRegionDto; + @AutoMap(() => PlanningReviewTypeDto) + type: PlanningReviewTypeDto; +} + +export class CreatePlanningReferralDto { + @IsUUID() + @IsNotEmpty() + planningReviewUuid: string; + + @IsString() + @IsNotEmpty() + referralDescription: string; + + @IsNumber() + @IsNotEmpty() + submissionDate: number; + + @IsNumber() + @IsOptional() + dueDate?: number; +} + +export class UpdatePlanningReferralDto { + @IsString() + @IsOptional() + referralDescription?: string; + + @IsNumber() + @IsOptional() + submissionDate?: number; + + @IsNumber() + @IsOptional() + dueDate?: number; + + @IsNumber() + @IsOptional() + responseDate?: number; + + @IsString() + @IsOptional() + responseDescription?: string; +} + +export class PlanningReferralDto { @AutoMap() - type: string; + uuid: string; + + dueDate: number; + submissionDate: number; + responseDate?: number; + + @AutoMap(() => String) + referralDescription?: string; + + @AutoMap(() => PlanningReviewDto) + planningReview: PlanningReviewDto; + + @AutoMap(() => String) + responseDescription?: string; + + @AutoMap(() => CardDto) + card: CardDto; +} + +export class PlanningReviewDetailedDto extends PlanningReviewDto { + @AutoMap(() => [PlanningReferralDto]) + referrals: PlanningReferralDto[]; +} + +export class UpdatePlanningReviewDto { + @IsBoolean() + @IsOptional() + open?: boolean; + + @IsString() + @IsOptional() + typeCode?: string; } diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts index 908e4f9d3e..d56139b567 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.entity.ts @@ -1,18 +1,15 @@ -import { Type } from 'class-transformer'; -import { - Column, - Entity, - Index, - JoinColumn, - ManyToOne, - OneToOne, -} from 'typeorm'; +import { AutoMap } from 'automapper-classes'; +import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; import { Base } from '../../common/entities/base.entity'; -import { Card } from '../card/card.entity'; +import { User } from '../../user/user.entity'; import { ApplicationRegion } from '../code/application-code/application-region/application-region.entity'; import { LocalGovernment } from '../local-government/local-government.entity'; +import { PlanningReferral } from './planning-referral/planning-referral.entity'; +import { PlanningReviewType } from './planning-review-type.entity'; -@Entity() +@Entity({ + comment: 'A review of a local government or municipalities plan', +}) export class PlanningReview extends Base { constructor(data?: Partial<PlanningReview>) { super(); @@ -21,20 +18,20 @@ export class PlanningReview extends Base { } } - @Index() @Column({ unique: true }) fileNumber: string; - @Column() - type: string; - - @Column({ type: 'uuid' }) - cardUuid: string; + @AutoMap(() => String) + @Column({ + type: 'text', + comment: + 'Application Id that is applicable only to paper version applications from 70s - 80s', + nullable: true, + }) + legacyId?: string | null; - @OneToOne(() => Card, { cascade: true }) - @JoinColumn() - @Type(() => Card) - card: Card; + @Column({ nullable: false }) + documentName: string; @ManyToOne(() => LocalGovernment) localGovernment: LocalGovernment; @@ -50,4 +47,24 @@ export class PlanningReview extends Base { @Column() regionCode: string; + + @AutoMap(() => PlanningReviewType) + @ManyToOne(() => PlanningReviewType, { nullable: false }) + type: PlanningReviewType; + + @AutoMap(() => [PlanningReferral]) + @OneToMany(() => PlanningReferral, (referral) => referral.planningReview) + referrals: PlanningReferral[]; + + @Column() + typeCode: string; + + @Column({ default: true }) + open: boolean; + + @ManyToOne(() => User) + closedBy: User; + + @Column({ type: 'timestamptz', nullable: true }) + closedDate: Date | null; } diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts index d54f7dbc68..9b81109c95 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.module.ts @@ -1,22 +1,49 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PlanningReviewProfile } from '../../common/automapper/planning-review.automapper.profile'; +import { DocumentCode } from '../../document/document-code.entity'; +import { DocumentModule } from '../../document/document.module'; +import { FileNumberModule } from '../../file-number/file-number.module'; import { BoardModule } from '../board/board.module'; import { CardModule } from '../card/card.module'; import { CodeModule } from '../code/code.module'; -import { PlanningReviewProfile } from '../../common/automapper/planning-meeting.automapper.profile'; +import { PlanningReferralController } from './planning-referral/planning-referral.controller'; +import { PlanningReferral } from './planning-referral/planning-referral.entity'; +import { PlanningReferralService } from './planning-referral/planning-referral.service'; +import { PlanningReviewDocumentController } from './planning-review-document/planning-review-document.controller'; +import { PlanningReviewDocument } from './planning-review-document/planning-review-document.entity'; +import { PlanningReviewDocumentService } from './planning-review-document/planning-review-document.service'; +import { PlanningReviewType } from './planning-review-type.entity'; import { PlanningReviewController } from './planning-review.controller'; import { PlanningReview } from './planning-review.entity'; import { PlanningReviewService } from './planning-review.service'; @Module({ imports: [ - TypeOrmModule.forFeature([PlanningReview]), + TypeOrmModule.forFeature([ + PlanningReview, + PlanningReferral, + PlanningReviewType, + PlanningReviewDocument, + DocumentCode, + ]), forwardRef(() => BoardModule), CardModule, CodeModule, + FileNumberModule, + DocumentModule, ], - controllers: [PlanningReviewController], - providers: [PlanningReviewService, PlanningReviewProfile], - exports: [PlanningReviewService], + controllers: [ + PlanningReviewController, + PlanningReferralController, + PlanningReviewDocumentController, + ], + providers: [ + PlanningReviewService, + PlanningReviewProfile, + PlanningReferralService, + PlanningReviewDocumentService, + ], + exports: [PlanningReviewService, PlanningReferralService], }) export class PlanningReviewModule {} diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts index 6e943f743e..447a0327f4 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.service.spec.ts @@ -4,20 +4,29 @@ import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { FileNumberService } from '../../file-number/file-number.service'; import { Board } from '../board/board.entity'; import { Card } from '../card/card.entity'; import { CardService } from '../card/card.service'; +import { PlanningReferral } from './planning-referral/planning-referral.entity'; +import { PlanningReviewType } from './planning-review-type.entity'; import { PlanningReview } from './planning-review.entity'; import { PlanningReviewService } from './planning-review.service'; describe('PlanningReviewService', () => { let service: PlanningReviewService; let mockRepository: DeepMocked<Repository<PlanningReview>>; + let mockTypeRepository: DeepMocked<Repository<PlanningReviewType>>; + let mockReferralRepository: DeepMocked<Repository<PlanningReferral>>; let mockCardService: DeepMocked<CardService>; + let mockFileNumberService: DeepMocked<FileNumberService>; beforeEach(async () => { - mockCardService = createMock<CardService>(); - mockRepository = createMock<Repository<PlanningReview>>(); + mockCardService = createMock(); + mockRepository = createMock(); + mockTypeRepository = createMock(); + mockReferralRepository = createMock(); + mockFileNumberService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -30,6 +39,18 @@ describe('PlanningReviewService', () => { provide: getRepositoryToken(PlanningReview), useValue: mockRepository, }, + { + provide: getRepositoryToken(PlanningReviewType), + useValue: mockTypeRepository, + }, + { + provide: getRepositoryToken(PlanningReferral), + useValue: mockReferralRepository, + }, + { + provide: FileNumberService, + useValue: mockFileNumberService, + }, { provide: CardService, useValue: mockCardService, @@ -49,84 +70,33 @@ describe('PlanningReviewService', () => { const mockCard = {} as Card; const fakeBoard = {} as Board; - mockRepository.findOne.mockResolvedValueOnce(null); - mockRepository.findOne.mockResolvedValueOnce({} as PlanningReview); - mockRepository.save.mockResolvedValue({} as PlanningReview); - mockCardService.create.mockResolvedValue(mockCard); - - const res = await service.create( - { - type: 'fake-type', - fileNumber: '1512311', - localGovernmentUuid: 'fake-uuid', - regionCode: 'region-code', - boardCode: 'board-code', - }, - fakeBoard, + mockFileNumberService.generateNextFileNumber.mockResolvedValue(1); + mockTypeRepository.findOneOrFail.mockResolvedValue( + new PlanningReviewType(), ); - - expect(mockRepository.findOne).toHaveBeenCalledTimes(2); - expect(mockCardService.create).toHaveBeenCalledTimes(1); - expect(mockRepository.save).toHaveBeenCalledTimes(1); - expect(mockRepository.save.mock.calls[0][0].card).toBe(mockCard); - }); - - it('should throw an exception when creating a meeting with an existing file ID', async () => { - const mockCard = {} as Card; - const fakeBoard = {} as Board; - const existingFileNumber = '1512311'; - - mockRepository.findOne.mockResolvedValueOnce({} as PlanningReview); mockRepository.save.mockResolvedValue({} as PlanningReview); mockCardService.create.mockResolvedValue(mockCard); + mockReferralRepository.save.mockResolvedValue(new PlanningReferral()); - const promise = service.create( + await service.create( { - type: 'fake-type', - fileNumber: existingFileNumber, + description: '', + documentName: '', + submissionDate: 0, + typeCode: '', localGovernmentUuid: 'fake-uuid', regionCode: 'region-code', - boardCode: 'board-code', }, fakeBoard, ); - await expect(promise).rejects.toMatchObject( - new Error( - `Planning meeting already exists with File ID ${existingFileNumber}`, - ), - ); - - expect(mockRepository.findOne).toHaveBeenCalledTimes(1); - expect(mockCardService.create).not.toHaveBeenCalled(); - expect(mockRepository.save).not.toHaveBeenCalled(); - }); - - it('should call through to the repo for get by card', async () => { - mockRepository.findOne.mockResolvedValue({} as PlanningReview); - const cardUuid = 'fake-card-uuid'; - await service.getByCardUuid(cardUuid); - - expect(mockRepository.findOne).toHaveBeenCalledTimes(1); - }); - - it('should throw an exception when getting by card fails', async () => { - mockRepository.findOne.mockResolvedValue(null); - const cardUuid = 'fake-card-uuid'; - const promise = service.getByCardUuid(cardUuid); - - await expect(promise).rejects.toMatchObject( - new Error(`Failed to find planning meeting with card uuid ${cardUuid}`), + expect(mockFileNumberService.generateNextFileNumber).toHaveBeenCalledTimes( + 1, ); - - expect(mockRepository.findOne).toHaveBeenCalledTimes(1); - }); - - it('should call through to the repo for get cards', async () => { - mockRepository.find.mockResolvedValue([]); - await service.getByBoard(''); - - expect(mockRepository.find).toHaveBeenCalledTimes(1); + expect(mockTypeRepository.findOneOrFail).toHaveBeenCalledTimes(1); + expect(mockCardService.create).toHaveBeenCalledTimes(1); + expect(mockRepository.save).toHaveBeenCalledTimes(1); + expect(mockReferralRepository.save).toHaveBeenCalledTimes(1); }); it('should call through to the repo for getby', async () => { @@ -134,18 +104,18 @@ describe('PlanningReviewService', () => { uuid: '5', }; mockRepository.find.mockResolvedValue([]); + await service.getBy(mockFilter); expect(mockRepository.find).toHaveBeenCalledTimes(1); expect(mockRepository.find.mock.calls[0][0]!.where).toEqual(mockFilter); }); - it('should load deleted cards', async () => { - mockRepository.find.mockResolvedValue([]); + it('should call through to the repo for getDetailedReview', async () => { + mockRepository.findOneOrFail.mockResolvedValue(new PlanningReview()); - await service.getDeletedCards('file-number'); + await service.getDetailedReview('file-number'); - expect(mockRepository.find).toHaveBeenCalledTimes(1); - expect(mockRepository.find.mock.calls[0][0]!.withDeleted).toEqual(true); + expect(mockRepository.findOneOrFail).toHaveBeenCalledTimes(1); }); }); diff --git a/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts b/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts index 23ec5bc9d9..561b8c1dad 100644 --- a/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts +++ b/services/apps/alcs/src/alcs/planning-review/planning-review.service.ts @@ -1,24 +1,21 @@ -import { - ServiceNotFoundException, - ServiceValidationException, -} from '@app/common/exceptions/base.exception'; -import { Mapper } from 'automapper-core'; -import { InjectMapper } from 'automapper-nestjs'; +import { ServiceNotFoundException } from '@app/common/exceptions/base.exception'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { - FindOptionsRelations, - FindOptionsWhere, - IsNull, - Not, - Repository, -} from 'typeorm'; +import { Mapper } from 'automapper-core'; +import { InjectMapper } from 'automapper-nestjs'; +import { FindOptionsRelations, FindOptionsWhere, Repository } from 'typeorm'; +import { FileNumberService } from '../../file-number/file-number.service'; +import { formatIncomingDate } from '../../utils/incoming-date.formatter'; +import { filterUndefined } from '../../utils/undefined'; import { Board } from '../board/board.entity'; import { CARD_TYPE } from '../card/card-type/card-type.entity'; import { CardService } from '../card/card.service'; +import { PlanningReferral } from './planning-referral/planning-referral.entity'; +import { PlanningReviewType } from './planning-review-type.entity'; import { CreatePlanningReviewDto, PlanningReviewDto, + UpdatePlanningReviewDto, } from './planning-review.dto'; import { PlanningReview } from './planning-review.entity'; @@ -27,49 +24,46 @@ export class PlanningReviewService { constructor( private cardService: CardService, @InjectRepository(PlanningReview) - private repository: Repository<PlanningReview>, + private reviewRepository: Repository<PlanningReview>, + @InjectRepository(PlanningReviewType) + private typeRepository: Repository<PlanningReviewType>, + @InjectRepository(PlanningReferral) + private referralRepository: Repository<PlanningReferral>, @InjectMapper() private mapper: Mapper, + private fileNumberService: FileNumberService, ) {} - private CARD_RELATION = { - board: true, - type: true, - status: true, - assignee: true, - }; - private DEFAULT_RELATIONS: FindOptionsRelations<PlanningReview> = { - card: this.CARD_RELATION, localGovernment: true, region: true, + type: true, }; async create(data: CreatePlanningReviewDto, board: Board) { - const existingReview = await this.repository.findOne({ + const fileNumber = await this.fileNumberService.generateNextFileNumber(); + const type = await this.typeRepository.findOneOrFail({ where: { - fileNumber: data.fileNumber, + code: data.typeCode, }, }); - if (existingReview) { - throw new ServiceValidationException( - `Planning meeting already exists with File ID ${data.fileNumber}`, - ); - } - const planningReview = new PlanningReview({ - type: data.type, + type, localGovernmentUuid: data.localGovernmentUuid, - fileNumber: data.fileNumber, + fileNumber: fileNumber, regionCode: data.regionCode, + documentName: data.documentName, }); - planningReview.card = await this.cardService.create( - CARD_TYPE.PLAN, - board, - false, - ); - const savedReview = await this.repository.save(planningReview); - return this.getOrFail(savedReview.uuid); + const savedReview = await this.reviewRepository.save(planningReview); + + const referral = new PlanningReferral({ + planningReview: savedReview, + dueDate: formatIncomingDate(data.dueDate), + submissionDate: formatIncomingDate(data.submissionDate)!, + referralDescription: data.description, + card: await this.cardService.create(CARD_TYPE.PLAN, board, false), + }); + return await this.referralRepository.save(referral); } async getOrFail(uuid: string) { @@ -91,43 +85,32 @@ export class PlanningReviewService { ); } - async getByCardUuid(cardUuid: string) { - const planningReview = await this.repository.findOne({ - where: { cardUuid }, - relations: this.DEFAULT_RELATIONS, - }); - - if (!planningReview) { - throw new ServiceNotFoundException( - `Failed to find planning meeting with card uuid ${cardUuid}`, - ); - } - - return planningReview; - } - getBy(findOptions: FindOptionsWhere<PlanningReview>) { - return this.repository.find({ + return this.reviewRepository.find({ where: findOptions, relations: this.DEFAULT_RELATIONS, }); } - getDeletedCards(fileNumber: string) { - return this.repository.find({ + getDetailedReview(fileNumber: string) { + return this.reviewRepository.findOneOrFail({ where: { fileNumber, - card: { - auditDeletedDateAt: Not(IsNull()), + }, + relations: { + ...this.DEFAULT_RELATIONS, + referrals: true, + }, + order: { + referrals: { + auditCreatedAt: 'DESC', }, }, - withDeleted: true, - relations: this.DEFAULT_RELATIONS, }); } private get(uuid: string) { - return this.repository.findOne({ + return this.reviewRepository.findOne({ where: { uuid, }, @@ -135,43 +118,37 @@ export class PlanningReviewService { }); } - async getByBoard(boardUuid: string) { - const res = await this.repository.find({ - relations: { - ...this.DEFAULT_RELATIONS, - card: { ...this.CARD_RELATION, board: false }, + async listTypes() { + return this.typeRepository.find({ + order: { + label: 'ASC', }, + }); + } + + async update(fileNumber: string, updateDto: UpdatePlanningReviewDto) { + const existingApp = await this.reviewRepository.findOneOrFail({ where: { - card: { - boardUuid, - auditDeletedDateAt: IsNull(), - }, + fileNumber, }, }); - //Typeorm bug its returning deleted cards - return res.filter((review) => !!review.card); + + existingApp.open = filterUndefined(updateDto.open, existingApp.open); + existingApp.typeCode = filterUndefined( + updateDto.typeCode, + existingApp.typeCode, + ); + + await this.reviewRepository.save(existingApp); + return this.getDetailedReview(fileNumber); } - async getWithIncompleteSubtaskByType(subtaskType: string) { - return this.repository.find({ + async getFileNumber(planningReviewUuid: string) { + return this.reviewRepository.findOneOrFail({ where: { - card: { - subtasks: { - completedAt: IsNull(), - type: { - code: subtaskType, - }, - }, - }, - }, - relations: { - card: { - status: true, - board: true, - type: true, - subtasks: { type: true, assignee: true }, - }, + uuid: planningReviewUuid, }, + select: ['fileNumber'], }); } } diff --git a/services/apps/alcs/src/alcs/search/non-applications/non-applications-view.entity.ts b/services/apps/alcs/src/alcs/search/non-applications/non-applications-view.entity.ts deleted file mode 100644 index bd502a97d5..0000000000 --- a/services/apps/alcs/src/alcs/search/non-applications/non-applications-view.entity.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - JoinColumn, - ManyToOne, - PrimaryColumn, - ViewColumn, - ViewEntity, -} from 'typeorm'; -import { LocalGovernment } from '../../local-government/local-government.entity'; - -@ViewEntity({ - expression: ` - SELECT - non_applications."uuid" - ,non_applications."file_number" - ,non_applications."applicant" - ,non_applications."type" - ,non_applications."class" - ,non_applications."local_government_uuid" as "local_government_uuid" - ,non_applications."card_uuid" - ,non_applications."board_code" - ,non_applications."region_code" - FROM - ( - SELECT - cov.uuid AS "uuid", - cov.file_number AS "file_number", - "applicant", - NULL AS "type", - 'COV' AS "class", - cov.local_government_uuid AS "local_government_uuid", - card.uuid AS "card_uuid", - board.code AS "board_code", - cov.region_code AS "region_code" - FROM - alcs.covenant cov - LEFT JOIN alcs.card card ON - cov.card_uuid = card.uuid AND card.audit_deleted_date_at IS NULL - LEFT JOIN alcs.board board ON - board.uuid = card.board_uuid AND board.audit_deleted_date_at IS NULL - WHERE cov.audit_deleted_date_at IS NULL - UNION - SELECT - planning_review.uuid AS "uuid", - planning_review.file_number AS "file_number", - NULL AS "applicant", - "type", - 'PLAN' AS "class", - planning_review.local_government_uuid AS "local_government_uuid", - card.uuid AS "card_uuid", - board.code AS "board_code", - planning_review.region_code AS "region_code" - FROM - alcs.planning_review planning_review - LEFT JOIN alcs.card card ON - planning_review.card_uuid = card.uuid AND card.audit_deleted_date_at IS NULL - LEFT JOIN alcs.board board ON - board.uuid = card.board_uuid AND board.audit_deleted_date_at IS NULL - WHERE planning_review.audit_deleted_date_at IS NULL - ) AS non_applications -`, -}) -export class NonApplicationSearchView { - @ViewColumn() - @PrimaryColumn() - uuid: string; - - @ViewColumn() - fileNumber: string; - - @ViewColumn() - applicant: string | null; - - @ViewColumn() - type: string | null; - - @ViewColumn() - localGovernmentUuid: string | null; - - @ViewColumn() - class: 'COV' | 'PLAN'; - - @ViewColumn() - cardUuid: string | null; - - @ViewColumn() - boardCode: string | null; - - @ViewColumn() - regionCode: string; - - @ManyToOne(() => LocalGovernment, { - nullable: true, - }) - @JoinColumn({ name: 'local_government_uuid' }) - localGovernment: LocalGovernment | null; -} diff --git a/services/apps/alcs/src/alcs/search/non-applications/non-applications.service.spec.ts b/services/apps/alcs/src/alcs/search/non-applications/non-applications.service.spec.ts deleted file mode 100644 index ba300db684..0000000000 --- a/services/apps/alcs/src/alcs/search/non-applications/non-applications.service.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { SearchRequestDto } from '../search.dto'; -import { NonApplicationSearchView } from './non-applications-view.entity'; -import { NonApplicationsAdvancedSearchService } from './non-applications.service'; - -describe('NonApplicationsService', () => { - let service: NonApplicationsAdvancedSearchService; - let mockNonApplicationsRepository: DeepMocked< - Repository<NonApplicationSearchView> - >; - - let mockQuery: any = {}; - - const mockSearchRequestDto: SearchRequestDto = { - fileNumber: '123', - governmentName: 'B', - regionCode: 'C', - name: 'D', - page: 1, - pageSize: 10, - sortField: 'applicant', - sortDirection: 'ASC', - fileTypes: [], - }; - - beforeEach(async () => { - mockNonApplicationsRepository = createMock(); - - mockQuery = { - getManyAndCount: jest.fn().mockResolvedValue([[], 0]), - orderBy: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - leftJoinAndMapOne: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - NonApplicationsAdvancedSearchService, - { - provide: getRepositoryToken(NonApplicationSearchView), - useValue: mockNonApplicationsRepository, - }, - ], - }).compile(); - - service = module.get<NonApplicationsAdvancedSearchService>( - NonApplicationsAdvancedSearchService, - ); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - it('should successfully build a query using all search parameters defined', async () => { - mockNonApplicationsRepository.createQueryBuilder.mockReturnValue( - mockQuery as any, - ); - - const result = await service.searchNonApplications(mockSearchRequestDto); - - expect(result).toEqual({ data: [], total: 0 }); - expect(mockNonApplicationsRepository.createQueryBuilder).toBeCalledTimes(1); - expect(mockQuery.andWhere).toBeCalledTimes(4); - expect(mockQuery.where).toBeCalledTimes(1); - }); - - it('should call compileSearchQuery method correctly', async () => { - const compileNonApplicationSearchQuerySpy = jest - .spyOn(service as any, 'compileSearchQuery') - .mockResolvedValue(mockQuery); - - const result = await service.searchNonApplications(mockSearchRequestDto); - - expect(result).toEqual({ data: [], total: 0 }); - expect(compileNonApplicationSearchQuerySpy).toBeCalledWith( - mockSearchRequestDto, - ); - expect(mockQuery.orderBy).toHaveBeenCalledTimes(1); - expect(mockQuery.offset).toHaveBeenCalledTimes(1); - expect(mockQuery.limit).toHaveBeenCalledTimes(1); - }); -}); diff --git a/services/apps/alcs/src/alcs/search/non-applications/non-applications.service.ts b/services/apps/alcs/src/alcs/search/non-applications/non-applications.service.ts deleted file mode 100644 index 0586789645..0000000000 --- a/services/apps/alcs/src/alcs/search/non-applications/non-applications.service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { formatStringToPostgresSearchStringArrayWithWildCard } from '../../../utils/search-helper'; -import { LocalGovernment } from '../../local-government/local-government.entity'; -import { AdvancedSearchResultDto, SearchRequestDto } from '../search.dto'; -import { NonApplicationSearchView } from './non-applications-view.entity'; - -@Injectable() -export class NonApplicationsAdvancedSearchService { - constructor( - @InjectRepository(NonApplicationSearchView) - private nonApplicationSearchRepository: Repository<NonApplicationSearchView>, - ) {} - - async searchNonApplications( - searchDto: SearchRequestDto, - ): Promise<AdvancedSearchResultDto<NonApplicationSearchView[]>> { - let query = await this.compileSearchQuery(searchDto); - - const sortQuery = this.compileSortQuery(searchDto); - - query = query - .orderBy( - sortQuery, - searchDto.sortDirection, - searchDto.sortDirection === 'ASC' ? 'NULLS FIRST' : 'NULLS LAST', - ) - .offset((searchDto.page - 1) * searchDto.pageSize) - .limit(searchDto.pageSize); - - const result = await query.getManyAndCount(); - - return { - data: result[0], - total: result[1], - }; - } - - private compileSortQuery(searchDto: SearchRequestDto) { - switch (searchDto.sortField) { - case 'applicant': - return '"nonApp"."applicant"'; - - case 'government': - return '"localGovernment"."name"'; - - case 'type': - return '"nonApp"."class"'; - - default: - case 'fileId': - return '"nonApp"."file_number"'; - } - } - - private async compileSearchQuery(searchDto: SearchRequestDto) { - let query = this.nonApplicationSearchRepository - .createQueryBuilder('nonApp') - .leftJoinAndMapOne( - 'nonApp.localGovernment', - LocalGovernment, - 'localGovernment', - '"nonApp"."local_government_uuid" = "localGovernment".uuid', - ) - .where('1 = 1'); - - if (searchDto.fileNumber) { - query = query.andWhere('nonApp.file_number = :fileNumber', { - fileNumber: searchDto.fileNumber ?? null, - }); - } - - if (searchDto.regionCode) { - query = query.andWhere('nonApp.region_code = :regionCode', { - regionCode: searchDto.regionCode, - }); - } - - if (searchDto.governmentName) { - query = query.andWhere('localGovernment.name = :localGovernmentName', { - localGovernmentName: searchDto.governmentName, - }); - } - - if (searchDto.name) { - const formattedSearchString = - formatStringToPostgresSearchStringArrayWithWildCard(searchDto.name!); - - query = query.andWhere('LOWER(nonApp.applicant) LIKE ANY (:names)', { - names: formattedSearchString, - }); - } - - if (searchDto.fileTypes.length > 0) { - query = query.andWhere('nonApp.class IN (:...typeCodes)', { - typeCodes: searchDto.fileTypes, - }); - } - - return query; - } -} diff --git a/services/apps/alcs/src/alcs/search/search.controller.spec.ts b/services/apps/alcs/src/alcs/search/search.controller.spec.ts index ebf3a417ff..41f513ec68 100644 --- a/services/apps/alcs/src/alcs/search/search.controller.spec.ts +++ b/services/apps/alcs/src/alcs/search/search.controller.spec.ts @@ -1,8 +1,8 @@ +import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { classes } from 'automapper-classes'; import { AutomapperModule } from 'automapper-nestjs'; -import { createMock, DeepMocked } from '@golevelup/nestjs-testing'; -import { Test, TestingModule } from '@nestjs/testing'; import { ClsService } from 'nestjs-cls'; import { DataSource, QueryRunner, Repository } from 'typeorm'; import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; @@ -13,9 +13,7 @@ import { ApplicationType } from '../code/application-code/application-type/appli import { Covenant } from '../covenant/covenant.entity'; import { NoticeOfIntent } from '../notice-of-intent/notice-of-intent.entity'; import { Notification } from '../notification/notification.entity'; -import { PlanningReview } from '../planning-review/planning-review.entity'; import { ApplicationAdvancedSearchService } from './application/application-advanced-search.service'; -import { NonApplicationsAdvancedSearchService } from './non-applications/non-applications.service'; import { NoticeOfIntentAdvancedSearchService } from './notice-of-intent/notice-of-intent-advanced-search.service'; import { NotificationAdvancedSearchService } from './notification/notification-advanced-search.service'; import { SearchController } from './search.controller'; @@ -27,7 +25,6 @@ describe('SearchController', () => { let mockSearchService: DeepMocked<SearchService>; let mockNoticeOfIntentAdvancedSearchService: DeepMocked<NoticeOfIntentAdvancedSearchService>; let mockApplicationAdvancedSearchService: DeepMocked<ApplicationAdvancedSearchService>; - let mockNonApplicationsAdvancedSearchService: DeepMocked<NonApplicationsAdvancedSearchService>; let mockNotificationAdvancedSearchService: DeepMocked<NotificationAdvancedSearchService>; let mockDataSource: DeepMocked<DataSource>; let mockQueryRunner: DeepMocked<QueryRunner>; @@ -37,7 +34,6 @@ describe('SearchController', () => { mockSearchService = createMock(); mockNoticeOfIntentAdvancedSearchService = createMock(); mockApplicationAdvancedSearchService = createMock(); - mockNonApplicationsAdvancedSearchService = createMock(); mockNotificationAdvancedSearchService = createMock(); mockDataSource = createMock(); mockAppTypeRepo = createMock(); @@ -61,10 +57,6 @@ describe('SearchController', () => { provide: ApplicationAdvancedSearchService, useValue: mockApplicationAdvancedSearchService, }, - { - provide: NonApplicationsAdvancedSearchService, - useValue: mockNonApplicationsAdvancedSearchService, - }, { provide: NotificationAdvancedSearchService, useValue: mockNotificationAdvancedSearchService, @@ -95,15 +87,6 @@ describe('SearchController', () => { mockSearchService.getApplication.mockResolvedValue(new Application()); mockSearchService.getNoi.mockResolvedValue(new NoticeOfIntent()); mockSearchService.getNotification.mockResolvedValue(new Notification()); - mockSearchService.getPlanningReview.mockResolvedValue( - new PlanningReview({ - card: { - board: { - code: 'fake_board', - } as Board, - } as Card, - }), - ); mockSearchService.getCovenant.mockResolvedValue( new Covenant({ card: { @@ -126,13 +109,6 @@ describe('SearchController', () => { total: 0, }); - mockNonApplicationsAdvancedSearchService.searchNonApplications.mockResolvedValue( - { - data: [], - total: 0, - }, - ); - mockNotificationAdvancedSearchService.search.mockResolvedValue({ data: [], total: 0, @@ -151,14 +127,12 @@ describe('SearchController', () => { expect(mockSearchService.getApplication).toBeCalledWith(searchString); expect(mockSearchService.getNoi).toBeCalledTimes(1); expect(mockSearchService.getNoi).toBeCalledWith(searchString); - expect(mockSearchService.getPlanningReview).toBeCalledTimes(1); - expect(mockSearchService.getPlanningReview).toBeCalledWith(searchString); expect(mockSearchService.getCovenant).toBeCalledTimes(1); expect(mockSearchService.getCovenant).toBeCalledWith(searchString); expect(mockSearchService.getNotification).toHaveBeenCalledTimes(1); expect(mockSearchService.getNotification).toBeCalledWith(searchString); expect(result).toBeDefined(); - expect(result.length).toBe(5); + expect(result.length).toBe(4); }); it('should call advanced search to retrieve Applications, NOIs, PlanningReviews, Covenants, Notifications', async () => { @@ -192,15 +166,6 @@ describe('SearchController', () => { ).toBeCalledWith(mockSearchRequestDto); expect(result.noticeOfIntents).toBeDefined(); expect(result.totalNoticeOfIntents).toBe(0); - - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledTimes(1); - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledWith(mockSearchRequestDto); - expect(result.nonApplications).toBeDefined(); - expect(result.totalNonApplications).toBe(0); }); it('should call applications advanced search to retrieve Applications', async () => { @@ -251,28 +216,6 @@ describe('SearchController', () => { expect(result.total).toBe(0); }); - it('should call non-applications advanced search to retrieve Non-Applications', async () => { - const mockSearchRequestDto: SearchRequestDto = { - pageSize: 1, - page: 1, - sortField: '1', - sortDirection: 'ASC', - fileTypes: [], - }; - - const result = - await controller.advancedSearchNonApplications(mockSearchRequestDto); - - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledTimes(1); - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledWith(mockSearchRequestDto); - expect(result.data).toBeDefined(); - expect(result.total).toBe(0); - }); - it('should call advanced search to retrieve Applications only when application file type selected', async () => { const mockSearchRequestDto = { pageSize: 1, @@ -320,57 +263,4 @@ describe('SearchController', () => { expect(result.noticeOfIntents).toBeDefined(); expect(result.totalNoticeOfIntents).toBe(0); }); - - it('should call advanced search to retrieve Non Applications only when non application file type selected', async () => { - const mockSearchRequestDto: SearchRequestDto = { - pageSize: 1, - page: 1, - sortField: '1', - sortDirection: 'ASC', - fileTypes: ['COV'], - }; - - const result = await controller.advancedSearch(mockSearchRequestDto); - - expect(result.totalNoticeOfIntents).toBe(0); - - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledTimes(1); - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledWith(mockSearchRequestDto); - expect(result.nonApplications).toBeDefined(); - expect(result.totalNonApplications).toBe(0); - }); - - it('should NOT call Non-applications advanced search to retrieve Non-applications if no non-application search fields specified', async () => { - const baseMockSearchRequestDto: SearchRequestDto = { - pageSize: 1, - page: 1, - sortField: '1', - sortDirection: 'ASC', - fileTypes: [], - }; - - const result = await controller.advancedSearch({ - ...baseMockSearchRequestDto, - legacyId: 'test', - }); - - expect( - mockApplicationAdvancedSearchService.searchApplications, - ).toBeCalledTimes(1); - expect( - mockApplicationAdvancedSearchService.searchApplications, - ).toBeCalledWith({ ...baseMockSearchRequestDto, legacyId: 'test' }, {}); - expect(result.applications).toBeDefined(); - expect(result.totalApplications).toBe(0); - - expect( - mockNonApplicationsAdvancedSearchService.searchNonApplications, - ).toBeCalledTimes(0); - expect(result.nonApplications).toBeDefined(); - expect(result.totalNonApplications).toBe(0); - }); }); diff --git a/services/apps/alcs/src/alcs/search/search.controller.ts b/services/apps/alcs/src/alcs/search/search.controller.ts index 3cf07d8e6d..1d2616f5ca 100644 --- a/services/apps/alcs/src/alcs/search/search.controller.ts +++ b/services/apps/alcs/src/alcs/search/search.controller.ts @@ -11,7 +11,6 @@ import { UserRoles } from '../../common/authorization/roles.decorator'; import { APPLICATION_SUBMISSION_TYPES } from '../../portal/pdf-generation/generate-submission-document.service'; import { isStringSetAndNotEmpty } from '../../utils/string-helper'; import { Application } from '../application/application.entity'; -import { ApplicationService } from '../application/application.service'; import { CARD_TYPE } from '../card/card-type/card-type.entity'; import { ApplicationTypeDto } from '../code/application-code/application-type/application-type.dto'; import { ApplicationType } from '../code/application-code/application-type/application-type.entity'; @@ -21,8 +20,6 @@ import { Notification } from '../notification/notification.entity'; import { PlanningReview } from '../planning-review/planning-review.entity'; import { ApplicationAdvancedSearchService } from './application/application-advanced-search.service'; import { ApplicationSubmissionSearchView } from './application/application-search-view.entity'; -import { NonApplicationSearchView } from './non-applications/non-applications-view.entity'; -import { NonApplicationsAdvancedSearchService } from './non-applications/non-applications.service'; import { NoticeOfIntentAdvancedSearchService } from './notice-of-intent/notice-of-intent-advanced-search.service'; import { NoticeOfIntentSubmissionSearchView } from './notice-of-intent/notice-of-intent-search-view.entity'; import { NotificationAdvancedSearchService } from './notification/notification-advanced-search.service'; @@ -31,7 +28,6 @@ import { AdvancedSearchResponseDto, AdvancedSearchResultDto, ApplicationSearchResultDto, - NonApplicationSearchResultDto, NoticeOfIntentSearchResultDto, NotificationSearchResultDto, SearchRequestDto, @@ -48,7 +44,6 @@ export class SearchController { @InjectMapper() private mapper: Mapper, private noticeOfIntentSearchService: NoticeOfIntentAdvancedSearchService, private applicationSearchService: ApplicationAdvancedSearchService, - private nonApplicationsSearchService: NonApplicationsAdvancedSearchService, private notificationSearchService: NotificationAdvancedSearchService, @InjectRepository(ApplicationType) private appTypeRepo: Repository<ApplicationType>, @@ -61,8 +56,6 @@ export class SearchController { async search(@Param('searchTerm') searchTerm) { const application = await this.searchService.getApplication(searchTerm); const noi = await this.searchService.getNoi(searchTerm); - const planningReview = - await this.searchService.getPlanningReview(searchTerm); const covenant = await this.searchService.getCovenant(searchTerm); const notification = await this.searchService.getNotification(searchTerm); @@ -72,7 +65,7 @@ export class SearchController { result, application, noi, - planningReview, + null, //TODO covenant, notification, ); @@ -142,17 +135,6 @@ export class SearchController { searchDto, ); } - - let nonApplications: AdvancedSearchResultDto< - NonApplicationSearchView[] - > | null = null; - if (searchNonApplications) { - nonApplications = - await this.nonApplicationsSearchService.searchNonApplications( - searchDto, - ); - } - let notifications: AdvancedSearchResultDto< NotificationSubmissionSearchView[] > | null = null; @@ -163,7 +145,7 @@ export class SearchController { return await this.mapAdvancedSearchResults( applicationSearchResult, noticeOfIntentSearchService, - nonApplications, + null, notifications, ); } finally { @@ -222,27 +204,6 @@ export class SearchController { }; } - @Post('/advanced/non-applications') - @UserRoles(...ROLES_ALLOWED_APPLICATIONS) - async advancedSearchNonApplications( - @Body() searchDto: SearchRequestDto, - ): Promise<AdvancedSearchResultDto<NonApplicationSearchResultDto[]>> { - const nonApplications = - await this.nonApplicationsSearchService.searchNonApplications(searchDto); - - const mappedSearchResult = await this.mapAdvancedSearchResults( - null, - null, - nonApplications, - null, - ); - - return { - total: mappedSearchResult.totalNonApplications, - data: mappedSearchResult.nonApplications, - }; - } - @Post('/advanced/notifications') @UserRoles(...ROLES_ALLOWED_APPLICATIONS) async advancedSearchNotifications( @@ -327,7 +288,7 @@ export class SearchController { noticeOfIntents: AdvancedSearchResultDto< NoticeOfIntentSubmissionSearchView[] > | null, - nonApplications: AdvancedSearchResultDto<NonApplicationSearchView[]> | null, + nonApplications: null, notifications: AdvancedSearchResultDto< NotificationSubmissionSearchView[] > | null, @@ -358,15 +319,6 @@ export class SearchController { ); } - const mappedNonApplications: NonApplicationSearchResultDto[] = []; - if (nonApplications?.data && nonApplications?.data.length > 0) { - mappedNonApplications.push( - ...nonApplications.data.map((nonApplication) => - this.mapNonApplicationToAdvancedSearchResult(nonApplication), - ), - ); - } - const mappedNotifications: NotificationSearchResultDto[] = []; if (notifications && notifications.data && notifications.data.length > 0) { mappedNotifications.push( @@ -380,8 +332,6 @@ export class SearchController { response.totalApplications = applications?.total ?? 0; response.noticeOfIntents = mappedNoticeOfIntents; response.totalNoticeOfIntents = noticeOfIntents?.total ?? 0; - response.nonApplications = mappedNonApplications; - response.totalNonApplications = nonApplications?.total ?? 0; response.notifications = mappedNotifications; response.totalNotifications = notifications?.total ?? 0; @@ -420,12 +370,12 @@ export class SearchController { private mapPlanningReviewToSearchResult( planning: PlanningReview, ): SearchResultDto { + //TODO return { - type: CARD_TYPE.PLAN, - referenceId: planning.cardUuid, - localGovernmentName: planning.localGovernment?.name, - fileNumber: planning.fileNumber, - boardCode: planning.card.board.code, + fileNumber: '', + localGovernmentName: undefined, + referenceId: '', + type: '', }; } @@ -491,20 +441,6 @@ export class SearchController { }; } - private mapNonApplicationToAdvancedSearchResult( - nonApplication: NonApplicationSearchView, - ): NonApplicationSearchResultDto { - return { - referenceId: nonApplication.cardUuid, - fileNumber: nonApplication.fileNumber, - applicant: nonApplication.applicant, - boardCode: nonApplication.boardCode, - type: nonApplication.type, - localGovernmentName: nonApplication.localGovernment?.name ?? null, - class: nonApplication.class, - }; - } - private mapNotificationToAdvancedSearchResult( notification: NotificationSubmissionSearchView, ): NoticeOfIntentSearchResultDto { diff --git a/services/apps/alcs/src/alcs/search/search.dto.ts b/services/apps/alcs/src/alcs/search/search.dto.ts index e169a54787..78b918f28f 100644 --- a/services/apps/alcs/src/alcs/search/search.dto.ts +++ b/services/apps/alcs/src/alcs/search/search.dto.ts @@ -69,7 +69,6 @@ export class NotificationSearchResultDto { export class AdvancedSearchResponseDto { applications: ApplicationSearchResultDto[]; noticeOfIntents: NoticeOfIntentSearchResultDto[]; - nonApplications: NonApplicationSearchResultDto[]; notifications: NotificationSearchResultDto[]; totalApplications: number; totalNoticeOfIntents: number; diff --git a/services/apps/alcs/src/alcs/search/search.module.ts b/services/apps/alcs/src/alcs/search/search.module.ts index 1ba7c64f06..6fbb75f13e 100644 --- a/services/apps/alcs/src/alcs/search/search.module.ts +++ b/services/apps/alcs/src/alcs/search/search.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationProfile } from '../../common/automapper/application.automapper.profile'; import { Application } from '../application/application.entity'; -import { ApplicationModule } from '../application/application.module'; import { ApplicationType } from '../code/application-code/application-type/application-type.entity'; import { Covenant } from '../covenant/covenant.entity'; import { LocalGovernment } from '../local-government/local-government.entity'; @@ -11,8 +10,6 @@ import { Notification } from '../notification/notification.entity'; import { PlanningReview } from '../planning-review/planning-review.entity'; import { ApplicationAdvancedSearchService } from './application/application-advanced-search.service'; import { ApplicationSubmissionSearchView } from './application/application-search-view.entity'; -import { NonApplicationSearchView } from './non-applications/non-applications-view.entity'; -import { NonApplicationsAdvancedSearchService } from './non-applications/non-applications.service'; import { NoticeOfIntentAdvancedSearchService } from './notice-of-intent/notice-of-intent-advanced-search.service'; import { NoticeOfIntentSubmissionSearchView } from './notice-of-intent/notice-of-intent-search-view.entity'; import { NotificationAdvancedSearchService } from './notification/notification-advanced-search.service'; @@ -32,7 +29,6 @@ import { SearchService } from './search.service'; LocalGovernment, ApplicationSubmissionSearchView, NoticeOfIntentSubmissionSearchView, - NonApplicationSearchView, NotificationSubmissionSearchView, ]), ], @@ -41,7 +37,6 @@ import { SearchService } from './search.service'; ApplicationProfile, ApplicationAdvancedSearchService, NoticeOfIntentAdvancedSearchService, - NonApplicationsAdvancedSearchService, NotificationAdvancedSearchService, ], controllers: [SearchController], diff --git a/services/apps/alcs/src/alcs/search/search.service.spec.ts b/services/apps/alcs/src/alcs/search/search.service.spec.ts index e901b4bc0c..a01f160f12 100644 --- a/services/apps/alcs/src/alcs/search/search.service.spec.ts +++ b/services/apps/alcs/src/alcs/search/search.service.spec.ts @@ -7,7 +7,6 @@ import { Covenant } from '../covenant/covenant.entity'; import { LocalGovernment } from '../local-government/local-government.entity'; import { NoticeOfIntent } from '../notice-of-intent/notice-of-intent.entity'; import { Notification } from '../notification/notification.entity'; -import { PlanningReview } from '../planning-review/planning-review.entity'; import { ApplicationSubmissionSearchView } from './application/application-search-view.entity'; import { SearchService } from './search.service'; @@ -15,7 +14,6 @@ describe('SearchService', () => { let service: SearchService; let mockApplicationRepository: DeepMocked<Repository<Application>>; let mockNoiRepository: DeepMocked<Repository<NoticeOfIntent>>; - let mockPlanningReviewRepository: DeepMocked<Repository<PlanningReview>>; let mockCovenantRepository: DeepMocked<Repository<Covenant>>; let mockApplicationSubmissionSearchView: DeepMocked< Repository<ApplicationSubmissionSearchView> @@ -28,7 +26,6 @@ describe('SearchService', () => { beforeEach(async () => { mockApplicationRepository = createMock(); mockNoiRepository = createMock(); - mockPlanningReviewRepository = createMock(); mockCovenantRepository = createMock(); mockApplicationSubmissionSearchView = createMock(); mockLocalGovernment = createMock(); @@ -45,10 +42,6 @@ describe('SearchService', () => { provide: getRepositoryToken(NoticeOfIntent), useValue: mockNoiRepository, }, - { - provide: getRepositoryToken(PlanningReview), - useValue: mockPlanningReviewRepository, - }, { provide: getRepositoryToken(Covenant), useValue: mockCovenantRepository, @@ -112,29 +105,6 @@ describe('SearchService', () => { expect(result).toBeDefined(); }); - it('should call repository to get planning review', async () => { - mockPlanningReviewRepository.findOne.mockResolvedValue( - new PlanningReview(), - ); - - const result = await service.getPlanningReview('fake'); - - expect(mockPlanningReviewRepository.findOne).toBeCalledTimes(1); - expect(mockPlanningReviewRepository.findOne).toBeCalledWith({ - where: { - fileNumber: fakeFileNumber, - card: { archived: false }, - }, - relations: { - card: { - board: true, - }, - localGovernment: true, - }, - }); - expect(result).toBeDefined(); - }); - it('should call repository to get covenant', async () => { mockCovenantRepository.findOne.mockResolvedValue(new Covenant()); diff --git a/services/apps/alcs/src/alcs/search/search.service.ts b/services/apps/alcs/src/alcs/search/search.service.ts index c69dc2906b..1351386f20 100644 --- a/services/apps/alcs/src/alcs/search/search.service.ts +++ b/services/apps/alcs/src/alcs/search/search.service.ts @@ -5,6 +5,7 @@ import { Application } from '../application/application.entity'; import { Covenant } from '../covenant/covenant.entity'; import { NoticeOfIntent } from '../notice-of-intent/notice-of-intent.entity'; import { Notification } from '../notification/notification.entity'; +import { PlanningReferral } from '../planning-review/planning-referral/planning-referral.entity'; import { PlanningReview } from '../planning-review/planning-review.entity'; const CARD_RELATIONSHIP = { @@ -21,8 +22,6 @@ export class SearchService { private applicationRepository: Repository<Application>, @InjectRepository(NoticeOfIntent) private noiRepository: Repository<NoticeOfIntent>, - @InjectRepository(PlanningReview) - private planningReviewRepository: Repository<PlanningReview>, @InjectRepository(Covenant) private covenantRepository: Repository<Covenant>, @InjectRepository(Notification) @@ -54,16 +53,6 @@ export class SearchService { }); } - async getPlanningReview(fileNumber: string) { - return await this.planningReviewRepository.findOne({ - where: { - fileNumber, - card: { archived: false }, - }, - relations: CARD_RELATIONSHIP, - }); - } - async getCovenant(fileNumber: string) { return await this.covenantRepository.findOne({ where: { diff --git a/services/apps/alcs/src/alcs/staff-journal/staff-journal.controller.ts b/services/apps/alcs/src/alcs/staff-journal/staff-journal.controller.ts index 6053726a9b..6d3c7fcb52 100644 --- a/services/apps/alcs/src/alcs/staff-journal/staff-journal.controller.ts +++ b/services/apps/alcs/src/alcs/staff-journal/staff-journal.controller.ts @@ -24,6 +24,7 @@ import { UpdateStaffJournalDto, CreateNoticeOfIntentStaffJournalDto, CreateNotificationStaffJournalDto, + CreatePlanningReviewStaffJournalDto, } from './staff-journal.dto'; import { StaffJournal } from './staff-journal.entity'; import { StaffJournalService } from './staff-journal.service'; @@ -92,6 +93,21 @@ export class StaffJournalController { return this.autoMapper.map(newRecord, StaffJournal, StaffJournalDto); } + @Post('/planning-review') + @UserRoles(...ROLES_ALLOWED_BOARDS) + async createForPlanningReview( + @Body() record: CreatePlanningReviewStaffJournalDto, + @Req() req, + ): Promise<StaffJournalDto> { + const newRecord = await this.staffJournalService.createForPlanningReview( + record.planningReviewUuid, + record.body, + req.user.entity, + ); + + return this.autoMapper.map(newRecord, StaffJournal, StaffJournalDto); + } + @Patch() @UserRoles(...ROLES_ALLOWED_BOARDS) async update( diff --git a/services/apps/alcs/src/alcs/staff-journal/staff-journal.dto.ts b/services/apps/alcs/src/alcs/staff-journal/staff-journal.dto.ts index 60c42b0704..4e15dcd8af 100644 --- a/services/apps/alcs/src/alcs/staff-journal/staff-journal.dto.ts +++ b/services/apps/alcs/src/alcs/staff-journal/staff-journal.dto.ts @@ -20,42 +20,38 @@ export class StaffJournalDto { isEditable = false; } -export class CreateApplicationStaffJournalDto { - @IsString() - @IsNotEmpty() - applicationUuid: string; - +class BaseCreateStaffJournalDto { @IsString() @IsNotEmpty() body: string; } -export class CreateNoticeOfIntentStaffJournalDto { +export class CreateApplicationStaffJournalDto extends BaseCreateStaffJournalDto { @IsString() @IsNotEmpty() - noticeOfIntentUuid: string; + applicationUuid: string; +} +export class CreateNoticeOfIntentStaffJournalDto extends BaseCreateStaffJournalDto { @IsString() @IsNotEmpty() - body: string; + noticeOfIntentUuid: string; } -export class CreateNotificationStaffJournalDto { +export class CreateNotificationStaffJournalDto extends BaseCreateStaffJournalDto { @IsString() @IsNotEmpty() notificationUuid: string; +} +export class CreatePlanningReviewStaffJournalDto extends BaseCreateStaffJournalDto { @IsString() @IsNotEmpty() - body: string; + planningReviewUuid: string; } -export class UpdateStaffJournalDto { +export class UpdateStaffJournalDto extends BaseCreateStaffJournalDto { @IsString() @IsNotEmpty() uuid: string; - - @IsString() - @IsNotEmpty() - body: string; } diff --git a/services/apps/alcs/src/alcs/staff-journal/staff-journal.entity.ts b/services/apps/alcs/src/alcs/staff-journal/staff-journal.entity.ts index 0dbf764297..d9726c024a 100644 --- a/services/apps/alcs/src/alcs/staff-journal/staff-journal.entity.ts +++ b/services/apps/alcs/src/alcs/staff-journal/staff-journal.entity.ts @@ -5,6 +5,7 @@ import { User } from '../../user/user.entity'; import { Application } from '../application/application.entity'; import { NoticeOfIntent } from '../notice-of-intent/notice-of-intent.entity'; import { Notification } from '../notification/notification.entity'; +import { PlanningReview } from '../planning-review/planning-review.entity'; @Entity() export class StaffJournal extends Base { @@ -48,4 +49,11 @@ export class StaffJournal extends Base { @Column({ nullable: true }) @Index() notificationUuid: string; + + @ManyToOne(() => PlanningReview) + planningReview: PlanningReview | null; + + @Column({ nullable: true }) + @Index() + planningReviewUuid: string; } diff --git a/services/apps/alcs/src/alcs/staff-journal/staff-journal.service.ts b/services/apps/alcs/src/alcs/staff-journal/staff-journal.service.ts index 729d6ca212..a3ab5c8c59 100644 --- a/services/apps/alcs/src/alcs/staff-journal/staff-journal.service.ts +++ b/services/apps/alcs/src/alcs/staff-journal/staff-journal.service.ts @@ -32,6 +32,9 @@ export class StaffJournalService { { notificationUuid: parentUuid, }, + { + planningReviewUuid: parentUuid, + }, ], relations: this.DEFAULT_STAFF_JOURNAL_RELATIONS, order: { @@ -91,6 +94,20 @@ export class StaffJournalService { return await this.staffJournalRepository.save(record); } + async createForPlanningReview( + planningReviewUuid: string, + noteBody: string, + author: User, + ) { + const record = new StaffJournal({ + body: noteBody, + planningReviewUuid, + author, + }); + + return await this.staffJournalRepository.save(record); + } + async delete(uuid: string): Promise<void> { const note = await this.staffJournalRepository.findOne({ where: { uuid }, diff --git a/services/apps/alcs/src/common/automapper/planning-meeting.automapper.profile.ts b/services/apps/alcs/src/common/automapper/planning-meeting.automapper.profile.ts deleted file mode 100644 index c5f68bd4dd..0000000000 --- a/services/apps/alcs/src/common/automapper/planning-meeting.automapper.profile.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createMap, Mapper } from 'automapper-core'; -import { AutomapperProfile, InjectMapper } from 'automapper-nestjs'; -import { Injectable } from '@nestjs/common'; -import { PlanningReviewDto } from '../../alcs/planning-review/planning-review.dto'; -import { PlanningReview } from '../../alcs/planning-review/planning-review.entity'; - -@Injectable() -export class PlanningReviewProfile extends AutomapperProfile { - constructor(@InjectMapper() mapper: Mapper) { - super(mapper); - } - - override get profile() { - return (mapper) => { - createMap(mapper, PlanningReview, PlanningReviewDto); - }; - } -} diff --git a/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts b/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts new file mode 100644 index 0000000000..b64c5ef743 --- /dev/null +++ b/services/apps/alcs/src/common/automapper/planning-review.automapper.profile.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { createMap, forMember, mapFrom, Mapper } from 'automapper-core'; +import { AutomapperProfile, InjectMapper } from 'automapper-nestjs'; +import { PlanningReferral } from '../../alcs/planning-review/planning-referral/planning-referral.entity'; +import { PlanningReviewDocumentDto } from '../../alcs/planning-review/planning-review-document/planning-review-document.dto'; +import { PlanningReviewDocument } from '../../alcs/planning-review/planning-review-document/planning-review-document.entity'; +import { PlanningReviewType } from '../../alcs/planning-review/planning-review-type.entity'; +import { + PlanningReferralDto, + PlanningReviewDetailedDto, + PlanningReviewDto, + PlanningReviewTypeDto, +} from '../../alcs/planning-review/planning-review.dto'; +import { PlanningReview } from '../../alcs/planning-review/planning-review.entity'; +import { DocumentCode } from '../../document/document-code.entity'; +import { DocumentTypeDto } from '../../document/document.dto'; + +@Injectable() +export class PlanningReviewProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper) => { + createMap(mapper, PlanningReviewType, PlanningReviewTypeDto); + createMap(mapper, PlanningReview, PlanningReviewDto); + createMap( + mapper, + PlanningReferral, + PlanningReferralDto, + forMember( + (dto) => dto.dueDate, + mapFrom((entity) => entity.dueDate?.getTime()), + ), + forMember( + (dto) => dto.submissionDate, + mapFrom((entity) => entity.submissionDate?.getTime()), + ), + forMember( + (dto) => dto.responseDate, + mapFrom((entity) => entity.responseDate?.getTime()), + ), + ); + createMap(mapper, PlanningReview, PlanningReviewDetailedDto); + + createMap( + mapper, + PlanningReviewDocument, + PlanningReviewDocumentDto, + forMember( + (a) => a.mimeType, + mapFrom((ad) => ad.document.mimeType), + ), + forMember( + (a) => a.fileName, + mapFrom((ad) => ad.document.fileName), + ), + forMember( + (a) => a.fileSize, + mapFrom((ad) => ad.document.fileSize), + ), + forMember( + (a) => a.uploadedBy, + mapFrom((ad) => ad.document.uploadedBy?.name), + ), + forMember( + (a) => a.uploadedAt, + mapFrom((ad) => ad.document.uploadedAt.getTime()), + ), + forMember( + (a) => a.documentUuid, + mapFrom((ad) => ad.document.uuid), + ), + forMember( + (a) => a.source, + mapFrom((ad) => ad.document.source), + ), + forMember( + (a) => a.system, + mapFrom((ad) => ad.document.system), + ), + ); + createMap(mapper, DocumentCode, DocumentTypeDto); + }; + } +} diff --git a/services/apps/alcs/src/main.module.ts b/services/apps/alcs/src/main.module.ts index 2983653d97..fb10cabf27 100644 --- a/services/apps/alcs/src/main.module.ts +++ b/services/apps/alcs/src/main.module.ts @@ -46,6 +46,9 @@ import { UserModule } from './user/user.module'; RedisModule, LoggerModule.forRoot({ pinoHttp: { + redact: { + paths: ['req.headers'], + }, level: config.get('LOG_LEVEL'), autoLogging: false, //Disable auto-logging every request/response for now transport: diff --git a/services/apps/alcs/src/main.ts b/services/apps/alcs/src/main.ts index 372dcf6e0d..9ac498d284 100644 --- a/services/apps/alcs/src/main.ts +++ b/services/apps/alcs/src/main.ts @@ -12,7 +12,7 @@ import * as config from 'config'; import { Logger } from 'nestjs-pino'; import { install } from 'source-map-support'; import { generateModuleGraph } from './commands/graph'; -import { importApplications, importNOIs } from './commands/import'; +import { importApplications } from './commands/import'; import { MainModule } from './main.module'; const registerSwagger = (app: NestFastifyApplication) => { diff --git a/services/apps/alcs/src/portal/application-document/application-document.controller.spec.ts b/services/apps/alcs/src/portal/application-document/application-document.controller.spec.ts index e197636f0d..420e84cfda 100644 --- a/services/apps/alcs/src/portal/application-document/application-document.controller.spec.ts +++ b/services/apps/alcs/src/portal/application-document/application-document.controller.spec.ts @@ -136,7 +136,9 @@ describe('ApplicationDocumentController', () => { }, }); + expect(appDocumentService.getInlineUrl).toHaveBeenCalledTimes(1); expect(res.url).toEqual(fakeUrl); + expect(res.fileName).toEqual(mockDocument.document.fileName); }); it('should call through for download', async () => { diff --git a/services/apps/alcs/src/portal/application-document/application-document.controller.ts b/services/apps/alcs/src/portal/application-document/application-document.controller.ts index 09394a6491..821fde6f82 100644 --- a/services/apps/alcs/src/portal/application-document/application-document.controller.ts +++ b/services/apps/alcs/src/portal/application-document/application-document.controller.ts @@ -75,7 +75,8 @@ export class ApplicationDocumentController { if (canAccessDocument) { const url = await this.applicationDocumentService.getInlineUrl(document); - return { url }; + const { fileName } = document.document; + return { url, fileName }; } throw new NotFoundException('Failed to find document'); diff --git a/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.spec.ts b/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.spec.ts index c3efe743c9..73c73b9c39 100644 --- a/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.spec.ts +++ b/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.spec.ts @@ -120,8 +120,9 @@ describe('NoticeOfIntentDocumentController', () => { const res = await controller.open('fake-uuid', mockRequest); - expect(res.url).toEqual(fakeUrl); expect(noiDocumentService.getInlineUrl).toHaveBeenCalledTimes(1); + expect(res.url).toEqual(fakeUrl); + expect(res.fileName).toEqual(mockDocument.document.fileName); }); it('should call through for download', async () => { diff --git a/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.ts b/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.ts index 4c287b96a2..5ccfe49ff8 100644 --- a/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.ts +++ b/services/apps/alcs/src/portal/notice-of-intent-document/notice-of-intent-document.controller.ts @@ -80,7 +80,8 @@ export class NoticeOfIntentDocumentController { if (canAccessDocument) { const url = await this.noticeOfIntentDocumentService.getInlineUrl(document); - return { url }; + const { fileName } = document.document; + return { url, fileName }; } throw new NotFoundException('Failed to find document'); diff --git a/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts b/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts index 2afbe33066..88891e0ae8 100644 --- a/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts +++ b/services/apps/alcs/src/portal/notification-document/notification-document.controller.spec.ts @@ -5,7 +5,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ClsService } from 'nestjs-cls'; import { mockKeyCloakProviders } from '../../../test/mocks/mockTypes'; import { VISIBILITY_FLAG } from '../../alcs/application/application-document/application-document.entity'; -import { NoticeOfIntentDocument } from '../../alcs/notice-of-intent/notice-of-intent-document/notice-of-intent-document.entity'; import { NotificationDocument } from '../../alcs/notification/notification-document/notification-document.entity'; import { NotificationDocumentService } from '../../alcs/notification/notification-document/notification-document.service'; import { NotificationService } from '../../alcs/notification/notification.service'; @@ -137,7 +136,11 @@ describe('NotificationDocumentController', () => { }, }); + expect(mockNotificationDocumentService.getInlineUrl).toHaveBeenCalledTimes( + 1, + ); expect(res.url).toEqual(fakeUrl); + expect(res.fileName).toEqual(mockDocument.document.fileName); }); it('should call through for download', async () => { diff --git a/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts b/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts index 54c990bb0a..555cde8cd6 100644 --- a/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts +++ b/services/apps/alcs/src/portal/notification-document/notification-document.controller.ts @@ -78,7 +78,8 @@ export class NotificationDocumentController { if (canAccessDocument) { const url = await this.notificationDocumentService.getInlineUrl(document); - return { url }; + const { fileName } = document.document; + return { url, fileName }; } throw new NotFoundException('Failed to find document'); diff --git a/services/apps/alcs/src/providers/keycloak/keycloak-config.service.ts b/services/apps/alcs/src/providers/keycloak/keycloak-config.service.ts index 99e315c2eb..ed45853805 100644 --- a/services/apps/alcs/src/providers/keycloak/keycloak-config.service.ts +++ b/services/apps/alcs/src/providers/keycloak/keycloak-config.service.ts @@ -20,6 +20,7 @@ export class KeycloakConfigService implements KeycloakConnectOptionsFactory { 'confidential-port': 0, tokenValidation: TokenValidation.OFFLINE, verifyTokenAudience: true, + logLevels: [], //Disable Expired Token Messages }; } } diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709614448912-add_oats_doc_app_id_to_notification_docs.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709614448912-add_oats_doc_app_id_to_notification_docs.ts new file mode 100644 index 0000000000..b40d09d386 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709614448912-add_oats_doc_app_id_to_notification_docs.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOatsDocAppIdToNotificationDocs1709614448912 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" ADD "oats_document_id" text`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" ADD "oats_application_id" text`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."notification_document"."oats_document_id" IS '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.documents/alcs.documents to alcs.notification_document.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."notification_document"."oats_application_id" IS '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.documents/alcs.documents to alcs.notification_document.'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."notification_document"."oats_document_id" IS '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.documents/alcs.documents to alcs.notification_document.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."notification_document"."oats_application_id" IS '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.documents/alcs.documents to alcs.notification_document.'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" DROP COLUMN "oats_document_id"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" DROP COLUMN "oats_application_id"`, + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709615421151-add_notification_unique_exclusion.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709615421151-add_notification_unique_exclusion.ts new file mode 100644 index 0000000000..f3e692ff08 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709615421151-add_notification_unique_exclusion.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNotificationUniqueExclusion1709615421151 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" ADD CONSTRAINT unique_doc_app_id UNIQUE (oats_document_id, oats_application_id)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."notification_document" DROP CONSTRAINT unique_doc_app_id UNIQUE (oats_document_id, oats_application_id)`, + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709662671997-planning_reviews_v2.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709662671997-planning_reviews_v2.ts new file mode 100644 index 0000000000..464a5f8c2f --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709662671997-planning_reviews_v2.ts @@ -0,0 +1,108 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PlanningReviewsV21709662671997 implements MigrationInterface { + name = 'PlanningReviewsV21709662671997'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`DROP VIEW "alcs"."non_application_search_view"`); + await queryRunner.query(`TRUNCATE TABLE "alcs"."planning_review"`); + await queryRunner.query( + `UPDATE "alcs"."card" SET "audit_deleted_date_at" = NOW(), "archived" = true WHERE "type_code" = 'PLAN'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP CONSTRAINT "FK_735dcdd4fa909a60d0fa1828f24"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_a62913da5fae4a128c8e8f264f"`, + ); + await queryRunner.query( + `CREATE TABLE "alcs"."planning_review_type" ("audit_deleted_date_at" TIMESTAMP WITH TIME ZONE, "audit_created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "audit_updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now(), "audit_created_by" character varying NOT NULL, "audit_updated_by" character varying, "label" character varying NOT NULL, "code" text NOT NULL, "description" text NOT NULL, "short_label" character varying NOT NULL, "background_color" character varying NOT NULL, "text_color" character varying NOT NULL, "html_description" text NOT NULL DEFAULT '', CONSTRAINT "UQ_ab764743ecbd39b1fc823d2445d" UNIQUE ("description"), CONSTRAINT "PK_d06659689a2bb22ccdc6a1a033b" PRIMARY KEY ("code"))`, + ); + await queryRunner.query( + `CREATE TABLE "alcs"."planning_referral" ("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(), "submission_date" TIMESTAMP WITH TIME ZONE NOT NULL, "due_date" TIMESTAMP WITH TIME ZONE, "response_date" TIMESTAMP WITH TIME ZONE, "referral_description" text, "response_description" text, "card_uuid" uuid NOT NULL, "planning_review_uuid" uuid, CONSTRAINT "REL_57f6fea41fefa2ca864a33b795" UNIQUE ("card_uuid"), CONSTRAINT "PK_1cd8a7e0399adfcc4cbd1ca7cb9" PRIMARY KEY ("uuid"))`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "type"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP CONSTRAINT "REL_03a05aa8fefbc2fc1cdf138d80"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "card_uuid"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "document_name" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "type_code" text NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "open" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "closed_date" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "closed_by_uuid" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD CONSTRAINT "FK_d06659689a2bb22ccdc6a1a033b" FOREIGN KEY ("type_code") REFERENCES "alcs"."planning_review_type"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD CONSTRAINT "FK_84caebfef3502f3fb80e168ba44" FOREIGN KEY ("closed_by_uuid") REFERENCES "alcs"."user"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" ADD CONSTRAINT "FK_095877a396b8c604d81d674f6f8" FOREIGN KEY ("planning_review_uuid") REFERENCES "alcs"."planning_review"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" ADD CONSTRAINT "FK_57f6fea41fefa2ca864a33b7950" FOREIGN KEY ("card_uuid") REFERENCES "alcs"."card"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" DROP CONSTRAINT "FK_57f6fea41fefa2ca864a33b7950"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" DROP CONSTRAINT "FK_095877a396b8c604d81d674f6f8"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP CONSTRAINT "FK_84caebfef3502f3fb80e168ba44"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP CONSTRAINT "FK_d06659689a2bb22ccdc6a1a033b"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "closed_by_uuid"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "closed_date"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "open"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "type_code"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "document_name"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "card_uuid" uuid NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD CONSTRAINT "REL_03a05aa8fefbc2fc1cdf138d80" UNIQUE ("card_uuid")`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "type" character varying NOT NULL`, + ); + await queryRunner.query(`DROP TABLE "alcs"."planning_referral"`); + await queryRunner.query(`DROP TABLE "alcs"."planning_review_type"`); + await queryRunner.query( + `CREATE INDEX "IDX_a62913da5fae4a128c8e8f264f" ON "alcs"."planning_review" ("file_number") `, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD CONSTRAINT "FK_735dcdd4fa909a60d0fa1828f24" FOREIGN KEY ("card_uuid") REFERENCES "alcs"."card"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709663586391-seed_planning_reviews_v2.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709663586391-seed_planning_reviews_v2.ts new file mode 100644 index 0000000000..271bdd32f0 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709663586391-seed_planning_reviews_v2.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedPlanningReviewsV21709663586391 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + //Statuses + await queryRunner.query(` + INSERT INTO "alcs"."planning_review_type" + ("audit_deleted_date_at", "audit_created_at", "audit_updated_at", "audit_created_by", "audit_updated_by", "label", "code", "description", "short_label", "background_color", "text_color", "html_description") VALUES + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Agricultural Area Plan', 'AAPP', 'Agricultural Area Plan', 'AAP', '#F5B8BA', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Misc Studies and Projects', 'MISC', 'Misc Studies and Projects', 'MISC', '#C8FCFC', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'L/FNG Boundary Adjustment', 'BAPP', 'L/FNG Boundary Adjustment', 'BA', '#FFDBE3', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'ALR Boundary', 'ALRB', 'ALR Boundary', 'ALRB', '#BDDCBD', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Regional Growth Strategy', 'RGSP', 'Regional Growth Strategy', 'RGS', '#FFE1B3', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Crown Land Use Plan', 'CLUP', 'Crown Land Use Plan', 'CLUP', '#B5C7E1', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Official Community Plan', 'OCPP', 'Official Community Plan', 'OCP', '#FFF9C5', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Transportation Plan', 'TPPP', 'Transportation Plan', 'TP', '#EDC0F5', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Utility/Energy Planning', 'UEPP', 'Utility/Energy Planning', 'UEP', '#E1F8C7', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Zoning Bylaw', 'ZBPP', 'Zoning Bylaw', 'ZB', '#B5D5E0', '#313132', DEFAULT), + (NULL, NOW(), NULL, 'migration-seed', NULL, 'Parks Planning', 'PARK', 'Parks Planning', 'PARK', '#C8E0FD', '#313132', DEFAULT); + `); + + //New Board + await queryRunner.query(` + INSERT INTO "alcs"."board" + ("uuid", "audit_deleted_date_at", "audit_created_at", "audit_updated_at", "audit_created_by", "audit_updated_by", "code", "title", "show_on_schedule") VALUES + ('e7b18852-4f8f-419e-83e3-60e706b4a494', NULL, NOW(), NULL, 'migration_seed', NULL, 'rppp', 'Regional Planning', 'f'); + `); + + //Allow Planning Cards on new board + await queryRunner.query(` + INSERT INTO "alcs"."board_allowed_card_types_card_type" ("board_uuid", "card_type_code") VALUES + ('e7b18852-4f8f-419e-83e3-60e706b4a494', 'PLAN'); + `); + + //Remove from Vetting + await queryRunner.query(` + DELETE FROM "alcs"."board_allowed_card_types_card_type" WHERE ("board_uuid" = 'bb70eb85-6250-49b9-9a5c-e3c2e0b9f3a2' AND "card_type_code" = 'PLAN'); + `); + + //Change creation from Executive Committee to Regional Planning Board + await queryRunner.query(` + DELETE FROM "alcs"."board_create_card_types_card_type" WHERE ("board_uuid" = 'd8c18278-cb41-474e-a180-534a101243ab' AND "card_type_code" = 'PLAN'); + `); + + await queryRunner.query(` + INSERT INTO "alcs"."board_create_card_types_card_type" ("board_uuid", "card_type_code") VALUES + ('e7b18852-4f8f-419e-83e3-60e706b4a494', 'PLAN'); + `); + + //Add column to board + await queryRunner.query(` + INSERT INTO "alcs"."board_status" + ("uuid", "audit_deleted_date_at", "audit_created_at", "audit_updated_at", "audit_created_by", "audit_updated_by", "order", "board_uuid", "status_code") VALUES + ('6560aaec-9b9d-4ad6-9b8b-ccf2ce384b69', NULL, NOW(), NULL, 'migration_seed', NULL, 0, 'e7b18852-4f8f-419e-83e3-60e706b4a494', 'SUBM'); + `); + } + + public async down(): Promise<void> { + //Nope + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709752843125-add_legacy_id_to_planning_review.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709752843125-add_legacy_id_to_planning_review.ts new file mode 100644 index 0000000000..bba87938c6 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709752843125-add_legacy_id_to_planning_review.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLegacyIdToPlanningReview1709752843125 + implements MigrationInterface +{ + name = 'AddLegacyIdToPlanningReview1709752843125'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" ADD "legacy_id" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" DROP COLUMN "legacy_id"`, + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709754346579-add_table_comments_to_pr.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709754346579-add_table_comments_to_pr.ts new file mode 100644 index 0000000000..98bc58f880 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709754346579-add_table_comments_to_pr.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTableCommentsToPr1709754346579 implements MigrationInterface { + name = 'AddTableCommentsToPr1709754346579'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `COMMENT ON TABLE "alcs"."planning_review" IS 'A review of a local government or municipalities plan'`, + ); + await queryRunner.query( + `COMMENT ON TABLE "alcs"."planning_referral" IS 'Planning Referrals represent each pass of a Planning Review with their own cards'`, + ); + } + + public async down(): Promise<void> { + //No + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709771987741-add_pr_staff_journal.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709771987741-add_pr_staff_journal.ts new file mode 100644 index 0000000000..fc26c56780 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709771987741-add_pr_staff_journal.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPrStaffJournal1709771987741 implements MigrationInterface { + name = 'AddPrStaffJournal1709771987741'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."staff_journal" ADD "planning_review_uuid" uuid`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_dd6d16cefeda057f9f7d1f909b" ON "alcs"."staff_journal" ("planning_review_uuid") `, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."staff_journal" ADD CONSTRAINT "FK_dd6d16cefeda057f9f7d1f909bc" FOREIGN KEY ("planning_review_uuid") REFERENCES "alcs"."planning_review"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."staff_journal" DROP CONSTRAINT "FK_dd6d16cefeda057f9f7d1f909bc"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_dd6d16cefeda057f9f7d1f909b"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."staff_journal" DROP COLUMN "planning_review_uuid"`, + ); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709856439937-add_pr_documents.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709856439937-add_pr_documents.ts new file mode 100644 index 0000000000..c2b21071a5 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709856439937-add_pr_documents.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPrDocuments1709856439937 implements MigrationInterface { + name = 'AddPrDocuments1709856439937'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "alcs"."planning_review_document" ("uuid" uuid NOT NULL DEFAULT gen_random_uuid(), "type_code" text, "description" text, "planning_review_uuid" uuid NOT NULL, "document_uuid" uuid, "visibility_flags" text array NOT NULL DEFAULT '{}', "evidentiary_record_sorting" integer, CONSTRAINT "REL_80d9441726c3d26ccd426cd469" UNIQUE ("document_uuid"), CONSTRAINT "PK_b8b1ceeaebfc4a6b5a746f0a85b" PRIMARY KEY ("uuid"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e95903f18d734736a1ba855569" ON "alcs"."planning_review_document" ("planning_review_uuid") `, + ); + await queryRunner.query( + `COMMENT ON TABLE "alcs"."planning_review_document" IS 'Stores planning review documents'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" ADD CONSTRAINT "FK_6ed3e4681afbbcd3444d7600a84" FOREIGN KEY ("type_code") REFERENCES "alcs"."document_code"("code") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" ADD CONSTRAINT "FK_e95903f18d734736a1ba8555698" FOREIGN KEY ("planning_review_uuid") REFERENCES "alcs"."planning_review"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" ADD CONSTRAINT "FK_80d9441726c3d26ccd426cd4699" FOREIGN KEY ("document_uuid") REFERENCES "alcs"."document"("uuid") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" DROP CONSTRAINT "FK_80d9441726c3d26ccd426cd4699"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" DROP CONSTRAINT "FK_e95903f18d734736a1ba8555698"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review_document" DROP CONSTRAINT "FK_6ed3e4681afbbcd3444d7600a84"`, + ); + await queryRunner.query( + `DROP INDEX "alcs"."IDX_e95903f18d734736a1ba855569"`, + ); + await queryRunner.query(`DROP TABLE "alcs"."planning_review_document"`); + } +} diff --git a/services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts b/services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts new file mode 100644 index 0000000000..afebde99c4 --- /dev/null +++ b/services/apps/alcs/src/providers/typeorm/migrations/1709857038186-move_legacy_id.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveLegacyId1709857038186 implements MigrationInterface { + name = 'MoveLegacyId1709857038186'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" DROP COLUMN "legacy_id"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" ADD "legacy_id" text`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."planning_review"."legacy_id" IS 'Application Id that is applicable only to paper version applications from 70s - 80s'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `COMMENT ON COLUMN "alcs"."planning_review"."legacy_id" IS 'Application Id that is applicable only to paper version applications from 70s - 80s'`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_review" DROP COLUMN "legacy_id"`, + ); + await queryRunner.query( + `ALTER TABLE "alcs"."planning_referral" ADD "legacy_id" text`, + ); + } +}