Skip to content

Commit 62e1c46

Browse files
rdonigianCarsonF
andauthored
DBL Upload Notification (#3429)
Co-authored-by: Carson Full <[email protected]>
1 parent 1b4fa40 commit 62e1c46

File tree

9 files changed

+442
-7
lines changed

9 files changed

+442
-7
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"@seedcompany/common": ">=0.19.1 <1",
5252
"@seedcompany/data-loader": "^2.0.1",
5353
"@seedcompany/nest": "^1.8.0",
54-
"@seedcompany/nestjs-email": "^4.1.1",
54+
"@seedcompany/nestjs-email": "^4.3.0",
5555
"@seedcompany/scripture": ">=0.8.0",
5656
"argon2": "^0.43.0",
5757
"aws-xray-sdk-core": "^3.5.3",

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BudgetModule } from './components/budget/budget.module';
66
import { CeremonyModule } from './components/ceremony/ceremony.module';
77
import { ChangesetModule } from './components/changeset/changeset.module';
88
import { CommentModule } from './components/comments/comment.module';
9+
import { DBLUploadNotificationModule } from './components/dbl-upload-notification/dbl-upload-notification.module';
910
import { EngagementModule } from './components/engagement/engagement.module';
1011
import { EthnoArtModule } from './components/ethno-art/ethno-art.module';
1112
import { FieldRegionModule } from './components/field-region/field-region.module';
@@ -88,6 +89,7 @@ if (process.env.NODE_ENV !== 'production') {
8889
NotificationModule,
8990
SystemNotificationModule,
9091
FinanceDepartmentModule,
92+
DBLUploadNotificationModule,
9193
],
9294
})
9395
export class AppModule {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Module } from '@nestjs/common';
2+
import { DBLUploadNotificationHandler } from './handlers/dbl-upload-notification.handler';
3+
4+
@Module({
5+
providers: [DBLUploadNotificationHandler],
6+
})
7+
export class DBLUploadNotificationModule {}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)