1
- import { asyncPool , groupBy , mapEntries , mapOf } from '@seedcompany/common' ;
2
- import { labelOfVerseRanges } from '@seedcompany/scripture' ;
3
- import { difference , uniq } from 'lodash' ;
4
- import { DateTime } from 'luxon' ;
5
- import { ID , Session , UnsecuredDto } from '~/common' ;
6
- import { EventsHandler , IEventHandler , ILogger , Logger } from '~/core' ;
7
- import { Engagement } from '../../engagement/dto' ;
1
+ import { EventsHandler , IEventHandler } from '~/core' ;
8
2
import {
9
3
EngagementCreatedEvent ,
10
4
EngagementUpdatedEvent ,
11
5
} from '../../engagement/events' ;
12
6
import { FileService } from '../../file' ;
13
- import { StoryService } from '../../story' ;
14
- import {
15
- CreateDerivativeScriptureProduct ,
16
- CreateDirectScriptureProduct ,
17
- DerivativeScriptureProduct ,
18
- getAvailableSteps ,
19
- ProducibleType ,
20
- ProgressMeasurement ,
21
- UpdateDerivativeScriptureProduct ,
22
- UpdateDirectScriptureProduct ,
23
- } from '../dto' ;
24
- import { ExtractedRow , ProductExtractor } from '../product.extractor' ;
25
- import { ProductRepository } from '../product.repository' ;
26
- import { ProductService } from '../product.service' ;
7
+ import { getAvailableSteps } from '../dto' ;
8
+ import { PnpProductSyncService } from '../pnp-product-sync.service' ;
27
9
28
10
type SubscribedEvent = EngagementCreatedEvent | EngagementUpdatedEvent ;
29
11
@@ -32,12 +14,8 @@ export class ExtractProductsFromPnpHandler
32
14
implements IEventHandler < SubscribedEvent >
33
15
{
34
16
constructor (
35
- private readonly products : ProductService ,
17
+ private readonly syncer : PnpProductSyncService ,
36
18
private readonly files : FileService ,
37
- private readonly extractor : ProductExtractor ,
38
- private readonly repo : ProductRepository ,
39
- private readonly stories : StoryService ,
40
- @Logger ( 'product:extractor' ) private readonly logger : ILogger ,
41
19
) { }
42
20
43
21
async handle ( event : SubscribedEvent ) : Promise < void > {
@@ -52,253 +30,26 @@ export class ExtractProductsFromPnpHandler
52
30
if ( ! hasPnpInput || ! methodology ) {
53
31
return ;
54
32
}
33
+ const availableSteps = getAvailableSteps ( { methodology } ) ;
55
34
56
- const pnp = await this . files . getFile ( engagement . pnp , event . session ) ;
57
- const file = this . files . asDownloadable ( { } , pnp . latestVersionId ) ;
58
-
59
- const availableSteps = getAvailableSteps ( {
60
- methodology,
61
- } ) ;
62
- let productRows ;
63
- try {
64
- productRows = await this . extractor . extract ( file , availableSteps ) ;
65
- } catch ( e ) {
66
- this . logger . warning ( e . message , {
67
- id : pnp . latestVersionId ,
68
- exception : e ,
69
- } ) ;
70
- return ;
71
- }
72
- if ( productRows . length === 0 ) {
73
- return ;
74
- }
75
-
76
- const actionableProductRows = await this . matchRowsToProductChanges (
77
- engagement ,
78
- productRows ,
79
- ) ;
80
-
81
- const storyIds = await this . getOrCreateStoriesByName (
82
- productRows ,
35
+ const file = await this . files . getFile ( engagement . pnp , event . session ) ;
36
+ const fv = await this . files . getFileVersion (
37
+ file . latestVersionId ,
83
38
event . session ,
84
39
) ;
40
+ const pnp = this . files . asDownloadable ( fv ) ;
85
41
86
- const createdAt = DateTime . now ( ) ;
87
-
88
- // Create/update products 5 at a time.
89
- await asyncPool ( 5 , actionableProductRows , async ( row ) => {
90
- const {
91
- scripture,
92
- unspecifiedScripture,
93
- existingId,
94
- steps,
95
- note,
96
- rowIndex : index ,
97
- } = row ;
98
-
99
- if ( row . bookName ) {
100
- // Populate one of the two product props based on whether it's a known verse range or not.
101
- const props = {
102
- methodology,
103
- scriptureReferences : scripture ,
104
- unspecifiedScripture : unspecifiedScripture ?? null ,
105
- steps : steps . map ( ( s ) => s . step ) ,
106
- describeCompletion : note ,
107
- } ;
108
- if ( existingId ) {
109
- const updates : UpdateDirectScriptureProduct = {
110
- ...props ,
111
- id : existingId ,
112
- } ;
113
- await this . products . updateDirect ( updates , event . session ) ;
114
- } else {
115
- const create : CreateDirectScriptureProduct = {
116
- ...props ,
117
- engagementId : engagement . id ,
118
- progressStepMeasurement : ProgressMeasurement . Percent ,
119
- pnpIndex : index ,
120
- // Attempt to order products in the same order as specified in the PnP
121
- // The default sort prop is createdAt.
122
- // This doesn't account for row changes in subsequent PnP uploads
123
- createdAt : createdAt . plus ( { milliseconds : index } ) ,
124
- } ;
125
- await this . products . create ( create , event . session ) ;
126
- }
127
- } else if ( row . story ) {
128
- const props = {
129
- produces : storyIds [ row . placeholder ? 'Unknown' : row . story ] ! ,
130
- placeholderDescription : row . placeholder
131
- ? `#${ row . order } ${ row . story } `
132
- : null ,
133
- methodology,
134
- steps : steps . map ( ( s ) => s . step ) ,
135
- scriptureReferencesOverride : row . scripture ,
136
- composite : row . composite ,
137
- describeCompletion : note ,
138
- } ;
139
- if ( existingId ) {
140
- const updates : UpdateDerivativeScriptureProduct = {
141
- ...props ,
142
- id : existingId ,
143
- } ;
144
- await this . products . updateDerivative ( updates , event . session ) ;
145
- } else {
146
- const create : CreateDerivativeScriptureProduct = {
147
- ...props ,
148
- engagementId : engagement . id ,
149
- progressStepMeasurement : ProgressMeasurement . Percent ,
150
- pnpIndex : index ,
151
- createdAt : createdAt . plus ( { milliseconds : index } ) ,
152
- } ;
153
- await this . products . create ( create , event . session ) ;
154
- }
155
- }
156
- } ) ;
157
- }
158
-
159
- /**
160
- * Determine which product rows correspond to an existing product
161
- * or if they should create a new one or if they should be skipped.
162
- */
163
- private async matchRowsToProductChanges (
164
- engagement : UnsecuredDto < Engagement > ,
165
- rows : readonly ExtractedRow [ ] ,
166
- ) {
167
- const scriptureProducts = rows [ 0 ] . bookName
168
- ? await this . products . loadProductIdsForBookAndVerse (
169
- engagement . id ,
170
- this . logger ,
171
- )
172
- : [ ] ;
173
-
174
- const storyProducts = rows [ 0 ] . story
175
- ? await this . products . loadProductIdsByPnpIndex (
176
- engagement . id ,
177
- DerivativeScriptureProduct . name ,
178
- )
179
- : mapOf ( { } ) ;
180
-
181
- if ( rows [ 0 ] . story ) {
182
- return rows . flatMap ( ( row ) => {
183
- if ( ! row . story ) return [ ] ;
184
- return { ...row , existingId : storyProducts . get ( row . rowIndex ) } ;
185
- } ) ;
186
- }
187
-
188
- const actionableProductRows = groupBy ( rows , ( row ) => {
189
- // group by book name
190
- return row . scripture [ 0 ] ?. start . book ?? row . unspecifiedScripture ?. book ;
191
- } ) . flatMap ( ( rowsOfBook ) => {
192
- const bookName =
193
- rowsOfBook [ 0 ] . scripture [ 0 ] ?. start . book ??
194
- rowsOfBook [ 0 ] . unspecifiedScripture ?. book ;
195
- if ( ! bookName ) return [ ] ;
196
- let existingProductsForBook = scriptureProducts . filter (
197
- ( ref ) => ref . book === bookName ,
198
- ) ;
199
-
200
- const matches : Array < ExtractedRow & { existingId : ID | undefined } > = [ ] ;
201
- let nonExactMatches : ExtractedRow [ ] = [ ] ;
202
-
203
- // Exact matches
204
- for ( const row of rowsOfBook ) {
205
- const rowScriptureLabel = labelOfVerseRanges ( row . scripture ) ;
206
- const withMatches = existingProductsForBook . filter ( ( existingRef ) => {
207
- if (
208
- existingRef . scriptureRanges . length > 0 &&
209
- rowScriptureLabel ===
210
- labelOfVerseRanges ( existingRef . scriptureRanges )
211
- ) {
212
- return true ;
213
- }
214
- if (
215
- existingRef . unspecifiedScripture &&
216
- row . unspecifiedScripture &&
217
- existingRef . unspecifiedScripture . book ===
218
- row . unspecifiedScripture . book &&
219
- existingRef . unspecifiedScripture . totalVerses ===
220
- row . unspecifiedScripture . totalVerses
221
- ) {
222
- return true ;
223
- }
224
- return false ;
225
- } ) ;
226
- const existingId =
227
- withMatches . length === 1 ? withMatches [ 0 ] . id : undefined ;
228
- if ( existingId ) {
229
- matches . push ( { ...row , existingId } ) ;
230
- existingProductsForBook = existingProductsForBook . filter (
231
- ( ref ) => ref . id !== existingId ,
232
- ) ;
233
- } else {
234
- nonExactMatches . push ( row ) ;
235
- }
236
- }
237
-
238
- // If there's only one product left for this book that hasn't been matched
239
- // And there's only one row left that can't be matched to a book & verse count
240
- if (
241
- existingProductsForBook . length === 1 &&
242
- nonExactMatches . length === 1
243
- ) {
244
- // Assume that ID belongs to this row.
245
- // Use case: A single row changes total verse count while other rows
246
- // for this book remain the same or are new.
247
- const oldVerseCountRef = existingProductsForBook [ 0 ] ! ;
248
- matches . push ( {
249
- ...nonExactMatches [ 0 ] ,
250
- existingId : oldVerseCountRef . id ,
251
- } ) ;
252
- existingProductsForBook = [ ] ;
253
- nonExactMatches = [ ] ;
254
- }
255
-
256
- if ( existingProductsForBook . length === 0 ) {
257
- // All remaining are new
258
- return [
259
- ...matches ,
260
- ...nonExactMatches . map ( ( row ) => ( {
261
- ...row ,
262
- existingId : undefined ,
263
- } ) ) ,
264
- ] ;
265
- }
266
-
267
- // If multiple total cells changed without the rows changing,
268
- // then rowIndex/pnpIndex could be used to correctly match them.
269
- // If rows changed though like inserting a row pushing multiple down,
270
- // this wouldn't work without more logic.
271
-
272
- // Not sure how to handle remaining so doing nothing with them
273
-
274
- return matches ;
42
+ const actionableProductRows = await this . syncer . parse ( {
43
+ engagementId : engagement . id ,
44
+ availableSteps,
45
+ pnp,
275
46
} ) ;
276
- return actionableProductRows ;
277
- }
278
47
279
- private async getOrCreateStoriesByName (
280
- rows : readonly ExtractedRow [ ] ,
281
- session : Session ,
282
- ) {
283
- const names = uniq (
284
- rows . flatMap ( ( row ) =>
285
- ! row . story ? [ ] : row . placeholder ? 'Unknown' : row . story ,
286
- ) ,
287
- ) ;
288
- if ( names . length === 0 ) {
289
- return { } ;
290
- }
291
- const existingList = await this . repo . getProducibleIdsByNames (
292
- names ,
293
- ProducibleType . Story ,
294
- ) ;
295
- const existing = mapEntries ( existingList , ( r ) => [ r . name , r . id ] ) . asRecord ;
296
- const byName = { ...existing } ;
297
- const newNames = difference ( names , Object . keys ( existing ) ) ;
298
- await asyncPool ( 3 , newNames , async ( name ) => {
299
- const story = await this . stories . create ( { name } , session ) ;
300
- byName [ name ] = story . id ;
48
+ await this . syncer . save ( {
49
+ engagementId : engagement . id ,
50
+ methodology,
51
+ actionableProductRows,
52
+ session : event . session ,
301
53
} ) ;
302
- return byName as Readonly < typeof byName > ;
303
54
}
304
55
}
0 commit comments