exflower-license-server 1.0.0 → 1.0.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/README.md +110 -5
- package/admin/index.html +130 -12
- package/db.js +18 -0
- package/index.js +9 -0
- package/package.json +2 -3
- package/routes.js +87 -1
package/README.md
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
# ExFlower License Server
|
|
2
2
|
|
|
3
|
-
ExFlower 在线授权验证服务。管理 License Key
|
|
3
|
+
ExFlower 在线授权验证服务。管理 License Key、企业授权、实例追踪、模板分发。
|
|
4
4
|
|
|
5
5
|
## 快速开始
|
|
6
6
|
|
|
7
|
-
### 方式一:npm
|
|
7
|
+
### 方式一:npm 全局安装(推荐)
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# 安装
|
|
11
11
|
npm install -g exflower-license-server
|
|
12
12
|
|
|
13
|
+
# 查看版本
|
|
14
|
+
exflower-license-server --version
|
|
15
|
+
# 输出: 1.0.2
|
|
16
|
+
|
|
13
17
|
# 运行
|
|
14
18
|
exflower-license-server
|
|
15
19
|
|
|
@@ -38,6 +42,81 @@ npm install
|
|
|
38
42
|
npm start
|
|
39
43
|
```
|
|
40
44
|
|
|
45
|
+
## 管理员 Token 管理
|
|
46
|
+
|
|
47
|
+
### 默认 Token
|
|
48
|
+
|
|
49
|
+
首次启动时,默认管理员 Token 为 **`exflower-admin-2026`**。
|
|
50
|
+
|
|
51
|
+
**⚠️ 生产环境必须修改默认 Token。**
|
|
52
|
+
|
|
53
|
+
### 临时修改(当前会话)
|
|
54
|
+
|
|
55
|
+
启动命令前设置环境变量:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Linux / macOS
|
|
59
|
+
ADMIN_TOKEN=your-secure-token exflower-license-server
|
|
60
|
+
|
|
61
|
+
# Windows (PowerShell)
|
|
62
|
+
$env:ADMIN_TOKEN="your-secure-token"; exflower-license-server
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 永久修改(推荐)
|
|
66
|
+
|
|
67
|
+
创建启动脚本 `start.sh`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
#!/bin/bash
|
|
71
|
+
export ADMIN_TOKEN="your-secure-token"
|
|
72
|
+
export PORT=4001
|
|
73
|
+
exflower-license-server
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
或使用 systemd 服务(见下方"生产部署")。
|
|
77
|
+
|
|
78
|
+
### 验证 Token 是否生效
|
|
79
|
+
|
|
80
|
+
启动后查看控制台输出:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
╔══════════════════════════════════════════════════╗
|
|
84
|
+
║ ExFlower License Server ║
|
|
85
|
+
║ Port: 4001 ║
|
|
86
|
+
║ Admin Dashboard: http://localhost:4001/admin ║
|
|
87
|
+
║ Admin Token: your-secure-token ║
|
|
88
|
+
╚══════════════════════════════════════════════════╝
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Token 必须与管理后台登录时输入的一致。
|
|
92
|
+
|
|
93
|
+
### 生产部署(systemd)
|
|
94
|
+
|
|
95
|
+
创建 `/etc/systemd/system/exflower-license-server.service`:
|
|
96
|
+
|
|
97
|
+
```ini
|
|
98
|
+
[Unit]
|
|
99
|
+
Description=ExFlower License Server
|
|
100
|
+
After=network.target
|
|
101
|
+
|
|
102
|
+
[Service]
|
|
103
|
+
Type=simple
|
|
104
|
+
Environment="ADMIN_TOKEN=your-secure-token"
|
|
105
|
+
Environment="PORT=4001"
|
|
106
|
+
ExecStart=/usr/bin/exflower-license-server
|
|
107
|
+
Restart=always
|
|
108
|
+
|
|
109
|
+
[Install]
|
|
110
|
+
WantedBy=multi-user.target
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
启用并启动:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
sudo systemctl daemon-reload
|
|
117
|
+
sudo systemctl enable --now exflower-license-server
|
|
118
|
+
```
|
|
119
|
+
|
|
41
120
|
## 环境变量
|
|
42
121
|
|
|
43
122
|
| 变量 | 默认值 | 说明 |
|
|
@@ -51,15 +130,44 @@ npm start
|
|
|
51
130
|
|
|
52
131
|
启动后访问 `http://localhost:4001/admin`,使用 `ADMIN_TOKEN` 登录。
|
|
53
132
|
|
|
133
|
+
管理后台功能:
|
|
134
|
+
- **企业管理**:创建/编辑/禁用/删除公司,设置授权配额
|
|
135
|
+
- **实例管理**:查看已激活的实例列表
|
|
136
|
+
- **模板管理**:上传/编辑/删除模板(agent/crew/excard/workflow),支持按公司分发,查看导入统计(次数/公司/实例)
|
|
137
|
+
|
|
54
138
|
## API 端点
|
|
55
139
|
|
|
140
|
+
### 客户端接口
|
|
141
|
+
|
|
56
142
|
| 端点 | 方法 | 说明 |
|
|
57
143
|
|------|------|------|
|
|
58
144
|
| `/health` | GET | 健康检查 |
|
|
59
145
|
| `/api/license/verify` | POST | 验证 License Key |
|
|
60
146
|
| `/api/license/heartbeat` | POST | 心跳上报 |
|
|
147
|
+
| `/api/templates` | GET | 获取模板列表(需 `x-license-key`) |
|
|
148
|
+
| `/api/templates/:id` | GET | 获取模板详情(需 `x-license-key`) |
|
|
149
|
+
|
|
150
|
+
### 管理后台接口
|
|
151
|
+
|
|
152
|
+
| 端点 | 方法 | 说明 |
|
|
153
|
+
|------|------|------|
|
|
61
154
|
| `/api/admin/companies` | GET/POST | 企业管理 |
|
|
155
|
+
| `/api/admin/companies/:id` | PATCH/DELETE | 更新/删除公司 |
|
|
62
156
|
| `/api/admin/instances` | GET | 实例列表 |
|
|
157
|
+
| `/api/admin/instances/:id` | DELETE | 删除实例 |
|
|
158
|
+
| `/api/admin/templates` | GET/POST | 模板列表/创建 |
|
|
159
|
+
| `/api/admin/templates/:id` | PATCH/DELETE | 更新/删除模板 |
|
|
160
|
+
| `/api/admin/imports` | GET | 导入统计(支持 `?template_id=` / `?company_id=` 过滤) |
|
|
161
|
+
|
|
162
|
+
所有管理接口需在 Header 中携带 `x-admin-token`。
|
|
163
|
+
|
|
164
|
+
## 模板系统
|
|
165
|
+
|
|
166
|
+
模板用于向客户端分发预设配置:
|
|
167
|
+
|
|
168
|
+
- **类型**:`agent` / `crew` / `excard` / `workflow`
|
|
169
|
+
- **范围**:`company_id = NULL` 为全局模板(所有公司可见);指定 `company_id` 则仅该公司可见
|
|
170
|
+
- **内容**:`content_json`(结构化数据)+ `content_md`(Markdown 说明)
|
|
63
171
|
|
|
64
172
|
## 构建 Docker 镜像
|
|
65
173
|
|
|
@@ -67,6 +175,3 @@ npm start
|
|
|
67
175
|
docker build -t exflower/license-server:latest .
|
|
68
176
|
```
|
|
69
177
|
|
|
70
|
-
## License
|
|
71
|
-
|
|
72
|
-
MIT
|
package/admin/index.html
CHANGED
|
@@ -284,6 +284,7 @@
|
|
|
284
284
|
<th>名称</th>
|
|
285
285
|
<th>公司</th>
|
|
286
286
|
<th>版本</th>
|
|
287
|
+
<th>导入次数</th>
|
|
287
288
|
<th>创建时间</th>
|
|
288
289
|
<th>操作</th>
|
|
289
290
|
</tr>
|
|
@@ -293,9 +294,30 @@
|
|
|
293
294
|
</div>
|
|
294
295
|
</div>
|
|
295
296
|
|
|
297
|
+
<div class="modal-overlay" id="import-detail-modal">
|
|
298
|
+
<div class="modal">
|
|
299
|
+
<h3 id="import-detail-title">导入记录</h3>
|
|
300
|
+
<table>
|
|
301
|
+
<thead>
|
|
302
|
+
<tr>
|
|
303
|
+
<th>公司</th>
|
|
304
|
+
<th>实例ID</th>
|
|
305
|
+
<th>模板名称</th>
|
|
306
|
+
<th>导入时间</th>
|
|
307
|
+
</tr>
|
|
308
|
+
</thead>
|
|
309
|
+
<tbody id="import-detail-body"></tbody>
|
|
310
|
+
</table>
|
|
311
|
+
<div class="modal-actions">
|
|
312
|
+
<button class="btn" onclick="document.getElementById('import-detail-modal').classList.remove('show')">关闭</button>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
|
|
296
317
|
<div class="modal-overlay" id="template-modal">
|
|
297
318
|
<div class="modal">
|
|
298
319
|
<h3 id="modal-title">上传模板</h3>
|
|
320
|
+
<input type="hidden" id="tmpl-id">
|
|
299
321
|
<div class="form-group">
|
|
300
322
|
<label>类型</label>
|
|
301
323
|
<select id="tmpl-type">
|
|
@@ -313,6 +335,10 @@
|
|
|
313
335
|
<label>描述</label>
|
|
314
336
|
<input type="text" id="tmpl-desc" placeholder="简短描述">
|
|
315
337
|
</div>
|
|
338
|
+
<div class="form-group">
|
|
339
|
+
<label>版本</label>
|
|
340
|
+
<input type="text" id="tmpl-version" placeholder="1.0">
|
|
341
|
+
</div>
|
|
316
342
|
<div class="form-group">
|
|
317
343
|
<label>所属公司 (留空 = 全局模板)</label>
|
|
318
344
|
<select id="tmpl-company">
|
|
@@ -460,21 +486,39 @@
|
|
|
460
486
|
loadStats();
|
|
461
487
|
}
|
|
462
488
|
|
|
489
|
+
let templateImportCounts = {};
|
|
490
|
+
|
|
463
491
|
async function loadTemplates() {
|
|
464
|
-
const res = await
|
|
492
|
+
const [res, importsRes] = await Promise.all([
|
|
493
|
+
api('GET', '/api/admin/templates'),
|
|
494
|
+
api('GET', '/api/admin/imports')
|
|
495
|
+
]);
|
|
465
496
|
const tbody = document.getElementById('templates-body');
|
|
466
497
|
tbody.innerHTML = '';
|
|
467
498
|
|
|
499
|
+
// Build import count map
|
|
500
|
+
templateImportCounts = {};
|
|
501
|
+
for (const imp of importsRes.imports || []) {
|
|
502
|
+
const tid = imp.template_id;
|
|
503
|
+
if (tid) {
|
|
504
|
+
templateImportCounts[tid] = (templateImportCounts[tid] || 0) + 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
468
508
|
for (const t of res.templates || []) {
|
|
469
509
|
const tr = document.createElement('tr');
|
|
470
510
|
const typeBadge = `badge template-type-${t.type}`;
|
|
511
|
+
const importCount = templateImportCounts[t.id] || 0;
|
|
471
512
|
tr.innerHTML = `
|
|
472
513
|
<td><span class="badge ${typeBadge}">${t.type}</span></td>
|
|
473
514
|
<td><strong>${t.name}</strong><br><span style="color:#86868b;font-size:11px">${t.description || ''}</span></td>
|
|
474
515
|
<td>${t.company_id ? t.company_id.slice(0, 8) + '...' : '全局'}</td>
|
|
475
516
|
<td>${t.version || '1.0'}</td>
|
|
517
|
+
<td>${importCount > 0 ? `<strong style="color:#0071e3">${importCount}</strong>` : '0'}</td>
|
|
476
518
|
<td>${t.created_at ? t.created_at.split('T')[0] : '-'}</td>
|
|
477
519
|
<td>
|
|
520
|
+
<button class="btn btn-small btn-primary" onclick="editTemplate('${t.id}')">编辑</button>
|
|
521
|
+
<button class="btn btn-small" style="background:#34c759;color:#fff;" onclick="showImportDetails('${t.id}', '${t.name.replace(/'/g, "\\'")}')">查看导入</button>
|
|
478
522
|
<button class="btn btn-small btn-danger" onclick="deleteTemplate('${t.id}')">删除</button>
|
|
479
523
|
</td>
|
|
480
524
|
`;
|
|
@@ -496,23 +540,56 @@
|
|
|
496
540
|
|
|
497
541
|
function openTemplateModal() {
|
|
498
542
|
document.getElementById('modal-title').textContent = '上传模板';
|
|
543
|
+
document.getElementById('tmpl-id').value = '';
|
|
499
544
|
document.getElementById('tmpl-type').value = 'agent';
|
|
500
545
|
document.getElementById('tmpl-name').value = '';
|
|
501
546
|
document.getElementById('tmpl-desc').value = '';
|
|
547
|
+
document.getElementById('tmpl-version').value = '';
|
|
502
548
|
document.getElementById('tmpl-json').value = '';
|
|
503
549
|
document.getElementById('tmpl-md').value = '';
|
|
504
550
|
populateCompanySelect();
|
|
505
551
|
document.getElementById('template-modal').classList.add('show');
|
|
506
552
|
}
|
|
507
553
|
|
|
554
|
+
async function editTemplate(id) {
|
|
555
|
+
const res = await api('GET', `/api/admin/templates?type=`);
|
|
556
|
+
const t = (res.templates || []).find(x => x.id === id);
|
|
557
|
+
if (!t) return toast('模板未找到');
|
|
558
|
+
|
|
559
|
+
document.getElementById('modal-title').textContent = '编辑模板';
|
|
560
|
+
document.getElementById('tmpl-id').value = t.id;
|
|
561
|
+
document.getElementById('tmpl-type').value = t.type;
|
|
562
|
+
document.getElementById('tmpl-name').value = t.name || '';
|
|
563
|
+
document.getElementById('tmpl-desc').value = t.description || '';
|
|
564
|
+
document.getElementById('tmpl-version').value = t.version || '';
|
|
565
|
+
await populateCompanySelect();
|
|
566
|
+
document.getElementById('tmpl-company').value = t.company_id || '';
|
|
567
|
+
|
|
568
|
+
let jsonStr = '';
|
|
569
|
+
if (t.content_json) {
|
|
570
|
+
try {
|
|
571
|
+
const parsed = typeof t.content_json === 'string' ? JSON.parse(t.content_json) : t.content_json;
|
|
572
|
+
jsonStr = JSON.stringify(parsed, null, 2);
|
|
573
|
+
} catch {
|
|
574
|
+
jsonStr = String(t.content_json);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
document.getElementById('tmpl-json').value = jsonStr;
|
|
578
|
+
document.getElementById('tmpl-md').value = t.content_md || '';
|
|
579
|
+
document.getElementById('template-modal').classList.add('show');
|
|
580
|
+
}
|
|
581
|
+
|
|
508
582
|
function closeTemplateModal() {
|
|
509
583
|
document.getElementById('template-modal').classList.remove('show');
|
|
584
|
+
document.getElementById('tmpl-id').value = '';
|
|
510
585
|
}
|
|
511
586
|
|
|
512
587
|
async function saveTemplate() {
|
|
588
|
+
const id = document.getElementById('tmpl-id').value;
|
|
513
589
|
const type = document.getElementById('tmpl-type').value;
|
|
514
590
|
const name = document.getElementById('tmpl-name').value.trim();
|
|
515
591
|
const desc = document.getElementById('tmpl-desc').value.trim();
|
|
592
|
+
const version = document.getElementById('tmpl-version').value.trim() || undefined;
|
|
516
593
|
const companyId = document.getElementById('tmpl-company').value || undefined;
|
|
517
594
|
const jsonRaw = document.getElementById('tmpl-json').value.trim();
|
|
518
595
|
const md = document.getElementById('tmpl-md').value.trim() || undefined;
|
|
@@ -527,17 +604,31 @@
|
|
|
527
604
|
return toast('JSON 格式错误: ' + e.message);
|
|
528
605
|
}
|
|
529
606
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
607
|
+
if (id) {
|
|
608
|
+
// Edit existing
|
|
609
|
+
const payload = {
|
|
610
|
+
type, name, description: desc, content_json: contentJson,
|
|
611
|
+
};
|
|
612
|
+
if (version) payload.version = version;
|
|
613
|
+
if (companyId !== undefined) payload.company_id = companyId;
|
|
614
|
+
if (md !== undefined) payload.content_md = md;
|
|
615
|
+
await api('PATCH', `/api/admin/templates/${id}`, payload);
|
|
616
|
+
closeTemplateModal();
|
|
617
|
+
toast('模板更新成功');
|
|
618
|
+
} else {
|
|
619
|
+
// Create new
|
|
620
|
+
await api('POST', '/api/admin/templates', {
|
|
621
|
+
company_id: companyId,
|
|
622
|
+
type,
|
|
623
|
+
name,
|
|
624
|
+
description: desc,
|
|
625
|
+
version,
|
|
626
|
+
content_json: contentJson,
|
|
627
|
+
content_md: md,
|
|
628
|
+
});
|
|
629
|
+
closeTemplateModal();
|
|
630
|
+
toast('模板上传成功');
|
|
631
|
+
}
|
|
541
632
|
loadTemplates();
|
|
542
633
|
}
|
|
543
634
|
|
|
@@ -548,6 +639,33 @@
|
|
|
548
639
|
loadTemplates();
|
|
549
640
|
}
|
|
550
641
|
|
|
642
|
+
async function showImportDetails(templateId, templateName) {
|
|
643
|
+
const res = await api('GET', `/api/admin/imports?template_id=${encodeURIComponent(templateId)}`);
|
|
644
|
+
const tbody = document.getElementById('import-detail-body');
|
|
645
|
+
tbody.innerHTML = '';
|
|
646
|
+
|
|
647
|
+
const imports = res.imports || [];
|
|
648
|
+
if (imports.length === 0) {
|
|
649
|
+
const tr = document.createElement('tr');
|
|
650
|
+
tr.innerHTML = `<td colspan="4" style="text-align:center;color:#86868b;padding:20px;">暂无导入记录</td>`;
|
|
651
|
+
tbody.appendChild(tr);
|
|
652
|
+
} else {
|
|
653
|
+
for (const imp of imports) {
|
|
654
|
+
const tr = document.createElement('tr');
|
|
655
|
+
tr.innerHTML = `
|
|
656
|
+
<td>${imp.company_name || '-'}</td>
|
|
657
|
+
<td><code>${imp.instance_id || '-'}</code></td>
|
|
658
|
+
<td>${imp.template_name || '-'}</td>
|
|
659
|
+
<td>${imp.imported_at ? imp.imported_at.split('T').join(' ') : '-'}</td>
|
|
660
|
+
`;
|
|
661
|
+
tbody.appendChild(tr);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
document.getElementById('import-detail-title').textContent = `「${templateName}」导入记录 (${imports.length}次)`;
|
|
666
|
+
document.getElementById('import-detail-modal').classList.add('show');
|
|
667
|
+
}
|
|
668
|
+
|
|
551
669
|
function showTab(name) {
|
|
552
670
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
553
671
|
event.target.classList.add('active');
|
package/db.js
CHANGED
|
@@ -80,6 +80,24 @@ db.exec(`
|
|
|
80
80
|
CREATE INDEX IF NOT EXISTS idx_templates_type ON templates(type)
|
|
81
81
|
`);
|
|
82
82
|
|
|
83
|
+
db.exec(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS template_imports (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
template_id TEXT NOT NULL,
|
|
87
|
+
company_id TEXT NOT NULL,
|
|
88
|
+
instance_id TEXT,
|
|
89
|
+
template_name TEXT,
|
|
90
|
+
template_type TEXT,
|
|
91
|
+
imported_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
92
|
+
)
|
|
93
|
+
`);
|
|
94
|
+
db.exec(`
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_imports_template ON template_imports(template_id)
|
|
96
|
+
`);
|
|
97
|
+
db.exec(`
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_imports_company ON template_imports(company_id)
|
|
99
|
+
`);
|
|
100
|
+
|
|
83
101
|
// Seed demo data if empty
|
|
84
102
|
const count = db.prepare('SELECT COUNT(*) as c FROM companies').get().c;
|
|
85
103
|
if (count === 0) {
|
package/index.js
CHANGED
|
@@ -14,8 +14,17 @@
|
|
|
14
14
|
const express = require('express');
|
|
15
15
|
const cors = require('cors');
|
|
16
16
|
const path = require('path');
|
|
17
|
+
const fs = require('fs');
|
|
17
18
|
const { setupRoutes } = require('./routes');
|
|
18
19
|
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
22
|
+
const pkgPath = path.join(__dirname, 'package.json');
|
|
23
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
24
|
+
console.log(pkg.version);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
const app = express();
|
|
20
29
|
const PORT = process.env.PORT || 4001;
|
|
21
30
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'exflower-admin-2026';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exflower-license-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "ExFlower License Server - 在线授权验证服务",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,5 @@
|
|
|
33
33
|
"exflower",
|
|
34
34
|
"authorization"
|
|
35
35
|
],
|
|
36
|
-
"author": "ExFlower"
|
|
37
|
-
"license": "MIT"
|
|
36
|
+
"author": "ExFlower"
|
|
38
37
|
}
|
package/routes.js
CHANGED
|
@@ -254,6 +254,29 @@ function setupRoutes(app) {
|
|
|
254
254
|
res.json({ success: true, templates });
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
// Import summary (must come before /:id to avoid matching 'import-summary' as an id)
|
|
258
|
+
app.get('/api/templates/import-summary', (req, res) => {
|
|
259
|
+
const licenseKey = req.headers['x-license-key'];
|
|
260
|
+
if (!licenseKey) {
|
|
261
|
+
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
262
|
+
}
|
|
263
|
+
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
264
|
+
if (!company) {
|
|
265
|
+
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const rows = queryAll(
|
|
269
|
+
`SELECT template_id, COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
|
|
270
|
+
FROM template_imports WHERE company_id = ? GROUP BY template_id`,
|
|
271
|
+
[company.id]
|
|
272
|
+
);
|
|
273
|
+
const map = {};
|
|
274
|
+
for (const r of rows) {
|
|
275
|
+
map[r.template_id] = { count: r.count, first_import: r.first_import, last_import: r.last_import };
|
|
276
|
+
}
|
|
277
|
+
res.json({ success: true, imports: map });
|
|
278
|
+
});
|
|
279
|
+
|
|
257
280
|
// Get a single template (full content)
|
|
258
281
|
app.get('/api/templates/:id', (req, res) => {
|
|
259
282
|
const licenseKey = req.headers['x-license-key'];
|
|
@@ -363,7 +386,7 @@ function setupRoutes(app) {
|
|
|
363
386
|
return res.status(400).json({ success: false, message: 'No fields to update' });
|
|
364
387
|
}
|
|
365
388
|
|
|
366
|
-
updates.push(
|
|
389
|
+
updates.push("updated_at = datetime('now')");
|
|
367
390
|
queryRun(`UPDATE templates SET ${updates.join(', ')} WHERE id = ?`, [...params, req.params.id]);
|
|
368
391
|
|
|
369
392
|
res.json({ success: true });
|
|
@@ -374,6 +397,69 @@ function setupRoutes(app) {
|
|
|
374
397
|
res.json({ success: true });
|
|
375
398
|
});
|
|
376
399
|
|
|
400
|
+
// ── Template Import Log (client-facing) ──
|
|
401
|
+
app.post('/api/templates/:id/import-log', (req, res) => {
|
|
402
|
+
const licenseKey = req.headers['x-license-key'];
|
|
403
|
+
if (!licenseKey) {
|
|
404
|
+
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
405
|
+
}
|
|
406
|
+
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
407
|
+
if (!company) {
|
|
408
|
+
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { instance_id, template_name, template_type } = req.body;
|
|
412
|
+
const id = generateId();
|
|
413
|
+
queryRun(
|
|
414
|
+
`INSERT INTO template_imports (id, template_id, company_id, instance_id, template_name, template_type)
|
|
415
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
416
|
+
[id, req.params.id, company.id, instance_id || null, template_name || '', template_type || '']
|
|
417
|
+
);
|
|
418
|
+
res.json({ success: true });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
app.get('/api/templates/:id/import-status', (req, res) => {
|
|
422
|
+
const licenseKey = req.headers['x-license-key'];
|
|
423
|
+
if (!licenseKey) {
|
|
424
|
+
return res.status(401).json({ success: false, message: 'Missing x-license-key header' });
|
|
425
|
+
}
|
|
426
|
+
const company = queryGet('SELECT id FROM companies WHERE license_key = ?', [licenseKey]);
|
|
427
|
+
if (!company) {
|
|
428
|
+
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const stats = queryGet(
|
|
432
|
+
`SELECT COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
|
|
433
|
+
FROM template_imports WHERE template_id = ? AND company_id = ?`,
|
|
434
|
+
[req.params.id, company.id]
|
|
435
|
+
);
|
|
436
|
+
res.json({ success: true, imported: stats.count > 0, count: stats.count, first_import: stats.first_import, last_import: stats.last_import });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ── Admin: Import Stats ──
|
|
440
|
+
app.get('/api/admin/imports', requireAdmin, (req, res) => {
|
|
441
|
+
const { template_id, company_id } = req.query;
|
|
442
|
+
let sql = `
|
|
443
|
+
SELECT i.*, c.name as company_name, t.name as template_name
|
|
444
|
+
FROM template_imports i
|
|
445
|
+
JOIN companies c ON i.company_id = c.id
|
|
446
|
+
LEFT JOIN templates t ON i.template_id = t.id
|
|
447
|
+
WHERE 1=1
|
|
448
|
+
`;
|
|
449
|
+
const params = [];
|
|
450
|
+
if (template_id) { sql += ' AND i.template_id = ?'; params.push(template_id); }
|
|
451
|
+
if (company_id) { sql += ' AND i.company_id = ?'; params.push(company_id); }
|
|
452
|
+
sql += ' ORDER BY i.imported_at DESC LIMIT 500';
|
|
453
|
+
|
|
454
|
+
const imports = queryAll(sql, params);
|
|
455
|
+
// Aggregate stats
|
|
456
|
+
const stats = queryGet(`
|
|
457
|
+
SELECT COUNT(*) as total_imports, COUNT(DISTINCT template_id) as unique_templates, COUNT(DISTINCT company_id) as unique_companies
|
|
458
|
+
FROM template_imports
|
|
459
|
+
`);
|
|
460
|
+
res.json({ success: true, imports, stats });
|
|
461
|
+
});
|
|
462
|
+
|
|
377
463
|
// ── Health ──
|
|
378
464
|
app.get('/health', (req, res) => {
|
|
379
465
|
const stats = queryGet(`
|