@steedos-labs/plugin-workflow 3.0.21 → 3.0.23

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.
@@ -0,0 +1,536 @@
1
+ /*
2
+ * @Author: Copilot
3
+ * @Date: 2026-01-28
4
+ * @Description: Transform old approval comments field configurations to new format
5
+ *
6
+ * This endpoint scans forms collection and transforms fields with approval comments formulas
7
+ * from old format to new steedos-field format with config object.
8
+ */
9
+
10
+ const { MongoClient } = require('mongodb');
11
+
12
+ // MongoDB connection
13
+ let client = null;
14
+ let db = null;
15
+
16
+ async function connectToMongoDB() {
17
+ if (db) {
18
+ return db;
19
+ }
20
+
21
+ const mongoUrl = process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/steedos';
22
+ try {
23
+ client = new MongoClient(mongoUrl);
24
+ await client.connect();
25
+
26
+ const dbName = mongoUrl.split('/').pop().split('?')[0];
27
+ db = client.db(dbName);
28
+ return db;
29
+ } catch (error) {
30
+ console.error('Failed to connect to MongoDB:', error.message);
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ async function getCollection(name) {
36
+ const database = await connectToMongoDB();
37
+ return database.collection(name);
38
+ }
39
+
40
+ /**
41
+ * Check if a field value matches approval comments patterns
42
+ */
43
+ function matchesApprovalPattern(value) {
44
+ if (!value || typeof value !== 'string') {
45
+ return false;
46
+ }
47
+
48
+ const patterns = [
49
+ /\{traces\./,
50
+ /\$\{traces\./,
51
+ /\{signature\.traces\./,
52
+ /\$\{signature\.traces\./,
53
+ /\{yijianlan:/
54
+ ];
55
+
56
+ return patterns.some(pattern => pattern.test(value));
57
+ }
58
+
59
+ /**
60
+ * Parse yijianlan object string (non-standard JSON with unquoted keys)
61
+ * Example: {yijianlan:{step:'部门经理审核',default:'已阅'}}
62
+ */
63
+ function parseYijianlan(str) {
64
+ try {
65
+ // Extract yijianlan content
66
+ const match = str.match(/\{yijianlan:\s*(\{[^}]+\})\s*\}/);
67
+ if (!match) {
68
+ return null;
69
+ }
70
+
71
+ const objStr = match[1];
72
+ // Convert to valid JSON by adding quotes to unquoted keys and converting single quotes to double quotes
73
+ // Note: This assumes the yijianlan content is in the expected format
74
+ const jsonStr = objStr
75
+ .replace(/(\w+):/g, '"$1":') // Add quotes to keys
76
+ .replace(/'/g, '"'); // Convert single quotes to double quotes
77
+ return JSON.parse(jsonStr);
78
+ } catch (error) {
79
+ // Log more detailed error for troubleshooting
80
+ console.error('Error parsing yijianlan:', error.message, 'Input:', str);
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Extract step name from formula/default_value
87
+ */
88
+ function extractStepName(value) {
89
+ // Handle yijianlan format
90
+ if (value.includes('yijianlan:')) {
91
+ const yijianlan = parseYijianlan(value);
92
+ if (yijianlan && yijianlan.step) {
93
+ return yijianlan.step;
94
+ }
95
+ }
96
+
97
+ // Handle ${signature.traces.XXX} or {signature.traces.XXX}
98
+ let match = value.match(/\$?\{signature\.traces\.([^}]+)\}/);
99
+ if (match) {
100
+ return match[1];
101
+ }
102
+
103
+ // Handle ${traces.XXX} or {traces.XXX}
104
+ match = value.match(/\$?\{traces\.([^}]+)\}/);
105
+ if (match) {
106
+ return match[1];
107
+ }
108
+
109
+ return '';
110
+ }
111
+
112
+ /**
113
+ * Parse special boolean logic for show_cc and show_handler
114
+ */
115
+ function parseShowOptions(yijianlan) {
116
+ if (!yijianlan) {
117
+ return { show_cc: true, show_handler: true };
118
+ }
119
+
120
+ if (yijianlan.only_cc === true || yijianlan.only_cc === 'true') {
121
+ return { show_cc: true, show_handler: false };
122
+ }
123
+
124
+ if (yijianlan.only_handler === true || yijianlan.only_handler === 'true') {
125
+ return { show_cc: false, show_handler: true };
126
+ }
127
+
128
+ return { show_cc: true, show_handler: true };
129
+ }
130
+
131
+ /**
132
+ * Transform a single field
133
+ */
134
+ function transformField(field) {
135
+ // Check formula and default_value only
136
+ const formulaValue = field.formula || field.default_value;
137
+
138
+ if (!formulaValue || !matchesApprovalPattern(formulaValue)) {
139
+ return null;
140
+ }
141
+
142
+ // Already transformed
143
+ if (field.__approval_comments_transformed) {
144
+ return null;
145
+ }
146
+
147
+ const stepName = extractStepName(formulaValue);
148
+ if (!stepName) {
149
+ return null;
150
+ }
151
+
152
+ // Parse yijianlan for additional config
153
+ const yijianlan = formulaValue.includes('yijianlan:') ? parseYijianlan(formulaValue) : null;
154
+ const showOptions = parseShowOptions(yijianlan);
155
+
156
+ // Check if it's signature.traces (image sign)
157
+ const showImageSign = formulaValue.includes('signature.traces') ||
158
+ (yijianlan && yijianlan.image_sign === true) ||
159
+ false;
160
+
161
+ // Get default value from yijianlan
162
+ const defaultValue = yijianlan && yijianlan.default ? yijianlan.default : undefined;
163
+
164
+ // Get label from field name or code only
165
+ const label = field.name || field.code;
166
+
167
+ // Build the step config
168
+ const step = {
169
+ name: stepName,
170
+ show_cc: showOptions.show_cc,
171
+ show_handler: showOptions.show_handler,
172
+ show_image_sign: showImageSign
173
+ };
174
+
175
+ if (defaultValue !== undefined) {
176
+ step.default = defaultValue;
177
+ }
178
+
179
+ // Build update object
180
+ const update = {
181
+ type: 'steedos-field',
182
+ __approval_comments_transformed: true,
183
+ __approval_comments_original_type: field.type || 'input', // Store original type for zero-loss rollback
184
+ config: {
185
+ type: 'approval_comments',
186
+ label: label,
187
+ object: '',
188
+ name: field.code || field.name,
189
+ steps: [step]
190
+ }
191
+ };
192
+
193
+ // Backup original formula if exists
194
+ if (field.formula) {
195
+ update.__approval_comments_original_formula = field.formula;
196
+ }
197
+
198
+ // Backup original default_value if exists
199
+ if (field.default_value) {
200
+ update.__approval_comments_original_default_value = field.default_value;
201
+ }
202
+
203
+ // Rename _amisField to __approval_comments_original__amisField if it exists
204
+ if (field._amisField) {
205
+ update.__approval_comments_original__amisField = field._amisField;
206
+ }
207
+
208
+ return update;
209
+ }
210
+
211
+ /**
212
+ * Transform a single amis schema field node
213
+ */
214
+ function transformAmisSchemaField(node) {
215
+ // Check if node has a value property that matches approval patterns
216
+ if (!node || !node.value || typeof node.value !== 'string') {
217
+ return null;
218
+ }
219
+
220
+ if (!matchesApprovalPattern(node.value)) {
221
+ return null;
222
+ }
223
+
224
+ const stepName = extractStepName(node.value);
225
+ if (!stepName) {
226
+ return null;
227
+ }
228
+
229
+ // Parse yijianlan for additional config
230
+ const yijianlan = node.value.includes('yijianlan:') ? parseYijianlan(node.value) : null;
231
+ const showOptions = parseShowOptions(yijianlan);
232
+
233
+ // Check if it's signature.traces (image sign)
234
+ const showImageSign = node.value.includes('signature.traces') ||
235
+ (yijianlan && yijianlan.image_sign === true) ||
236
+ false;
237
+
238
+ // Get default value from yijianlan
239
+ const defaultValue = yijianlan && yijianlan.default ? yijianlan.default : undefined;
240
+
241
+ // Build the step config
242
+ const step = {
243
+ name: stepName,
244
+ show_cc: showOptions.show_cc,
245
+ show_handler: showOptions.show_handler,
246
+ show_image_sign: showImageSign
247
+ };
248
+
249
+ if (defaultValue !== undefined) {
250
+ step.default = defaultValue;
251
+ }
252
+
253
+ // Build transformed node - keep all original properties except value
254
+ const transformed = {
255
+ ...node,
256
+ type: 'sfield-approvalcomments',
257
+ __approval_comments_original_type: node.type, // Store original type for zero-loss rollback
258
+ config: {
259
+ type: 'approval_comments',
260
+ label: node.label || node.name,
261
+ object: '',
262
+ name: node.name,
263
+ steps: [step]
264
+ }
265
+ };
266
+
267
+ // Remove value property from transformed node
268
+ delete transformed.value;
269
+
270
+ return transformed;
271
+ }
272
+
273
+ /**
274
+ * Process amis_schema string - parse, transform body nodes, and stringify
275
+ */
276
+ function processAmisSchema(amisSchemaStr) {
277
+ if (!amisSchemaStr || typeof amisSchemaStr !== 'string') {
278
+ return null;
279
+ }
280
+
281
+ try {
282
+ // Parse the JSON string
283
+ const schema = JSON.parse(amisSchemaStr);
284
+
285
+ // Only process if schema has a body array
286
+ if (!schema.body || !Array.isArray(schema.body)) {
287
+ return null;
288
+ }
289
+
290
+ let hasChanges = false;
291
+
292
+ // Process only first-level body nodes
293
+ for (let i = 0; i < schema.body.length; i++) {
294
+ const node = schema.body[i];
295
+ const transformed = transformAmisSchemaField(node);
296
+
297
+ if (transformed) {
298
+ schema.body[i] = transformed;
299
+ hasChanges = true;
300
+ }
301
+ }
302
+
303
+ // Return stringified schema if there were changes
304
+ if (hasChanges) {
305
+ return JSON.stringify(schema);
306
+ }
307
+
308
+ return null;
309
+ } catch (error) {
310
+ console.error('Error processing amis_schema:', error.message, error.stack);
311
+ return null;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Process fields array and return bulk operations
317
+ */
318
+ function processFieldsArray(fields, pathPrefix) {
319
+ const updates = [];
320
+ const unsets = [];
321
+
322
+ if (!Array.isArray(fields)) {
323
+ return { updates, unsets };
324
+ }
325
+
326
+ for (let i = 0; i < fields.length; i++) {
327
+ const field = fields[i];
328
+ const transformation = transformField(field);
329
+
330
+ if (transformation) {
331
+ updates.push({
332
+ index: i,
333
+ path: `${pathPrefix}.${i}`,
334
+ update: transformation,
335
+ fieldId: field._id || field.code || field.name, // Track field for counting
336
+ hasAmisField: !!field._amisField // Track if we need to unset _amisField
337
+ });
338
+
339
+ // Unset original properties that are being backed up
340
+ if (field._amisField) {
341
+ unsets.push(`${pathPrefix}.${i}._amisField`);
342
+ }
343
+ if (field.formula) {
344
+ unsets.push(`${pathPrefix}.${i}.formula`);
345
+ }
346
+ if (field.default_value) {
347
+ unsets.push(`${pathPrefix}.${i}.default_value`);
348
+ }
349
+ }
350
+ }
351
+
352
+ return { updates, unsets };
353
+ }
354
+
355
+ module.exports = {
356
+ rest: {
357
+ method: 'GET',
358
+ fullPath: '/api/workflow/migrateApprovalCommentsField'
359
+ },
360
+ params: {
361
+ batchSize: { type: 'number', optional: true, convert: true },
362
+ dryRun: { type: 'boolean', optional: true, convert: true },
363
+ fid: { type: 'string', optional: true } // Flow ID to process single flow
364
+ },
365
+ async handler(ctx) {
366
+ const { user } = ctx.meta;
367
+
368
+ // Check if user exists and is space admin
369
+ if (!user || user.is_space_admin !== true) {
370
+ throw new Error('只有管理员才能执行此操作');
371
+ }
372
+
373
+ const { batchSize = 500, dryRun = false, fid } = ctx.params;
374
+
375
+ const formsCollection = await getCollection('forms');
376
+
377
+ // Build query filter
378
+ const query = { state: 'enabled' }; // Only process enabled forms
379
+
380
+ // If fid is provided, query flows collection to get form ID
381
+ if (fid) {
382
+ const flowsCollection = await getCollection('flows');
383
+ const flow = await flowsCollection.findOne({ _id: fid });
384
+
385
+ if (!flow) {
386
+ throw new Error(`Flow with ID ${fid} not found`);
387
+ }
388
+
389
+ if (!flow.form) {
390
+ throw new Error(`Flow ${fid} does not have a form associated`);
391
+ }
392
+
393
+ // Add form ID filter
394
+ query._id = flow.form;
395
+ }
396
+
397
+ // Use cursor for memory efficiency
398
+ const cursor = formsCollection.find(query);
399
+
400
+ const results = {
401
+ totalForms: 0,
402
+ totalFields: 0,
403
+ errors: []
404
+ };
405
+
406
+ const bulkOperations = [];
407
+
408
+ // Process forms one by one using cursor
409
+ while (await cursor.hasNext()) {
410
+ const form = await cursor.next();
411
+ try {
412
+ let formUpdated = false;
413
+ const formUpdate = {};
414
+ const formUnset = {};
415
+ let formFieldCount = 0; // Track actual fields transformed in this form
416
+
417
+ // Process current.fields
418
+ if (form.current && Array.isArray(form.current.fields)) {
419
+ const { updates, unsets } = processFieldsArray(form.current.fields, 'current.fields');
420
+
421
+ if (updates.length > 0) {
422
+ formUpdated = true;
423
+ formFieldCount += updates.length; // Count fields, not operations
424
+
425
+ // Build $set updates for current.fields
426
+ for (const update of updates) {
427
+ for (const key in update.update) {
428
+ formUpdate[`current.fields.${update.index}.${key}`] = update.update[key];
429
+ }
430
+ }
431
+
432
+ // Build $unset for _amisField
433
+ for (const path of unsets) {
434
+ formUnset[path] = '';
435
+ }
436
+ }
437
+ }
438
+
439
+ // Process historys[].fields
440
+ if (Array.isArray(form.historys)) {
441
+ for (let h = 0; h < form.historys.length; h++) {
442
+ const history = form.historys[h];
443
+ if (Array.isArray(history.fields)) {
444
+ const { updates, unsets } = processFieldsArray(
445
+ history.fields,
446
+ `historys.${h}.fields`
447
+ );
448
+
449
+ if (updates.length > 0) {
450
+ formUpdated = true;
451
+ formFieldCount += updates.length; // Count fields, not operations
452
+
453
+ // Build $set updates for historys.fields
454
+ for (const update of updates) {
455
+ for (const key in update.update) {
456
+ formUpdate[`historys.${h}.fields.${update.index}.${key}`] = update.update[key];
457
+ }
458
+ }
459
+
460
+ // Build $unset for _amisField
461
+ for (const path of unsets) {
462
+ formUnset[path] = '';
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ // Process root amis_schema (not current.amis_schema or historys[*].amis_schema)
470
+ if (form.amis_schema && typeof form.amis_schema === 'string') {
471
+ const transformedSchema = processAmisSchema(form.amis_schema);
472
+
473
+ if (transformedSchema) {
474
+ formUpdated = true;
475
+
476
+ // Backup original amis_schema
477
+ formUpdate.__approval_comments_original__amis_schema = form.amis_schema;
478
+
479
+ // Set transformed amis_schema
480
+ formUpdate.amis_schema = transformedSchema;
481
+ }
482
+ }
483
+
484
+ if (formUpdated) {
485
+ results.totalForms++;
486
+ results.totalFields += formFieldCount; // Add actual field count
487
+
488
+ if (!dryRun) {
489
+ const updateDoc = { $set: formUpdate };
490
+ if (Object.keys(formUnset).length > 0) {
491
+ updateDoc.$unset = formUnset;
492
+ }
493
+
494
+ bulkOperations.push({
495
+ updateOne: {
496
+ filter: { _id: form._id },
497
+ update: updateDoc
498
+ }
499
+ });
500
+
501
+ // Execute bulk write in batches
502
+ if (bulkOperations.length >= batchSize) {
503
+ await formsCollection.bulkWrite(bulkOperations, { ordered: false });
504
+ bulkOperations.length = 0; // Clear array
505
+ }
506
+ }
507
+ }
508
+ } catch (error) {
509
+ console.error(`Error processing form ${form._id}:`, error);
510
+ results.errors.push({
511
+ formId: form._id,
512
+ formName: form.name,
513
+ error: error.message
514
+ });
515
+ }
516
+ }
517
+
518
+ // Execute remaining bulk operations
519
+ if (!dryRun && bulkOperations.length > 0) {
520
+ try {
521
+ await formsCollection.bulkWrite(bulkOperations, { ordered: false });
522
+ } catch (error) {
523
+ console.error('Error executing final bulk write:', error);
524
+ results.errors.push({
525
+ error: 'Final bulk write failed: ' + error.message
526
+ });
527
+ }
528
+ }
529
+
530
+ return {
531
+ ...results,
532
+ dryRun,
533
+ message: dryRun ? 'Dry run completed - no changes made' : 'Transformation completed'
534
+ };
535
+ }
536
+ };