Skip to content

Commit ed063d3

Browse files
committed
Extract product sync logic to its own service
1 parent a7e3d1d commit ed063d3

File tree

4 files changed

+317
-269
lines changed

4 files changed

+317
-269
lines changed

src/components/pnp/findStepColumns.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import 'ix/add/iterable-operators/toarray.js';
1414
*/
1515
export function findStepColumns(
1616
sheet: PlanningSheet | ProgressSheet,
17-
availableSteps: readonly Step[] = Object.values(Step),
17+
availableSteps: readonly Step[] = [...Step],
1818
) {
1919
const matchedColumns: Partial<Record<Step, Column>> = {};
2020
let remainingSteps = availableSteps;
Lines changed: 18 additions & 267 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,11 @@
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';
82
import {
93
EngagementCreatedEvent,
104
EngagementUpdatedEvent,
115
} from '../../engagement/events';
126
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';
279

2810
type SubscribedEvent = EngagementCreatedEvent | EngagementUpdatedEvent;
2911

@@ -32,12 +14,8 @@ export class ExtractProductsFromPnpHandler
3214
implements IEventHandler<SubscribedEvent>
3315
{
3416
constructor(
35-
private readonly products: ProductService,
17+
private readonly syncer: PnpProductSyncService,
3618
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,
4119
) {}
4220

4321
async handle(event: SubscribedEvent): Promise<void> {
@@ -52,253 +30,26 @@ export class ExtractProductsFromPnpHandler
5230
if (!hasPnpInput || !methodology) {
5331
return;
5432
}
33+
const availableSteps = getAvailableSteps({ methodology });
5534

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,
8338
event.session,
8439
);
40+
const pnp = this.files.asDownloadable(fv);
8541

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,
27546
});
276-
return actionableProductRows;
277-
}
27847

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,
30153
});
302-
return byName as Readonly<typeof byName>;
30354
}
30455
}

0 commit comments

Comments
 (0)