@open-mercato/core 0.6.2-develop.3330.1.8ad2b3b84c → 0.6.2-develop.3366.1.4692954e44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/sync_excel_upload/index.js +37 -0
  3. package/dist/generated/entities/sync_excel_upload/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +19 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/customers/search.js +3 -2
  9. package/dist/modules/customers/search.js.map +2 -2
  10. package/dist/modules/data_sync/api/runs/[id]/cancel.js +16 -0
  11. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  12. package/dist/modules/data_sync/di.js +2 -1
  13. package/dist/modules/data_sync/di.js.map +2 -2
  14. package/dist/modules/data_sync/lib/sync-engine.js +178 -54
  15. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  16. package/dist/modules/data_sync/lib/sync-run-service.js +23 -0
  17. package/dist/modules/data_sync/lib/sync-run-service.js.map +3 -3
  18. package/dist/modules/integrations/backend/integrations/[id]/page.js +241 -55
  19. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +3 -3
  20. package/dist/modules/sync_excel/acl.js +10 -0
  21. package/dist/modules/sync_excel/acl.js.map +7 -0
  22. package/dist/modules/sync_excel/api/import/route.js +164 -0
  23. package/dist/modules/sync_excel/api/import/route.js.map +7 -0
  24. package/dist/modules/sync_excel/api/preview/route.js +84 -0
  25. package/dist/modules/sync_excel/api/preview/route.js.map +7 -0
  26. package/dist/modules/sync_excel/api/upload/route.js +131 -0
  27. package/dist/modules/sync_excel/api/upload/route.js.map +7 -0
  28. package/dist/modules/sync_excel/data/entities.js +79 -0
  29. package/dist/modules/sync_excel/data/entities.js.map +7 -0
  30. package/dist/modules/sync_excel/data/validators.js +58 -0
  31. package/dist/modules/sync_excel/data/validators.js.map +7 -0
  32. package/dist/modules/sync_excel/di.js +11 -0
  33. package/dist/modules/sync_excel/di.js.map +7 -0
  34. package/dist/modules/sync_excel/index.js +11 -0
  35. package/dist/modules/sync_excel/index.js.map +7 -0
  36. package/dist/modules/sync_excel/integration.js +36 -0
  37. package/dist/modules/sync_excel/integration.js.map +7 -0
  38. package/dist/modules/sync_excel/lib/adapters/customers.js +852 -0
  39. package/dist/modules/sync_excel/lib/adapters/customers.js.map +7 -0
  40. package/dist/modules/sync_excel/lib/column-detector.js +77 -0
  41. package/dist/modules/sync_excel/lib/column-detector.js.map +7 -0
  42. package/dist/modules/sync_excel/lib/mapping.js +16 -0
  43. package/dist/modules/sync_excel/lib/mapping.js.map +7 -0
  44. package/dist/modules/sync_excel/lib/parser.js +125 -0
  45. package/dist/modules/sync_excel/lib/parser.js.map +7 -0
  46. package/dist/modules/sync_excel/lib/scope.js +44 -0
  47. package/dist/modules/sync_excel/lib/scope.js.map +7 -0
  48. package/dist/modules/sync_excel/lib/upload-storage.js +63 -0
  49. package/dist/modules/sync_excel/lib/upload-storage.js.map +7 -0
  50. package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js +11 -0
  51. package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js.map +7 -0
  52. package/dist/modules/sync_excel/setup.js +19 -0
  53. package/dist/modules/sync_excel/setup.js.map +7 -0
  54. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +293 -0
  55. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +7 -0
  56. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js +934 -0
  57. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js.map +7 -0
  58. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js +15 -0
  59. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js.map +7 -0
  60. package/dist/modules/sync_excel/widgets/injection-table.js +17 -0
  61. package/dist/modules/sync_excel/widgets/injection-table.js.map +7 -0
  62. package/generated/entities/sync_excel_upload/index.ts +17 -0
  63. package/generated/entities.ids.generated.ts +4 -0
  64. package/generated/entity-fields-registry.ts +19 -0
  65. package/package.json +7 -7
  66. package/src/modules/customers/search.ts +3 -2
  67. package/src/modules/data_sync/api/runs/[id]/cancel.ts +18 -0
  68. package/src/modules/data_sync/di.ts +4 -1
  69. package/src/modules/data_sync/lib/adapter.ts +15 -0
  70. package/src/modules/data_sync/lib/sync-engine.ts +205 -52
  71. package/src/modules/data_sync/lib/sync-run-service.ts +24 -0
  72. package/src/modules/integrations/backend/integrations/[id]/page.tsx +220 -15
  73. package/src/modules/integrations/i18n/de.json +9 -0
  74. package/src/modules/integrations/i18n/en.json +9 -0
  75. package/src/modules/integrations/i18n/es.json +9 -0
  76. package/src/modules/integrations/i18n/pl.json +9 -0
  77. package/src/modules/sync_excel/acl.ts +6 -0
  78. package/src/modules/sync_excel/api/import/route.ts +182 -0
  79. package/src/modules/sync_excel/api/preview/route.ts +89 -0
  80. package/src/modules/sync_excel/api/upload/route.ts +144 -0
  81. package/src/modules/sync_excel/data/entities.ts +61 -0
  82. package/src/modules/sync_excel/data/validators.ts +58 -0
  83. package/src/modules/sync_excel/di.ts +9 -0
  84. package/src/modules/sync_excel/i18n/de.json +107 -0
  85. package/src/modules/sync_excel/i18n/en.json +107 -0
  86. package/src/modules/sync_excel/i18n/es.json +107 -0
  87. package/src/modules/sync_excel/i18n/pl.json +107 -0
  88. package/src/modules/sync_excel/index.ts +7 -0
  89. package/src/modules/sync_excel/integration.ts +31 -0
  90. package/src/modules/sync_excel/lib/adapters/customers.ts +1097 -0
  91. package/src/modules/sync_excel/lib/column-detector.ts +91 -0
  92. package/src/modules/sync_excel/lib/mapping.ts +19 -0
  93. package/src/modules/sync_excel/lib/parser.ts +165 -0
  94. package/src/modules/sync_excel/lib/scope.ts +63 -0
  95. package/src/modules/sync_excel/lib/upload-storage.ts +79 -0
  96. package/src/modules/sync_excel/migrations/.snapshot-open-mercato.json +199 -0
  97. package/src/modules/sync_excel/migrations/Migration20260329181120_sync_excel.ts +10 -0
  98. package/src/modules/sync_excel/setup.ts +20 -0
  99. package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +351 -0
  100. package/src/modules/sync_excel/widgets/injection/upload-config/widget.client.tsx +1224 -0
  101. package/src/modules/sync_excel/widgets/injection/upload-config/widget.ts +30 -0
  102. package/src/modules/sync_excel/widgets/injection-table.ts +15 -0
@@ -0,0 +1,1097 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'
6
+ import type {
7
+ DataMapping,
8
+ DataSyncAdapter,
9
+ FieldMapping,
10
+ ImportBatch,
11
+ ImportItem,
12
+ TenantScope,
13
+ } from '../../../data_sync/lib/adapter'
14
+ import type { ExternalIdMappingService } from '../../../data_sync/lib/id-mapping'
15
+ import { SyncMapping } from '../../../data_sync/data/entities'
16
+ import { CustomerAddress, CustomerEntity } from '../../../customers/data/entities'
17
+ import { Attachment } from '../../../attachments/data/entities'
18
+ import { CustomFieldDef } from '../../../entities/data/entities'
19
+ import { SyncExcelUpload } from '../../data/entities'
20
+ import { parseCsvDocument, type CsvPreviewRow } from '../parser'
21
+ import { readSyncExcelUploadBuffer } from '../upload-storage'
22
+ import { E } from '#generated/entities.ids.generated'
23
+
24
+ type SyncExcelCursor = {
25
+ uploadId: string
26
+ offset: number
27
+ }
28
+
29
+ type Container = Awaited<ReturnType<typeof createRequestContainer>>
30
+
31
+ const CUSTOMER_IMPORT_COVERAGE_ENTITY_TYPES = [
32
+ E.customers.customer_entity,
33
+ E.customers.customer_person_profile,
34
+ E.customers.customer_address,
35
+ ]
36
+
37
+ type PersonFieldValues = {
38
+ externalId?: string | null
39
+ firstName?: string | null
40
+ lastName?: string | null
41
+ displayName?: string | null
42
+ primaryEmail?: string | null
43
+ primaryPhone?: string | null
44
+ jobTitle?: string | null
45
+ status?: string | null
46
+ source?: string | null
47
+ description?: string | null
48
+ }
49
+
50
+ type AddressFieldValues = {
51
+ name?: string | null
52
+ purpose?: string | null
53
+ companyName?: string | null
54
+ addressLine1?: string | null
55
+ addressLine2?: string | null
56
+ buildingNumber?: string | null
57
+ flatNumber?: string | null
58
+ city?: string | null
59
+ region?: string | null
60
+ postalCode?: string | null
61
+ country?: string | null
62
+ latitude?: number | null
63
+ longitude?: number | null
64
+ }
65
+
66
+ type PersonRowValues = {
67
+ values: PersonFieldValues
68
+ customFields: Record<string, unknown>
69
+ addressValues: AddressFieldValues
70
+ }
71
+
72
+ type ImportCustomFieldDefinition = {
73
+ key: string
74
+ kind: string
75
+ entityId: string
76
+ organizationId?: string | null
77
+ tenantId?: string | null
78
+ updatedAt?: Date | string | null
79
+ }
80
+
81
+ type EmailDedupeIndex = Map<string, string>
82
+
83
+ type BuiltPersonPayload = {
84
+ values: PersonFieldValues
85
+ customFields: Record<string, unknown>
86
+ addressValues: AddressFieldValues
87
+ createInput: {
88
+ organizationId: string
89
+ tenantId: string
90
+ firstName: string
91
+ lastName: string
92
+ displayName: string
93
+ primaryEmail?: string
94
+ primaryPhone?: string
95
+ jobTitle?: string
96
+ status?: string
97
+ source?: string
98
+ description?: string
99
+ customFields?: Record<string, unknown>
100
+ } | null
101
+ updateInput: {
102
+ organizationId: string
103
+ tenantId: string
104
+ primaryEmail?: string
105
+ primaryPhone?: string
106
+ jobTitle?: string
107
+ status?: string
108
+ source?: string
109
+ description?: string
110
+ firstName?: string
111
+ lastName?: string
112
+ displayName?: string
113
+ customFields?: Record<string, unknown>
114
+ }
115
+ sourceIdentifier: string | null
116
+ }
117
+
118
+ function normalizeOptionalString(value: unknown): string | null {
119
+ if (typeof value !== 'string') return null
120
+ const trimmed = value.trim()
121
+ return trimmed.length > 0 ? trimmed : null
122
+ }
123
+
124
+ function normalizeEmail(value: unknown): string | null {
125
+ const normalized = normalizeOptionalString(value)
126
+ return normalized ? normalized.toLowerCase() : null
127
+ }
128
+
129
+ const COUNTRY_DIAL_CODES: Record<string, string> = {
130
+ canada: '1',
131
+ england: '44',
132
+ greatbritain: '44',
133
+ poland: '48',
134
+ scotland: '44',
135
+ uk: '44',
136
+ unitedkingdom: '44',
137
+ unitedstates: '1',
138
+ unitedstatesofamerica: '1',
139
+ usa: '1',
140
+ wales: '44',
141
+ }
142
+
143
+ function normalizeCountryKey(value: unknown): string | null {
144
+ const normalized = normalizeOptionalString(value)
145
+ if (!normalized) return null
146
+ return normalized.toLowerCase().replace(/[^a-z]/g, '')
147
+ }
148
+
149
+ function resolveCountryDialCode(row: CsvPreviewRow): string | null {
150
+ const countryCandidates = [
151
+ row.Country,
152
+ row.country,
153
+ row['Country/Region'],
154
+ row['country/region'],
155
+ ]
156
+
157
+ for (const candidate of countryCandidates) {
158
+ const countryKey = normalizeCountryKey(candidate)
159
+ if (!countryKey) continue
160
+ const dialCode = COUNTRY_DIAL_CODES[countryKey]
161
+ if (dialCode) return dialCode
162
+ }
163
+
164
+ return null
165
+ }
166
+
167
+ function stripPhoneExtension(value: string): string {
168
+ return value
169
+ .replace(/\s*(ext\.?|extension|x)\s*\d+$/i, '')
170
+ .replace(/^\+\s+/, '+')
171
+ .replace(/\s+/g, ' ')
172
+ .trim()
173
+ }
174
+
175
+ function normalizeImportedPhone(value: unknown, row: CsvPreviewRow): string | null {
176
+ const normalized = normalizeOptionalString(value)
177
+ if (!normalized) return null
178
+
179
+ const directValidation = validatePhoneNumber(normalized)
180
+ if (directValidation.valid) {
181
+ return directValidation.normalized
182
+ }
183
+
184
+ const sanitized = stripPhoneExtension(normalized)
185
+ const sanitizedValidation = validatePhoneNumber(sanitized)
186
+ if (sanitizedValidation.valid) {
187
+ return sanitizedValidation.normalized
188
+ }
189
+
190
+ const digits = extractPhoneDigits(sanitized)
191
+ if (!digits) return null
192
+
193
+ if (digits.length >= 9 && digits.length <= 15 && sanitized.startsWith('00')) {
194
+ const internationalCandidate = `+${digits.slice(2)}`
195
+ const internationalValidation = validatePhoneNumber(internationalCandidate)
196
+ if (internationalValidation.valid) {
197
+ return internationalValidation.normalized
198
+ }
199
+ }
200
+
201
+ const dialCode = resolveCountryDialCode(row)
202
+ if (!dialCode) return null
203
+
204
+ let nationalDigits = digits
205
+ if (nationalDigits.startsWith(dialCode)) {
206
+ nationalDigits = nationalDigits.slice(dialCode.length)
207
+ } else {
208
+ nationalDigits = nationalDigits.replace(/^0+/, '')
209
+ }
210
+
211
+ if (!nationalDigits) return null
212
+
213
+ const localizedCandidate = `+${dialCode}${nationalDigits}`
214
+ const localizedValidation = validatePhoneNumber(localizedCandidate)
215
+ return localizedValidation.valid ? localizedValidation.normalized : null
216
+ }
217
+
218
+ function normalizeOptionalNumber(value: unknown): number | null {
219
+ if (typeof value === 'number') {
220
+ return Number.isFinite(value) ? value : null
221
+ }
222
+ if (typeof value !== 'string') return null
223
+ const trimmed = value.trim()
224
+ if (trimmed.length === 0) return null
225
+ const parsed = Number(trimmed)
226
+ return Number.isFinite(parsed) ? parsed : null
227
+ }
228
+
229
+ function normalizeCustomFieldDate(value: string): string | null {
230
+ const parsed = new Date(value)
231
+ if (Number.isNaN(parsed.getTime())) return null
232
+ return parsed.toISOString().slice(0, 10)
233
+ }
234
+
235
+ function normalizeCustomFieldBoolean(value: string): boolean | null {
236
+ const normalized = value.trim().toLowerCase()
237
+ if (['true', '1', 'yes', 'y', 't', 'tak'].includes(normalized)) return true
238
+ if (['false', '0', 'no', 'n', 'f', 'nie'].includes(normalized)) return false
239
+ return null
240
+ }
241
+
242
+ function coerceCustomFieldValue(
243
+ key: string,
244
+ value: unknown,
245
+ definitions: Map<string, ImportCustomFieldDefinition>,
246
+ ): { ok: true; value: unknown } | { ok: false; error: string } {
247
+ if (typeof value === 'string' && value.trim().length === 0) {
248
+ return { ok: true, value: null }
249
+ }
250
+
251
+ const definition = definitions.get(key)
252
+ if (!definition) {
253
+ return { ok: true, value: typeof value === 'string' ? value.trim() : value }
254
+ }
255
+
256
+ if (value === null || value === undefined) {
257
+ return { ok: true, value: null }
258
+ }
259
+
260
+ const text = typeof value === 'string' ? value.trim() : String(value)
261
+ if (text.length === 0) {
262
+ return { ok: true, value: null }
263
+ }
264
+
265
+ const kind = definition.kind.toLowerCase()
266
+ if (kind === 'boolean') {
267
+ const booleanValue = normalizeCustomFieldBoolean(text)
268
+ if (booleanValue === null) {
269
+ return { ok: false, error: `Custom field "${key}" expects a boolean value.` }
270
+ }
271
+ return { ok: true, value: booleanValue }
272
+ }
273
+
274
+ if (kind === 'integer') {
275
+ const numberValue = Number(text)
276
+ if (!Number.isInteger(numberValue)) {
277
+ return { ok: false, error: `Custom field "${key}" expects an integer value.` }
278
+ }
279
+ return { ok: true, value: numberValue }
280
+ }
281
+
282
+ if (kind === 'float' || kind === 'currency' || kind === 'number') {
283
+ const numberValue = Number(text)
284
+ if (!Number.isFinite(numberValue)) {
285
+ return { ok: false, error: `Custom field "${key}" expects a number value.` }
286
+ }
287
+ return { ok: true, value: numberValue }
288
+ }
289
+
290
+ if (kind === 'date' || kind === 'datetime') {
291
+ const dateValue = normalizeCustomFieldDate(text)
292
+ if (!dateValue) {
293
+ return { ok: false, error: `Custom field "${key}" expects a date value.` }
294
+ }
295
+ return { ok: true, value: dateValue }
296
+ }
297
+
298
+ return { ok: true, value: text }
299
+ }
300
+
301
+ function coerceCustomFields(
302
+ values: Record<string, unknown>,
303
+ definitions: Map<string, ImportCustomFieldDefinition>,
304
+ ): { ok: true; values: Record<string, unknown> } | { ok: false; error: string } {
305
+ const coerced: Record<string, unknown> = {}
306
+ for (const [key, value] of Object.entries(values)) {
307
+ const result = coerceCustomFieldValue(key, value, definitions)
308
+ if (!result.ok) return result
309
+ coerced[key] = result.value
310
+ }
311
+ return { ok: true, values: coerced }
312
+ }
313
+
314
+ function customFieldDefinitionScopeScore(definition: ImportCustomFieldDefinition): number {
315
+ return (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0)
316
+ }
317
+
318
+ function customFieldDefinitionUpdatedAt(definition: ImportCustomFieldDefinition): number {
319
+ if (!definition.updatedAt) return 0
320
+ const value = definition.updatedAt instanceof Date
321
+ ? definition.updatedAt.getTime()
322
+ : new Date(definition.updatedAt).getTime()
323
+ return Number.isFinite(value) ? value : 0
324
+ }
325
+
326
+ function preferCustomFieldDefinition(
327
+ current: ImportCustomFieldDefinition | undefined,
328
+ candidate: ImportCustomFieldDefinition,
329
+ ): boolean {
330
+ if (!current) return true
331
+ const candidateScore = customFieldDefinitionScopeScore(candidate)
332
+ const currentScore = customFieldDefinitionScopeScore(current)
333
+ if (candidateScore !== currentScore) return candidateScore > currentScore
334
+ return customFieldDefinitionUpdatedAt(candidate) >= customFieldDefinitionUpdatedAt(current)
335
+ }
336
+
337
+ async function loadImportCustomFieldDefinitions(
338
+ em: EntityManager,
339
+ scope: TenantScope,
340
+ ): Promise<Map<string, ImportCustomFieldDefinition>> {
341
+ const definitions = await findWithDecryption(
342
+ em,
343
+ CustomFieldDef,
344
+ {
345
+ entityId: { $in: [E.customers.customer_entity, E.customers.customer_person_profile] as string[] },
346
+ deletedAt: null,
347
+ isActive: true,
348
+ $and: [
349
+ { $or: [{ tenantId: scope.tenantId }, { tenantId: null }] },
350
+ { $or: [{ organizationId: scope.organizationId }, { organizationId: null }] },
351
+ ],
352
+ },
353
+ undefined,
354
+ scope,
355
+ )
356
+
357
+ const byKey = new Map<string, ImportCustomFieldDefinition>()
358
+ for (const definition of definitions) {
359
+ const candidate: ImportCustomFieldDefinition = {
360
+ key: definition.key,
361
+ kind: definition.kind,
362
+ entityId: definition.entityId,
363
+ organizationId: definition.organizationId ?? null,
364
+ tenantId: definition.tenantId ?? null,
365
+ updatedAt: definition.updatedAt ?? null,
366
+ }
367
+ if (preferCustomFieldDefinition(byKey.get(candidate.key), candidate)) {
368
+ byKey.set(candidate.key, candidate)
369
+ }
370
+ }
371
+ return byKey
372
+ }
373
+
374
+ function buildCommandContext(container: Container, scope: TenantScope): CommandRuntimeContext {
375
+ return {
376
+ container,
377
+ auth: null,
378
+ organizationScope: {
379
+ selectedId: scope.organizationId,
380
+ filterIds: [scope.organizationId],
381
+ allowedIds: [scope.organizationId],
382
+ tenantId: scope.tenantId,
383
+ },
384
+ selectedOrganizationId: scope.organizationId,
385
+ organizationIds: [scope.organizationId],
386
+ }
387
+ }
388
+
389
+ export function createCursor(uploadId: string, offset: number): string {
390
+ return JSON.stringify({
391
+ uploadId,
392
+ offset,
393
+ })
394
+ }
395
+
396
+ export function parseCursor(value: string | null | undefined): SyncExcelCursor | null {
397
+ if (!value) return null
398
+ try {
399
+ const parsed = JSON.parse(value) as Partial<SyncExcelCursor> | null
400
+ if (!parsed || typeof parsed !== 'object') return null
401
+ if (typeof parsed.uploadId !== 'string' || parsed.uploadId.trim().length === 0) return null
402
+ if (typeof parsed.offset !== 'number' || !Number.isFinite(parsed.offset) || parsed.offset < 0) return null
403
+ return {
404
+ uploadId: parsed.uploadId,
405
+ offset: parsed.offset,
406
+ }
407
+ } catch {
408
+ return null
409
+ }
410
+ }
411
+
412
+ function mapRowValues(row: CsvPreviewRow, fields: FieldMapping[]): PersonRowValues {
413
+ const values: PersonFieldValues = {}
414
+ const customFields: Record<string, unknown> = {}
415
+ const addressValues: AddressFieldValues = {}
416
+
417
+ for (const field of fields) {
418
+ if (field.mappingKind === 'ignore') continue
419
+ const rawValue = row[field.externalField]
420
+ if (rawValue === undefined || rawValue === null) continue
421
+ if (field.mappingKind === 'custom_field' || field.localField.startsWith('cf:')) {
422
+ const customFieldKey = field.localField.startsWith('cf:') ? field.localField.slice(3) : field.localField
423
+ if (customFieldKey.trim().length > 0) {
424
+ customFields[customFieldKey] = rawValue
425
+ }
426
+ continue
427
+ }
428
+
429
+ if (field.localField === 'person.externalId') {
430
+ values.externalId = normalizeOptionalString(rawValue)
431
+ continue
432
+ }
433
+ if (field.localField === 'person.firstName') {
434
+ values.firstName = normalizeOptionalString(rawValue)
435
+ continue
436
+ }
437
+ if (field.localField === 'person.lastName') {
438
+ values.lastName = normalizeOptionalString(rawValue)
439
+ continue
440
+ }
441
+ if (field.localField === 'person.displayName') {
442
+ values.displayName = normalizeOptionalString(rawValue)
443
+ continue
444
+ }
445
+ if (field.localField === 'person.primaryEmail') {
446
+ values.primaryEmail = normalizeEmail(rawValue)
447
+ continue
448
+ }
449
+ if (field.localField === 'person.primaryPhone') {
450
+ values.primaryPhone = normalizeImportedPhone(rawValue, row)
451
+ continue
452
+ }
453
+ if (field.localField === 'person.jobTitle') {
454
+ values.jobTitle = normalizeOptionalString(rawValue)
455
+ continue
456
+ }
457
+ if (field.localField === 'person.status') {
458
+ values.status = normalizeOptionalString(rawValue)
459
+ continue
460
+ }
461
+ if (field.localField === 'person.source') {
462
+ values.source = normalizeOptionalString(rawValue)
463
+ continue
464
+ }
465
+ if (field.localField === 'person.description') {
466
+ values.description = normalizeOptionalString(rawValue)
467
+ continue
468
+ }
469
+
470
+ if (field.localField === 'address.name') {
471
+ addressValues.name = normalizeOptionalString(rawValue)
472
+ continue
473
+ }
474
+ if (field.localField === 'address.purpose') {
475
+ addressValues.purpose = normalizeOptionalString(rawValue)
476
+ continue
477
+ }
478
+ if (field.localField === 'address.companyName') {
479
+ addressValues.companyName = normalizeOptionalString(rawValue)
480
+ continue
481
+ }
482
+ if (field.localField === 'address.addressLine1') {
483
+ addressValues.addressLine1 = normalizeOptionalString(rawValue)
484
+ continue
485
+ }
486
+ if (field.localField === 'address.addressLine2') {
487
+ addressValues.addressLine2 = normalizeOptionalString(rawValue)
488
+ continue
489
+ }
490
+ if (field.localField === 'address.buildingNumber') {
491
+ addressValues.buildingNumber = normalizeOptionalString(rawValue)
492
+ continue
493
+ }
494
+ if (field.localField === 'address.flatNumber') {
495
+ addressValues.flatNumber = normalizeOptionalString(rawValue)
496
+ continue
497
+ }
498
+ if (field.localField === 'address.city') {
499
+ addressValues.city = normalizeOptionalString(rawValue)
500
+ continue
501
+ }
502
+ if (field.localField === 'address.region') {
503
+ addressValues.region = normalizeOptionalString(rawValue)
504
+ continue
505
+ }
506
+ if (field.localField === 'address.postalCode') {
507
+ addressValues.postalCode = normalizeOptionalString(rawValue)
508
+ continue
509
+ }
510
+ if (field.localField === 'address.country') {
511
+ addressValues.country = normalizeOptionalString(rawValue)
512
+ continue
513
+ }
514
+ if (field.localField === 'address.latitude') {
515
+ addressValues.latitude = normalizeOptionalNumber(rawValue)
516
+ continue
517
+ }
518
+ if (field.localField === 'address.longitude') {
519
+ addressValues.longitude = normalizeOptionalNumber(rawValue)
520
+ }
521
+ }
522
+
523
+ return {
524
+ values,
525
+ customFields,
526
+ addressValues,
527
+ }
528
+ }
529
+
530
+ function derivePersonNames(values: PersonFieldValues): { firstName: string; lastName: string; displayName: string } | null {
531
+ const firstName = values.firstName ?? null
532
+ const lastName = values.lastName ?? null
533
+ const explicitDisplayName = values.displayName ?? null
534
+
535
+ if (firstName && lastName) {
536
+ return {
537
+ firstName,
538
+ lastName,
539
+ displayName: explicitDisplayName ?? `${firstName} ${lastName}`.trim(),
540
+ }
541
+ }
542
+
543
+ if (explicitDisplayName) {
544
+ const parts = explicitDisplayName.split(/\s+/).filter((part) => part.length > 0)
545
+ if (parts.length >= 2) {
546
+ return {
547
+ firstName: firstName ?? parts.slice(0, -1).join(' '),
548
+ lastName: lastName ?? parts.at(-1) ?? explicitDisplayName,
549
+ displayName: explicitDisplayName,
550
+ }
551
+ }
552
+ return {
553
+ firstName: firstName ?? explicitDisplayName,
554
+ lastName: lastName ?? explicitDisplayName,
555
+ displayName: explicitDisplayName,
556
+ }
557
+ }
558
+
559
+ return null
560
+ }
561
+
562
+ export function buildPersonPayload(row: CsvPreviewRow, mapping: DataMapping, scope: TenantScope): BuiltPersonPayload {
563
+ const { values, customFields, addressValues } = mapRowValues(row, mapping.fields)
564
+ const derivedNames = derivePersonNames(values)
565
+ const sourceIdentifier = values.externalId ?? values.primaryEmail ?? values.displayName ?? null
566
+
567
+ const updateInput: BuiltPersonPayload['updateInput'] = {
568
+ organizationId: scope.organizationId,
569
+ tenantId: scope.tenantId,
570
+ }
571
+
572
+ if (values.primaryEmail) updateInput.primaryEmail = values.primaryEmail
573
+ if (values.primaryPhone) updateInput.primaryPhone = values.primaryPhone
574
+ if (values.jobTitle) updateInput.jobTitle = values.jobTitle
575
+ if (values.status) updateInput.status = values.status
576
+ if (values.source) updateInput.source = values.source
577
+ if (values.description) updateInput.description = values.description
578
+ if (derivedNames?.firstName) updateInput.firstName = derivedNames.firstName
579
+ if (derivedNames?.lastName) updateInput.lastName = derivedNames.lastName
580
+ if (derivedNames?.displayName) updateInput.displayName = derivedNames.displayName
581
+
582
+ if (!derivedNames) {
583
+ return {
584
+ values,
585
+ customFields,
586
+ addressValues,
587
+ createInput: null,
588
+ updateInput,
589
+ sourceIdentifier,
590
+ }
591
+ }
592
+
593
+ return {
594
+ values,
595
+ customFields,
596
+ addressValues,
597
+ createInput: {
598
+ organizationId: scope.organizationId,
599
+ tenantId: scope.tenantId,
600
+ firstName: derivedNames.firstName,
601
+ lastName: derivedNames.lastName,
602
+ displayName: derivedNames.displayName,
603
+ ...(values.primaryEmail ? { primaryEmail: values.primaryEmail } : {}),
604
+ ...(values.primaryPhone ? { primaryPhone: values.primaryPhone } : {}),
605
+ ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),
606
+ ...(values.status ? { status: values.status } : {}),
607
+ ...(values.source ? { source: values.source } : {}),
608
+ ...(values.description ? { description: values.description } : {}),
609
+ },
610
+ updateInput,
611
+ sourceIdentifier,
612
+ }
613
+ }
614
+
615
+ function hasAddressValues(addressValues: AddressFieldValues): boolean {
616
+ return Object.values(addressValues).some((value) => value !== null && value !== undefined)
617
+ }
618
+
619
+ function buildAddressCreateInput(addressValues: AddressFieldValues, entityId: string, scope: TenantScope) {
620
+ if (!addressValues.addressLine1) return null
621
+
622
+ return {
623
+ organizationId: scope.organizationId,
624
+ tenantId: scope.tenantId,
625
+ entityId,
626
+ addressLine1: addressValues.addressLine1,
627
+ isPrimary: true,
628
+ ...(addressValues.name ? { name: addressValues.name } : {}),
629
+ ...(addressValues.purpose ? { purpose: addressValues.purpose } : {}),
630
+ ...(addressValues.companyName ? { companyName: addressValues.companyName } : {}),
631
+ ...(addressValues.addressLine2 ? { addressLine2: addressValues.addressLine2 } : {}),
632
+ ...(addressValues.buildingNumber ? { buildingNumber: addressValues.buildingNumber } : {}),
633
+ ...(addressValues.flatNumber ? { flatNumber: addressValues.flatNumber } : {}),
634
+ ...(addressValues.city ? { city: addressValues.city } : {}),
635
+ ...(addressValues.region ? { region: addressValues.region } : {}),
636
+ ...(addressValues.postalCode ? { postalCode: addressValues.postalCode } : {}),
637
+ ...(addressValues.country ? { country: addressValues.country } : {}),
638
+ ...(addressValues.latitude !== null && addressValues.latitude !== undefined ? { latitude: addressValues.latitude } : {}),
639
+ ...(addressValues.longitude !== null && addressValues.longitude !== undefined ? { longitude: addressValues.longitude } : {}),
640
+ }
641
+ }
642
+
643
+ async function upsertPrimaryAddress(params: {
644
+ entityId: string
645
+ addressValues: AddressFieldValues
646
+ scope: TenantScope
647
+ commandBus: CommandBus
648
+ commandContext: CommandRuntimeContext
649
+ em: EntityManager
650
+ }): Promise<void> {
651
+ if (!hasAddressValues(params.addressValues)) return
652
+ if (!params.addressValues.addressLine1) return
653
+
654
+ const [existingPrimaryAddress] = await findWithDecryption(
655
+ params.em,
656
+ CustomerAddress,
657
+ {
658
+ entity: params.entityId,
659
+ organizationId: params.scope.organizationId,
660
+ tenantId: params.scope.tenantId,
661
+ isPrimary: true,
662
+ },
663
+ {
664
+ orderBy: {
665
+ createdAt: 'asc',
666
+ },
667
+ limit: 1,
668
+ },
669
+ params.scope,
670
+ )
671
+
672
+ if (existingPrimaryAddress) {
673
+ await params.commandBus.execute('customers.addresses.update', {
674
+ input: {
675
+ id: existingPrimaryAddress.id,
676
+ isPrimary: true,
677
+ ...(params.addressValues.name ? { name: params.addressValues.name } : {}),
678
+ ...(params.addressValues.purpose ? { purpose: params.addressValues.purpose } : {}),
679
+ ...(params.addressValues.companyName ? { companyName: params.addressValues.companyName } : {}),
680
+ ...(params.addressValues.addressLine1 ? { addressLine1: params.addressValues.addressLine1 } : {}),
681
+ ...(params.addressValues.addressLine2 ? { addressLine2: params.addressValues.addressLine2 } : {}),
682
+ ...(params.addressValues.buildingNumber ? { buildingNumber: params.addressValues.buildingNumber } : {}),
683
+ ...(params.addressValues.flatNumber ? { flatNumber: params.addressValues.flatNumber } : {}),
684
+ ...(params.addressValues.city ? { city: params.addressValues.city } : {}),
685
+ ...(params.addressValues.region ? { region: params.addressValues.region } : {}),
686
+ ...(params.addressValues.postalCode ? { postalCode: params.addressValues.postalCode } : {}),
687
+ ...(params.addressValues.country ? { country: params.addressValues.country } : {}),
688
+ ...(params.addressValues.latitude !== null && params.addressValues.latitude !== undefined
689
+ ? { latitude: params.addressValues.latitude }
690
+ : {}),
691
+ ...(params.addressValues.longitude !== null && params.addressValues.longitude !== undefined
692
+ ? { longitude: params.addressValues.longitude }
693
+ : {}),
694
+ },
695
+ ctx: params.commandContext,
696
+ })
697
+ return
698
+ }
699
+
700
+ const createInput = buildAddressCreateInput(params.addressValues, params.entityId, params.scope)
701
+ if (!createInput) return
702
+
703
+ await params.commandBus.execute('customers.addresses.create', {
704
+ input: createInput,
705
+ ctx: params.commandContext,
706
+ })
707
+ }
708
+
709
+ async function loadStoredMapping(em: EntityManager, entityType: string, scope: TenantScope): Promise<DataMapping> {
710
+ const stored = await findOneWithDecryption(
711
+ em,
712
+ SyncMapping,
713
+ {
714
+ integrationId: 'sync_excel',
715
+ entityType,
716
+ organizationId: scope.organizationId,
717
+ tenantId: scope.tenantId,
718
+ },
719
+ undefined,
720
+ scope,
721
+ )
722
+
723
+ if (stored?.mapping && typeof stored.mapping === 'object') {
724
+ return stored.mapping as unknown as DataMapping
725
+ }
726
+
727
+ return {
728
+ entityType,
729
+ fields: [],
730
+ matchStrategy: 'custom',
731
+ }
732
+ }
733
+
734
+ async function resolveUpload(em: EntityManager, runId: string | undefined, cursor: SyncExcelCursor | null, scope: TenantScope): Promise<SyncExcelUpload | null> {
735
+ if (runId) {
736
+ const uploadByRun = await findOneWithDecryption(
737
+ em,
738
+ SyncExcelUpload,
739
+ {
740
+ syncRunId: runId,
741
+ organizationId: scope.organizationId,
742
+ tenantId: scope.tenantId,
743
+ },
744
+ undefined,
745
+ scope,
746
+ )
747
+ if (uploadByRun) return uploadByRun
748
+ }
749
+
750
+ if (cursor?.uploadId) {
751
+ return findOneWithDecryption(
752
+ em,
753
+ SyncExcelUpload,
754
+ {
755
+ id: cursor.uploadId,
756
+ organizationId: scope.organizationId,
757
+ tenantId: scope.tenantId,
758
+ },
759
+ undefined,
760
+ scope,
761
+ )
762
+ }
763
+
764
+ return null
765
+ }
766
+
767
+ async function resolveExistingPersonId(params: {
768
+ externalIdMappingService: ExternalIdMappingService
769
+ externalId: string | null | undefined
770
+ email: string | null | undefined
771
+ emailDedupeIndex: EmailDedupeIndex
772
+ scope: TenantScope
773
+ }): Promise<string | null> {
774
+ if (params.externalId) {
775
+ const mappedLocalId = await params.externalIdMappingService.lookupLocalId(
776
+ 'sync_excel',
777
+ 'customers.person',
778
+ params.externalId,
779
+ params.scope,
780
+ )
781
+ if (mappedLocalId) return mappedLocalId
782
+ }
783
+
784
+ if (!params.email) return null
785
+ return params.emailDedupeIndex.get(params.email) ?? null
786
+ }
787
+
788
+ async function buildEmailDedupeIndex(params: {
789
+ em: EntityManager
790
+ rows: CsvPreviewRow[]
791
+ mapping: DataMapping
792
+ scope: TenantScope
793
+ }): Promise<EmailDedupeIndex> {
794
+ const emails = new Set<string>()
795
+ for (const row of params.rows) {
796
+ const rowValues = mapRowValues(row, params.mapping.fields)
797
+ const email = rowValues.values.primaryEmail
798
+ if (email) emails.add(email)
799
+ }
800
+
801
+ if (emails.size === 0) return new Map()
802
+
803
+ const candidates = await findWithDecryption(
804
+ params.em,
805
+ CustomerEntity,
806
+ {
807
+ kind: 'person',
808
+ organizationId: params.scope.organizationId,
809
+ tenantId: params.scope.tenantId,
810
+ deletedAt: null,
811
+ isActive: true,
812
+ },
813
+ {
814
+ orderBy: {
815
+ createdAt: 'asc',
816
+ },
817
+ },
818
+ params.scope,
819
+ )
820
+
821
+ const index: EmailDedupeIndex = new Map()
822
+ for (const candidate of candidates) {
823
+ const email = normalizeEmail(candidate.primaryEmail)
824
+ if (!email || !emails.has(email) || index.has(email)) continue
825
+ index.set(email, candidate.id)
826
+ }
827
+ return index
828
+ }
829
+
830
+ function isEmptyRow(row: CsvPreviewRow): boolean {
831
+ return !Object.values(row).some((value) => normalizeOptionalString(value))
832
+ }
833
+
834
+ async function processRow(params: {
835
+ row: CsvPreviewRow
836
+ rowNumber: number
837
+ mapping: DataMapping
838
+ scope: TenantScope
839
+ commandBus: CommandBus
840
+ commandContext: CommandRuntimeContext
841
+ externalIdMappingService: ExternalIdMappingService
842
+ emailDedupeIndex: EmailDedupeIndex
843
+ customFieldDefinitions: Map<string, ImportCustomFieldDefinition>
844
+ em: EntityManager
845
+ }): Promise<ImportItem> {
846
+ if (isEmptyRow(params.row)) {
847
+ return {
848
+ externalId: `row:${params.rowNumber}`,
849
+ action: 'skip',
850
+ data: {
851
+ rowNumber: params.rowNumber,
852
+ reason: 'empty_row',
853
+ },
854
+ }
855
+ }
856
+
857
+ const payload = buildPersonPayload(params.row, params.mapping, params.scope)
858
+ const externalId = payload.values.externalId ?? null
859
+ const sourceIdentifier = payload.sourceIdentifier ?? `row:${params.rowNumber}`
860
+ const customFieldResult = coerceCustomFields(payload.customFields, params.customFieldDefinitions)
861
+ if (!customFieldResult.ok) {
862
+ return {
863
+ externalId: externalId ?? sourceIdentifier,
864
+ action: 'failed',
865
+ data: {
866
+ rowNumber: params.rowNumber,
867
+ sourceIdentifier,
868
+ errorMessage: customFieldResult.error,
869
+ },
870
+ }
871
+ }
872
+ const existingId = await resolveExistingPersonId({
873
+ externalIdMappingService: params.externalIdMappingService,
874
+ externalId,
875
+ email: payload.values.primaryEmail,
876
+ emailDedupeIndex: params.emailDedupeIndex,
877
+ scope: params.scope,
878
+ })
879
+
880
+ if (!existingId && !payload.createInput) {
881
+ return {
882
+ externalId: externalId ?? sourceIdentifier,
883
+ action: 'failed',
884
+ data: {
885
+ rowNumber: params.rowNumber,
886
+ sourceIdentifier,
887
+ errorMessage: 'Import row is missing a usable person name mapping.',
888
+ },
889
+ }
890
+ }
891
+
892
+ try {
893
+ if (existingId) {
894
+ const updateInput = {
895
+ id: existingId,
896
+ ...payload.updateInput,
897
+ ...(Object.keys(customFieldResult.values).length > 0 ? { customFields: customFieldResult.values } : {}),
898
+ }
899
+ await params.commandBus.execute('customers.people.update', {
900
+ input: updateInput,
901
+ ctx: params.commandContext,
902
+ })
903
+
904
+ await upsertPrimaryAddress({
905
+ entityId: existingId,
906
+ addressValues: payload.addressValues,
907
+ scope: params.scope,
908
+ commandBus: params.commandBus,
909
+ commandContext: params.commandContext,
910
+ em: params.em,
911
+ })
912
+
913
+ if (externalId) {
914
+ await params.externalIdMappingService.storeExternalIdMapping(
915
+ 'sync_excel',
916
+ 'customers.person',
917
+ existingId,
918
+ externalId,
919
+ params.scope,
920
+ )
921
+ }
922
+ if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
923
+ params.emailDedupeIndex.set(payload.values.primaryEmail, existingId)
924
+ }
925
+
926
+ return {
927
+ externalId: externalId ?? sourceIdentifier,
928
+ action: 'update',
929
+ data: {
930
+ localId: existingId,
931
+ rowNumber: params.rowNumber,
932
+ sourceIdentifier,
933
+ },
934
+ }
935
+ }
936
+
937
+ const commandResult = await params.commandBus.execute<
938
+ NonNullable<BuiltPersonPayload['createInput']>,
939
+ { entityId: string; personId: string }
940
+ >('customers.people.create', {
941
+ input: {
942
+ ...payload.createInput!,
943
+ ...(Object.keys(customFieldResult.values).length > 0 ? { customFields: customFieldResult.values } : {}),
944
+ },
945
+ ctx: params.commandContext,
946
+ })
947
+
948
+ await upsertPrimaryAddress({
949
+ entityId: commandResult.result.entityId,
950
+ addressValues: payload.addressValues,
951
+ scope: params.scope,
952
+ commandBus: params.commandBus,
953
+ commandContext: params.commandContext,
954
+ em: params.em,
955
+ })
956
+
957
+ if (externalId) {
958
+ await params.externalIdMappingService.storeExternalIdMapping(
959
+ 'sync_excel',
960
+ 'customers.person',
961
+ commandResult.result.entityId,
962
+ externalId,
963
+ params.scope,
964
+ )
965
+ }
966
+ if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
967
+ params.emailDedupeIndex.set(payload.values.primaryEmail, commandResult.result.entityId)
968
+ }
969
+
970
+ return {
971
+ externalId: externalId ?? sourceIdentifier,
972
+ action: 'create',
973
+ data: {
974
+ localId: commandResult.result.entityId,
975
+ personId: commandResult.result.personId,
976
+ rowNumber: params.rowNumber,
977
+ sourceIdentifier,
978
+ },
979
+ }
980
+ } catch (error) {
981
+ const errorMessage = error instanceof Error ? error.message : 'Import row failed'
982
+ return {
983
+ externalId: externalId ?? sourceIdentifier,
984
+ action: 'failed',
985
+ data: {
986
+ rowNumber: params.rowNumber,
987
+ sourceIdentifier,
988
+ errorMessage,
989
+ },
990
+ }
991
+ }
992
+ }
993
+
994
+ export const syncExcelCustomersAdapter: DataSyncAdapter = {
995
+ providerKey: 'excel',
996
+ operationalTelemetry: true,
997
+ direction: 'import',
998
+ supportedEntities: ['customers.person'],
999
+
1000
+ async getMapping(input): Promise<DataMapping> {
1001
+ const container = await createRequestContainer()
1002
+ const em = container.resolve('em') as EntityManager
1003
+ return loadStoredMapping(em, input.entityType, input.scope)
1004
+ },
1005
+
1006
+ async *streamImport(input): AsyncIterable<ImportBatch> {
1007
+ if (input.entityType !== 'customers.person') {
1008
+ throw new Error(`Unsupported sync_excel entity type: ${input.entityType}`)
1009
+ }
1010
+
1011
+ const container = await createRequestContainer()
1012
+ const em = container.resolve('em') as EntityManager
1013
+ const commandBus = container.resolve('commandBus') as CommandBus
1014
+ const externalIdMappingService = container.resolve('externalIdMappingService') as ExternalIdMappingService
1015
+ const cursor = parseCursor(input.cursor)
1016
+ const upload = await resolveUpload(em, input.runId, cursor, input.scope)
1017
+
1018
+ if (!upload) {
1019
+ throw new Error('CSV upload session was not found for this sync run.')
1020
+ }
1021
+
1022
+ if (input.runId) {
1023
+ upload.syncRunId = input.runId
1024
+ }
1025
+ upload.status = 'importing'
1026
+ await em.flush()
1027
+
1028
+ try {
1029
+ const attachment = await findOneWithDecryption(
1030
+ em,
1031
+ Attachment,
1032
+ {
1033
+ id: upload.attachmentId,
1034
+ organizationId: input.scope.organizationId,
1035
+ tenantId: input.scope.tenantId,
1036
+ },
1037
+ undefined,
1038
+ input.scope,
1039
+ )
1040
+
1041
+ if (!attachment) {
1042
+ throw new Error('CSV upload attachment could not be found.')
1043
+ }
1044
+
1045
+ const fileBuffer = await readSyncExcelUploadBuffer(attachment)
1046
+ const document = parseCsvDocument(fileBuffer)
1047
+ const startOffset = cursor?.uploadId === upload.id ? cursor.offset : 0
1048
+ const commandContext = buildCommandContext(container, input.scope)
1049
+ const customFieldDefinitions = await loadImportCustomFieldDefinitions(em, input.scope)
1050
+
1051
+ for (let offset = startOffset, batchIndex = 0; offset < document.rows.length; offset += input.batchSize, batchIndex += 1) {
1052
+ const batchRows = document.rows.slice(offset, offset + input.batchSize)
1053
+ const emailDedupeIndex = await buildEmailDedupeIndex({
1054
+ em,
1055
+ rows: batchRows,
1056
+ mapping: input.mapping,
1057
+ scope: input.scope,
1058
+ })
1059
+ const items: ImportItem[] = []
1060
+
1061
+ for (let index = 0; index < batchRows.length; index += 1) {
1062
+ items.push(await processRow({
1063
+ row: batchRows[index],
1064
+ rowNumber: offset + index + 1,
1065
+ mapping: input.mapping,
1066
+ scope: input.scope,
1067
+ commandBus,
1068
+ commandContext,
1069
+ externalIdMappingService,
1070
+ emailDedupeIndex,
1071
+ customFieldDefinitions,
1072
+ em,
1073
+ }))
1074
+ }
1075
+
1076
+ const nextOffset = offset + batchRows.length
1077
+ yield {
1078
+ items,
1079
+ cursor: createCursor(upload.id, nextOffset),
1080
+ hasMore: nextOffset < document.rows.length,
1081
+ totalEstimate: document.totalRows,
1082
+ processedCount: batchRows.length,
1083
+ refreshCoverageEntityTypes: CUSTOMER_IMPORT_COVERAGE_ENTITY_TYPES,
1084
+ batchIndex,
1085
+ message: `Processed ${nextOffset} of ${document.totalRows} CSV rows`,
1086
+ }
1087
+ }
1088
+
1089
+ upload.status = 'completed'
1090
+ await em.flush()
1091
+ } catch (error) {
1092
+ upload.status = 'failed'
1093
+ await em.flush()
1094
+ throw error
1095
+ }
1096
+ },
1097
+ }