@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,91 @@
|
|
|
1
|
+
import type { DataMapping, FieldMapping } from '../../data_sync/lib/adapter'
|
|
2
|
+
|
|
3
|
+
const PERSON_FIELD_ALIASES: Record<string, string[]> = {
|
|
4
|
+
'person.externalId': ['record id', 'external id', 'lead id'],
|
|
5
|
+
'person.firstName': ['first name', 'firstname', 'given name'],
|
|
6
|
+
'person.lastName': ['last name', 'lastname', 'surname', 'family name'],
|
|
7
|
+
'person.displayName': ['lead name', 'display name', 'full name', 'name'],
|
|
8
|
+
'person.primaryEmail': ['email', 'primary email', 'email address'],
|
|
9
|
+
'person.primaryPhone': ['mobile', 'phone', 'mobile phone', 'telephone'],
|
|
10
|
+
'person.jobTitle': ['title', 'job title', 'position'],
|
|
11
|
+
'person.status': ['lead status', 'status'],
|
|
12
|
+
'person.source': ['lead source', 'source'],
|
|
13
|
+
'person.description': ['description', 'notes', 'comment'],
|
|
14
|
+
'address.name': ['address label', 'address name', 'label'],
|
|
15
|
+
'address.purpose': ['address purpose', 'address type'],
|
|
16
|
+
'address.companyName': ['address company', 'address company name'],
|
|
17
|
+
'address.addressLine1': ['address line 1', 'street address', 'street', 'street 1'],
|
|
18
|
+
'address.addressLine2': ['address line 2', 'street 2'],
|
|
19
|
+
'address.buildingNumber': ['building number', 'building no', 'house number'],
|
|
20
|
+
'address.flatNumber': ['flat number', 'apartment number', 'unit number'],
|
|
21
|
+
'address.city': ['city', 'town'],
|
|
22
|
+
'address.region': ['region', 'state', 'province'],
|
|
23
|
+
'address.postalCode': ['postal code', 'zip code', 'postcode'],
|
|
24
|
+
'address.country': ['country'],
|
|
25
|
+
'address.latitude': ['latitude', 'lat'],
|
|
26
|
+
'address.longitude': ['longitude', 'lng', 'lon'],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeLabel(value: string): string {
|
|
30
|
+
return value
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/[_-]+/g, ' ')
|
|
35
|
+
.replace(/\s+/g, ' ')
|
|
36
|
+
.trim()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildFieldMapping(externalField: string, localField: string): FieldMapping {
|
|
40
|
+
if (localField === 'person.externalId') {
|
|
41
|
+
return {
|
|
42
|
+
externalField,
|
|
43
|
+
localField,
|
|
44
|
+
mappingKind: 'external_id',
|
|
45
|
+
dedupeRole: 'primary',
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (localField === 'person.primaryEmail') {
|
|
50
|
+
return {
|
|
51
|
+
externalField,
|
|
52
|
+
localField,
|
|
53
|
+
mappingKind: 'core',
|
|
54
|
+
dedupeRole: 'secondary',
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
externalField,
|
|
60
|
+
localField,
|
|
61
|
+
mappingKind: 'core',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function detectCustomersPersonMapping(columns: string[]): DataMapping & { unmappedColumns: string[] } {
|
|
66
|
+
const fields: FieldMapping[] = []
|
|
67
|
+
const unmappedColumns: string[] = []
|
|
68
|
+
|
|
69
|
+
for (const column of columns) {
|
|
70
|
+
const normalizedColumn = normalizeLabel(column)
|
|
71
|
+
const matchingField = Object.entries(PERSON_FIELD_ALIASES).find(([, aliases]) => aliases.includes(normalizedColumn))
|
|
72
|
+
|
|
73
|
+
if (!matchingField) {
|
|
74
|
+
unmappedColumns.push(column)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fields.push(buildFieldMapping(column, matchingField[0]))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hasExternalId = fields.some((field) => field.localField === 'person.externalId')
|
|
82
|
+
const hasEmail = fields.some((field) => field.localField === 'person.primaryEmail')
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
entityType: 'customers.person',
|
|
86
|
+
matchStrategy: hasExternalId ? 'externalId' : hasEmail ? 'email' : 'custom',
|
|
87
|
+
matchField: hasExternalId ? 'person.externalId' : hasEmail ? 'person.primaryEmail' : undefined,
|
|
88
|
+
fields,
|
|
89
|
+
unmappedColumns,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DataMapping, FieldMapping } from '../../data_sync/lib/adapter'
|
|
2
|
+
import { detectCustomersPersonMapping } from './column-detector'
|
|
3
|
+
|
|
4
|
+
export type SuggestedMapping = DataMapping & {
|
|
5
|
+
unmappedColumns: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function buildSuggestedMapping(entityType: string, headers: string[]): SuggestedMapping {
|
|
9
|
+
if (entityType === 'customers.person') {
|
|
10
|
+
return detectCustomersPersonMapping(headers)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
entityType,
|
|
15
|
+
matchStrategy: 'custom',
|
|
16
|
+
fields: [] as FieldMapping[],
|
|
17
|
+
unmappedColumns: headers.slice(),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
export type CsvPreviewRow = Record<string, string | null>
|
|
2
|
+
|
|
3
|
+
export type CsvPreview = {
|
|
4
|
+
headers: string[]
|
|
5
|
+
sampleRows: CsvPreviewRow[]
|
|
6
|
+
totalRows: number
|
|
7
|
+
delimiter: ',' | ';'
|
|
8
|
+
encoding: 'utf-8'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CsvDocument = {
|
|
12
|
+
headers: string[]
|
|
13
|
+
rows: CsvPreviewRow[]
|
|
14
|
+
totalRows: number
|
|
15
|
+
delimiter: ',' | ';'
|
|
16
|
+
encoding: 'utf-8'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ParseCsvPreviewOptions = {
|
|
20
|
+
maxRows?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stripUtf8Bom(value: string): string {
|
|
24
|
+
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function countFields(line: string, delimiter: ',' | ';'): number {
|
|
28
|
+
let count = 1
|
|
29
|
+
let inQuotes = false
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
32
|
+
const character = line[index]
|
|
33
|
+
if (character === '"') {
|
|
34
|
+
if (inQuotes && line[index + 1] === '"') {
|
|
35
|
+
index += 1
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
inQuotes = !inQuotes
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (!inQuotes && character === delimiter) {
|
|
42
|
+
count += 1
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return count
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function detectCsvDelimiter(text: string): ',' | ';' {
|
|
50
|
+
const normalized = stripUtf8Bom(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
51
|
+
const lines = normalized
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter((line) => line.length > 0)
|
|
55
|
+
.slice(0, 5)
|
|
56
|
+
|
|
57
|
+
if (lines.length === 0) return ','
|
|
58
|
+
|
|
59
|
+
const commaScore = lines.reduce((accumulator, line) => accumulator + countFields(line, ','), 0)
|
|
60
|
+
const semicolonScore = lines.reduce((accumulator, line) => accumulator + countFields(line, ';'), 0)
|
|
61
|
+
|
|
62
|
+
return semicolonScore > commaScore ? ';' : ','
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseCsvText(text: string, delimiter: ',' | ';'): string[][] {
|
|
66
|
+
const normalized = stripUtf8Bom(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
67
|
+
const rows: string[][] = []
|
|
68
|
+
let currentRow: string[] = []
|
|
69
|
+
let currentValue = ''
|
|
70
|
+
let inQuotes = false
|
|
71
|
+
|
|
72
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
73
|
+
const character = normalized[index]
|
|
74
|
+
|
|
75
|
+
if (character === '"') {
|
|
76
|
+
if (inQuotes && normalized[index + 1] === '"') {
|
|
77
|
+
currentValue += '"'
|
|
78
|
+
index += 1
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
inQuotes = !inQuotes
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!inQuotes && character === delimiter) {
|
|
86
|
+
currentRow.push(currentValue)
|
|
87
|
+
currentValue = ''
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!inQuotes && character === '\n') {
|
|
92
|
+
currentRow.push(currentValue)
|
|
93
|
+
const isMeaningfulRow = currentRow.some((value) => value.trim().length > 0)
|
|
94
|
+
if (isMeaningfulRow) {
|
|
95
|
+
rows.push(currentRow)
|
|
96
|
+
}
|
|
97
|
+
currentRow = []
|
|
98
|
+
currentValue = ''
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
currentValue += character
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (currentValue.length > 0 || currentRow.length > 0) {
|
|
106
|
+
currentRow.push(currentValue)
|
|
107
|
+
const isMeaningfulRow = currentRow.some((value) => value.trim().length > 0)
|
|
108
|
+
if (isMeaningfulRow) {
|
|
109
|
+
rows.push(currentRow)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return rows
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeHeaders(headers: string[]): string[] {
|
|
117
|
+
return headers.map((header, index) => {
|
|
118
|
+
const trimmed = header.trim()
|
|
119
|
+
return trimmed.length > 0 ? trimmed : `Column ${index + 1}`
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function rowsToObjects(headers: string[], rows: string[][], maxRows: number): CsvPreviewRow[] {
|
|
124
|
+
return rows.slice(0, maxRows).map((row) => {
|
|
125
|
+
const record: CsvPreviewRow = {}
|
|
126
|
+
headers.forEach((header, index) => {
|
|
127
|
+
const value = row[index]
|
|
128
|
+
record[header] = typeof value === 'string' && value.length > 0 ? value : null
|
|
129
|
+
})
|
|
130
|
+
return record
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseCsvPreview(buffer: Buffer, options: ParseCsvPreviewOptions = {}): CsvPreview {
|
|
135
|
+
const text = buffer.toString('utf-8')
|
|
136
|
+
const delimiter = detectCsvDelimiter(text)
|
|
137
|
+
const rows = parseCsvText(text, delimiter)
|
|
138
|
+
const [rawHeaders = [], ...dataRows] = rows
|
|
139
|
+
const headers = normalizeHeaders(rawHeaders)
|
|
140
|
+
const maxRows = options.maxRows ?? 5
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
headers,
|
|
144
|
+
sampleRows: rowsToObjects(headers, dataRows, maxRows),
|
|
145
|
+
totalRows: dataRows.length,
|
|
146
|
+
delimiter,
|
|
147
|
+
encoding: 'utf-8',
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseCsvDocument(buffer: Buffer): CsvDocument {
|
|
152
|
+
const text = buffer.toString('utf-8')
|
|
153
|
+
const delimiter = detectCsvDelimiter(text)
|
|
154
|
+
const rows = parseCsvText(text, delimiter)
|
|
155
|
+
const [rawHeaders = [], ...dataRows] = rows
|
|
156
|
+
const headers = normalizeHeaders(rawHeaders)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
headers,
|
|
160
|
+
rows: rowsToObjects(headers, dataRows, dataRows.length),
|
|
161
|
+
totalRows: dataRows.length,
|
|
162
|
+
delimiter,
|
|
163
|
+
encoding: 'utf-8',
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { isAllOrganizationsSelection } from '@open-mercato/core/modules/directory/constants'
|
|
4
|
+
import { getSelectedOrganizationFromRequest, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
5
|
+
import type { TenantScope } from '../../data_sync/lib/adapter'
|
|
6
|
+
|
|
7
|
+
export type SyncExcelConcreteScopeResult =
|
|
8
|
+
| { ok: true; scope: TenantScope }
|
|
9
|
+
| { ok: false; status: 401 | 422; error: string }
|
|
10
|
+
|
|
11
|
+
const CONCRETE_ORGANIZATION_REQUIRED_ERROR = 'Select a concrete organization before importing CSV.'
|
|
12
|
+
|
|
13
|
+
function normalizeSelectedOrganizationId(value: string | null | undefined): string | null {
|
|
14
|
+
if (typeof value !== 'string') return null
|
|
15
|
+
const trimmed = value.trim()
|
|
16
|
+
return trimmed.length > 0 ? trimmed : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function resolveSyncExcelConcreteScope(params: {
|
|
20
|
+
auth: AuthContext
|
|
21
|
+
container: AwilixContainer
|
|
22
|
+
request: Request
|
|
23
|
+
}): Promise<SyncExcelConcreteScopeResult> {
|
|
24
|
+
const { auth, container, request } = params
|
|
25
|
+
if (!auth?.tenantId) {
|
|
26
|
+
return { ok: false, status: 401, error: 'Unauthorized' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const requestedSelectedId = normalizeSelectedOrganizationId(getSelectedOrganizationFromRequest(request))
|
|
30
|
+
if (!requestedSelectedId || isAllOrganizationsSelection(requestedSelectedId)) {
|
|
31
|
+
return { ok: false, status: 422, error: CONCRETE_ORGANIZATION_REQUIRED_ERROR }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const organizationScope = await resolveOrganizationScopeForRequest({
|
|
35
|
+
container,
|
|
36
|
+
auth,
|
|
37
|
+
request,
|
|
38
|
+
selectedId: requestedSelectedId,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const selectedId = organizationScope.selectedId
|
|
42
|
+
const tenantId = organizationScope.tenantId ?? auth.tenantId
|
|
43
|
+
const filterIds = organizationScope.filterIds
|
|
44
|
+
const selectedIsResolvable = Boolean(
|
|
45
|
+
selectedId
|
|
46
|
+
&& tenantId
|
|
47
|
+
&& Array.isArray(filterIds)
|
|
48
|
+
&& selectedId === requestedSelectedId
|
|
49
|
+
&& filterIds.includes(selectedId),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if (!selectedIsResolvable || !selectedId || !tenantId) {
|
|
53
|
+
return { ok: false, status: 422, error: CONCRETE_ORGANIZATION_REQUIRED_ERROR }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
scope: {
|
|
59
|
+
organizationId: selectedId,
|
|
60
|
+
tenantId,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { Attachment, AttachmentPartition } from '../../attachments/data/entities'
|
|
5
|
+
import { buildAttachmentFileUrl } from '../../attachments/lib/imageUrls'
|
|
6
|
+
import { ensureDefaultPartitions } from '../../attachments/lib/partitions'
|
|
7
|
+
import { resolveAttachmentAbsolutePath, storePartitionFile } from '../../attachments/lib/storage'
|
|
8
|
+
|
|
9
|
+
const SYNC_EXCEL_ATTACHMENT_ENTITY_ID = 'sync_excel:upload'
|
|
10
|
+
const SYNC_EXCEL_PARTITION_CODE = 'privateAttachments'
|
|
11
|
+
|
|
12
|
+
export async function createSyncExcelUploadAttachment(input: {
|
|
13
|
+
em: EntityManager
|
|
14
|
+
uploadId: string
|
|
15
|
+
organizationId: string
|
|
16
|
+
tenantId: string
|
|
17
|
+
fileName: string
|
|
18
|
+
mimeType: string
|
|
19
|
+
buffer: Buffer
|
|
20
|
+
}): Promise<Attachment> {
|
|
21
|
+
await ensureDefaultPartitions(input.em)
|
|
22
|
+
|
|
23
|
+
// Attachment partitions are global storage configuration, not tenant-scoped payload data.
|
|
24
|
+
const partition = await input.em.findOne(AttachmentPartition, { code: SYNC_EXCEL_PARTITION_CODE })
|
|
25
|
+
if (!partition) {
|
|
26
|
+
throw new Error('Storage partition is not configured.')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stored = await storePartitionFile({
|
|
30
|
+
partitionCode: partition.code,
|
|
31
|
+
orgId: input.organizationId,
|
|
32
|
+
tenantId: input.tenantId,
|
|
33
|
+
fileName: input.fileName,
|
|
34
|
+
buffer: input.buffer,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const attachmentId = randomUUID()
|
|
38
|
+
const attachment = input.em.create(Attachment, {
|
|
39
|
+
id: attachmentId,
|
|
40
|
+
entityId: SYNC_EXCEL_ATTACHMENT_ENTITY_ID,
|
|
41
|
+
recordId: input.uploadId,
|
|
42
|
+
organizationId: input.organizationId,
|
|
43
|
+
tenantId: input.tenantId,
|
|
44
|
+
partitionCode: partition.code,
|
|
45
|
+
fileName: input.fileName,
|
|
46
|
+
mimeType: input.mimeType,
|
|
47
|
+
fileSize: input.buffer.length,
|
|
48
|
+
storageDriver: partition.storageDriver || 'local',
|
|
49
|
+
storagePath: stored.storagePath,
|
|
50
|
+
url: buildAttachmentFileUrl(attachmentId),
|
|
51
|
+
storageMetadata: {
|
|
52
|
+
module: 'sync_excel',
|
|
53
|
+
temporary: true,
|
|
54
|
+
uploadId: input.uploadId,
|
|
55
|
+
inlineCsvBase64: input.buffer.toString('base64'),
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
input.em.persist(attachment)
|
|
60
|
+
await input.em.flush()
|
|
61
|
+
|
|
62
|
+
return attachment
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readSyncExcelUploadBuffer(
|
|
66
|
+
attachment: Pick<Attachment, 'partitionCode' | 'storagePath' | 'storageDriver' | 'storageMetadata'>,
|
|
67
|
+
): Promise<Buffer> {
|
|
68
|
+
const inlineCsvBase64 = attachment.storageMetadata?.inlineCsvBase64
|
|
69
|
+
if (typeof inlineCsvBase64 === 'string' && inlineCsvBase64.length > 0) {
|
|
70
|
+
return Buffer.from(inlineCsvBase64, 'base64')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const absolutePath = resolveAttachmentAbsolutePath(
|
|
74
|
+
attachment.partitionCode,
|
|
75
|
+
attachment.storagePath,
|
|
76
|
+
attachment.storageDriver,
|
|
77
|
+
)
|
|
78
|
+
return fs.readFile(absolutePath)
|
|
79
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
{
|
|
2
|
+
"namespaces": [
|
|
3
|
+
"public"
|
|
4
|
+
],
|
|
5
|
+
"name": "public",
|
|
6
|
+
"tables": [
|
|
7
|
+
{
|
|
8
|
+
"columns": {
|
|
9
|
+
"id": {
|
|
10
|
+
"name": "id",
|
|
11
|
+
"type": "uuid",
|
|
12
|
+
"unsigned": false,
|
|
13
|
+
"autoincrement": false,
|
|
14
|
+
"primary": false,
|
|
15
|
+
"nullable": false,
|
|
16
|
+
"default": "gen_random_uuid()",
|
|
17
|
+
"mappedType": "uuid"
|
|
18
|
+
},
|
|
19
|
+
"attachment_id": {
|
|
20
|
+
"name": "attachment_id",
|
|
21
|
+
"type": "uuid",
|
|
22
|
+
"unsigned": false,
|
|
23
|
+
"autoincrement": false,
|
|
24
|
+
"primary": false,
|
|
25
|
+
"nullable": false,
|
|
26
|
+
"mappedType": "uuid"
|
|
27
|
+
},
|
|
28
|
+
"filename": {
|
|
29
|
+
"name": "filename",
|
|
30
|
+
"type": "text",
|
|
31
|
+
"unsigned": false,
|
|
32
|
+
"autoincrement": false,
|
|
33
|
+
"primary": false,
|
|
34
|
+
"nullable": false,
|
|
35
|
+
"mappedType": "text"
|
|
36
|
+
},
|
|
37
|
+
"mime_type": {
|
|
38
|
+
"name": "mime_type",
|
|
39
|
+
"type": "text",
|
|
40
|
+
"unsigned": false,
|
|
41
|
+
"autoincrement": false,
|
|
42
|
+
"primary": false,
|
|
43
|
+
"nullable": false,
|
|
44
|
+
"mappedType": "text"
|
|
45
|
+
},
|
|
46
|
+
"file_size": {
|
|
47
|
+
"name": "file_size",
|
|
48
|
+
"type": "int",
|
|
49
|
+
"unsigned": false,
|
|
50
|
+
"autoincrement": false,
|
|
51
|
+
"primary": false,
|
|
52
|
+
"nullable": false,
|
|
53
|
+
"mappedType": "integer"
|
|
54
|
+
},
|
|
55
|
+
"entity_type": {
|
|
56
|
+
"name": "entity_type",
|
|
57
|
+
"type": "text",
|
|
58
|
+
"unsigned": false,
|
|
59
|
+
"autoincrement": false,
|
|
60
|
+
"primary": false,
|
|
61
|
+
"nullable": false,
|
|
62
|
+
"mappedType": "text"
|
|
63
|
+
},
|
|
64
|
+
"delimiter": {
|
|
65
|
+
"name": "delimiter",
|
|
66
|
+
"type": "text",
|
|
67
|
+
"unsigned": false,
|
|
68
|
+
"autoincrement": false,
|
|
69
|
+
"primary": false,
|
|
70
|
+
"nullable": true,
|
|
71
|
+
"mappedType": "text"
|
|
72
|
+
},
|
|
73
|
+
"encoding": {
|
|
74
|
+
"name": "encoding",
|
|
75
|
+
"type": "text",
|
|
76
|
+
"unsigned": false,
|
|
77
|
+
"autoincrement": false,
|
|
78
|
+
"primary": false,
|
|
79
|
+
"nullable": true,
|
|
80
|
+
"mappedType": "text"
|
|
81
|
+
},
|
|
82
|
+
"headers": {
|
|
83
|
+
"name": "headers",
|
|
84
|
+
"type": "jsonb",
|
|
85
|
+
"unsigned": false,
|
|
86
|
+
"autoincrement": false,
|
|
87
|
+
"primary": false,
|
|
88
|
+
"nullable": false,
|
|
89
|
+
"mappedType": "json"
|
|
90
|
+
},
|
|
91
|
+
"sample_rows": {
|
|
92
|
+
"name": "sample_rows",
|
|
93
|
+
"type": "jsonb",
|
|
94
|
+
"unsigned": false,
|
|
95
|
+
"autoincrement": false,
|
|
96
|
+
"primary": false,
|
|
97
|
+
"nullable": false,
|
|
98
|
+
"mappedType": "json"
|
|
99
|
+
},
|
|
100
|
+
"total_rows": {
|
|
101
|
+
"name": "total_rows",
|
|
102
|
+
"type": "int",
|
|
103
|
+
"unsigned": false,
|
|
104
|
+
"autoincrement": false,
|
|
105
|
+
"primary": false,
|
|
106
|
+
"nullable": false,
|
|
107
|
+
"mappedType": "integer"
|
|
108
|
+
},
|
|
109
|
+
"status": {
|
|
110
|
+
"name": "status",
|
|
111
|
+
"type": "text",
|
|
112
|
+
"unsigned": false,
|
|
113
|
+
"autoincrement": false,
|
|
114
|
+
"primary": false,
|
|
115
|
+
"nullable": false,
|
|
116
|
+
"default": "'uploaded'",
|
|
117
|
+
"mappedType": "text"
|
|
118
|
+
},
|
|
119
|
+
"sync_run_id": {
|
|
120
|
+
"name": "sync_run_id",
|
|
121
|
+
"type": "uuid",
|
|
122
|
+
"unsigned": false,
|
|
123
|
+
"autoincrement": false,
|
|
124
|
+
"primary": false,
|
|
125
|
+
"nullable": true,
|
|
126
|
+
"mappedType": "uuid"
|
|
127
|
+
},
|
|
128
|
+
"organization_id": {
|
|
129
|
+
"name": "organization_id",
|
|
130
|
+
"type": "uuid",
|
|
131
|
+
"unsigned": false,
|
|
132
|
+
"autoincrement": false,
|
|
133
|
+
"primary": false,
|
|
134
|
+
"nullable": false,
|
|
135
|
+
"mappedType": "uuid"
|
|
136
|
+
},
|
|
137
|
+
"tenant_id": {
|
|
138
|
+
"name": "tenant_id",
|
|
139
|
+
"type": "uuid",
|
|
140
|
+
"unsigned": false,
|
|
141
|
+
"autoincrement": false,
|
|
142
|
+
"primary": false,
|
|
143
|
+
"nullable": false,
|
|
144
|
+
"mappedType": "uuid"
|
|
145
|
+
},
|
|
146
|
+
"created_at": {
|
|
147
|
+
"name": "created_at",
|
|
148
|
+
"type": "timestamptz",
|
|
149
|
+
"unsigned": false,
|
|
150
|
+
"autoincrement": false,
|
|
151
|
+
"primary": false,
|
|
152
|
+
"nullable": false,
|
|
153
|
+
"length": 6,
|
|
154
|
+
"mappedType": "datetime"
|
|
155
|
+
},
|
|
156
|
+
"updated_at": {
|
|
157
|
+
"name": "updated_at",
|
|
158
|
+
"type": "timestamptz",
|
|
159
|
+
"unsigned": false,
|
|
160
|
+
"autoincrement": false,
|
|
161
|
+
"primary": false,
|
|
162
|
+
"nullable": false,
|
|
163
|
+
"length": 6,
|
|
164
|
+
"mappedType": "datetime"
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
"name": "sync_excel_uploads",
|
|
168
|
+
"schema": "public",
|
|
169
|
+
"indexes": [
|
|
170
|
+
{
|
|
171
|
+
"keyName": "sync_excel_uploads_organization_id_tenant_id_status_index",
|
|
172
|
+
"columnNames": [
|
|
173
|
+
"organization_id",
|
|
174
|
+
"tenant_id",
|
|
175
|
+
"status"
|
|
176
|
+
],
|
|
177
|
+
"composite": true,
|
|
178
|
+
"constraint": false,
|
|
179
|
+
"primary": false,
|
|
180
|
+
"unique": false
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"keyName": "sync_excel_uploads_pkey",
|
|
184
|
+
"columnNames": [
|
|
185
|
+
"id"
|
|
186
|
+
],
|
|
187
|
+
"composite": false,
|
|
188
|
+
"constraint": true,
|
|
189
|
+
"primary": true,
|
|
190
|
+
"unique": true
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
"checks": [],
|
|
194
|
+
"foreignKeys": {},
|
|
195
|
+
"nativeEnums": {}
|
|
196
|
+
}
|
|
197
|
+
],
|
|
198
|
+
"nativeEnums": {}
|
|
199
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260329181120_sync_excel extends Migration {
|
|
4
|
+
|
|
5
|
+
override async up(): Promise<void> {
|
|
6
|
+
this.addSql(`create table "sync_excel_uploads" ("id" uuid not null default gen_random_uuid(), "attachment_id" uuid not null, "filename" text not null, "mime_type" text not null, "file_size" int not null, "entity_type" text not null, "delimiter" text null, "encoding" text null, "headers" jsonb not null, "sample_rows" jsonb not null, "total_rows" int not null, "status" text not null default 'uploaded', "sync_run_id" uuid null, "organization_id" uuid not null, "tenant_id" uuid not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "sync_excel_uploads_pkey" primary key ("id"));`);
|
|
7
|
+
this.addSql(`create index "sync_excel_uploads_organization_id_tenant_id_status_index" on "sync_excel_uploads" ("organization_id", "tenant_id", "status");`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
2
|
+
import type { CredentialsService } from '../integrations/lib/credentials-service'
|
|
3
|
+
import type { IntegrationStateService } from '../integrations/lib/state-service'
|
|
4
|
+
|
|
5
|
+
export const setup: ModuleSetupConfig = {
|
|
6
|
+
defaultRoleFeatures: {
|
|
7
|
+
superadmin: ['sync_excel.view', 'sync_excel.run'],
|
|
8
|
+
admin: ['sync_excel.view', 'sync_excel.run'],
|
|
9
|
+
},
|
|
10
|
+
async seedDefaults({ tenantId, organizationId, container }) {
|
|
11
|
+
const credentialsService = container.resolve('integrationCredentialsService') as CredentialsService
|
|
12
|
+
const integrationStateService = container.resolve('integrationStateService') as IntegrationStateService
|
|
13
|
+
const scope = { tenantId, organizationId }
|
|
14
|
+
|
|
15
|
+
await credentialsService.save('sync_excel', {}, scope)
|
|
16
|
+
await integrationStateService.upsert('sync_excel', { isEnabled: true }, scope)
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default setup
|