|
| 1 | +import { ModuleRef } from '@nestjs/core'; |
| 2 | +import { |
| 3 | + asNonEmptyArray, |
| 4 | + groupBy, |
| 5 | + type NonEmptyArray, |
| 6 | + setOf, |
| 7 | +} from '@seedcompany/common'; |
| 8 | +import { EmailService } from '@seedcompany/nestjs-email'; |
| 9 | +import { |
| 10 | + Book, |
| 11 | + mergeVerseRanges, |
| 12 | + splitRangeByBook, |
| 13 | + type Verse, |
| 14 | +} from '@seedcompany/scripture'; |
| 15 | +import { type ComponentProps as PropsOf } from 'react'; |
| 16 | +import { type ID, type Range } from '~/common'; |
| 17 | +import { |
| 18 | + ConfigService, |
| 19 | + EventsHandler, |
| 20 | + type IEventHandler, |
| 21 | + ILogger, |
| 22 | + Logger, |
| 23 | + ResourceLoader, |
| 24 | +} from '~/core'; |
| 25 | +import { Identity } from '~/core/authentication'; |
| 26 | +import { |
| 27 | + type ProgressReport, |
| 28 | + ProgressReportStatus as Status, |
| 29 | +} from '../../../components/progress-report/dto'; |
| 30 | +import { WorkflowUpdatedEvent } from '../../../components/progress-report/workflow/events/workflow-updated.event'; |
| 31 | +import { ProductService } from '../../product'; |
| 32 | +import { ProgressReportVariantProgress } from '../../product-progress/dto'; |
| 33 | +import { ProductProgressByReportLoader } from '../../product-progress/product-progress-by-report.loader'; |
| 34 | +import { |
| 35 | + asProductType, |
| 36 | + DirectScriptureProduct, |
| 37 | + ProductListInput, |
| 38 | + resolveProductType, |
| 39 | +} from '../../product/dto'; |
| 40 | +import { ProjectMemberRepository } from '../../project/project-member/project-member.repository'; |
| 41 | +import { DBLUpload } from '../templates/dbl-upload-email.template'; |
| 42 | + |
| 43 | +@EventsHandler(WorkflowUpdatedEvent) |
| 44 | +export class DBLUploadNotificationHandler |
| 45 | + implements IEventHandler<WorkflowUpdatedEvent> |
| 46 | +{ |
| 47 | + constructor( |
| 48 | + private readonly identity: Identity, |
| 49 | + private readonly moduleRef: ModuleRef, |
| 50 | + private readonly resources: ResourceLoader, |
| 51 | + private readonly config: ConfigService, |
| 52 | + private readonly mailer: EmailService, |
| 53 | + @Logger('progress-report:dbl-upload-notifier') |
| 54 | + private readonly logger: ILogger, |
| 55 | + ) {} |
| 56 | + |
| 57 | + async handle({ reportId, previousStatus, next }: WorkflowUpdatedEvent) { |
| 58 | + const nextStatus = typeof next === 'string' ? next : next.to; |
| 59 | + // Continue if the report is at least Approved, and wasn't before. |
| 60 | + if ( |
| 61 | + !( |
| 62 | + Status.indexOf(nextStatus) >= Status.indexOf('Approved') && |
| 63 | + Status.indexOf(previousStatus) < Status.indexOf('Approved') |
| 64 | + ) |
| 65 | + ) { |
| 66 | + return; |
| 67 | + } |
| 68 | + |
| 69 | + const report = await this.resources.load('ProgressReport', reportId); |
| 70 | + |
| 71 | + const completedProducts = await this.determineCompletedProducts(report); |
| 72 | + if (completedProducts.size === 0) { |
| 73 | + return; |
| 74 | + } |
| 75 | + |
| 76 | + const completedBooks = await this.determineCompletedBooks( |
| 77 | + report.parent.properties.id, |
| 78 | + completedProducts, |
| 79 | + ); |
| 80 | + if (!completedBooks) { |
| 81 | + return; |
| 82 | + } |
| 83 | + |
| 84 | + await this.notifyProjectManagersOfUploadNeeded(report, completedBooks); |
| 85 | + } |
| 86 | + |
| 87 | + private async notifyProjectManagersOfUploadNeeded( |
| 88 | + report: ProgressReport, |
| 89 | + completedBooks: NonEmptyArray<Range<Verse>>, |
| 90 | + ) { |
| 91 | + const engagement = await this.resources.load( |
| 92 | + 'LanguageEngagement', |
| 93 | + report.parent.properties.id, |
| 94 | + ); |
| 95 | + const notifyees = await this.moduleRef |
| 96 | + .get(ProjectMemberRepository, { strict: false }) |
| 97 | + .listAsNotifiers(engagement.project.id, ['ProjectManager']); |
| 98 | + |
| 99 | + const notifyeesProps = await Promise.all( |
| 100 | + notifyees |
| 101 | + .filter((n) => n.email) |
| 102 | + .map(({ id: userId }) => |
| 103 | + this.gatherTemplateProps( |
| 104 | + userId, |
| 105 | + engagement.id, |
| 106 | + engagement.language.value!.id, |
| 107 | + engagement.project.id, |
| 108 | + completedBooks, |
| 109 | + ), |
| 110 | + ), |
| 111 | + ); |
| 112 | + |
| 113 | + this.logger.info('Notifying', { |
| 114 | + engagement: notifyeesProps[0]?.engagement.id ?? undefined, |
| 115 | + reportId: report.id, |
| 116 | + reportDate: report.start, |
| 117 | + books: completedBooks.map((r) => r.start.book.name), |
| 118 | + emails: notifyeesProps.map((r) => r.recipient.email.value), |
| 119 | + }); |
| 120 | + |
| 121 | + for (const props of notifyeesProps) { |
| 122 | + // members without an email address are already omitted |
| 123 | + const to = props.recipient.email.value!; |
| 124 | + await this.mailer |
| 125 | + .withOptions({ send: !!this.config.email.notifyDblUpload }) |
| 126 | + .render(DBLUpload, props) |
| 127 | + .with({ |
| 128 | + to, |
| 129 | + ...(this.config.email.notifyDblUpload && { |
| 130 | + 'reply-to': this.config.email.notifyDblUpload.replyTo, |
| 131 | + }), |
| 132 | + }) |
| 133 | + .send(); |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + private async determineCompletedProducts(report: ProgressReport) { |
| 138 | + const loader = await this.resources.getLoader( |
| 139 | + ProductProgressByReportLoader, |
| 140 | + ); |
| 141 | + const { details: productProgress } = await loader.load({ |
| 142 | + report, |
| 143 | + variant: ProgressReportVariantProgress.Variants.byKey('official'), |
| 144 | + }); |
| 145 | + |
| 146 | + const completed = productProgress.flatMap(({ steps, productId }) => { |
| 147 | + const completed = steps.some( |
| 148 | + (step) => |
| 149 | + step.step === 'ConsultantCheck' && step.completed?.value === 100, |
| 150 | + ); |
| 151 | + return completed ? productId : []; |
| 152 | + }); |
| 153 | + return setOf(completed); |
| 154 | + } |
| 155 | + |
| 156 | + private async determineCompletedBooks( |
| 157 | + engagementId: ID<'Engagement'>, |
| 158 | + completedProductIds: ReadonlySet<ID<'Product'>>, |
| 159 | + ) { |
| 160 | + const { items: products } = await this.moduleRef |
| 161 | + .get(ProductService, { strict: false }) |
| 162 | + .list( |
| 163 | + ProductListInput.defaultValue(ProductListInput, { |
| 164 | + filter: { engagementId }, |
| 165 | + count: 1_000, // no pagination |
| 166 | + }), |
| 167 | + ); |
| 168 | + |
| 169 | + const completedProducts = products.flatMap((product) => { |
| 170 | + if (!completedProductIds.has(product.id)) { |
| 171 | + return []; |
| 172 | + } |
| 173 | + const type = resolveProductType(product); |
| 174 | + if (type !== DirectScriptureProduct) { |
| 175 | + return []; |
| 176 | + } |
| 177 | + const dsp = asProductType(DirectScriptureProduct)(product); |
| 178 | + if (dsp.scriptureReferences.value.length === 0) { |
| 179 | + return []; // sanity check shouldn't really happen |
| 180 | + } |
| 181 | + // TODO filter on methodology? |
| 182 | + return dsp; |
| 183 | + }); |
| 184 | + |
| 185 | + const completedRanges = mergeVerseRanges([ |
| 186 | + ...completedProducts.flatMap( |
| 187 | + (product) => product.scriptureReferences.value, |
| 188 | + ), |
| 189 | + // Aggregate unspecified scripture |
| 190 | + // and pull out completed books identified by matching the total verse count. |
| 191 | + ...groupBy( |
| 192 | + completedProducts.flatMap( |
| 193 | + (product) => product.unspecifiedScripture.value ?? [], |
| 194 | + ), |
| 195 | + (unknownRange) => unknownRange.book, |
| 196 | + ).flatMap((unknownRanges) => { |
| 197 | + const book = Book.named(unknownRanges[0].book); |
| 198 | + const totalDeclared = unknownRanges.reduce( |
| 199 | + (total, unknownRange) => total + unknownRange.totalVerses, |
| 200 | + 0, |
| 201 | + ); |
| 202 | + return book.totalVerses === totalDeclared ? book.full : []; |
| 203 | + }), |
| 204 | + ]); |
| 205 | + const completedBooks = completedRanges |
| 206 | + .flatMap(splitRangeByBook) |
| 207 | + .flatMap((range) => { |
| 208 | + const fullBook = range.start.book.full; |
| 209 | + return range.start.equals(fullBook.start) && |
| 210 | + range.end.equals(fullBook.end) |
| 211 | + ? range |
| 212 | + : []; |
| 213 | + }); |
| 214 | + return asNonEmptyArray(completedBooks); |
| 215 | + } |
| 216 | + |
| 217 | + private async gatherTemplateProps( |
| 218 | + recipientId: ID<'User'>, |
| 219 | + engagementId: ID, |
| 220 | + languageId: ID, |
| 221 | + projectId: ID, |
| 222 | + completedBooks: NonEmptyArray<Range<Verse>>, |
| 223 | + ) { |
| 224 | + return await this.identity.asUser(recipientId, async () => { |
| 225 | + const [recipient, language, engagement, project] = await Promise.all([ |
| 226 | + this.resources.load('User', recipientId), |
| 227 | + this.resources.load('Language', languageId), |
| 228 | + this.resources.load('Engagement', engagementId), |
| 229 | + this.resources.load('Project', projectId), |
| 230 | + ]); |
| 231 | + |
| 232 | + return { |
| 233 | + recipient, |
| 234 | + language, |
| 235 | + project, |
| 236 | + engagement, |
| 237 | + completedBooks, |
| 238 | + dblFormUrl: this.config.email.notifyDblUpload?.formUrl ?? '', |
| 239 | + } satisfies PropsOf<typeof DBLUpload>; |
| 240 | + }); |
| 241 | + } |
| 242 | +} |
0 commit comments