import { Injectable } from '@angular/core'; import { PolicyService } from '@core/services/policy.service'; import { TimeZoneService } from '@core/services/time-zone.service'; import { ApplicationForUi, ApplicationFromPaginated, ApproveDeclinePayload, AutomaticallyRouted, BulkRouteApplicationsPayload, ChangeStatusPayload, NotifyOfStatusForApi, RouteApplicationPayload } from '@core/typings/application.typing'; import { ApplicationStatuses } from '@core/typings/status.typing'; import { CancelApplicationPayload } from '@core/typings/ui/cancel-application.typing'; import { WorkflowManagerActions } from '@core/typings/workflow.typing'; import { AddOrganizationService } from '@features/add-organization/add-organization.service'; import { ApplicationActionResources } from '@features/application-manager/resources/application-action.resources'; import { UpdateProgramModalResponse } from '@features/application-manager/update-program-modal/update-program-modal.component'; import { ArchiveGroup } from '@features/archive/archive-modal/archive-modal.component'; import { BulkApproveAwardPayPayload } from '@features/awards/typings/award.typing'; import { ProgramService } from '@features/programs/program.service'; import { WorkflowService } from '@features/workflow/workflow.service'; import { AutoTableRepositoryFactory } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { ConfirmAndTakeActionService } from '@yourcause/common/modals'; import { NotifierService } from '@yourcause/common/notifier'; @Injectable({ providedIn: 'root' }) export class ApplicationActionService { constructor ( private policyService: PolicyService, private i18n: I18nService, private notifier: NotifierService, private logger: LogService, private applicationActionResources: ApplicationActionResources, private timezoneService: TimeZoneService, private programService: ProgramService, private autoTableFactory: AutoTableRepositoryFactory, private addOrgService: AddOrganizationService, private workflowService: WorkflowService, private confirmAndTakeActionService: ConfirmAndTakeActionService ) { } getApplicationActionFlags ( app: ApplicationFromPaginated|ApplicationForUi ) { return { canApprove: this.canTakeAction(app, WorkflowManagerActions.Approve), canDecline: this.canTakeAction(app, WorkflowManagerActions.Decline), canArchiveUnarchive: this.canTakeAction(app, WorkflowManagerActions.ArchiveUnarchive), canAwardPay: this.canTakeAction(app, WorkflowManagerActions.AwardPay), canDeleteDraft: this.canTakeAction(app, WorkflowManagerActions.DeleteApplications), canNotifyOfStatus: this.canTakeAction(app, WorkflowManagerActions.NotifyOfStatus), canRoute: this.canTakeAction(app, WorkflowManagerActions.Route), canUpdateCycle: this.canTakeAction(app, WorkflowManagerActions.UpdateCycle), canUpdateStatus: this.canTakeAction(app, WorkflowManagerActions.UpdateStatus), canViewComms: this.canTakeAction(app, WorkflowManagerActions.ViewCommunications), canManageCollabs: this.canTakeAction(app, WorkflowManagerActions.ManageCollabs), canSendMailMerge: this.canTakeAction(app, WorkflowManagerActions.MailMerge) }; } canTakeAction ( app: ApplicationFromPaginated|ApplicationForUi, action: WorkflowManagerActions ) { const canTakeActions = this.policyService.grantApplication.canManageAllApplications() || this.policyService.grantApplication.canTakeActionsOnAllApps(); const workflowId = 'workflowId' in app ? app.workflowId : app.currentWorkFlowId; const myWorkflowActions = this.workflowService.myWorkflowManagerRolesMap; const actions = myWorkflowActions[workflowId] || []; const canApproveOrDecline = [ ApplicationStatuses.AwaitingReview, ApplicationStatuses.InProgress ].includes(+app.applicationStatus); switch (action) { case WorkflowManagerActions.SendReminder: return ![ ApplicationStatuses.Canceled, ApplicationStatuses.Draft ].includes(app.applicationStatus) && (canTakeActions || actions.includes(WorkflowManagerActions.SendReminder)); case WorkflowManagerActions.Approve: const canApprove = canApproveOrDecline && (canTakeActions || actions.includes(WorkflowManagerActions.Approve)); return canApprove; case WorkflowManagerActions.Decline: return canApproveOrDecline && (canTakeActions || actions.includes(WorkflowManagerActions.Decline)); case WorkflowManagerActions.ArchiveUnarchive: return canTakeActions || actions.includes(WorkflowManagerActions.ArchiveUnarchive); case WorkflowManagerActions.AwardPay: return app.applicationStatus !== ApplicationStatuses.Canceled && (canTakeActions || actions.includes(WorkflowManagerActions.AwardPay)); case WorkflowManagerActions.DeleteApplications: return ( app.applicationStatus === ApplicationStatuses.Draft || app.applicationStatus === ApplicationStatuses.AwaitingReview || app.applicationStatus === ApplicationStatuses.InProgress || app.isArchived ) && ( canTakeActions || actions.includes(WorkflowManagerActions.DeleteApplications) ); case WorkflowManagerActions.NotifyOfStatus: return app.applicationStatus !== ApplicationStatuses.Canceled && (canTakeActions || actions.includes(WorkflowManagerActions.NotifyOfStatus)); case WorkflowManagerActions.Route: const canRoute = app.applicationStatus !== ApplicationStatuses.Hold; return canRoute && (canTakeActions || actions.includes(WorkflowManagerActions.Route)); case WorkflowManagerActions.UpdateCycle: return app.applicationStatus !== ApplicationStatuses.Canceled && (canTakeActions || actions.includes(WorkflowManagerActions.UpdateCycle)); case WorkflowManagerActions.UpdateStatus: const canUpdateStatus = !app.isDraft; return canUpdateStatus && (canTakeActions || actions.includes(WorkflowManagerActions.UpdateStatus)); case WorkflowManagerActions.ViewCommunications: return app.isDraft && (!app.isMasked || app.canViewMaskedApplicantInfo) && (canTakeActions || actions.includes(WorkflowManagerActions.ViewCommunications)); case WorkflowManagerActions.ManageCollabs: const canManageCollabs = app.programAllowCollaboration && (!app.isMasked || app.canViewMaskedApplicantInfo) && app.applicationStatus !== ApplicationStatuses.Canceled; return canManageCollabs && (canTakeActions || actions.includes(WorkflowManagerActions.ManageCollabs)) && app.applicationStatus !== ApplicationStatuses.Canceled; case WorkflowManagerActions.MailMerge: return (canTakeActions || actions.includes(WorkflowManagerActions.MailMerge)) && ![ ApplicationStatuses.Canceled, ApplicationStatuses.Draft ].includes(app.applicationStatus); case WorkflowManagerActions.AddTags: return canTakeActions || actions.includes(WorkflowManagerActions.AddTags) && app.applicationStatus !== ApplicationStatuses.Canceled; case WorkflowManagerActions.UpdateProgram: return !app.isDraft && app.applicationStatus !== ApplicationStatuses.Hold && app.applicationStatus !== ApplicationStatuses.Canceled && (canTakeActions || actions.includes(WorkflowManagerActions.UpdateProgram)); case WorkflowManagerActions.Cancel: const isValidStatus = [ ApplicationStatuses.AwaitingReview, ApplicationStatuses.Approved, ApplicationStatuses.Hold, ApplicationStatuses.InProgress ].includes(+app.applicationStatus); return isValidStatus && (canTakeActions || actions.includes(WorkflowManagerActions.Cancel)); default: return canTakeActions; } } showAutomaticallyRoutedToaster (isNomination = false) { this.successToastr(this.i18n.translate( isNomination ? 'APPLICATION:textRoutingRuleCriteriaMetNomination' : 'APPLICATION:textRoutingRuleCriteriaMetApplication', {}, isNomination ? 'Routing rule criteria met. Nomination has been routed to the next workflow level.' : 'Routing rule criteria met. Application has been routed to the next workflow level.' )); } async handleApproveDeclineModal ( type: 'Approve'|'Decline', payload: ApproveDeclinePayload, isApplicationManager = true, isNomination = false, isOffline = false ) { const id = payload.applicationIds[0]; let automaticallyRouted = false; if (type === 'Approve') { try { if (isApplicationManager) { await this.applicationActionResources.approveApplicationByAppManager( payload ); } else { const response = await this.applicationActionResources.approveApplication( id, payload ); automaticallyRouted = response.automaticallyRouted; } } catch (e) { this.handleApproveDeclineError(e as Error, type, isNomination); } } else { try { if (isApplicationManager) { await this.applicationActionResources.declineApplicationByAppManager( payload ); } else { const response = await this.applicationActionResources.declineApplication( id, payload ); automaticallyRouted = response.automaticallyRouted; } } catch (e) { this.handleApproveDeclineError(e as Error, type, isNomination); } } if (!isOffline) { if (type === 'Approve') { this.successToastr(this.i18n.translate( isNomination ? 'MANAGE:textSuccessfullyApproveNomination' : 'MANAGE:textSuccessfullyApproveApplication', {}, isNomination ? 'Successfully approved nomination' : 'Successfully approved application' )); } else { this.successToastr(this.i18n.translate( isNomination ? 'MANAGE:textSuccessfullyDeclineNomination' : 'MANAGE:textSuccessfullyDeclineApplication', {}, isNomination ? 'Successfully declined nomination' : 'Successfully declined application' )); } } if (automaticallyRouted) { this.showAutomaticallyRoutedToaster(isNomination); } this.resetMyWorkspace(); return true; } handleApproveDeclineError ( e: Error, type: 'Approve'|'Decline', isNomination = false ) { this.logger.error(e); if (type === 'Approve') { this.errorToastr(this.i18n.translate( isNomination ? 'MANAGE:textErrorApproveNomination' : 'MANAGE:textErrorApproveApplication', {}, `There was an error approving the ${ isNomination ? 'nomination' : 'application' }` )); } else { this.errorToastr(this.i18n.translate( isNomination ? 'MANAGE:textErrorDeclineNomination' : 'MANAGE:textErrorDeclineApplication', {}, `There was an error declining the ${ isNomination ? 'nomination' : 'application' }` )); } throw e; } resetMyWorkspace () { const myWorkspaceRepo = this.autoTableFactory.getRepository('MY_WORKSPACE'); if (myWorkspaceRepo) { myWorkspaceRepo.reset(); } } successToastr (message: string) { this.notifier.success(message); } errorToastr (message: string) { this.notifier.error(message); } /** * Fires method to hit endpoint for bulk approve award pay and also resets relevant data * * @param adaptedPayload has updated award dates for each application */ async bulkApproveAwardPayAndRefreshData ( adaptedPayload: BulkApproveAwardPayPayload ) { await this.applicationActionResources.bulkApproveAwardPay(adaptedPayload); this.programService.clearPaymentProcessingPrograms(); this.resetMyWorkspace(); } /** * Handles approving and/or awarding applications and success/error toaster * * @param payload includes list of applications, funding information, and custom message * @param awardOnly method is also used for awarding already approved applications */ async handleBulkApproveAwardPay ( payload: BulkApproveAwardPayPayload, awardOnly = false ) { const adaptedPayload = this.adaptPayloadForBulkAAP(payload); await this.confirmAndTakeActionService.genericTakeAction( () => this.bulkApproveAwardPayAndRefreshData(adaptedPayload), this.i18n.translate( awardOnly ? 'AWARDS:textSuccessAwardingAndPayingBulk' : 'AWARDS:textSuccessApproveAwardPayBulk', {}, awardOnly ? 'Successfully awarded the applications' : 'Successfully approved and awarded the applications' ), this.i18n.translate( awardOnly ? 'AWARDS:textErrorAwardingAndPayingBulk' : 'AWARDS:textErrorApproveAwardPayBulk', {}, awardOnly ? 'There was an error awarding the applications' : 'There was an error approving and awarding the applications' ), true ); } adaptPayloadForBulkAAP (payload: BulkApproveAwardPayPayload) { payload.applications.map((application) => { application.awards.forEach((award) => { award.awardDate = this.timezoneService.returnMidnightUTCDate(award.awardDate); }); }); return payload; } /** * handles routing and resets data * * @param payload includes application/nomination Ids and the new workflow level */ async handleBulkRouteAndResetMyWorkspace ( payload: BulkRouteApplicationsPayload ) { await this.applicationActionResources.bulkRouteApplications( payload ); this.resetMyWorkspace(); } /** * handles routing and resets data and success/error toaster * * @param data includes application/nomination Ids and the new workflow level * @param isNomination toggles nomination language */ async handleBulkRouteApplications ( data: BulkRouteApplicationsPayload, isNomination: boolean ) { await this.confirmAndTakeActionService.genericTakeAction( () => this.handleBulkRouteAndResetMyWorkspace(data), this.i18n.translate( isNomination ? 'MANAGE:textSuccessRoutedNominations' : 'MANAGE:textSuccessRoutedApplications', {}, isNomination ? 'Successfully routed the nominations' : 'Successfully routed the applications' ), this.i18n.translate( isNomination ? 'MANAGE:textErrorRoutingNominations' : 'MANAGE:textErrorRoutingApplications', {}, `There was an error routing the ${ isNomination ? 'nominations' : 'applications' }` ), true ); } /** * Handles routing and resets data * * @param id ID of application * @param data includes workflow level id and comments * @param isApplicationManager toggles endpoint called */ async handleRouteApplicationAndResetData ( id: number, data: RouteApplicationPayload, isApplicationManager = true ) { if (isApplicationManager) { await this.applicationActionResources.routeApplicationByAppManager( id, data ); } else { await this.applicationActionResources.routeApplication(id, data); } this.resetMyWorkspace(); } /** * Kicks off routing and resets data and success/error toaster * * @param id ID of application * @param data includes workflow level id and comments * @param isApplicationManager toggles endpoint called * @param isNomination toggles language */ async handleRouteApplication ( id: number, data: RouteApplicationPayload, isApplicationManager = true, isNomination = false ) { await this.confirmAndTakeActionService.genericTakeAction( () => this.handleRouteApplicationAndResetData( id, data, isApplicationManager ), this.i18n.translate( isNomination ? 'MANAGE:textSuccessRoutedNomination' : 'MANAGE:textSuccessRoutedApplication', {}, `Successfully routed the ${ isNomination ? 'nomination' : 'application' }` ), this.i18n.translate( isNomination ? 'MANAGE:textErrorRoutingNomination' : 'MANAGE:textErrorRoutingApplication', {}, `There was an error routing the ${ isNomination ? 'nomination' : 'application' }` ), true ); } /** * Calls endpoint to either archive or unarchive group of applications * * @param response modal returns archive code, ids for applications, and notes * @param context either archiving or unarchiving */ async handleArchiveModalResponse ( response: ArchiveGroup, context: 'archiveApp'|'unarchiveApp' ) { const payload = { applicationIds: response.ids, notes: response.notes, code: response.code }; await this.confirmAndTakeActionService.genericTakeAction( context === 'archiveApp' ? () => this.applicationActionResources.archiveApplication(payload) : () => this.applicationActionResources.unarchiveApplication(payload.applicationIds), context === 'archiveApp' ? this.i18n.translate( 'PROGRAM:textSuccessArchiveApplication', {}, 'Successfully archived the application' ) : this.i18n.translate( 'PROGRAM:textSuccessUnarchiveApplication', {}, 'Successfully unarchived the application' ), context === 'archiveApp' ? this.i18n.translate( 'PROGRAM:textErrorArchivingApplication', {}, 'There was an error archiving the application' ) : this.i18n.translate( 'PROGRAM:textErrorUnarchivingApplication', {}, 'There was an error unarchiving the application' ), true ); } /** * Calls an endpoint for updating the status of an application or nomination * * @param data payload from modal related to the change of status action * @param isNomination boolean, true when updating nominations */ async changeApplicationStatus ( data: ChangeStatusPayload, isNomination = false ) { await this.confirmAndTakeActionService.genericTakeAction( () => this.applicationActionResources.changeApplicationStatus(data), this.i18n.translate( isNomination ? 'APPLY:textSuccessfullyUpdatedStatusNom' : 'APPLY:textSuccessfullyUpdatedStatusApp', {}, isNomination ? 'Successfully updated the status of the nomination' : 'Successfully updated the status of the application' ), this.i18n.translate( isNomination ? 'APPLY:textErrorUpdatingStatusNom' : 'APPLY:textErrorUpdatingStatusApp', {}, isNomination ? 'There was an error updating the status of the nomination' : 'There was an error updating the status of the application' ), true ); } /** * Handles sending an email notifying the application of the status * * @param data payload includes applicationIds, custom msg, and email details * @param isNomination boolean, true for nominations */ async handleSendApplicationStatusEmail ( data: NotifyOfStatusForApi, isNomination = false ) { await this.confirmAndTakeActionService.genericTakeAction( () => this.applicationActionResources.sendApplicationStatusEmail(data), this.i18n.translate( isNomination ? 'MANAGE:textSuccessNotifyingNominator' : 'MANAGE:textSuccessNotifyingApplicant', {}, isNomination ? 'Successfully notified nominator of status' : 'Successfully notified applicant of status' ), this.i18n.translate( isNomination ? 'MANAGE:textErrorNotifyingNominator' : 'MANAGE:textErrorNotifyingApplicant', {}, isNomination ? 'There was an error notifying the nominator of the status' : 'There was an error notifying the applicant of the status' ), true ); } /** * Calls endpoint to delete a single application/nomination * * @param applicationId id for application to delete * @param isNomination boolean, true if nomination */ async handleDeleteApplication ( applicationId: number, isNomination = false ) { const response = await this.confirmAndTakeActionService.genericTakeAction( () => this.applicationActionResources.deleteApplication(applicationId), this.i18n.translate( isNomination ? 'APPLY:textSuccessfullyDeletedNomination' : 'APPLY:textSuccessfullyDeletedApplication', {}, isNomination ? 'Successfully deleted the nomination' : 'Successfully deleted the application' ), this.i18n.translate( isNomination ? 'APPLY:textErrorDeletingNomination' : 'APPLY:textErrorDeletingApplication', {}, isNomination ? 'There was an error deleting the nomination' : 'There was an error deleting the application' ) ); return response?.passed; } /** * Calls and endpoint to set the recommended funding amount for an application * * @param applicationId application ID for setting the amount * @param recommendedFundingAmount amount for recommended funding */ async handleSetRecommendedFunding ( applicationId: number, recommendedFundingAmount: number ) { const result = await this.confirmAndTakeActionService.genericTakeAction( () => this.applicationActionResources.setRecommendedFundingAmount( applicationId, recommendedFundingAmount ), this.i18n.translate( 'GLOBAL:textSuccessSettingRecommendedFundingAmount', {}, 'Successfully set the recommended funding amount' ), this.i18n.translate( 'GLOBAL:textErrorSettingRecommendedFundingAmount', {}, 'There was an error setting the recommended funding amount' ) ); if (result?.endpointResponse.automaticallyRouted) { this.showAutomaticallyRoutedToaster(); } } /** * Responsible for making calls to update a program and optionally handle vetting if relevant * * @param modalResponse Contains vetting information * @param applicationId Application being updated * @param organizationId Org for application being updated */ async handleUpdateProgramAndVetting ( modalResponse: UpdateProgramModalResponse, applicationId: number, organizationId: number ) { if (modalResponse.vettingInfo) { await this.addOrgService.handleVettingAfterUpdateProgram( organizationId, modalResponse.vettingInfo.fullName, modalResponse.vettingInfo.email, modalResponse.vettingInfo.website, modalResponse.cycleId, applicationId ); } await this.applicationActionResources.updateProgram( modalResponse, applicationId ); } /** * Kicks off request for updating program and vetting and shows toaster for success/error * * @param modalResponse Contains vetting information * @param applicationId Application being updated * @param organizationId Org for application being updated */ async handleUpdateProgram ( modalResponse: UpdateProgramModalResponse, applicationId: number, organizationId: number ) { await this.confirmAndTakeActionService.genericTakeAction( () => this.handleUpdateProgramAndVetting( modalResponse, applicationId, organizationId ), this.i18n.translate( 'PROGRAM:textSuccessfullyUpdatedProgram', {}, 'Successfully updated the program' ), this.i18n.translate( 'PROGRAM:textErrorUpdatingProgram', {}, 'There was an error updating the program' ) ); } /** * Calls an endpoint to cancel and application * * @param payload Contains applicationID, email info, and info related to the action like reason, and a comment */ async cancelApplication ( payload: CancelApplicationPayload ) { await this.confirmAndTakeActionService.genericTakeAction( () => this.applicationActionResources.cancelApplication(payload), this.i18n.translate( 'common:textSuccessfullyCanceledApplication', {}, 'Successfully canceled application' ), this.i18n.translate( 'common:textProblemCancelingApplication', {}, 'There was a problem canceling this application' ) ); } }