exflower-license-server 1.0.5 → 1.0.7

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.
Files changed (3) hide show
  1. package/admin/index.html +246 -20
  2. package/package.json +2 -2
  3. package/routes.js +89 -6
package/admin/index.html CHANGED
@@ -156,6 +156,7 @@
156
156
  .template-type-crew { background: #f3e5f5; color: #6a1b9a; }
157
157
  .template-type-excard { background: #e8f5e9; color: #2e7d32; }
158
158
  .template-type-workflow { background: #fff3e0; color: #ef6c00; }
159
+ .template-type-doc { background: #e0f7fa; color: #00838f; }
159
160
  textarea {
160
161
  width: 100%;
161
162
  padding: 8px 12px;
@@ -234,6 +235,14 @@
234
235
  <input type="number" name="max_instances" placeholder="实例数" value="1" id="new-instances">
235
236
  <input type="number" name="max_agents" placeholder="Agent数" value="5" id="new-agents">
236
237
  <input type="number" name="max_crews" placeholder="Crew数" value="3" id="new-crews">
238
+ <select id="new-validity">
239
+ <option value="1">1 个月</option>
240
+ <option value="3">3 个月</option>
241
+ <option value="6">6 个月</option>
242
+ <option value="12" selected>1 年</option>
243
+ <option value="24">2 年</option>
244
+ <option value="0">永久</option>
245
+ </select>
237
246
  <button class="btn btn-primary" onclick="createCompany()">创建</button>
238
247
  </div>
239
248
 
@@ -294,6 +303,50 @@
294
303
  </div>
295
304
  </div>
296
305
 
306
+ <div class="modal-overlay" id="company-edit-modal">
307
+ <div class="modal">
308
+ <h3>编辑公司授权</h3>
309
+ <input type="hidden" id="ce-id">
310
+ <div class="form-group">
311
+ <label>公司</label>
312
+ <input type="text" id="ce-name" disabled>
313
+ </div>
314
+ <div class="form-group">
315
+ <label>有效期</label>
316
+ <select id="ce-validity" onchange="document.getElementById('ce-expires').disabled = (this.value !== 'custom')">
317
+ <option value="1">顺延 1 个月</option>
318
+ <option value="3">顺延 3 个月</option>
319
+ <option value="6">顺延 6 个月</option>
320
+ <option value="12">顺延 1 年</option>
321
+ <option value="24">顺延 2 年</option>
322
+ <option value="permanent">永久</option>
323
+ <option value="custom">指定日期</option>
324
+ <option value="keep">不修改</option>
325
+ </select>
326
+ </div>
327
+ <div class="form-group">
328
+ <label>到期日期(仅"指定日期"时可用)</label>
329
+ <input type="date" id="ce-expires" disabled>
330
+ </div>
331
+ <div class="form-group">
332
+ <label>最大实例数</label>
333
+ <input type="number" id="ce-instances" min="1">
334
+ </div>
335
+ <div class="form-group">
336
+ <label>最大 Agent 数</label>
337
+ <input type="number" id="ce-agents" min="1">
338
+ </div>
339
+ <div class="form-group">
340
+ <label>最大 Crew 数</label>
341
+ <input type="number" id="ce-crews" min="1">
342
+ </div>
343
+ <div class="modal-actions">
344
+ <button class="btn" onclick="document.getElementById('company-edit-modal').classList.remove('show')">取消</button>
345
+ <button class="btn btn-primary" onclick="saveCompanyEdit()">保存</button>
346
+ </div>
347
+ </div>
348
+ </div>
349
+
297
350
  <div class="modal-overlay" id="import-detail-modal">
298
351
  <div class="modal">
299
352
  <h3 id="import-detail-title">导入记录</h3>
@@ -320,10 +373,10 @@
320
373
  <input type="hidden" id="tmpl-id">
321
374
  <div class="form-group">
322
375
  <label>类型</label>
323
- <select id="tmpl-type">
376
+ <select id="tmpl-type" onchange="onTemplateTypeChange()">
324
377
  <option value="agent">Agent</option>
325
- <option value="crew">Crew</option>
326
378
  <option value="excard">ExCard</option>
379
+ <option value="doc">文档</option>
327
380
  <option value="workflow">Workflow</option>
328
381
  </select>
329
382
  </div>
@@ -345,13 +398,17 @@
345
398
  <option value="">全局模板</option>
346
399
  </select>
347
400
  </div>
348
- <div class="form-group">
349
- <label>内容 (JSON)</label>
350
- <textarea id="tmpl-json" rows="8" placeholder='{"id": "...", ...}'></textarea>
401
+ <div class="form-group" id="tmpl-md-file-group">
402
+ <label>Markdown 文件(Agent / ExCard / 文档直接上传客户端导出的 .md)</label>
403
+ <input type="file" id="tmpl-md-file" accept=".md,.markdown,text/markdown,text/plain" onchange="onTemplateFileSelected(event)">
351
404
  </div>
352
- <div class="form-group">
353
- <label>Markdown 内容 (可选,Agent/ExCard 需要)</label>
354
- <textarea id="tmpl-md" rows="6" placeholder="YAML frontmatter + Markdown body"></textarea>
405
+ <div class="form-group" id="tmpl-json-group">
406
+ <label>内容 (JSON) <span style="color:#86868b" id="tmpl-json-hint"></span></label>
407
+ <textarea id="tmpl-json" rows="8"></textarea>
408
+ </div>
409
+ <div class="form-group" id="tmpl-md-group">
410
+ <label id="tmpl-md-label">Markdown 内容</label>
411
+ <textarea id="tmpl-md" rows="10" oninput="onTemplateMdInput()"></textarea>
355
412
  </div>
356
413
  <div class="modal-actions">
357
414
  <button class="btn" onclick="closeTemplateModal()">取消</button>
@@ -392,26 +449,43 @@
392
449
  document.getElementById('stat-instances').textContent = res.active_instances || 0;
393
450
  }
394
451
 
452
+ let companiesCache = [];
453
+
395
454
  async function loadCompanies() {
396
455
  const res = await api('GET', '/api/admin/companies');
456
+ companiesCache = res.companies || [];
397
457
  const tbody = document.getElementById('companies-body');
398
458
  tbody.innerHTML = '';
399
459
 
400
- for (const c of res.companies || []) {
460
+ for (const c of companiesCache) {
401
461
  const tr = document.createElement('tr');
402
462
  const statusClass = c.status === 'active' ? 'badge-active' : c.status === 'suspended' ? 'badge-suspended' : 'badge-expired';
403
463
  const expired = c.expires_at && new Date(c.expires_at) < new Date();
404
464
  const finalStatus = expired ? 'expired' : c.status;
405
465
  const finalClass = expired ? 'badge-expired' : statusClass;
406
466
 
467
+ let expiresHtml = '永久';
468
+ if (c.expires_at) {
469
+ const dateStr = c.expires_at.split('T')[0];
470
+ const daysLeft = Math.ceil((new Date(c.expires_at) - Date.now()) / 86400000);
471
+ if (daysLeft < 0) {
472
+ expiresHtml = `${dateStr} <span style="color:#ff3b30">已过期</span>`;
473
+ } else if (daysLeft < 30) {
474
+ expiresHtml = `${dateStr} <span style="color:#ff9500">剩 ${daysLeft} 天</span>`;
475
+ } else {
476
+ expiresHtml = `${dateStr} <span style="color:#86868b">剩 ${daysLeft} 天</span>`;
477
+ }
478
+ }
479
+
407
480
  tr.innerHTML = `
408
481
  <td><strong>${c.name}</strong></td>
409
482
  <td><span class="license-key" onclick="navigator.clipboard.writeText('${c.license_key}');toast('已复制')">${c.license_key}</span></td>
410
483
  <td>${c.max_instances}实例 / ${c.max_agents}Agent / ${c.max_crews}Crew</td>
411
484
  <td><span class="badge ${finalClass}">${finalStatus}</span></td>
412
- <td>${c.expires_at ? c.expires_at.split('T')[0] : '永久'}</td>
485
+ <td>${expiresHtml}</td>
413
486
  <td>${c.active_instances || 0}</td>
414
487
  <td>
488
+ <button class="btn btn-small btn-primary" onclick="openCompanyEdit('${c.id}')">编辑</button>
415
489
  <button class="btn btn-small ${c.status === 'active' ? 'btn-danger' : 'btn-primary'}" onclick="toggleStatus('${c.id}', '${c.status}')">
416
490
  ${c.status === 'active' ? '暂停' : '恢复'}
417
491
  </button>
@@ -449,12 +523,20 @@
449
523
  const name = document.getElementById('new-name').value.trim();
450
524
  if (!name) return toast('请输入公司名称');
451
525
 
526
+ const months = parseInt(document.getElementById('new-validity').value);
527
+ let expiresAt = null; // 永久
528
+ if (months > 0) {
529
+ const d = new Date();
530
+ d.setMonth(d.getMonth() + months);
531
+ expiresAt = d.toISOString().slice(0, 10);
532
+ }
533
+
452
534
  await api('POST', '/api/admin/companies', {
453
535
  name,
454
536
  max_instances: parseInt(document.getElementById('new-instances').value) || 1,
455
537
  max_agents: parseInt(document.getElementById('new-agents').value) || 5,
456
538
  max_crews: parseInt(document.getElementById('new-crews').value) || 3,
457
- expires_at: new Date(Date.now() + 365 * 86400000).toISOString()
539
+ expires_at: expiresAt
458
540
  });
459
541
 
460
542
  document.getElementById('new-name').value = '';
@@ -463,6 +545,56 @@
463
545
  loadStats();
464
546
  }
465
547
 
548
+ function openCompanyEdit(id) {
549
+ const c = companiesCache.find(x => x.id === id);
550
+ if (!c) return toast('公司未找到');
551
+ document.getElementById('ce-id').value = c.id;
552
+ document.getElementById('ce-name').value = c.name;
553
+ document.getElementById('ce-validity').value = 'keep';
554
+ document.getElementById('ce-expires').value = c.expires_at ? c.expires_at.split('T')[0] : '';
555
+ document.getElementById('ce-expires').disabled = true;
556
+ document.getElementById('ce-instances').value = c.max_instances;
557
+ document.getElementById('ce-agents').value = c.max_agents;
558
+ document.getElementById('ce-crews').value = c.max_crews;
559
+ document.getElementById('company-edit-modal').classList.add('show');
560
+ }
561
+
562
+ async function saveCompanyEdit() {
563
+ const id = document.getElementById('ce-id').value;
564
+ const c = companiesCache.find(x => x.id === id);
565
+ const validity = document.getElementById('ce-validity').value;
566
+
567
+ const payload = {
568
+ max_instances: parseInt(document.getElementById('ce-instances').value) || 1,
569
+ max_agents: parseInt(document.getElementById('ce-agents').value) || 1,
570
+ max_crews: parseInt(document.getElementById('ce-crews').value) || 1,
571
+ };
572
+
573
+ if (validity === 'permanent') {
574
+ payload.expires_at = null;
575
+ } else if (validity === 'custom') {
576
+ const d = document.getElementById('ce-expires').value;
577
+ if (!d) return toast('请选择到期日期');
578
+ payload.expires_at = d;
579
+ } else if (validity !== 'keep') {
580
+ // 顺延:从当前到期日(未过期)或今天(已过期/永久)起算
581
+ const months = parseInt(validity);
582
+ const now = new Date();
583
+ let base = now;
584
+ if (c && c.expires_at) {
585
+ const cur = new Date(c.expires_at);
586
+ if (cur > now) base = cur;
587
+ }
588
+ base.setMonth(base.getMonth() + months);
589
+ payload.expires_at = base.toISOString().slice(0, 10);
590
+ }
591
+
592
+ await api('PATCH', `/api/admin/companies/${id}`, payload);
593
+ document.getElementById('company-edit-modal').classList.remove('show');
594
+ toast('已保存');
595
+ loadCompanies();
596
+ }
597
+
466
598
  async function toggleStatus(id, current) {
467
599
  const next = current === 'active' ? 'suspended' : 'active';
468
600
  await api('PATCH', `/api/admin/companies/${id}`, { status: next });
@@ -538,6 +670,83 @@
538
670
  }
539
671
  }
540
672
 
673
+ // ── Template type-driven upload ──
674
+ const MD_TYPES = ['agent', 'excard', 'doc'];
675
+ const WORKFLOW_SKELETON = `{
676
+ "title": "工作流程名称",
677
+ "description": "",
678
+ "type": "one-time",
679
+ "agent": "",
680
+ "steps": [
681
+ {
682
+ "stepType": "task",
683
+ "title": "步骤1",
684
+ "description": "",
685
+ "agent": "",
686
+ "excardId": "<excard模板ID>",
687
+ "transitions": { "pass": "next", "fail": "end" }
688
+ }
689
+ ]
690
+ }`;
691
+
692
+ function onTemplateTypeChange() {
693
+ const type = document.getElementById('tmpl-type').value;
694
+ const isMd = MD_TYPES.includes(type);
695
+ document.getElementById('tmpl-json-group').classList.toggle('hidden', isMd);
696
+ document.getElementById('tmpl-md-file-group').classList.toggle('hidden', !isMd);
697
+ document.getElementById('tmpl-md-label').textContent = isMd
698
+ ? 'Markdown 内容(上传文件后可编辑)'
699
+ : 'Markdown 内容 (可选)';
700
+ if (type === 'workflow') {
701
+ const jsonEl = document.getElementById('tmpl-json');
702
+ jsonEl.placeholder = WORKFLOW_SKELETON;
703
+ document.getElementById('tmpl-json-hint').textContent = '步骤结构,excardId 引用模板库中的 ExCard 模板 ID';
704
+ }
705
+ }
706
+
707
+ function parseFrontmatter(md) {
708
+ if (!md || !md.startsWith('---')) return { frontmatter: {}, body: md || '' };
709
+ const end = md.indexOf('\n---', 3);
710
+ if (end === -1) return { frontmatter: {}, body: md };
711
+ const fmText = md.slice(3, end).trim();
712
+ const body = md.slice(end + 4).trim();
713
+ const frontmatter = {};
714
+ for (const line of fmText.split('\n')) {
715
+ const m = line.match(/^([A-Za-z_][\w]*)\s*:\s*(.*)$/);
716
+ if (!m) continue;
717
+ let val = m[2].trim();
718
+ if (/^[[{]/.test(val) || /^(true|false|null|-?\d+(\.\d+)?)$/.test(val)) {
719
+ try { val = JSON.parse(val); } catch { /* keep as string */ }
720
+ }
721
+ frontmatter[m[1]] = val;
722
+ }
723
+ return { frontmatter, body };
724
+ }
725
+
726
+ function onTemplateFileSelected(event) {
727
+ const file = event.target.files?.[0];
728
+ if (!file) return;
729
+ file.text().then(text => {
730
+ document.getElementById('tmpl-md').value = text;
731
+ onTemplateMdInput();
732
+ });
733
+ }
734
+
735
+ function onTemplateMdInput() {
736
+ const type = document.getElementById('tmpl-type').value;
737
+ if (!MD_TYPES.includes(type)) return;
738
+ const md = document.getElementById('tmpl-md').value;
739
+ const nameEl = document.getElementById('tmpl-name');
740
+ if (nameEl.value.trim()) return; // 不覆盖管理员手填的名称
741
+ const { frontmatter, body } = parseFrontmatter(md);
742
+ let auto = typeof frontmatter.name === 'string' ? frontmatter.name : '';
743
+ if (!auto) {
744
+ const h1 = body.match(/^#\s+(.+)$/m);
745
+ if (h1) auto = h1[1].trim();
746
+ }
747
+ if (auto) nameEl.value = auto;
748
+ }
749
+
541
750
  function openTemplateModal() {
542
751
  document.getElementById('modal-title').textContent = '上传模板';
543
752
  document.getElementById('tmpl-id').value = '';
@@ -547,7 +756,9 @@
547
756
  document.getElementById('tmpl-version').value = '';
548
757
  document.getElementById('tmpl-json').value = '';
549
758
  document.getElementById('tmpl-md').value = '';
759
+ document.getElementById('tmpl-md-file').value = '';
550
760
  populateCompanySelect();
761
+ onTemplateTypeChange();
551
762
  document.getElementById('template-modal').classList.add('show');
552
763
  }
553
764
 
@@ -576,6 +787,8 @@
576
787
  }
577
788
  document.getElementById('tmpl-json').value = jsonStr;
578
789
  document.getElementById('tmpl-md').value = t.content_md || '';
790
+ document.getElementById('tmpl-md-file').value = '';
791
+ onTemplateTypeChange();
579
792
  document.getElementById('template-modal').classList.add('show');
580
793
  }
581
794
 
@@ -592,16 +805,29 @@
592
805
  const version = document.getElementById('tmpl-version').value.trim() || undefined;
593
806
  const companyId = document.getElementById('tmpl-company').value || undefined;
594
807
  const jsonRaw = document.getElementById('tmpl-json').value.trim();
595
- const md = document.getElementById('tmpl-md').value.trim() || undefined;
808
+ const md = document.getElementById('tmpl-md').value.trim();
596
809
 
597
810
  if (!name) return toast('请输入模板名称');
598
- if (!jsonRaw) return toast('请输入 JSON 内容');
599
811
 
600
- let contentJson;
601
- try {
602
- contentJson = JSON.parse(jsonRaw);
603
- } catch (e) {
604
- return toast('JSON 格式错误: ' + e.message);
812
+ let contentJson, contentMd;
813
+ if (type === 'workflow') {
814
+ if (!jsonRaw) return toast('请输入 JSON 内容');
815
+ try {
816
+ contentJson = JSON.parse(jsonRaw);
817
+ } catch (e) {
818
+ return toast('JSON 格式错误: ' + e.message);
819
+ }
820
+ contentMd = md || undefined;
821
+ } else {
822
+ // agent / excard / doc:Markdown 原生上传
823
+ if (!md) return toast('请上传或粘贴 Markdown 内容');
824
+ const { frontmatter } = parseFrontmatter(md);
825
+ if (type === 'agent' || type === 'excard') {
826
+ contentJson = frontmatter; // 客户端导入时从 frontmatter 读取字段
827
+ } else {
828
+ contentJson = { assets: [] };
829
+ }
830
+ contentMd = md;
605
831
  }
606
832
 
607
833
  if (id) {
@@ -611,7 +837,7 @@
611
837
  };
612
838
  if (version) payload.version = version;
613
839
  if (companyId !== undefined) payload.company_id = companyId;
614
- if (md !== undefined) payload.content_md = md;
840
+ if (contentMd !== undefined) payload.content_md = contentMd;
615
841
  await api('PATCH', `/api/admin/templates/${id}`, payload);
616
842
  closeTemplateModal();
617
843
  toast('模板更新成功');
@@ -624,7 +850,7 @@
624
850
  description: desc,
625
851
  version,
626
852
  content_json: contentJson,
627
- content_md: md,
853
+ content_md: contentMd,
628
854
  });
629
855
  closeTemplateModal();
630
856
  toast('模板上传成功');
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "exflower-license-server",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "ExFlower License Server - 在线授权验证服务",
5
5
  "main": "index.js",
6
6
  "bin": {
7
- "exflower-license-server": "./index.js"
7
+ "exflower-license-server": "index.js"
8
8
  },
9
9
  "files": [
10
10
  "index.js",
package/routes.js CHANGED
@@ -109,6 +109,7 @@ function setupRoutes(app) {
109
109
  max_agents: company.max_agents,
110
110
  max_crews: company.max_crews
111
111
  },
112
+ active_instances: activeInstances,
112
113
  expires_at: company.expires_at
113
114
  });
114
115
  });
@@ -169,9 +170,10 @@ function setupRoutes(app) {
169
170
  const key = generateLicenseKey();
170
171
  const id = generateId();
171
172
 
173
+ // expires_at 显式传 null = 永久授权;不传则默认 1 年
172
174
  const defaultExpires = new Date();
173
175
  defaultExpires.setFullYear(defaultExpires.getFullYear() + 1);
174
- const expires = expires_at || defaultExpires.toISOString().slice(0, 10);
176
+ const expires = expires_at === null ? null : (expires_at || defaultExpires.toISOString().slice(0, 10));
175
177
 
176
178
  queryRun(
177
179
  `INSERT INTO companies (id, name, license_key, max_instances, max_agents, max_crews, expires_at, status)
@@ -326,14 +328,49 @@ function setupRoutes(app) {
326
328
  res.json({ success: true, templates });
327
329
  });
328
330
 
331
+ // ── Template Relationship Helpers ──
332
+ function _extractWorkflowExcardRefs(contentJson) {
333
+ try {
334
+ const data = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
335
+ const steps = data.steps || [];
336
+ const refs = new Set();
337
+ for (const step of steps) {
338
+ const eid = step.excardId || step.excard_id;
339
+ if (eid) refs.add(eid);
340
+ }
341
+ return Array.from(refs);
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+
347
+ function _findWorkflowRefsToExcard(excardId) {
348
+ const allWorkflows = queryAll("SELECT id, name, content_json FROM templates WHERE type = 'workflow'");
349
+ const refs = [];
350
+ for (const wf of allWorkflows) {
351
+ const ids = _extractWorkflowExcardRefs(wf.content_json);
352
+ if (ids.includes(excardId)) {
353
+ refs.push({ id: wf.id, name: wf.name });
354
+ }
355
+ }
356
+ return refs;
357
+ }
358
+
329
359
  app.post('/api/admin/templates', requireAdmin, (req, res) => {
330
360
  const { company_id, type, name, description, content_json, content_md, tags, version } = req.body;
331
361
 
332
- if (!type || !name || !content_json) {
333
- return res.status(400).json({ success: false, message: 'type, name, content_json are required' });
362
+ if (!type || !name) {
363
+ return res.status(400).json({ success: false, message: 'type and name are required' });
364
+ }
365
+ if (!['agent', 'excard', 'workflow', 'doc'].includes(type)) {
366
+ return res.status(400).json({ success: false, message: 'type must be agent, excard, workflow, or doc' });
334
367
  }
335
- if (!['agent', 'crew', 'excard', 'workflow', 'doc'].includes(type)) {
336
- return res.status(400).json({ success: false, message: 'type must be agent, crew, excard, workflow, or doc' });
368
+ // workflow 必须以 content_json 描述步骤结构;其余类型可用 content_md 原生上传
369
+ if (type === 'workflow' && !content_json) {
370
+ return res.status(400).json({ success: false, message: 'workflow templates require content_json' });
371
+ }
372
+ if (!content_json && !content_md) {
373
+ return res.status(400).json({ success: false, message: 'content_json or content_md is required' });
337
374
  }
338
375
 
339
376
  // If company_id is provided, verify it exists
@@ -344,6 +381,20 @@ function setupRoutes(app) {
344
381
  }
345
382
  }
346
383
 
384
+ // Validate workflow excard references
385
+ if (type === 'workflow') {
386
+ const excardRefs = _extractWorkflowExcardRefs(content_json);
387
+ for (const eid of excardRefs) {
388
+ const exists = queryGet('SELECT id FROM templates WHERE id = ? AND type = ?', [eid, 'excard']);
389
+ if (!exists) {
390
+ return res.status(400).json({
391
+ success: false,
392
+ message: `Workflow references excard template '${eid}' which does not exist in the template library`
393
+ });
394
+ }
395
+ }
396
+ }
397
+
347
398
  const id = generateId();
348
399
  queryRun(
349
400
  `INSERT INTO templates (id, company_id, type, name, description, content_json, content_md, tags, version, created_at, updated_at)
@@ -354,7 +405,9 @@ function setupRoutes(app) {
354
405
  type,
355
406
  name,
356
407
  description || '',
357
- typeof content_json === 'string' ? content_json : JSON.stringify(content_json),
408
+ content_json
409
+ ? (typeof content_json === 'string' ? content_json : JSON.stringify(content_json))
410
+ : '{}',
358
411
  content_md || null,
359
412
  tags ? JSON.stringify(tags) : '[]',
360
413
  version || '1.0',
@@ -369,6 +422,22 @@ function setupRoutes(app) {
369
422
  if (!template) return res.status(404).json({ success: false, message: 'Template not found' });
370
423
 
371
424
  const { company_id, type, name, description, content_json, content_md, tags, version, is_public } = req.body;
425
+
426
+ // Validate workflow excard references when content_json is updated
427
+ const isWorkflow = (type !== undefined ? type : template.type) === 'workflow';
428
+ if (isWorkflow && content_json !== undefined) {
429
+ const excardRefs = _extractWorkflowExcardRefs(content_json);
430
+ for (const eid of excardRefs) {
431
+ const exists = queryGet('SELECT id FROM templates WHERE id = ? AND type = ?', [eid, 'excard']);
432
+ if (!exists) {
433
+ return res.status(400).json({
434
+ success: false,
435
+ message: `Workflow references excard template '${eid}' which does not exist in the template library`
436
+ });
437
+ }
438
+ }
439
+ }
440
+
372
441
  const updates = [];
373
442
  const params = [];
374
443
 
@@ -393,6 +462,20 @@ function setupRoutes(app) {
393
462
  });
394
463
 
395
464
  app.delete('/api/admin/templates/:id', requireAdmin, (req, res) => {
465
+ const template = queryGet('SELECT * FROM templates WHERE id = ?', [req.params.id]);
466
+ if (!template) return res.status(404).json({ success: false, message: 'Template not found' });
467
+
468
+ // Prevent deleting excard templates referenced by workflow templates
469
+ if (template.type === 'excard') {
470
+ const refs = _findWorkflowRefsToExcard(template.id);
471
+ if (refs.length > 0) {
472
+ return res.status(400).json({
473
+ success: false,
474
+ message: `Cannot delete: this ExCard template is referenced by ${refs.length} workflow template(s): ${refs.map(r => `'${r.name}'`).join(', ')}`
475
+ });
476
+ }
477
+ }
478
+
396
479
  queryRun('DELETE FROM templates WHERE id = ?', [req.params.id]);
397
480
  res.json({ success: true });
398
481
  });