🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
+547
View File
@@ -0,0 +1,547 @@
# Agent Architecture Audit — 小龙 🐉 全系统
> **审计日期:** 2026-06-05 22:13 CST
> **审计范围:** scripts/, memory/, test/, rules/, workspace config, prompts
> **审计方法:** 静态分析 + 架构模式检查 + 运行行为验证
---
## 第一部分:代码质量审计
### 发现清单
#### Critical
**C-01: Domain Matcher 生成无效 FK 引用**
- 位置: `model-contract.mjs``createContract()` → FK 生成逻辑
- 问题: 生成 `REFERENCES boards(id)``REFERENCES assignees(id)``REFERENCES equipments(id)` 等指向不存在的表的外键
- 影响: 每次生成新 domain 都会破坏 SQLite schema,导致 INSERT/DELETE 测试失败
- 根因: FK 生成只看 field name 是否含 `Id` 后缀,不验证目标表是否存在
- 修复: `createContract` 阶段校验 FK 目标表是否在 entities 列表中
**C-02: Domain Matcher 误匹配导致实体污染**
- 位置: `project-intake-agent.mjs``matchDomain()`
- 问题: "仓库出入库"→NoteApp"合同管理"→OAFlow"设备巡检"→generic
- 影响: 2/3 真实项目匹配到错误 domain,产生无关组件(NoteCard、contracts/approvals 表)
- 根因: 纯关键词计数匹配,无权重/否定词机制
- 修复: 增加关键词权重、否定关键词、最高频实体优先
#### High
**H-01: Duplicate Export Bug**
- 位置: `frontend-builder-agent.mjs` → service 文件生成逻辑
- 问题: `items.ts` 生成了两遍 `export function get()``export function post()`
- 影响: TypeScript 编译失败
- 根因: Domain 模板和 generic 模板叠加生成,无去重
**H-02: Singularize Bug — `equipments` vs `equipment`**
- 位置: `model-contract.mjs``toTableName()`
- 问题: `equipment` 被 singularize 为 `equipments`(添加了 s),但 SQLite 表名是 `equipment`
- 影响: FK 引用失败
- 根因: singularize 规则对不可数名词处理有误
**H-03: Domain Bleed — 跨域组件污染**
- 位置: `frontend-builder-agent.mjs` → component 生成逻辑
- 问题: NoteApp domain 的 `NoteCard` 组件被注入到仓库出入库系统
- 影响: 构建失败(类型不匹配)
- 根因: component 生成不检查当前 domain 是否为 note
**H-04: 3.9GB `.tmp-benchmark` 目录未清理**
- 位置: `/Users/a1234/.openclaw/workspace/.tmp-benchmark/`
- 问题: 历史 benchmark 产生的临时文件占 3.9GB
- 影响: 磁盘浪费
- 根因: 无自动清理机制
#### Medium
**M-01: Guard Scripts 全部使用相同 import 模式但无共享基类**
- 位置: `scripts/guard-*.mjs` (6 个文件)
- 问题: 每个 guard 都独立 import `readdirSync, readFileSync, existsSync`,逻辑高度相似
- 影响: 维护成本高,改一处要改 6 处
**M-02: `type: "commonjs"` 但所有脚本使用 ESM**
- 位置: `package.json`
- 问题: `"type": "commonjs"` 但所有 `.mjs` 文件使用 `import/export`
- 影响: `.mjs` 扩展名强制 ESM 所以能跑,但 `package.json` 声明与实际不符
- 风险: 低(.mjs 优先级高于 type 字段),但容易误导
**M-03: Active Memory 测试存在但脚本已删除**
- 位置: `test/active-memory/*.test.mjs` (31 个文件) vs `scripts/active-memory-*.mjs` (不存在)
- 问题: `package.json` 引用的 `active-memory:*` 脚本全部不存在
- 影响: 6 个 npm script 全部不可用
- 根因: 脚本被删除但测试和 package.json 未清理
**M-04: Dream Cycle 日志但事件数极少**
- 位置: `memory/.dreams/events.jsonl` (8 events), `memory/.dreams/short-term-recall.json` (350 entries)
- 问题: Dream Cycle 每天运行但事件捕获极少
- 影响: 记忆系统实际贡献度存疑
#### Low
**L-01: `.memory-core-test/` 和 `.active-memory-precompute-cache/` 空目录**
- 位置: workspace root
- 影响: 无功能影响,只是残留
**L-02: 多个 report 文件堆积在 workspace root**
- 位置: `benchmark-report.md`, `certification-report.md`, `capability-report.md` 等 10+ 个文件
- 影响: workspace 混乱,应归档到 `docs/reports/`
---
## 第二部分:性能与瘦身审计
### 存储浪费分析
| 路径 | 大小 | 状态 |
|------|------|------|
| `.tmp-benchmark/` | **3.9 GB** | 🔴 **立即删除** |
| `output/` | **1.3 GB** | 🟡 按需保留 |
| `.benchmark/` | 87 MB | 🟡 可归档 |
| `.codegraph/` | 19 MB | 🟢 保留 |
| `.tmp-pr12-test/` | 16 KB | 🔴 删除 |
| `.tmp-pr13-test/` | 28 KB | 🔴 删除 |
| `.memory-core-test/` | 0 B | 🔴 删除空目录 |
| `.active-memory-precompute-cache/` | 0 B | 🔴 删除空目录 |
**总浪费: ~4 GB**
### 代码冗余分析
| 冗余类型 | 位置 | 影响 |
|----------|------|------|
| Guard 脚本重复 | 6× guard-*.mjs | 维护成本 ×6 |
| Contract 相关脚本重复 | model-contract + contract-consistency-test + contract-e2e (1,630 行) | 测试和核心逻辑边界模糊 |
| Active Memory 测试残留 | 31 个 test 文件,0 个实现 | 测试无效 |
| Workspace root 报告堆积 | 10+ 个 .md/.json | 混乱 |
### Token 浪费分析
| 浪费源 | 估算 |
|--------|------|
| MEMORY.md 注入上下文(每次对话) | ~2,500 tokens |
| AGENTS.md + SOUL.md + IDENTITY.md + USER.md + TOOLS.md | ~3,000 tokens |
| HEARTBEAT.md | ~500 tokens |
| capability-registry.json (注入 context) | ~800 tokens |
| 其他 workspace files | ~1,000 tokens |
| **总注入上下文** | **~7,800 tokens/会话** |
**优化空间:** capability-registry.json 和 TOOLS.md 注入无实际使用价值(工具由 OpenClaw 管理),可节省 ~1,800 tokens。
### 必须保留 / 建议删除
#### 必须保留
- `scripts/project-intake-agent.mjs` — Generator 入口
- `scripts/architecture-agent.mjs` — 架构生成
- `scripts/frontend-builder-agent.mjs` — 前端生成
- `scripts/backend-builder-agent.mjs` — 后端生成
- `scripts/fullstack-composer-agent.mjs` — 全栈组合
- `scripts/model-contract.mjs` — 核心数据模型
- `scripts/lib/deprecation-warning.mjs` — 工具函数
- `memory/daily/` — 日常记忆
- `memory/vault.md` — 动态记忆
- `memory/registers/` — 持久化事实
- `test/e2e-regression.test.mjs` — 核心回归测试
- `test/software-factory-core.test.mjs` — 核心测试
#### 建议保留
- `scripts/guard-all.sh` — 统一入口
- `scripts/guard-runtime-core.mjs` — 最有价值的 guard
- `scripts/smoke-test.mjs` — 快速验证
- `scripts/dream-cycle.sh` — 记忆维护
- `scripts/memory-sync.sh` — 记忆同步
- `scripts/memory-auto.sh` — 记忆自动化
#### 可选
- `scripts/guard-dreaming-phase.mjs` — 低频使用
- `scripts/guard-memory-backend.mjs` — 低频使用
- `scripts/guard-tool-path.mjs` — 低频使用
- `scripts/guard-tool-trace.mjs` — 低频使用
- `scripts/check-deprecations.mjs` — 低频使用
- `scripts/electron-builder-agent.mjs` — Real World 未使用
- `scripts/release-builder-agent.mjs` — Real World 未使用
- `test/active-memory/` (31 个文件) — 实现已删除
#### 建议删除
- `.tmp-benchmark/` (3.9 GB) — 纯浪费
- `.tmp-pr12-test/`, `.tmp-pr13-test/` — 临时残留
- `.memory-core-test/`, `.active-memory-precompute-cache/` — 空目录
- `scripts/contract-consistency-test.mjs` — 可合并到 e2e-regression
- `scripts/contract-e2e.mjs` — 可合并到 e2e-regression
- workspace root 的 10+ 个 report 文件 → 归档到 `docs/reports/`
---
## 第三部分:任务规划能力审计
### 当前状态
```
User 输入
小龙理解意图(LLM 推理)
直接执行(调用工具)
验证结果
返回用户
```
### 缺失环节
| 环节 | 存在? | 说明 |
|------|--------|------|
| **Planner Agent** | ❌ | 无独立规划器 |
| **Task Decomposition** | ❌ | 无自动任务拆解 |
| **Dynamic Task Tree** | ❌ | Goal→SubGoal→Task→Action 不支持 |
| **Task Rollback** | ❌ | 失败后无回滚机制 |
| **Failure Replanning** | ❌ | 失败后靠 LLM 临时推理,无结构化重规划 |
| **Complexity Detection** | ⚠️ 部分 | 靠 LLM 推理判断,无显式机制 |
| **Plan Persistence** | ⚠️ 部分 | `update_plan` 工具存在但仅当前会话有效 |
### 评估
- **简单任务**: 直接执行 ✅ 正确
- **复杂任务**: 仍直接执行,靠 LLM 推理拆解 ⚠️ 不稳定
- **多步骤任务**: 会使用 `update_plan` 跟踪进度 ✅ 但无自动拆解
- **失败恢复**: 靠 LLM 自行判断重试 ⚠️ 不可靠
### 评分: **35/100**
**优势:**
- LLM 推理能力作为隐式 planner 能处理大多数任务
- `update_plan` 提供了基本的任务跟踪
**缺陷:**
- 无显式 planning 层
- 无 complexity classifier
- 无 structured replanning
- 无 task dependency graph
- 无 parallel task execution planning
### 改进方案
```
Level 0 (当前): LLM 推理 → 直接执行
Level 1 (推荐): LLM 推理 → Plan Check → 执行 → 验证
Level 2 (进阶): Planner Agent → Task Graph → 执行器 → 反思
Level 3 (理想): 自适应规划 → 动态任务树 → 失败重规划 → 学习
```
**立即可做:** 在 AGENTS.md 中增加 planning 协议 — 复杂任务必须先输出 plan 再执行。
---
## 第四部分:记忆系统审计
### 记忆层全景
| 记忆层 | 路径 | 行数 | 真正使用? | 使用频率 | 实际贡献 |
|--------|------|------|-----------|----------|----------|
| **MEMORY.md** | workspace root | 126 | ✅ 每会话注入 | 每次 | ⭐⭐⭐⭐⭐ 核心 |
| **AGENTS.md** | workspace root | 128 | ✅ 每会话注入 | 每次 | ⭐⭐⭐⭐ 行为指南 |
| **SOUL.md** | workspace root | 30 | ✅ 每会话注入 | 每次 | ⭐⭐⭐⭐ 人格 |
| **IDENTITY.md** | workspace root | 19 | ✅ 每会话注入 | 每次 | ⭐⭐⭐ 基础 |
| **USER.md** | workspace root | 13 | ✅ 每会话注入 | 每次 | ⭐⭐⭐ 基础 |
| **TOOLS.md** | workspace root | 21 | ✅ 每会话注入 | 每次 | ⭐⭐ 低(工具由 OpenClaw 管理) |
| **HEARTBEAT.md** | workspace root | 52 | ⚠️ 心跳时 | 低频 | ⭐⭐⭐ 心跳检查 |
| **memory/vault.md** | memory/ | ~200 | ⚠️ 按需读取 | 中频 | ⭐⭐⭐⭐ 动态记忆 |
| **memory/daily/** | memory/daily/ | ~700 | ⚠️ 按需读取 | 中频 | ⭐⭐⭐⭐ 日志 |
| **memory/registers/** | memory/registers/ | ~200 | ⚠️ 按需读取 | 低频 | ⭐⭐⭐ 持久事实 |
| **memory/USER.md** | memory/ | ~50 | ⚠️ 按需读取 | 低频 | ⭐⭐ 与 workspace USER.md 重复 |
| **memory/SESSION_START.md** | memory/ | ~30 | ⚠️ 启动时 | 低频 | ⭐⭐ 协议文件 |
| **memory/core/** | memory/core/ | ~300 | ❌ 极少读取 | 极低 | ⭐ 与 registers 重复 |
| **memory/entities/** | memory/entities/ | ~100 | ❌ 极少读取 | 极低 | ⭐ 实体记忆,价值低 |
| **memory/projects/** | memory/projects/ | ~400 | ⚠️ 按需 | 低频 | ⭐⭐⭐ 项目记忆 |
| **memory/CONTRADICTION.md** | memory/ | ~50 | ❌ 从未读取 | 无 | ⭐ 无贡献 |
| **memory/companion-log.md** | memory/ | ~50 | ❌ 从未读取 | 无 | ⭐ 无贡献 |
| **memory/pet-tuanzi.md** | memory/ | ~30 | ❌ 从未读取 | 无 | ⭐ 无贡献 |
| **memory/index.md** | memory/ | ~50 | ❌ 从未读取 | 无 | ⭐ 无贡献 |
| **memory/.dreams/** | memory/.dreams/ | ~350 | ⚠️ dream-cycle | 低频 | ⭐⭐ 短期召回 |
| **memory/2026-06-04.md** | memory/ | ~50 | ⚠️ 按需 | 低频 | ⭐⭐ 旧格式日志 |
### 问题诊断
#### 1. 记忆冗余
- `memory/USER.md``workspace/USER.md` 重复
- `memory/core/``memory/registers/` 功能重叠
- `memory/SESSION_START.md``AGENTS.md` 功能重叠
#### 2. 记忆污染
- `memory/pet-tuanzi.md` — 宠物信息,与核心工作无关
- `memory/companion-log.md` — 伴侣日志,从未使用
- `memory/CONTRADICTION.md` — 矛盾记录,从未使用
#### 3. 记忆孤岛
- `memory/core/` 目录有 6 个文件但几乎从不被读取
- `memory/entities/` 有 2 个实体文件但从未被检索
- `memory/index.md` 存在但无实际索引功能
#### 4. Dream Cycle 效率低
- `events.jsonl` 只有 8 条事件
- `short-term-recall.json` 有 350 条但从未被主动检索
- Dream Cycle 每天运行但产出极少
### 保留/删除/替代
| 文件 | 操作 | 原因 |
|------|------|------|
| `memory/core/` | **删除** | 与 registers 重复,从未被读取 |
| `memory/entities/` | **删除** | 从未被检索,价值低 |
| `memory/CONTRADICTION.md` | **删除** | 从未使用 |
| `memory/companion-log.md` | **删除** | 从未使用 |
| `memory/pet-tuanzi.md` | **归档** | 移到 `memory/archive/` |
| `memory/index.md` | **删除** | 无实际索引功能 |
| `memory/USER.md` | **删除** | 与 workspace USER.md 重复 |
| `memory/SESSION_START.md` | **合并** | 合并到 AGENTS.md |
| `memory/2026-06-04.md` | **归档** | 旧格式,移到 `memory/daily/` |
---
## 第五部分:工具系统审计
### 工具价值排行榜
#### 高价值(每次会话都用)
| 工具 | 价值 |
|------|------|
| `read` / `write` / `edit` | ⭐⭐⭐⭐⭐ 文件操作核心 |
| `exec` | ⭐⭐⭐⭐⭐ 命令执行核心 |
| `context-mode__ctx_search` | ⭐⭐⭐⭐⭐ 知识检索 |
| `context-mode__ctx_execute` | ⭐⭐⭐⭐⭐ 代码沙箱 |
| `context-mode__ctx_index` | ⭐⭐⭐⭐ 知识存储 |
| `context-mode__ctx_fetch_and_index` | ⭐⭐⭐⭐ Web 内容索引 |
| `web_search` / `web_fetch` | ⭐⭐⭐⭐ 信息获取 |
| `memory_search` / `memory_get` | ⭐⭐⭐⭐ 记忆检索 |
| `cron` | ⭐⭐⭐⭐ 定时任务 |
#### 中价值(偶尔使用)
| 工具 | 价值 |
|------|------|
| `sessions_spawn` / `sessions_yield` | ⭐⭐⭐ 子任务 |
| `update_plan` | ⭐⭐⭐ 任务跟踪 |
| `session_status` | ⭐⭐ 状态查看 |
| `image` | ⭐⭐ 图像分析 |
| `video_generate` | ⭐⭐ 视频生成 |
#### 低价值(极少使用)
| 工具 | 价值 |
|------|------|
| `context-mode__ctx_insight` | ⭐ Dashboard |
| `context-mode__ctx_doctor` | ⭐ 诊断 |
| `context-mode__ctx_upgrade` | ⭐ 升级 |
| `context-mode__ctx_purge` | ⭐ 清理 |
| `context-mode__ctx_stats` | ⭐ 统计 |
### 工具问题
1. **context-mode 工具过多** — 10 个 ctx_* 工具,功能重叠
2. **sessions 工具链复杂** — sessions_list/history/send/spawn/yield,使用率低
3. **image/video 工具未充分利用** — 生成式能力几乎未使用
---
## 第六部分:Prompt 系统审计
### 注入上下文分析
| 文件 | Token 估算 | 每会话注入 | 冗余度 |
|------|-----------|-----------|--------|
| AGENTS.md | ~1,500 | ✅ | 低 |
| SOUL.md | ~400 | ✅ | 低 |
| IDENTITY.md | ~250 | ✅ | 中(与 SOUL.md 重叠) |
| USER.md | ~200 | ✅ | 中(与 memory/USER.md 重叠) |
| TOOLS.md | ~300 | ✅ | 高(工具由 OpenClaw 管理) |
| MEMORY.md | ~2,500 | ✅ | 低 |
| HEARTBEAT.md | ~700 | ✅ | 低 |
**总注入: ~5,850 tokens/会话**
### 冲突检测
| 冲突 | 严重度 |
|------|--------|
| IDENTITY.md 中的性格定义 vs SOUL.md 中的 Vibe 定义 | 低(互补) |
| USER.md vs memory/USER.md 重复 | 中(浪费 token |
| TOOLS.md 说"工具速查" vs 实际工具由 OpenClaw 管理 | 中(误导) |
| AGENTS.md 的"执行流程" vs MEMORY.md 的"工程范式" 重叠 | 低(强化) |
### 精简方案
| 操作 | 节省 Token |
|------|-----------|
| 删除 TOOLS.md(工具由 OpenClaw 管理) | ~300 |
| 合并 IDENTITY.md 到 SOUL.md | ~200 |
| 删除 workspace USER.md,保留 memory/USER.md | ~200 |
| 精简 MEMORY.md 中的重复内容 | ~300 |
| **总节省** | **~1,000 tokens** |
---
## 第七部分:AI Agent 反模式检查
| 反模式 | 存在? | 严重度 | 证据 |
|--------|--------|--------|------|
| **Feature Creep** (功能膨胀) | ✅ | 🔴 High | Active Memory 系统:31 个测试文件但实现已删除 |
| **Over Engineering** (过度设计) | ✅ | 🟡 Medium | 6 个 Guard 脚本做类似的事 |
| **Memory Overload** (记忆过载) | ✅ | 🟡 Medium | 20+ 个记忆文件,多个从未使用 |
| **Tool Explosion** (工具爆炸) | ⚠️ | 🟡 Medium | 10 个 ctx_* 工具,功能重叠 |
| **Prompt Bloat** (Prompt 肥大) | ⚠️ | 🟢 Low | ~5,850 tokens,可控 |
| **Agent Drift** (Agent 漂移) | ⚠️ | 🟡 Medium | Domain Matcher 偏离用户意图 |
| **Context Pollution** (上下文污染) | ✅ | 🟡 Medium | workspace root 堆积 10+ 个报告文件 |
| **Architecture Debt** (架构债务) | ✅ | 🔴 High | Active Memory 脚本删除但测试残留 |
| **Reasoning Collapse** (推理退化) | ❌ | - | 未观察到 |
| **Planning Collapse** (规划退化) | ❌ | - | 原生就弱,非退化 |
---
## 第八部分:未来升级空间
### 当前等级评估
```
Level 1: Script ✅ 已超越
Level 2: Workflow ✅ 已超越
Level 3: Agent ✅ 当前位置 — 隐式规划,工具调用,记忆系统
Level 4: Multi-Agent ⚠️ 部分具备 — sessions_spawn 但无协调协议
Level 5: Autonomous ❌ 未达到
Level 6: Self-Improving ❌ 未达到
Level 7: Self-Evolving ❌ 未达到
```
**当前: Level 3 — Agent**
### 距离 Level 4 (Multi-Agent) 还缺什么
1. **Agent 间通信协议** — 当前 sessions_send 是消息传递,非结构化协议
2. **任务分配器** — 无中央调度器决定哪个 agent 处理什么任务
3. **共享状态** — agents 间无共享 memory/workspace(除了文件系统)
4. **角色定义** — 无专门化的 agent 角色(planner/executor/validator
5. **冲突解决** — 多 agent 并发修改同一资源无锁机制
### 距离 Level 5 (Autonomous) 还缺什么
1. **目标驱动** — 当前是任务驱动,非目标驱动
2. **自主决策** — 无自主决定下一步做什么的能力
3. **环境感知** — 无持续监控外部环境变化
4. **资源管理** — 无自主管理 token/时间/存储预算
---
## 最终输出
### 1. 架构评分卡
| 维度 | 评分 | 说明 |
|------|------|------|
| **Planning** | 35/100 | 无显式 planner,靠 LLM 推理 |
| **Memory** | 55/100 | 多层架构但冗余严重,实际使用率低 |
| **Tool Use** | 75/100 | 核心工具优秀,但 ctx_* 过多 |
| **Reasoning** | 80/100 | LLM 推理能力作为隐式推理层 |
| **Reliability** | 70/100 | 生成器可靠性高,但 domain matcher 不稳定 |
| **Maintainability** | 50/100 | 架构债务明显,残留代码多 |
| **Scalability** | 45/100 | 无 multi-agent 协调,无自治能力 |
**综合评分: 59/100**
### 2. 风险排行榜 Top 10
| # | 风险 | 严重度 | 影响 |
|---|------|--------|------|
| 1 | Domain Matcher 系统性误匹配 | 🔴 Critical | 2/3 真实项目生成错误 |
| 2 | FK 生成不验证目标表存在 | 🔴 Critical | 每次生成都会破坏 schema |
| 3 | Active Memory 系统残留 | 🔴 High | 31 个无效测试,6 个无效 npm script |
| 4 | `.tmp-benchmark` 3.9GB 未清理 | 🔴 High | 磁盘浪费 |
| 5 | Singularize 规则 bug | 🟡 High | `equipment``equipments` 破坏 FK |
| 6 | Domain Bleed 组件污染 | 🟡 High | 跨域组件导致构建失败 |
| 7 | Duplicate Export 生成 | 🟡 Medium | service 文件重复导出 |
| 8 | 记忆系统冗余严重 | 🟡 Medium | 20+ 文件多个无用 |
| 9 | 无显式 Planning 层 | 🟡 Medium | 复杂任务不稳定 |
| 10 | Workspace 根目录混乱 | 🟢 Low | 10+ 个报告文件堆积 |
### 3. 优化路线图
#### 立即修复(今天)
1. `rm -rf .tmp-benchmark/ .tmp-pr12-test/ .tmp-pr13-test/ .memory-core-test/ .active-memory-precompute-cache/` — 释放 3.9GB
2. 修复 `model-contract.mjs` FK 生成逻辑 — 验证目标表存在
3. 修复 `matchDomain()` — 增加关键词权重和否定词
4. 删除 `test/active-memory/` (31 个无效测试)
5. 清理 `package.json` 中的 6 个无效 npm script
#### 1 周内修复
1. 修复 singularize 规则 — `equipment` 不可数名词处理
2. 修复 domain bleed — component 生成检查当前 domain
3. 修复 duplicate export — service 文件生成去重
4. 清理记忆系统 — 删除 `memory/core/`, `memory/entities/`, `memory/CONTRADICTION.md`, `memory/companion-log.md`
5. 合并 IDENTITY.md 到 SOUL.md,删除 TOOLS.md
6. 归档 workspace root 的 10+ 个报告文件到 `docs/reports/`
#### 1 个月内修复
1. 重构 Guard 脚本 — 提取共享基类,6→2 个文件
2. 增加显式 Planning 协议 — 复杂任务先输出 plan
3. 合并 contract-consistency-test + contract-e2e 到 e2e-regression
4. 优化 Dream Cycle — 增加事件捕获频率
5. 减少注入上下文 — 目标从 ~5,850 降到 ~4,000 tokens
#### 长期规划
1. 设计 Multi-Agent 协调协议
2. 实现显式 Planner Agent
3. 实现 Complexity Classifier
4. 实现 Task Dependency Graph
5. 实现 Failure Replanning
### 4. 删除清单
> **删掉后系统会更强的东西**
| 删除项 | 大小/行数 | 理由 |
|--------|----------|------|
| `.tmp-benchmark/` | 3.9 GB | 纯浪费 |
| `test/active-memory/` (31 files) | ~15,000 行 | 实现已删除,测试无效 |
| `memory/core/` (6 files) | ~300 行 | 与 registers 重复,从未读取 |
| `memory/entities/` (2 files) | ~100 行 | 从未被检索 |
| `memory/CONTRADICTION.md` | ~50 行 | 从未使用 |
| `memory/companion-log.md` | ~50 行 | 从未使用 |
| `memory/index.md` | ~50 行 | 无实际索引功能 |
| `scripts/contract-consistency-test.mjs` | 776 行 | 可合并到 e2e-regression |
| `scripts/contract-e2e.mjs` | 206 行 | 可合并到 e2e-regression |
| `TOOLS.md` | 21 行 | 工具由 OpenClaw 管理,注入浪费 |
| workspace root 10+ report files | ~50,000 行 | 归档到 docs/reports/ |
**预计释放: 3.9GB 磁盘 + ~16,500 行代码 + ~500 tokens/会话**
### 5. 最终结论
> **如果我是这个项目的 CTO**
#### 会保留
- **Generator 核心** (7 个 agent scripts + model-contract) — 这是真正的价值
- **记忆系统核心** (MEMORY.md + daily/ + vault.md + registers/) — 经过验证有效
- **E2E 测试** (e2e-regression + smoke-test + guard-all) — 质量保障
- **OpenClaw 基础设施** — 工具系统、会话管理、cron
#### 会删除
- **Active Memory 全系统** — 脚本已删,测试残留,package.json 引用无效
- **3.9GB 临时文件** — 无理由保留
- **6 个 Guard 脚本中的 4 个** — 合并到 guard-all.sh 一个脚本
- **记忆系统中的孤岛文件** — core/, entities/, CONTRADICTION.md, companion-log.md, index.md
- **workspace root 的报告堆积** — 归档到 docs/
#### 会重构
1. **Domain Matcher** — 从纯关键词计数改为加权匹配 + 否定词 + 实体优先级
2. **FK 生成逻辑** — 增加目标表存在性验证
3. **Guard 脚本** — 从 6 个独立脚本重构为 1 个可配置的 guard 框架
4. **记忆系统** — 从"写入优先"重构为"读取优先",删除从未被读取的文件
#### 为什么
> 这个项目的核心问题不是功能不够,而是**功能膨胀后的维护成本**。
>
> Generator 本身是优秀的(Real World Qualification 证明了这一点),但围绕它生长了大量从未使用的基础设施(Active Memory 测试、Guard 脚本、记忆孤岛)。
>
> **下一步应该做的不是加功能,而是减功能。** 删掉 3.9GB 的临时文件、16,500 行无效测试、6 个重复的 Guard 脚本、10 个从未读取的记忆文件。
>
> 系统会因此变得更强。
---
*审计完成 — 小龙 🐉 Agent Architecture Audit*
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.next/
data/
.env
*.db
*.db-journal
*.db-wal
+118
View File
@@ -0,0 +1,118 @@
# OAFlow — Fullstack Project
> 一款支持审批流、考勤管理、部门协作的企业办公自动化系统。
## Architecture
```
apps/
├── web/ # Next.js 15 + TypeScript + Tailwind CSS
└── api/ # Fastify 5 + TypeScript + SQLite (sql.js)
packages/
├── shared-types/ # @shared/types — shared TypeScript interfaces
└── shared-config/ # @shared/config — shared configuration
```
## Quick Start
```bash
# Install all dependencies (root + workspaces)
npm install
# Start both frontend and backend in dev mode
npm run dev
# Or start individually
npm run dev:web # http://localhost:3000
npm run dev:api # http://localhost:3001
```
## Build
```bash
# Build everything
npm run build
# Or individually
npm run build:web
npm run build:api
```
## Testing
```bash
# Run API tests
npm test
```
## API Endpoints
### Auth
- `POST /api/auth/register`
- `POST /api/auth/login`
- `GET /api/auth/me`
### Resources
#### users
- `GET /api/users` — List
- `GET /api/users/:id` — Get
- `POST /api/users` — Create
- `PUT /api/users/:id` — Update
- `DELETE /api/users/:id` — Delete
#### contracts
- `GET /api/contracts` — List
- `GET /api/contracts/:id` — Get
- `POST /api/contracts` — Create
- `PUT /api/contracts/:id` — Update
- `DELETE /api/contracts/:id` — Delete
#### approvals
- `GET /api/approvals` — List
- `GET /api/approvals/:id` — Get
- `POST /api/approvals` — Create
- `PUT /api/approvals/:id` — Update
- `DELETE /api/approvals/:id` — Delete
#### customers
- `GET /api/customers` — List
- `GET /api/customers/:id` — Get
- `POST /api/customers` — Create
- `PUT /api/customers/:id` — Update
- `DELETE /api/customers/:id` — Delete
#### reminders
- `GET /api/reminders` — List
- `GET /api/reminders/:id` — Get
- `POST /api/reminders` — Create
- `PUT /api/reminders/:id` — Update
- `DELETE /api/reminders/:id` — Delete
#### items
- `GET /api/items` — List
- `GET /api/items/:id` — Get
- `POST /api/items` — Create
- `PUT /api/items/:id` — Update
- `DELETE /api/items/:id` — Delete
#### clients
- `GET /api/clients` — List
- `GET /api/clients/:id` — Get
- `POST /api/clients` — Create
- `PUT /api/clients/:id` — Update
- `DELETE /api/clients/:id` — Delete
#### templates
- `GET /api/templates` — List
- `GET /api/templates/:id` — Get
- `POST /api/templates` — Create
- `PUT /api/templates/:id` — Update
- `DELETE /api/templates/:id` — Delete
### System
- `GET /api/health` — Health check
## Environment Variables
See `.env.example` for all available configuration.
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
data/
.env
*.db
*.db-journal
*.db-wal
+111
View File
@@ -0,0 +1,111 @@
# OAFlow — Backend API
> 一款支持审批流、考勤管理、部门协作的企业办公自动化系统。
## Tech Stack
- **Runtime**: Node.js
- **Framework**: Fastify 5
- **Language**: TypeScript
- **Database**: SQLite (better-sqlite3)
- **Auth**: JWT + bcrypt
## Getting Started
```bash
# Install dependencies
npm install
# Development (hot reload)
npm run dev
# Build
npm run build
# Production start
npm run start
# Run tests
npm test
```
## Project Structure
```
src/
├── index.ts # Server entry point
├── db/
│ ├── schema.ts # SQLite schema
│ └── client.ts # Database client
├── routes/
│ ├── auth.ts # Auth routes (register/login/me)
│ └── *.ts # CRUD routes
├── services/
│ └── *.ts # Business logic
├── middleware/
│ └── auth.ts # JWT middleware
├── types/
│ └── index.ts # TypeScript types
└── __tests__/
└── *.test.ts # Tests
```
## API Endpoints
### Auth
- `POST /api/auth/register` — Register
- `POST /api/auth/login` — Login
- `GET /api/auth/me` — Current user (auth required)
### Resources
#### contracts
- `GET /api/contracts` — List all
- `GET /api/contracts/:id` — Get by ID
- `POST /api/contracts` — Create
- `PUT /api/contracts/:id` — Update
- `DELETE /api/contracts/:id` — Delete
#### approvals
- `GET /api/approvals` — List all
- `GET /api/approvals/:id` — Get by ID
- `POST /api/approvals` — Create
- `PUT /api/approvals/:id` — Update
- `DELETE /api/approvals/:id` — Delete
#### customers
- `GET /api/customers` — List all
- `GET /api/customers/:id` — Get by ID
- `POST /api/customers` — Create
- `PUT /api/customers/:id` — Update
- `DELETE /api/customers/:id` — Delete
#### reminders
- `GET /api/reminders` — List all
- `GET /api/reminders/:id` — Get by ID
- `POST /api/reminders` — Create
- `PUT /api/reminders/:id` — Update
- `DELETE /api/reminders/:id` — Delete
#### items
- `GET /api/items` — List all
- `GET /api/items/:id` — Get by ID
- `POST /api/items` — Create
- `PUT /api/items/:id` — Update
- `DELETE /api/items/:id` — Delete
#### clients
- `GET /api/clients` — List all
- `GET /api/clients/:id` — Get by ID
- `POST /api/clients` — Create
- `PUT /api/clients/:id` — Update
- `DELETE /api/clients/:id` — Delete
#### templates
- `GET /api/templates` — List all
- `GET /api/templates/:id` — Get by ID
- `POST /api/templates` — Create
- `PUT /api/templates/:id` — Update
- `DELETE /api/templates/:id` — Delete
### System
- `GET /api/health` — Health check
@@ -0,0 +1,29 @@
{
"name": "oaflow-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "node --import tsx --test src/__tests__/*.test.ts",
"test:watch": "node --import tsx --test --watch src/__tests__/*.test.ts"
},
"dependencies": {
"fastify": "^5.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/jwt": "^9.0.0",
"sql.js": "^1.12.0",
"bcrypt": "^5.1.0",
"@shared/types": "*",
"@shared/config": "*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/bcrypt": "^5.0.0",
"typescript": "^5.6.0",
"tsx": "^4.0.0",
"pino-pretty": "^11.0.0"
}
}
@@ -0,0 +1,121 @@
// Approval CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"entityType": "sample-entitytype",
"entityId": "sample-entityid",
"applicantId": regRes.json().user.id,
"status": "sample-status",
"formData": "sample-formdata"
};
});
after(async () => {
await app.close();
});
describe("GET /api/approvals", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/approvals",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/approvals", () => {
it("creates a approval", async () => {
const res = await app.inject({
method: "POST",
url: "/api/approvals",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/approvals/:id", () => {
it("returns the created approval", async () => {
const res = await app.inject({
method: "GET",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/approvals/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/approvals/:id", () => {
it("updates the approval", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/approvals/:id", () => {
it("deletes the approval", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,96 @@
// Auth routes test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
describe("POST /api/auth/register", () => {
it("registers a new user", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.token);
assert.equal(body.user.username, "testuser");
token = body.token;
});
it("rejects duplicate username", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 409);
});
it("rejects short password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "user2", password: "123" },
});
assert.equal(res.statusCode, 400);
});
});
describe("POST /api/auth/login", () => {
it("logs in with correct credentials", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(body.token);
token = body.token;
});
it("rejects wrong password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "testuser", password: "wrongpassword" },
});
assert.equal(res.statusCode, 401);
});
});
describe("GET /api/auth/me", () => {
it("returns current user with valid token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/auth/me",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.username, "testuser");
});
it("rejects without token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/auth/me",
});
assert.equal(res.statusCode, 401);
});
});
@@ -0,0 +1,119 @@
// Clients CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"title": "sample-title",
"description": "sample-description",
"data": "sample-data"
};
});
after(async () => {
await app.close();
});
describe("GET /api/clients", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/clients",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/clients", () => {
it("creates a client", async () => {
const res = await app.inject({
method: "POST",
url: "/api/clients",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/clients/:id", () => {
it("returns the created client", async () => {
const res = await app.inject({
method: "GET",
url: `/api/clients/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/clients/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/clients/:id", () => {
it("updates the client", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/clients/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/clients/:id", () => {
it("deletes the client", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/clients/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/clients/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,124 @@
// Contract CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"title": "sample-title",
"partyA": "sample-partya",
"partyB": "sample-partyb",
"amount": 1,
"signedAt": "sample-signedat",
"expiresAt": "sample-expiresat",
"status": "sample-status",
"fileUrl": "sample-fileurl"
};
});
after(async () => {
await app.close();
});
describe("GET /api/contracts", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/contracts",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/contracts", () => {
it("creates a contract", async () => {
const res = await app.inject({
method: "POST",
url: "/api/contracts",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/contracts/:id", () => {
it("returns the created contract", async () => {
const res = await app.inject({
method: "GET",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/contracts/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/contracts/:id", () => {
it("updates the contract", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/contracts/:id", () => {
it("deletes the contract", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,122 @@
// Customer CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"name": "sample-name",
"email": "sample-email",
"phone": "sample-phone",
"company": "sample-company",
"source": "sample-source",
"tags": "sample-tags"
};
});
after(async () => {
await app.close();
});
describe("GET /api/customers", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/customers",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/customers", () => {
it("creates a customer", async () => {
const res = await app.inject({
method: "POST",
url: "/api/customers",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/customers/:id", () => {
it("returns the created customer", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/customers/:id", () => {
it("updates the customer", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/customers/:id", () => {
it("deletes the customer", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,119 @@
// Items CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"title": "sample-title",
"description": "sample-description",
"data": "sample-data"
};
});
after(async () => {
await app.close();
});
describe("GET /api/items", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/items",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/items", () => {
it("creates a item", async () => {
const res = await app.inject({
method: "POST",
url: "/api/items",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/items/:id", () => {
it("returns the created item", async () => {
const res = await app.inject({
method: "GET",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/items/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/items/:id", () => {
it("updates the item", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/items/:id", () => {
it("deletes the item", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,121 @@
// Reminder CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"entityType": "sample-entitytype",
"entityId": "sample-entityid",
"remindAt": "sample-remindat",
"message": "sample-message",
"sent": true
};
});
after(async () => {
await app.close();
});
describe("GET /api/reminders", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/reminders",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/reminders", () => {
it("creates a reminder", async () => {
const res = await app.inject({
method: "POST",
url: "/api/reminders",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/reminders/:id", () => {
it("returns the created reminder", async () => {
const res = await app.inject({
method: "GET",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/reminders/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/reminders/:id", () => {
it("updates the reminder", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/reminders/:id", () => {
it("deletes the reminder", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,123 @@
// Templates CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"title": "sample-title",
"partyA": "sample-partya",
"partyB": "sample-partyb",
"amount": 1,
"signedAt": "sample-signedat",
"expiresAt": "sample-expiresat",
"fileUrl": "sample-fileurl"
};
});
after(async () => {
await app.close();
});
describe("GET /api/templates", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/templates",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/templates", () => {
it("creates a template", async () => {
const res = await app.inject({
method: "POST",
url: "/api/templates",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/templates/:id", () => {
it("returns the created template", async () => {
const res = await app.inject({
method: "GET",
url: `/api/templates/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/templates/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/templates/:id", () => {
it("updates the template", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/templates/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/templates/:id", () => {
it("deletes the template", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/templates/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/templates/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,93 @@
// SQLite database client (sql.js — pure WASM, no native deps)
import initSqlJs, { type Database, type BindParams } from "sql.js";
import { createTables } from "./schema.js";
let db: Database | null = null;
let initPromise: Promise<Database> | null = null;
/** Initialize the database (call once at startup). */
export async function initDb(dbPath?: string): Promise<Database> {
if (db) return db;
if (initPromise) return initPromise;
initPromise = (async () => {
const SQL = await initSqlJs();
const path = dbPath || process.env.DATABASE_URL || ":memory:";
// Try to load existing database from file
let buffer: ArrayLike<number> | undefined;
if (path !== ":memory:") {
try {
const fs = await import("node:fs/promises");
const data = await fs.readFile(path);
buffer = new Uint8Array(data);
} catch {
// File doesn't exist yet — start fresh
}
}
db = new SQL.Database(buffer);
db.run("PRAGMA foreign_keys = ON");
createTables(db);
return db;
})();
return initPromise;
}
/** Get the initialized database (must call initDb first). */
export function getDb(): Database {
if (!db) throw new Error("Database not initialized. Call initDb() first.");
return db;
}
/** Save database to disk. */
export async function saveDb(dbPath?: string): Promise<void> {
if (!db) return;
const path = dbPath || process.env.DATABASE_URL || "./data/app.db";
if (path === ":memory:") return;
const fs = await import("node:fs/promises");
const { dirname } = await import("node:path");
await fs.mkdir(dirname(path), { recursive: true });
const data = db.export();
await fs.writeFile(path, Buffer.from(data));
}
export async function closeDb(): Promise<void> {
if (db) {
await saveDb();
db.close();
db = null;
initPromise = null;
}
}
// Helper: run a query and return all rows as objects
export function queryAll<T = Record<string, unknown>>(sql: string, params: BindParams = []): T[] {
const d = getDb();
const stmt = d.prepare(sql);
if (params) stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
const row = stmt.getAsObject();
results.push(row as unknown as T);
}
stmt.free();
return results;
}
// Helper: run a query and return the first row
export function queryOne<T = Record<string, unknown>>(sql: string, params: BindParams = []): T | undefined {
const rows = queryAll<T>(sql, params);
return rows[0];
}
// Helper: run a mutation and return { changes, lastInsertRowid }
export function execute(sql: string, params: BindParams = []): { changes: number; lastInsertRowid: number } {
const d = getDb();
d.run(sql, params);
return {
changes: d.getRowsModified(),
lastInsertRowid: 0,
};
}
@@ -0,0 +1,19 @@
// Auto-generated SQLite schema
import type { Database } from "sql.js";
export function createTables(db: Database): void {
const statements = [
"CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n username TEXT NOT NULL UNIQUE,\n password_hash TEXT NOT NULL,\n nickname TEXT,\n role TEXT DEFAULT 'user',\n phone TEXT,\n avatar_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS contracts (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n title TEXT NOT NULL,\n party_a TEXT,\n party_b TEXT,\n amount REAL,\n signed_at TEXT,\n expires_at TEXT,\n status TEXT DEFAULT 'draft',\n file_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS approvals (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n entity_type TEXT NOT NULL,\n entity_id TEXT,\n applicant_id TEXT NOT NULL REFERENCES users(id),\n status TEXT DEFAULT 'pending',\n form_data TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS customers (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n name TEXT NOT NULL,\n email TEXT,\n phone TEXT,\n company TEXT,\n source TEXT,\n tags TEXT DEFAULT '[]',\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS reminders (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n entity_type TEXT NOT NULL,\n entity_id TEXT,\n remind_at TEXT NOT NULL,\n message TEXT,\n sent INTEGER DEFAULT 'false',\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS items (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n description TEXT,\n status TEXT,\n data TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS clients (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n description TEXT,\n status TEXT,\n data TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS templates (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n party_a TEXT,\n party_b TEXT,\n amount REAL,\n signed_at TEXT,\n expires_at TEXT,\n status TEXT,\n file_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);"
];
for (const sql of statements) {
const trimmed = sql.trim();
if (trimmed) db.run(trimmed);
}
}
@@ -0,0 +1,77 @@
// OAFlow — Fastify Backend Server
import Fastify from "fastify";
import cors from "@fastify/cors";
import fjwt from "@fastify/jwt";
import { initDb, closeDb } from "./db/client.js";
import { authRoutes } from "./routes/auth.js";
import { contractsRoutes } from "./routes/contracts.js";
import { approvalsRoutes } from "./routes/approvals.js";
import { customersRoutes } from "./routes/customers.js";
import { remindersRoutes } from "./routes/reminders.js";
import { itemsRoutes } from "./routes/items.js";
import { clientsRoutes } from "./routes/clients.js";
import { templatesRoutes } from "./routes/templates.js";
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-4bdc0134";
export async function buildApp() {
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
},
});
// Init database
await initDb();
// Plugins
await app.register(cors, {
origin: process.env.CORS_ORIGIN || "*",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
});
await app.register(fjwt, { secret: JWT_SECRET });
// Routes
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(contractsRoutes, { prefix: "/api/contracts" });
await app.register(approvalsRoutes, { prefix: "/api/approvals" });
await app.register(customersRoutes, { prefix: "/api/customers" });
await app.register(remindersRoutes, { prefix: "/api/reminders" });
await app.register(itemsRoutes, { prefix: "/api/items" });
await app.register(clientsRoutes, { prefix: "/api/clients" });
await app.register(templatesRoutes, { prefix: "/api/templates" });
// Health check
app.get("/api/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
// Graceful shutdown
app.addHook("onClose", async () => {
closeDb();
});
return app;
}
// Start server if called directly (not when imported by tests)
const port = parseInt(process.env.PORT || "3001", 10);
const host = process.env.HOST || "0.0.0.0";
async function main() {
const app = await buildApp();
try {
await app.listen({ port, host });
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
// Guard: only run when executed directly, not when imported
const isMain = process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("/src/index.ts") || process.argv[1].endsWith("/src/index.js"));
if (isMain) {
main();
}
@@ -0,0 +1,41 @@
// JWT Authentication Middleware
import type { FastifyRequest, FastifyReply } from "fastify";
import type { JwtPayload } from "../types/index.js";
/**
* Verify JWT token and attach user to request.
*/
export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
await request.jwtVerify();
} catch (err) {
reply.status(401).send({ error: "Unauthorized", message: "Invalid or expired token", statusCode: 401 });
}
}
/** Helper to get typed user from request (after authenticate). */
export function getUser(request: FastifyRequest): JwtPayload {
return request.user as unknown as JwtPayload;
}
/**
* Require admin role.
* Must be used after authenticate.
*/
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const user = request.user as unknown as JwtPayload | undefined;
if (!user || user.role !== "admin") {
reply.status(403).send({ error: "Forbidden", message: "Admin access required", statusCode: 403 });
}
}
/**
* Optional auth: attach user if token present, but don't fail if missing.
*/
export async function optionalAuth(request: FastifyRequest): Promise<void> {
try {
await request.jwtVerify();
} catch {
// No token or invalid — continue without user
}
}
@@ -0,0 +1,51 @@
// Auto-generated Approval routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { ApprovalService } from "../services/approval.js";
import type { CreateApprovalInput, UpdateApprovalInput } from "../types/index.js";
const service = new ApprovalService();
export async function approvalsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/approvals — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/approvals/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Approval not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/approvals — create
app.post<{ Body: CreateApprovalInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/approvals/:id — update
app.put<{ Params: { id: string }; Body: UpdateApprovalInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Approval not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/approvals/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Approval not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,121 @@
// Authentication routes
import type { FastifyInstance } from "fastify";
import bcrypt from "bcrypt";
import { queryOne, execute } from "../db/client.js";
import { authenticate } from "../middleware/auth.js";
import type { User, RegisterInput, LoginInput, AuthResponse } from "../types/index.js";
const SALT_ROUNDS = 10;
export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/register
app.post<{ Body: RegisterInput }>("/register", async (request, reply) => {
const { username, password, nickname } = request.body;
if (!username || !password) {
return reply.status(400).send({
error: "Bad Request",
message: "Username and password are required",
statusCode: 400,
});
}
if (password.length < 6) {
return reply.status(400).send({
error: "Bad Request",
message: "Password must be at least 6 characters",
statusCode: 400,
});
}
const existing = queryOne("SELECT id FROM users WHERE username = ?", [username]);
if (existing) {
return reply.status(409).send({
error: "Conflict",
message: "Username already exists",
statusCode: 409,
});
}
const id = crypto.randomUUID();
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const now = new Date().toISOString();
execute(
"INSERT INTO users (id, username, password_hash, nickname, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
[id, username, passwordHash, nickname || username, now, now]
);
const user = queryOne<User>(
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
[id]
);
if (!user) {
return reply.status(500).send({ error: "Internal Error", message: "Failed to create user", statusCode: 500 });
}
const token = app.jwt.sign({ userId: id, username, role: user.role });
return reply.status(201).send({ token, user } satisfies AuthResponse);
});
// POST /api/auth/login
app.post<{ Body: LoginInput }>("/login", async (request, reply) => {
const { username, password } = request.body;
if (!username || !password) {
return reply.status(400).send({
error: "Bad Request",
message: "Username and password are required",
statusCode: 400,
});
}
const user = queryOne<User & { password_hash: string }>(
"SELECT * FROM users WHERE username = ?",
[username]
);
if (!user) {
return reply.status(401).send({
error: "Unauthorized",
message: "Invalid username or password",
statusCode: 401,
});
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return reply.status(401).send({
error: "Unauthorized",
message: "Invalid username or password",
statusCode: 401,
});
}
const token = app.jwt.sign({ userId: user.id, username: user.username, role: user.role });
const { password_hash, ...safeUser } = user;
return { token, user: safeUser } satisfies AuthResponse;
});
// GET /api/auth/me — current user info
app.get("/me", { onRequest: [authenticate] }, async (request, reply) => {
const jwtUser = request.user as unknown as { userId: string };
const user = queryOne<User>(
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
[jwtUser.userId]
);
if (!user) {
return reply.status(404).send({
error: "Not Found",
message: "User not found",
statusCode: 404,
});
}
return { data: user };
});
}
@@ -0,0 +1,51 @@
// Auto-generated Clients routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { ClientsService } from "../services/client.js";
import type { CreateClientsInput, UpdateClientsInput } from "../types/index.js";
const service = new ClientsService();
export async function clientsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/clients — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/clients/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Clients not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/clients — create
app.post<{ Body: CreateClientsInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/clients/:id — update
app.put<{ Params: { id: string }; Body: UpdateClientsInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Clients not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/clients/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Clients not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Contract routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { ContractService } from "../services/contract.js";
import type { CreateContractInput, UpdateContractInput } from "../types/index.js";
const service = new ContractService();
export async function contractsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/contracts — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/contracts/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Contract not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/contracts — create
app.post<{ Body: CreateContractInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/contracts/:id — update
app.put<{ Params: { id: string }; Body: UpdateContractInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Contract not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/contracts/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Contract not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Customer routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { CustomerService } from "../services/customer.js";
import type { CreateCustomerInput, UpdateCustomerInput } from "../types/index.js";
const service = new CustomerService();
export async function customersRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/customers — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/customers/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Customer not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/customers — create
app.post<{ Body: CreateCustomerInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/customers/:id — update
app.put<{ Params: { id: string }; Body: UpdateCustomerInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Customer not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/customers/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Customer not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Items routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { ItemsService } from "../services/item.js";
import type { CreateItemsInput, UpdateItemsInput } from "../types/index.js";
const service = new ItemsService();
export async function itemsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/items — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/items/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Items not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/items — create
app.post<{ Body: CreateItemsInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/items/:id — update
app.put<{ Params: { id: string }; Body: UpdateItemsInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Items not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/items/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Items not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Reminder routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { ReminderService } from "../services/reminder.js";
import type { CreateReminderInput, UpdateReminderInput } from "../types/index.js";
const service = new ReminderService();
export async function remindersRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/reminders — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/reminders/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Reminder not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/reminders — create
app.post<{ Body: CreateReminderInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/reminders/:id — update
app.put<{ Params: { id: string }; Body: UpdateReminderInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Reminder not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/reminders/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Reminder not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,51 @@
// Auto-generated Templates routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { TemplatesService } from "../services/template.js";
import type { CreateTemplatesInput, UpdateTemplatesInput } from "../types/index.js";
const service = new TemplatesService();
export async function templatesRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/templates — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/templates/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Templates not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/templates — create
app.post<{ Body: CreateTemplatesInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/templates/:id — update
app.put<{ Params: { id: string }; Body: UpdateTemplatesInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Templates not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/templates/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Templates not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,56 @@
// Auto-generated Approval service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Approval, CreateApprovalInput, UpdateApprovalInput } from "../types/index.js";
export class ApprovalService {
/** List all approvals */
list(): Approval[] {
return queryAll<Approval>("SELECT * FROM approvals ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Approval | undefined {
return queryOne<Approval>("SELECT * FROM approvals WHERE id = ?", [id]);
}
/** Create */
create(input: CreateApprovalInput): Approval {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "entity_type", "entity_id", "applicant_id", "status", "form_data", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.entityType ?? null, input.entityId ?? null, input.applicantId ?? null, input.status ?? null, input.formData ?? null, now, now];
execute(`INSERT INTO approvals (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateApprovalInput): Approval | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.entityType !== undefined) { sets.push("entity_type = ?"); values.push(input.entityType); }
if (input.entityId !== undefined) { sets.push("entity_id = ?"); values.push(input.entityId); }
if (input.applicantId !== undefined) { sets.push("applicant_id = ?"); values.push(input.applicantId); }
if (input.status !== undefined) { sets.push("status = ?"); values.push(input.status); }
if (input.formData !== undefined) { sets.push("form_data = ?"); values.push(input.formData); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE approvals SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM approvals WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,54 @@
// Auto-generated Clients service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Clients, CreateClientsInput, UpdateClientsInput } from "../types/index.js";
export class ClientsService {
/** List all clients */
list(): Clients[] {
return queryAll<Clients>("SELECT * FROM clients ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Clients | undefined {
return queryOne<Clients>("SELECT * FROM clients WHERE id = ?", [id]);
}
/** Create */
create(input: CreateClientsInput): Clients {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "title", "description", "data", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.title ?? null, input.description ?? null, input.data ?? null, now, now];
execute(`INSERT INTO clients (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateClientsInput): Clients | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.title !== undefined) { sets.push("title = ?"); values.push(input.title); }
if (input.description !== undefined) { sets.push("description = ?"); values.push(input.description); }
if (input.data !== undefined) { sets.push("data = ?"); values.push(input.data); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE clients SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM clients WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,59 @@
// Auto-generated Contract service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Contract, CreateContractInput, UpdateContractInput } from "../types/index.js";
export class ContractService {
/** List all contracts */
list(): Contract[] {
return queryAll<Contract>("SELECT * FROM contracts ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Contract | undefined {
return queryOne<Contract>("SELECT * FROM contracts WHERE id = ?", [id]);
}
/** Create */
create(input: CreateContractInput): Contract {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "title", "party_a", "party_b", "amount", "signed_at", "expires_at", "status", "file_url", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.title ?? null, input.partyA ?? null, input.partyB ?? null, input.amount ?? null, input.signedAt ?? null, input.expiresAt ?? null, input.status ?? null, input.fileUrl ?? null, now, now];
execute(`INSERT INTO contracts (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateContractInput): Contract | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.title !== undefined) { sets.push("title = ?"); values.push(input.title); }
if (input.partyA !== undefined) { sets.push("party_a = ?"); values.push(input.partyA); }
if (input.partyB !== undefined) { sets.push("party_b = ?"); values.push(input.partyB); }
if (input.amount !== undefined) { sets.push("amount = ?"); values.push(input.amount); }
if (input.signedAt !== undefined) { sets.push("signed_at = ?"); values.push(input.signedAt); }
if (input.expiresAt !== undefined) { sets.push("expires_at = ?"); values.push(input.expiresAt); }
if (input.status !== undefined) { sets.push("status = ?"); values.push(input.status); }
if (input.fileUrl !== undefined) { sets.push("file_url = ?"); values.push(input.fileUrl); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE contracts SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM contracts WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,57 @@
// Auto-generated Customer service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Customer, CreateCustomerInput, UpdateCustomerInput } from "../types/index.js";
export class CustomerService {
/** List all customers */
list(): Customer[] {
return queryAll<Customer>("SELECT * FROM customers ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Customer | undefined {
return queryOne<Customer>("SELECT * FROM customers WHERE id = ?", [id]);
}
/** Create */
create(input: CreateCustomerInput): Customer {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "name", "email", "phone", "company", "source", "tags", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.name ?? null, input.email ?? null, input.phone ?? null, input.company ?? null, input.source ?? null, input.tags ?? null, now, now];
execute(`INSERT INTO customers (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateCustomerInput): Customer | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.name !== undefined) { sets.push("name = ?"); values.push(input.name); }
if (input.email !== undefined) { sets.push("email = ?"); values.push(input.email); }
if (input.phone !== undefined) { sets.push("phone = ?"); values.push(input.phone); }
if (input.company !== undefined) { sets.push("company = ?"); values.push(input.company); }
if (input.source !== undefined) { sets.push("source = ?"); values.push(input.source); }
if (input.tags !== undefined) { sets.push("tags = ?"); values.push(input.tags); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE customers SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM customers WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,54 @@
// Auto-generated Items service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Items, CreateItemsInput, UpdateItemsInput } from "../types/index.js";
export class ItemsService {
/** List all items */
list(): Items[] {
return queryAll<Items>("SELECT * FROM items ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Items | undefined {
return queryOne<Items>("SELECT * FROM items WHERE id = ?", [id]);
}
/** Create */
create(input: CreateItemsInput): Items {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "title", "description", "data", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.title ?? null, input.description ?? null, input.data ?? null, now, now];
execute(`INSERT INTO items (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateItemsInput): Items | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.title !== undefined) { sets.push("title = ?"); values.push(input.title); }
if (input.description !== undefined) { sets.push("description = ?"); values.push(input.description); }
if (input.data !== undefined) { sets.push("data = ?"); values.push(input.data); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE items SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM items WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,56 @@
// Auto-generated Reminder service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Reminder, CreateReminderInput, UpdateReminderInput } from "../types/index.js";
export class ReminderService {
/** List all reminders */
list(): Reminder[] {
return queryAll<Reminder>("SELECT * FROM reminders ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Reminder | undefined {
return queryOne<Reminder>("SELECT * FROM reminders WHERE id = ?", [id]);
}
/** Create */
create(input: CreateReminderInput): Reminder {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "entity_type", "entity_id", "remind_at", "message", "sent", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.entityType ?? null, input.entityId ?? null, input.remindAt ?? null, input.message ?? null, input.sent ?? null, now, now];
execute(`INSERT INTO reminders (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateReminderInput): Reminder | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.entityType !== undefined) { sets.push("entity_type = ?"); values.push(input.entityType); }
if (input.entityId !== undefined) { sets.push("entity_id = ?"); values.push(input.entityId); }
if (input.remindAt !== undefined) { sets.push("remind_at = ?"); values.push(input.remindAt); }
if (input.message !== undefined) { sets.push("message = ?"); values.push(input.message); }
if (input.sent !== undefined) { sets.push("sent = ?"); values.push(input.sent); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE reminders SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM reminders WHERE id = ?", [id]);
return result.changes > 0;
}
}
@@ -0,0 +1,58 @@
// Auto-generated Templates service
import { queryAll, queryOne, execute } from "../db/client.js";
import type { Templates, CreateTemplatesInput, UpdateTemplatesInput } from "../types/index.js";
export class TemplatesService {
/** List all templates */
list(): Templates[] {
return queryAll<Templates>("SELECT * FROM templates ORDER BY created_at DESC");
}
/** Get by ID */
getById(id: string): Templates | undefined {
return queryOne<Templates>("SELECT * FROM templates WHERE id = ?", [id]);
}
/** Create */
create(input: CreateTemplatesInput): Templates {
const id = crypto.randomUUID();
const now = new Date().toISOString();
const cols = ["id", "user_id", "title", "party_a", "party_b", "amount", "signed_at", "expires_at", "file_url", "created_at", "updated_at"];
const values = [id, input.userId ?? null, input.title ?? null, input.partyA ?? null, input.partyB ?? null, input.amount ?? null, input.signedAt ?? null, input.expiresAt ?? null, input.fileUrl ?? null, now, now];
execute(`INSERT INTO templates (${cols.join(", ")}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, values);
return this.getById(id)!;
}
/** Update */
update(id: string, input: UpdateTemplatesInput): Templates | undefined {
const existing = this.getById(id);
if (!existing) return undefined;
const sets: string[] = [];
const values: unknown[] = [];
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
if (input.title !== undefined) { sets.push("title = ?"); values.push(input.title); }
if (input.partyA !== undefined) { sets.push("party_a = ?"); values.push(input.partyA); }
if (input.partyB !== undefined) { sets.push("party_b = ?"); values.push(input.partyB); }
if (input.amount !== undefined) { sets.push("amount = ?"); values.push(input.amount); }
if (input.signedAt !== undefined) { sets.push("signed_at = ?"); values.push(input.signedAt); }
if (input.expiresAt !== undefined) { sets.push("expires_at = ?"); values.push(input.expiresAt); }
if (input.fileUrl !== undefined) { sets.push("file_url = ?"); values.push(input.fileUrl); }
if (sets.length === 0) return existing;
sets.push("updated_at = ?");
values.push(new Date().toISOString());
values.push(id);
execute(`UPDATE templates SET ${sets.join(", ")} WHERE id = ?`, values);
return this.getById(id)!;
}
/** Delete */
delete(id: string): boolean {
const result = execute("DELETE FROM templates WHERE id = ?", [id]);
return result.changes > 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
// Augment Fastify request with JWT user
import type { JwtPayload } from "./index.js";
declare module "fastify" {
interface FastifyRequest {
user?: JwtPayload;
}
}
@@ -0,0 +1,205 @@
// Import and re-export shared entity types
import type {
User,
Contract,
Approval,
Customer,
Reminder,
Items,
Clients,
Templates,
ApiResponse,
PaginatedResponse,
ErrorResponse,
} from "@shared/types";
export type {
User,
Contract,
Approval,
Customer,
Reminder,
Items,
Clients,
Templates,
ApiResponse,
PaginatedResponse,
ErrorResponse,
};
// Auto-generated types (from Model Contract)
export interface CreateUserInput {
username: string;
passwordHash: string;
nickname?: string;
role?: string;
phone?: string;
avatarUrl?: string;
}
export interface CreateContractInput {
userId: string;
title: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
status?: string;
fileUrl?: string;
}
export interface CreateApprovalInput {
userId: string;
entityType: string;
entityId?: string;
applicantId: string;
status?: string;
formData?: Record<string, unknown>;
}
export interface CreateCustomerInput {
userId: string;
name: string;
email?: string;
phone?: string;
company?: string;
source?: string;
tags?: Record<string, unknown>;
}
export interface CreateReminderInput {
userId: string;
entityType: string;
entityId?: string;
remindAt: string;
message?: string;
sent?: boolean;
}
export interface CreateItemsInput {
userId?: string;
title: string;
description?: string;
data?: Record<string, unknown>;
}
export interface CreateClientsInput {
userId?: string;
title: string;
description?: string;
data?: Record<string, unknown>;
}
export interface CreateTemplatesInput {
userId?: string;
title: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
fileUrl?: string;
}
export interface UpdateUserInput {
username?: string;
passwordHash?: string;
nickname?: string;
role?: string;
phone?: string;
avatarUrl?: string;
}
export interface UpdateContractInput {
userId?: string;
title?: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
status?: string;
fileUrl?: string;
}
export interface UpdateApprovalInput {
userId?: string;
entityType?: string;
entityId?: string;
applicantId?: string;
status?: string;
formData?: Record<string, unknown>;
}
export interface UpdateCustomerInput {
userId?: string;
name?: string;
email?: string;
phone?: string;
company?: string;
source?: string;
tags?: Record<string, unknown>;
}
export interface UpdateReminderInput {
userId?: string;
entityType?: string;
entityId?: string;
remindAt?: string;
message?: string;
sent?: boolean;
}
export interface UpdateItemsInput {
userId?: string;
title?: string;
description?: string;
data?: Record<string, unknown>;
}
export interface UpdateClientsInput {
userId?: string;
title?: string;
description?: string;
data?: Record<string, unknown>;
}
export interface UpdateTemplatesInput {
userId?: string;
title?: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
fileUrl?: string;
}
// ─── Auth ───────────────────────────────────────────
export interface LoginInput {
username: string;
password: string;
}
export interface RegisterInput {
username: string;
password: string;
nickname?: string;
}
export interface AuthResponse {
token: string;
user: User;
}
// ─── API ────────────────────────────────────────────
// ─── JWT ────────────────────────────────────────────
export interface JwtPayload {
userId: string;
username: string;
role: string;
iat?: number;
exp?: number;
}
+27
View File
@@ -0,0 +1,27 @@
// Type declarations for sql.js (no native types package available)
declare module "sql.js" {
export interface Database {
run(sql: string, params?: BindParams): void;
exec(sql: string): void;
prepare(sql: string): Statement;
export(): Uint8Array;
close(): void;
getRowsModified(): number;
}
export interface Statement {
bind(params?: BindParams): boolean;
step(): boolean;
getAsObject<T = Record<string, unknown>>(): T;
getColumnNames(): string[];
free(): boolean;
}
export type BindParams = unknown[] | Record<string, unknown>;
export interface SqlJsStatic {
Database: new (data?: ArrayLike<number>) => Database;
}
export default function initSqlJs(config?: Record<string, unknown>): Promise<SqlJsStatic>;
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"src/__tests__"
]
}
@@ -0,0 +1,35 @@
"use client";
import { Card } from "@/components/ui/Card";
const approvals = [
{ id: "a1", type: "请假", applicant: "张三", status: "pending", date: "2026-06-05" },
{ id: "a2", type: "报销", applicant: "李四", status: "approved", date: "2026-06-04" },
{ id: "a3", type: "加班", applicant: "王五", status: "rejected", date: "2026-06-03" },
];
const statusMap: Record<string, { label: string; color: string }> = {
pending: { label: "待审批", color: "bg-yellow-100 text-yellow-700" },
approved: { label: "已通过", color: "bg-green-100 text-green-700" },
rejected: { label: "已驳回", color: "bg-red-100 text-red-700" },
};
export default function ApprovalPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800"></h1>
{approvals.map(a => {
const s = statusMap[a.status];
return (
<Card key={a.id}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{a.type} {a.applicant}</p>
<p className="text-xs text-gray-400 mt-1">{a.date}</p>
</div>
<span className={`text-xs px-2 py-1 rounded ${s.color}`}>{s.label}</span>
</div>
</Card>
);
})}
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
export default function PagePage() {
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{<Card>
<p className="text-gray-500 text-sm"> </p>
<p className="text-xs text-gray-400 mt-1">: /clients</p>
</Card>}
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { useContracts } from "@/hooks/useContracts";
export default function PagePage() {
const { data, loading, error, refetch } = useContracts();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
import { useItems } from "@/hooks/useItems";
export default function PagePage() {
const { data, loading, error, refetch } = useItems();
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : error ? (
<Card className="text-center py-8">
<p className="text-red-500 text-sm">{error}</p>
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2"></Button>
</Card>
) : data.length === 0 ? (
<EmptyState
icon="📭"
title="暂无数据"
description="还没有任何记录,点击上方按钮开始"
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>}
/>
) : (
<div className="space-y-3">
{data.map((item: any) => (
<Card key={item.id}>
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/layout/Sidebar";
import { BottomNav } from "@/components/layout/BottomNav";
export const metadata: Metadata = {
title: "OAFlow",
description: "一款支持审批流、考勤管理、部门协作的企业办公自动化系统。",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body className="bg-gray-50 min-h-screen">
<div className="flex">
{/* Desktop Sidebar */}
<Sidebar items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"合同创建","href":"/contracts","icon":"📋"},{"name":"审批","href":"/approvals","icon":"📅"},{"name":"签署","href":"/items","icon":"✏️"},{"name":"归档","href":"/items","icon":"📸"}] } projectName="OAFlow" />
{/* Main Content */}
<main className="flex-1 lg:pl-64 pb-20 lg:pb-0">
<div className="max-w-2xl mx-auto px-4 py-6">
{children}
</div>
</main>
</div>
{/* Mobile Bottom Nav */}
<BottomNav items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"合同创建","href":"/contracts","icon":"📋"},{"name":"审批","href":"/approvals","icon":"📅"},{"name":"签署","href":"/items","icon":"✏️"}] } />
</body>
</html>
);
}
@@ -0,0 +1,50 @@
import Link from "next/link";
export default function HomePage() {
return (
<div className="space-y-6">
{/* Hero Section */}
<section className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white">
<h1 className="text-2xl font-bold">OAFlow</h1>
<p className="mt-2 text-blue-100 text-sm"></p>
</section>
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3"></h2>
<div className="grid grid-cols-2 gap-3">
<Link key="0" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📋</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="1" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📅</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="2" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📝</span>
<span className="text-sm text-gray-600"></span>
</Link>
<Link key="3" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
<span className="text-2xl block mb-1">📸</span>
<span className="text-sm text-gray-600"></span>
</Link>
</div>
</section>
{/* Recent Activity / Stats */}
<section>
<h2 className="text-lg font-semibold text-gray-800 mb-3"></h2>
<div className="bg-white rounded-xl border border-gray-100 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">使 OAFlow</p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
<span className="text-3xl">👋</span>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
export default function PagePage() {
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{<Card>
<p className="text-gray-500 text-sm"> </p>
<p className="text-xs text-gray-400 mt-1">: /reminders</p>
</Card>}
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { EmptyState } from "@/components/ui/EmptyState";
export default function PagePage() {
const [open, setOpen] = useState(false);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
<Button onClick={() => setOpen(true)} variant="primary" size="sm"></Button>
</div>
{<Card>
<p className="text-gray-500 text-sm"> </p>
<p className="text-xs text-gray-400 mt-1">: /templates</p>
</Card>}
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
export function BottomNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
if (!items.length) return null;
return (
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 z-40">
<div className="flex items-center justify-around h-16">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-0.5 px-3 py-1 ${
active ? "text-blue-600" : "text-gray-400"
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="text-[10px]">{item.name}</span>
</Link>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
name: string;
href: string;
icon: string;
}
interface SidebarProps {
items: NavItem[];
projectName: string;
}
export function Sidebar({ items, projectName }: SidebarProps) {
const pathname = usePathname();
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-white border-r border-gray-100">
<div className="px-6 py-5 border-b border-gray-50">
<h1 className="text-lg font-bold text-gray-800">{projectName}</h1>
</div>
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{items.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
active ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-600 hover:bg-gray-50"
}`}
>
<span className="text-lg">{item.icon}</span>
<span>{item.name}</span>
</Link>
);
})}
</nav>
</aside>
);
}
@@ -0,0 +1,31 @@
"use client";
import { type ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
export function Button({ variant = "primary", size = "md", loading, children, className = "", disabled, ...props }: ButtonProps) {
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
};
const sizes = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
return (
<button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} disabled={disabled || loading} {...props}>
{loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
{children}
</button>
);
}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
}
export function Card({ children, className = "", onClick }: CardProps) {
return (
<div
className={`bg-white rounded-xl shadow-sm border border-gray-100 p-4 ${onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""} ${className}`}
onClick={onClick}
>
{children}
</div>
);
}
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-5xl mb-4">{icon}</span>
<h3 className="text-lg font-medium text-gray-700 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{action}
</div>
);
}
@@ -0,0 +1,22 @@
"use client";
import { type InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = "", id, ...props }: InputProps) {
return (
<div className="w-full">
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<input
id={id}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${error ? "border-red-300" : "border-gray-300"} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect, type ReactNode } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for approvals
type Approval = Record<string, unknown>;
/**
* Fetch and manage approvals data.
*/
export function useApprovals() {
const [data, setData] = useState<Approval[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/approvals`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for auth
type Auth = Record<string, unknown>;
/**
* Fetch and manage auth data.
*/
export function useAuth() {
const [data, setData] = useState<Auth[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/auth`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for contracts
type Contract = Record<string, unknown>;
/**
* Fetch and manage contracts data.
*/
export function useContracts() {
const [data, setData] = useState<Contract[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/contracts`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
@@ -0,0 +1,14 @@
"use client";
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
@@ -0,0 +1,33 @@
"use client";
import { useState, useCallback } from "react";
interface UseFormOptions<T> {
initialValues: T;
onSubmit: (values: T) => Promise<void>;
}
export function useForm<T extends Record<string, unknown>>({ initialValues, onSubmit }: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
setError(null);
await onSubmit(values);
} catch (err) {
setError(err instanceof Error ? err.message : "Submit failed");
} finally {
setSubmitting(false);
}
}, [values, onSubmit]);
return { values, setField, submitting, error, handleSubmit };
}
@@ -0,0 +1,35 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Generic data type for items
type Item = Record<string, unknown>;
/**
* Fetch and manage items data.
*/
export function useItems() {
const [data, setData] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/items`);
const json = await res.json();
setData(Array.isArray(json) ? json : json?.data || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
@@ -0,0 +1,27 @@
{
"name": "oaflow-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@shared/types": "*",
"@shared/config": "*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.6.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,49 @@
// Auto-generated API client for OAFlow
import { API_BASE_URL, API_PREFIX } from "@shared/config";
const API_BASE = `${API_BASE_URL}${API_PREFIX}`;
interface RequestOptions extends RequestInit {
params?: Record<string, string | number>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => searchParams.set(k, String(v)));
url += `?${searchParams.toString()}`;
}
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `API Error: ${res.status}`);
}
return res.json();
}
export const api = {
get: <T>(url: string, params?: Record<string, string | number>) =>
request<T>(url, { method: "GET", params }),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "POST", body: JSON.stringify(data) }),
put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(url: string) =>
request<T>(url, { method: "DELETE" }),
};
@@ -0,0 +1,10 @@
// Auto-generated approvals service
import { api } from "./api";
export function get() {
return api.get("/api/approvals");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/approvals", data);
}
@@ -0,0 +1,10 @@
// Auto-generated auth service
import { api } from "./api";
export function postlogin(data: Record<string, unknown>) {
return api.post("/api/auth", data);
}
export function getme() {
return api.get("/api/auth");
}
@@ -0,0 +1,10 @@
// Auto-generated contracts service
import { api } from "./api";
export function get() {
return api.get("/api/contracts");
}
export function post(data: Record<string, unknown>) {
return api.post("/api/contracts", data);
}
@@ -0,0 +1,22 @@
// Auto-generated items service
import { api } from "./api";
export function getItems() {
return api.get("/api/items");
}
export function createItem(data: Record<string, unknown>) {
return api.post("/api/items", data);
}
export function getItem(id: string) {
return api.get(`/api/items/${id}`);
}
export function updateItem(id: string, data: Record<string, unknown>) {
return api.put(`/api/items/${id}`, data);
}
export function deleteItem(id: string) {
return api.delete(`/api/items/${id}`);
}
@@ -0,0 +1,10 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx,mdx}", "./services/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
@@ -0,0 +1,121 @@
// Auto-generated types for OAFlow
// ─── Base ───────────────────────────────────────────
// ─── Base ───────────────────────────────────────────
export interface User {
id: string;
phone: string;
nickname: string;
avatarUrl: string;
createdAt: string;
updatedAt: string;
}
export interface Contract {
id?: string;
userId: string;
title: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
status?: string;
fileUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Approval {
id?: string;
userId: string;
entityType: string;
entityId?: string;
applicantId: string;
status?: string;
formData?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Customer {
id?: string;
userId: string;
name: string;
email?: string;
phone?: string;
company?: string;
source?: string;
tags?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Reminder {
id?: string;
userId: string;
entityType: string;
entityId?: string;
remindAt: string;
message?: string;
sent?: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface Items {
id: string;
userId?: string;
title: string;
description?: string;
status?: string;
data?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Clients {
id: string;
userId?: string;
title: string;
description?: string;
status?: string;
data?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Templates {
id: string;
userId?: string;
title: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
status?: string;
fileUrl?: string;
createdAt?: string;
updatedAt?: string;
}
// ─── API Responses ──────────────────────────────────
export interface ApiResponse<T> {
data: T;
message: string;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
total: number;
page: number;
pageSize: number;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "oaflow",
"version": "0.1.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
"dev:web": "npm run dev -w apps/web",
"dev:api": "npm run dev -w apps/api",
"build:web": "npm run build -w apps/web",
"build:api": "npm run build -w apps/api",
"test": "npm run test -w apps/api",
"lint": "npm run lint -w apps/web"
}
}
@@ -0,0 +1,7 @@
{
"name": "@shared/config",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
}
@@ -0,0 +1,16 @@
// Shared configuration for fullstack project
/** API base URL — reads from env or defaults */
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
/** API prefix for all endpoints */
export const API_PREFIX = "/api";
/** Full API URL */
export const API_URL = `${API_BASE_URL}${API_PREFIX}`;
/** Auth token storage key */
export const AUTH_TOKEN_KEY = "auth_token";
/** Default page size for paginated endpoints */
export const DEFAULT_PAGE_SIZE = 20;
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist"
},
"include": [
"src"
]
}
@@ -0,0 +1,7 @@
{
"name": "@shared/types",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
}
@@ -0,0 +1,123 @@
// Shared types for fullstack project
// Auto-generated — used by both apps/web and apps/api
// ─── Entities ────────────────────────────────────────
export interface User {
id?: string;
username: string;
passwordHash: string;
nickname?: string;
role?: string;
phone?: string;
avatarUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Contract {
id?: string;
userId: string;
title: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
status?: string;
fileUrl?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Approval {
id?: string;
userId: string;
entityType: string;
entityId?: string;
applicantId: string;
status?: string;
formData?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Customer {
id?: string;
userId: string;
name: string;
email?: string;
phone?: string;
company?: string;
source?: string;
tags?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Reminder {
id?: string;
userId: string;
entityType: string;
entityId?: string;
remindAt: string;
message?: string;
sent?: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface Items {
id: string;
userId?: string;
title: string;
description?: string;
status?: string;
data?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Clients {
id: string;
userId?: string;
title: string;
description?: string;
status?: string;
data?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface Templates {
id: string;
userId?: string;
title: string;
partyA?: string;
partyB?: string;
amount?: number;
signedAt?: string;
expiresAt?: string;
status?: string;
fileUrl?: string;
createdAt?: string;
updatedAt?: string;
}
// ─── API Response Wrappers ───────────────────────────
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
}
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist"
},
"include": [
"src"
]
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* Build script — builds both web and api.
* Usage: node scripts/build.mjs
*/
import { execSync } from "node:child_process";
const ROOT = new URL("..", import.meta.url).pathname;
function run(cmd, cwd) {
console.log(`\n🔨 ${cmd} (in ${cwd})`);
execSync(cmd, { cwd, stdio: "inherit" });
}
console.log("🏗️ Building fullstack project...\n");
try {
run("npm run build", `${ROOT}/apps/api`);
run("npm run build", `${ROOT}/apps/web`);
console.log("\n✅ Build complete!");
} catch (e) {
console.error("\n❌ Build failed:", e.message);
process.exit(1);
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Dev script — starts both web and api in parallel.
* Usage: node scripts/dev.mjs
*/
import { spawn } from "node:child_process";
function start(name, command, args, cwd) {
const child = spawn(command, args, {
cwd,
stdio: "inherit",
shell: true,
env: { ...process.env, FORCE_COLOR: "1" },
});
child.on("error", (err) => console.error(`[${name}] Failed: ${err.message}`));
child.on("exit", (code) => {
if (code !== 0 && code !== null) console.error(`[${name}] Exited with code ${code}`);
});
return child;
}
const ROOT = new URL("..", import.meta.url).pathname;
console.log("🚀 Starting fullstack dev servers...\n");
const api = start("api", "npm", ["run", "dev"], `${ROOT}/apps/api`);
const web = start("web", "npm", ["run", "dev"], `${ROOT}/apps/web`);
process.on("SIGINT", () => { api.kill(); web.kill(); process.exit(0); });
process.on("SIGTERM", () => { api.kill(); web.kill(); process.exit(0); });
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.next/
data/
.env
*.db
*.db-journal
*.db-wal
+125
View File
@@ -0,0 +1,125 @@
# MyProject — Fullstack Project
> 一款根据用户需求定制的应用。
## Architecture
```
apps/
├── web/ # Next.js 15 + TypeScript + Tailwind CSS
└── api/ # Fastify 5 + TypeScript + SQLite (sql.js)
packages/
├── shared-types/ # @shared/types — shared TypeScript interfaces
└── shared-config/ # @shared/config — shared configuration
```
## Quick Start
```bash
# Install all dependencies (root + workspaces)
npm install
# Start both frontend and backend in dev mode
npm run dev
# Or start individually
npm run dev:web # http://localhost:3000
npm run dev:api # http://localhost:3001
```
## Build
```bash
# Build everything
npm run build
# Or individually
npm run build:web
npm run build:api
```
## Testing
```bash
# Run API tests
npm test
```
## API Endpoints
### Auth
- `POST /api/auth/register`
- `POST /api/auth/login`
- `GET /api/auth/me`
### Resources
#### users
- `GET /api/users` — List
- `GET /api/users/:id` — Get
- `POST /api/users` — Create
- `PUT /api/users/:id` — Update
- `DELETE /api/users/:id` — Delete
#### contracts
- `GET /api/contracts` — List
- `GET /api/contracts/:id` — Get
- `POST /api/contracts` — Create
- `PUT /api/contracts/:id` — Update
- `DELETE /api/contracts/:id` — Delete
#### approvals
- `GET /api/approvals` — List
- `GET /api/approvals/:id` — Get
- `POST /api/approvals` — Create
- `PUT /api/approvals/:id` — Update
- `DELETE /api/approvals/:id` — Delete
#### customers
- `GET /api/customers` — List
- `GET /api/customers/:id` — Get
- `POST /api/customers` — Create
- `PUT /api/customers/:id` — Update
- `DELETE /api/customers/:id` — Delete
#### reminders
- `GET /api/reminders` — List
- `GET /api/reminders/:id` — Get
- `POST /api/reminders` — Create
- `PUT /api/reminders/:id` — Update
- `DELETE /api/reminders/:id` — Delete
#### equipment
- `GET /api/equipment` — List
- `GET /api/equipment/:id` — Get
- `POST /api/equipment` — Create
- `PUT /api/equipment/:id` — Update
- `DELETE /api/equipment/:id` — Delete
#### inspection_plans
- `GET /api/inspection_plans` — List
- `GET /api/inspection_plans/:id` — Get
- `POST /api/inspection_plans` — Create
- `PUT /api/inspection_plans/:id` — Update
- `DELETE /api/inspection_plans/:id` — Delete
#### tasks
- `GET /api/tasks` — List
- `GET /api/tasks/:id` — Get
- `POST /api/tasks` — Create
- `PUT /api/tasks/:id` — Update
- `DELETE /api/tasks/:id` — Delete
#### items
- `GET /api/items` — List
- `GET /api/items/:id` — Get
- `POST /api/items` — Create
- `PUT /api/items/:id` — Update
- `DELETE /api/items/:id` — Delete
### System
- `GET /api/health` — Health check
## Environment Variables
See `.env.example` for all available configuration.
@@ -0,0 +1,7 @@
node_modules/
dist/
data/
.env
*.db
*.db-journal
*.db-wal
@@ -0,0 +1,118 @@
# MyProject — Backend API
> 一款根据用户需求定制的应用。
## Tech Stack
- **Runtime**: Node.js
- **Framework**: Fastify 5
- **Language**: TypeScript
- **Database**: SQLite (better-sqlite3)
- **Auth**: JWT + bcrypt
## Getting Started
```bash
# Install dependencies
npm install
# Development (hot reload)
npm run dev
# Build
npm run build
# Production start
npm run start
# Run tests
npm test
```
## Project Structure
```
src/
├── index.ts # Server entry point
├── db/
│ ├── schema.ts # SQLite schema
│ └── client.ts # Database client
├── routes/
│ ├── auth.ts # Auth routes (register/login/me)
│ └── *.ts # CRUD routes
├── services/
│ └── *.ts # Business logic
├── middleware/
│ └── auth.ts # JWT middleware
├── types/
│ └── index.ts # TypeScript types
└── __tests__/
└── *.test.ts # Tests
```
## API Endpoints
### Auth
- `POST /api/auth/register` — Register
- `POST /api/auth/login` — Login
- `GET /api/auth/me` — Current user (auth required)
### Resources
#### contracts
- `GET /api/contracts` — List all
- `GET /api/contracts/:id` — Get by ID
- `POST /api/contracts` — Create
- `PUT /api/contracts/:id` — Update
- `DELETE /api/contracts/:id` — Delete
#### approvals
- `GET /api/approvals` — List all
- `GET /api/approvals/:id` — Get by ID
- `POST /api/approvals` — Create
- `PUT /api/approvals/:id` — Update
- `DELETE /api/approvals/:id` — Delete
#### customers
- `GET /api/customers` — List all
- `GET /api/customers/:id` — Get by ID
- `POST /api/customers` — Create
- `PUT /api/customers/:id` — Update
- `DELETE /api/customers/:id` — Delete
#### reminders
- `GET /api/reminders` — List all
- `GET /api/reminders/:id` — Get by ID
- `POST /api/reminders` — Create
- `PUT /api/reminders/:id` — Update
- `DELETE /api/reminders/:id` — Delete
#### equipment
- `GET /api/equipment` — List all
- `GET /api/equipment/:id` — Get by ID
- `POST /api/equipment` — Create
- `PUT /api/equipment/:id` — Update
- `DELETE /api/equipment/:id` — Delete
#### inspection_plans
- `GET /api/inspection_plans` — List all
- `GET /api/inspection_plans/:id` — Get by ID
- `POST /api/inspection_plans` — Create
- `PUT /api/inspection_plans/:id` — Update
- `DELETE /api/inspection_plans/:id` — Delete
#### tasks
- `GET /api/tasks` — List all
- `GET /api/tasks/:id` — Get by ID
- `POST /api/tasks` — Create
- `PUT /api/tasks/:id` — Update
- `DELETE /api/tasks/:id` — Delete
#### items
- `GET /api/items` — List all
- `GET /api/items/:id` — Get by ID
- `POST /api/items` — Create
- `PUT /api/items/:id` — Update
- `DELETE /api/items/:id` — Delete
### System
- `GET /api/health` — Health check
@@ -0,0 +1,29 @@
{
"name": "myproject-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "node --import tsx --test src/__tests__/*.test.ts",
"test:watch": "node --import tsx --test --watch src/__tests__/*.test.ts"
},
"dependencies": {
"fastify": "^5.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/jwt": "^9.0.0",
"sql.js": "^1.12.0",
"bcrypt": "^5.1.0",
"@shared/types": "*",
"@shared/config": "*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/bcrypt": "^5.0.0",
"typescript": "^5.6.0",
"tsx": "^4.0.0",
"pino-pretty": "^11.0.0"
}
}
@@ -0,0 +1,121 @@
// Approval CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"entityType": "sample-entitytype",
"entityId": "sample-entityid",
"applicantId": regRes.json().user.id,
"status": "sample-status",
"formData": "sample-formdata"
};
});
after(async () => {
await app.close();
});
describe("GET /api/approvals", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/approvals",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/approvals", () => {
it("creates a approval", async () => {
const res = await app.inject({
method: "POST",
url: "/api/approvals",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/approvals/:id", () => {
it("returns the created approval", async () => {
const res = await app.inject({
method: "GET",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/approvals/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/approvals/:id", () => {
it("updates the approval", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/approvals/:id", () => {
it("deletes the approval", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/approvals/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,96 @@
// Auth routes test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
describe("POST /api/auth/register", () => {
it("registers a new user", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.token);
assert.equal(body.user.username, "testuser");
token = body.token;
});
it("rejects duplicate username", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 409);
});
it("rejects short password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: "user2", password: "123" },
});
assert.equal(res.statusCode, 400);
});
});
describe("POST /api/auth/login", () => {
it("logs in with correct credentials", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "testuser", password: "password123" },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(body.token);
token = body.token;
});
it("rejects wrong password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "testuser", password: "wrongpassword" },
});
assert.equal(res.statusCode, 401);
});
});
describe("GET /api/auth/me", () => {
it("returns current user with valid token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/auth/me",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.username, "testuser");
});
it("rejects without token", async () => {
const res = await app.inject({
method: "GET",
url: "/api/auth/me",
});
assert.equal(res.statusCode, 401);
});
});
@@ -0,0 +1,124 @@
// Contract CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"title": "sample-title",
"partyA": "sample-partya",
"partyB": "sample-partyb",
"amount": 1,
"signedAt": "sample-signedat",
"expiresAt": "sample-expiresat",
"status": "sample-status",
"fileUrl": "sample-fileurl"
};
});
after(async () => {
await app.close();
});
describe("GET /api/contracts", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/contracts",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/contracts", () => {
it("creates a contract", async () => {
const res = await app.inject({
method: "POST",
url: "/api/contracts",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/contracts/:id", () => {
it("returns the created contract", async () => {
const res = await app.inject({
method: "GET",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/contracts/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/contracts/:id", () => {
it("updates the contract", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/contracts/:id", () => {
it("deletes the contract", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/contracts/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,122 @@
// Customer CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"name": "sample-name",
"email": "sample-email",
"phone": "sample-phone",
"company": "sample-company",
"source": "sample-source",
"tags": "sample-tags"
};
});
after(async () => {
await app.close();
});
describe("GET /api/customers", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/customers",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/customers", () => {
it("creates a customer", async () => {
const res = await app.inject({
method: "POST",
url: "/api/customers",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/customers/:id", () => {
it("returns the created customer", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/customers/:id", () => {
it("updates the customer", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/customers/:id", () => {
it("deletes the customer", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/customers/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,121 @@
// Equipment CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"name": "sample-name",
"code": "sample-code",
"location": "sample-location",
"model": "sample-model",
"installedAt": "sample-installedat"
};
});
after(async () => {
await app.close();
});
describe("GET /api/equipment", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/equipment",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/equipment", () => {
it("creates a equipment", async () => {
const res = await app.inject({
method: "POST",
url: "/api/equipment",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/equipment/:id", () => {
it("returns the created equipment", async () => {
const res = await app.inject({
method: "GET",
url: `/api/equipment/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/equipment/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/equipment/:id", () => {
it("updates the equipment", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/equipment/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/equipment/:id", () => {
it("deletes the equipment", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/equipment/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/equipment/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,128 @@
// InspectionPlans CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Create a real equipment first so FK reference is valid
const eqRes = await app.inject({
method: "POST",
url: "/api/equipment",
headers: { authorization: `Bearer ${token}` },
payload: { name: "test-eq", code: `EQ_${Date.now()}`, location: "lab" },
});
const eqId = eqRes.json().data?.id || eqRes.json().id;
payload = {
userId: regRes.json().user.id,
name: "sample-name",
frequency: "sample-frequency",
equipmentId: eqId,
nextRun: "sample-nextrun",
};
});
after(async () => {
await app.close();
});
describe("GET /api/inspection_plans", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/inspection_plans",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/inspection_plans", () => {
it("creates a inspection_plan", async () => {
const res = await app.inject({
method: "POST",
url: "/api/inspection_plans",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/inspection_plans/:id", () => {
it("returns the created inspection_plan", async () => {
const res = await app.inject({
method: "GET",
url: `/api/inspection_plans/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/inspection_plans/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/inspection_plans/:id", () => {
it("updates the inspection_plan", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/inspection_plans/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/inspection_plans/:id", () => {
it("deletes the inspection_plan", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/inspection_plans/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/inspection_plans/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,119 @@
// Items CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"title": "sample-title",
"description": "sample-description",
"data": "sample-data"
};
});
after(async () => {
await app.close();
});
describe("GET /api/items", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/items",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/items", () => {
it("creates a item", async () => {
const res = await app.inject({
method: "POST",
url: "/api/items",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/items/:id", () => {
it("returns the created item", async () => {
const res = await app.inject({
method: "GET",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/items/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/items/:id", () => {
it("updates the item", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/items/:id", () => {
it("deletes the item", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/items/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,121 @@
// Reminder CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"entityType": "sample-entitytype",
"entityId": "sample-entityid",
"remindAt": "sample-remindat",
"message": "sample-message",
"sent": true
};
});
after(async () => {
await app.close();
});
describe("GET /api/reminders", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/reminders",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/reminders", () => {
it("creates a reminder", async () => {
const res = await app.inject({
method: "POST",
url: "/api/reminders",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/reminders/:id", () => {
it("returns the created reminder", async () => {
const res = await app.inject({
method: "GET",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/reminders/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/reminders/:id", () => {
it("updates the reminder", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/reminders/:id", () => {
it("deletes the reminder", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/reminders/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,121 @@
// Tasks CRUD test
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../index.js";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
let token: string;
let createdId: string;
let payload: Record<string, unknown>;
before(async () => {
process.env.JWT_SECRET = "test-secret";
process.env.DATABASE_URL = ":memory:";
app = await buildApp();
await app.ready();
// Register and login to get a token
const regRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: { username: `test_${Date.now()}`, password: "password123" },
});
token = regRes.json().token;
// Resolve user FK references with the registered user's real ID
payload = {
"userId": regRes.json().user.id,
"boardId": "sample-boardid",
"title": "sample-title",
"description": "sample-description",
"dueDate": "sample-duedate",
"assigneeId": "sample-assigneeid"
};
});
after(async () => {
await app.close();
});
describe("GET /api/tasks", () => {
it("returns empty list", async () => {
const res = await app.inject({
method: "GET",
url: "/api/tasks",
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.ok(Array.isArray(body.data));
});
});
describe("POST /api/tasks", () => {
it("creates a task", async () => {
const res = await app.inject({
method: "POST",
url: "/api/tasks",
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 201);
const body = res.json();
assert.ok(body.data.id);
createdId = body.data.id;
});
});
describe("GET /api/tasks/:id", () => {
it("returns the created task", async () => {
const res = await app.inject({
method: "GET",
url: `/api/tasks/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.data.id, createdId);
});
it("returns 404 for nonexistent id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/tasks/nonexistent`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
describe("PUT /api/tasks/:id", () => {
it("updates the task", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/tasks/${createdId}`,
headers: { authorization: `Bearer ${token}` },
payload,
});
assert.equal(res.statusCode, 200);
});
});
describe("DELETE /api/tasks/:id", () => {
it("deletes the task", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/tasks/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 204);
});
it("returns 404 after delete", async () => {
const res = await app.inject({
method: "GET",
url: `/api/tasks/${createdId}`,
headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 404);
});
});
@@ -0,0 +1,93 @@
// SQLite database client (sql.js — pure WASM, no native deps)
import initSqlJs, { type Database, type BindParams } from "sql.js";
import { createTables } from "./schema.js";
let db: Database | null = null;
let initPromise: Promise<Database> | null = null;
/** Initialize the database (call once at startup). */
export async function initDb(dbPath?: string): Promise<Database> {
if (db) return db;
if (initPromise) return initPromise;
initPromise = (async () => {
const SQL = await initSqlJs();
const path = dbPath || process.env.DATABASE_URL || ":memory:";
// Try to load existing database from file
let buffer: ArrayLike<number> | undefined;
if (path !== ":memory:") {
try {
const fs = await import("node:fs/promises");
const data = await fs.readFile(path);
buffer = new Uint8Array(data);
} catch {
// File doesn't exist yet — start fresh
}
}
db = new SQL.Database(buffer);
db.run("PRAGMA foreign_keys = ON");
createTables(db);
return db;
})();
return initPromise;
}
/** Get the initialized database (must call initDb first). */
export function getDb(): Database {
if (!db) throw new Error("Database not initialized. Call initDb() first.");
return db;
}
/** Save database to disk. */
export async function saveDb(dbPath?: string): Promise<void> {
if (!db) return;
const path = dbPath || process.env.DATABASE_URL || "./data/app.db";
if (path === ":memory:") return;
const fs = await import("node:fs/promises");
const { dirname } = await import("node:path");
await fs.mkdir(dirname(path), { recursive: true });
const data = db.export();
await fs.writeFile(path, Buffer.from(data));
}
export async function closeDb(): Promise<void> {
if (db) {
await saveDb();
db.close();
db = null;
initPromise = null;
}
}
// Helper: run a query and return all rows as objects
export function queryAll<T = Record<string, unknown>>(sql: string, params: BindParams = []): T[] {
const d = getDb();
const stmt = d.prepare(sql);
if (params) stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
const row = stmt.getAsObject();
results.push(row as unknown as T);
}
stmt.free();
return results;
}
// Helper: run a query and return the first row
export function queryOne<T = Record<string, unknown>>(sql: string, params: BindParams = []): T | undefined {
const rows = queryAll<T>(sql, params);
return rows[0];
}
// Helper: run a mutation and return { changes, lastInsertRowid }
export function execute(sql: string, params: BindParams = []): { changes: number; lastInsertRowid: number } {
const d = getDb();
d.run(sql, params);
return {
changes: d.getRowsModified(),
lastInsertRowid: 0,
};
}
@@ -0,0 +1,20 @@
// Auto-generated SQLite schema
import type { Database } from "sql.js";
export function createTables(db: Database): void {
const statements = [
"CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n username TEXT NOT NULL UNIQUE,\n password_hash TEXT NOT NULL,\n nickname TEXT,\n role TEXT DEFAULT 'user',\n phone TEXT,\n avatar_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS contracts (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n title TEXT NOT NULL,\n party_a TEXT,\n party_b TEXT,\n amount REAL,\n signed_at TEXT,\n expires_at TEXT,\n status TEXT DEFAULT 'draft',\n file_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS approvals (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n entity_type TEXT NOT NULL,\n entity_id TEXT,\n applicant_id TEXT NOT NULL REFERENCES users(id),\n status TEXT DEFAULT 'pending',\n form_data TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS customers (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n name TEXT NOT NULL,\n email TEXT,\n phone TEXT,\n company TEXT,\n source TEXT,\n tags TEXT DEFAULT '[]',\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS reminders (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT NOT NULL REFERENCES users(id),\n entity_type TEXT NOT NULL,\n entity_id TEXT,\n remind_at TEXT NOT NULL,\n message TEXT,\n sent INTEGER DEFAULT 'false',\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS equipment (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n name TEXT NOT NULL,\n code TEXT UNIQUE,\n location TEXT,\n model TEXT,\n installed_at TEXT,\n status TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS inspection_plans (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n name TEXT NOT NULL,\n frequency TEXT,\n equipment_id TEXT REFERENCES equipment(id),\n next_run TEXT,\n active INTEGER,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS tasks (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n board_id TEXT,\n title TEXT NOT NULL,\n description TEXT,\n status TEXT,\n priority TEXT,\n due_date TEXT,\n assignee_id TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
"CREATE TABLE IF NOT EXISTS items (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n user_id TEXT REFERENCES users(id),\n title TEXT NOT NULL,\n description TEXT,\n status TEXT,\n data TEXT,\n created_at TEXT,\n updated_at TEXT\n);"
];
for (const sql of statements) {
const trimmed = sql.trim();
if (trimmed) db.run(trimmed);
}
}
@@ -0,0 +1,79 @@
// MyProject — Fastify Backend Server
import Fastify from "fastify";
import cors from "@fastify/cors";
import fjwt from "@fastify/jwt";
import { initDb, closeDb } from "./db/client.js";
import { authRoutes } from "./routes/auth.js";
import { contractsRoutes } from "./routes/contracts.js";
import { approvalsRoutes } from "./routes/approvals.js";
import { customersRoutes } from "./routes/customers.js";
import { remindersRoutes } from "./routes/reminders.js";
import { equipmentRoutes } from "./routes/equipment.js";
import { inspection_plansRoutes } from "./routes/inspection_plans.js";
import { tasksRoutes } from "./routes/tasks.js";
import { itemsRoutes } from "./routes/items.js";
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-83a72a74";
export async function buildApp() {
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
},
});
// Init database
await initDb();
// Plugins
await app.register(cors, {
origin: process.env.CORS_ORIGIN || "*",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
});
await app.register(fjwt, { secret: JWT_SECRET });
// Routes
await app.register(authRoutes, { prefix: "/api/auth" });
await app.register(contractsRoutes, { prefix: "/api/contracts" });
await app.register(approvalsRoutes, { prefix: "/api/approvals" });
await app.register(customersRoutes, { prefix: "/api/customers" });
await app.register(remindersRoutes, { prefix: "/api/reminders" });
await app.register(equipmentRoutes, { prefix: "/api/equipment" });
await app.register(inspection_plansRoutes, { prefix: "/api/inspection_plans" });
await app.register(tasksRoutes, { prefix: "/api/tasks" });
await app.register(itemsRoutes, { prefix: "/api/items" });
// Health check
app.get("/api/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
// Graceful shutdown
app.addHook("onClose", async () => {
closeDb();
});
return app;
}
// Start server if called directly (not when imported by tests)
const port = parseInt(process.env.PORT || "3001", 10);
const host = process.env.HOST || "0.0.0.0";
async function main() {
const app = await buildApp();
try {
await app.listen({ port, host });
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
// Guard: only run when executed directly, not when imported
const isMain = process.argv[1] && (import.meta.url === `file://${process.argv[1]}` || import.meta.url.endsWith(process.argv[1]) || process.argv[1].endsWith("/src/index.ts") || process.argv[1].endsWith("/src/index.js"));
if (isMain) {
main();
}
@@ -0,0 +1,41 @@
// JWT Authentication Middleware
import type { FastifyRequest, FastifyReply } from "fastify";
import type { JwtPayload } from "../types/index.js";
/**
* Verify JWT token and attach user to request.
*/
export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
await request.jwtVerify();
} catch (err) {
reply.status(401).send({ error: "Unauthorized", message: "Invalid or expired token", statusCode: 401 });
}
}
/** Helper to get typed user from request (after authenticate). */
export function getUser(request: FastifyRequest): JwtPayload {
return request.user as unknown as JwtPayload;
}
/**
* Require admin role.
* Must be used after authenticate.
*/
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const user = request.user as unknown as JwtPayload | undefined;
if (!user || user.role !== "admin") {
reply.status(403).send({ error: "Forbidden", message: "Admin access required", statusCode: 403 });
}
}
/**
* Optional auth: attach user if token present, but don't fail if missing.
*/
export async function optionalAuth(request: FastifyRequest): Promise<void> {
try {
await request.jwtVerify();
} catch {
// No token or invalid — continue without user
}
}
@@ -0,0 +1,51 @@
// Auto-generated Approval routes
import type { FastifyInstance } from "fastify";
import { authenticate } from "../middleware/auth.js";
import { ApprovalService } from "../services/approval.js";
import type { CreateApprovalInput, UpdateApprovalInput } from "../types/index.js";
const service = new ApprovalService();
export async function approvalsRoutes(app: FastifyInstance): Promise<void> {
// All routes require authentication
app.addHook("onRequest", authenticate);
// GET /api/approvals — list
app.get("/", async (request, reply) => {
const items = service.list();
return { data: items };
});
// GET /api/approvals/:id — get by id
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
const item = service.getById(request.params.id);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Approval not found", statusCode: 404 });
}
return { data: item };
});
// POST /api/approvals — create
app.post<{ Body: CreateApprovalInput }>("/", async (request, reply) => {
const item = service.create(request.body);
return reply.status(201).send({ data: item });
});
// PUT /api/approvals/:id — update
app.put<{ Params: { id: string }; Body: UpdateApprovalInput }>("/:id", async (request, reply) => {
const item = service.update(request.params.id, request.body);
if (!item) {
return reply.status(404).send({ error: "Not Found", message: "Approval not found", statusCode: 404 });
}
return { data: item };
});
// DELETE /api/approvals/:id — delete
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
const deleted = service.delete(request.params.id);
if (!deleted) {
return reply.status(404).send({ error: "Not Found", message: "Approval not found", statusCode: 404 });
}
return reply.status(204).send();
});
}
@@ -0,0 +1,121 @@
// Authentication routes
import type { FastifyInstance } from "fastify";
import bcrypt from "bcrypt";
import { queryOne, execute } from "../db/client.js";
import { authenticate } from "../middleware/auth.js";
import type { User, RegisterInput, LoginInput, AuthResponse } from "../types/index.js";
const SALT_ROUNDS = 10;
export async function authRoutes(app: FastifyInstance): Promise<void> {
// POST /api/auth/register
app.post<{ Body: RegisterInput }>("/register", async (request, reply) => {
const { username, password, nickname } = request.body;
if (!username || !password) {
return reply.status(400).send({
error: "Bad Request",
message: "Username and password are required",
statusCode: 400,
});
}
if (password.length < 6) {
return reply.status(400).send({
error: "Bad Request",
message: "Password must be at least 6 characters",
statusCode: 400,
});
}
const existing = queryOne("SELECT id FROM users WHERE username = ?", [username]);
if (existing) {
return reply.status(409).send({
error: "Conflict",
message: "Username already exists",
statusCode: 409,
});
}
const id = crypto.randomUUID();
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const now = new Date().toISOString();
execute(
"INSERT INTO users (id, username, password_hash, nickname, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
[id, username, passwordHash, nickname || username, now, now]
);
const user = queryOne<User>(
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
[id]
);
if (!user) {
return reply.status(500).send({ error: "Internal Error", message: "Failed to create user", statusCode: 500 });
}
const token = app.jwt.sign({ userId: id, username, role: user.role });
return reply.status(201).send({ token, user } satisfies AuthResponse);
});
// POST /api/auth/login
app.post<{ Body: LoginInput }>("/login", async (request, reply) => {
const { username, password } = request.body;
if (!username || !password) {
return reply.status(400).send({
error: "Bad Request",
message: "Username and password are required",
statusCode: 400,
});
}
const user = queryOne<User & { password_hash: string }>(
"SELECT * FROM users WHERE username = ?",
[username]
);
if (!user) {
return reply.status(401).send({
error: "Unauthorized",
message: "Invalid username or password",
statusCode: 401,
});
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return reply.status(401).send({
error: "Unauthorized",
message: "Invalid username or password",
statusCode: 401,
});
}
const token = app.jwt.sign({ userId: user.id, username: user.username, role: user.role });
const { password_hash, ...safeUser } = user;
return { token, user: safeUser } satisfies AuthResponse;
});
// GET /api/auth/me — current user info
app.get("/me", { onRequest: [authenticate] }, async (request, reply) => {
const jwtUser = request.user as unknown as { userId: string };
const user = queryOne<User>(
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
[jwtUser.userId]
);
if (!user) {
return reply.status(404).send({
error: "Not Found",
message: "User not found",
statusCode: 404,
});
}
return { data: user };
});
}

Some files were not shown because too many files have changed in this diff Show More