import { Injectable } from '@angular/core'; import { StatusService } from '@core/services/status.service'; import { TimeZoneService } from '@core/services/time-zone.service'; import { TranslationService } from '@core/services/translation.service'; import { BudgetService } from '@features/budgets/budget.service'; import { InKindAwardedItemApi } from '@features/in-kind/in-kind.typing'; import { ApplicantPermission } from '@features/my-workspace/my-workspace.typing'; import { APIResult, FileService, MoneyService, PaginationOptions } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import moment from 'moment'; import { ActivityStatusDefinitions } from './activity-status-definitions.service'; import { ApplicationActivityForUi, ApplicationActivityFromApi, GrantActivityChange, GrantActivityChangeFieldType, GrantActivityTypes, PreviousToCurrentActivityEnum } from './activity.typing'; import { ApplicationActivityResources } from './application-activity.resources'; @Injectable({ providedIn: 'root' }) export class ApplicationActivityService { constructor ( private translationService: TranslationService, private i18n: I18nService, private applicationActivityResources: ApplicationActivityResources, private statusService: StatusService, private timezoneService: TimeZoneService, private moneyService: MoneyService, private fileService: FileService, private activityStatusDefinitions: ActivityStatusDefinitions, private budgetService: BudgetService ) { } get activityMap () { return this.activityStatusDefinitions.activityMap; } /** * * @param paginationOptions: Pagination options for request * @returns My activity data */ async getMyActivity ( paginationOptions: PaginationOptions ) { const response = await this.applicationActivityResources.getMyActivity( paginationOptions ); const records = response.records.map((activity) => { return this.adaptActivityForUi(activity); }); return { records, recordCount: response.recordCount }; } /** * * @param id: Application ID * @param options: Pagination options for request * @returns Application activity data */ async getAppActivity ( id: number, options: PaginationOptions ): Promise> { options.retrieveTotalRecordCount = true; const results = await this.applicationActivityResources.getApplicationActivity( id, options ); const records = results.records.map((activity) => { return this.adaptActivityForUi(activity); }); return { success: true, data: { records, recordCount: results.recordCount } }; } /** * * @param id: Application ID * @returns All application activity data */ async getAllAppActivity ( id: number ): Promise { const options: PaginationOptions = { returnAll: true, retrieveTotalRecordCount: false, filterColumns: [], sortColumns: [], rowsPerPage: 1000000, pageNumber: 0 }; const results = await this.applicationActivityResources.getApplicationActivity( id, options ); const records = results.records.map((activity) => { return this.adaptActivityForUi(activity); }); return records; } /** * * @param activities: activities to convert to csv * @param isNomination: is nomination * @returns csv of activities */ convertActivitiesToCsv ( activities: ApplicationActivityForUi[], isNomination: boolean ): string { if (activities.length > 0) { const adapted = this.adaptActivitiesForCsv(activities, isNomination); return this.fileService.convertObjectArrayToCSVString(adapted); } return ''; } /** * * @param activities: activities to convert to csv * @param isNomination: is nomination * @returns the adapted activities */ adaptActivitiesForCsv ( activities: ApplicationActivityForUi[], isNomination: boolean ) { const appIdText = this.i18n.translate( isNomination ? 'GLOBAL:textNominationID' : 'common:textApplicationId', {}, isNomination ? 'Nomination ID' : 'Application ID' ); const activityIdText = this.i18n.translate( 'common:textActivityId', {}, 'Activity ID' ); const typeText = this.i18n.translate( 'common:hdrType', {}, 'Type' ); const descriptionText = this.i18n.translate( 'common:textDescription', {}, 'Description' ); const workflowLevelText = this.i18n.translate( 'common:lblWorkflowLevel', {}, 'Workflow level' ); const updatedByText = this.i18n.translate( 'GLOBAL:lblUpdatedBy', {}, 'Updated by' ); const impersonatedByText = this.i18n.translate( 'common:textImpersonatedBy', {}, 'Impersonated by' ); const dateText = this.i18n.translate( 'common:hdrDate', {}, 'Date' ); const commentText = this.i18n.translate( 'common:labelComment', {}, 'Comment' ); let hasImpersonation = false; const adapted = activities.map((activity) => { if (!!activity.createdImpersonatedByName) { hasImpersonation = true; } return { [appIdText]: activity.applicationId, [activityIdText]: activity.id, [typeText]: isNomination ? this.activityMap[activity.activityTypeId]?.nomTranslated : this.activityMap[activity.activityTypeId]?.appTranslated, [descriptionText]: activity.descriptionArray.join(', '), [workflowLevelText]: activity.workFlowLevelName, [updatedByText]: activity.createdByName, [impersonatedByText]: activity.createdImpersonatedByName, [dateText]: moment(activity.createdDate).format('ll'), [commentText]: activity.comment }; }); if (!hasImpersonation) { adapted.forEach((activity) => { delete activity[impersonatedByText]; }); } return adapted; } /** * * @param activity: Activity record for adapting * @returns the adapted activity */ adaptActivityForUi ( activity: ApplicationActivityFromApi ): ApplicationActivityForUi { if (activity.formId) { const viewTranslations = this.translationService.viewTranslations; const formTranslationMap = viewTranslations.FormTranslation; activity.formName = formTranslationMap[activity.formId]?.Name ?? activity.formName; } const grantActivityChangeParsed = activity.grantActivityChangeJson ? JSON.parse(activity.grantActivityChangeJson) : []; const grantActivityChange = grantActivityChangeParsed[0] as GrantActivityChange; const fieldType = grantActivityChangeParsed[0]?.fieldType; const activityTypeId = this.adaptActivityType( activity, fieldType ); const descriptionArray = this.getHtmlDescriptionsForActivity( activity, grantActivityChange ); const { previousInKindItems, currentInKindItems } = this.getInKindItems(activity, grantActivityChange, activityTypeId); const { previousAmount, currentAmount } = this.getCurrentAndPreviousAmount(activity, grantActivityChange); return { ...activity, activityTypeId, descriptionArray, descriptionString: descriptionArray.join(', '), isAwardOrPaymentUpdate: this.isAwardOrPaymentUpdate(activityTypeId), isAwardOrPaymentCreateOrDelete: this.isAwardOrPaymentSetOrDelete(activityTypeId), isInKind: this.isInKindChange(activityTypeId), previousInKindItems, currentInKindItems, previousAmount, currentAmount, isPayment: this.isPaymentChange(activityTypeId), grantActivityChange }; } /** * * @param activity: Activity record * @param activityChange: Activity change infoo * @returns the current and previous amounts */ getCurrentAndPreviousAmount ( activity: ApplicationActivityFromApi, activityChange: GrantActivityChange ): { currentAmount: string; previousAmount: string; } { if (activity.activityTypeId === GrantActivityTypes.RecommendedFundingAmountSet) { return { previousAmount: activityChange?.previousRecommendedFundingAmount ?? ('' + activity.recommendedFundingAmount), currentAmount: activityChange?.currentRecommendedFundingAmount ?? ('' + activity.recommendedFundingAmount) }; } return { previousAmount: activityChange?.previous, currentAmount: activityChange?.current || (activity.amount + '') }; } /** * * @param activity: Activity record * @param activityChange: Activity change info * @param type: Activity type * @returns current and previous in kind items */ getInKindItems ( activity: ApplicationActivityFromApi, activityChange: GrantActivityChange, type: GrantActivityTypes ): { previousInKindItems: InKindAwardedItemApi[]; currentInKindItems: InKindAwardedItemApi[]; } { if (this.isInKindChange(type)) { return { previousInKindItems: activityChange?.previousInKindItems ?? [], currentInKindItems: activityChange?.currentInKindItems || activity.awardInKindItems }; } return { previousInKindItems: [], currentInKindItems: [] }; } /** * * @param type: Activity type * @returns if the type is a payment change */ isPaymentChange (type: GrantActivityTypes) { return [ GrantActivityTypes.InKindPaymentCreated, GrantActivityTypes.InKindPaymentUpdated, GrantActivityTypes.InKindPaymentDeleted, GrantActivityTypes.PaymentCreated, GrantActivityTypes.PaymentUpdated, GrantActivityTypes.PaymentDeleted ].includes(type); } /** * * @param type: Activity type * @returns if the type is an in kind change */ isInKindChange (type: GrantActivityTypes) { return [ GrantActivityTypes.InKindAwardCreated, GrantActivityTypes.InKindAwardUpdated, GrantActivityTypes.InkindAwardDeleted, GrantActivityTypes.InKindPaymentCreated, GrantActivityTypes.InKindPaymentUpdated, GrantActivityTypes.InKindPaymentDeleted, GrantActivityTypes.InKindAmountRequestedUpdated ].includes(type); } /** * * @param type: Activity type * @returns if the type is an award or payment update */ isAwardOrPaymentUpdate (type: GrantActivityTypes) { return [ GrantActivityTypes.InKindAwardUpdated, GrantActivityTypes.InKindPaymentUpdated, GrantActivityTypes.AmountRequestedUpdated, GrantActivityTypes.InKindAmountRequestedUpdated, GrantActivityTypes.AwardUpdated, GrantActivityTypes.PaymentUpdated ].includes(type); } /** * * @param type: Activity type * @returns if the type is an award or payment set or delete */ isAwardOrPaymentSetOrDelete (type: GrantActivityTypes) { return [ GrantActivityTypes.InKindAwardCreated, GrantActivityTypes.InKindPaymentCreated, GrantActivityTypes.PaymentCreated, GrantActivityTypes.ApplicationAwarded, GrantActivityTypes.PaymentDeleted, GrantActivityTypes.InKindPaymentDeleted, GrantActivityTypes.AwardDeleted, GrantActivityTypes.InkindAwardDeleted, GrantActivityTypes.RecommendedFundingAmountSet ].includes(type); } /** * * @param activity: activity to get the description for * @returns the activities html description */ getHtmlDescriptionsForActivity ( activity: ApplicationActivityFromApi, activityChange: GrantActivityChange ): string[] { const descriptionsArray: string[] = this.initDescriptionsWithBasicItems( activity ); const type = activity.activityTypeId; // Applicant Activity const applicantPermissionUpdate = [ GrantActivityTypes.ApplicantAdded, GrantActivityTypes.ApplicantPermissionUpdated ].includes(type); const applicantPermissionRemove = type === GrantActivityTypes.ApplicantRemoved; const showGmName = type === GrantActivityTypes.ReminderSentToGrantManager; if (applicantPermissionUpdate || applicantPermissionRemove) { descriptionsArray.push(activity.applicantFullNameForActivity); } if (showGmName) { descriptionsArray.push(activity.grantManagerFullNameForActivity); } const currentPreviousString = this.appendPreviousToCurrentString( activity, activityChange ); if (currentPreviousString) { descriptionsArray.push(currentPreviousString); } if ( !activityChange && type === GrantActivityTypes.ApplicantPermissionUpdated ) { descriptionsArray.push( this.getApplicantPermissionUpdateText(false, '', activity.applicantPermissions) ); } // Application Canceled if (type === GrantActivityTypes.ApplicationCanceled) { const cancelReasonMap = this.statusService.getCancelationReasonMap(); const reasonString = this.i18n.translate( 'common:textReason', {}, 'Reason' ) + ': ' + cancelReasonMap[activityChange.canceledReason]; descriptionsArray.push(reasonString); } // GM Form Submitted const gmFormSubmitted = type === GrantActivityTypes.GrantManagerFormSubmitted && activity.managerFormResponsesRequired && activity.managerFormResponsesCompleted; if (gmFormSubmitted) { const gmFormText = this.i18n.translate( 'GLOBAL:textNumberOutOfNumberComplete', { number: activity.managerFormResponsesCompleted, count: activity.managerFormResponsesRequired }, '__number__ out of __count__ complete' ); descriptionsArray.push(gmFormText); } // Form Due Date Extended const formDateExtended = type === GrantActivityTypes.DueDateExtended && activity.formDueDate; if (formDateExtended) { const formDateText = this.i18n.translate( 'GLOBAL:textNewDueDate', { date: this.timezoneService.returnLocalDateTimeAndTZ(activity.formDueDate, 'll h:mm') }, 'New due date: __date__ ' ); descriptionsArray.push(formDateText); } // Budget Assigned const budgetAssigned = type === GrantActivityTypes.BudgetAssigned || type === GrantActivityTypes.ApplicationBudgetFundingSourceUpdated; if (budgetAssigned) { descriptionsArray.push( activity.assignedBudgetName + ' | ' + activity.assignedFundingSourceName ); } // Awards / Payments const isRemainingMoneyType = this.isAwardOrPaymentSetOrDelete(type); if (isRemainingMoneyType) { descriptionsArray.push( this.moneyService.formatMoney(activityChange?.current || `${activity.amount}`) ); } // Archive Reason Code if (activity.archiveReasonCode) { const archiveReasonCodeMap = this.statusService.archiveReasonCodeMap; descriptionsArray.push( archiveReasonCodeMap[activity.archiveReasonCode] ); } return descriptionsArray; } /** * * @param activity: Activity record * @returns the initial simple descriptions in the array */ initDescriptionsWithBasicItems ( activity: ApplicationActivityFromApi ) { const descriptionsArray: string[] = []; const attrs: (keyof ApplicationActivityFromApi)[] = [ 'formName', 'applicationTagList', 'fileName', 'communicationSubject' ]; attrs.forEach((attr) => { if (!!activity[attr]) { descriptionsArray.push(activity[attr] as string); } }); return descriptionsArray; } /** * * @param activity: Activity record * @param activityChange: Activity change * @returns the previous to current string. example: Level 1 > Level 2 */ appendPreviousToCurrentString ( activity: ApplicationActivityFromApi, activityChange: GrantActivityChange ): string { const previousToCurrentActivityType = this.getPreviousToCurrentActivityType( activity, activityChange ); let previous = ''; let current = ''; switch (previousToCurrentActivityType) { default: case PreviousToCurrentActivityEnum.NotApplicable: return ''; case PreviousToCurrentActivityEnum.ApplicantPermissionWithCurrentPrevious: previous = this.getApplicantPermissionUpdateText( true, activityChange.previous ); current = this.getApplicantPermissionUpdateText( true, activityChange.current ); break; case PreviousToCurrentActivityEnum.CycleUpdated: previous = activity.previousGrantProgramCycleName; current = activity.grantProgramCycleName; break; case PreviousToCurrentActivityEnum.AppRouted: previous = activity.previousWorkFlowLevelName; current = activity.workFlowLevelName; if (activity.workflowLevelAutomationRuleName) { current = `${current} (${activity.workflowLevelAutomationRuleName})`; } break; case PreviousToCurrentActivityEnum.OrgUpdated: previous = activity.previousOrganizationName; current = activity.organizationName; break; case PreviousToCurrentActivityEnum.ProgramUpdated: const viewTranslations = this.translationService.viewTranslations; const programTranslationMap = viewTranslations.Grant_Program; previous = programTranslationMap[activity.previousGrantProgramId]?.Name; current = programTranslationMap[activity.newGrantProgramId]?.Name ?? activity.grantProgramName; break; case PreviousToCurrentActivityEnum.PaymentStatusChanged: const paymentStatusMap = this.statusService.paymentStatusMap; previous = paymentStatusMap[activityChange.previous].translated; current = paymentStatusMap[activityChange.current].translated; break; // Money use cases case PreviousToCurrentActivityEnum.InKindAwardUpdated: case PreviousToCurrentActivityEnum.InKindPaymentUpdated: case PreviousToCurrentActivityEnum.AmontRequestedUpdated: case PreviousToCurrentActivityEnum.InKindRequestedUpdated: case PreviousToCurrentActivityEnum.CashAwardUpdated: case PreviousToCurrentActivityEnum.CashPaymentUpdated: if (activityChange) { previous = this.moneyService.formatMoney(activityChange.previous); current = this.moneyService.formatMoney(activityChange.current); } else { return `${this.moneyService.formatMoney(activity.amount)}`; } break; case PreviousToCurrentActivityEnum.PaymentBudgetFsUpdated: if (activityChange) { const previousBudgetId = activityChange.previousBudgetId || activityChange.updatedBudgetId; const currentBudgetId = activityChange.updatedBudgetId; const previousFsId = activityChange.previousFundingSourceId || activityChange.updatedFundingSourceId; const currentFsId = activityChange.updatedFundingSourceId; const budgetMap = this.budgetService.budgetNameMap; const fsMap = this.budgetService.fundingSourceNameMap; previous = `${budgetMap[previousBudgetId]} / ${fsMap[previousFsId]}`; current = `${budgetMap[currentBudgetId]} / ${fsMap[currentFsId]}`; } break; } if (previous !== current) { return `${previous} > ${current}`; } else { return current; } } /** * * @param activity: Application activity record * @param activityChange: Activity Change that has been parsed * @returns the correct PreviousToCurrentActivityEnum */ getPreviousToCurrentActivityType ( activity: ApplicationActivityFromApi, activityChange: GrantActivityChange ): PreviousToCurrentActivityEnum { const type = activity.activityTypeId; const applicantPermissionUpdate = [ GrantActivityTypes.ApplicantAdded, GrantActivityTypes.ApplicantPermissionUpdated ].includes(type); let returnVal = PreviousToCurrentActivityEnum.NotApplicable; if (!!activityChange && applicantPermissionUpdate) { returnVal = PreviousToCurrentActivityEnum.ApplicantPermissionWithCurrentPrevious; } else if (type === GrantActivityTypes.ApplicationCycleUpdated) { returnVal = PreviousToCurrentActivityEnum.CycleUpdated; } else if (type === GrantActivityTypes.ApplicationAdvanced) { returnVal = PreviousToCurrentActivityEnum.AppRouted; } else if (type === GrantActivityTypes.OrganizationUpdated) { returnVal = PreviousToCurrentActivityEnum.OrgUpdated; } else if ( type === GrantActivityTypes.ProgramUpdated && activity.previousGrantProgramId && activity.newGrantProgramId ) { returnVal = PreviousToCurrentActivityEnum.ProgramUpdated; } else if (type === GrantActivityTypes.PaymentStatusChange) { returnVal = PreviousToCurrentActivityEnum.PaymentStatusChanged; } else if (type === GrantActivityTypes.InKindAwardUpdated) { returnVal = PreviousToCurrentActivityEnum.InKindAwardUpdated; } else if (type === GrantActivityTypes.InKindPaymentUpdated) { returnVal = PreviousToCurrentActivityEnum.InKindPaymentUpdated; } else if (type === GrantActivityTypes.AmountRequestedUpdated) { returnVal = PreviousToCurrentActivityEnum.AmontRequestedUpdated; } else if (type === GrantActivityTypes.InKindAmountRequestedUpdated) { returnVal = PreviousToCurrentActivityEnum.InKindRequestedUpdated; } else if (type === GrantActivityTypes.AwardUpdated) { returnVal = PreviousToCurrentActivityEnum.CashAwardUpdated; } else if (type === GrantActivityTypes.PaymentUpdated) { returnVal = PreviousToCurrentActivityEnum.CashPaymentUpdated; } else if (type === GrantActivityTypes.PaymentBudgetFsUpdated) { returnVal = PreviousToCurrentActivityEnum.PaymentBudgetFsUpdated; } return returnVal; } /** * * @param activity: Activity to adapt * @param fieldType: GrantActivityChangeFieldType * @returns the adapted activity type */ adaptActivityType ( activity: ApplicationActivityFromApi, fieldType: GrantActivityChangeFieldType ) { // we are adapting the activities below after backend changes, // this will align those changes with our template logic if ([ GrantActivityTypes.PaymentCreated, GrantActivityTypes.ApplicationAwarded, GrantActivityTypes.AwardUpdated, GrantActivityTypes.PaymentUpdated, GrantActivityTypes.AmountRequestedUpdated, GrantActivityTypes.PaymentDeleted, GrantActivityTypes.AwardDeleted ].includes(activity.activityTypeId)) { let activityTypeId; switch (activity.activityTypeId) { case GrantActivityTypes.PaymentCreated: if (activity.isInKindPaymentOrAward) { activityTypeId = GrantActivityTypes.InKindPaymentCreated; } else { activityTypeId = activity.activityTypeId; } break; case GrantActivityTypes.ApplicationAwarded: if (activity.isInKindPaymentOrAward) { activityTypeId = GrantActivityTypes.InKindAwardCreated; } else { activityTypeId = activity.activityTypeId; } break; case GrantActivityTypes.AwardUpdated: if (activity.isInKindPaymentOrAward) { activityTypeId = GrantActivityTypes.InKindAwardUpdated; } else { activityTypeId = activity.activityTypeId; } break; case GrantActivityTypes.PaymentUpdated: if (activity.isInKindPaymentOrAward) { activityTypeId = GrantActivityTypes.InKindPaymentUpdated; } else { activityTypeId = activity.activityTypeId; } break; case GrantActivityTypes.AmountRequestedUpdated: if (fieldType === GrantActivityChangeFieldType.InKindAmountRequestedUpdated) { activityTypeId = GrantActivityTypes.InKindAmountRequestedUpdated; } else { activityTypeId = activity.activityTypeId; } break; case GrantActivityTypes.PaymentDeleted: if (activity.isInKindPaymentOrAward) { activityTypeId = GrantActivityTypes.InKindPaymentDeleted; } else { activityTypeId = activity.activityTypeId; } break; case GrantActivityTypes.AwardDeleted: if (activity.isInKindPaymentOrAward) { activityTypeId = GrantActivityTypes.InkindAwardDeleted; } else { activityTypeId = activity.activityTypeId; } break; default: activityTypeId = activity.activityTypeId; } return activityTypeId; } else { return activity.activityTypeId; } } /** * * @param isJSONString: Is the permission from a JSON string? * @param permissionString: Permission string from JSON * @param permissions: Applicant permission update details * @returns the text for applicant permission updates */ getApplicantPermissionUpdateText ( isJSONString: boolean, permissionString?: string, permissions?: ApplicantPermission ) { if (isJSONString) { return this.getApplicantPermissionChangeTranslation(permissionString); } else { return this.getApplicantPermissionTranslation(permissions); } } /** * * @param permissionString: Permission string from JSON * @returns the translated permission string */ getApplicantPermissionChangeTranslation (permissionString: string) { let translations: string[] = []; const permissionArray = permissionString.trim().split(','); const cleanArray = permissionArray.map((permission) => { return permission.trim().toLowerCase(); }); if (cleanArray.includes('can manage applicants')) { translations = [ ...translations, this.i18n.translate( 'APPLY:lblCanManageApplicants', {}, 'Can manage applicants' ) ]; } if (cleanArray.includes('can receive emails')) { translations = [ ...translations, this.i18n.translate( 'GLOBAL:lblCanReceiveEmails', {}, 'Can receive emails' ) ]; } if (cleanArray.includes('owner')) { translations = [ ...translations, this.i18n.translate( 'common:textOwner', {}, 'Owner' ) ]; } if (cleanArray.includes('none')) { translations = [ ...translations, this.i18n.translate( 'common:textNone', {}, 'None' ) ]; } return translations.join(', '); } /** * * @param permissions: Applicant permission update details * @returns the translated update text */ getApplicantPermissionTranslation (permissions: ApplicantPermission) { let translations: string[] = []; if (permissions.canManageApplicants) { translations = [ ...translations, this.i18n.translate( 'APPLY:lblCanManageApplicants', {}, 'Can manage applicants' ) ]; } if (permissions.canReceiveEmails) { translations = [ ...translations, this.i18n.translate( 'GLOBAL:lblCanReceiveEmails', {}, 'Can receive emails' ) ]; } if (permissions.isOwner) { translations = [ ...translations, this.i18n.translate( 'common:textOwner', {}, 'Owner' ) ]; } return translations.join(', '); } }