newo 3.4.0 → 3.4.2
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/CHANGELOG.md +16 -0
- package/dist/api.d.ts +3 -1
- package/dist/api.js +49 -1
- package/dist/application/migration/MigrationEngine.d.ts +141 -0
- package/dist/application/migration/MigrationEngine.js +322 -0
- package/dist/application/migration/index.d.ts +5 -0
- package/dist/application/migration/index.js +5 -0
- package/dist/application/sync/SyncEngine.d.ts +134 -0
- package/dist/application/sync/SyncEngine.js +335 -0
- package/dist/application/sync/index.d.ts +5 -0
- package/dist/application/sync/index.js +5 -0
- package/dist/cli/commands/create-attribute.js +1 -1
- package/dist/cli/commands/create-customer.d.ts +3 -0
- package/dist/cli/commands/create-customer.js +159 -0
- package/dist/cli/commands/diff.d.ts +6 -0
- package/dist/cli/commands/diff.js +288 -0
- package/dist/cli/commands/help.js +63 -3
- package/dist/cli/commands/logs.d.ts +18 -0
- package/dist/cli/commands/logs.js +283 -0
- package/dist/cli/commands/pull.js +114 -10
- package/dist/cli/commands/push.js +122 -12
- package/dist/cli/commands/update-attribute.d.ts +3 -0
- package/dist/cli/commands/update-attribute.js +78 -0
- package/dist/cli/commands/watch.d.ts +6 -0
- package/dist/cli/commands/watch.js +195 -0
- package/dist/cli-new/bootstrap.d.ts +74 -0
- package/dist/cli-new/bootstrap.js +154 -0
- package/dist/cli-new/di/Container.d.ts +64 -0
- package/dist/cli-new/di/Container.js +122 -0
- package/dist/cli-new/di/tokens.d.ts +77 -0
- package/dist/cli-new/di/tokens.js +76 -0
- package/dist/cli.js +20 -0
- package/dist/domain/resources/common/types.d.ts +71 -0
- package/dist/domain/resources/common/types.js +42 -0
- package/dist/domain/strategies/sync/AkbSyncStrategy.d.ts +63 -0
- package/dist/domain/strategies/sync/AkbSyncStrategy.js +274 -0
- package/dist/domain/strategies/sync/AttributeSyncStrategy.d.ts +87 -0
- package/dist/domain/strategies/sync/AttributeSyncStrategy.js +378 -0
- package/dist/domain/strategies/sync/ConversationSyncStrategy.d.ts +61 -0
- package/dist/domain/strategies/sync/ConversationSyncStrategy.js +232 -0
- package/dist/domain/strategies/sync/ISyncStrategy.d.ts +149 -0
- package/dist/domain/strategies/sync/ISyncStrategy.js +24 -0
- package/dist/domain/strategies/sync/IntegrationSyncStrategy.d.ts +68 -0
- package/dist/domain/strategies/sync/IntegrationSyncStrategy.js +413 -0
- package/dist/domain/strategies/sync/ProjectSyncStrategy.d.ts +111 -0
- package/dist/domain/strategies/sync/ProjectSyncStrategy.js +523 -0
- package/dist/domain/strategies/sync/index.d.ts +13 -0
- package/dist/domain/strategies/sync/index.js +19 -0
- package/dist/sync/migrate.js +99 -23
- package/dist/types.d.ts +124 -0
- package/package.json +3 -1
- package/src/api.ts +53 -2
- package/src/application/migration/MigrationEngine.ts +492 -0
- package/src/application/migration/index.ts +5 -0
- package/src/application/sync/SyncEngine.ts +467 -0
- package/src/application/sync/index.ts +5 -0
- package/src/cli/commands/create-attribute.ts +1 -1
- package/src/cli/commands/create-customer.ts +185 -0
- package/src/cli/commands/diff.ts +360 -0
- package/src/cli/commands/help.ts +63 -3
- package/src/cli/commands/logs.ts +329 -0
- package/src/cli/commands/pull.ts +128 -11
- package/src/cli/commands/push.ts +131 -13
- package/src/cli/commands/update-attribute.ts +82 -0
- package/src/cli/commands/watch.ts +227 -0
- package/src/cli-new/bootstrap.ts +252 -0
- package/src/cli-new/di/Container.ts +152 -0
- package/src/cli-new/di/tokens.ts +105 -0
- package/src/cli.ts +25 -0
- package/src/domain/resources/common/types.ts +106 -0
- package/src/domain/strategies/sync/AkbSyncStrategy.ts +358 -0
- package/src/domain/strategies/sync/AttributeSyncStrategy.ts +508 -0
- package/src/domain/strategies/sync/ConversationSyncStrategy.ts +299 -0
- package/src/domain/strategies/sync/ISyncStrategy.ts +182 -0
- package/src/domain/strategies/sync/IntegrationSyncStrategy.ts +522 -0
- package/src/domain/strategies/sync/ProjectSyncStrategy.ts +747 -0
- package/src/domain/strategies/sync/index.ts +46 -0
- package/src/sync/migrate.ts +103 -24
- package/src/types.ts +135 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AttributeSyncStrategy - Handles synchronization of Customer and Project Attributes
|
|
3
|
+
*
|
|
4
|
+
* This strategy implements ISyncStrategy for the Attributes resource.
|
|
5
|
+
*
|
|
6
|
+
* Key responsibilities:
|
|
7
|
+
* - Pull customer attributes from NEWO platform
|
|
8
|
+
* - Pull project attributes for all projects
|
|
9
|
+
* - Push changed attributes back to platform
|
|
10
|
+
* - Detect changes using stored hashes
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { getCustomerAttributes, getProjectAttributes, updateCustomerAttribute, updateProjectAttribute, listProjects } from '../../../api.js';
|
|
16
|
+
import { writeFileSafe, customerAttributesPath, customerAttributesMapPath } from '../../../fsutil.js';
|
|
17
|
+
import { sha256, saveHashes, loadHashes } from '../../../hash.js';
|
|
18
|
+
/**
|
|
19
|
+
* AttributeSyncStrategy - Handles attribute synchronization
|
|
20
|
+
*/
|
|
21
|
+
export class AttributeSyncStrategy {
|
|
22
|
+
apiClientFactory;
|
|
23
|
+
logger;
|
|
24
|
+
resourceType = 'attributes';
|
|
25
|
+
displayName = 'Attributes';
|
|
26
|
+
constructor(apiClientFactory, logger) {
|
|
27
|
+
this.apiClientFactory = apiClientFactory;
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pull all attributes from NEWO platform
|
|
32
|
+
*/
|
|
33
|
+
async pull(customer, options = {}) {
|
|
34
|
+
const client = await this.apiClientFactory(customer, options.verbose ?? false);
|
|
35
|
+
const hashes = {};
|
|
36
|
+
const items = [];
|
|
37
|
+
this.logger.verbose(`🔍 Fetching attributes for ${customer.idn}...`);
|
|
38
|
+
// Pull customer attributes
|
|
39
|
+
const customerAttrs = await this.pullCustomerAttributes(client, customer, hashes, options);
|
|
40
|
+
items.push(customerAttrs);
|
|
41
|
+
// Pull project attributes
|
|
42
|
+
const projects = await listProjects(client);
|
|
43
|
+
this.logger.verbose(`📁 Pulling attributes for ${projects.length} projects`);
|
|
44
|
+
for (const project of projects) {
|
|
45
|
+
try {
|
|
46
|
+
const projectAttrs = await this.pullProjectAttributes(client, customer, project.id, project.idn, hashes, options);
|
|
47
|
+
if (projectAttrs) {
|
|
48
|
+
items.push(projectAttrs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
this.logger.warn(`Failed to pull attributes for project ${project.idn}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Save hashes
|
|
56
|
+
const existingHashes = await loadHashes(customer.idn);
|
|
57
|
+
await saveHashes({ ...existingHashes, ...hashes }, customer.idn);
|
|
58
|
+
return {
|
|
59
|
+
items,
|
|
60
|
+
count: items.length,
|
|
61
|
+
hashes
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pull customer attributes
|
|
66
|
+
*/
|
|
67
|
+
async pullCustomerAttributes(client, customer, hashes, options) {
|
|
68
|
+
this.logger.verbose(` 📦 Fetching customer attributes...`);
|
|
69
|
+
const response = await getCustomerAttributes(client, true);
|
|
70
|
+
const attributes = response.attributes || [];
|
|
71
|
+
// Create ID mapping
|
|
72
|
+
const idMapping = {};
|
|
73
|
+
const cleanAttributes = attributes.map(attr => {
|
|
74
|
+
if (attr.id) {
|
|
75
|
+
idMapping[attr.idn] = attr.id;
|
|
76
|
+
}
|
|
77
|
+
return this.cleanAttribute(attr);
|
|
78
|
+
});
|
|
79
|
+
// Format as YAML
|
|
80
|
+
const yamlContent = this.formatAttributesYaml(cleanAttributes);
|
|
81
|
+
// Save files
|
|
82
|
+
const attributesPath = customerAttributesPath(customer.idn);
|
|
83
|
+
await writeFileSafe(attributesPath, yamlContent);
|
|
84
|
+
await writeFileSafe(customerAttributesMapPath(customer.idn), JSON.stringify(idMapping, null, 2));
|
|
85
|
+
hashes[attributesPath] = sha256(yamlContent);
|
|
86
|
+
if (options.verbose) {
|
|
87
|
+
this.logger.info(` ✓ Saved ${cleanAttributes.length} customer attributes`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
type: 'customer',
|
|
91
|
+
attributes: cleanAttributes,
|
|
92
|
+
idMapping
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Pull project attributes
|
|
97
|
+
*/
|
|
98
|
+
async pullProjectAttributes(client, customer, projectId, projectIdn, hashes, options) {
|
|
99
|
+
try {
|
|
100
|
+
const response = await getProjectAttributes(client, projectId, true);
|
|
101
|
+
const attributes = response.attributes || [];
|
|
102
|
+
if (attributes.length === 0) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
// Create ID mapping
|
|
106
|
+
const idMapping = {};
|
|
107
|
+
const cleanAttributes = attributes.map(attr => {
|
|
108
|
+
if (attr.id) {
|
|
109
|
+
idMapping[attr.idn] = attr.id;
|
|
110
|
+
}
|
|
111
|
+
return this.cleanAttribute(attr);
|
|
112
|
+
});
|
|
113
|
+
// Format as YAML
|
|
114
|
+
const yamlContent = this.formatAttributesYaml(cleanAttributes);
|
|
115
|
+
// Save files
|
|
116
|
+
const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
|
|
117
|
+
const projectDir = path.join(customerDir, 'projects', projectIdn);
|
|
118
|
+
await fs.ensureDir(projectDir);
|
|
119
|
+
const attributesFile = path.join(projectDir, 'attributes.yaml');
|
|
120
|
+
const mapFile = path.join(process.cwd(), '.newo', customer.idn, `project_${projectIdn}_attributes-map.json`);
|
|
121
|
+
await writeFileSafe(attributesFile, yamlContent);
|
|
122
|
+
await fs.ensureDir(path.dirname(mapFile));
|
|
123
|
+
await writeFileSafe(mapFile, JSON.stringify(idMapping, null, 2));
|
|
124
|
+
hashes[attributesFile] = sha256(yamlContent);
|
|
125
|
+
if (options.verbose) {
|
|
126
|
+
this.logger.verbose(` ✓ Saved ${cleanAttributes.length} attributes for project ${projectIdn}`);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
type: 'project',
|
|
130
|
+
projectIdn,
|
|
131
|
+
attributes: cleanAttributes,
|
|
132
|
+
idMapping
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Clean an attribute for local storage
|
|
141
|
+
*/
|
|
142
|
+
cleanAttribute(attr) {
|
|
143
|
+
let processedValue = attr.value;
|
|
144
|
+
// Handle JSON string values
|
|
145
|
+
if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(attr.value);
|
|
148
|
+
processedValue = JSON.stringify(parsed, null, 0);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
processedValue = attr.value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
idn: attr.idn,
|
|
156
|
+
value: processedValue,
|
|
157
|
+
title: attr.title || '',
|
|
158
|
+
description: attr.description || '',
|
|
159
|
+
group: attr.group || '',
|
|
160
|
+
is_hidden: attr.is_hidden,
|
|
161
|
+
possible_values: attr.possible_values || [],
|
|
162
|
+
value_type: attr.value_type
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Format attributes as YAML
|
|
167
|
+
*/
|
|
168
|
+
formatAttributesYaml(attributes) {
|
|
169
|
+
// Add enum placeholders for value_type
|
|
170
|
+
const attributesWithPlaceholders = attributes.map(attr => ({
|
|
171
|
+
...attr,
|
|
172
|
+
value_type: `__ENUM_PLACEHOLDER_${attr.value_type}__`
|
|
173
|
+
}));
|
|
174
|
+
let yamlContent = yaml.dump({ attributes: attributesWithPlaceholders }, {
|
|
175
|
+
indent: 2,
|
|
176
|
+
quotingType: '"',
|
|
177
|
+
forceQuotes: false,
|
|
178
|
+
lineWidth: 80,
|
|
179
|
+
noRefs: true,
|
|
180
|
+
sortKeys: false,
|
|
181
|
+
flowLevel: -1
|
|
182
|
+
});
|
|
183
|
+
// Replace placeholders with enum syntax
|
|
184
|
+
yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"');
|
|
185
|
+
yamlContent = yamlContent.replace(/\\"/g, '"');
|
|
186
|
+
return yamlContent;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Push changed attributes to NEWO platform
|
|
190
|
+
*/
|
|
191
|
+
async push(customer, changes) {
|
|
192
|
+
const result = { created: 0, updated: 0, deleted: 0, errors: [] };
|
|
193
|
+
if (!changes) {
|
|
194
|
+
changes = await this.getChanges(customer);
|
|
195
|
+
}
|
|
196
|
+
if (changes.length === 0) {
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
const client = await this.apiClientFactory(customer, false);
|
|
200
|
+
for (const change of changes) {
|
|
201
|
+
try {
|
|
202
|
+
if (change.item.type === 'customer') {
|
|
203
|
+
const updateCount = await this.pushCustomerAttributes(client, customer, change.item);
|
|
204
|
+
result.updated += updateCount;
|
|
205
|
+
}
|
|
206
|
+
else if (change.item.type === 'project' && change.item.projectIdn) {
|
|
207
|
+
const updateCount = await this.pushProjectAttributes(client, customer, change.item.projectIdn, change.item);
|
|
208
|
+
result.updated += updateCount;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
result.errors.push(`Failed to push ${change.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Push customer attributes
|
|
219
|
+
*/
|
|
220
|
+
async pushCustomerAttributes(client, customer, _data) {
|
|
221
|
+
// Load current attributes from file
|
|
222
|
+
const attributesFile = customerAttributesPath(customer.idn);
|
|
223
|
+
const mapFile = customerAttributesMapPath(customer.idn);
|
|
224
|
+
if (!(await fs.pathExists(attributesFile)) || !(await fs.pathExists(mapFile))) {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
let content = await fs.readFile(attributesFile, 'utf-8');
|
|
228
|
+
content = content.replace(/!enum "AttributeValueTypes\.(\w+)"/g, '$1');
|
|
229
|
+
const localData = yaml.load(content);
|
|
230
|
+
const idMapping = JSON.parse(await fs.readFile(mapFile, 'utf-8'));
|
|
231
|
+
// Get remote attributes
|
|
232
|
+
const remoteResponse = await getCustomerAttributes(client, true);
|
|
233
|
+
const remoteMap = new Map();
|
|
234
|
+
remoteResponse.attributes.forEach(attr => remoteMap.set(attr.idn, attr));
|
|
235
|
+
let updatedCount = 0;
|
|
236
|
+
for (const localAttr of localData.attributes) {
|
|
237
|
+
const attributeId = idMapping[localAttr.idn];
|
|
238
|
+
if (!attributeId)
|
|
239
|
+
continue;
|
|
240
|
+
const remoteAttr = remoteMap.get(localAttr.idn);
|
|
241
|
+
if (!remoteAttr)
|
|
242
|
+
continue;
|
|
243
|
+
if (String(localAttr.value) !== String(remoteAttr.value)) {
|
|
244
|
+
await updateCustomerAttribute(client, {
|
|
245
|
+
id: attributeId,
|
|
246
|
+
...localAttr
|
|
247
|
+
});
|
|
248
|
+
updatedCount++;
|
|
249
|
+
this.logger.info(` ✓ Updated customer attribute: ${localAttr.idn}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return updatedCount;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Push project attributes
|
|
256
|
+
*/
|
|
257
|
+
async pushProjectAttributes(client, customer, projectIdn, _data) {
|
|
258
|
+
const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
|
|
259
|
+
const attributesFile = path.join(customerDir, 'projects', projectIdn, 'attributes.yaml');
|
|
260
|
+
const mapFile = path.join(process.cwd(), '.newo', customer.idn, `project_${projectIdn}_attributes-map.json`);
|
|
261
|
+
if (!(await fs.pathExists(attributesFile)) || !(await fs.pathExists(mapFile))) {
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
let content = await fs.readFile(attributesFile, 'utf-8');
|
|
265
|
+
content = content.replace(/!enum "AttributeValueTypes\.(\w+)"/g, '$1');
|
|
266
|
+
const localData = yaml.load(content);
|
|
267
|
+
const idMapping = JSON.parse(await fs.readFile(mapFile, 'utf-8'));
|
|
268
|
+
// Get project ID from projects list
|
|
269
|
+
const projects = await listProjects(client);
|
|
270
|
+
const project = projects.find(p => p.idn === projectIdn);
|
|
271
|
+
if (!project) {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
// Get remote attributes
|
|
275
|
+
const remoteResponse = await getProjectAttributes(client, project.id, true);
|
|
276
|
+
const remoteMap = new Map();
|
|
277
|
+
remoteResponse.attributes.forEach(attr => remoteMap.set(attr.idn, attr));
|
|
278
|
+
let updatedCount = 0;
|
|
279
|
+
for (const localAttr of localData.attributes) {
|
|
280
|
+
const attributeId = idMapping[localAttr.idn];
|
|
281
|
+
if (!attributeId)
|
|
282
|
+
continue;
|
|
283
|
+
const remoteAttr = remoteMap.get(localAttr.idn);
|
|
284
|
+
if (!remoteAttr)
|
|
285
|
+
continue;
|
|
286
|
+
if (String(localAttr.value) !== String(remoteAttr.value)) {
|
|
287
|
+
await updateProjectAttribute(client, project.id, {
|
|
288
|
+
id: attributeId,
|
|
289
|
+
...localAttr
|
|
290
|
+
});
|
|
291
|
+
updatedCount++;
|
|
292
|
+
this.logger.info(` ✓ Updated project attribute: ${projectIdn}/${localAttr.idn}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return updatedCount;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Detect changes in attribute files
|
|
299
|
+
*/
|
|
300
|
+
async getChanges(customer) {
|
|
301
|
+
const changes = [];
|
|
302
|
+
const hashes = await loadHashes(customer.idn);
|
|
303
|
+
// Check customer attributes
|
|
304
|
+
const customerAttrsPath = customerAttributesPath(customer.idn);
|
|
305
|
+
if (await fs.pathExists(customerAttrsPath)) {
|
|
306
|
+
const content = await fs.readFile(customerAttrsPath, 'utf-8');
|
|
307
|
+
const currentHash = sha256(content);
|
|
308
|
+
const storedHash = hashes[customerAttrsPath];
|
|
309
|
+
if (storedHash !== currentHash) {
|
|
310
|
+
changes.push({
|
|
311
|
+
item: { type: 'customer', attributes: [], idMapping: {} },
|
|
312
|
+
operation: 'modified',
|
|
313
|
+
path: customerAttrsPath
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Check project attributes
|
|
318
|
+
const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn, 'projects');
|
|
319
|
+
if (await fs.pathExists(customerDir)) {
|
|
320
|
+
const projectDirs = await fs.readdir(customerDir);
|
|
321
|
+
for (const projectIdn of projectDirs) {
|
|
322
|
+
const attributesFile = path.join(customerDir, projectIdn, 'attributes.yaml');
|
|
323
|
+
if (await fs.pathExists(attributesFile)) {
|
|
324
|
+
const content = await fs.readFile(attributesFile, 'utf-8');
|
|
325
|
+
const currentHash = sha256(content);
|
|
326
|
+
const storedHash = hashes[attributesFile];
|
|
327
|
+
if (storedHash !== currentHash) {
|
|
328
|
+
changes.push({
|
|
329
|
+
item: { type: 'project', projectIdn, attributes: [], idMapping: {} },
|
|
330
|
+
operation: 'modified',
|
|
331
|
+
path: attributesFile
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return changes;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Validate attribute data
|
|
341
|
+
*/
|
|
342
|
+
async validate(_customer, items) {
|
|
343
|
+
const errors = [];
|
|
344
|
+
for (const item of items) {
|
|
345
|
+
for (const attr of item.attributes) {
|
|
346
|
+
if (!attr.idn) {
|
|
347
|
+
errors.push({
|
|
348
|
+
field: 'idn',
|
|
349
|
+
message: 'Attribute IDN is required'
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { valid: errors.length === 0, errors };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Get status summary
|
|
358
|
+
*/
|
|
359
|
+
async getStatus(customer) {
|
|
360
|
+
const changes = await this.getChanges(customer);
|
|
361
|
+
return {
|
|
362
|
+
resourceType: this.resourceType,
|
|
363
|
+
displayName: this.displayName,
|
|
364
|
+
changedCount: changes.length,
|
|
365
|
+
changes: changes.map(c => ({
|
|
366
|
+
path: c.path,
|
|
367
|
+
operation: c.operation
|
|
368
|
+
}))
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Factory function for creating AttributeSyncStrategy
|
|
374
|
+
*/
|
|
375
|
+
export function createAttributeSyncStrategy(apiClientFactory, logger) {
|
|
376
|
+
return new AttributeSyncStrategy(apiClientFactory, logger);
|
|
377
|
+
}
|
|
378
|
+
//# sourceMappingURL=AttributeSyncStrategy.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationSyncStrategy - Handles synchronization of Conversation history
|
|
3
|
+
*
|
|
4
|
+
* This strategy implements ISyncStrategy for the Conversations resource.
|
|
5
|
+
* Note: This is a pull-only strategy as conversations are read-only.
|
|
6
|
+
*
|
|
7
|
+
* Key responsibilities:
|
|
8
|
+
* - Pull conversation history from NEWO platform
|
|
9
|
+
* - Process user personas and their acts
|
|
10
|
+
* - Save conversations to YAML format
|
|
11
|
+
*/
|
|
12
|
+
import type { ISyncStrategy, PullOptions, PullResult, PushResult, ChangeItem, ValidationResult, StatusSummary } from './ISyncStrategy.js';
|
|
13
|
+
import type { CustomerConfig, ILogger } from '../../resources/common/types.js';
|
|
14
|
+
import type { AxiosInstance } from 'axios';
|
|
15
|
+
import type { UserPersona, ProcessedPersona } from '../../../types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Local conversation data for storage
|
|
18
|
+
*/
|
|
19
|
+
export interface LocalConversationData {
|
|
20
|
+
personas: ProcessedPersona[];
|
|
21
|
+
totalActs: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* API client factory type
|
|
25
|
+
*/
|
|
26
|
+
export type ApiClientFactory = (customer: CustomerConfig, verbose: boolean) => Promise<AxiosInstance>;
|
|
27
|
+
/**
|
|
28
|
+
* ConversationSyncStrategy - Handles conversation synchronization
|
|
29
|
+
*/
|
|
30
|
+
export declare class ConversationSyncStrategy implements ISyncStrategy<UserPersona, LocalConversationData> {
|
|
31
|
+
private apiClientFactory;
|
|
32
|
+
private logger;
|
|
33
|
+
readonly resourceType = "conversations";
|
|
34
|
+
readonly displayName = "Conversations";
|
|
35
|
+
constructor(apiClientFactory: ApiClientFactory, logger: ILogger);
|
|
36
|
+
/**
|
|
37
|
+
* Pull all conversations from NEWO platform
|
|
38
|
+
*/
|
|
39
|
+
pull(customer: CustomerConfig, options?: PullOptions): Promise<PullResult<LocalConversationData>>;
|
|
40
|
+
/**
|
|
41
|
+
* Push is not supported for conversations (read-only)
|
|
42
|
+
*/
|
|
43
|
+
push(_customer: CustomerConfig, _changes?: ChangeItem<LocalConversationData>[]): Promise<PushResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Get changes - conversations are typically regenerated on each pull
|
|
46
|
+
*/
|
|
47
|
+
getChanges(_customer: CustomerConfig): Promise<ChangeItem<LocalConversationData>[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Validate conversation data
|
|
50
|
+
*/
|
|
51
|
+
validate(_customer: CustomerConfig, _items: LocalConversationData[]): Promise<ValidationResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Get status summary
|
|
54
|
+
*/
|
|
55
|
+
getStatus(customer: CustomerConfig): Promise<StatusSummary>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Factory function for creating ConversationSyncStrategy
|
|
59
|
+
*/
|
|
60
|
+
export declare function createConversationSyncStrategy(apiClientFactory: ApiClientFactory, logger: ILogger): ConversationSyncStrategy;
|
|
61
|
+
//# sourceMappingURL=ConversationSyncStrategy.d.ts.map
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationSyncStrategy - Handles synchronization of Conversation history
|
|
3
|
+
*
|
|
4
|
+
* This strategy implements ISyncStrategy for the Conversations resource.
|
|
5
|
+
* Note: This is a pull-only strategy as conversations are read-only.
|
|
6
|
+
*
|
|
7
|
+
* Key responsibilities:
|
|
8
|
+
* - Pull conversation history from NEWO platform
|
|
9
|
+
* - Process user personas and their acts
|
|
10
|
+
* - Save conversations to YAML format
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import pLimit from 'p-limit';
|
|
16
|
+
import { listUserPersonas, getChatHistory } from '../../../api.js';
|
|
17
|
+
import { sha256, saveHashes, loadHashes } from '../../../hash.js';
|
|
18
|
+
// Concurrency limit for API calls
|
|
19
|
+
const concurrencyLimit = pLimit(5);
|
|
20
|
+
/**
|
|
21
|
+
* ConversationSyncStrategy - Handles conversation synchronization
|
|
22
|
+
*/
|
|
23
|
+
export class ConversationSyncStrategy {
|
|
24
|
+
apiClientFactory;
|
|
25
|
+
logger;
|
|
26
|
+
resourceType = 'conversations';
|
|
27
|
+
displayName = 'Conversations';
|
|
28
|
+
constructor(apiClientFactory, logger) {
|
|
29
|
+
this.apiClientFactory = apiClientFactory;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Pull all conversations from NEWO platform
|
|
34
|
+
*/
|
|
35
|
+
async pull(customer, options = {}) {
|
|
36
|
+
const client = await this.apiClientFactory(customer, options.verbose ?? false);
|
|
37
|
+
const hashes = {};
|
|
38
|
+
this.logger.verbose(`💬 Fetching conversations for ${customer.idn}...`);
|
|
39
|
+
const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
|
|
40
|
+
await fs.ensureDir(customerDir);
|
|
41
|
+
// Get all user personas with pagination
|
|
42
|
+
const allPersonas = [];
|
|
43
|
+
let page = 1;
|
|
44
|
+
const perPage = 50;
|
|
45
|
+
let hasMore = true;
|
|
46
|
+
while (hasMore) {
|
|
47
|
+
const response = await listUserPersonas(client, page, perPage);
|
|
48
|
+
allPersonas.push(...response.items);
|
|
49
|
+
this.logger.verbose(` 📋 Page ${page}: Found ${response.items.length} personas (${allPersonas.length}/${response.metadata.total} total)`);
|
|
50
|
+
hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
|
|
51
|
+
page++;
|
|
52
|
+
}
|
|
53
|
+
this.logger.verbose(`👥 Processing ${allPersonas.length} personas...`);
|
|
54
|
+
// Process personas concurrently with limited concurrency
|
|
55
|
+
const processedPersonas = [];
|
|
56
|
+
let totalActs = 0;
|
|
57
|
+
await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
|
|
58
|
+
try {
|
|
59
|
+
// Extract phone number from actors
|
|
60
|
+
const phoneActor = persona.actors.find(actor => actor.integration_idn === 'newo_voice' &&
|
|
61
|
+
actor.connector_idn === 'newo_voice_connector' &&
|
|
62
|
+
actor.contact_information?.startsWith('+'));
|
|
63
|
+
const phone = phoneActor?.contact_information || null;
|
|
64
|
+
// Get user actor IDs from persona actors
|
|
65
|
+
const userActors = persona.actors.filter(actor => actor.integration_idn === 'newo_voice' &&
|
|
66
|
+
actor.connector_idn === 'newo_voice_connector');
|
|
67
|
+
if (userActors.length === 0) {
|
|
68
|
+
processedPersonas.push({
|
|
69
|
+
id: persona.id,
|
|
70
|
+
name: persona.name,
|
|
71
|
+
phone,
|
|
72
|
+
act_count: persona.act_count,
|
|
73
|
+
acts: []
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Fetch chat history
|
|
78
|
+
const allActs = [];
|
|
79
|
+
let actPage = 1;
|
|
80
|
+
const actsPerPage = 100;
|
|
81
|
+
let hasMoreActs = true;
|
|
82
|
+
const maxPages = 50;
|
|
83
|
+
while (hasMoreActs && actPage <= maxPages) {
|
|
84
|
+
try {
|
|
85
|
+
const chatHistoryParams = {
|
|
86
|
+
user_actor_id: userActors[0].id,
|
|
87
|
+
page: actPage,
|
|
88
|
+
per: actsPerPage
|
|
89
|
+
};
|
|
90
|
+
const chatResponse = await getChatHistory(client, chatHistoryParams);
|
|
91
|
+
if (chatResponse.items && chatResponse.items.length > 0) {
|
|
92
|
+
const convertedActs = chatResponse.items.map((item) => ({
|
|
93
|
+
id: item.id || `chat_${Math.random()}`,
|
|
94
|
+
command_act_id: null,
|
|
95
|
+
external_event_id: item.external_event_id || 'chat_history',
|
|
96
|
+
arguments: [],
|
|
97
|
+
reference_idn: item.is_agent === true ? 'agent_message' : 'user_message',
|
|
98
|
+
runtime_context_id: item.runtime_context_id || 'chat_history',
|
|
99
|
+
source_text: item.payload?.text || item.message || '',
|
|
100
|
+
original_text: item.payload?.text || item.message || '',
|
|
101
|
+
datetime: item.datetime || item.created_at || new Date().toISOString(),
|
|
102
|
+
user_actor_id: userActors[0].id,
|
|
103
|
+
agent_actor_id: null,
|
|
104
|
+
user_persona_id: persona.id,
|
|
105
|
+
user_persona_name: persona.name,
|
|
106
|
+
agent_persona_id: item.agent_persona_id || 'unknown',
|
|
107
|
+
external_id: item.external_id || null,
|
|
108
|
+
integration_idn: 'newo_voice',
|
|
109
|
+
connector_idn: 'newo_voice_connector',
|
|
110
|
+
to_integration_idn: null,
|
|
111
|
+
to_connector_idn: null,
|
|
112
|
+
is_agent: Boolean(item.is_agent === true),
|
|
113
|
+
project_idn: null,
|
|
114
|
+
flow_idn: item.flow_idn || 'unknown',
|
|
115
|
+
skill_idn: item.skill_idn || 'unknown',
|
|
116
|
+
session_id: item.session_id || 'unknown',
|
|
117
|
+
recordings: item.recordings || [],
|
|
118
|
+
contact_information: item.contact_information || null
|
|
119
|
+
}));
|
|
120
|
+
allActs.push(...convertedActs);
|
|
121
|
+
hasMoreActs = chatResponse.items.length === actsPerPage &&
|
|
122
|
+
(!chatResponse.metadata?.total || allActs.length < chatResponse.metadata.total);
|
|
123
|
+
actPage++;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
hasMoreActs = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (_error) {
|
|
130
|
+
hasMoreActs = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Process acts for YAML output
|
|
134
|
+
const processedActs = allActs
|
|
135
|
+
.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime())
|
|
136
|
+
.map(act => ({
|
|
137
|
+
datetime: act.datetime,
|
|
138
|
+
type: act.is_agent ? 'agent' : 'user',
|
|
139
|
+
message: act.source_text,
|
|
140
|
+
contact_information: act.contact_information,
|
|
141
|
+
flow_idn: act.flow_idn,
|
|
142
|
+
skill_idn: act.skill_idn,
|
|
143
|
+
session_id: act.session_id
|
|
144
|
+
}));
|
|
145
|
+
processedPersonas.push({
|
|
146
|
+
id: persona.id,
|
|
147
|
+
name: persona.name,
|
|
148
|
+
phone,
|
|
149
|
+
act_count: persona.act_count,
|
|
150
|
+
acts: processedActs
|
|
151
|
+
});
|
|
152
|
+
totalActs += processedActs.length;
|
|
153
|
+
this.logger.verbose(` ✓ Processed ${persona.name}: ${processedActs.length} acts`);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
this.logger.warn(`Failed to process persona ${persona.name}`);
|
|
157
|
+
}
|
|
158
|
+
})));
|
|
159
|
+
// Sort personas by most recent activity
|
|
160
|
+
processedPersonas.sort((a, b) => {
|
|
161
|
+
const aLastAct = a.acts[a.acts.length - 1]?.datetime;
|
|
162
|
+
const bLastAct = b.acts[b.acts.length - 1]?.datetime;
|
|
163
|
+
if (!aLastAct && !bLastAct)
|
|
164
|
+
return 0;
|
|
165
|
+
if (!aLastAct)
|
|
166
|
+
return 1;
|
|
167
|
+
if (!bLastAct)
|
|
168
|
+
return -1;
|
|
169
|
+
return new Date(bLastAct).getTime() - new Date(aLastAct).getTime();
|
|
170
|
+
});
|
|
171
|
+
// Save to YAML
|
|
172
|
+
const conversationsFile = path.join(customerDir, 'conversations.yaml');
|
|
173
|
+
const yamlContent = yaml.dump({ personas: processedPersonas }, { lineWidth: -1 });
|
|
174
|
+
await fs.writeFile(conversationsFile, yamlContent);
|
|
175
|
+
hashes[conversationsFile] = sha256(yamlContent);
|
|
176
|
+
// Save hashes
|
|
177
|
+
const existingHashes = await loadHashes(customer.idn);
|
|
178
|
+
await saveHashes({ ...existingHashes, ...hashes }, customer.idn);
|
|
179
|
+
this.logger.info(`✅ Saved ${processedPersonas.length} personas with ${totalActs} conversation acts`);
|
|
180
|
+
return {
|
|
181
|
+
items: [{
|
|
182
|
+
personas: processedPersonas,
|
|
183
|
+
totalActs
|
|
184
|
+
}],
|
|
185
|
+
count: 1,
|
|
186
|
+
hashes
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Push is not supported for conversations (read-only)
|
|
191
|
+
*/
|
|
192
|
+
async push(_customer, _changes) {
|
|
193
|
+
this.logger.warn('Conversations are read-only and cannot be pushed');
|
|
194
|
+
return { created: 0, updated: 0, deleted: 0, errors: ['Conversations are read-only'] };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get changes - conversations are typically regenerated on each pull
|
|
198
|
+
*/
|
|
199
|
+
async getChanges(_customer) {
|
|
200
|
+
// Conversations don't support change detection in the traditional sense
|
|
201
|
+
// They are regenerated on each pull
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Validate conversation data
|
|
206
|
+
*/
|
|
207
|
+
async validate(_customer, _items) {
|
|
208
|
+
// Conversations are read-only, no validation needed
|
|
209
|
+
return { valid: true, errors: [] };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Get status summary
|
|
213
|
+
*/
|
|
214
|
+
async getStatus(customer) {
|
|
215
|
+
const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
|
|
216
|
+
const conversationsFile = path.join(customerDir, 'conversations.yaml');
|
|
217
|
+
const exists = await fs.pathExists(conversationsFile);
|
|
218
|
+
return {
|
|
219
|
+
resourceType: this.resourceType,
|
|
220
|
+
displayName: this.displayName,
|
|
221
|
+
changedCount: 0,
|
|
222
|
+
changes: exists ? [] : [{ path: conversationsFile, operation: 'created' }]
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Factory function for creating ConversationSyncStrategy
|
|
228
|
+
*/
|
|
229
|
+
export function createConversationSyncStrategy(apiClientFactory, logger) {
|
|
230
|
+
return new ConversationSyncStrategy(apiClientFactory, logger);
|
|
231
|
+
}
|
|
232
|
+
//# sourceMappingURL=ConversationSyncStrategy.js.map
|