openxiangda 1.0.21 → 1.0.22

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/lib/cli.js CHANGED
@@ -1952,6 +1952,9 @@ function ensureResourceBuckets(bound) {
1952
1952
  bound.resources.menus = bound.resources.menus || {};
1953
1953
  bound.resources.roles = bound.resources.roles || {};
1954
1954
  bound.resources.connectors = bound.resources.connectors || {};
1955
+ bound.resources.notifications = bound.resources.notifications || {};
1956
+ bound.resources.notifications.templates = bound.resources.notifications.templates || {};
1957
+ bound.resources.notifications.typeConfigs = bound.resources.notifications.typeConfigs || {};
1955
1958
  bound.resources.pagePermissionGroups = bound.resources.pagePermissionGroups || {};
1956
1959
  bound.resources.formPermissionGroups = bound.resources.formPermissionGroups || {};
1957
1960
  bound.resources.formSettings = bound.resources.formSettings || {};
@@ -2156,6 +2159,46 @@ function saveConnectorResource(target, connectorCode, connectorId, extra = {}) {
2156
2159
  }, ['connectorId', 'apis']);
2157
2160
  }
2158
2161
 
2162
+ function saveNotificationTemplateResource(target, templateCode, templateId, extra = {}) {
2163
+ const nextBound = prepareStateResourceWrite(target);
2164
+ nextBound.resources.notifications.templates[templateCode] = {
2165
+ templateId,
2166
+ ...pickStateFields(extra, ['level', 'formUuid']),
2167
+ updatedAt: new Date().toISOString(),
2168
+ };
2169
+ saveProjectState(target.state);
2170
+ }
2171
+
2172
+ function saveNotificationTypeConfigResource(target, configCode, configId, extra = {}) {
2173
+ const nextBound = prepareStateResourceWrite(target);
2174
+ nextBound.resources.notifications.typeConfigs[configCode] = {
2175
+ configId,
2176
+ ...pickStateFields(extra, [
2177
+ 'notificationType',
2178
+ 'level',
2179
+ 'formUuid',
2180
+ 'templateId',
2181
+ 'templateCode',
2182
+ ]),
2183
+ updatedAt: new Date().toISOString(),
2184
+ };
2185
+ saveProjectState(target.state);
2186
+ }
2187
+
2188
+ function prepareStateResourceWrite(target) {
2189
+ target.state.profiles = target.state.profiles || {};
2190
+ target.state.profiles[target.profileName] = {
2191
+ ...target.bound,
2192
+ baseUrl: target.profile.baseUrl,
2193
+ appType: target.appType,
2194
+ updatedAt: new Date().toISOString(),
2195
+ };
2196
+ const nextBound = target.state.profiles[target.profileName];
2197
+ ensureResourceBuckets(nextBound);
2198
+ target.bound = nextBound;
2199
+ return nextBound;
2200
+ }
2201
+
2159
2202
  function savePagePermissionGroupResource(target, groupCode, groupId) {
2160
2203
  saveStateResource(target, 'pagePermissionGroups', groupCode, { groupId }, ['groupId']);
2161
2204
  }
@@ -2202,6 +2245,7 @@ function pickStateFields(value, keys) {
2202
2245
  const RESOURCE_SPECS = [
2203
2246
  { key: 'roles', dir: 'roles', topFiles: ['roles.json'], pluralKeys: ['roles'] },
2204
2247
  { key: 'connectors', dir: 'connectors', topFiles: ['connectors.json'], pluralKeys: ['connectors'] },
2248
+ { key: 'notifications', dir: 'notifications', topFiles: ['notifications.json'], pluralKeys: ['notifications'] },
2205
2249
  { key: 'menus', dir: 'menus', topFiles: ['menus.json'], pluralKeys: ['menus'] },
2206
2250
  { key: 'workflows', dir: 'workflows', topFiles: ['workflows.json'], pluralKeys: ['workflows'] },
2207
2251
  { key: 'automations', dir: 'automations', topFiles: ['automations.json'], pluralKeys: ['automations'] },
@@ -2280,6 +2324,7 @@ function readResourceItemsFromFile(filePath, spec) {
2280
2324
  item.code ||
2281
2325
  item.resourceCode ||
2282
2326
  item.methodName ||
2327
+ (spec.key === 'notifications' ? inferNotificationResourceCode(item) : undefined) ||
2283
2328
  item.formCode ||
2284
2329
  (values.length === 1 ? defaultCode : undefined);
2285
2330
  return {
@@ -2292,7 +2337,20 @@ function readResourceItemsFromFile(filePath, spec) {
2292
2337
  });
2293
2338
  }
2294
2339
 
2340
+ function inferNotificationResourceCode(item) {
2341
+ const resourceType = normalizeNotificationResourceType(item);
2342
+ if (resourceType === 'template') return item.code || item.templateCode;
2343
+ if (resourceType !== 'typeConfig' || !item.notificationType) {
2344
+ return item.notificationType || item.templateCode;
2345
+ }
2346
+ const level = item.level || (item.formCode || item.formUuid ? 'form' : 'app');
2347
+ if (level === 'app') return item.notificationType;
2348
+ const formKey = item.formCode || item.formUuid || 'form';
2349
+ return `${level}:${formKey}:${item.notificationType}`;
2350
+ }
2351
+
2295
2352
  function extractResourceValues(value, spec) {
2353
+ if (spec.key === 'notifications') return extractNotificationResourceValues(value);
2296
2354
  if (Array.isArray(value)) return value;
2297
2355
  if (!value || typeof value !== 'object') return [value];
2298
2356
  for (const key of spec.pluralKeys || []) {
@@ -2302,6 +2360,29 @@ function extractResourceValues(value, spec) {
2302
2360
  return [value];
2303
2361
  }
2304
2362
 
2363
+ function extractNotificationResourceValues(value) {
2364
+ if (Array.isArray(value)) return value;
2365
+ if (!value || typeof value !== 'object') return [value];
2366
+ const result = [];
2367
+ for (const template of value.templates || []) {
2368
+ result.push({ ...template, resourceType: 'template' });
2369
+ }
2370
+ for (const config of value.typeConfigs || value.notificationTypeConfigs || []) {
2371
+ result.push({ ...config, resourceType: 'typeConfig' });
2372
+ }
2373
+ if (Array.isArray(value.notifications)) {
2374
+ result.push(...value.notifications);
2375
+ }
2376
+ if (result.length > 0) return result;
2377
+ if (value.template || value.typeConfig) {
2378
+ return [
2379
+ value.template ? { ...value.template, resourceType: 'template' } : null,
2380
+ value.typeConfig ? { ...value.typeConfig, resourceType: 'typeConfig' } : null,
2381
+ ].filter(Boolean);
2382
+ }
2383
+ return [value];
2384
+ }
2385
+
2305
2386
  function listJsonFiles(dirPath) {
2306
2387
  if (!fs.existsSync(dirPath)) return [];
2307
2388
  return fs
@@ -2325,6 +2406,7 @@ function validateWorkspaceResources(manifest) {
2325
2406
  validateResourceItem(spec.key, item, errors, warnings);
2326
2407
  }
2327
2408
  }
2409
+ validateNotificationReferences(manifest.notifications || [], errors);
2328
2410
  return {
2329
2411
  valid: errors.length === 0,
2330
2412
  errors,
@@ -2357,6 +2439,11 @@ function validateResourceItem(kind, item, errors, warnings) {
2357
2439
  return;
2358
2440
  }
2359
2441
 
2442
+ if (kind === 'notifications') {
2443
+ validateNotificationResourceItem(item, errors, warnings);
2444
+ return;
2445
+ }
2446
+
2360
2447
  if (kind === 'roles' && !item.name) errors.push(`${label}: 缺少 name`);
2361
2448
  if (kind === 'menus' && !item.name) errors.push(`${label}: 缺少 name`);
2362
2449
  if (kind === 'workflows') {
@@ -2392,6 +2479,99 @@ function validateResourceItem(kind, item, errors, warnings) {
2392
2479
  }
2393
2480
  }
2394
2481
 
2482
+ const NOTIFICATION_CHANNELS = new Set([
2483
+ 'inapp',
2484
+ 'email',
2485
+ 'dingding',
2486
+ 'wechat',
2487
+ 'thirdparty_todo',
2488
+ ]);
2489
+
2490
+ function validateNotificationResourceItem(item, errors, warnings) {
2491
+ const label = resourceLabel('notifications', item);
2492
+ const resourceType = normalizeNotificationResourceType(item);
2493
+ if (!resourceType) {
2494
+ errors.push(`${label}: resourceType 必须是 template 或 typeConfig`);
2495
+ return;
2496
+ }
2497
+
2498
+ if (resourceType === 'template') {
2499
+ if (!item.code) errors.push(`${label}: 通知模板缺少 code`);
2500
+ if (!item.name) errors.push(`${label}: 通知模板缺少 name`);
2501
+ if (item.level && !['app', 'form'].includes(item.level)) {
2502
+ errors.push(`${label}: OpenXiangda 通知模板 level 只能是 app 或 form`);
2503
+ }
2504
+ if ((item.level === 'form' || item.formCode || item.formUuid) && !item.formCode && !item.formUuid) {
2505
+ errors.push(`${label}: 表单级通知模板缺少 formCode 或 formUuid`);
2506
+ }
2507
+ if (!item.content && !item.channelsConfig) {
2508
+ warnings.push(`${label}: 未声明 content 或 channelsConfig`);
2509
+ }
2510
+ validateNotificationChannels(label, item.channelsConfig, errors);
2511
+ return;
2512
+ }
2513
+
2514
+ if (!item.notificationType) errors.push(`${label}: 通知类型配置缺少 notificationType`);
2515
+ if (!item.templateCode && !item.templateId) {
2516
+ errors.push(`${label}: 通知类型配置缺少 templateCode 或 templateId`);
2517
+ }
2518
+ if (item.level && !['app', 'form'].includes(item.level)) {
2519
+ errors.push(`${label}: OpenXiangda 通知类型配置 level 只能是 app 或 form`);
2520
+ }
2521
+ if ((item.level === 'form' || item.formCode || item.formUuid) && !item.formCode && !item.formUuid) {
2522
+ errors.push(`${label}: 表单级通知类型配置缺少 formCode 或 formUuid`);
2523
+ }
2524
+ }
2525
+
2526
+ function normalizeNotificationResourceType(item) {
2527
+ const value = item.resourceType || item.kind || item.type;
2528
+ if (value === 'template' || value === 'notificationTemplate') return 'template';
2529
+ if (value === 'typeConfig' || value === 'notificationTypeConfig') return 'typeConfig';
2530
+ if (item.notificationType || item.templateCode || item.templateId) return 'typeConfig';
2531
+ if (item.channelsConfig || item.content || item.variables) return 'template';
2532
+ return undefined;
2533
+ }
2534
+
2535
+ function validateNotificationReferences(items, errors) {
2536
+ const templates = new Set(
2537
+ (items || [])
2538
+ .filter(item => normalizeNotificationResourceType(item) === 'template')
2539
+ .map(item => item.code)
2540
+ .filter(Boolean)
2541
+ );
2542
+ for (const item of items || []) {
2543
+ if (normalizeNotificationResourceType(item) !== 'typeConfig') continue;
2544
+ if (item.templateCode && !templates.has(item.templateCode) && !item.templateId) {
2545
+ errors.push(
2546
+ `${resourceLabel('notifications', item)}: templateCode ${item.templateCode} 未在 src/resources/notifications 中声明`
2547
+ );
2548
+ }
2549
+ }
2550
+ }
2551
+
2552
+ function validateNotificationChannels(label, channelsConfig, errors) {
2553
+ if (!channelsConfig || typeof channelsConfig !== 'object' || Array.isArray(channelsConfig)) {
2554
+ return;
2555
+ }
2556
+ for (const [channel, config] of Object.entries(channelsConfig)) {
2557
+ if (!NOTIFICATION_CHANNELS.has(channel)) {
2558
+ errors.push(`${label}: 不支持的通知渠道 ${channel}`);
2559
+ }
2560
+ validateNoSensitiveNotificationConfig(`${label}.channelsConfig.${channel}`, config, errors);
2561
+ }
2562
+ }
2563
+
2564
+ function validateNoSensitiveNotificationConfig(label, value, errors) {
2565
+ if (!value || typeof value !== 'object') return;
2566
+ for (const [key, item] of Object.entries(value)) {
2567
+ if (/token|secret|password|authorization|auth|credential|headers/i.test(key)) {
2568
+ errors.push(`${label}.${key}: 通知资源不允许配置通道密钥或鉴权字段`);
2569
+ continue;
2570
+ }
2571
+ validateNoSensitiveNotificationConfig(`${label}.${key}`, item, errors);
2572
+ }
2573
+ }
2574
+
2395
2575
  function resourceLabel(kind, item) {
2396
2576
  return `${kind}:${item.code || item.__source || item.__index || '?'}`;
2397
2577
  }
@@ -2412,6 +2592,7 @@ async function buildResourcePlan(config, target, manifest) {
2412
2592
  addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
2413
2593
  addPlanActions(actions, 'menu', manifest.menus, existing.menus, (item, current) => menuEquals(target.bound, item, current));
2414
2594
  addPlanActions(actions, 'connector', manifest.connectors, existing.connectors, connectorEquals);
2595
+ addNotificationPlanActions(target, actions, manifest.notifications || [], existing);
2415
2596
  await addWorkflowPlanActions(config, target, actions, manifest.workflows, existing.workflows);
2416
2597
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
2417
2598
  addPlanActions(actions, 'pagePermissionGroup', manifest.pagePermissionGroups, existing.pagePermissionGroups, (item, current) => pagePermissionGroupEquals(target.bound, item, current));
@@ -2444,6 +2625,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
2444
2625
  await publishFormSettingsResources(config, target, manifest.formSettings || [], result);
2445
2626
  await publishMenuResources(config, target, manifest.menus || [], result);
2446
2627
  await publishConnectorResources(config, target, manifest.connectors || [], result);
2628
+ await publishNotificationResources(config, target, manifest.notifications || [], result);
2447
2629
  await publishWorkflowResources(config, target, manifest.workflows || [], result);
2448
2630
  await publishAutomationResources(config, target, manifest.automations || [], result);
2449
2631
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result);
@@ -2463,6 +2645,8 @@ async function fetchExistingResourceMaps(config, target, manifest) {
2463
2645
  roles: new Map(),
2464
2646
  menus: new Map(),
2465
2647
  connectors: new Map(),
2648
+ notificationTemplates: new Map(),
2649
+ notificationTypeConfigs: new Map(),
2466
2650
  workflows: new Map(),
2467
2651
  automations: new Map(),
2468
2652
  pagePermissionGroups: new Map(),
@@ -2496,6 +2680,31 @@ async function fetchExistingResourceMaps(config, target, manifest) {
2496
2680
  );
2497
2681
  indexByCode(maps.connectors, normalizeItems(data), item => item.code || item.methodName);
2498
2682
  }
2683
+ if ((manifest.notifications || []).length > 0) {
2684
+ const templates = await requestWithAuth(
2685
+ config,
2686
+ target.profileName,
2687
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/templates`, {
2688
+ page: 1,
2689
+ limit: 1000,
2690
+ })
2691
+ );
2692
+ indexByCode(maps.notificationTemplates, normalizeItems(templates), item => item.code);
2693
+
2694
+ const typeConfigs = await requestWithAuth(
2695
+ config,
2696
+ target.profileName,
2697
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/type-configs`, {
2698
+ page: 1,
2699
+ limit: 1000,
2700
+ })
2701
+ );
2702
+ indexByCode(
2703
+ maps.notificationTypeConfigs,
2704
+ normalizeItems(typeConfigs),
2705
+ item => notificationTypeConfigExistingKey(item)
2706
+ );
2707
+ }
2499
2708
  if ((manifest.workflows || []).length > 0) {
2500
2709
  const data = await requestWithAuth(
2501
2710
  config,
@@ -2558,6 +2767,37 @@ function addPlanActions(actions, kind, desiredItems = [], existingMap, equals) {
2558
2767
  }
2559
2768
  }
2560
2769
 
2770
+ function addNotificationPlanActions(target, actions, notificationItems = [], existing) {
2771
+ const { templates, typeConfigs } = splitNotificationResources(notificationItems);
2772
+ for (const template of templates) {
2773
+ const existingTemplate = existing.notificationTemplates.get(template.code);
2774
+ actions.push({
2775
+ kind: 'notificationTemplate',
2776
+ code: template.code,
2777
+ action: existingTemplate
2778
+ ? notificationTemplateEquals(target.bound, template, existingTemplate)
2779
+ ? 'noop'
2780
+ : 'update'
2781
+ : 'create',
2782
+ platformId: existingTemplate?.id,
2783
+ });
2784
+ }
2785
+ for (const config of typeConfigs) {
2786
+ const key = notificationTypeConfigDesiredKey(target.bound, config);
2787
+ const existingConfig = existing.notificationTypeConfigs.get(key);
2788
+ actions.push({
2789
+ kind: 'notificationTypeConfig',
2790
+ code: config.code || config.notificationType,
2791
+ action: existingConfig
2792
+ ? notificationTypeConfigEquals(target.bound, config, existingConfig)
2793
+ ? 'noop'
2794
+ : 'update'
2795
+ : 'create',
2796
+ platformId: existingConfig?.id,
2797
+ });
2798
+ }
2799
+ }
2800
+
2561
2801
  async function addWorkflowPlanActions(config, target, actions, desiredItems = [], existingMap) {
2562
2802
  for (const item of desiredItems) {
2563
2803
  const code = item.code || item.resourceCode;
@@ -2842,6 +3082,57 @@ async function publishConnectorResources(config, target, connectors, result) {
2842
3082
  }
2843
3083
  }
2844
3084
 
3085
+ async function publishNotificationResources(config, target, notifications, result) {
3086
+ const { templates, typeConfigs } = splitNotificationResources(notifications);
3087
+ for (const template of templates) {
3088
+ const body = normalizeNotificationTemplateManifest(target.bound, template);
3089
+ const data = await requestWithAuth(
3090
+ config,
3091
+ target.profileName,
3092
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/templates/${encodeURIComponent(template.code)}`,
3093
+ { method: 'PUT', body }
3094
+ );
3095
+ if (data?.id) {
3096
+ saveNotificationTemplateResource(target, template.code, data.id, {
3097
+ level: data.level || body.level,
3098
+ formUuid: data.formUuid || body.formUuid,
3099
+ });
3100
+ }
3101
+ result.published.push({
3102
+ kind: 'notificationTemplate',
3103
+ code: template.code,
3104
+ action: data?.created ? 'create' : 'update',
3105
+ id: data?.id,
3106
+ });
3107
+ }
3108
+
3109
+ for (const configItem of typeConfigs) {
3110
+ const body = normalizeNotificationTypeConfigManifest(target.bound, configItem);
3111
+ const data = await requestWithAuth(
3112
+ config,
3113
+ target.profileName,
3114
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/type-configs/${encodeURIComponent(configItem.notificationType)}`,
3115
+ { method: 'PUT', body }
3116
+ );
3117
+ const stateCode = configItem.code || notificationTypeConfigDesiredKey(target.bound, configItem);
3118
+ if (data?.id) {
3119
+ saveNotificationTypeConfigResource(target, stateCode, data.id, {
3120
+ notificationType: data.notificationType || configItem.notificationType,
3121
+ level: data.level || body.level,
3122
+ formUuid: data.formUuid || body.formUuid,
3123
+ templateId: data.templateId,
3124
+ templateCode: data.template?.code || configItem.templateCode,
3125
+ });
3126
+ }
3127
+ result.published.push({
3128
+ kind: 'notificationTypeConfig',
3129
+ code: configItem.code || configItem.notificationType,
3130
+ action: data?.created ? 'create' : 'update',
3131
+ id: data?.id,
3132
+ });
3133
+ }
3134
+ }
3135
+
2845
3136
  async function publishWorkflowResources(config, target, workflows, result) {
2846
3137
  for (const workflowItem of workflows) {
2847
3138
  const existing = await findExistingWorkflow(config, target, workflowItem.code);
@@ -3379,6 +3670,40 @@ async function pullResources(config, target) {
3379
3670
  written.push(path.relative(process.cwd(), filePath));
3380
3671
  }
3381
3672
 
3673
+ const notificationTemplates = await requestWithAuth(
3674
+ config,
3675
+ target.profileName,
3676
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/templates`, {
3677
+ page: 1,
3678
+ limit: 1000,
3679
+ })
3680
+ );
3681
+ for (const template of normalizeItems(notificationTemplates)) {
3682
+ const code = template.code || template.id;
3683
+ const filePath = path.join(baseDir, 'notifications', 'templates', `${code}.json`);
3684
+ writeResourceJsonFile(filePath, {
3685
+ templates: [toPulledNotificationTemplate(template, pullLookups)],
3686
+ });
3687
+ written.push(path.relative(process.cwd(), filePath));
3688
+ }
3689
+
3690
+ const notificationTypeConfigs = await requestWithAuth(
3691
+ config,
3692
+ target.profileName,
3693
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/type-configs`, {
3694
+ page: 1,
3695
+ limit: 1000,
3696
+ })
3697
+ );
3698
+ for (const configItem of normalizeItems(notificationTypeConfigs)) {
3699
+ const code = notificationTypeConfigExistingKey(configItem).replace(/[^a-zA-Z0-9_.-]+/g, '_');
3700
+ const filePath = path.join(baseDir, 'notifications', 'type-configs', `${code}.json`);
3701
+ writeResourceJsonFile(filePath, {
3702
+ typeConfigs: [toPulledNotificationTypeConfig(configItem, pullLookups)],
3703
+ });
3704
+ written.push(path.relative(process.cwd(), filePath));
3705
+ }
3706
+
3382
3707
  const menus = await requestWithAuth(
3383
3708
  config,
3384
3709
  target.profileName,
@@ -3598,6 +3923,47 @@ function splitPagePermissionTargetsForManifest(values = [], lookups) {
3598
3923
  };
3599
3924
  }
3600
3925
 
3926
+ function toPulledNotificationTemplate(template, lookups) {
3927
+ const formCode = lookups.formCodeByUuid.get(template.formUuid);
3928
+ return stripUndefinedValues({
3929
+ code: template.code,
3930
+ name: template.name,
3931
+ content: template.content || '',
3932
+ description: template.description || '',
3933
+ level: template.level === 'form' ? 'form' : 'app',
3934
+ ...(formCode ? { formCode } : template.formUuid ? { formUuid: template.formUuid } : {}),
3935
+ priority: template.priority,
3936
+ enabled: template.enabled,
3937
+ variables: template.variables || undefined,
3938
+ channelsConfig: stripNotificationSecrets(template.channelsConfig),
3939
+ });
3940
+ }
3941
+
3942
+ function toPulledNotificationTypeConfig(configItem, lookups) {
3943
+ const formCode = lookups.formCodeByUuid.get(configItem.formUuid);
3944
+ return stripUndefinedValues({
3945
+ code: notificationTypeConfigExistingKey(configItem),
3946
+ notificationType: configItem.notificationType,
3947
+ level: configItem.level === 'form' ? 'form' : 'app',
3948
+ ...(formCode ? { formCode } : configItem.formUuid ? { formUuid: configItem.formUuid } : {}),
3949
+ templateCode: configItem.template?.code,
3950
+ enabled: configItem.enabled,
3951
+ priority: configItem.priority,
3952
+ description: configItem.description || '',
3953
+ });
3954
+ }
3955
+
3956
+ function stripNotificationSecrets(value) {
3957
+ if (!value || typeof value !== 'object') return value;
3958
+ if (Array.isArray(value)) return value.map(stripNotificationSecrets);
3959
+ const next = {};
3960
+ for (const [key, item] of Object.entries(value)) {
3961
+ if (/token|secret|password|authorization|auth|credential|headers/i.test(key)) continue;
3962
+ next[key] = stripNotificationSecrets(item);
3963
+ }
3964
+ return next;
3965
+ }
3966
+
3601
3967
  function writeResourceJsonFile(filePath, value) {
3602
3968
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
3603
3969
  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
@@ -3671,6 +4037,58 @@ function parseConnectorUrl(connector) {
3671
4037
  }
3672
4038
  }
3673
4039
 
4040
+ function splitNotificationResources(items = []) {
4041
+ const templates = [];
4042
+ const typeConfigs = [];
4043
+ for (const item of items || []) {
4044
+ const resourceType = normalizeNotificationResourceType(item);
4045
+ if (resourceType === 'template') templates.push(item);
4046
+ if (resourceType === 'typeConfig') typeConfigs.push(item);
4047
+ }
4048
+ return { templates, typeConfigs };
4049
+ }
4050
+
4051
+ function normalizeNotificationTemplateManifest(bound, template) {
4052
+ const level = template.level || (template.formCode || template.formUuid ? 'form' : 'app');
4053
+ const formUuid = level === 'form' ? resolveManifestFormUuid(bound, template) : undefined;
4054
+ return stripUndefinedValues({
4055
+ name: template.name || template.code,
4056
+ content: template.content || '',
4057
+ description: template.description || '',
4058
+ channelsConfig: template.channelsConfig,
4059
+ level,
4060
+ formUuid,
4061
+ priority: template.priority,
4062
+ enabled: template.enabled,
4063
+ variables: template.variables,
4064
+ });
4065
+ }
4066
+
4067
+ function normalizeNotificationTypeConfigManifest(bound, config) {
4068
+ const level = config.level || (config.formCode || config.formUuid ? 'form' : 'app');
4069
+ const formUuid = level === 'form' ? resolveManifestFormUuid(bound, config) : undefined;
4070
+ return stripUndefinedValues({
4071
+ level,
4072
+ formUuid,
4073
+ templateId: config.templateId,
4074
+ templateCode: config.templateCode,
4075
+ enabled: config.enabled,
4076
+ priority: config.priority,
4077
+ description: config.description || '',
4078
+ });
4079
+ }
4080
+
4081
+ function notificationTypeConfigDesiredKey(bound, config) {
4082
+ const level = config.level || (config.formCode || config.formUuid ? 'form' : 'app');
4083
+ const formUuid = level === 'form' ? resolveManifestFormUuid(bound, config) : '';
4084
+ return `${level}:${formUuid || ''}:${config.notificationType}`;
4085
+ }
4086
+
4087
+ function notificationTypeConfigExistingKey(config) {
4088
+ const level = config.level || 'app';
4089
+ return `${level}:${config.formUuid || ''}:${config.notificationType}`;
4090
+ }
4091
+
3674
4092
  async function resolveManifestJson(config, profileName, item, objectKey, fileKey, optional = false) {
3675
4093
  let value = item[objectKey];
3676
4094
  if (value === undefined && item[fileKey]) {
@@ -3778,6 +4196,40 @@ function connectorEquals(desired, existing) {
3778
4196
  return JSON.stringify(normalizedExisting) === JSON.stringify(normalizedDesired);
3779
4197
  }
3780
4198
 
4199
+ function notificationTemplateEquals(bound, desired, existing) {
4200
+ if (!existing) return false;
4201
+ const expected = normalizeNotificationTemplateManifest(bound, desired);
4202
+ return (
4203
+ String(existing.code || '') === String(desired.code || '') &&
4204
+ String(existing.name || '') === String(expected.name || '') &&
4205
+ String(existing.content || '') === String(expected.content || '') &&
4206
+ String(existing.description || '') === String(expected.description || '') &&
4207
+ String(existing.level || 'app') === String(expected.level || 'app') &&
4208
+ String(existing.formUuid || '') === String(expected.formUuid || '') &&
4209
+ optionalScalarEquals(existing.priority, expected.priority, expected.priority !== undefined) &&
4210
+ optionalScalarEquals(Boolean(existing.enabled), Boolean(expected.enabled), expected.enabled !== undefined) &&
4211
+ jsonEqualsForPlan(existing.variables || [], expected.variables || [], desired.__dir) &&
4212
+ jsonEqualsForPlan(existing.channelsConfig || {}, expected.channelsConfig || {}, desired.__dir)
4213
+ );
4214
+ }
4215
+
4216
+ function notificationTypeConfigEquals(bound, desired, existing) {
4217
+ if (!existing) return false;
4218
+ const expected = normalizeNotificationTypeConfigManifest(bound, desired);
4219
+ const existingTemplateCode = existing.template?.code;
4220
+ return (
4221
+ String(existing.notificationType || '') === String(desired.notificationType || '') &&
4222
+ String(existing.level || 'app') === String(expected.level || 'app') &&
4223
+ String(existing.formUuid || '') === String(expected.formUuid || '') &&
4224
+ (expected.templateId
4225
+ ? String(existing.templateId || '') === String(expected.templateId)
4226
+ : String(existingTemplateCode || '') === String(expected.templateCode || '')) &&
4227
+ optionalScalarEquals(Boolean(existing.enabled), Boolean(expected.enabled), expected.enabled !== undefined) &&
4228
+ optionalScalarEquals(existing.priority, expected.priority, expected.priority !== undefined) &&
4229
+ String(existing.description || '') === String(expected.description || '')
4230
+ );
4231
+ }
4232
+
3781
4233
  function pagePermissionGroupEquals(bound, desired, existing) {
3782
4234
  if (!existing) return false;
3783
4235
  const desiredTargets = resolvePagePermissionGroupTargets(bound, desired);
@@ -105,6 +105,7 @@ Core CLI / state:
105
105
  - `references/openxiangda-api.md` — `/openxiangda-api/v1` request and response fields.
106
106
  - `references/workspace-state.md` — `.openxiangda/state.json` shape and profile isolation rules.
107
107
  - `references/connector-resources.md` — `src/resources` manifests, connector schema, and SDK connector calls.
108
+ - `references/notifications.md` — `src/resources/notifications`, notification templates/type bindings, and `sdk.notification` / `ctx.notification`.
108
109
 
109
110
  Form authoring:
110
111
 
@@ -123,6 +123,8 @@ The backend runs the snapshot in the trusted Node runtime, applies the node time
123
123
 
124
124
  Runtime context includes `ctx.triggerEvent`, `ctx.formData`, `ctx.workflowData`, `ctx.operator`, `ctx.app`, `ctx.variables`, and `ctx.node`. Data/process bridge methods include `ctx.methods.queryOneData`, `queryManyData`, `updateOneData`, `updateDataByFormInstanceId`, `updateManyData`, `createOneData`, `terminateProcess`, and `getAllParentDepartments`.
125
125
 
126
+ Notification bridge methods include `ctx.notification.sendByType`, `batchSendByType`, `findConfig`, and `previewTemplate`. For custom business messages, create `src/resources/notifications/` first and use its `notificationType`; do not call legacy `/api/notification-config/*` endpoints directly.
127
+
126
128
  Example `src/js-code-nodes/scheduled_reconcile/index.ts`:
127
129
 
128
130
  ```ts
@@ -5,6 +5,7 @@ OpenXiangda engineering resources live under `src/resources/`.
5
5
  Common folders:
6
6
 
7
7
  - `connectors`
8
+ - `notifications`
8
9
  - `roles`
9
10
  - `menus`
10
11
  - `workflows`
@@ -24,6 +25,8 @@ openxiangda resource pull --profile dev
24
25
 
25
26
  Connector manifests use stable `code` values. The platform maps connector `code` to the existing connector `methodName`, and API `code` to the existing connector API `methodName`.
26
27
 
28
+ Notification manifests live under `src/resources/notifications/` and contain `templates` plus `typeConfigs`. See `notifications.md` before generating reminders or message templates.
29
+
27
30
  ```json
28
31
  {
29
32
  "code": "crm",