@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/sync_excel_upload/index.js +37 -0
- package/dist/generated/entities/sync_excel_upload/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +4 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +19 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/search.js +3 -2
- package/dist/modules/customers/search.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +16 -0
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/di.js +2 -1
- package/dist/modules/data_sync/di.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +178 -54
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +23 -0
- package/dist/modules/data_sync/lib/sync-run-service.js.map +3 -3
- package/dist/modules/integrations/backend/integrations/[id]/page.js +241 -55
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +3 -3
- package/dist/modules/sync_excel/acl.js +10 -0
- package/dist/modules/sync_excel/acl.js.map +7 -0
- package/dist/modules/sync_excel/api/import/route.js +164 -0
- package/dist/modules/sync_excel/api/import/route.js.map +7 -0
- package/dist/modules/sync_excel/api/preview/route.js +84 -0
- package/dist/modules/sync_excel/api/preview/route.js.map +7 -0
- package/dist/modules/sync_excel/api/upload/route.js +131 -0
- package/dist/modules/sync_excel/api/upload/route.js.map +7 -0
- package/dist/modules/sync_excel/data/entities.js +79 -0
- package/dist/modules/sync_excel/data/entities.js.map +7 -0
- package/dist/modules/sync_excel/data/validators.js +58 -0
- package/dist/modules/sync_excel/data/validators.js.map +7 -0
- package/dist/modules/sync_excel/di.js +11 -0
- package/dist/modules/sync_excel/di.js.map +7 -0
- package/dist/modules/sync_excel/index.js +11 -0
- package/dist/modules/sync_excel/index.js.map +7 -0
- package/dist/modules/sync_excel/integration.js +36 -0
- package/dist/modules/sync_excel/integration.js.map +7 -0
- package/dist/modules/sync_excel/lib/adapters/customers.js +852 -0
- package/dist/modules/sync_excel/lib/adapters/customers.js.map +7 -0
- package/dist/modules/sync_excel/lib/column-detector.js +77 -0
- package/dist/modules/sync_excel/lib/column-detector.js.map +7 -0
- package/dist/modules/sync_excel/lib/mapping.js +16 -0
- package/dist/modules/sync_excel/lib/mapping.js.map +7 -0
- package/dist/modules/sync_excel/lib/parser.js +125 -0
- package/dist/modules/sync_excel/lib/parser.js.map +7 -0
- package/dist/modules/sync_excel/lib/scope.js +44 -0
- package/dist/modules/sync_excel/lib/scope.js.map +7 -0
- package/dist/modules/sync_excel/lib/upload-storage.js +63 -0
- package/dist/modules/sync_excel/lib/upload-storage.js.map +7 -0
- package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js +11 -0
- package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js.map +7 -0
- package/dist/modules/sync_excel/setup.js +19 -0
- package/dist/modules/sync_excel/setup.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +293 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js +934 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js +15 -0
- package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js.map +7 -0
- package/dist/modules/sync_excel/widgets/injection-table.js +17 -0
- package/dist/modules/sync_excel/widgets/injection-table.js.map +7 -0
- package/generated/entities/sync_excel_upload/index.ts +17 -0
- package/generated/entities.ids.generated.ts +4 -0
- package/generated/entity-fields-registry.ts +19 -0
- package/package.json +7 -7
- package/src/modules/customers/search.ts +3 -2
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +18 -0
- package/src/modules/data_sync/di.ts +4 -1
- package/src/modules/data_sync/lib/adapter.ts +15 -0
- package/src/modules/data_sync/lib/sync-engine.ts +205 -52
- package/src/modules/data_sync/lib/sync-run-service.ts +24 -0
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +220 -15
- package/src/modules/integrations/i18n/de.json +9 -0
- package/src/modules/integrations/i18n/en.json +9 -0
- package/src/modules/integrations/i18n/es.json +9 -0
- package/src/modules/integrations/i18n/pl.json +9 -0
- package/src/modules/sync_excel/acl.ts +6 -0
- package/src/modules/sync_excel/api/import/route.ts +182 -0
- package/src/modules/sync_excel/api/preview/route.ts +89 -0
- package/src/modules/sync_excel/api/upload/route.ts +144 -0
- package/src/modules/sync_excel/data/entities.ts +61 -0
- package/src/modules/sync_excel/data/validators.ts +58 -0
- package/src/modules/sync_excel/di.ts +9 -0
- package/src/modules/sync_excel/i18n/de.json +107 -0
- package/src/modules/sync_excel/i18n/en.json +107 -0
- package/src/modules/sync_excel/i18n/es.json +107 -0
- package/src/modules/sync_excel/i18n/pl.json +107 -0
- package/src/modules/sync_excel/index.ts +7 -0
- package/src/modules/sync_excel/integration.ts +31 -0
- package/src/modules/sync_excel/lib/adapters/customers.ts +1097 -0
- package/src/modules/sync_excel/lib/column-detector.ts +91 -0
- package/src/modules/sync_excel/lib/mapping.ts +19 -0
- package/src/modules/sync_excel/lib/parser.ts +165 -0
- package/src/modules/sync_excel/lib/scope.ts +63 -0
- package/src/modules/sync_excel/lib/upload-storage.ts +79 -0
- package/src/modules/sync_excel/migrations/.snapshot-open-mercato.json +199 -0
- package/src/modules/sync_excel/migrations/Migration20260329181120_sync_excel.ts +10 -0
- package/src/modules/sync_excel/setup.ts +20 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +351 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/widget.client.tsx +1224 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/widget.ts +30 -0
- 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
|
+
}
|