diff --git a/internal-docs/doc-kanban-unification/design.md b/internal-docs/doc-kanban-unification/design.md index 850b8a8..9783a3c 100644 --- a/internal-docs/doc-kanban-unification/design.md +++ b/internal-docs/doc-kanban-unification/design.md @@ -90,7 +90,7 @@ due: 2026-07-01 # 可选 ## 5. 退役/暂缓 - **退役**:桌面 `KanbanBoard` 全屏模态、`kanban_local.rs`/`kanban.json`、`kanbanSync.ts`(本地优先看板改由 Markdown 卡片承载)。保留文件以防引用编译错误时再删,但从 App 接线移除。 -- **暂缓(后续 PRD)**:云端 `kanban_*` 表、Web `Kanban.tsx` 页面、看板的云端同步。本轮聚焦桌面本地体验(用户强调「本地」)。文档/卡片本就走现有文档同步链路。 +- ~~**暂缓(后续 PRD)**:云端 `kanban_*` 表、Web `Kanban.tsx` 页面、看板的云端同步。~~ **→ 已立项(2026-06-23)**:该"后续 PRD"即 **[`../kanban/unification-v2.md`](../kanban/unification-v2.md)**——决定**退役**云端 DB 看板,把其功能搬到文档型看板并云端本地互通。本轮聚焦桌面本地体验(用户强调「本地」)。文档/卡片本就走现有文档同步链路。 ## 6. 验收标准(Acceptance Criteria) diff --git a/internal-docs/kanban/design.md b/internal-docs/kanban/design.md index 4e2bdcb..1cacd56 100644 --- a/internal-docs/kanban/design.md +++ b/internal-docs/kanban/design.md @@ -1,5 +1,7 @@ # JType 看板(Kanban)技术设计文档 +> 🛑 **整体退役(2026-06-23)**:本文描述的**云端 DB 看板**(`kanban_*` 表 + `handlers/kanban/*` + `Kanban.tsx`)已决定**退役**,看板收敛到文档型(`.md` 卡片 + `.board` 视图)。当前真相源见 **[`unification-v2.md`](./unification-v2.md)**。本文仅作历史设计留存,不反映方向。 + 状态:CLOUD(第 2–5、8–11 节)已实现 v1。**LOCAL(第 6 节)与 Desktop↔Cloud 同步(第 7 节)已撤回**——`kanban_local.rs` 曾实现后在提交 `1576515` 中整体删除(−672 行),当前 `main` 不存在。第 6/7 节保留为历史设计意图,**不反映当前代码**。桌面看板现为纯文件式(`.board` + `.md` 卡片)经文档同步管线收敛——详见 [`next-features-design.md` §0](./next-features-design.md) 与 [`gaps-and-roadmap.md`](./gaps-and-roadmap.md)。 初始日期:2026-06-13 更新日期:2026-06-20(标注 §6/§7 已撤回) diff --git a/internal-docs/kanban/gaps-and-roadmap.md b/internal-docs/kanban/gaps-and-roadmap.md index d8168bb..f07eb3e 100644 --- a/internal-docs/kanban/gaps-and-roadmap.md +++ b/internal-docs/kanban/gaps-and-roadmap.md @@ -1,8 +1,11 @@ # JType 看板 — 缺口盘点与优先级路线图 +> 🛑 **部分推翻(2026-06-23)**:本文 §0 的"**DB 看板保留为次要/遗留**"口径已被推翻——决定**退役 DB 看板**,看板彻底收敛到文档型并云端本地互通。当前真相源见 **[`unification-v2.md`](./unification-v2.md)**。 +> 另:本文 2026-06-20 后已有变化——**B1 评论(`0016_kanban_comments`)、D2 Webhook(`0017_kanban_webhooks`)均已落地(落在 DB 看板上)**;§1 表格里它们的"待设计/待做"状态过时。下方路线图作为历史审计留存。 + 状态:现状审计 + 决策收敛(基于 `main` HEAD 实际代码 + 用户 2026-06-20 逐条决策) 初始日期:2026-06-20 -更新日期:2026-06-20 +更新日期:2026-06-23(加退役横幅 + 标注 B1/D2 已落地) > 配套文档: > - [`design.md`](./design.md) — v1 三环境设计。**第 6/7 节已过时**(依赖的 `kanban_local.rs` 已在提交 `1576515` 删除)。 @@ -13,7 +16,9 @@ ## 0. 一句话现状 -看板有**两套互不相通的系统**:云端 DB 看板(`Kanban.tsx` + `kanban_*` 表,成熟,但桌面不可达)与**文件看板**(`.board` + `.md` 卡片,用户实际在用,桌面↔云端经文档同步双向可见)。两者复用同一个 `BoardSurface`,故视图级功能对两套都生效。模型已定为 **markdown 卡片**;**DB 看板暂保留为次要/遗留**,新 per-card 功能以文件看板为真相源。 +看板有**两套互不相通的系统**:云端 DB 看板(`Kanban.tsx` + `kanban_*` 表,成熟,但桌面不可达)与**文件看板**(`.board` + `.md` 卡片,用户实际在用,桌面↔云端经文档同步双向可见)。两者复用同一个 `BoardSurface`,故视图级功能对两套都生效。模型已定为 **markdown 卡片**;~~DB 看板暂保留为次要/遗留~~ **→ 已改为退役 DB 看板(2026-06-23,见 [`unification-v2.md`](./unification-v2.md))**,新 per-card 功能以文件看板为真相源。 + +> 订正(2026-06-23):更准确说是**三个渲染面/两套数据层**——① 桌面文件看板、② Web 文件看板(`WebBoardView.tsx`,已与桌面双向同步)、③ Web 云端 DB 看板(`Kanban.tsx`,孤岛)。①②已互通,③待退役。 --- @@ -69,10 +74,12 @@ ## 3. 需要回写修订的过时文档(DOC 任务) -- [ ] [`design.md` 第 6 节「LOCAL — kanban_local.rs」](./design.md):文件已删除,整节失效,标注「已移除」或重写。 -- [ ] [`design.md` 第 7 节「同步数据流(Desktop)」](./design.md):依赖已删除的 `take_pending_ops`/`merge_remote_board`,标注实现已撤回。 -- [ ] `design.md` 头部状态行:从「尚未接通,为设计意图」升级为「曾实现后移除(提交 1576515)」。 -- [ ] [`shared/components/board/types.ts:23`](shared/components/board/types.ts#L23) 注释「web → localStorage」过时:Web 实际已改 `saveDocument` 写 `.board`(C3 设计时顺手修正)。 +> ✅ 2026-06-23:design.md 头部 + §6/§7 已加撤回/退役横幅;本文已加退役横幅。文档订正大部完成,余项随 v2 落地清理。 + +- [x] [`design.md` 第 6 节「LOCAL — kanban_local.rs」](./design.md):已加 ⚠️ 撤回标注 + v2 退役横幅。 +- [x] [`design.md` 第 7 节「同步数据流(Desktop)」](./design.md):已标注实现撤回。 +- [x] `design.md` 头部状态行:已更新为「曾实现后移除(提交 1576515)」+ v2 退役横幅。 +- [ ] [`shared/components/board/types.ts`](shared/components/board/types.ts) 注释「web → localStorage」:`WebBoardView` 已改 `saveDocument` 写 `.board`(随 v2 §5 核对修正)。 --- diff --git a/internal-docs/kanban/next-features-design.md b/internal-docs/kanban/next-features-design.md index 9bbe57c..3779bc6 100644 --- a/internal-docs/kanban/next-features-design.md +++ b/internal-docs/kanban/next-features-design.md @@ -1,5 +1,7 @@ # JType 看板 — 下一批功能设计(D1 依赖 / C3 日历 / D2 Webhook) +> ℹ️ **部分更新(2026-06-23)**:D1 依赖 / C3 日历已落地。**D2 Webhook 的基建设计(HMAC 签名 / 重试队列 / SSRF 防护 / 投递 worker)仍有效**,但**触发源已改**——不再挂在云端 DB 看板的 `kanban:*` 事件,而是**重接到文档保存路径**(`save_document`),作用域改用 board 文档 id。详见 **[`unification-v2.md`](./unification-v2.md) §2.5**。本文 D2 章节中"仅 DB 看板会发"的限定已不成立。 + 状态:**设计提案(仅设计,未实现,不含代码)** 初始日期:2026-06-20 来源:基于 [`gaps-and-roadmap.md`](./gaps-and-roadmap.md) 的用户决策收敛 —— 绿灯三项 D1 / C3 / D2,外加 A1 现状订正。 diff --git a/internal-docs/kanban/ticket-links.md b/internal-docs/kanban/ticket-links.md new file mode 100644 index 0000000..6e117c0 --- /dev/null +++ b/internal-docs/kanban/ticket-links.md @@ -0,0 +1,185 @@ +# JType 看板 — ticket link(`OCCSV-3371`) + +状态:设计已定稿(用户 2026-06-23 锁定关键决策),待实现 +初始日期:2026-06-23 +更新日期:2026-06-23 + +> **目标**:支持工单链接 `OCCSV-3371`,其中 `OCCSV` = 看板的 ticket 前缀(board key),`3371` = 该看板内的顺序卡号。链接在 workspace 上下文里解析(路由 `/workspaces/:id/tickets/:ticket`),不做 workspace-agnostic 的裸链接。 +> **配套**:本特性长在 [`unification-v2.md`](./unification-v2.md) 的文档型看板之上,复用其 §1.1 两条架构原则(`documents.id` 唯一键 + web 为主/desktop 为辅)。落地排在 v2 退役③、文件型看板坐稳之后。 + +--- + +## 0. 锁定决策(先读) + +| 决策 | 选择 | 影响 | +|---|---|---| +| 号存哪 | **只存云端索引**(不进 `.md` frontmatter) | 号是云端特性;离线补号无需改写文档 content,实现更干净 | +| 离线发号 | **A+C**:在线建卡实时发号;离线建卡先无号,首次同步补号 | 号单调连续、零碰撞、不可变 | +| 主次 | **web 为主、desktop 为辅** | 桌面离线看不到号 / 解不了 ticket 链接可接受;只读缓存为可选增强 | + +**核心后果**:ticket 号变成**云端能力**。`.md` 文件不带号 → git/桌面离线看不到 `OCCSV-3371`。但 **board key 在 `.board` 里(会同步)**,桌面能识别 `OCCSV-` 是合法前缀、能 linkify,只是离线点不开具体卡。 + +--- + +## 1. 数据模型 + +### 1.1 `.board` 加 board key +`BoardViewConfig`([`shared/lib/board.ts`](shared/lib/board.ts#L23))顶层**新增** board 级字段 `ticketKey: string`: +- 注意现有的 `key`([`board.ts:7`](shared/lib/board.ts#L7))是**列** key,**勿复用**;用 `ticketKey` 避免歧义。 +- 格式:大写字母/数字,`^[A-Z][A-Z0-9]{1,9}$`。从 board 标题派生默认值("OCC Services" → `OCCSV`),用户可改。 +- **workspace 内唯一**(否则 `OCCSV-3371` 有歧义)。 + +### 1.2 卡片 frontmatter — **不带号** +决策为"只存云端索引",故卡片 `.md` 的 frontmatter **不新增** ticket/number 字段,保持 [`BoardCardInfo`](services/jtype-core/src/lib.rs#L1342) 现状。卡片身份对内走 `documents.id`(云端 UUID)、对外走 ticket(云端索引计算)。 + +### 1.3 云端两张薄表(迁移 `0019_card_tickets`) + +```sql +-- 0019_card_tickets.up.sql (草案;避开 migration runner 朴素分句器:无行内 -- 注释、语句内无裸分号) + +CREATE TABLE board_sequences ( + workspace_id CHAR(36) NOT NULL, + ticket_key VARCHAR(16) NOT NULL, + last_number BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (workspace_id, ticket_key), + CONSTRAINT fk_board_seq_ws FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE TABLE card_tickets ( + id CHAR(36) NOT NULL PRIMARY KEY, + workspace_id CHAR(36) NOT NULL, + document_id CHAR(36) NOT NULL, + ticket_key VARCHAR(16) NOT NULL, + number BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_card_tickets_doc (document_id), + UNIQUE KEY uq_card_tickets_ref (workspace_id, ticket_key, number), + CONSTRAINT fk_card_tickets_ws FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT fk_card_tickets_doc FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE +); +``` + +- `card_tickets` 是 **ticket 的单一真相源**:`ticket_key` + `number` 是**铸号时的快照**,行一旦写入不再改 → ID 永久稳定(board key 改名只影响新卡,旧行仍是 `OCCSV-3371`)。 +- `UNIQUE(document_id)`:**一卡一号、终身一号**,让发号**幂等**。 +- `UNIQUE(workspace_id, ticket_key, number)`:防重号。 +- `board_sequences`:**唯一发号源**,`last_number` 只增不减。 + +--- + +## 2. 发号(A + C) + +服务端是**唯一发号者**(两条路都过 server),故 `last_number` 权威、无碰撞、无需客户端自愈。 + +``` +在线建卡(web / 联网桌面): + 建卡(已有 documents.id) → allocate(workspace, ticketKey, documentId) + → server 事务: UPDATE board_sequences SET last_number=last_number+1 + INSERT card_tickets(documentId, ticketKey, number) + → 返回 OCCSV-N → 卡片立即显示号 ✅ 即时 + +离线建卡(断网桌面): + 建卡(无号) → 离线引用走 documents.id / path + ↓ 联网首次 sync(push) + server 扫"有 board: frontmatter、card_tickets 无行"的文档 → 逐个 allocate(纯插行) + → 下次该卡在 web 打开即显示号 ⏳ 首同步后 +``` + +- **幂等**:`allocate` 先查 `card_tickets` 是否已有该 `document_id`,有则直接返回,不重复发。离线卡反复同步只补一次。 +- **无 content 改写**:号不进 frontmatter → 补号只是 `INSERT` 一行索引,`.md` 文件原封不动,**不触发 content_hash 变化 / 三方合并**。这正是"只存云端索引"相对"写 frontmatter"的最大实现红利。 +- **事务**:`UPDATE board_sequences` + `INSERT card_tickets` 同一事务;`UNIQUE(document_id)` 兜底并发重复。 + +> 备选方案对比(A 云端实时 / B `.board` 计数器 / C 离线补号 / D 设备号段)见 [`unification-v2.md`] 决策过程;B 因"不可变 ID + 离线碰撞"否决,D 因"空洞/乱序"仅在"离线必须即时有号"时才用——本设计取 A+C。 + +--- + +## 3. 解析、路由与跳转 + +### 3.1 ticket 解析路由(Web,主场) +1. 解析 `OCCSV-3371` → `(ticket_key=OCCSV, number=3371)`;非法格式 → 400。 +2. 查 `card_tickets`(按 `workspace_id + ticket_key + number`)→ `document_id` → 经 `documents` 取 `relative_path`。 +3. 跳到工作区并打开该卡(如 `/workspaces/:id?doc=&card=`,落进 `WebBoardView` 的 peek)。 +4. 查不到 → 404 / "已删除"页。**号删了永不复用**(`last_number` 不回退)。 + +> ticket 作用域 = workspace,故解析路由**带 workspace**:`/workspaces/:id/tickets/:ticket`(前端 `TicketRedirect`)。**不**提供 workspace-agnostic 的裸 ticket 解析——key 仅在 workspace 内唯一,跨 workspace 可能重名,裸链接会有歧义。 + +### 3.2 Desktop(辅) +- 在线:调云端 resolve 端点,同 web。 +- 离线:默认**解不了**(号在云端)。可选增强:同步时拉 `card_tickets` 快照到 `.jtype/tickets.json` 只读缓存,桌面据此显示/解析。**首版不做,标为可选。** + +### 3.3 移板 / 删除策略 +- **卡片移到别的 board**:保留原 ticket(行键 `document_id`,不重铸)→ ID 稳定。(如需"跨板换号"再单独设计。) +- **删除/归档**:`last_number` 不回退,号永不复用;归档卡保留号。 + +--- + +## 4. Markdown 自动链接 + +仿 [`shared/lib/markdown.ts` `renderWikilinks`](shared/lib/markdown.ts#L137) 新增 `renderTicketLinks`:把正文里的 `OCCSV-3371` 渲成 ``,平台层解析跳转(与 `data-wikilink` 同套路;记得把 `data-ticket` 加进 [`markdown.ts:194`](shared/lib/markdown.ts#L194) 的 DOMPurify `ADD_ATTR`)。 + +- **pattern**:`\b([A-Z][A-Z0-9]{1,9})-(\d+)\b`。 +- **必须白名单**:只对**已知 board key** 生效,否则 `UTF-8`/`COVID-19`/`SHA-256` 全被误链。 + - 渲染层传入当前 vault/workspace 的 `ticketKey` 集合;前缀不在集合内则不链。 + - 桌面从 `.board` 文件扫 `ticketKey`(`.board` 会同步,故桌面也有 key 集合);web 从 board 列表 / `card_tickets`。 +- **解析**:web 查 `card_tickets`;桌面在线查 resolve,离线靠可选缓存(无则 span 不可点)。 + +--- + +## 5. Board key 生命周期 + +| 事件 | 处理 | +|---|---| +| 派生默认 | 从 board 标题取大写首字母/缩写("OCC Services"→`OCCSV`),用户可改 | +| 唯一性 | 创建/改 key 时校验 workspace 内不重复(云端唯一约束 + 桌面扫 `.board` 校验) | +| 改名 | 已铸号在 `card_tickets` 是快照,不动 → 旧 ID 永久有效。可选:`.board` 存 `aliasKeys[]`,解析路由与 linkify 白名单也认旧前缀 | +| 首卡后冻结 | 建议铸出首张号后默认锁定 key(或仅允许走 alias),避免链接面变动 | + +--- + +## 6. 后端改动点(`services/jtype-web/src/`) + +- **迁移** `0019_card_tickets`(§1.3)+ 注册进 [`db/migrations.rs`](services/jtype-web/src/db/migrations.rs)(append-only,紧跟 v2 的 `0018_drop_kanban`)。 +- **allocate 端点**:`POST /api/v1/workspaces/:id/tickets/allocate { documentId }` → 解析该文档 board → 取 `ticketKey` → 事务发号 → 返回 `{ ticket, number }`。幂等。 +- **push handler 补号**:在 [`handlers/sync.rs`](services/jtype-web/src/handlers/sync.rs) push 处理里,对新到/变更的文档,若 frontmatter 有 `board:` 且 `card_tickets` 无行,则 allocate(纯插行,不改 content)。 +- **resolve 端点**:`GET /api/v1/workspaces/:id/tickets/:ticket` → 查表 → 返回卡片(前端 `TicketRedirect` 据此重定向)。 +- **board key 校验端点**:创建/改 `.board` 时校验 `ticketKey` 唯一与格式。 + +## 7. 前端改动点(`services/jtype-web/frontend/src/`) + +- `WebBoardView`:加载本看板时一并拉 `card_tickets`(`document_id → ticket` map),卡片渲染 ticket 徽标;创建卡片后调 `allocate` 拿号回填徽标。 +- `main.tsx`:加 `/workspaces/:workspaceId/tickets/:ticket` 路由(`TicketRedirect`:解析 → 重定向到 workspace + 打开卡)。 +- `shared/lib/markdown.ts`:`renderTicketLinks` + DOMPurify `ADD_ATTR: data-ticket`;平台层(web/desktop)实现 `data-ticket` 点击解析。 +- `.board` 编辑 UI:暴露 `ticketKey` 字段(带唯一性校验提示)。 + +--- + +## 8. 分期 + +1. **模型**:`.board` 加 `ticketKey` + 校验;迁移 `0019` 两表。 +2. **在线发号(A)**:`allocate` 端点 + `WebBoardView` 显示徽标 + 创建即发号。 +3. **解析/路由**:workspace-scoped ticket 路由 + resolve 端点。 +4. **离线补号(C)**:push handler 补号。 +5. **markdown linkify**:`renderTicketLinks` + board-key 白名单。 +6. **可选增强**:桌面 `tickets.json` 只读缓存(按需)。 + +做完 1+2+3 即可:在线建的卡都有 `OCCSV-3371` 并能 `/browse` 打开。4 是离线增强,5 是正文链接体验。 + +--- + +## 9. 验收标准 + +通用门槛同 [`unification-v2.md` §8](tsc/build/cargo/启动无错)。 + +- AC1:`.board` 设 `ticketKey=OCCSV`;同 workspace 再设一个 `OCCSV` 被拒(唯一性)。 +- AC2:web 新建卡 → 立即显示 `OCCSV-N`,N 较上一张 +1;`card_tickets` 有对应行(键 `document_id`)。 +- AC3:`/workspaces/:id/tickets/OCCSV-3371` 打开对应卡;不存在的号 → 404;删卡后该号仍 404 且**不被新卡复用**。 +- AC4:断网桌面建卡(无号) → 联网同步后,该卡在 web 显示号;重复同步不重号(幂等)。 +- AC5:正文写 `OCCSV-3371` 在预览渲成可点链接并跳转;写 `UTF-8`/`COVID-19` **不**被链接(白名单生效)。 +- AC6:把卡片 `board:` 改到另一 board → 其 ticket **不变**(移板保号)。 +- AC7:改 board 的 `ticketKey` → 已有卡的 `OCCSV-N` 不变(快照稳定);若启用 alias,旧前缀仍可达。 + +## 10. 边界与未决 + +- **跨 workspace 重名 key**:已解决——解析路由带 workspace(`/workspaces/:id/tickets/:ticket`),不提供裸 ticket 解析(key 仅 workspace 内唯一)。 +- **桌面离线解析**:默认不支持,依赖可选 `tickets.json` 缓存。 +- **board key 派生算法**:标题→缩写规则需定(取大写首字母?前 N 字符?),可先用"标题大写去空格取前 5–6 位 + 去重后缀",允许用户覆盖。 +- **大量离线卡补号顺序**:同一 board 多张离线卡在一次 sync 补号,顺序按 push 顺序/创建时间,需定一个确定性排序避免号序随机。 diff --git a/internal-docs/kanban/unification-v2.md b/internal-docs/kanban/unification-v2.md new file mode 100644 index 0000000..3ff41a3 --- /dev/null +++ b/internal-docs/kanban/unification-v2.md @@ -0,0 +1,255 @@ +# JType 看板统一 v2 — 收敛到文档型看板 + 退役云端 DB 看板 + +状态:设计已定稿(用户 2026-06-23 锁定关键决策),待实现 +初始日期:2026-06-23 +更新日期:2026-06-23 + +> **目标**:把"云端 DB 看板"与"本地文件看板"**彻底融合成一套、云端本地互通**。 +> 真相源(single source of truth)= **markdown 卡片(`.md`)+ `.board` 视图文件**;**退役**孤立的云端 DB 看板(`Kanban.tsx` + `kanban_*` 表)。 +> +> **配套 / 被本文取代的文档**: +> - [`design.md`](./design.md) — v1 三环境云端设计,**整体退役**(其 LOCAL/CLOUD 都不再是方向)。 +> - [`gaps-and-roadmap.md`](./gaps-and-roadmap.md)(2026-06-20)— "DB 看板保留为遗留" 的口径**已被本文推翻**,改为退役。 +> - [`next-features-design.md`](./next-features-design.md) — D2 Webhook 的 HMAC/重试/SSRF 设计仍有效,但**触发源改挂文档事件**(见 §2.5)。 +> - [`../doc-kanban-unification/design.md`](../doc-kanban-unification/design.md) §5 的"暂缓(后续 PRD)" = **本文**。 +> - [`../web-board-alignment/design.md`](../web-board-alignment/design.md) §4.2 的"两条独立路径" = 本文要消除的对象。 + +--- + +## 0. 背景:三套看板 / 两套数据层(关键认知) + +看板实际上是 **3 个渲染面、2 套数据层**(旧文档里"两套看板"的说法不完整): + +| | 渲染面 | 数据层 | 互通 | +|---|---|---|---| +| ① 桌面文件看板 | `src/components/BoardView.tsx` | `.board` JSON + `.md` 卡片(jtype-core 扫描) | ←→ 与②同源 | +| ② Web 文件看板 | `services/jtype-web/frontend/src/pages/WebBoardView.tsx` | 同一批 `.board`/`.md`,存云端 `documents` 表 | **✅ 已与桌面双向同步** | +| ③ Web 云端 DB 看板 | `services/jtype-web/frontend/src/pages/Kanban.tsx`,路由 `/workspaces/:id/kanban` | 独立 `kanban_*` MySQL 表 | **❌ 孤岛:无 `document_id`/`relative_path`、不同步、无 UI 入口** | + +**关键事实**:①②已经"一套数据、两端渲染",都跑同一个共享 `shared/components/board/BoardSurface.tsx`;保存 `.board`/`.md` → 撞 `updated_clock` → 广播 `DocumentChanged` → 另一端 sync 拉取。**"云端本地互通"在文件型看板上已经实现**——用户实际在用的就是这套。 + +因此"融合"= **把③独有的功能搬到文档模型 → 删掉③**,而**不是**搭新同步管道。 + +--- + +## 1. 目标架构 + +``` + shared/components/board/BoardSurface.tsx (唯一 UI) + ▲ ▲ + ┌───────────────┘ └───────────────┐ + 桌面 adapter Web 文件 adapter + src/components/BoardView.tsx WebBoardView.tsx + (.board + .md 文件) (documents 表里的 .board + .md) + └──── 同一批文件,经文档同步双向互通 ────┘ + + 退役:Web 云端 DB adapter Kanban.tsx + kanban_* 表 + handlers/kanban/* +``` + +- 平台差异全部表达为 `BoardSurface` 的**可选 props**(`assigneeOptions`/`tagOptions`/`loadComments`/`loadActivity`);不传则对应 UI 不渲染。纯客户端能力(分组/排序/筛选/表格/日历/泳道/任务进度/自定义字段/emoji/依赖)①②已全有。 + +**删 / 留 / 改总览**: + +| 对象 | 处置 | +|---|---| +| ③ 的页面、路由、`handlers/kanban/*`、`api.ts` kanban 块、MCP/CLI kanban 工具 | **删** | +| `kanban_boards/columns/labels/cards/card_labels/card_trash` | **删**(存量为空,无需迁移脚本) | +| `kanban_card_comments` | **留+改键**:评论暂留云端 DB,但从 `card_id`(FK kanban_cards) 改挂 `document_id`(FK `documents.id`) | +| `kanban_webhooks` + `kanban_webhook_deliveries` + 投递 worker | **留+重接**:基建保留,触发源改挂文档保存路径 | + +### 1.1 架构定调(两条不可动摇的原则) + +**① `documents.id` 是唯一连接键。** 看板**内容**永远是 `documents` 表里的 `.md`/`.board` 文件(双向同步,给互通/可移植/git/桌面/统一 vault);所有云端**关系型"卫星"**一律键挂 `documents.id`,不再各自造卡片身份: + +| 卫星 | 键 | +|---|---| +| `card_comments` | `document_id` | +| `webhooks.board_ref` | board 的**逻辑 id**(frontmatter `board` 值,`VARCHAR`,NULL=全部看板)¹ | +| `card_tickets`(见 [`ticket-links.md`](./ticket-links.md)) | `document_id` | +| 活动流 | 派生自 `document_versions`(按 doc) | + +> ¹ webhook 作用域是唯一的例外:键挂 board 的**逻辑 ref**(frontmatter `board`),**不**用 `.board` 文件的 `document_id`。这样作用域在 `.board` 文件移动/改名后**不变**,与实际迁移契约一致。 + +人类可读的 ticket 串(如 `OCCSV-3371`)**只是展示别名,永不做外键**——`document_id` 改名/移动都不变、且离线卡未铸号时就已存在;**已铸号的 ticket 串也保持不变**(`card_tickets` 里是 `ticket_key`+`number` 快照),只有**新发号**才会使用新的 board key。 +> 前提:卫星都是云端特性,`document_id` 在卡片进入云端 `documents` 表后才有;"web 为主"下这是常态。 + +**② Web 为主,Desktop 为辅。** Web = 功能完整的主场(号 / 评论 / webhook / 活动这些富功能**在线才全**);Desktop = 能离线读写同一批看板文件的**轻客户端**,离线不享有云端卫星。由此: + +- 富功能"**云端独占**"可接受,不为离线妥协架构。 +- **桌面只读缓存**(如 ticket 的 `tickets.json`)、**评论本地化(v3)** 一律**降级为可选/以后再说**,不进首版。 +- **最终形态固定**:*文件存内容 + 薄云端表存关系型卫星*。**不要再把卡片塞回 DB**。 + +--- + +## 2. 功能补齐(parity) + +③接了、而文件模型还没存储位的 5 项。除评论外都落进 frontmatter / `.board` / 已有表,零新基建。 + +| 功能 | ③ 现状 | 文件模型归宿 | 工作量 | +|---|---|---|---| +| 成员指派 | `kanban_cards.assignee_user_id` FK→users | §2.3 | 低-中 | +| 彩色标签 | `kanban_labels`(color) + 关联表 | §2.2 | 中 | +| 活动流 | `card.rs` 读时派生 | §2.4 | 低 | +| 评论 | `kanban_card_comments` 表 | §2.1(暂留云端,改键) | 中 | +| Webhook | `kanban_webhooks` + worker | §2.5(重接文档事件) | 中-高 | + +### 2.1 评论(暂留云端 DB,改键到文档) +- **决策**:本轮评论**不进文件模型**,仍走云端表,但脱离即将删除的 `kanban_cards`。 +- 表 `kanban_card_comments` → 重命名 `card_comments`,键 `card_id` → **`document_id`(FK `documents.id`)**。卡片就是文档,文档 id 稳定、改名/移动不丢评论。 +- `WebBoardView` 接 `loadComments/addComment/deleteComment`(卡片的 `document_id` 即评论键)。桌面离线暂不显示评论(云端独占,可接受)。 +- 后续若要真正本地化,再迁移到卡片正文 `## Comments` 区或 sidecar `.md`(留作 v3)。 + +### 2.2 彩色标签(存进 `.board`) +- 标签定义 `{ key, label, color }` 放进 **`.board` JSON 的 `labels[]`**;卡片 frontmatter `tags` 按 key 引用。 +- 随 `.board` 文档同步,桌面/web 一致,**不要 DB**。`BoardTag` 已支持 color,只需 adapter 把 `.board.labels` 映进 `tagOptions`。 + +### 2.3 成员指派(member) +- Web adapter 给 `BoardSurface` 传 `assigneeOptions`(来自 `api.listMembers`);选中后 frontmatter `assignee` 存**稳定 handle**(email 或用户名),两端都能渲染。 +- 桌面无成员体系时保持自由文本,能正常显示同一字段。 + +### 2.4 活动流(派生自文档版本史) +- 复用**已有的 `document_versions` 表**(已记录 `updated_clock` + 编辑者)派生活动,比③现在硬编码的 4 种事件更细。 +- adapter 实现 `loadActivity`:读卡片文档的版本史 → 映成 `BoardSurface` 的活动条目。无新表。 + +### 2.5 Webhook(保留基建,重接文档事件) +- **保留**:HMAC-SHA256 签名、`webhook_deliveries` 重试队列、SSRF 防护、后台投递 worker `tasks/webhook_delivery.rs`——全部不动。 +- **重接触发源**:从 `handlers/kanban/card.rs` 的 3 处 `enqueue_event`,搬到 **`handlers/document.rs` 的 `save_document`**(及 sync push 的文档写入站点): + - 保存一个带 `board:` frontmatter 的 `.md` 卡片,或一个 `.board` 文件时, + - 服务端**解析 frontmatter 的 `board` 字段**解出所属看板 → 匹配 `webhooks` 表中作用域命中的记录 → `enqueue_event`。 +- 表 `kanban_webhooks` → 重命名 `webhooks`,`board_id` 去掉对 `kanban_boards` 的 FK,改存 **board 的逻辑 ref**(frontmatter `board` 值,`VARCHAR`,nullable = 全部看板)。用逻辑 ref 而非文档 id,作用域才能在文件移动/改名后不变。 +- 事件名沿用 `kanban:card-updated` / `kanban:card-archived` 等,保持 payload 兼容。 +- ⚠️ 需要服务端在文档保存路径上做 frontmatter 解析(已有 util 可复用);若先求简,可先只支持 workspace 级作用域(不按 board 过滤),board 级作为二期。 + +--- + +## 3. 数据库变更(迁移 `0018_drop_kanban`) + +> 迁移是 **append-only**:**绝不编辑** `0007/0016/0017`,新增 `0018` 并注册进 [`db/migrations.rs`](../../services/jtype-web/src/db/migrations.rs)。 +> ⚠️ 迁移 runner 的分句器很朴素(见记忆 migration-runner-naive-splitter / [`migrations.rs`]):剥掉整行 `--` 注释后按 `;` 裸切,**无字符串/行内注释意识**。SQL 草案里**不要写行内 `--` 注释、不要在语句里出现裸 `;`**。 + +**因存量为空,推荐"删旧建新"而非 ALTER 改键**(避免查 FK 约束名的麻烦): + +```sql +-- 0018_drop_kanban.up.sql (草案;正式落地前以实际列定义校对) + +DROP TABLE IF EXISTS kanban_webhook_deliveries; +DROP TABLE IF EXISTS kanban_webhooks; +DROP TABLE IF EXISTS kanban_card_comments; +DROP TABLE IF EXISTS kanban_card_labels; +DROP TABLE IF EXISTS kanban_card_trash; +DROP TABLE IF EXISTS kanban_cards; +DROP TABLE IF EXISTS kanban_labels; +DROP TABLE IF EXISTS kanban_columns; +DROP TABLE IF EXISTS kanban_boards; + +CREATE TABLE card_comments ( + id CHAR(36) NOT NULL PRIMARY KEY, + workspace_id CHAR(36) NOT NULL, + document_id CHAR(36) NOT NULL, + author_user_id CHAR(36) NOT NULL, + body MEDIUMTEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_card_comments_ws FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT fk_card_comments_doc FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + CONSTRAINT fk_card_comments_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE webhooks ( + id CHAR(36) NOT NULL PRIMARY KEY, + workspace_id CHAR(36) NOT NULL, + board_ref VARCHAR(255) NULL, + name VARCHAR(120) NOT NULL, + target_url VARCHAR(2048) NOT NULL, + secret CHAR(64) NOT NULL, + event_types JSON NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_delivery_at TIMESTAMP NULL, + last_status VARCHAR(32) NULL, + created_by_user_id CHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_webhooks_ws FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT fk_webhooks_user FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE webhook_deliveries ( + id CHAR(36) NOT NULL PRIMARY KEY, + webhook_id CHAR(36) NOT NULL, + workspace_id CHAR(36) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + status ENUM('pending','succeeded','failed','dead') NOT NULL DEFAULT 'pending', + attempt_count INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 6, + last_status_code INT NULL, + last_error TEXT NULL, + next_retry_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_deliveries_webhook FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE, + CONSTRAINT fk_deliveries_ws FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); +``` + +> `.down.sql` 反向(drop 新 3 表 + 重建旧 9 表)可直接复用 `0007/0016/0017` 的 `.up` 拼成,仅用于本地回滚演练。 + +**删除顺序(子→父,已编码进上面)**:`webhook_deliveries → webhooks → card_comments → card_labels → card_trash → cards → labels → columns → boards`。所有 FK 为 CASCADE/SET NULL,按此序 `DROP TABLE IF EXISTS` 即干净。 + +--- + +## 4. 后端改动(`services/jtype-web/src/`) + +- **删** `handlers/kanban/` 整目录(board/card/column/label/comment/mod)。 +- `handlers/mod.rs`:去掉 `pub mod kanban;`。 +- `lib.rs`:删 ~18 条 kanban 路由(boards/columns/cards/trash/move/archive/restore/comments/activity/labels)。 +- `db/models.rs`:删 Kanban 结构体块。 +- `hub.rs`:删 `WorkspaceEvent` 的 `kanban:*` 变体(document/member/workspace 变体保留)。 +- `tasks/cleanup_trash.rs`:去掉 `kanban_card_trash` 清理(**保留** `document_trash`)。 +- **新增/搬迁**:评论端点改挂 `document_id`(新 `handlers/comments.rs` 或并入 document handler);webhook 端点(注册/列表/删除)保留,触发逻辑搬进 `handlers/document.rs::save_document`(见 §2.5)。`tasks/webhook_delivery.rs` 保留。 + +## 5. 前端改动(`services/jtype-web/frontend/src/`) + +- **删** `pages/Kanban.tsx`;`main.tsx` 去掉 import + `/kanban` 路由。 +- `api.ts`:删 `kanban: { … }` 客户端块及 `Kanban*` 类型;**保留/迁移**评论、webhook 的 API(改挂文档维度)。 +- `WebBoardView.tsx`:接 `assigneeOptions`(成员)、`tagOptions`(`.board.labels`)、`loadComments/addComment`、`loadActivity`,达成与③的功能对齐。 +- i18n:删除 `Kanban.tsx` 后跑 `lingui extract` 自动剪死字符串(注意 MCP/营销文案里的 "kanban" 词条要保留,见记忆 i18n-catalog-split)。 +- Help:删 `kanban-boards-and-cards`、`kanban-web-board-view` 文章 + `KanbanExplainer` Remotion(glob 自动注销);其余散文提及改写。 + +## 6. MCP / CLI / 测试 / 文档清理 + +- **MCP**(`mcp/tools.rs`):6 个 kanban 工具(`list_boards/get_board/list_cards/create_card/update_card/move_card`)**改写成打本地 vault 模型**(读写 `.board`/`.md` 文档),或删除。notes 工具不动。 +- **CLI**(`jtype-cli/src/kanban.rs`):整模块删,`main.rs` 去掉 `Board`/`Card` 子命令。 +- **测试**:删 `tests/kanban_tests.rs`、`kanban_e2e_tests.rs`;修 `mcp_tests.rs`、`jtype-cli/tests/e2e.sh` 的 kanban 段。 +- **文档**:本文为新真相源;旧文档(`design.md`/`gaps-and-roadmap.md`/`next-features-design.md`/`doc-kanban-unification`/`web-board-alignment`)已加状态横幅指向本文。 +- **AI skill** `internal-docs/ai-integration/skills/jtype-kanban/SKILL.md`:随 MCP/CLI 改写或更新。 + +--- + +## 7. 落地阶段与顺序 + +1. **功能补齐**(不依赖删除,先做,立刻让②变强):彩色标签 → 成员指派 → 活动流。 +2. **评论改键**:`card_comments` 挂 `document_id`,`WebBoardView` 接评论 UI。 +3. **Webhook 重接**:`webhooks` 表改造 + 触发源搬到 `save_document`。 +4. **清理③**:删 `Kanban.tsx`/路由/`handlers/kanban`/`api.ts` 块/MCP·CLI 工具/测试/help。 +5. **`0018_drop_kanban`**:删旧建新 + 注册进 `migrations.rs`。 + +> 顺序原则:**先建后拆**——先把②补到不输③,再删③,删除放最后最安全。 + +--- + +## 8. 验收标准 + +**通用门槛(每阶段)**:`tsc --noEmit`(desktop + web)= 0;两端 `vite build` = 0;`cargo check` + `cargo test`(jtype-web + src-tauri)通过;应用启动无 console 错误。 + +- AC1 彩色标签:在 `.board` 定义带色标签,卡片打标后,桌面与 web 同色渲染;新增/改色随 `.board` 同步到另一端。 +- AC2 成员指派:web peek 出现成员下拉,选中写回 frontmatter `assignee`,桌面打开同卡显示同一负责人。 +- AC3 活动流:卡片 peek 的活动条来自文档版本史,编辑卡片后新增一条,**无新表**。 +- AC4 评论:web 上对某卡评论,刷新/换设备仍在;评论键为该卡 `document_id`,卡片改名后评论不丢。 +- AC5 Webhook:在统一看板上改一张卡(②的 `.md`),命中作用域的 webhook 收到签名请求;`/kanban` 旧页已不存在。 +- AC6 清理:`/workspaces/:id/kanban` 路由 404;`grep -ri "kanban_" services/jtype-web/src` 仅余迁移历史文件;`kanban_boards` 等 6 表不存在;`card_comments`/`webhooks`/`webhook_deliveries` 三表存在且键正确。 +- AC7 互通回归:web 建/改 `.board` 卡片 → 桌面同步可见;桌面改 → web 可见(②已有能力不回退)。 + +## 9. 风险与未决 + +- **评论本地化**留到 v3(本轮仅改键,仍云端独占,桌面离线不显示评论)。 +- **Webhook board 级作用域**依赖服务端在保存路径解析 frontmatter;若求简先做 workspace 级,board 级二期。 +- **成员 handle 选型**(email vs 用户名)需与现有成员标识统一;桌面无成员体系,跨端显示需约定回退。 +- **存量为空是删除前提**:落地前用 `SELECT COUNT(*)` 逐表确认 `kanban_*` 确无真实数据,否则需补一次性导出脚本。 diff --git a/internal-docs/web-board-alignment/design.md b/internal-docs/web-board-alignment/design.md index 59175a5..e365cff 100644 --- a/internal-docs/web-board-alignment/design.md +++ b/internal-docs/web-board-alignment/design.md @@ -169,6 +169,8 @@ type BoardActions = { - **生效前提(两步,不只是 docker)**:① `docker compose build jtype-web && docker compose up -d jtype-web`;② **重启桌面 app 并同步一次**(桌面现在才会把 `.board` 推上云;之前从没上传)。之后 web 文件树里出现 `.board`,点开即同一个看板,双向同步。 > 说明:这与 §4.1 的「云端 DB 看板页」(`/workspaces//kanban`)是两条独立路径 —— DB 看板是 web 自建数据;`.board` 文档视图是桌面同款数据。 +> +> 🛑 **更新(2026-06-23)**:这"两条独立路径"正是要消除的对象。已决定**退役**云端 DB 看板页,只留 `.board` 文档视图(`WebBoardView`)这一条,并把 DB 看板的独有功能(评论/webhook/成员/彩色标签/活动)搬到文档模型。统一方案见 **[`../kanban/unification-v2.md`](../kanban/unification-v2.md)**。 ## 5. 验收标准 diff --git a/services/jtype-cli/src/client.rs b/services/jtype-cli/src/client.rs index d4ddad5..3c77bcd 100644 --- a/services/jtype-cli/src/client.rs +++ b/services/jtype-cli/src/client.rs @@ -67,15 +67,12 @@ impl ApiClient { pub async fn post(&self, path: &str, body: Value) -> Result { self.send(Method::POST, path, Some(body)).await } - pub async fn patch(&self, path: &str, body: Value) -> Result { - self.send(Method::PATCH, path, Some(body)).await - } /// Raw POST to the MCP endpoint (used by `mcp-stdio`). Returns (status, body). - pub async fn post_mcp_raw(&self, json_line: &str) -> Result<(StatusCode, String)> { + pub async fn post_mcp_raw(&self, json_line: &str, path: &str) -> Result<(StatusCode, String)> { let mut req = self .http - .post(self.url("/mcp")) + .post(self.url(path)) .header("content-type", "application/json") .body(json_line.to_string()); if let Some(token) = &self.token { diff --git a/services/jtype-cli/src/kanban.rs b/services/jtype-cli/src/kanban.rs index 799f013..5a3954c 100644 --- a/services/jtype-cli/src/kanban.rs +++ b/services/jtype-cli/src/kanban.rs @@ -1,176 +1,233 @@ -//! Kanban board + card commands. +//! Local-first kanban: `board`/`card` commands over the vault's `.board` view +//! files and `.md` card-notes. Reads scan the vault directly (offline-capable); +//! writes update the `.md` frontmatter and write-through to the bound cloud +//! workspace — exactly like the `note` commands. The cloud DB kanban is gone; a +//! board is just files, so this is built entirely on `jtype-core`. -use anyhow::Result; -use serde_json::{json, Value}; +use std::path::Path; -use crate::client::ApiClient; +use anyhow::{anyhow, Context, Result}; +use serde_json::json; + +use crate::config::Config; +use crate::notes; use crate::print::emit; -pub async fn list_boards(client: &ApiClient, ws: &str, json: bool) -> Result<()> { - let boards = client.get(&format!("/api/v1/workspaces/{ws}/kanban/boards")).await?; +pub fn list_boards_local(vault_root: &Path, json: bool) -> Result<()> { + let boards = jtype_core::list_boards(vault_root).map_err(|e| anyhow!(e))?; if json { - emit(true, &boards); + emit(true, &serde_json::to_value(&boards)?); + } else if boards.is_empty() { + println!("(no boards — create a .board file)"); } else { - println!("{:<38} {:<24} {:>5} {:>5}", "ID", "NAME", "COLS", "CARDS"); - for b in boards.as_array().cloned().unwrap_or_default() { - println!( - "{:<38} {:<24} {:>5} {:>5}", - b["id"].as_str().unwrap_or("-"), - b["name"].as_str().unwrap_or("-"), - b["columnCount"].as_i64().unwrap_or(0), - b["cardCount"].as_i64().unwrap_or(0), - ); + for b in &boards { + println!("{}\t{}\t[{}]", b.id, b.title, b.relative_path); } } Ok(()) } -pub async fn get_board(client: &ApiClient, ws: &str, board: &str, json: bool) -> Result<()> { - let b = client - .get(&format!("/api/v1/workspaces/{ws}/kanban/boards/{board}")) - .await?; +pub fn show_board_local(vault_root: &Path, board: &str, json: bool) -> Result<()> { + let boards = jtype_core::list_boards(vault_root).map_err(|e| anyhow!(e))?; + let cfg = boards + .iter() + .find(|b| b.id == board) + .ok_or_else(|| anyhow!("no board with id '{board}' (try `jtype board list`)"))?; + let cards = jtype_core::scan_board_cards(vault_root, board).map_err(|e| anyhow!(e))?; if json { - emit(true, &b); + emit(true, &json!({ "board": cfg, "cards": cards })); return Ok(()); } - println!("# {}\n", b["name"].as_str().unwrap_or("Board")); - let cards = b["cards"].as_array().cloned().unwrap_or_default(); - for col in b["columns"].as_array().cloned().unwrap_or_default() { - let cid = col["id"].as_str().unwrap_or(""); - println!("## {} ({})", col["name"].as_str().unwrap_or("-"), cid); - for c in cards.iter().filter(|c| c["columnId"].as_str() == Some(cid)) { - let pr = c["priority"].as_str().unwrap_or("none"); - println!(" - [{}] {} · {}", pr, c["title"].as_str().unwrap_or("-"), c["id"].as_str().unwrap_or("")); + println!("{} ({})", cfg.title, cfg.id); + for col in &cfg.columns { + let in_col: Vec<_> = cards.iter().filter(|c| c.status == col.key).collect(); + println!("\n {} ({})", col.name, in_col.len()); + for c in in_col { + println!(" - {}\t[{}]", c.title, c.relative_path); } - println!(); } Ok(()) } -pub async fn list_cards( - client: &ApiClient, - ws: &str, - board: &str, - column: Option<&str>, - json: bool, -) -> Result<()> { - let cards = client - .get(&format!("/api/v1/workspaces/{ws}/kanban/boards/{board}/cards")) - .await?; - let arr: Vec = cards - .as_array() - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|c| column.is_none() || c["columnId"].as_str() == column) - .collect(); +pub fn list_cards_local(vault_root: &Path, board: &str, status: Option<&str>, json: bool) -> Result<()> { + let mut cards = jtype_core::scan_board_cards(vault_root, board).map_err(|e| anyhow!(e))?; + if let Some(s) = status { + cards.retain(|c| c.status == s); + } if json { - emit(true, &json!(arr)); + emit(true, &serde_json::to_value(&cards)?); + } else if cards.is_empty() { + println!("(no cards)"); } else { - for c in &arr { - println!( - "[{}] {} · {}", - c["priority"].as_str().unwrap_or("none"), - c["title"].as_str().unwrap_or("-"), - c["id"].as_str().unwrap_or("") - ); + for c in &cards { + println!("[{}]\t{}\t{}", c.status, c.title, c.relative_path); } - println!("\n{} card(s)", arr.len()); } Ok(()) } +/// Reject a column key that matches no column on the board: board views render +/// a card only under a matching column key, so a typo would orphan it invisibly. +fn ensure_column(b: &jtype_core::BoardSummaryInfo, key: &str) -> Result<()> { + if !b.columns.iter().any(|c| c.key == key) { + let cols = b.columns.iter().map(|c| c.key.as_str()).collect::>().join(", "); + return Err(anyhow!("'{key}' is not a column of board '{}' (columns: {cols})", b.id)); + } + Ok(()) +} + +/// Resolve the board a card-note belongs to via its `board:` frontmatter; errors +/// if the note isn't a card or points at an unknown board. +fn card_board(vault_root: &Path, content: &str) -> Result { + let board_id = jtype_core::parse_frontmatter(content) + .get("board") + .cloned() + .ok_or_else(|| anyhow!("note has no `board:` frontmatter — not a board card"))?; + jtype_core::list_boards(vault_root) + .map_err(|e| anyhow!(e))? + .into_iter() + .find(|b| b.id == board_id) + .ok_or_else(|| anyhow!("card references unknown board '{board_id}'")) +} + #[allow(clippy::too_many_arguments)] -pub async fn create_card( - client: &ApiClient, - ws: &str, +pub async fn create_card_local( + vault_root: &Path, + cfg: &Config, + workspace: Option<&str>, board: &str, - column: &str, + status: &str, title: &str, - description: Option<&str>, priority: Option<&str>, assignee: Option<&str>, + due: Option<&str>, json: bool, ) -> Result<()> { - let mut body = json!({ "columnId": column, "title": title }); - if let Some(d) = description { - body["description"] = json!(d); - } - if let Some(p) = priority { - body["priority"] = json!(p); + let boards = jtype_core::list_boards(vault_root).map_err(|e| anyhow!(e))?; + let b = boards + .iter() + .find(|b| b.id == board) + .ok_or_else(|| anyhow!("no board with id '{board}' (try `jtype board list`)"))?; + ensure_column(b, status)?; + // Cards live in the folder sibling to `.board` (strip the extension). + let dir = b.relative_path.strip_suffix(".board").unwrap_or(&b.relative_path); + // Append to the end of the target column: max position there + 1. + let existing = jtype_core::scan_board_cards(vault_root, board).map_err(|e| anyhow!(e))?; + let next_pos = existing + .iter() + .filter(|c| c.status == status) + .map(|c| c.position) + .max() + .map(|m| m + 1) + .unwrap_or(0); + // Don't clobber an existing card whose title slugifies the same: suffix + // -2, -3, … until the path is free (save_note_local truncate-writes). + let slug = slugify(title); + let mut rel = format!("{dir}/{slug}.md"); + let mut n = 2; + while jtype_core::safe_join(vault_root, &rel).map_err(|e| anyhow!(e))?.exists() { + rel = format!("{dir}/{slug}-{n}.md"); + n += 1; } - if let Some(a) = assignee { - body["assigneeUserId"] = json!(a); - } - let card = client - .post(&format!("/api/v1/workspaces/{ws}/kanban/boards/{board}/cards"), body) - .await?; - if json { - emit(true, &card); - } else { - println!( - "✓ created card {} — {}", - card["id"].as_str().unwrap_or("?"), - card["title"].as_str().unwrap_or(title) - ); + let content = build_card_content(board, status, next_pos, title, priority, assignee, due); + notes::save_note_local(vault_root, cfg, workspace, &rel, &content, Some(title), json).await +} + +pub async fn move_card_local( + vault_root: &Path, + cfg: &Config, + workspace: Option<&str>, + path: &str, + to: &str, + position: Option, + json: bool, +) -> Result<()> { + let rel = notes::normalize_note_rel(path); + let target = jtype_core::safe_join(vault_root, &rel).map_err(|e| anyhow!(e))?; + let content = std::fs::read_to_string(&target).with_context(|| format!("reading {}", target.display()))?; + ensure_column(&card_board(vault_root, &content)?, to)?; + let mut updated = jtype_core::set_frontmatter_field(&content, "status", Some(to)); + if let Some(p) = position { + updated = jtype_core::set_frontmatter_field(&updated, "position", Some(&p.to_string())); } - Ok(()) + notes::save_note_local(vault_root, cfg, workspace, &rel, &updated, None, json).await } -pub async fn update_card( - client: &ApiClient, - ws: &str, - card: &str, - title: Option<&str>, - description: Option<&str>, +#[allow(clippy::too_many_arguments)] +pub async fn set_card_local( + vault_root: &Path, + cfg: &Config, + workspace: Option<&str>, + path: &str, + status: Option<&str>, priority: Option<&str>, assignee: Option<&str>, + due: Option<&str>, json: bool, ) -> Result<()> { - let mut body = serde_json::Map::new(); - if let Some(t) = title { - body.insert("title".into(), json!(t)); + let rel = notes::normalize_note_rel(path); + let target = jtype_core::safe_join(vault_root, &rel).map_err(|e| anyhow!(e))?; + let mut content = std::fs::read_to_string(&target).with_context(|| format!("reading {}", target.display()))?; + // A non-empty status must name a real column (empty clears it, which is fine). + if let Some(s) = status.filter(|s| !s.is_empty()) { + ensure_column(&card_board(vault_root, &content)?, s)?; + } + let mut touched = false; + for (key, val) in [ + ("status", status), + ("priority", priority), + ("assignee", assignee), + ("due", due), + ] { + if let Some(v) = val { + // An explicit empty string clears the field (removes it). + let set = if v.is_empty() { None } else { Some(v) }; + content = jtype_core::set_frontmatter_field(&content, key, set); + touched = true; + } } - if let Some(d) = description { - body.insert("description".into(), json!(d)); + if !touched { + return Err(anyhow!("set: provide at least one field (--status/--priority/--assignee/--due)")); } + notes::save_note_local(vault_root, cfg, workspace, &rel, &content, None, json).await +} + +fn build_card_content( + board: &str, + status: &str, + position: i64, + title: &str, + priority: Option<&str>, + assignee: Option<&str>, + due: Option<&str>, +) -> String { + let body = format!("# {title}\n"); + let mut c = jtype_core::set_frontmatter_field(&body, "board", Some(board)); + c = jtype_core::set_frontmatter_field(&c, "status", Some(status)); + c = jtype_core::set_frontmatter_field(&c, "position", Some(&position.to_string())); if let Some(p) = priority { - body.insert("priority".into(), json!(p)); + c = jtype_core::set_frontmatter_field(&c, "priority", Some(p)); } if let Some(a) = assignee { - body.insert("assigneeUserId".into(), json!(a)); - } - if body.is_empty() { - anyhow::bail!("provide at least one field to update (--title/--description/--priority/--assignee)"); + c = jtype_core::set_frontmatter_field(&c, "assignee", Some(a)); } - let res = client - .patch(&format!("/api/v1/workspaces/{ws}/kanban/cards/{card}"), Value::Object(body)) - .await?; - if json { - emit(true, &res); - } else { - println!("✓ updated card {card}"); + if let Some(d) = due { + c = jtype_core::set_frontmatter_field(&c, "due", Some(d)); } - Ok(()) + c } -pub async fn move_card( - client: &ApiClient, - ws: &str, - board: &str, - card: &str, - to_column: &str, - position: i64, - json: bool, -) -> Result<()> { - let body = json!({ "cardId": card, "targetColumnId": to_column, "targetPosition": position }); - let res = client - .post(&format!("/api/v1/workspaces/{ws}/kanban/boards/{board}/cards/move"), body) - .await?; - if json { - emit(true, &res); +/// Filename slug from a card title: alphanumerics kept (lowercased), everything +/// else collapsed to single hyphens. +fn slugify(title: &str) -> String { + let mapped: String = title + .chars() + .map(|ch| if ch.is_alphanumeric() { ch.to_ascii_lowercase() } else { '-' }) + .collect(); + let collapsed = mapped.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + if collapsed.is_empty() { + "card".to_string() } else { - println!("✓ moved card {card} → column {to_column}"); + collapsed } - Ok(()) } diff --git a/services/jtype-cli/src/main.rs b/services/jtype-cli/src/main.rs index 96e31fa..7ef3322 100644 --- a/services/jtype-cli/src/main.rs +++ b/services/jtype-cli/src/main.rs @@ -1,9 +1,9 @@ -//! jtype — manage JType notes and kanban projects from the terminal or AI agents. +//! jtype — manage JType notes from the terminal or AI agents. //! //! Notes are **local-first**: when run inside a vault (a `.jtype/`-marked folder, //! discovered from the cwd) the `note` commands read/write the vault's `.md` files //! directly, and create/update additionally write-through to the bound cloud -//! workspace. Board/card stay cloud (remote). Auth is the OAuth device flow +//! workspace. Auth is the OAuth device flow //! (`jtype login`). The same binary doubles as a local stdio MCP server (`jtype mcp-stdio`). mod auth; @@ -17,14 +17,14 @@ mod sync; mod tokens; mod vault; -use anyhow::{anyhow, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; use client::ApiClient; use config::Config; #[derive(Parser)] -#[command(name = "jtype", version, about = "Manage JType notes & kanban from the terminal or AI agents")] +#[command(name = "jtype", version, about = "Manage JType notes from the terminal or AI agents")] struct Cli { /// Override the server URL (default: from config or http://localhost:13345). #[arg(long, global = true)] @@ -57,6 +57,16 @@ enum Command { #[command(subcommand)] cmd: NoteCmd, }, + /// Kanban board commands — local-first over the vault's .board view files. + Board { + #[command(subcommand)] + cmd: BoardCmd, + }, + /// Kanban card commands — local-first over the vault's .md card-notes. + Card { + #[command(subcommand)] + cmd: CardCmd, + }, /// Bind the current vault to a cloud workspace (writes .jtype/cloud.json). Bind { /// Cloud workspace id, name, or slug. @@ -70,24 +80,19 @@ enum Command { }, /// Sync the current vault with its bound cloud workspace (pull + push). Sync, - /// Kanban board commands. - Board { - #[command(subcommand)] - cmd: BoardCmd, - }, - /// Kanban card commands. - Card { - #[command(subcommand)] - cmd: CardCmd, - }, /// Manage scoped MCP tokens for AI clients. Token { #[command(subcommand)] cmd: TokenCmd, }, - /// Run a local stdio MCP server bridging to the HTTP `/mcp` endpoint. + /// Run a local stdio MCP server bridging to the HTTP `/mcp` endpoint + /// (or the separate kanban server at `/mcp/kanban` with --kanban). #[command(name = "mcp-stdio")] - McpStdio, + McpStdio { + /// Bridge the separate kanban MCP server (/mcp/kanban) instead of notes. + #[arg(long)] + kanban: bool, + }, } #[derive(Subcommand)] @@ -166,71 +171,64 @@ enum NoteCmd { #[derive(Subcommand)] enum BoardCmd { - /// List boards. - List { - #[arg(long)] - workspace: Option, - }, - /// Show a board with columns and cards. - Get { - #[arg(long)] - workspace: Option, + /// List boards in the vault (scans .board files). + List, + /// Show a board with its columns and cards. + Show { + /// Board id (from `jtype board list`). board: String, }, } #[derive(Subcommand)] enum CardCmd { - /// List cards on a board. + /// List a board's cards, optionally filtered to one column/status. List { - #[arg(long)] - workspace: Option, #[arg(long)] board: String, #[arg(long)] - column: Option, + status: Option, }, - /// Create a card. + /// Create a card-note in a column (writes a .md + write-through to cloud). Create { #[arg(long)] workspace: Option, #[arg(long)] board: String, #[arg(long)] - column: String, + status: String, title: String, #[arg(long)] - description: Option, - #[arg(long)] priority: Option, #[arg(long)] assignee: Option, + #[arg(long)] + due: Option, }, - /// Update a card's fields. - Update { + /// Move a card to another column (rewrites its `status` frontmatter). + Move { #[arg(long)] workspace: Option, - card: String, + /// Card path relative to the vault. + path: String, + #[arg(long = "to")] + to: String, #[arg(long)] - title: Option, + position: Option, + }, + /// Set card fields (empty value clears): any of --status/--priority/--assignee/--due. + Set { #[arg(long)] - description: Option, + workspace: Option, + path: String, + #[arg(long)] + status: Option, #[arg(long)] priority: Option, #[arg(long)] assignee: Option, - }, - /// Move a card to another column (status change). - Move { #[arg(long)] - workspace: Option, - #[arg(long)] - board: String, - card: String, - #[arg(long, name = "to-column")] - to_column: String, - #[arg(long, default_value_t = 0)] - position: i64, + due: Option, }, } @@ -254,24 +252,6 @@ async fn main() { } } -/// Resolve a cloud workspace id for a REMOTE command: explicit `--workspace`, else the -/// bound `workspaceId` of the cwd vault, else a readable error. -fn resolve_remote_ws(explicit: Option<&str>, vault_opt: Option<&str>) -> Result { - if let Some(ws) = explicit { - return Ok(ws.to_string()); - } - if let Ok(root) = vault::require_vault(vault_opt) { - if let Some(b) = vault::load_binding(&root) { - if b.is_bound() { - return Ok(b.workspace_id); - } - } - } - Err(anyhow!( - "--workspace required (or run `jtype bind` in a vault to set a default workspace)" - )) -} - async fn run() -> Result<()> { let cli = Cli::parse(); let mut cfg = Config::load()?; @@ -331,6 +311,46 @@ async fn run() -> Result<()> { notes::save_note_local(&root, &cfg, workspace.as_deref(), &path, &body, None, json).await? } }, + Command::Board { cmd } => match cmd { + BoardCmd::List => { + let root = vault::require_vault(cli.vault.as_deref())?; + kanban::list_boards_local(&root, json)? + } + BoardCmd::Show { board } => { + let root = vault::require_vault(cli.vault.as_deref())?; + kanban::show_board_local(&root, &board, json)? + } + }, + Command::Card { cmd } => match cmd { + CardCmd::List { board, status } => { + let root = vault::require_vault(cli.vault.as_deref())?; + kanban::list_cards_local(&root, &board, status.as_deref(), json)? + } + CardCmd::Create { + workspace, board, status, title, priority, assignee, due, + } => { + let root = vault::vault_or_init(cli.vault.as_deref())?; + kanban::create_card_local( + &root, &cfg, workspace.as_deref(), &board, &status, &title, + priority.as_deref(), assignee.as_deref(), due.as_deref(), json, + ) + .await? + } + CardCmd::Move { workspace, path, to, position } => { + let root = vault::require_vault(cli.vault.as_deref())?; + kanban::move_card_local(&root, &cfg, workspace.as_deref(), &path, &to, position, json).await? + } + CardCmd::Set { + workspace, path, status, priority, assignee, due, + } => { + let root = vault::require_vault(cli.vault.as_deref())?; + kanban::set_card_local( + &root, &cfg, workspace.as_deref(), &path, + status.as_deref(), priority.as_deref(), assignee.as_deref(), due.as_deref(), json, + ) + .await? + } + }, Command::Bind { workspace } => { cfg.require_token()?; let root = vault::vault_or_init(cli.vault.as_deref())?; @@ -383,54 +403,6 @@ async fn run() -> Result<()> { let root = vault::require_vault(cli.vault.as_deref())?; sync::sync(&cfg, &root, json).await? } - Command::Board { cmd } => { - cfg.require_token()?; - let c = client(); - match cmd { - BoardCmd::List { workspace } => { - let ws = resolve_remote_ws(workspace.as_deref(), cli.vault.as_deref())?; - kanban::list_boards(&c, &ws, json).await? - } - BoardCmd::Get { workspace, board } => { - let ws = resolve_remote_ws(workspace.as_deref(), cli.vault.as_deref())?; - kanban::get_board(&c, &ws, &board, json).await? - } - } - } - Command::Card { cmd } => { - cfg.require_token()?; - let c = client(); - match cmd { - CardCmd::List { workspace, board, column } => { - let ws = resolve_remote_ws(workspace.as_deref(), cli.vault.as_deref())?; - kanban::list_cards(&c, &ws, &board, column.as_deref(), json).await? - } - CardCmd::Create { - workspace, board, column, title, description, priority, assignee, - } => { - let ws = resolve_remote_ws(workspace.as_deref(), cli.vault.as_deref())?; - kanban::create_card( - &c, &ws, &board, &column, &title, - description.as_deref(), priority.as_deref(), assignee.as_deref(), json, - ) - .await? - } - CardCmd::Update { - workspace, card, title, description, priority, assignee, - } => { - let ws = resolve_remote_ws(workspace.as_deref(), cli.vault.as_deref())?; - kanban::update_card( - &c, &ws, &card, title.as_deref(), - description.as_deref(), priority.as_deref(), assignee.as_deref(), json, - ) - .await? - } - CardCmd::Move { workspace, board, card, to_column, position } => { - let ws = resolve_remote_ws(workspace.as_deref(), cli.vault.as_deref())?; - kanban::move_card(&c, &ws, &board, &card, &to_column, position, json).await? - } - } - } Command::Token { cmd } => { cfg.require_token()?; let c = client(); @@ -442,9 +414,10 @@ async fn run() -> Result<()> { TokenCmd::Revoke { id } => tokens::revoke(&c, &id).await?, } } - Command::McpStdio => { + Command::McpStdio { kanban } => { cfg.require_token()?; - mcpstdio::run(&client()).await? + let endpoint = if kanban { "/mcp/kanban" } else { "/mcp" }; + mcpstdio::run(&client(), endpoint).await? } } Ok(()) diff --git a/services/jtype-cli/src/mcpstdio.rs b/services/jtype-cli/src/mcpstdio.rs index abdfe26..0433a08 100644 --- a/services/jtype-cli/src/mcpstdio.rs +++ b/services/jtype-cli/src/mcpstdio.rs @@ -12,7 +12,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Stdout}; use crate::client::ApiClient; -pub async fn run(client: &ApiClient) -> Result<()> { +pub async fn run(client: &ApiClient, endpoint: &str) -> Result<()> { let stdin = tokio::io::stdin(); let mut lines = BufReader::new(stdin).lines(); let mut stdout = tokio::io::stdout(); @@ -28,7 +28,7 @@ pub async fn run(client: &ApiClient) -> Result<()> { .ok() .and_then(|v| v.get("id").cloned()); - match client.post_mcp_raw(trimmed).await { + match client.post_mcp_raw(trimmed, endpoint).await { Ok((status, body)) => { if status.is_success() { // Forward the upstream JSON-RPC response. An empty 2xx body is diff --git a/services/jtype-cli/tests/e2e.sh b/services/jtype-cli/tests/e2e.sh index 24deb18..50ec4aa 100644 --- a/services/jtype-cli/tests/e2e.sh +++ b/services/jtype-cli/tests/e2e.sh @@ -3,9 +3,9 @@ # # Exercises every subcommand, including the real OAuth device flow (login is # approved out-of-band with a freshly-registered user's token). Seeds a -# workspace + board via REST (the CLI intentionally has no workspace/board -# create), then drives local-first notes (bind + write-through + sync), kanban, -# and the stdio MCP bridge. +# workspace via REST (the CLI intentionally has no workspace create), then +# drives local-first notes (bind + write-through + sync) and the stdio MCP +# bridge. # # Usage: bash e2e.sh [SERVER_URL] (default http://localhost:13346) @@ -50,15 +50,10 @@ else fi JT whoami | grep -q "$USER" && ok "whoami" || no "whoami" -# 2. Seed a workspace + board via REST. +# 2. Seed a workspace via REST. WS=$(curl -s -X POST "$SERVER/api/v1/workspaces" -H "authorization: Bearer $TOKEN" \ -H 'content-type: application/json' -d '{"name":"CLI E2E"}' | jq -r .id) -BOARD=$(curl -s -X POST "$SERVER/api/v1/workspaces/$WS/kanban/boards" -H "authorization: Bearer $TOKEN" \ - -H 'content-type: application/json' -d '{"name":"CLI Board"}') -BID=$(echo "$BOARD" | jq -r .id) -COL=$(echo "$BOARD" | jq -r '.columns[0].id') -COL2=$(echo "$BOARD" | jq -r '.columns[1].id') -[ -n "$WS" ] && [ "$WS" != null ] && ok "seed workspace+board" || no "seed workspace+board" +[ -n "$WS" ] && [ "$WS" != null ] && ok "seed workspace" || no "seed workspace" # 3. Local-first notes: bind a vault, read/write disk files, write-through to cloud. VAULT="$WORK/vault"; mkdir -p "$VAULT" @@ -90,17 +85,7 @@ chk "sync (pull + push)" JTV sync JTV --json sync | jq -e '.pulled.written == 0' >/dev/null 2>&1 \ && ok "re-sync idempotent (0 written)" || no "re-sync idempotent" -# 4. Kanban. -JT board list --workspace "$WS" | grep -q "CLI Board" && ok "board list" || no "board list" -BG=$(JT board get --workspace "$WS" "$BID"); echo "$BG" | grep -q "To do" && ok "board get" || no "board get" -CID=$(JT --json card create --workspace "$WS" --board "$BID" --column "$COL" "Ship CLI" --priority high | jq -r .id) -[ -n "$CID" ] && [ "$CID" != null ] && ok "card create" || no "card create" -JT card list --workspace "$WS" --board "$BID" --column "$COL" | grep -q "Ship CLI" && ok "card list" || no "card list" -chk "card update" JT card update --workspace "$WS" "$CID" --priority urgent -chk "card move" JT card move --workspace "$WS" --board "$BID" "$CID" --to-column "$COL2" -JT card list --workspace "$WS" --board "$BID" --column "$COL2" | grep -q "Ship CLI" && ok "card moved to Doing" || no "card moved to Doing" - -# 5. stdio MCP bridge. +# 4. stdio MCP bridge. TL=$(printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | JT mcp-stdio) echo "$TL" | jq -e '.result.tools | length >= 14' >/dev/null 2>&1 && ok "mcp-stdio tools/list (>=14 tools)" || no "mcp-stdio tools/list" CALLR=$(printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_workspaces","arguments":{}}}' | JT mcp-stdio) diff --git a/services/jtype-core/src/lib.rs b/services/jtype-core/src/lib.rs index f6ba656..1994152 100644 --- a/services/jtype-core/src/lib.rs +++ b/services/jtype-core/src/lib.rs @@ -1150,7 +1150,7 @@ fn normalize_status(frontmatter: &HashMap) -> &'static str { } } -fn parse_frontmatter(content: &str) -> HashMap { +pub fn parse_frontmatter(content: &str) -> HashMap { let mut frontmatter = HashMap::new(); let normalized = content.replace("\r\n", "\n"); let mut lines = normalized.lines(); @@ -1531,6 +1531,136 @@ fn scan_board_cards_inner( Ok(()) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoardColumnInfo { + pub key: String, + pub name: String, +} + +/// A `.board` view file's summary: id / title / columns, without loading cards. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BoardSummaryInfo { + pub relative_path: String, + pub id: String, + pub title: String, + pub columns: Vec, +} + +/// Walk the vault for `.board` view files and parse each one's JSON config into a +/// summary (id / title / columns). The board's cards are NOT loaded — call +/// [`scan_board_cards`] with the returned `id` for that. +pub fn list_boards(root: &Path) -> Result, String> { + let mut boards = Vec::new(); + list_boards_inner(root, root, &mut boards)?; + boards.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(boards) +} + +fn list_boards_inner(root: &Path, current: &Path, boards: &mut Vec) -> Result<(), String> { + for entry in fs::read_dir(current).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name == ".git" || file_name == "node_modules" || file_name == "target" || file_name == ".jtype" { + continue; + } + if path.is_dir() { + list_boards_inner(root, &path, boards)?; + } else if is_board_path(&path) { + let content = fs::read_to_string(&path).unwrap_or_default(); + let cfg: serde_json::Value = serde_json::from_str(&content).unwrap_or(serde_json::Value::Null); + let relative = path.strip_prefix(root).map_err(|error| error.to_string())?; + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("board") + .to_string(); + let id = cfg + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(&stem) + .to_string(); + let title = cfg + .get("title") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(&id) + .to_string(); + let columns = cfg + .get("columns") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| { + let key = c.get("key").and_then(|v| v.as_str())?.to_string(); + let name = c + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&key) + .to_string(); + Some(BoardColumnInfo { key, name }) + }) + .collect() + }) + .unwrap_or_default(); + boards.push(BoardSummaryInfo { + relative_path: path_to_string(relative), + id, + title, + columns, + }); + } + } + Ok(()) +} + +/// Set (or, with `value == None`, remove) one flat frontmatter field, preserving +/// every other frontmatter line and the body verbatim. Creates a frontmatter +/// block if the content has none. The write-side counterpart to +/// [`parse_frontmatter`]: card mutations compose on it — move = set `status` + +/// `position`; edit = set `priority`/`assignee`/`due`/…; create = set the lot +/// onto a `# Title` body. +pub fn set_frontmatter_field(content: &str, key: &str, value: Option<&str>) -> String { + let normalized = content.replace("\r\n", "\n"); + let (mut fm_lines, body): (Vec, String) = match normalized.strip_prefix("---\n") { + Some(rest) => match rest.find("\n---") { + Some(end) => { + let fm = rest[..end].lines().map(str::to_string).collect(); + // `rest[end + 1..]` starts at the closing `---`; drop it + leading newlines. + let body = rest[end + 1..] + .strip_prefix("---") + .map(|b| b.trim_start_matches('\n').to_string()) + .unwrap_or_default(); + (fm, body) + } + None => (Vec::new(), normalized.clone()), + }, + None => (Vec::new(), normalized.clone()), + }; + + let pos = fm_lines.iter().position(|line| { + line.split_once(':') + .map(|(k, _)| k.trim() == key) + .unwrap_or(false) + }); + match (pos, value) { + (Some(i), Some(v)) => fm_lines[i] = format!("{key}: {v}"), + (Some(i), None) => { + fm_lines.remove(i); + } + (None, Some(v)) => fm_lines.push(format!("{key}: {v}")), + (None, None) => {} + } + + if fm_lines.is_empty() { + return body; + } + format!("---\n{}\n---\n{}", fm_lines.join("\n"), body) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CardTemplateInfo { @@ -2611,6 +2741,48 @@ mod tests { assert_eq!(b.task_total, 0); } + #[test] + fn set_frontmatter_field_updates_inserts_and_removes() { + let base = "---\nboard: rm\nstatus: todo\n---\n# Card\n\nbody\n"; + // Update an existing key — this is the card "move" (change status). + let moved = set_frontmatter_field(base, "status", Some("doing")); + let fm = parse_frontmatter(&moved); + assert_eq!(fm.get("status").map(String::as_str), Some("doing")); + assert_eq!(fm.get("board").map(String::as_str), Some("rm")); + assert!(moved.contains("# Card") && moved.contains("body")); + // Insert a key that was absent. + let with_pri = set_frontmatter_field(&moved, "priority", Some("high")); + assert_eq!( + parse_frontmatter(&with_pri).get("priority").map(String::as_str), + Some("high") + ); + // Remove a key (clear field). + let cleared = set_frontmatter_field(&with_pri, "priority", None); + assert!(parse_frontmatter(&cleared).get("priority").is_none()); + // Create a frontmatter block where the note had none. + let created = set_frontmatter_field("# Plain\n", "board", Some("rm")); + assert!(created.starts_with("---\nboard: rm\n---\n")); + } + + #[test] + fn list_boards_parses_board_files() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("roadmap.board"), + r#"{"id":"rm","title":"Roadmap","columns":[{"key":"todo","name":"To do"},{"key":"doing","name":"Doing"}]}"#, + ) + .unwrap(); + fs::write(root.join("note.md"), "# note\n").unwrap(); + let boards = list_boards(root).unwrap(); + assert_eq!(boards.len(), 1); + assert_eq!(boards[0].id, "rm"); + assert_eq!(boards[0].title, "Roadmap"); + assert_eq!(boards[0].columns.len(), 2); + assert_eq!(boards[0].columns[0].key, "todo"); + assert_eq!(boards[0].columns[0].name, "To do"); + } + #[test] fn sync_collects_board_files_as_opaque_documents() { let dir = tempdir().unwrap(); diff --git a/services/jtype-web/Cargo.lock b/services/jtype-web/Cargo.lock index f409945..2095e99 100644 --- a/services/jtype-web/Cargo.lock +++ b/services/jtype-web/Cargo.lock @@ -1003,6 +1003,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jtype-core" +version = "0.1.0" +dependencies = [ + "pulldown-cmark", + "serde", + "serde_json", +] + [[package]] name = "jtype-web" version = "0.2.0" @@ -1014,6 +1023,7 @@ dependencies = [ "futures", "hex", "hmac", + "jtype-core", "lettre", "mime_guess", "object_store", diff --git a/services/jtype-web/Cargo.toml b/services/jtype-web/Cargo.toml index 3620b07..37ddce4 100644 --- a/services/jtype-web/Cargo.toml +++ b/services/jtype-web/Cargo.toml @@ -8,6 +8,7 @@ argon2 = "0.5" axum = { version = "0.7", features = ["ws"] } base64 = "0.22" hex = "0.4" +jtype-core = { path = "../jtype-core" } lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "rustls-tls", "tokio1-rustls-tls"] } mime_guess = "2" object_store = { version = "0.11", features = ["aws"] } diff --git a/services/jtype-web/Dockerfile b/services/jtype-web/Dockerfile index e8d85d0..2ab94e5 100644 --- a/services/jtype-web/Dockerfile +++ b/services/jtype-web/Dockerfile @@ -22,6 +22,10 @@ WORKDIR /app # version). Defaults are per-stage in Docker, so re-declare the ARG here. ARG JTYPE_VERSION=0.1.0 ENV JTYPE_VERSION=$JTYPE_VERSION +# jtype-web depends on the sibling crate via `jtype-core = { path = "../jtype-core" }`. +# WORKDIR is /app, so that path resolves to /jtype-core — copy the crate there. +# (`.dockerignore` strips its target/, so this stays lean.) +COPY services/jtype-core /jtype-core COPY services/jtype-web/Cargo.toml ./Cargo.toml COPY services/jtype-web/src ./src COPY services/jtype-web/migrations ./migrations diff --git a/services/jtype-web/frontend/src/api.ts b/services/jtype-web/frontend/src/api.ts index 228808a..4c3fcbd 100644 --- a/services/jtype-web/frontend/src/api.ts +++ b/services/jtype-web/frontend/src/api.ts @@ -261,6 +261,27 @@ export const api = { request(`/api/v1/workspaces/${workspaceId}/documents/${docId}/publish`, { method: 'DELETE' }), listVersions: (workspaceId: string, docId: string) => request(`/api/v1/workspaces/${workspaceId}/documents/${docId}/versions`), + // Card comments (document-backed board): keyed by the card's document id. + listComments: (workspaceId: string, docId: string) => + request(`/api/v1/workspaces/${workspaceId}/documents/${docId}/comments`), + createComment: (workspaceId: string, docId: string, body: string) => + request(`/api/v1/workspaces/${workspaceId}/documents/${docId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }), + deleteComment: (workspaceId: string, commentId: string) => + request(`/api/v1/workspaces/${workspaceId}/comments/${commentId}`, { method: 'DELETE' }), + // Webhooks (document-backed board): board scope is a board's logical id. + listWebhooks: (workspaceId: string) => + request(`/api/v1/workspaces/${workspaceId}/webhooks`), + createWebhook: (workspaceId: string, data: { name: string; targetUrl: string; boardRef?: string | null; eventTypes: string[] }) => + request(`/api/v1/workspaces/${workspaceId}/webhooks`, { method: 'POST', body: JSON.stringify(data) }), + deleteWebhook: (workspaceId: string, webhookId: string) => + request(`/api/v1/workspaces/${workspaceId}/webhooks/${webhookId}`, { method: 'DELETE' }), + // Ticket links (OCCSV-3371): per-card number is cloud-indexed, scoped to a workspace. + allocateTicket: (workspaceId: string, data: { relativePath: string; ticketKey: string }) => + request(`/api/v1/workspaces/${workspaceId}/tickets/allocate`, { method: 'POST', body: JSON.stringify(data) }), + listTickets: (workspaceId: string) => + request(`/api/v1/workspaces/${workspaceId}/tickets`), + resolveTicket: (workspaceId: string, ticket: string) => + request(`/api/v1/workspaces/${workspaceId}/tickets/${encodeURIComponent(ticket)}`), listTrash: (workspaceId: string) => request(`/api/v1/workspaces/${workspaceId}/trash`), restoreTrash: (workspaceId: string, trashId: string) => @@ -294,70 +315,6 @@ export const api = { uploadCertificate: (id: string, certChainPem: string, privateKeyPem: string) => request(`/api/v1/domains/${id}/certificate`, { method: 'POST', body: JSON.stringify({ certChainPem, privateKeyPem }) }), - // Kanban (cloud). Mirrors services/jtype-web/src/handlers/kanban. - kanban: { - listBoards: (workspaceId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards`), - createBoard: (workspaceId: string, data: { name: string; description?: string }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards`, { method: 'POST', body: JSON.stringify(data) }), - getBoard: (workspaceId: string, boardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}`), - patchBoard: (workspaceId: string, boardId: string, data: { name?: string; description?: string }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteBoard: (workspaceId: string, boardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}`, { method: 'DELETE' }), - reorderBoards: (workspaceId: string, boardIds: string[]) => - request<{ ok: boolean }>(`/api/v1/workspaces/${workspaceId}/kanban/boards/reorder`, { method: 'POST', body: JSON.stringify({ boardIds }) }), - - createColumn: (workspaceId: string, boardId: string, data: { name: string; wipLimit?: number | null; color?: string | null }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/columns`, { method: 'POST', body: JSON.stringify(data) }), - patchColumn: (workspaceId: string, columnId: string, data: { name?: string; wipLimit?: number | null; color?: string | null }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/columns/${columnId}`, { method: 'PATCH', body: JSON.stringify(data) }), - reorderColumns: (workspaceId: string, boardId: string, columnIds: string[]) => - request<{ ok: boolean }>(`/api/v1/workspaces/${workspaceId}/kanban/columns/reorder`, { method: 'POST', body: JSON.stringify({ boardId, columnIds }) }), - deleteColumn: (workspaceId: string, columnId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/columns/${columnId}`, { method: 'DELETE' }), - - listCards: (workspaceId: string, boardId: string, includeArchived = false) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/cards${includeArchived ? '?includeArchived=true' : ''}`), - createCard: (workspaceId: string, boardId: string, data: CreateKanbanCardRequest) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/cards`, { method: 'POST', body: JSON.stringify(data) }), - patchCard: (workspaceId: string, cardId: string, data: UpdateKanbanCardRequest) => - request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}`, { method: 'PATCH', body: JSON.stringify(data) }), - moveCard: (workspaceId: string, boardId: string, data: MoveKanbanCardRequest) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/cards/move`, { method: 'POST', body: JSON.stringify(data) }), - archiveCard: (workspaceId: string, cardId: string) => - request<{ id: string; cardId: string }>(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/archive`, { method: 'POST', body: '{}' }), - restoreCard: (workspaceId: string, cardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/restore`, { method: 'POST', body: '{}' }), - deleteCard: (workspaceId: string, cardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}`, { method: 'DELETE' }), - listTrash: (workspaceId: string, boardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/trash`), - listComments: (workspaceId: string, cardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/comments`), - createComment: (workspaceId: string, cardId: string, body: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/comments`, { method: 'POST', body: JSON.stringify({ body }) }), - deleteComment: (workspaceId: string, commentId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/comments/${commentId}`, { method: 'DELETE' }), - getCardActivity: (workspaceId: string, cardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/cards/${cardId}/activity`), - listWebhooks: (workspaceId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/webhooks`), - createWebhook: (workspaceId: string, data: { name: string; targetUrl: string; boardId?: string | null; eventTypes: string[] }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/webhooks`, { method: 'POST', body: JSON.stringify(data) }), - deleteWebhook: (workspaceId: string, webhookId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/webhooks/${webhookId}`, { method: 'DELETE' }), - - listLabels: (workspaceId: string, boardId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/labels`), - createLabel: (workspaceId: string, boardId: string, data: { name: string; color: string; description?: string }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/boards/${boardId}/labels`, { method: 'POST', body: JSON.stringify(data) }), - patchLabel: (workspaceId: string, labelId: string, data: { name?: string; color?: string; description?: string | null }) => - request(`/api/v1/workspaces/${workspaceId}/kanban/labels/${labelId}`, { method: 'PATCH', body: JSON.stringify(data) }), - deleteLabel: (workspaceId: string, labelId: string) => - request(`/api/v1/workspaces/${workspaceId}/kanban/labels/${labelId}`, { method: 'DELETE' }), - }, } // Types @@ -578,6 +535,41 @@ export interface DocumentVersion { contentHash: string content: string createdAt: string + authorUsername?: string | null +} + +export interface CardComment { + id: string + documentId: string + authorUserId: string + author: string | null + body: string + createdAt: string + updatedAt: string +} + +export interface Webhook { + id: string + boardRef?: string | null + name: string + targetUrl: string + eventTypes: string[] + enabled: boolean + secretMasked: string + lastDeliveryAt?: string | null + lastStatus?: string | null + createdAt: string +} +export interface WebhookCreated extends Webhook { + secret: string +} + +export interface Ticket { + documentId: string + relativePath: string | null + ticketKey: string + number: number + ticket: string } export interface TrashItem { @@ -776,163 +768,3 @@ export interface InvitePreview { role: string status: 'pending' | 'accepted' | 'revoked' } - -// Kanban types (camelCase mirror of the cloud JSON contract) -export type KanbanPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent' - -export interface KanbanBoardSummary { - id: string - workspaceId: string - name: string - description: string | null - position: number - createdByUserId: string - updatedClock: number - createdAt: string - updatedAt: string - cardCount: number - columnCount: number -} - -export interface KanbanColumn { - id: string - boardId: string - name: string - position: number - wipLimit: number | null - color: string | null - cardCount: number -} - -export interface KanbanCard { - id: string - workspaceId: string - boardId: string - columnId: string - title: string - description: string | null - position: number - priority: KanbanPriority - dueAt: string | null - assigneeUserId: string | null - propertiesExtra: unknown | null - labelIds: string[] - createdByUserId: string - updatedClock: number - versionId: string - archivedAt: string | null - createdAt: string - updatedAt: string -} - -export interface KanbanLabel { - id: string - boardId: string - name: string - color: string - description: string | null - createdAt: string - updatedAt: string -} - -export interface KanbanBoardFull extends KanbanBoardSummary { - columns: KanbanColumn[] - cards: KanbanCard[] - labels: KanbanLabel[] -} - -export interface KanbanWebhook { - id: string - boardId?: string | null - name: string - targetUrl: string - eventTypes: string[] - enabled: boolean - secretMasked: string - lastDeliveryAt?: string | null - lastStatus?: string | null - createdAt: string -} -export interface KanbanWebhookCreated extends KanbanWebhook { - secret: string -} - -export interface KanbanComment { - id: string - cardId: string - authorUserId: string - author?: string | null - body: string - createdAt: string - updatedAt: string -} - -export interface KanbanActivityEvent { - kind: string - at: string - by?: string | null -} - -export interface KanbanTrashItem { - id: string - cardId: string - workspaceId: string - boardId: string - columnId: string - title: string - description: string | null - priority: KanbanPriority - position: number - dueAt: string | null - assigneeUserId: string | null - labelIds: string[] - archivedByUserId: string - archivedByDeviceId: string | null - archivedClock: number - archivedAt: string - expiresAt: string - restoredAt: string | null -} - -export interface CreateKanbanCardRequest { - columnId: string - title: string - description?: string - priority?: KanbanPriority - dueAt?: string - assigneeUserId?: string - labelIds?: string[] - propertiesExtra?: Record | null -} - -export interface UpdateKanbanCardRequest { - title?: string - description?: string | null - priority?: KanbanPriority - dueAt?: string | null - assigneeUserId?: string | null - labelIds?: string[] - propertiesExtra?: Record | null - baseUpdatedClock?: number - force?: boolean -} - -export interface MoveKanbanCardRequest { - cardId: string - targetColumnId: string - targetPosition: number - baseUpdatedClock?: number - force?: boolean -} - -export interface KanbanConflict { - error: 'conflict' - cardId: string - latest: KanbanCard - baseUpdatedClock: number | null -} - -/** Narrow a patch/move response that may be a 409 conflict payload. */ -export function isKanbanConflict(r: KanbanCard | KanbanConflict): r is KanbanConflict { - return (r as KanbanConflict).error === 'conflict' -} diff --git a/services/jtype-web/frontend/src/main.tsx b/services/jtype-web/frontend/src/main.tsx index 2c26311..2f4810d 100644 --- a/services/jtype-web/frontend/src/main.tsx +++ b/services/jtype-web/frontend/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode, Suspense, lazy } from 'react' import { useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' -import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, useNavigate, useParams } from 'react-router-dom' import { I18nProvider } from '@lingui/react' import './index.css' import { AuthProvider } from './components/AuthContext' @@ -19,7 +19,6 @@ import { Landing } from './pages/Landing' import { Login } from './pages/Login' import { Admin } from './pages/Admin' import { Workspace } from './pages/Workspace' -import { Kanban } from './pages/Kanban' import { DeviceOAuth } from './pages/DeviceOAuth' import { ResetPassword } from './pages/ResetPassword' import { VerifyEmail } from './pages/VerifyEmail' @@ -40,6 +39,21 @@ async function loadPlatformMessages(locale: SupportedLocale): Promise() + const navigate = useNavigate() + const [error, setError] = useState('') + useEffect(() => { + if (!workspaceId || !ticket) return + api + .resolveTicket(workspaceId, ticket) + .then((r) => navigate(`/workspaces/${workspaceId}?doc=${encodeURIComponent(r.documentId)}`, { replace: true })) + .catch(() => setError(`Ticket ${ticket} not found.`)) + }, [workspaceId, ticket, navigate]) + return
{error || `Opening ${ticket}…`}
+} + function renderApp() { createRoot(document.getElementById('root')!).render( @@ -71,7 +85,7 @@ function renderApp() { } /> } /> } /> - } /> + } /> diff --git a/services/jtype-web/frontend/src/pages/Kanban.tsx b/services/jtype-web/frontend/src/pages/Kanban.tsx deleted file mode 100644 index 17aa7d0..0000000 --- a/services/jtype-web/frontend/src/pages/Kanban.tsx +++ /dev/null @@ -1,635 +0,0 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { useParams, useNavigate } from 'react-router-dom' -import { Dialog, DialogPanel, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react' -import { PlusIcon, EllipsisHorizontalIcon, TrashIcon, ArchiveBoxIcon, ArrowUturnLeftIcon, TagIcon, XMarkIcon, ChevronDownIcon, BoltIcon } from '@heroicons/react/24/outline' -import { - api, - setSessionId, - getStoredUsername, - isKanbanConflict, - type KanbanBoardSummary, - type KanbanBoardFull, - type KanbanLabel, - type KanbanTrashItem, - type KanbanPriority, - type KanbanWebhook, - type KanbanWebhookCreated, - type UpdateKanbanCardRequest, - type MemberInfo, -} from '../api' -import { useConfirm, usePrompt } from '@shared/components/PromptDialogContext' -import { BoardSurface, type BoardActions } from '@shared/components/board' -import { countTasks, bodyExcerpt, pickCustomFields, type BoardViewCard, type BoardViewConfig } from '@shared/lib/board' -import { useWorkspaceSocket } from '../hooks/useWorkspaceSocket' - -const VIEW_KEY = (boardId: string) => `kanban-view:${boardId}` - -/** - * Web adapter for the shared {@link BoardSurface}: the same board experience as - * the desktop, backed by the REST kanban API + realtime websocket. View settings - * (group-by/sort/view-type/colors) live in localStorage; data lives on the server. - */ -export function Kanban() { - const { workspaceId } = useParams<{ workspaceId: string }>() - const navigate = useNavigate() - const confirm = useConfirm() - const prompt = usePrompt() - - const [boards, setBoards] = useState([]) - const [boardId, setBoardId] = useState(null) - const [board, setBoard] = useState(null) - const [members, setMembers] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [showLabels, setShowLabels] = useState(false) - const [showTrash, setShowTrash] = useState(false) - const [showWebhooks, setShowWebhooks] = useState(false) - const [view, setView] = useState>({}) - - const { sessionId: wsSessionId, subscribe: wsSubscribe, status: wsStatus } = useWorkspaceSocket(workspaceId) - useEffect(() => { setSessionId(wsSessionId) }, [wsSessionId]) - - // ── data loading ── - const loadBoards = useCallback(async () => { - if (!workspaceId) return - const list = await api.kanban.listBoards(workspaceId) - setBoards(list) - setBoardId(prev => prev ?? list[0]?.id ?? null) - return list - }, [workspaceId]) - - const loadBoard = useCallback(async (id: string) => { - if (!workspaceId) return - setBoard(await api.kanban.getBoard(workspaceId, id)) - }, [workspaceId]) - - useEffect(() => { - let cancelled = false - setLoading(true); setError('') - Promise.all([loadBoards(), workspaceId ? api.listMembers(workspaceId).catch(() => []) : []]) - .then(([, mem]) => { if (!cancelled) setMembers(mem as MemberInfo[]) }) - .catch(err => { if (!cancelled) setError(String(err)) }) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [loadBoards, workspaceId]) - - useEffect(() => { - if (!boardId) { setBoard(null); return } - loadBoard(boardId).catch(err => setError(String(err))) - try { setView(JSON.parse(localStorage.getItem(VIEW_KEY(boardId)) || '{}')) } catch { setView({}) } - }, [boardId, loadBoard]) - - // ── realtime: refetch the open board on any kanban:* event ── - const refetchTimer = useRef | null>(null) - useEffect(() => { - const unsub = wsSubscribe((ev: { type?: string }) => { - if (!ev.type || !ev.type.startsWith('kanban:')) return - if (refetchTimer.current) clearTimeout(refetchTimer.current) - refetchTimer.current = setTimeout(() => { - loadBoards().catch(() => {}) - if (boardId) loadBoard(boardId).catch(() => {}) - }, 150) - }) - return () => { unsub(); if (refetchTimer.current) clearTimeout(refetchTimer.current) } - }, [wsSubscribe, boardId, loadBoard, loadBoards]) - - const reload = useCallback(() => { - loadBoards().catch(() => {}) - if (boardId) loadBoard(boardId).catch(err => setError(String(err))) - }, [loadBoards, loadBoard, boardId]) - - async function createBoard() { - if (!workspaceId) return - const name = (await prompt('New board name'))?.trim(); if (!name) return - try { const created = await api.kanban.createBoard(workspaceId, { name }); await loadBoards(); setBoardId(created.id) } catch (err) { setError(String(err)) } - } - async function renameBoard() { - if (!workspaceId || !board) return - const name = (await prompt('Rename board'))?.trim(); if (!name) return - try { await api.kanban.patchBoard(workspaceId, board.id, { name }); reload() } catch (err) { setError(String(err)) } - } - async function deleteBoard() { - if (!workspaceId || !board) return - if (!(await confirm(`Delete board "${board.name}" and all its cards? This cannot be undone.`, { title: 'Delete board', destructive: true }))) return - try { await api.kanban.deleteBoard(workspaceId, board.id); setBoardId(null); setBoard(null); await loadBoards() } catch (err) { setError(String(err)) } - } - - // ── normalization (REST → shared model) ── - const memberName = useCallback((uid: string | null) => (uid ? members.find(m => m.userId === uid)?.username ?? uid : null), [members]) - const memberIdByName = useMemo(() => new Map(members.map(m => [m.username, m.userId])), [members]) - const rawCardById = useMemo(() => new Map((board?.cards ?? []).map(c => [c.id, c])), [board]) - - const cards: BoardViewCard[] = useMemo(() => { - if (!board) return [] - const labelById = new Map(board.labels.map(l => [l.id, l])) - return board.cards - .filter(c => !c.archivedAt) - .map(c => { - const tasks = countTasks(c.description ?? '') - const propsObj = c.propertiesExtra && typeof c.propertiesExtra === 'object' ? (c.propertiesExtra as Record) : {} - const propsStr: Record = {} - for (const [k, v] of Object.entries(propsObj)) if (typeof v === 'string') propsStr[k] = v - return { - id: c.id, - columnKey: c.columnId, - position: c.position, - title: c.title, - icon: (c.propertiesExtra && typeof c.propertiesExtra === 'object' ? (c.propertiesExtra as Record).icon : null) as string | null, - priority: c.priority, - assignee: memberName(c.assigneeUserId), - due: c.dueAt ? c.dueAt.slice(0, 10) : null, - tags: c.labelIds.map(id => { const l = labelById.get(id); return { id, label: l?.name ?? id, color: l?.color } }), - notes: c.description ?? '', - taskDone: tasks.done, - taskTotal: tasks.total, - excerpt: bodyExcerpt(c.description ?? ''), - attachments: c.propertiesExtra && typeof c.propertiesExtra === 'object' && Array.isArray((c.propertiesExtra as Record).attachments) - ? ((c.propertiesExtra as Record).attachments as unknown[]).filter((x): x is string => typeof x === 'string') - : [], - custom: pickCustomFields(propsStr, view.fields), - } - }) - }, [board, memberName, view.fields]) - - const viewConfig: BoardViewConfig = useMemo( - () => - board - ? { - title: board.name, - columns: board.columns.slice().sort((a, b) => a.position - b.position).map(c => ({ key: c.id, name: c.name, color: c.color, limit: c.wipLimit })), - groupBy: (view.groupBy as BoardViewConfig['groupBy']) ?? 'status', - viewType: view.viewType ?? 'board', - calendarMode: view.calendarMode, - fields: view.fields, - swimlaneBy: view.swimlaneBy, - colorColumns: view.colorColumns, - doneColumn: view.doneColumn, - } - : { title: '', columns: [] }, - [board, view], - ) - - const assigneeOptions = useMemo( - () => [{ value: '', label: '—' }, ...members.map(m => ({ value: m.username, label: m.username }))], - [members], - ) - const tagOptions = useMemo(() => (board?.labels ?? []).map(l => ({ id: l.id, label: l.name, color: l.color })), [board]) - - const setConfig = useCallback( - (patch: Partial) => { - setView(prev => { - const next = { ...prev, ...patch } - if (boardId) localStorage.setItem(VIEW_KEY(boardId), JSON.stringify(next)) - return next - }) - }, - [boardId], - ) - - const doMove = useCallback( - async (id: string, toCol: string, index: number) => { - if (!workspaceId || !board) return - const raw = rawCardById.get(id) - if (!raw) return - const groupKey = view.groupBy ?? 'status' - try { - if (groupKey === 'priority') { - if (raw.priority === (toCol || 'none')) return - const res = await api.kanban.patchCard(workspaceId, id, { priority: (toCol || 'none') as KanbanPriority, baseUpdatedClock: raw.updatedClock }) - if (isKanbanConflict(res)) setError('Card changed elsewhere — reloaded latest.') - } else if (groupKey === 'assignee') { - const uid = toCol ? memberIdByName.get(toCol) ?? null : null - if (raw.assigneeUserId === uid) return - const res = await api.kanban.patchCard(workspaceId, id, { assigneeUserId: uid, baseUpdatedClock: raw.updatedClock }) - if (isKanbanConflict(res)) setError('Card changed elsewhere — reloaded latest.') - } else { - const count = board.cards.filter(c => c.columnId === toCol && !c.archivedAt && c.id !== id).length - const res = await api.kanban.moveCard(workspaceId, board.id, { - cardId: id, - targetColumnId: toCol, - targetPosition: Math.max(0, Math.min(index, count)), - baseUpdatedClock: raw.updatedClock, - }) - if (isKanbanConflict(res)) setError('Card changed elsewhere — reloaded latest.') - } - reload() - } catch (e) { setError(String(e)) } - }, - [workspaceId, board, rawCardById, view.groupBy, memberIdByName, reload], - ) - - const columnIds = useMemo(() => board?.columns.slice().sort((a, b) => a.position - b.position).map(c => c.id) ?? [], [board]) - - const actions: BoardActions = useMemo( - () => ({ - refresh: () => reload(), - setConfig, - moveCard: doMove, - createCard: async (colKey, title) => { - if (!workspaceId || !board) return - const groupKey = view.groupBy ?? 'status' - const columnId = groupKey === 'status' ? colKey : columnIds[0] - if (!columnId) return - try { - const created = await api.kanban.createCard(workspaceId, board.id, { - columnId, - title, - priority: groupKey === 'priority' ? ((colKey || 'none') as KanbanPriority) : undefined, - assigneeUserId: groupKey === 'assignee' && colKey ? memberIdByName.get(colKey) ?? undefined : undefined, - }) - reload() - return created.id - } catch (e) { setError(String(e)) } - }, - updateCard: async (id, patch) => { - if (!workspaceId || !board) return - const raw = rawCardById.get(id) - if (!raw) return - if (patch.columnKey !== undefined && patch.columnKey !== raw.columnId) { - await doMove(id, patch.columnKey, Number.MAX_SAFE_INTEGER) - return - } - const body: UpdateKanbanCardRequest = { baseUpdatedClock: raw.updatedClock } - if (patch.title !== undefined) body.title = patch.title - if (patch.priority !== undefined) body.priority = (patch.priority || 'none') as KanbanPriority - if (patch.assignee !== undefined) body.assigneeUserId = patch.assignee ? memberIdByName.get(patch.assignee) ?? null : null - if (patch.due !== undefined) body.dueAt = patch.due ? `${patch.due} 00:00:00` : null - if (patch.tags !== undefined) body.labelIds = patch.tags.map(t => t.id).filter(Boolean) as string[] - if (patch.notes !== undefined) body.description = patch.notes - if (patch.icon !== undefined || patch.attachments !== undefined || patch.custom !== undefined) { - const cur = raw.propertiesExtra && typeof raw.propertiesExtra === 'object' ? { ...(raw.propertiesExtra as Record) } : {} - if (patch.icon !== undefined) { - if (patch.icon) cur.icon = patch.icon - else delete cur.icon - } - if (patch.attachments !== undefined) { - if (patch.attachments.length) cur.attachments = patch.attachments - else delete cur.attachments - } - if (patch.custom !== undefined) for (const [k, v] of Object.entries(patch.custom)) { if (v) cur[k] = v; else delete cur[k] } - body.propertiesExtra = cur - } - try { - const res = await api.kanban.patchCard(workspaceId, id, body) - if (isKanbanConflict(res)) setError('Card changed elsewhere — reloaded latest.') - reload() - } catch (e) { setError(String(e)) } - }, - deleteCard: async (card) => { - if (!workspaceId) return - if (!(await confirm(`Archive card "${card.title}"? You can restore it from Archived cards.`, { title: 'Archive card' }))) return - try { await api.kanban.archiveCard(workspaceId, card.id); reload() } catch (e) { setError(String(e)) } - }, - duplicateCard: async (card) => { - if (!workspaceId || !board) return - const raw = rawCardById.get(card.id) - if (!raw) return - try { - await api.kanban.createCard(workspaceId, board.id, { - columnId: raw.columnId, - title: `${raw.title} copy`, - description: raw.description ?? undefined, - priority: raw.priority, - dueAt: raw.dueAt ?? undefined, - assigneeUserId: raw.assigneeUserId ?? undefined, - labelIds: raw.labelIds, - propertiesExtra: (raw.propertiesExtra as Record | null) ?? undefined, - }) - reload() - } catch (e) { setError(String(e)) } - }, - reorderColumns: async (fromKey, toKey) => { - if (!workspaceId || !board || fromKey === toKey) return - const ids = [...columnIds] - const from = ids.indexOf(fromKey) - const to = ids.indexOf(toKey) - if (from < 0 || to < 0) return - const [m] = ids.splice(from, 1) - if (m === undefined) return - ids.splice(to, 0, m) - try { await api.kanban.reorderColumns(workspaceId, board.id, ids); reload() } catch (e) { setError(String(e)) } - }, - addColumn: async (name) => { - if (!workspaceId || !board) return - try { await api.kanban.createColumn(workspaceId, board.id, { name }); reload() } catch (e) { setError(String(e)) } - }, - renameColumn: async (key) => { - if (!workspaceId || !board) return - const col = board.columns.find(c => c.id === key) - const name = (await prompt('Rename column', col?.name))?.trim(); if (!name) return - try { await api.kanban.patchColumn(workspaceId, key, { name }); reload() } catch (e) { setError(String(e)) } - }, - deleteColumn: async (key) => { - if (!workspaceId || !board || board.columns.length <= 1) return - const col = board.columns.find(c => c.id === key) - const colName = col?.name ?? 'this column' - const fallback = board.columns.filter(c => c.id !== key).sort((a, b) => a.position - b.position)[0] - const count = board.cards.filter(c => c.columnId === key && !c.archivedAt).length - const msg = count > 0 - ? `Delete column "${colName}"? Its ${count} card(s) move to "${fallback?.name}".` - : `Delete column "${colName}"?` - if (!(await confirm(msg, { title: 'Delete column', destructive: true }))) return - try { await api.kanban.deleteColumn(workspaceId, key); reload() } catch (e) { setError(String(e)) } - }, - setColumnColor: async (key, color) => { - if (!workspaceId) return - try { await api.kanban.patchColumn(workspaceId, key, { color }); reload() } catch (e) { setError(String(e)) } - }, - setColumnLimit: async (key) => { - if (!workspaceId || !board) return - const col = board.columns.find(c => c.id === key) - const raw = await prompt('WIP limit (blank to clear)', col?.wipLimit != null ? String(col.wipLimit) : '') - if (raw === null) return - const n = parseInt(raw.trim(), 10) - const wipLimit = raw.trim() === '' || Number.isNaN(n) || n <= 0 ? null : n - try { await api.kanban.patchColumn(workspaceId, key, { wipLimit }); reload() } catch (e) { setError(String(e)) } - }, - toggleDoneColumn: (key) => { - setConfig({ doneColumn: (view.doneColumn ?? 'done') === key ? undefined : key }) - }, - }), - [workspaceId, board, view.groupBy, view.doneColumn, columnIds, rawCardById, memberIdByName, doMove, reload, setConfig, prompt, confirm], - ) - - if (loading) { - return ( -
-
-
- ) - } - - return ( -
- {/* board chrome */} -
-
- - - {board ? board.name : 'Select board'} - - - - {boards.length === 0 &&
No boards yet
} - {boards.map(b => ( - - - - ))} -
- - - - -
- -
- {board && ( -
- - - - - - - - - - -
- )} -
- - {!board ? ( -
-

No board selected.

- -
- ) : ( -
- api.uploadAsset(workspaceId, file).then((a) => a.url) : undefined} - currentUser={getStoredUsername() ?? undefined} - loadComments={workspaceId ? (cardId) => api.kanban.listComments(workspaceId, cardId) : undefined} - addComment={workspaceId ? (cardId, body) => api.kanban.createComment(workspaceId, cardId, body) : undefined} - deleteComment={workspaceId ? (commentId) => api.kanban.deleteComment(workspaceId, commentId) : undefined} - loadActivity={workspaceId ? (cardId) => api.kanban.getCardActivity(workspaceId, cardId) : undefined} - /> -
- )} - - {showLabels && board && workspaceId && ( - setShowLabels(false)} onChanged={reload} /> - )} - {showTrash && board && workspaceId && ( - setShowTrash(false)} onChanged={reload} /> - )} - {showWebhooks && workspaceId && ( - setShowWebhooks(false)} /> - )} - - -
- ) -} - -// ── Labels manager ── -function LabelManagerDialog(props: { workspaceId: string; boardId: string; labels: KanbanLabel[]; onClose: () => void; onChanged: () => void }) { - const { workspaceId, boardId, labels, onClose, onChanged } = props - const confirm = useConfirm() - const [name, setName] = useState('') - const [color, setColor] = useState('#10b981') - const [err, setErr] = useState('') - - async function add() { - if (!name.trim()) return - try { await api.kanban.createLabel(workspaceId, boardId, { name: name.trim(), color }); setName(''); onChanged() } catch (e) { setErr(String(e)) } - } - async function remove(id: string) { - if (!(await confirm('Delete this label? It will be removed from all cards.', { title: 'Delete label', destructive: true }))) return - try { await api.kanban.deleteLabel(workspaceId, id); onChanged() } catch (e) { setErr(String(e)) } - } - - return ( - -
-
- -
-

Labels

- -
-
- {labels.map(l => ( -
- - {l.name} - -
- ))} - {labels.length === 0 &&

No labels yet.

} -
-
- setColor(e.target.value)} className="h-8 w-8 cursor-pointer rounded border border-black/[0.08]" /> - setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && add()} /> - -
- {err &&

{err}

} -
-
-
- ) -} - -// ── Archived cards (trash) ── -function TrashDialog(props: { workspaceId: string; boardId: string; onClose: () => void; onChanged: () => void }) { - const { workspaceId, boardId, onClose, onChanged } = props - const [items, setItems] = useState([]) - const [loading, setLoading] = useState(true) - const [err, setErr] = useState('') - - const load = useCallback(() => { - setLoading(true) - api.kanban.listTrash(workspaceId, boardId).then(setItems).catch(e => setErr(String(e))).finally(() => setLoading(false)) - }, [workspaceId, boardId]) - useEffect(() => { load() }, [load]) - - async function restore(cardId: string) { - try { await api.kanban.restoreCard(workspaceId, cardId); load(); onChanged() } catch (e) { setErr(String(e)) } - } - - return ( - -
-
- -
-

Archived cards

- -
- {loading ? ( -

Loading…

- ) : items.length === 0 ? ( -

No archived cards.

- ) : ( -
- {items.map(it => ( -
-
-

{it.title}

-

archived {it.archivedAt.slice(0, 10)} · expires {it.expiresAt.slice(0, 10)}

-
- -
- ))} -
- )} - {err &&

{err}

} -
-
-
- ) -} - -const WEBHOOK_EVENTS = ['kanban:card-updated', 'kanban:card-archived', '*'] - -function WebhooksDialog(props: { workspaceId: string; boardId: string | null; onClose: () => void }) { - const { workspaceId, boardId, onClose } = props - const [hooks, setHooks] = useState([]) - const [name, setName] = useState('') - const [url, setUrl] = useState('') - const [scope, setScope] = useState<'all' | 'board'>('all') - const [events, setEvents] = useState(['kanban:card-updated']) - const [revealed, setRevealed] = useState(null) - const [error, setError] = useState('') - - const load = useCallback(() => { - api.kanban.listWebhooks(workspaceId).then(setHooks).catch((e) => setError(String(e))) - }, [workspaceId]) - useEffect(() => { load() }, [load]) - - const toggleEvent = (e: string) => setEvents((prev) => (prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e])) - - const create = async () => { - if (!name.trim() || !url.trim() || events.length === 0) return - try { - const created = await api.kanban.createWebhook(workspaceId, { - name: name.trim(), - targetUrl: url.trim(), - boardId: scope === 'board' ? boardId : null, - eventTypes: events, - }) - setRevealed(created); setName(''); setUrl(''); setError(''); load() - } catch (e) { setError(String(e)) } - } - const remove = async (id: string) => { - try { await api.kanban.deleteWebhook(workspaceId, id); load() } catch (e) { setError(String(e)) } - } - - return ( - -
-
- -
-

Webhooks

- -
- {error &&

{error}

} - {revealed && ( -
-
Signing secret — shown once, copy it now:
- {revealed.secret} -
- )} -
    - {hooks.map((h) => ( -
  • -
    -
    {h.name}
    -
    {h.targetUrl}
    -
    {h.eventTypes.join(', ')}{h.boardId ? ' · this board' : ' · all boards'}{h.lastStatus ? ` · last: ${h.lastStatus}` : ''}
    -
    - -
  • - ))} - {hooks.length === 0 &&
  • No webhooks yet.
  • } -
-
{ e.preventDefault(); void create() }} className="space-y-2 border-t border-zinc-100 pt-3"> - setName(e.target.value)} /> - setUrl(e.target.value)} /> -
- {WEBHOOK_EVENTS.map((ev) => ( - - ))} -
-
- - {boardId && } -
-
- -
-
-
-
-
- ) -} diff --git a/services/jtype-web/frontend/src/pages/WebBoardView.tsx b/services/jtype-web/frontend/src/pages/WebBoardView.tsx index b4048d1..c724f0b 100644 --- a/services/jtype-web/frontend/src/pages/WebBoardView.tsx +++ b/services/jtype-web/frontend/src/pages/WebBoardView.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Dialog, DialogPanel } from '@headlessui/react' +import { BoltIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline' import { useConfirm, usePrompt } from '@shared/components/PromptDialogContext' import { parseFrontmatter, writeFrontmatter } from '@shared/lib/frontmatter' import { BoardSurface, type BoardActions } from '@shared/components/board' @@ -11,13 +13,16 @@ import { parseLinks, parseTagList, pickCustomFields, + resolveTags, serializeAttachments, serializeLinks, slugify, + type BoardActivityEvent, + type BoardComment, type BoardViewCard, type BoardViewConfig, } from '@shared/lib/board' -import { api, setSessionId } from '../api' +import { api, getStoredUsername, setSessionId, type MemberInfo, type Webhook, type WebhookCreated } from '../api' type CardMeta = { id: string; relativePath: string; content: string; contentHash: string } type BoardConfigJSON = { @@ -30,6 +35,8 @@ type BoardConfigJSON = { viewType?: 'board' | 'table' | 'calendar' calendarMode?: 'month' | 'agenda' fields?: { key: string; label: string; type?: 'text' | 'number' | 'date' }[] + labels?: { label: string; color?: string | null }[] + ticketKey?: string swimlaneBy?: 'status' | 'priority' | 'assignee' } @@ -65,6 +72,8 @@ export function WebBoardView({ const [cards, setCards] = useState([]) const [metaByPath, setMetaByPath] = useState>(new Map()) const [error, setError] = useState('') + const [showWebhooks, setShowWebhooks] = useState(false) + const [ticketByDoc, setTicketByDoc] = useState>(new Map()) const load = useCallback(async () => { try { @@ -95,7 +104,7 @@ export function WebBoardView({ priority: fm.data.priority || null, assignee: fm.data.assignee || null, due: fm.data.due || null, - tags: (fm.data.tags ? parseTagList(fm.data.tags) : []).map((label) => ({ label })), + tags: resolveTags(fm.data.tags ? parseTagList(fm.data.tags) : [], cfg.labels), notes: fm.body, taskDone: tasks.done, taskTotal: tasks.total, @@ -190,6 +199,8 @@ export function WebBoardView({ viewType: config.viewType, calendarMode: config.calendarMode, fields: config.fields, + labels: config.labels, + ticketKey: config.ticketKey, swimlaneBy: config.swimlaneBy as BoardViewConfig['swimlaneBy'], groupBy: (config.groupBy as BoardViewConfig['groupBy']) || 'status', } @@ -197,6 +208,109 @@ export function WebBoardView({ [config, boardDir], ) + // Members → assignee dropdown (web has a member system; desktop stays free text). + const [members, setMembers] = useState([]) + useEffect(() => { + let cancelled = false + api.listMembers(workspaceId).then((m) => { if (!cancelled) setMembers(m) }).catch(() => {}) + return () => { cancelled = true } + }, [workspaceId]) + const assigneeOptions = useMemo(() => { + const memberNames = new Set(members.map((m) => m.username)) + // Keep off-roster assignees (legacy values, removed members, or names typed on + // desktop's free-text field) both visible and selectable, never silently '—'. + const extra = [...new Set(cards.map((c) => c.assignee).filter((a): a is string => !!a && !memberNames.has(a)))] + return [ + { value: '', label: '—' }, + ...members.map((m) => ({ value: m.username, label: m.username })), + ...extra.map((a) => ({ value: a, label: a })), + ] + }, [members, cards]) + // Activity timeline derived from the card document's version history. Tag colors + // ride on each card's tags (resolveTags), so no tag-vocabulary prop is needed — + // the peek keeps its free-text tag input for adding arbitrary new tags. + const loadActivity = useCallback( + async (cardId: string): Promise => { + const meta = metaByPath.get(cardId) + if (!meta) return [] + try { + const versions = await api.listVersions(workspaceId, meta.id) + // Newest-first; the version with no parent is the card's creation. + return versions.map((v) => ({ + kind: v.parentVersionId ? 'updated' : 'created', + // Real author username; omit `by` (rather than show the write-channel + // token like 'web') when the author user no longer exists. + by: v.authorUsername ?? undefined, + at: v.createdAt, + })) + } catch { + return [] + } + }, + [workspaceId, metaByPath], + ) + + // Card comments — kept cloud-side, keyed by the card's document id. + const loadComments = useCallback( + async (cardId: string): Promise => { + const meta = metaByPath.get(cardId) + if (!meta) return [] + try { + return await api.listComments(workspaceId, meta.id) + } catch { + return [] + } + }, + [workspaceId, metaByPath], + ) + const addComment = useCallback( + (cardId: string, body: string): Promise => { + const meta = metaByPath.get(cardId) + if (!meta) return Promise.reject(new Error('card not found')) + return api.createComment(workspaceId, meta.id, body) + }, + [workspaceId, metaByPath], + ) + const deleteComment = useCallback( + (commentId: string) => api.deleteComment(workspaceId, commentId), + [workspaceId], + ) + + // Ticket links: fetch the workspace's ticket index, lazily allocating a number + // for any card that lacks one (allocation is idempotent server-side), then map + // documents.id → ticket so each card can show an OCCSV-3371 badge. + useEffect(() => { + const key = config?.ticketKey + if (!key) { setTicketByDoc(new Map()); return } + let cancelled = false + ;(async () => { + try { + const list = await api.listTickets(workspaceId) + const map = new Map(list.map((t) => [t.documentId, t.ticket])) + for (const meta of metaByPath.values()) { + if (!map.has(meta.id)) { + try { + const t = await api.allocateTicket(workspaceId, { relativePath: meta.relativePath, ticketKey: key }) + map.set(meta.id, t.ticket) + } catch { /* best-effort */ } + } + } + if (!cancelled) setTicketByDoc(map) + } catch { /* ignore */ } + })() + return () => { cancelled = true } + }, [workspaceId, config?.ticketKey, metaByPath]) + + const displayCards = useMemo( + () => + cards.map((c) => { + const docId = metaByPath.get(c.id)?.id + const ticket = docId ? ticketByDoc.get(docId) : undefined + return ticket ? { ...c, ticket } : c + }), + [cards, metaByPath, ticketByDoc], + ) + const actions: BoardActions = useMemo( () => ({ refresh: () => load(), @@ -368,16 +482,121 @@ export function WebBoardView({ ) return ( -
+
api.uploadAsset(workspaceId, file).then((a) => a.url)} fullscreen={fullscreen} onToggleFullscreen={onToggleFullscreen} /> + + {showWebhooks && ( + setShowWebhooks(false)} /> + )}
) } + +const WEBHOOK_EVENTS = ['kanban:card-updated', '*'] + +/** Register/list/delete outbound webhooks for this workspace, optionally scoped + * to the current board (by its logical id). Fires on card `.md` saves. */ +function WebhooksDialog({ workspaceId, board, onClose }: { workspaceId: string; board: string | null; onClose: () => void }) { + const [hooks, setHooks] = useState([]) + const [name, setName] = useState('') + const [url, setUrl] = useState('') + const [scope, setScope] = useState<'all' | 'board'>('all') + const [events, setEvents] = useState(['kanban:card-updated']) + const [revealed, setRevealed] = useState(null) + const [error, setError] = useState('') + + const load = useCallback(() => { + api.listWebhooks(workspaceId).then(setHooks).catch((e) => setError(String(e))) + }, [workspaceId]) + useEffect(() => { load() }, [load]) + + const toggleEvent = (e: string) => setEvents((prev) => (prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e])) + + const create = async () => { + if (!name.trim() || !url.trim() || events.length === 0) return + try { + const created = await api.createWebhook(workspaceId, { + name: name.trim(), + targetUrl: url.trim(), + boardRef: scope === 'board' ? board : null, + eventTypes: events, + }) + setRevealed(created); setName(''); setUrl(''); setError(''); load() + } catch (e) { setError(String(e)) } + } + const remove = async (id: string) => { + try { await api.deleteWebhook(workspaceId, id); load() } catch (e) { setError(String(e)) } + } + + return ( + +
+
+ +
+

Webhooks

+ +
+ {error &&

{error}

} + {revealed && ( +
+
Signing secret — shown once, copy it now:
+ {revealed.secret} +
+ )} +
    + {hooks.map((h) => ( +
  • +
    +
    {h.name}
    +
    {h.targetUrl}
    +
    {h.eventTypes.join(', ')}{h.boardRef ? ` · board: ${h.boardRef}` : ' · all boards'}{h.lastStatus ? ` · last: ${h.lastStatus}` : ''}
    +
    + +
  • + ))} + {hooks.length === 0 &&
  • No webhooks yet.
  • } +
+
{ e.preventDefault(); void create() }} className="space-y-2 border-t border-zinc-100 pt-3"> + setName(e.target.value)} /> + setUrl(e.target.value)} /> +
+ {WEBHOOK_EVENTS.map((ev) => ( + + ))} +
+
+ + {board && } +
+
+ +
+
+
+
+
+ ) +} diff --git a/services/jtype-web/frontend/src/pages/Workspace.tsx b/services/jtype-web/frontend/src/pages/Workspace.tsx index feee638..ba3962b 100644 --- a/services/jtype-web/frontend/src/pages/Workspace.tsx +++ b/services/jtype-web/frontend/src/pages/Workspace.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef, useCallback, useMemo, memo, lazy, Suspense } from 'react' import { Menu, MenuButton, MenuItems, MenuItem, Dialog, DialogPanel } from '@headlessui/react' -import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' +import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { api, getStoredUsername, setSessionId, type WorkspaceSummary, type DocumentListItem, type FolderListItem, type DomainResponse, type TrashItem, type MemberInfo, type InviteListItem, type InviteResponse, type PublishStatusResponse, type BlobManifestEntry } from '../api' import { renderToContainer } from '@shared/lib/markdown' import { parseFrontmatter, writeFrontmatter } from '@shared/lib/frontmatter' @@ -355,6 +355,20 @@ export function Workspace() { setDirty(false) } + // Deep link: `/workspaces/:id?doc=` (e.g. from a resolved + // OCCSV-3371 ticket link) opens that card document once the workspace is + // ready, then clears the param. + const [searchParams, setSearchParams] = useSearchParams() + useEffect(() => { + const doc = searchParams.get('doc') + if (!doc || !workspaceId) return + void openDocument(doc).catch(() => {}) + const next = new URLSearchParams(searchParams) + next.delete('doc') + setSearchParams(next, { replace: true }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, workspaceId]) + // Active (non-tombstoned) PDF blobs — the binary documents shown in the tree. const pdfBlobs = useMemo( () => blobs.filter(b => b.deletedClock == null && isBinaryDocumentPath(b.relativePath)), diff --git a/services/jtype-web/migrations/0018_drop_kanban.down.sql b/services/jtype-web/migrations/0018_drop_kanban.down.sql new file mode 100644 index 0000000..136b52d --- /dev/null +++ b/services/jtype-web/migrations/0018_drop_kanban.down.sql @@ -0,0 +1,3 @@ +-- Irreversible in this demo: the cloud Kanban tables are dropped without a +-- data-preserving rollback. To recreate the old schema for local testing, +-- replay migrations 0007 / 0016 / 0017 up SQL manually. No-op here. diff --git a/services/jtype-web/migrations/0018_drop_kanban.up.sql b/services/jtype-web/migrations/0018_drop_kanban.up.sql new file mode 100644 index 0000000..daecbef --- /dev/null +++ b/services/jtype-web/migrations/0018_drop_kanban.up.sql @@ -0,0 +1,16 @@ +-- Retire the cloud DB-backed Kanban subsystem (unification v2). +-- The board is now the document-backed `.board` + `.md` model synced via the +-- documents table; the standalone kanban_* tables are redundant and removed. +-- Comments / webhooks / ticket numbers will be rebuilt fresh on the document +-- model (keyed by documents.id) in later migrations. +-- Drop in child-to-parent FK order. + +DROP TABLE IF EXISTS kanban_webhook_deliveries; +DROP TABLE IF EXISTS kanban_webhooks; +DROP TABLE IF EXISTS kanban_card_comments; +DROP TABLE IF EXISTS kanban_card_trash; +DROP TABLE IF EXISTS kanban_card_labels; +DROP TABLE IF EXISTS kanban_cards; +DROP TABLE IF EXISTS kanban_labels; +DROP TABLE IF EXISTS kanban_columns; +DROP TABLE IF EXISTS kanban_boards; diff --git a/services/jtype-web/migrations/0019_card_comments.down.sql b/services/jtype-web/migrations/0019_card_comments.down.sql new file mode 100644 index 0000000..3fd8222 --- /dev/null +++ b/services/jtype-web/migrations/0019_card_comments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS card_comments; diff --git a/services/jtype-web/migrations/0019_card_comments.up.sql b/services/jtype-web/migrations/0019_card_comments.up.sql new file mode 100644 index 0000000..9f61903 --- /dev/null +++ b/services/jtype-web/migrations/0019_card_comments.up.sql @@ -0,0 +1,16 @@ +-- Card comments rebuilt on the document model (unification v2). A comment hangs +-- off the card's vault DOCUMENT (documents.id), not the retired kanban_cards row. +CREATE TABLE card_comments ( + id CHAR(36) NOT NULL, + workspace_id CHAR(36) NOT NULL, + document_id CHAR(36) NOT NULL, + author_user_id CHAR(36) NOT NULL, + body MEDIUMTEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_card_comments_doc (document_id, created_at), + CONSTRAINT card_comments_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT card_comments_doc_fk FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + CONSTRAINT card_comments_author_fk FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/services/jtype-web/migrations/0020_webhooks.down.sql b/services/jtype-web/migrations/0020_webhooks.down.sql new file mode 100644 index 0000000..066f361 --- /dev/null +++ b/services/jtype-web/migrations/0020_webhooks.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS webhook_deliveries; +DROP TABLE IF EXISTS webhooks; diff --git a/services/jtype-web/migrations/0020_webhooks.up.sql b/services/jtype-web/migrations/0020_webhooks.up.sql new file mode 100644 index 0000000..5c9914d --- /dev/null +++ b/services/jtype-web/migrations/0020_webhooks.up.sql @@ -0,0 +1,45 @@ +-- Webhooks rebuilt on the document model (unification v2). Re-homed off the +-- retired kanban_* tables: board scope is now the board's LOGICAL id (a card's +-- `board:` frontmatter value), not a kanban_boards row. Triggered from document +-- saves (handlers/document.rs), delivered by tasks::webhook_delivery. +CREATE TABLE webhooks ( + id CHAR(36) NOT NULL, + workspace_id CHAR(36) NOT NULL, + board_ref VARCHAR(255) NULL, + name VARCHAR(160) NOT NULL, + target_url VARCHAR(2048) NOT NULL, + secret CHAR(64) NOT NULL, + event_types JSON NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_by_user_id CHAR(36) NOT NULL, + last_delivery_at TIMESTAMP NULL, + last_status VARCHAR(32) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_webhooks_workspace (workspace_id), + KEY idx_webhooks_enabled (workspace_id, enabled), + CONSTRAINT webhooks_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT webhooks_creator_fk FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE webhook_deliveries ( + id CHAR(36) NOT NULL, + webhook_id CHAR(36) NOT NULL, + workspace_id CHAR(36) NOT NULL, + event_type VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + status ENUM('pending','succeeded','failed','dead') NOT NULL DEFAULT 'pending', + attempt_count INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 6, + last_status_code INT NULL, + last_error VARCHAR(512) NULL, + next_retry_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_deliveries_webhook (webhook_id), + KEY idx_deliveries_due (status, next_retry_at), + CONSTRAINT deliveries_webhook_fk FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE, + CONSTRAINT deliveries_workspace_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/services/jtype-web/migrations/0021_card_tickets.down.sql b/services/jtype-web/migrations/0021_card_tickets.down.sql new file mode 100644 index 0000000..43f3662 --- /dev/null +++ b/services/jtype-web/migrations/0021_card_tickets.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS card_tickets; +DROP TABLE IF EXISTS board_sequences; diff --git a/services/jtype-web/migrations/0021_card_tickets.up.sql b/services/jtype-web/migrations/0021_card_tickets.up.sql new file mode 100644 index 0000000..1db2b63 --- /dev/null +++ b/services/jtype-web/migrations/0021_card_tickets.up.sql @@ -0,0 +1,25 @@ +-- Jira-style ticket links (unification v2 / ticket-links.md). The per-card number +-- is CLOUD-INDEX-ONLY (not in frontmatter): board_sequences is the sole allocator, +-- card_tickets the immutable index keyed by documents.id. ticket_key + number are +-- snapshotted so the id (e.g. OCCSV-3371) is stable through board-key renames. +CREATE TABLE board_sequences ( + workspace_id CHAR(36) NOT NULL, + ticket_key VARCHAR(16) NOT NULL, + last_number BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (workspace_id, ticket_key), + CONSTRAINT board_sequences_ws_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE card_tickets ( + id CHAR(36) NOT NULL, + workspace_id CHAR(36) NOT NULL, + document_id CHAR(36) NOT NULL, + ticket_key VARCHAR(16) NOT NULL, + number BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_card_tickets_doc (document_id), + UNIQUE KEY uq_card_tickets_ref (workspace_id, ticket_key, number), + CONSTRAINT card_tickets_ws_fk FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + CONSTRAINT card_tickets_doc_fk FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/services/jtype-web/src/db/migrations.rs b/services/jtype-web/src/db/migrations.rs index 8a02670..39b66c5 100644 --- a/services/jtype-web/src/db/migrations.rs +++ b/services/jtype-web/src/db/migrations.rs @@ -110,6 +110,30 @@ fn all_migrations() -> Vec { up: include_str!("../../migrations/0017_kanban_webhooks.up.sql"), down: include_str!("../../migrations/0017_kanban_webhooks.down.sql"), }, + Migration { + version: 18, + name: "drop_kanban", + up: include_str!("../../migrations/0018_drop_kanban.up.sql"), + down: include_str!("../../migrations/0018_drop_kanban.down.sql"), + }, + Migration { + version: 19, + name: "card_comments", + up: include_str!("../../migrations/0019_card_comments.up.sql"), + down: include_str!("../../migrations/0019_card_comments.down.sql"), + }, + Migration { + version: 20, + name: "webhooks", + up: include_str!("../../migrations/0020_webhooks.up.sql"), + down: include_str!("../../migrations/0020_webhooks.down.sql"), + }, + Migration { + version: 21, + name: "card_tickets", + up: include_str!("../../migrations/0021_card_tickets.up.sql"), + down: include_str!("../../migrations/0021_card_tickets.down.sql"), + }, ] } diff --git a/services/jtype-web/src/db/models.rs b/services/jtype-web/src/db/models.rs index ba1cc82..366255f 100644 --- a/services/jtype-web/src/db/models.rs +++ b/services/jtype-web/src/db/models.rs @@ -1,18 +1,6 @@ -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -/// Deserialize `Option>` so that a *missing* field is `None` (no -/// change) while an explicit `null` is `Some(None)` (clear the field). Used with -/// `#[serde(default, deserialize_with = "double_option")]`. Plain serde collapses -/// both to `None`, which makes "clear/unset" impossible to express. -fn double_option<'de, T, D>(de: D) -> Result>, D::Error> -where - T: Deserialize<'de>, - D: Deserializer<'de>, -{ - Deserialize::deserialize(de).map(Some) -} - // ── User ── #[derive(Debug, Clone, Serialize)] @@ -323,6 +311,8 @@ pub struct DocumentVersionResponse { pub content_hash: String, pub content: String, pub created_at: String, + /// Username of the version's author (for an activity-timeline `by` field). + pub author_username: Option, } #[derive(Debug, Serialize)] @@ -664,239 +654,3 @@ pub struct AssetResponse { pub original_name: Option, pub created_at: String, } - -// ── Kanban ── -// -// Cloud-only Kanban boards inside a workspace. -// Boards → Columns → Cards, with board-level Labels (M:N to Cards). -// Soft delete: cards go to kanban_card_trash for 30 days, then hard-deleted by -// the tokio cron cleanup task. Columns and boards have no archive state — -// board deletion hard-cascades everything (including archived cards in trash). - -// ── Board ── - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateKanbanBoardRequest { - pub name: String, - pub description: Option, - /// Optional client-generated board id (reused local↔cloud; design §11.11). - pub id: Option, - /// Optional client-generated ids for the seeded columns (must match the - /// default column count). Lets an offline-created board converge by id. - pub column_ids: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateKanbanBoardRequest { - pub name: Option, - pub description: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReorderKanbanBoardsRequest { - pub board_ids: Vec, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct KanbanBoardSummary { - pub id: String, - pub workspace_id: String, - pub name: String, - pub description: Option, - pub position: i32, - pub created_by_user_id: String, - pub updated_clock: i64, - pub created_at: String, - pub updated_at: String, - pub card_count: i64, - pub column_count: i64, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct KanbanBoard { - #[serde(flatten)] - pub summary: KanbanBoardSummary, - pub columns: Vec, - pub cards: Vec, - pub labels: Vec, -} - -// ── Column ── - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateKanbanColumnRequest { - pub name: String, - pub wip_limit: Option, - pub color: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateKanbanColumnRequest { - pub name: Option, - pub wip_limit: Option>, - pub color: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReorderKanbanColumnsRequest { - pub board_id: String, - pub column_ids: Vec, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct KanbanColumn { - pub id: String, - pub board_id: String, - pub name: String, - pub position: i32, - pub wip_limit: Option, - pub color: Option, - pub card_count: i64, -} - -// ── Card ── - -#[derive(Debug, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct CreateKanbanCardRequest { - /// Optional client-generated card id (reused local↔cloud; design §11.11). - pub id: Option, - pub column_id: String, - pub title: String, - pub description: Option, - pub priority: Option, - pub due_at: Option, - pub assignee_user_id: Option, - pub label_ids: Option>, - /// Extensible card properties (e.g. `{ "icon": "🚀" }`); stored as-is. - pub properties_extra: Option, -} - -#[derive(Debug, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct UpdateKanbanCardRequest { - pub title: Option, - #[serde(default, deserialize_with = "double_option")] - pub description: Option>, - pub priority: Option, - #[serde(default, deserialize_with = "double_option")] - pub due_at: Option>, - #[serde(default, deserialize_with = "double_option")] - pub assignee_user_id: Option>, - pub label_ids: Option>, - /// Replace the extensible properties blob (present → set). - pub properties_extra: Option, - pub base_updated_clock: Option, - pub force: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MoveKanbanCardRequest { - pub card_id: String, - pub target_column_id: String, - pub target_position: i32, - pub base_updated_clock: Option, - pub force: Option, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct KanbanCard { - pub id: String, - pub workspace_id: String, - pub board_id: String, - pub column_id: String, - pub title: String, - pub description: Option, - pub position: i32, - pub priority: String, - pub due_at: Option, - pub assignee_user_id: Option, - pub properties_extra: Option, - pub label_ids: Vec, - pub created_by_user_id: String, - pub updated_clock: i64, - pub version_id: String, - pub archived_at: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct KanbanCardTrashItem { - pub id: String, - pub card_id: String, - pub workspace_id: String, - pub board_id: String, - pub column_id: String, - pub title: String, - pub description: Option, - pub priority: String, - pub position: i32, - pub due_at: Option, - pub assignee_user_id: Option, - pub label_ids: Vec, - pub archived_by_user_id: String, - pub archived_by_device_id: Option, - pub source_device_id: Option, - pub source_user_id: Option, - pub archived_clock: i64, - pub archived_at: String, - pub expires_at: String, - pub restored_at: Option, - pub restored_by_user_id: Option, - pub restored_by_device_id: Option, - pub restored_clock: Option, -} - -// ── Label ── - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateKanbanLabelRequest { - pub name: String, - pub color: String, - pub description: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateKanbanLabelRequest { - pub name: Option, - pub color: Option, - pub description: Option>, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct KanbanLabel { - pub id: String, - pub board_id: String, - pub name: String, - pub color: String, - pub description: Option, - pub created_at: String, - pub updated_at: String, -} - -// ── Conflict response ── - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct KanbanConflictResponse { - pub error: &'static str, - pub card_id: String, - pub latest: KanbanCard, - pub base_updated_clock: Option, -} diff --git a/services/jtype-web/src/handlers/kanban/comment.rs b/services/jtype-web/src/handlers/comments.rs similarity index 61% rename from services/jtype-web/src/handlers/kanban/comment.rs rename to services/jtype-web/src/handlers/comments.rs index 5d7c7c4..b05d93d 100644 --- a/services/jtype-web/src/handlers/kanban/comment.rs +++ b/services/jtype-web/src/handlers/comments.rs @@ -1,11 +1,11 @@ -//! Card comment handlers (DB board). +//! Card comments, rebuilt on the document model (unification v2). //! -//! Endpoints: -//! GET /api/v1/workspaces/:workspace_id/kanban/cards/:card_id/comments -//! POST /api/v1/workspaces/:workspace_id/kanban/cards/:card_id/comments -//! DELETE /api/v1/workspaces/:workspace_id/kanban/comments/:comment_id -//! -//! Comments are a DB-board feature (file boards keep discussion in the note body). +//! A comment hangs off the card's vault DOCUMENT (`documents.id`), not a kanban +//! row — cards are `.md` documents now. Comments stay a cloud feature (the desktop +//! does not show them offline). Endpoints: +//! GET /api/v1/workspaces/:workspace_id/documents/:document_id/comments +//! POST /api/v1/workspaces/:workspace_id/documents/:document_id/comments +//! DELETE /api/v1/workspaces/:workspace_id/comments/:comment_id use axum::{ extract::{Path, State}, @@ -17,7 +17,6 @@ use serde::{Deserialize, Serialize}; use sqlx::Row; use uuid::Uuid; -use super::clamp_str; use crate::error::AppError; use crate::handlers::workspace::require_workspace_role; use crate::middleware::auth::extract_user; @@ -25,11 +24,23 @@ use crate::AppState; const MAX_COMMENT: usize = 16_000; +/// Truncate to at most `max` bytes without splitting a UTF-8 char. +fn clamp_str(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut idx = max; + while idx > 0 && !s.is_char_boundary(idx) { + idx -= 1; + } + s[..idx].to_string() +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct KanbanComment { +pub struct CardComment { pub id: String, - pub card_id: String, + pub document_id: String, pub author_user_id: String, pub author: Option, pub body: String, @@ -42,15 +53,15 @@ pub struct CreateCommentRequest { pub body: String, } -const SELECT_COMMENT: &str = r#"SELECT c.id, c.card_id, c.author_user_id, u.username AS author, c.body, +const SELECT_COMMENT: &str = r#"SELECT c.id, c.document_id, c.author_user_id, u.username AS author, c.body, CAST(c.created_at AS CHAR) AS created_at, CAST(c.updated_at AS CHAR) AS updated_at -FROM kanban_card_comments c +FROM card_comments c LEFT JOIN users u ON u.id = c.author_user_id"#; -fn row_to_comment(r: &sqlx::mysql::MySqlRow) -> Result { - Ok(KanbanComment { +fn row_to_comment(r: &sqlx::mysql::MySqlRow) -> Result { + Ok(CardComment { id: r.try_get("id")?, - card_id: r.try_get("card_id")?, + document_id: r.try_get("document_id")?, author_user_id: r.try_get("author_user_id")?, author: r.try_get("author")?, body: r.try_get("body")?, @@ -59,14 +70,14 @@ fn row_to_comment(r: &sqlx::mysql::MySqlRow) -> Result }) } -async fn ensure_card_in_workspace( +async fn ensure_document_in_workspace( pool: &sqlx::Pool, workspace_id: &str, - card_id: &str, + document_id: &str, ) -> Result<(), AppError> { let exists: Option = - sqlx::query_scalar("SELECT id FROM kanban_cards WHERE id = ? AND workspace_id = ?") - .bind(card_id) + sqlx::query_scalar("SELECT id FROM documents WHERE id = ? AND workspace_id = ?") + .bind(document_id) .bind(workspace_id) .fetch_optional(pool) .await?; @@ -79,14 +90,14 @@ async fn ensure_card_in_workspace( pub async fn list_comments( State(state): State, headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, + Path((workspace_id, document_id)): Path<(String, String)>, ) -> Result { let user = extract_user(&state.pool, &headers).await?; require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor", "viewer"]).await?; - ensure_card_in_workspace(&state.pool, &workspace_id, &card_id).await?; + ensure_document_in_workspace(&state.pool, &workspace_id, &document_id).await?; - let rows = sqlx::query(&format!("{SELECT_COMMENT} WHERE c.card_id = ? ORDER BY c.created_at ASC")) - .bind(&card_id) + let rows = sqlx::query(&format!("{SELECT_COMMENT} WHERE c.document_id = ? ORDER BY c.created_at ASC")) + .bind(&document_id) .fetch_all(&state.pool) .await?; let out = rows.iter().map(row_to_comment).collect::, _>>()?; @@ -96,12 +107,12 @@ pub async fn list_comments( pub async fn create_comment( State(state): State, headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, + Path((workspace_id, document_id)): Path<(String, String)>, Json(payload): Json, ) -> Result { let user = extract_user(&state.pool, &headers).await?; require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor"]).await?; - ensure_card_in_workspace(&state.pool, &workspace_id, &card_id).await?; + ensure_document_in_workspace(&state.pool, &workspace_id, &document_id).await?; let body = clamp_str(payload.body.trim(), MAX_COMMENT); if body.is_empty() { @@ -109,16 +120,14 @@ pub async fn create_comment( } let id = Uuid::new_v4().to_string(); - sqlx::query( - "INSERT INTO kanban_card_comments (id, workspace_id, card_id, author_user_id, body) VALUES (?, ?, ?, ?, ?)", - ) - .bind(&id) - .bind(&workspace_id) - .bind(&card_id) - .bind(&user.id) - .bind(&body) - .execute(&state.pool) - .await?; + sqlx::query("INSERT INTO card_comments (id, workspace_id, document_id, author_user_id, body) VALUES (?, ?, ?, ?, ?)") + .bind(&id) + .bind(&workspace_id) + .bind(&document_id) + .bind(&user.id) + .bind(&body) + .execute(&state.pool) + .await?; let row = sqlx::query(&format!("{SELECT_COMMENT} WHERE c.id = ?")) .bind(&id) @@ -135,7 +144,7 @@ pub async fn delete_comment( let user = extract_user(&state.pool, &headers).await?; require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor"]).await?; - let row = sqlx::query("SELECT author_user_id FROM kanban_card_comments WHERE id = ? AND workspace_id = ?") + let row = sqlx::query("SELECT author_user_id FROM card_comments WHERE id = ? AND workspace_id = ?") .bind(&comment_id) .bind(&workspace_id) .fetch_optional(&state.pool) @@ -147,7 +156,7 @@ pub async fn delete_comment( require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin"]).await?; } - sqlx::query("DELETE FROM kanban_card_comments WHERE id = ?") + sqlx::query("DELETE FROM card_comments WHERE id = ?") .bind(&comment_id) .execute(&state.pool) .await?; diff --git a/services/jtype-web/src/handlers/document.rs b/services/jtype-web/src/handlers/document.rs index 2687d9b..1ddc487 100644 --- a/services/jtype-web/src/handlers/document.rs +++ b/services/jtype-web/src/handlers/document.rs @@ -267,10 +267,12 @@ pub async fn list_versions( .await?; let rows = sqlx::query( - r#"SELECT id, parent_version_id, source, content_hash, content, created_at - FROM document_versions - WHERE document_id = ? AND workspace_id = ? - ORDER BY created_at DESC + r#"SELECT v.id, v.parent_version_id, v.source, v.content_hash, v.content, + CAST(v.created_at AS CHAR) AS created_at, u.username AS author_username + FROM document_versions v + LEFT JOIN users u ON u.id = v.author_user_id + WHERE v.document_id = ? AND v.workspace_id = ? + ORDER BY v.created_at DESC LIMIT 50"#, ) .bind(&document_id) @@ -287,6 +289,7 @@ pub async fn list_versions( content_hash: row.try_get("content_hash").unwrap_or_default(), content: row.try_get("content").unwrap_or_default(), created_at: row.try_get::("created_at").unwrap_or_default(), + author_username: row.try_get("author_username").unwrap_or(None), }) .collect(); @@ -308,12 +311,69 @@ pub enum SaveDocumentOutcome { Conflict(SyncConflict), } +/// Persist a document version, then fire the re-homed kanban webhook for card +/// saves. The trigger lives HERE (not only in the REST `save_document`) so the +/// content write paths that flow through this wrapper — REST, desktop sync push, +/// live collaborative edits — notify on a real card change. Paths that persist +/// via [`save_merged_document`] directly (notably conflict resolution) do NOT +/// fire here; they call [`fire_card_webhook`] explicitly after the merged save. pub async fn save_document_version( pool: &sqlx::Pool, workspace_id: &str, user: &AuthUser, payload: CloudSaveDocumentRequest, source: &str, +) -> Result { + let outcome = save_document_version_inner(pool, workspace_id, user, payload, source).await?; + if let SaveDocumentOutcome::Saved(doc, status) = &outcome { + // Unchanged = a no-op re-save; don't fire on those. + if !matches!(status, MergeStatus::Unchanged) { + fire_card_webhook(pool, workspace_id, doc, &user.username).await; + } + } + Ok(outcome) +} + +/// Fire a `kanban:card-updated` webhook for a saved card (`.md` carrying `board:` +/// frontmatter), scoped to the board's logical id. Best-effort — never affects +/// the save; a non-card document is a no-op. +pub(crate) async fn fire_card_webhook( + pool: &sqlx::Pool, + workspace_id: &str, + doc: &CloudDocument, + editor: &str, +) { + if !doc.relative_path.to_ascii_lowercase().ends_with(".md") { + return; + } + let fm = jtype_core::parse_frontmatter(&doc.content); + let Some(board_ref) = fm.get("board").map(String::as_str).filter(|b| !b.is_empty()) else { + return; + }; + let payload = serde_json::json!({ + "event": "kanban:card-updated", + "workspaceId": workspace_id, + "board": board_ref, + "card": { + "path": doc.relative_path, + "title": fm.get("title").cloned().unwrap_or_default(), + "status": fm.get("status").cloned().unwrap_or_default(), + "priority": fm.get("priority"), + "assignee": fm.get("assignee"), + "due": fm.get("due"), + }, + "editedBy": editor, + "updatedClock": doc.updated_clock, + }); + crate::handlers::webhooks::enqueue_event(pool, workspace_id, Some(board_ref), "kanban:card-updated", payload).await; +} + +async fn save_document_version_inner( + pool: &sqlx::Pool, + workspace_id: &str, + user: &AuthUser, + payload: CloudSaveDocumentRequest, + source: &str, ) -> Result { let relative_path = normalize_relative_markdown_path(&payload.relative_path)?; let content_hash = sha256_hex(&payload.content); diff --git a/services/jtype-web/src/handlers/kanban/board.rs b/services/jtype-web/src/handlers/kanban/board.rs deleted file mode 100644 index 6c3a198..0000000 --- a/services/jtype-web/src/handlers/kanban/board.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! Board CRUD handlers. -//! -//! Endpoints: -//! GET /api/v1/workspaces/:workspace_id/kanban/boards -//! POST /api/v1/workspaces/:workspace_id/kanban/boards -//! GET /api/v1/workspaces/:workspace_id/kanban/boards/:board_id -//! PATCH /api/v1/workspaces/:workspace_id/kanban/boards/:board_id -//! POST /api/v1/workspaces/:workspace_id/kanban/boards/reorder -//! DELETE /api/v1/workspaces/:workspace_id/kanban/boards/:board_id -//! -//! Role gates: -//! list / get → viewer+ -//! create / patch / reorder → editor+ -//! delete → admin+ (hard delete cascade incl. archived cards in trash) - -use axum::{ - extract::{Path, State}, - http::HeaderMap, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; -use sqlx::Row; -use uuid::Uuid; - -use super::{clamp_str, next_workspace_clock, validate_uuid}; -use crate::db::models::*; -use crate::error::AppError; -use crate::handlers::workspace::require_workspace_role; -use crate::hub::WorkspaceEvent; -use crate::middleware::auth::extract_user; -use crate::AppState; - -const MAX_BOARD_NAME: usize = 255; -const DEFAULT_COLUMNS: [&str; 3] = ["To do", "Doing", "Done"]; - -pub async fn list_boards( - State(state): State, - headers: HeaderMap, - Path(workspace_id): Path, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor", "viewer"], - ) - .await?; - - let rows = sqlx::query( - r#"SELECT b.id, b.workspace_id, b.name, b.description, b.position, - b.created_by_user_id, b.updated_clock, - CAST(b.created_at AS CHAR) AS created_at, CAST(b.updated_at AS CHAR) AS updated_at, - (SELECT COUNT(*) FROM kanban_cards c WHERE c.board_id = b.id AND c.archived_at IS NULL) AS card_count, - (SELECT COUNT(*) FROM kanban_columns col WHERE col.board_id = b.id) AS column_count - FROM kanban_boards b - WHERE b.workspace_id = ? - ORDER BY b.position ASC, b.created_at ASC"#, - ) - .bind(&workspace_id) - .fetch_all(&state.pool) - .await?; - - let boards: Vec = rows - .into_iter() - .map(|row| KanbanBoardSummary { - id: row.try_get("id").unwrap_or_default(), - workspace_id: row.try_get("workspace_id").unwrap_or_default(), - name: row.try_get("name").unwrap_or_default(), - description: row.try_get("description").unwrap_or(None), - position: row.try_get("position").unwrap_or(0), - created_by_user_id: row.try_get("created_by_user_id").unwrap_or_default(), - updated_clock: row.try_get("updated_clock").unwrap_or(0), - created_at: row.try_get::("created_at").unwrap_or_default(), - updated_at: row.try_get::("updated_at").unwrap_or_default(), - card_count: row.try_get("card_count").unwrap_or(0), - column_count: row.try_get("column_count").unwrap_or(0), - }) - .collect(); - - Ok(Json(boards).into_response()) -} - -pub async fn create_board( - State(state): State, - headers: HeaderMap, - Path(workspace_id): Path, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let name = clamp_str(payload.name.trim(), MAX_BOARD_NAME); - if name.is_empty() { - return Err(AppError::BadRequest("board name cannot be empty".into())); - } - let description = payload.description.as_deref().map(|d| clamp_str(d.trim(), 65_535)); - - // Client-generated ids are reused on both ends (design §11.11) so a board - // created offline converges with its cloud twin; absent → server-generated. - let board_id = match payload.id.as_deref() { - Some(id) => { validate_uuid(id)?; id.to_string() } - None => Uuid::new_v4().to_string(), - }; - let seed_column_ids: Vec = match &payload.column_ids { - Some(ids) => { - if ids.len() != DEFAULT_COLUMNS.len() { - return Err(AppError::BadRequest(format!( - "columnIds must contain exactly {} ids", - DEFAULT_COLUMNS.len() - ))); - } - for id in ids { - validate_uuid(id)?; - } - ids.clone() - } - None => DEFAULT_COLUMNS.iter().map(|_| Uuid::new_v4().to_string()).collect(), - }; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Append to end: position = max + 1 - let next_pos: i32 = sqlx::query_scalar( - r#"SELECT COALESCE(MAX(position), -1) + 1 FROM kanban_boards WHERE workspace_id = ?"#, - ) - .bind(&workspace_id) - .fetch_one(&mut *tx) - .await?; - - sqlx::query( - r#"INSERT INTO kanban_boards - (id, workspace_id, name, description, position, created_by_user_id, updated_clock) - VALUES (?, ?, ?, ?, ?, ?, ?)"#, - ) - .bind(&board_id) - .bind(&workspace_id) - .bind(&name) - .bind(&description) - .bind(next_pos) - .bind(&user.id) - .bind(next_clock) - .execute(&mut *tx) - .await - .map_err(|e| match &e { - sqlx::Error::Database(db_err) if db_err.message().contains("uniq_board_per_workspace") => { - AppError::BadRequest(format!("board name '{}' already exists in this workspace", name)) - } - _ => AppError::Database(e), - })?; - - // Seed 3 default columns (reusing client-supplied ids when provided) - let column_ids: Vec = seed_column_ids; - for (i, col_name) in DEFAULT_COLUMNS.iter().enumerate() { - sqlx::query( - r#"INSERT INTO kanban_columns (id, board_id, name, position) VALUES (?, ?, ?, ?)"#, - ) - .bind(&column_ids[i]) - .bind(&board_id) - .bind(col_name) - .bind(i as i32) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - // Broadcast: board created - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanBoardUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - name: name.clone(), - position: next_pos, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id: device_id.clone(), - }, - session_id.as_deref(), - ) - .await; - - // Broadcast each default column - for (i, col_id) in column_ids.iter().enumerate() { - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanColumnUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - column_id: col_id.clone(), - name: DEFAULT_COLUMNS[i].to_string(), - position: i as i32, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id: device_id.clone(), - }, - session_id.as_deref(), - ) - .await; - } - - // Return the full board (with seeded columns) - get_board_inner(&state, &workspace_id, &board_id).await -} - -pub async fn get_board( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor", "viewer"], - ) - .await?; - get_board_inner(&state, &workspace_id, &board_id).await -} - -pub(crate) async fn get_board_inner( - state: &AppState, - workspace_id: &str, - board_id: &str, -) -> Result { - // Board summary - let row = sqlx::query( - r#"SELECT id, workspace_id, name, description, position, created_by_user_id, - updated_clock, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at, - (SELECT COUNT(*) FROM kanban_cards c WHERE c.board_id = b.id AND c.archived_at IS NULL) AS card_count, - (SELECT COUNT(*) FROM kanban_columns col WHERE col.board_id = b.id) AS column_count - FROM kanban_boards b - WHERE b.id = ? AND b.workspace_id = ?"#, - ) - .bind(board_id) - .bind(workspace_id) - .fetch_optional(&state.pool) - .await? - .ok_or(AppError::NotFound)?; - - let summary = KanbanBoardSummary { - id: row.try_get("id")?, - workspace_id: row.try_get("workspace_id")?, - name: row.try_get("name")?, - description: row.try_get("description")?, - position: row.try_get("position")?, - created_by_user_id: row.try_get("created_by_user_id")?, - updated_clock: row.try_get("updated_clock")?, - created_at: row.try_get::("created_at")?, - updated_at: row.try_get::("updated_at")?, - card_count: row.try_get("card_count")?, - column_count: row.try_get("column_count")?, - }; - - // Columns - let col_rows = sqlx::query( - r#"SELECT id, board_id, name, position, wip_limit, color, - (SELECT COUNT(*) FROM kanban_cards c WHERE c.column_id = col.id AND c.archived_at IS NULL) AS card_count - FROM kanban_columns col - WHERE col.board_id = ? - ORDER BY col.position ASC"#, - ) - .bind(board_id) - .fetch_all(&state.pool) - .await?; - - let columns: Vec = col_rows - .into_iter() - .map(|r| KanbanColumn { - id: r.try_get("id").unwrap_or_default(), - board_id: r.try_get("board_id").unwrap_or_default(), - name: r.try_get("name").unwrap_or_default(), - position: r.try_get("position").unwrap_or(0), - wip_limit: r.try_get("wip_limit").unwrap_or(None), - color: r.try_get("color").unwrap_or(None), - card_count: r.try_get("card_count").unwrap_or(0), - }) - .collect(); - - // Active cards (default: exclude archived) - let card_rows = sqlx::query( - r#"SELECT id, workspace_id, board_id, column_id, title, description, position, priority, - CAST(due_at AS CHAR) AS due_at, assignee_user_id, properties_extra, - created_by_user_id, updated_clock, version_id, - CAST(archived_at AS CHAR) AS archived_at, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at - FROM kanban_cards - WHERE board_id = ? AND archived_at IS NULL - ORDER BY column_id ASC, position ASC"#, - ) - .bind(board_id) - .fetch_all(&state.pool) - .await?; - - let card_ids: Vec = card_rows - .iter() - .filter_map(|r| r.try_get::("id").ok()) - .collect(); - let mut labels_by_card = load_label_ids_for_cards(&state.pool, &card_ids).await?; - let mut cards: Vec = Vec::with_capacity(card_rows.len()); - for r in card_rows { - let card_id: String = r.try_get("id")?; - let label_ids = labels_by_card.remove(&card_id).unwrap_or_default(); - cards.push(KanbanCard { - id: card_id, - workspace_id: r.try_get("workspace_id")?, - board_id: r.try_get("board_id")?, - column_id: r.try_get("column_id")?, - title: r.try_get("title")?, - description: r.try_get("description")?, - position: r.try_get("position")?, - priority: r.try_get("priority")?, - due_at: r.try_get::, _>("due_at")?, - assignee_user_id: r.try_get("assignee_user_id")?, - properties_extra: r.try_get("properties_extra")?, - label_ids, - created_by_user_id: r.try_get("created_by_user_id")?, - updated_clock: r.try_get("updated_clock")?, - version_id: r.try_get("version_id")?, - archived_at: r.try_get::, _>("archived_at")?, - created_at: r.try_get::("created_at")?, - updated_at: r.try_get::("updated_at")?, - }); - } - - // Labels - let label_rows = sqlx::query( - r#"SELECT id, board_id, name, color, description, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at - FROM kanban_labels - WHERE board_id = ? - ORDER BY name ASC"#, - ) - .bind(board_id) - .fetch_all(&state.pool) - .await?; - - let labels: Vec = label_rows - .into_iter() - .map(|r| KanbanLabel { - id: r.try_get("id").unwrap_or_default(), - board_id: r.try_get("board_id").unwrap_or_default(), - name: r.try_get("name").unwrap_or_default(), - color: r.try_get("color").unwrap_or_default(), - description: r.try_get("description").unwrap_or(None), - created_at: r.try_get::("created_at").unwrap_or_default(), - updated_at: r.try_get::("updated_at").unwrap_or_default(), - }) - .collect(); - - let board = KanbanBoard { - summary, - columns, - cards, - labels, - }; - Ok(Json(board).into_response()) -} - -pub async fn patch_board( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let new_name = match payload.name.as_deref() { - Some(n) => { - let t = clamp_str(n.trim(), MAX_BOARD_NAME); - if t.is_empty() { - return Err(AppError::BadRequest("board name cannot be empty".into())); - } - Some(t) - } - None => None, - }; - let new_desc = payload - .description - .as_deref() - .map(|d| clamp_str(d.trim(), 65_535)); - - let mut tx = state.pool.begin().await?; - let row = sqlx::query( - r#"SELECT 1 AS ok FROM kanban_boards WHERE id = ? AND workspace_id = ? FOR UPDATE"#, - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let _: i32 = row.try_get("ok").unwrap_or(0); - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Update fields conditionally - if let Some(n) = &new_name { - sqlx::query("UPDATE kanban_boards SET name = ? WHERE id = ?") - .bind(n) - .bind(&board_id) - .execute(&mut *tx) - .await - .map_err(|e| match &e { - sqlx::Error::Database(db_err) - if db_err.message().contains("uniq_board_per_workspace") => - { - AppError::BadRequest(format!("board name '{}' already exists in this workspace", n)) - } - _ => AppError::Database(e), - })?; - } - if let Some(d) = &new_desc { - sqlx::query("UPDATE kanban_boards SET description = ? WHERE id = ?") - .bind(d) - .bind(&board_id) - .execute(&mut *tx) - .await?; - } - sqlx::query("UPDATE kanban_boards SET updated_clock = ? WHERE id = ?") - .bind(next_clock) - .bind(&board_id) - .execute(&mut *tx) - .await?; - - // Fetch the final position for the broadcast - let position: i32 = sqlx::query_scalar("SELECT position FROM kanban_boards WHERE id = ?") - .bind(&board_id) - .fetch_one(&mut *tx) - .await?; - let final_name: String = sqlx::query_scalar("SELECT name FROM kanban_boards WHERE id = ?") - .bind(&board_id) - .fetch_one(&mut *tx) - .await?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanBoardUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - name: final_name, - position, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - get_board_inner(&state, &workspace_id, &board_id).await -} - -pub async fn reorder_boards( - State(state): State, - headers: HeaderMap, - Path(workspace_id): Path, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - if payload.board_ids.is_empty() { - return Err(AppError::BadRequest("board_ids cannot be empty".into())); - } - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Verify all boards belong to this workspace, and that payload is a - // permutation of the workspace's current boards. - let existing: Vec = sqlx::query_scalar( - r#"SELECT id FROM kanban_boards WHERE workspace_id = ? ORDER BY id"#, - ) - .bind(&workspace_id) - .fetch_all(&mut *tx) - .await?; - let mut payload_sorted = payload.board_ids.clone(); - payload_sorted.sort(); - let mut existing_sorted = existing.clone(); - existing_sorted.sort(); - if payload_sorted != existing_sorted { - return Err(AppError::BadRequest( - "board_ids must be a permutation of all boards in the workspace".into(), - )); - } - - for (i, bid) in payload.board_ids.iter().enumerate() { - sqlx::query("UPDATE kanban_boards SET position = ? WHERE id = ? AND workspace_id = ?") - .bind(i as i32) - .bind(bid) - .bind(&workspace_id) - .execute(&mut *tx) - .await?; - sqlx::query("UPDATE kanban_boards SET updated_clock = ? WHERE id = ?") - .bind(next_clock) - .bind(bid) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - // Broadcast each board's new position - for (i, bid) in payload.board_ids.iter().enumerate() { - let name: String = sqlx::query_scalar("SELECT name FROM kanban_boards WHERE id = ?") - .bind(bid) - .fetch_one(&state.pool) - .await - .unwrap_or_default(); - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanBoardUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: bid.clone(), - name, - position: i as i32, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id: device_id.clone(), - }, - session_id.as_deref(), - ) - .await; - } - - Ok((axum::http::StatusCode::OK, Json(json!({"ok": true}))).into_response()) -} - -pub async fn delete_board( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - let row = sqlx::query("SELECT 1 AS ok FROM kanban_boards WHERE id = ? AND workspace_id = ? FOR UPDATE") - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let _: i32 = row.try_get("ok").unwrap_or(0); - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Hard delete: cascade FKs handle columns, cards, labels, card_labels, - // AND archived rows in kanban_card_trash (per migration 0007 design). - sqlx::query("DELETE FROM kanban_boards WHERE id = ?") - .bind(&board_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanBoardDeleted { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id, - deleted_clock: next_clock, - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(axum::http::StatusCode::NO_CONTENT.into_response()) -} - -// ── Module-private helpers ── - -pub(crate) async fn load_card_label_ids( - pool: &sqlx::Pool, - card_id: &str, -) -> Result, AppError> { - let rows = sqlx::query("SELECT label_id FROM kanban_card_labels WHERE card_id = ?") - .bind(card_id) - .fetch_all(pool) - .await?; - Ok(rows - .into_iter() - .filter_map(|r| r.try_get::("label_id").ok()) - .collect()) -} - -/// Batch-load label ids for many cards in a single query (avoids N+1). -/// Returns a map keyed by card_id; cards with no labels are absent from the map. -pub(crate) async fn load_label_ids_for_cards( - pool: &sqlx::Pool, - card_ids: &[String], -) -> Result>, AppError> { - use std::collections::HashMap; - let mut map: HashMap> = HashMap::new(); - if card_ids.is_empty() { - return Ok(map); - } - let placeholders = vec!["?"; card_ids.len()].join(","); - let sql = format!( - "SELECT card_id, label_id FROM kanban_card_labels WHERE card_id IN ({placeholders})" - ); - let mut q = sqlx::query(&sql); - for id in card_ids { - q = q.bind(id); - } - let rows = q.fetch_all(pool).await?; - for r in rows { - let cid: String = r.try_get("card_id")?; - let lid: String = r.try_get("label_id")?; - map.entry(cid).or_default().push(lid); - } - Ok(map) -} - -pub(crate) fn header_device_id(headers: &HeaderMap) -> Option { - headers - .get("x-device-id") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) -} diff --git a/services/jtype-web/src/handlers/kanban/card.rs b/services/jtype-web/src/handlers/kanban/card.rs deleted file mode 100644 index 65d9a18..0000000 --- a/services/jtype-web/src/handlers/kanban/card.rs +++ /dev/null @@ -1,1328 +0,0 @@ -//! Card handlers — the most complex part of the kanban module. -//! -//! Endpoints: -//! GET /api/v1/workspaces/:workspace_id/kanban/boards/:board_id/cards -//! POST /api/v1/workspaces/:workspace_id/kanban/boards/:board_id/cards -//! PATCH /api/v1/workspaces/:workspace_id/kanban/cards/:card_id -//! POST /api/v1/workspaces/:workspace_id/kanban/boards/:board_id/cards/move -//! POST /api/v1/workspaces/:workspace_id/kanban/cards/:card_id/archive -//! POST /api/v1/workspaces/:workspace_id/kanban/cards/:card_id/restore -//! DELETE /api/v1/workspaces/:workspace_id/kanban/cards/:card_id (admin+) -//! -//! Concurrency: PATCH and POST /move (if base_updated_clock is provided) use -//! optimistic locking — return 409 with the latest snapshot on stale write. - -use axum::{ - extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::Row; -use uuid::Uuid; - -use super::board::{header_device_id, load_card_label_ids, load_label_ids_for_cards}; -use super::{clamp_str, next_workspace_clock, normalize_due_at, validate_assignee, validate_priority, validate_uuid}; -use crate::db::models::*; -use crate::error::AppError; -use crate::handlers::workspace::require_workspace_role; -use crate::hub::WorkspaceEvent; -use crate::middleware::auth::extract_user; -use crate::AppState; - -const MAX_CARD_TITLE: usize = 512; -const MAX_CARD_DESCRIPTION: usize = 16 * 1024 * 1024 - 1; // MEDIUMTEXT cap -const TRASH_RETENTION_DAYS: i64 = 30; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ListCardsQuery { - #[serde(default)] - pub include_archived: bool, -} - -// ── list_cards ── - -pub async fn list_cards( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, - Query(q): Query, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor", "viewer"], - ) - .await?; - - // Verify board belongs to workspace - let exists: Option = sqlx::query_scalar( - "SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?", - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&state.pool) - .await?; - if exists.is_none() { - return Err(AppError::NotFound); - } - - let archived_filter = if q.include_archived { - "" // no filter — include everything - } else { - "AND archived_at IS NULL" - }; - - let sql = format!( - r#"SELECT id, workspace_id, board_id, column_id, title, description, position, priority, - CAST(due_at AS CHAR) AS due_at, assignee_user_id, properties_extra, - created_by_user_id, updated_clock, version_id, - CAST(archived_at AS CHAR) AS archived_at, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at - FROM kanban_cards - WHERE board_id = ? {archived_filter} - ORDER BY column_id ASC, position ASC"#, - ); - - let rows = sqlx::query(&sql) - .bind(&board_id) - .fetch_all(&state.pool) - .await?; - - let card_ids: Vec = rows - .iter() - .filter_map(|r| r.try_get::("id").ok()) - .collect(); - let mut labels_by_card = load_label_ids_for_cards(&state.pool, &card_ids).await?; - let mut cards: Vec = Vec::with_capacity(rows.len()); - for r in rows { - let card_id: String = r.try_get("id")?; - let label_ids = labels_by_card.remove(&card_id).unwrap_or_default(); - cards.push(KanbanCard { - id: card_id, - workspace_id: r.try_get("workspace_id")?, - board_id: r.try_get("board_id")?, - column_id: r.try_get("column_id")?, - title: r.try_get("title")?, - description: r.try_get("description")?, - position: r.try_get("position")?, - priority: r.try_get("priority")?, - due_at: r.try_get::, _>("due_at")?, - assignee_user_id: r.try_get("assignee_user_id")?, - properties_extra: r.try_get("properties_extra")?, - label_ids, - created_by_user_id: r.try_get("created_by_user_id")?, - updated_clock: r.try_get("updated_clock")?, - version_id: r.try_get("version_id")?, - archived_at: r.try_get::, _>("archived_at")?, - created_at: r.try_get::("created_at")?, - updated_at: r.try_get::("updated_at")?, - }); - } - - Ok(Json(cards).into_response()) -} - -// ── create_card ── - -pub async fn create_card( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let title = clamp_str(payload.title.trim(), MAX_CARD_TITLE); - if title.is_empty() { - return Err(AppError::BadRequest("card title cannot be empty".into())); - } - let description = payload - .description - .as_deref() - .map(|d| clamp_str(d, MAX_CARD_DESCRIPTION)); - let priority = match payload.priority.as_deref() { - Some(p) => validate_priority(p)?.to_string(), - None => "none".to_string(), - }; - let due_at = match payload.due_at.as_deref() { - Some(d) => Some(normalize_due_at(d)?), - None => None, - }; - validate_assignee(&state.pool, &workspace_id, payload.assignee_user_id.as_deref()).await?; - // Client-generated id reused on both ends (design §11.11); absent → generated. - let card_id = match payload.id.as_deref() { - Some(id) => { validate_uuid(id)?; id.to_string() } - None => Uuid::new_v4().to_string(), - }; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Verify column belongs to board belongs to workspace - let col_info: Option<(String, String)> = sqlx::query_as( - r#"SELECT c.id, c.board_id - FROM kanban_columns c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.id = ? AND b.workspace_id = ?"#, - ) - .bind(&payload.column_id) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - let (_col_id_check, _board_id_check) = col_info.ok_or(AppError::NotFound)?; - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Append to end of column - let next_pos: i32 = sqlx::query_scalar( - r#"SELECT COALESCE(MAX(position), -1) + 1 FROM kanban_cards - WHERE column_id = ? AND archived_at IS NULL"#, - ) - .bind(&payload.column_id) - .fetch_one(&mut *tx) - .await?; - - let version_id = Uuid::new_v4().to_string(); - - sqlx::query( - r#"INSERT INTO kanban_cards - (id, workspace_id, board_id, column_id, title, description, position, priority, - due_at, assignee_user_id, properties_extra, created_by_user_id, updated_clock, version_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .bind(&board_id) - .bind(&payload.column_id) - .bind(&title) - .bind(&description) - .bind(next_pos) - .bind(&priority) - .bind(due_at.as_deref()) - .bind(payload.assignee_user_id.as_deref()) - .bind(payload.properties_extra.clone()) - .bind(&user.id) - .bind(next_clock) - .bind(&version_id) - .execute(&mut *tx) - .await?; - - // Labels - if let Some(label_ids) = &payload.label_ids { - for lid in label_ids { - // Ensure label belongs to this board - let ok: Option = sqlx::query_scalar( - "SELECT id FROM kanban_labels WHERE id = ? AND board_id = ?", - ) - .bind(lid) - .bind(&board_id) - .fetch_optional(&mut *tx) - .await?; - if ok.is_none() { - return Err(AppError::BadRequest(format!( - "label {} does not belong to this board", - lid - ))); - } - sqlx::query( - "INSERT INTO kanban_card_labels (card_id, label_id) VALUES (?, ?)", - ) - .bind(&card_id) - .bind(lid) - .execute(&mut *tx) - .await?; - } - } - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanCardUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - column_id: payload.column_id.clone(), - card_id: card_id.clone(), - title: title.clone(), - position: next_pos, - priority: priority.clone(), - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - super::webhook::enqueue_event( - &state.pool, - &workspace_id, - &board_id, - "kanban:card-updated", - json!({ "event": "kanban:card-updated", "cardId": card_id, "boardId": board_id }), - ) - .await; - - // Re-fetch with DB timestamps - fetch_card_response(&state, &workspace_id, &card_id).await -} - -// ── patch_card (with optional optimistic lock) ── - -pub async fn patch_card( - State(state): State, - headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - // Validate inputs before taking any lock (pure, no DB access). - let new_title = match payload.title.as_deref() { - Some(t) => { - let v = clamp_str(t.trim(), MAX_CARD_TITLE); - if v.is_empty() { - return Err(AppError::BadRequest("card title cannot be empty".into())); - } - Some(v) - } - None => None, - }; - let new_priority = match payload.priority.as_deref() { - Some(p) => Some(validate_priority(p)?.to_string()), - None => None, - }; - // Outer Some = field present in patch; inner Option = set-or-clear. - let new_due_at: Option> = match &payload.due_at { - Some(Some(d)) => Some(Some(normalize_due_at(d)?)), - Some(None) => Some(None), - None => None, - }; - if let Some(Some(a)) = &payload.assignee_user_id { - validate_assignee(&state.pool, &workspace_id, Some(a)).await?; - } - - let mut tx = state.pool.begin().await?; - - // Lock the card row, verify ownership, and load current state inside the tx - // so the optimistic-lock check and the write are atomic (no TOCTOU window). - let current_row = sqlx::query( - r#"SELECT c.id, c.workspace_id, c.board_id, c.column_id, c.title, c.description, c.position, - c.priority, CAST(c.due_at AS CHAR) AS due_at, c.assignee_user_id, c.properties_extra, - c.created_by_user_id, c.updated_clock, c.version_id, - CAST(c.archived_at AS CHAR) AS archived_at, - CAST(c.created_at AS CHAR) AS created_at, CAST(c.updated_at AS CHAR) AS updated_at - FROM kanban_cards c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ? - FOR UPDATE"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - - let current_clock: i64 = current_row.try_get("updated_clock")?; - let current_priority: String = current_row.try_get("priority")?; - let current_column_id: String = current_row.try_get("column_id")?; - let current_board_id: String = current_row.try_get("board_id")?; - let current_position: i32 = current_row.try_get("position")?; - - // Optimistic lock check (inside the tx; an early return rolls it back). - if let Some(base) = payload.base_updated_clock { - if base != current_clock && !payload.force.unwrap_or(false) { - // Build current snapshot - let current_label_ids = load_card_label_ids(&state.pool, &card_id).await?; - let latest = KanbanCard { - id: card_id.clone(), - workspace_id: workspace_id.clone(), - board_id: current_board_id.clone(), - column_id: current_column_id.clone(), - title: current_row.try_get("title")?, - description: current_row.try_get("description")?, - position: current_position, - priority: current_priority.clone(), - due_at: current_row.try_get::, _>("due_at")?, - assignee_user_id: current_row.try_get("assignee_user_id")?, - properties_extra: current_row.try_get("properties_extra")?, - label_ids: current_label_ids, - created_by_user_id: current_row.try_get("created_by_user_id")?, - updated_clock: current_clock, - version_id: current_row.try_get("version_id")?, - archived_at: current_row.try_get::, _>("archived_at")?, - created_at: current_row.try_get::("created_at")?, - updated_at: current_row.try_get::("updated_at")?, - }; - return Ok(( - StatusCode::CONFLICT, - Json(KanbanConflictResponse { - error: "conflict", - card_id: card_id.clone(), - latest, - base_updated_clock: Some(base), - }), - ) - .into_response()); - } - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - let new_version = Uuid::new_v4().to_string(); - - if let Some(t) = &new_title { - sqlx::query("UPDATE kanban_cards SET title = ? WHERE id = ?") - .bind(t) - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - if let Some(d_opt) = &payload.description { - match d_opt { - Some(d) => { - sqlx::query("UPDATE kanban_cards SET description = ? WHERE id = ?") - .bind(clamp_str(d, MAX_CARD_DESCRIPTION)) - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - None => { - sqlx::query("UPDATE kanban_cards SET description = NULL WHERE id = ?") - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - } - } - if let Some(p) = &new_priority { - sqlx::query("UPDATE kanban_cards SET priority = ? WHERE id = ?") - .bind(p) - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - if let Some(due_opt) = &new_due_at { - match due_opt { - Some(d) => { - sqlx::query("UPDATE kanban_cards SET due_at = ? WHERE id = ?") - .bind(d) - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - None => { - sqlx::query("UPDATE kanban_cards SET due_at = NULL WHERE id = ?") - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - } - } - if let Some(assignee_opt) = &payload.assignee_user_id { - match assignee_opt { - Some(a) => { - sqlx::query("UPDATE kanban_cards SET assignee_user_id = ? WHERE id = ?") - .bind(a) - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - None => { - sqlx::query("UPDATE kanban_cards SET assignee_user_id = NULL WHERE id = ?") - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - } - } - if let Some(extra) = &payload.properties_extra { - sqlx::query("UPDATE kanban_cards SET properties_extra = ? WHERE id = ?") - .bind(extra.clone()) - .bind(&card_id) - .execute(&mut *tx) - .await?; - } - if let Some(label_ids) = &payload.label_ids { - // Replace label set - sqlx::query("DELETE FROM kanban_card_labels WHERE card_id = ?") - .bind(&card_id) - .execute(&mut *tx) - .await?; - for lid in label_ids { - let ok: Option = sqlx::query_scalar( - "SELECT id FROM kanban_labels WHERE id = ? AND board_id = ?", - ) - .bind(lid) - .bind(¤t_board_id) - .fetch_optional(&mut *tx) - .await?; - if ok.is_none() { - return Err(AppError::BadRequest(format!( - "label {} does not belong to this board", - lid - ))); - } - sqlx::query("INSERT INTO kanban_card_labels (card_id, label_id) VALUES (?, ?)") - .bind(&card_id) - .bind(lid) - .execute(&mut *tx) - .await?; - } - } - - sqlx::query("UPDATE kanban_cards SET updated_clock = ?, version_id = ? WHERE id = ?") - .bind(next_clock) - .bind(&new_version) - .bind(&card_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - let final_title = new_title.unwrap_or_else(|| { - current_row.try_get::("title").unwrap_or_default() - }); - let final_priority = new_priority.unwrap_or(current_priority); - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanCardUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: current_board_id.clone(), - column_id: current_column_id.clone(), - card_id: card_id.clone(), - title: final_title.clone(), - position: current_position, - priority: final_priority.clone(), - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - fetch_card_response(&state, &workspace_id, &card_id).await -} - -// ── move_card (across columns / reorder within column) ── - -pub async fn move_card( - State(state): State, - headers: HeaderMap, - Path((workspace_id, _board_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Lock the card row and load current state inside the tx so the optimistic-lock - // check and the position rewrite are atomic (no TOCTOU window). - let current = sqlx::query( - r#"SELECT c.board_id, c.column_id, c.updated_clock, - CAST(c.archived_at AS CHAR) AS archived_at - FROM kanban_cards c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ? - FOR UPDATE"#, - ) - .bind(&payload.card_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let current_board_id: String = current.try_get("board_id")?; - let current_column_id: String = current.try_get("column_id")?; - let current_clock: i64 = current.try_get("updated_clock")?; - let current_archived: Option = current.try_get("archived_at")?; - if current_archived.is_some() { - return Err(AppError::BadRequest("cannot move archived card; restore first".into())); - } - - // Optional optimistic lock (inside the tx; an early return rolls it back). - if let Some(base) = payload.base_updated_clock { - if base != current_clock && !payload.force.unwrap_or(false) { - let latest = fetch_card_value(&state, &workspace_id, &payload.card_id).await?; - return Ok(( - StatusCode::CONFLICT, - Json(KanbanConflictResponse { - error: "conflict", - card_id: payload.card_id.clone(), - latest, - base_updated_clock: Some(base), - }), - ) - .into_response()); - } - } - - // Verify target column belongs to same board and same workspace. - let target_col: Option<(String, String)> = sqlx::query_as( - r#"SELECT c.id, c.board_id - FROM kanban_columns c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ?"#, - ) - .bind(&payload.target_column_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - let (_tcid, target_board_id) = target_col.ok_or(AppError::NotFound)?; - if target_board_id != current_board_id { - return Err(AppError::BadRequest( - "cannot move card across boards via this endpoint".into(), - )); - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - let new_version = Uuid::new_v4().to_string(); - - if payload.target_column_id == current_column_id { - // Reorder within the same column - // Simple approach: shift others and place this card at target_position - // Pull all cards in target column (active only) ordered by position - let mut all_ids: Vec<(String, i32)> = sqlx::query_as( - r#"SELECT id, position FROM kanban_cards - WHERE column_id = ? AND archived_at IS NULL - ORDER BY position ASC - FOR UPDATE"#, - ) - .bind(&payload.target_column_id) - .fetch_all(&mut *tx) - .await?; - // Remove the moving card - all_ids.retain(|(id, _)| id != &payload.card_id); - let target_idx = (payload.target_position as usize).min(all_ids.len()); - all_ids.insert(target_idx, (payload.card_id.clone(), 0)); - - for (i, (id, _)) in all_ids.iter().enumerate() { - sqlx::query("UPDATE kanban_cards SET position = ? WHERE id = ?") - .bind(i as i32) - .bind(id) - .execute(&mut *tx) - .await?; - } - } else { - // Move across columns: compact source column and place in target - // Compact source - let source_cards: Vec<(String,)> = sqlx::query_as( - r#"SELECT id FROM kanban_cards - WHERE column_id = ? AND archived_at IS NULL AND id <> ? - ORDER BY position ASC - FOR UPDATE"#, - ) - .bind(¤t_column_id) - .bind(&payload.card_id) - .fetch_all(&mut *tx) - .await?; - for (i, (id,)) in source_cards.iter().enumerate() { - sqlx::query("UPDATE kanban_cards SET position = ? WHERE id = ?") - .bind(i as i32) - .bind(id) - .execute(&mut *tx) - .await?; - } - - // Insert into target column - let target_cards: Vec<(String,)> = sqlx::query_as( - r#"SELECT id FROM kanban_cards - WHERE column_id = ? AND archived_at IS NULL - ORDER BY position ASC - FOR UPDATE"#, - ) - .bind(&payload.target_column_id) - .fetch_all(&mut *tx) - .await?; - let target_idx = (payload.target_position as usize).min(target_cards.len()); - - // Shift target cards at and after target_idx - let mut new_target: Vec = target_cards.into_iter().map(|(s,)| s).collect(); - new_target.insert(target_idx, payload.card_id.clone()); - for (i, id) in new_target.iter().enumerate() { - sqlx::query( - r#"UPDATE kanban_cards SET position = ?, column_id = CASE WHEN id = ? THEN ? ELSE column_id END - WHERE id = ?"#, - ) - .bind(i as i32) - .bind(id) - .bind(&payload.target_column_id) - .bind(id) - .execute(&mut *tx) - .await?; - } - } - - sqlx::query("UPDATE kanban_cards SET updated_clock = ?, version_id = ? WHERE id = ?") - .bind(next_clock) - .bind(&new_version) - .bind(&payload.card_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - // Refetch final card - let card = fetch_card_value(&state, &workspace_id, &payload.card_id).await?; - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanCardUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: card.board_id.clone(), - column_id: card.column_id.clone(), - card_id: card.id.clone(), - title: card.title.clone(), - position: card.position, - priority: card.priority.clone(), - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - super::webhook::enqueue_event( - &state.pool, - &workspace_id, - &card.board_id, - "kanban:card-updated", - json!({ "event": "kanban:card-updated", "cardId": card.id, "boardId": card.board_id }), - ) - .await; - - Ok(Json(card).into_response()) -} - -// ── archive_card ── - -pub async fn archive_card( - State(state): State, - headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - let row = sqlx::query( - r#"SELECT c.id, c.workspace_id, c.board_id, c.column_id, c.title, c.description, c.priority, - c.position, CAST(c.due_at AS CHAR) AS due_at, c.assignee_user_id, - c.properties_extra, c.created_by_user_id - FROM kanban_cards c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ? - FOR UPDATE"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - - let already_archived: Option = - sqlx::query_scalar("SELECT archived_at FROM kanban_cards WHERE id = ?") - .bind(&card_id) - .fetch_one(&mut *tx) - .await?; - if already_archived.is_some() { - return Err(AppError::BadRequest("card already archived".into())); - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - let trash_id = Uuid::new_v4().to_string(); - let column_id: String = row.try_get("column_id")?; - let board_id: String = row.try_get("board_id")?; - let label_ids = load_card_label_ids(&state.pool, &card_id).await?; - let label_ids_json = serde_json::to_value(&label_ids).unwrap_or(serde_json::json!([])); - - sqlx::query( - r#"INSERT INTO kanban_card_trash - (id, workspace_id, card_id, board_id, column_id, title, description, priority, - position, due_at, assignee_user_id, properties_extra, label_ids, - created_by_user_id, archived_by_user_id, archived_by_device_id, source_device_id, source_user_id, - archived_clock, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? DAY))"#, - ) - .bind(&trash_id) - .bind(&workspace_id) - .bind(&card_id) - .bind(&board_id) - .bind(&column_id) - .bind(row.try_get::("title")?) - .bind(row.try_get::, _>("description")?) - .bind(row.try_get::("priority")?) - .bind(row.try_get::("position")?) - .bind(row.try_get::, _>("due_at")?) - .bind(row.try_get::, _>("assignee_user_id")?) - .bind(row.try_get::, _>("properties_extra")?) - .bind(&label_ids_json) - .bind(row.try_get::("created_by_user_id")?) - .bind(&user.id) - .bind(&device_id) - .bind(&device_id) - .bind(&user.id) - .bind(next_clock) - .bind(TRASH_RETENTION_DAYS) - .execute(&mut *tx) - .await?; - - // Mark the card as archived (do NOT delete — restore needs it) - sqlx::query("UPDATE kanban_cards SET archived_at = CURRENT_TIMESTAMP, updated_clock = ? WHERE id = ?") - .bind(next_clock) - .bind(&card_id) - .execute(&mut *tx) - .await?; - - // Compact the source column so the archived card leaves no positional gap - // (keeps archive consistent with move_card's compaction). - let remaining: Vec<(String,)> = sqlx::query_as( - r#"SELECT id FROM kanban_cards - WHERE column_id = ? AND archived_at IS NULL - ORDER BY position ASC"#, - ) - .bind(&column_id) - .fetch_all(&mut *tx) - .await?; - for (i, (id,)) in remaining.iter().enumerate() { - sqlx::query("UPDATE kanban_cards SET position = ? WHERE id = ?") - .bind(i as i32) - .bind(id) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanCardArchived { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - card_id: card_id.clone(), - column_id: column_id.clone(), - archived_clock: next_clock, - source: "web".to_string(), - device_id: device_id.clone(), - }, - session_id.as_deref(), - ) - .await; - - super::webhook::enqueue_event( - &state.pool, - &workspace_id, - &board_id, - "kanban:card-archived", - json!({ "event": "kanban:card-archived", "cardId": card_id, "boardId": board_id }), - ) - .await; - - Ok(Json(json!({ - "id": trash_id, - "cardId": card_id, - "workspaceId": workspace_id, - "boardId": board_id, - "columnId": column_id, - "archivedByUserId": user.id, - "archivedByDeviceId": device_id, - "archivedClock": next_clock, - })) - .into_response()) -} - -// ── restore_card ── - -pub async fn restore_card( - State(state): State, - headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Pick the current (un-restored) trash row deterministically. A card can be - // archived → restored → archived again, leaving several trash rows; only the - // most recent un-restored one represents the active archival to undo. - let trash_row = sqlx::query( - r#"SELECT id, column_id, label_ids - FROM kanban_card_trash - WHERE card_id = ? AND workspace_id = ? AND restored_at IS NULL - ORDER BY archived_clock DESC - LIMIT 1 - FOR UPDATE"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - - let trash_id: String = trash_row.try_get("id")?; - let target_column_id: String = trash_row.try_get("column_id")?; - let label_ids_json: serde_json::Value = trash_row.try_get("label_ids")?; - let label_ids: Vec = serde_json::from_value(label_ids_json).unwrap_or_default(); - - // Lock the card row and confirm it is actually archived. - let card_row = sqlx::query( - r#"SELECT CAST(archived_at AS CHAR) AS archived_at FROM kanban_cards - WHERE id = ? AND workspace_id = ? FOR UPDATE"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let archived_at: Option = card_row.try_get("archived_at")?; - if archived_at.is_none() { - return Err(AppError::BadRequest("card is not archived".into())); - } - - // Verify the original column still exists. - let column_exists: Option = sqlx::query_scalar( - r#"SELECT c.id FROM kanban_columns c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ?"#, - ) - .bind(&target_column_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - if column_exists.is_none() { - return Err(AppError::BadRequest( - "original column was deleted; cannot auto-restore".into(), - )); - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - let new_version = Uuid::new_v4().to_string(); - - // Append to the end of the target column (active cards only); the card's old - // frozen position may now collide with cards added while it was archived. - let new_pos: i32 = sqlx::query_scalar( - r#"SELECT COALESCE(MAX(position), -1) + 1 FROM kanban_cards - WHERE column_id = ? AND archived_at IS NULL"#, - ) - .bind(&target_column_id) - .fetch_one(&mut *tx) - .await?; - - sqlx::query( - r#"UPDATE kanban_cards - SET archived_at = NULL, column_id = ?, position = ?, updated_clock = ?, version_id = ? - WHERE id = ?"#, - ) - .bind(&target_column_id) - .bind(new_pos) - .bind(next_clock) - .bind(&new_version) - .bind(&card_id) - .execute(&mut *tx) - .await?; - - // Restore label associations (in case they were lost) - sqlx::query("DELETE FROM kanban_card_labels WHERE card_id = ?") - .bind(&card_id) - .execute(&mut *tx) - .await?; - for lid in &label_ids { - sqlx::query("INSERT IGNORE INTO kanban_card_labels (card_id, label_id) VALUES (?, ?)") - .bind(&card_id) - .bind(lid) - .execute(&mut *tx) - .await?; - } - - sqlx::query( - r#"UPDATE kanban_card_trash - SET restored_at = CURRENT_TIMESTAMP, restored_by_user_id = ?, - restored_by_device_id = ?, restored_clock = ? - WHERE id = ?"#, - ) - .bind(&user.id) - .bind(&device_id) - .bind(next_clock) - .bind(&trash_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanCardRestored { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: String::new(), // resolved by subscriber via get_board - card_id: card_id.clone(), - column_id: target_column_id.clone(), - restored_clock: next_clock, - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - fetch_card_response(&state, &workspace_id, &card_id).await -} - -// ── hard_delete_card (admin+) ── - -pub async fn delete_card( - State(state): State, - headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - let row = sqlx::query( - r#"SELECT c.board_id, c.column_id - FROM kanban_cards c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ? - FOR UPDATE"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let board_id: String = row.try_get("board_id")?; - let column_id: String = row.try_get("column_id")?; - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Hard delete (cascades to kanban_card_labels AND kanban_card_trash if present) - sqlx::query("DELETE FROM kanban_cards WHERE id = ?") - .bind(&card_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanCardDeleted { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id, - card_id, - column_id, - deleted_clock: next_clock, - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(StatusCode::NO_CONTENT.into_response()) -} - -// ── list_card_trash (archived cards with audit metadata) ── - -pub async fn list_card_trash( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor", "viewer"], - ) - .await?; - - // Verify board belongs to workspace - let exists: Option = sqlx::query_scalar( - "SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?", - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&state.pool) - .await?; - if exists.is_none() { - return Err(AppError::NotFound); - } - - // Only active (un-restored, not-yet-expired) archival rows. - let rows = sqlx::query( - r#"SELECT id, card_id, workspace_id, board_id, column_id, title, description, priority, position, - CAST(due_at AS CHAR) AS due_at, assignee_user_id, label_ids, - archived_by_user_id, archived_by_device_id, source_device_id, source_user_id, - archived_clock, CAST(archived_at AS CHAR) AS archived_at, - CAST(expires_at AS CHAR) AS expires_at, - CAST(restored_at AS CHAR) AS restored_at, restored_by_user_id, - restored_by_device_id, restored_clock - FROM kanban_card_trash - WHERE board_id = ? AND workspace_id = ? - AND restored_at IS NULL AND expires_at > CURRENT_TIMESTAMP - ORDER BY archived_at DESC"#, - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_all(&state.pool) - .await?; - - let mut items: Vec = Vec::with_capacity(rows.len()); - for r in rows { - let label_ids_json: serde_json::Value = - r.try_get("label_ids").unwrap_or_else(|_| serde_json::json!([])); - let label_ids: Vec = serde_json::from_value(label_ids_json).unwrap_or_default(); - items.push(KanbanCardTrashItem { - id: r.try_get("id")?, - card_id: r.try_get("card_id")?, - workspace_id: r.try_get("workspace_id")?, - board_id: r.try_get("board_id")?, - column_id: r.try_get("column_id")?, - title: r.try_get("title")?, - description: r.try_get("description")?, - priority: r.try_get("priority")?, - position: r.try_get("position")?, - due_at: r.try_get::, _>("due_at")?, - assignee_user_id: r.try_get("assignee_user_id")?, - label_ids, - archived_by_user_id: r.try_get("archived_by_user_id")?, - archived_by_device_id: r.try_get("archived_by_device_id")?, - source_device_id: r.try_get("source_device_id")?, - source_user_id: r.try_get("source_user_id")?, - archived_clock: r.try_get("archived_clock")?, - archived_at: r.try_get::("archived_at")?, - expires_at: r.try_get::("expires_at")?, - restored_at: r.try_get::, _>("restored_at")?, - restored_by_user_id: r.try_get("restored_by_user_id")?, - restored_by_device_id: r.try_get("restored_by_device_id")?, - restored_clock: r.try_get("restored_clock")?, - }); - } - - Ok(Json(items).into_response()) -} - -// ── Shared helpers ── - -pub(crate) async fn fetch_card_response( - state: &AppState, - workspace_id: &str, - card_id: &str, -) -> Result { - let card = fetch_card_value(state, workspace_id, card_id).await?; - Ok(Json(card).into_response()) -} - -pub(crate) async fn fetch_card_value( - state: &AppState, - workspace_id: &str, - card_id: &str, -) -> Result { - let r = sqlx::query( - r#"SELECT c.id, c.workspace_id, c.board_id, c.column_id, c.title, c.description, c.position, - c.priority, CAST(c.due_at AS CHAR) AS due_at, c.assignee_user_id, c.properties_extra, - c.created_by_user_id, c.updated_clock, c.version_id, - CAST(c.archived_at AS CHAR) AS archived_at, - CAST(c.created_at AS CHAR) AS created_at, CAST(c.updated_at AS CHAR) AS updated_at - FROM kanban_cards c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ?"#, - ) - .bind(card_id) - .bind(workspace_id) - .fetch_optional(&state.pool) - .await? - .ok_or(AppError::NotFound)?; - - let label_ids = load_card_label_ids(&state.pool, card_id).await?; - Ok(KanbanCard { - id: r.try_get("id")?, - workspace_id: r.try_get("workspace_id")?, - board_id: r.try_get("board_id")?, - column_id: r.try_get("column_id")?, - title: r.try_get("title")?, - description: r.try_get("description")?, - position: r.try_get("position")?, - priority: r.try_get("priority")?, - due_at: r.try_get::, _>("due_at")?, - assignee_user_id: r.try_get("assignee_user_id")?, - properties_extra: r.try_get("properties_extra")?, - label_ids, - created_by_user_id: r.try_get("created_by_user_id")?, - updated_clock: r.try_get("updated_clock")?, - version_id: r.try_get("version_id")?, - archived_at: r.try_get::, _>("archived_at")?, - created_at: r.try_get::("created_at")?, - updated_at: r.try_get::("updated_at")?, - }) -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct KanbanActivityEvent { - /// One of: created | updated | archived | restored. - pub kind: String, - pub at: String, - pub by: Option, -} - -/// Derive a card's activity timeline from existing data — no activity table. -/// Combines kanban_cards (created / last-updated) with kanban_card_trash -/// (archive / restore audit). Newest first. -pub async fn card_activity( - State(state): State, - headers: HeaderMap, - Path((workspace_id, card_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor", "viewer"], - ) - .await?; - - let card = sqlx::query( - r#"SELECT CAST(c.created_at AS CHAR) AS created_at, - CAST(c.updated_at AS CHAR) AS updated_at, - cu.username AS created_by - FROM kanban_cards c - JOIN users cu ON cu.id = c.created_by_user_id - WHERE c.id = ? AND c.workspace_id = ?"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_optional(&state.pool) - .await? - .ok_or(AppError::NotFound)?; - - let created_at: String = card.try_get("created_at")?; - let updated_at: String = card.try_get("updated_at")?; - let created_by: Option = card.try_get("created_by")?; - - let mut events = vec![KanbanActivityEvent { - kind: "created".into(), - at: created_at.clone(), - by: created_by, - }]; - if updated_at != created_at { - events.push(KanbanActivityEvent { kind: "updated".into(), at: updated_at, by: None }); - } - - let trash = sqlx::query( - r#"SELECT CAST(t.archived_at AS CHAR) AS archived_at, - au.username AS archived_by, - CAST(t.restored_at AS CHAR) AS restored_at, - ru.username AS restored_by - FROM kanban_card_trash t - JOIN users au ON au.id = t.archived_by_user_id - LEFT JOIN users ru ON ru.id = t.restored_by_user_id - WHERE t.card_id = ? AND t.workspace_id = ?"#, - ) - .bind(&card_id) - .bind(&workspace_id) - .fetch_all(&state.pool) - .await?; - for r in trash { - if let Some(at) = r.try_get::, _>("archived_at")? { - events.push(KanbanActivityEvent { kind: "archived".into(), at, by: r.try_get("archived_by")? }); - } - if let Some(at) = r.try_get::, _>("restored_at")? { - events.push(KanbanActivityEvent { kind: "restored".into(), at, by: r.try_get("restored_by")? }); - } - } - - // Newest first (zero-padded timestamp strings sort lexically). - events.sort_by(|a, b| b.at.cmp(&a.at)); - Ok(Json(events).into_response()) -} diff --git a/services/jtype-web/src/handlers/kanban/column.rs b/services/jtype-web/src/handlers/kanban/column.rs deleted file mode 100644 index 183c091..0000000 --- a/services/jtype-web/src/handlers/kanban/column.rs +++ /dev/null @@ -1,494 +0,0 @@ -//! Column handlers. -//! -//! Endpoints: -//! POST /api/v1/workspaces/:workspace_id/kanban/boards/:board_id/columns -//! PATCH /api/v1/workspaces/:workspace_id/kanban/columns/:column_id -//! DELETE /api/v1/workspaces/:workspace_id/kanban/columns/:column_id -//! POST /api/v1/workspaces/:workspace_id/kanban/columns/reorder -//! -//! DELETE merges the column's cards into a fallback column (never lost) and -//! refuses to remove a board's last column. Reorder is atomic (single tx). -//! -//! `wip_limit` is advisory metadata for the UI to surface a warning; the server -//! deliberately does NOT block card creation or moves into a full column, so a -//! limit never makes the board unusable. Enforce client-side (or revisit here). - -use axum::{ - extract::{Path, State}, - http::HeaderMap, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; -use sqlx::Row; -use uuid::Uuid; - -use super::board::header_device_id; -use super::{next_workspace_clock, validate_hex_color, clamp_str}; -use crate::db::models::*; -use crate::error::AppError; -use crate::handlers::workspace::require_workspace_role; -use crate::hub::WorkspaceEvent; -use crate::middleware::auth::extract_user; -use crate::AppState; - -const MAX_COLUMN_NAME: usize = 255; - -pub async fn create_column( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let name = clamp_str(payload.name.trim(), MAX_COLUMN_NAME); - if name.is_empty() { - return Err(AppError::BadRequest("column name cannot be empty".into())); - } - if let Some(c) = &payload.color { - validate_hex_color(c)?; - } - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Verify board belongs to this workspace - let exists: Option = sqlx::query_scalar( - "SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?", - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - if exists.is_none() { - return Err(AppError::NotFound); - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Append to end: position = max + 1 - let next_pos: i32 = sqlx::query_scalar( - r#"SELECT COALESCE(MAX(position), -1) + 1 FROM kanban_columns WHERE board_id = ?"#, - ) - .bind(&board_id) - .fetch_one(&mut *tx) - .await?; - - let col_id = Uuid::new_v4().to_string(); - sqlx::query( - r#"INSERT INTO kanban_columns (id, board_id, name, position, wip_limit, color) - VALUES (?, ?, ?, ?, ?, ?)"#, - ) - .bind(&col_id) - .bind(&board_id) - .bind(&name) - .bind(next_pos) - .bind(payload.wip_limit) - .bind(&payload.color) - .execute(&mut *tx) - .await - .map_err(|e| match &e { - sqlx::Error::Database(db_err) if db_err.message().contains("uniq_column_per_board") => { - AppError::BadRequest(format!("column name '{}' already exists on this board", name)) - } - _ => AppError::Database(e), - })?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanColumnUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - column_id: col_id.clone(), - name: name.clone(), - position: next_pos, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - // Return the created column - let col = KanbanColumn { - id: col_id, - board_id, - name, - position: next_pos, - wip_limit: payload.wip_limit, - color: payload.color, - card_count: 0, - }; - Ok(Json(col).into_response()) -} - -pub async fn patch_column( - State(state): State, - headers: HeaderMap, - Path((workspace_id, column_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - // Find board_id from column (and verify ownership) - let (board_id, _old_name) = super::resolve_column(&state.pool, &workspace_id, &column_id).await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let new_name = match payload.name.as_deref() { - Some(n) => { - let t = clamp_str(n.trim(), MAX_COLUMN_NAME); - if t.is_empty() { - return Err(AppError::BadRequest("column name cannot be empty".into())); - } - Some(t) - } - None => None, - }; - - let mut tx = state.pool.begin().await?; - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - if let Some(n) = &new_name { - sqlx::query( - r#"UPDATE kanban_columns SET name = ? WHERE id = ? AND board_id IN - (SELECT id FROM kanban_boards WHERE workspace_id = ?)"#, - ) - .bind(n) - .bind(&column_id) - .bind(&workspace_id) - .execute(&mut *tx) - .await - .map_err(|e| match &e { - sqlx::Error::Database(db_err) if db_err.message().contains("uniq_column_per_board") => { - AppError::BadRequest(format!("column name '{}' already exists on this board", n)) - } - _ => AppError::Database(e), - })?; - } - - if let Some(wip) = &payload.wip_limit { - sqlx::query( - r#"UPDATE kanban_columns SET wip_limit = ? WHERE id = ? AND board_id IN - (SELECT id FROM kanban_boards WHERE workspace_id = ?)"#, - ) - .bind(wip) - .bind(&column_id) - .bind(&workspace_id) - .execute(&mut *tx) - .await?; - } - - if let Some(color_opt) = &payload.color { - match color_opt { - Some(color) => { - validate_hex_color(color)?; - sqlx::query( - r#"UPDATE kanban_columns SET color = ? WHERE id = ? AND board_id IN - (SELECT id FROM kanban_boards WHERE workspace_id = ?)"#, - ) - .bind(color) - .bind(&column_id) - .bind(&workspace_id) - .execute(&mut *tx) - .await?; - } - None => { - // explicit null = clear color - sqlx::query( - r#"UPDATE kanban_columns SET color = NULL WHERE id = ? AND board_id IN - (SELECT id FROM kanban_boards WHERE workspace_id = ?)"#, - ) - .bind(&column_id) - .bind(&workspace_id) - .execute(&mut *tx) - .await?; - } - } - } - - // Read final state - let row = sqlx::query( - r#"SELECT id, board_id, name, position, wip_limit, color, - (SELECT COUNT(*) FROM kanban_cards c WHERE c.column_id = col.id AND c.archived_at IS NULL) AS card_count - FROM kanban_columns col - WHERE col.id = ?"#, - ) - .bind(&column_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - - let col = KanbanColumn { - id: row.try_get("id")?, - board_id: row.try_get("board_id")?, - name: row.try_get("name")?, - position: row.try_get("position")?, - wip_limit: row.try_get("wip_limit")?, - color: row.try_get("color")?, - card_count: row.try_get("card_count")?, - }; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanColumnUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - column_id: col.id.clone(), - name: col.name.clone(), - position: col.position, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(Json(col).into_response()) -} - -/// Delete a column, merging its cards into a fallback column. Mirrors the file -/// board's delete-column behavior (cards move, never lost). The `kanban_cards` -/// FK is `ON DELETE CASCADE`, so we MUST reassign every card (active + archived) -/// off the column before deleting it. Refuses to delete a board's last column. -pub async fn delete_column( - State(state): State, - headers: HeaderMap, - Path((workspace_id, column_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Resolve column → board, scoped to this workspace. - let board_id: Option = sqlx::query_scalar( - r#"SELECT b.id FROM kanban_columns c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ?"#, - ) - .bind(&column_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - let Some(board_id) = board_id else { - return Err(AppError::NotFound); - }; - - // Fallback = first remaining column by position. Refuse if this is the last. - let fallback_id: Option = sqlx::query_scalar( - r#"SELECT id FROM kanban_columns WHERE board_id = ? AND id != ? ORDER BY position, id LIMIT 1"#, - ) - .bind(&board_id) - .bind(&column_id) - .fetch_optional(&mut *tx) - .await?; - let Some(fallback_id) = fallback_id else { - return Err(AppError::BadRequest( - "cannot delete the only column on a board".into(), - )); - }; - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Active cards append to the fallback's tail, preserving their order. - let base: i32 = sqlx::query_scalar( - r#"SELECT COALESCE(MAX(position), -1) FROM kanban_cards WHERE column_id = ? AND archived_at IS NULL"#, - ) - .bind(&fallback_id) - .fetch_one(&mut *tx) - .await?; - let active: Vec = sqlx::query_scalar( - r#"SELECT id FROM kanban_cards WHERE column_id = ? AND archived_at IS NULL ORDER BY position, id"#, - ) - .bind(&column_id) - .fetch_all(&mut *tx) - .await?; - for (i, cid) in active.iter().enumerate() { - sqlx::query("UPDATE kanban_cards SET column_id = ?, position = ? WHERE id = ?") - .bind(&fallback_id) - .bind(base + 1 + i as i32) - .bind(cid) - .execute(&mut *tx) - .await?; - } - // Archived (soft-deleted) cards just move column so the cascade can't drop them. - sqlx::query("UPDATE kanban_cards SET column_id = ? WHERE column_id = ? AND archived_at IS NOT NULL") - .bind(&fallback_id) - .bind(&column_id) - .execute(&mut *tx) - .await?; - // Repoint the trash snapshots too: restore_card reads the target column from - // kanban_card_trash, so a dangling column_id there would make archived cards - // un-restorable after the column is gone. - sqlx::query("UPDATE kanban_card_trash SET column_id = ? WHERE column_id = ? AND workspace_id = ?") - .bind(&fallback_id) - .bind(&column_id) - .bind(&workspace_id) - .execute(&mut *tx) - .await?; - - sqlx::query("DELETE FROM kanban_columns WHERE id = ?") - .bind(&column_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanColumnDeleted { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id, - column_id, - deleted_clock: next_clock, - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(axum::http::StatusCode::NO_CONTENT.into_response()) -} - -pub async fn reorder_columns( - State(state): State, - headers: HeaderMap, - Path(workspace_id): Path, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - if payload.column_ids.is_empty() { - return Err(AppError::BadRequest("column_ids cannot be empty".into())); - } - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Verify board belongs to workspace - let board_exists: Option = sqlx::query_scalar( - "SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?", - ) - .bind(&payload.board_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - if board_exists.is_none() { - return Err(AppError::NotFound); - } - - // Verify payload is a permutation of the board's current columns - let existing: Vec = sqlx::query_scalar( - r#"SELECT id FROM kanban_columns WHERE board_id = ? ORDER BY id"#, - ) - .bind(&payload.board_id) - .fetch_all(&mut *tx) - .await?; - let mut p = payload.column_ids.clone(); - p.sort(); - let mut e = existing.clone(); - e.sort(); - if p != e { - return Err(AppError::BadRequest( - "column_ids must be a permutation of all columns in the board".into(), - )); - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - for (i, cid) in payload.column_ids.iter().enumerate() { - sqlx::query("UPDATE kanban_columns SET position = ? WHERE id = ? AND board_id = ?") - .bind(i as i32) - .bind(cid) - .bind(&payload.board_id) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - - // Broadcast each column's new position - for (i, cid) in payload.column_ids.iter().enumerate() { - let name: String = sqlx::query_scalar("SELECT name FROM kanban_columns WHERE id = ?") - .bind(cid) - .fetch_one(&state.pool) - .await - .unwrap_or_default(); - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanColumnUpdated { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: payload.board_id.clone(), - column_id: cid.clone(), - name, - position: i as i32, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id: device_id.clone(), - }, - session_id.as_deref(), - ) - .await; - } - - Ok((axum::http::StatusCode::OK, Json(json!({"ok": true}))).into_response()) -} diff --git a/services/jtype-web/src/handlers/kanban/label.rs b/services/jtype-web/src/handlers/kanban/label.rs deleted file mode 100644 index a4e683d..0000000 --- a/services/jtype-web/src/handlers/kanban/label.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Label handlers — board-scoped M:N labels on cards. -//! -//! Endpoints: -//! GET /api/v1/workspaces/:workspace_id/kanban/boards/:board_id/labels -//! POST /api/v1/workspaces/:workspace_id/kanban/boards/:board_id/labels -//! PATCH /api/v1/workspaces/:workspace_id/kanban/labels/:label_id -//! DELETE /api/v1/workspaces/:workspace_id/kanban/labels/:label_id -//! -//! Validation: name 1-80 chars, color `#RRGGBB`, max 50/board (app-layer check). - -use axum::{ - extract::{Path, State}, - http::HeaderMap, - response::{IntoResponse, Response}, - Json, -}; -use sqlx::Row; -use uuid::Uuid; - -use super::board::header_device_id; -use super::{clamp_str, next_workspace_clock, validate_hex_color}; -use crate::db::models::*; -use crate::error::AppError; -use crate::handlers::workspace::require_workspace_role; -use crate::hub::WorkspaceEvent; -use crate::middleware::auth::extract_user; -use crate::AppState; - -const MAX_LABEL_NAME: usize = 80; -const MAX_LABEL_DESCRIPTION: usize = 255; -const MAX_LABELS_PER_BOARD: i64 = 50; - -pub async fn list_labels( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor", "viewer"], - ) - .await?; - - let exists: Option = sqlx::query_scalar( - "SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?", - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&state.pool) - .await?; - if exists.is_none() { - return Err(AppError::NotFound); - } - - let rows = sqlx::query( - r#"SELECT id, board_id, name, color, description, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at - FROM kanban_labels - WHERE board_id = ? - ORDER BY name ASC"#, - ) - .bind(&board_id) - .fetch_all(&state.pool) - .await?; - - let labels: Vec = rows - .into_iter() - .map(|r| KanbanLabel { - id: r.try_get("id").unwrap_or_default(), - board_id: r.try_get("board_id").unwrap_or_default(), - name: r.try_get("name").unwrap_or_default(), - color: r.try_get("color").unwrap_or_default(), - description: r.try_get("description").unwrap_or(None), - created_at: r.try_get::("created_at").unwrap_or_default(), - updated_at: r.try_get::("updated_at").unwrap_or_default(), - }) - .collect(); - - Ok(Json(labels).into_response()) -} - -pub async fn create_label( - State(state): State, - headers: HeaderMap, - Path((workspace_id, board_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let name = clamp_str(payload.name.trim(), MAX_LABEL_NAME); - if name.is_empty() { - return Err(AppError::BadRequest("label name cannot be empty".into())); - } - validate_hex_color(&payload.color)?; - let description = payload - .description - .as_deref() - .map(|d| clamp_str(d.trim(), MAX_LABEL_DESCRIPTION)); - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Verify board belongs to workspace - let exists: Option = sqlx::query_scalar( - "SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?", - ) - .bind(&board_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await?; - if exists.is_none() { - return Err(AppError::NotFound); - } - - // 50/board cap - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM kanban_labels WHERE board_id = ?") - .bind(&board_id) - .fetch_one(&mut *tx) - .await?; - if count >= MAX_LABELS_PER_BOARD { - return Err(AppError::BadRequest(format!( - "max {} labels per board (current: {})", - MAX_LABELS_PER_BOARD, count - ))); - } - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - let label_id = Uuid::new_v4().to_string(); - - sqlx::query( - r#"INSERT INTO kanban_labels (id, board_id, name, color, description) - VALUES (?, ?, ?, ?, ?)"#, - ) - .bind(&label_id) - .bind(&board_id) - .bind(&name) - .bind(&payload.color) - .bind(&description) - .execute(&mut *tx) - .await - .map_err(|e| match &e { - sqlx::Error::Database(db_err) if db_err.message().contains("uniq_label_per_board") => { - AppError::BadRequest(format!("label name '{}' already exists on this board", name)) - } - _ => AppError::Database(e), - })?; - - tx.commit().await?; - - // Read final state - let row = sqlx::query( - "SELECT id, board_id, name, color, description, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at - FROM kanban_labels WHERE id = ?", - ) - .bind(&label_id) - .fetch_one(&state.pool) - .await?; - let label = KanbanLabel { - id: row.try_get("id")?, - board_id: row.try_get("board_id")?, - name: row.try_get("name")?, - color: row.try_get("color")?, - description: row.try_get("description")?, - created_at: row.try_get::("created_at")?, - updated_at: row.try_get::("updated_at")?, - }; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanLabelChanged { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id: board_id.clone(), - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(Json(label).into_response()) -} - -pub async fn patch_label( - State(state): State, - headers: HeaderMap, - Path((workspace_id, label_id)): Path<(String, String)>, - Json(payload): Json, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let new_name = match payload.name.as_deref() { - Some(n) => { - let t = clamp_str(n.trim(), MAX_LABEL_NAME); - if t.is_empty() { - return Err(AppError::BadRequest("label name cannot be empty".into())); - } - Some(t) - } - None => None, - }; - if let Some(c) = &payload.color { - validate_hex_color(c)?; - } - - let mut tx = state.pool.begin().await?; - - // Verify ownership: label → board → workspace - let row = sqlx::query( - r#"SELECT l.id, l.board_id - FROM kanban_labels l - JOIN kanban_boards b ON b.id = l.board_id - WHERE l.id = ? AND b.workspace_id = ? - FOR UPDATE"#, - ) - .bind(&label_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let board_id: String = row.try_get("board_id")?; - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - if let Some(n) = &new_name { - sqlx::query("UPDATE kanban_labels SET name = ? WHERE id = ?") - .bind(n) - .bind(&label_id) - .execute(&mut *tx) - .await - .map_err(|e| match &e { - sqlx::Error::Database(db_err) if db_err.message().contains("uniq_label_per_board") => { - AppError::BadRequest(format!("label name '{}' already exists on this board", n)) - } - _ => AppError::Database(e), - })?; - } - if let Some(c) = &payload.color { - sqlx::query("UPDATE kanban_labels SET color = ? WHERE id = ?") - .bind(c) - .bind(&label_id) - .execute(&mut *tx) - .await?; - } - if let Some(d_opt) = &payload.description { - match d_opt { - Some(d) => { - sqlx::query("UPDATE kanban_labels SET description = ? WHERE id = ?") - .bind(clamp_str(d, MAX_LABEL_DESCRIPTION)) - .bind(&label_id) - .execute(&mut *tx) - .await?; - } - None => { - sqlx::query("UPDATE kanban_labels SET description = NULL WHERE id = ?") - .bind(&label_id) - .execute(&mut *tx) - .await?; - } - } - } - - tx.commit().await?; - - // Read final state - let row = sqlx::query( - "SELECT id, board_id, name, color, description, - CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at - FROM kanban_labels WHERE id = ?", - ) - .bind(&label_id) - .fetch_one(&state.pool) - .await?; - let label = KanbanLabel { - id: row.try_get("id")?, - board_id: row.try_get("board_id")?, - name: row.try_get("name")?, - color: row.try_get("color")?, - description: row.try_get("description")?, - created_at: row.try_get::("created_at")?, - updated_at: row.try_get::("updated_at")?, - }; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanLabelChanged { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(Json(label).into_response()) -} - -pub async fn delete_label( - State(state): State, - headers: HeaderMap, - Path((workspace_id, label_id)): Path<(String, String)>, -) -> Result { - let user = extract_user(&state.pool, &headers).await?; - require_workspace_role( - &state.pool, - &workspace_id, - &user.id, - &["owner", "admin", "editor"], - ) - .await?; - - let device_id = header_device_id(&headers); - let session_id = crate::handlers::extract_session_id(&headers); - - let mut tx = state.pool.begin().await?; - - // Verify ownership - let row = sqlx::query( - r#"SELECT l.id, l.board_id - FROM kanban_labels l - JOIN kanban_boards b ON b.id = l.board_id - WHERE l.id = ? AND b.workspace_id = ? - FOR UPDATE"#, - ) - .bind(&label_id) - .bind(&workspace_id) - .fetch_optional(&mut *tx) - .await? - .ok_or(AppError::NotFound)?; - let board_id: String = row.try_get("board_id")?; - - let next_clock = next_workspace_clock(&mut tx, &workspace_id).await?; - - // Hard delete — cascades to kanban_card_labels - sqlx::query("DELETE FROM kanban_labels WHERE id = ?") - .bind(&label_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - state - .hub - .publish_to_workspace( - &workspace_id, - WorkspaceEvent::KanbanLabelChanged { - workspace_id: workspace_id.clone(), - source_session_id: session_id.clone(), - board_id, - updated_clock: next_clock, - edited_by: user.username.clone(), - source: "web".to_string(), - device_id, - }, - session_id.as_deref(), - ) - .await; - - Ok(axum::http::StatusCode::NO_CONTENT.into_response()) -} diff --git a/services/jtype-web/src/handlers/kanban/mod.rs b/services/jtype-web/src/handlers/kanban/mod.rs deleted file mode 100644 index 895b48e..0000000 --- a/services/jtype-web/src/handlers/kanban/mod.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Kanban handlers — boards, columns, cards, labels. -//! -//! Cloud-only: all state lives in MySQL, no Tauri commands, no local files. -//! WebSocket broadcasts via `hub::ConnectionHub` for realtime. -//! -//! Role gates (delegated to `require_workspace_role`): -//! viewer → read -//! editor → write, archive, restore -//! admin → hard delete, board delete - -pub mod board; -pub mod card; -pub mod column; -pub mod comment; -pub mod label; -pub mod webhook; - -use sqlx::Row; - -use crate::error::AppError; - -// ── Shared helpers (used across board/column/card/label) ── - -/// Advance `workspaces.sync_clock` and return the new value. -/// Re-exported from `document.rs` to avoid coupling. -pub(crate) async fn next_workspace_clock( - tx: &mut sqlx::Transaction<'_, sqlx::MySql>, - workspace_id: &str, -) -> Result { - crate::handlers::document::next_workspace_clock(tx, workspace_id).await -} - -/// Validate kanban priority string. -pub(crate) fn validate_priority(p: &str) -> Result<&str, AppError> { - match p { - "none" | "low" | "medium" | "high" | "urgent" => Ok(p), - _ => Err(AppError::BadRequest(format!( - "invalid priority '{}' (expected: none|low|medium|high|urgent)", - p - ))), - } -} - -/// Verify a prospective card assignee is a member (or owner) of the workspace. -/// `None` assignee is always allowed (unassigned). Returns BadRequest otherwise, -/// preventing cards from being assigned to non-members or non-existent users -/// (the latter would otherwise surface as an opaque FK 500). -pub(crate) async fn validate_assignee( - pool: &sqlx::Pool, - workspace_id: &str, - assignee_user_id: Option<&str>, -) -> Result<(), AppError> { - let Some(uid) = assignee_user_id else { - return Ok(()); - }; - let ok: Option = sqlx::query_scalar( - r#"SELECT 1 - FROM workspaces w - LEFT JOIN workspace_members m - ON m.workspace_id = w.id AND m.user_id = ? AND m.status = 'active' - WHERE w.id = ? - AND (m.user_id IS NOT NULL OR w.user_id = ? OR w.owner_user_id = ?)"#, - ) - .bind(uid) - .bind(workspace_id) - .bind(uid) - .bind(uid) - .fetch_optional(pool) - .await?; - if ok.is_none() { - return Err(AppError::BadRequest( - "assignee is not a member of this workspace".into(), - )); - } - Ok(()) -} - -/// Normalize and validate a `due_at` string into MySQL `DATETIME` form -/// (`YYYY-MM-DD HH:MM:SS`). Accepts ISO-8601 (`T` separator, trailing `Z`, -/// fractional seconds, timezone offset — all stripped to a naive datetime, -/// consistent with how the rest of the service stores timestamps) and the -/// bare MySQL form. A date-only value gets `00:00:00` appended. -pub(crate) fn normalize_due_at(s: &str) -> Result { - let t = s.trim().replace('T', " "); - let t = t.trim_end_matches('Z').trim(); - let bytes = t.as_bytes(); - if bytes.len() >= 19 && is_date(&t[..10]) && bytes[10] == b' ' && is_time(&t[11..19]) { - return Ok(t[..19].to_string()); - } - if t.len() == 10 && is_date(t) { - return Ok(format!("{} 00:00:00", t)); - } - Err(AppError::BadRequest(format!( - "due_at must be 'YYYY-MM-DD HH:MM:SS' or an ISO-8601 datetime, got '{}'", - s - ))) -} - -fn is_date(s: &str) -> bool { - let b = s.as_bytes(); - s.len() == 10 - && b[4] == b'-' - && b[7] == b'-' - && b[..4].iter().all(u8::is_ascii_digit) - && b[5..7].iter().all(u8::is_ascii_digit) - && b[8..10].iter().all(u8::is_ascii_digit) -} - -fn is_time(s: &str) -> bool { - let b = s.as_bytes(); - s.len() == 8 - && b[2] == b':' - && b[5] == b':' - && b[..2].iter().all(u8::is_ascii_digit) - && b[3..5].iter().all(u8::is_ascii_digit) - && b[6..8].iter().all(u8::is_ascii_digit) -} - -/// Validate a client-supplied id is a well-formed UUID (8-4-4-4-12 hex). -/// Clients generate ids and reuse them on both ends (design §11.11); rejecting -/// malformed ids keeps junk out of `CHAR(36)` PKs / FKs. -pub(crate) fn validate_uuid(s: &str) -> Result<(), AppError> { - let b = s.as_bytes(); - let ok = b.len() == 36 - && b[8] == b'-' - && b[13] == b'-' - && b[18] == b'-' - && b[23] == b'-' - && b.iter().enumerate().all(|(i, c)| { - matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit() - }); - if ok { - Ok(()) - } else { - Err(AppError::BadRequest(format!("invalid id '{}' (expected a UUID)", s))) - } -} - -/// Validate hex color: `#RRGGBB`. -pub(crate) fn validate_hex_color(s: &str) -> Result<(), AppError> { - let bytes = s.as_bytes(); - if bytes.len() != 7 || bytes[0] != b'#' { - return Err(AppError::BadRequest(format!( - "color must be '#RRGGBB', got '{}'", - s - ))); - } - for &b in &bytes[1..] { - if !(b.is_ascii_hexdigit()) { - return Err(AppError::BadRequest(format!( - "color must be '#RRGGBB', got '{}'", - s - ))); - } - } - Ok(()) -} - -/// Truncate a string to max bytes at a UTF-8 boundary. -pub(crate) fn clamp_str(s: &str, max: usize) -> String { - if s.len() <= max { - return s.to_string(); - } - let mut idx = max; - while idx > 0 && !s.is_char_boundary(idx) { - idx -= 1; - } - s[..idx].to_string() -} - -/// Resolve a column → (board_id, column_name). Verifies workspace ownership. -pub(crate) async fn resolve_column( - pool: &sqlx::Pool, - workspace_id: &str, - column_id: &str, -) -> Result<(String, String), AppError> { - let row = sqlx::query( - r#"SELECT b.id AS board_id, c.name AS column_name - FROM kanban_columns c - JOIN kanban_boards b ON b.id = c.board_id - WHERE c.id = ? AND b.workspace_id = ?"#, - ) - .bind(column_id) - .bind(workspace_id) - .fetch_optional(pool) - .await? - .ok_or(AppError::NotFound)?; - let board_id: String = row.try_get("board_id")?; - let column_name: String = row.try_get("column_name")?; - Ok((board_id, column_name)) -} diff --git a/services/jtype-web/src/handlers/mod.rs b/services/jtype-web/src/handlers/mod.rs index 165c1f7..7a952f6 100644 --- a/services/jtype-web/src/handlers/mod.rs +++ b/services/jtype-web/src/handlers/mod.rs @@ -2,10 +2,10 @@ pub mod admin; pub mod assets; pub mod blobs; pub mod auth; +pub mod comments; pub mod document; pub mod domain; pub mod folder; -pub mod kanban; pub mod live; pub mod mail; pub mod member; @@ -14,8 +14,10 @@ pub mod publish; pub mod settings; pub mod site; pub mod sync; +pub mod tickets; pub mod trash; pub mod user; +pub mod webhooks; pub mod workspace; /// Extract the optional WS session ID from `X-Session-Id` header. diff --git a/services/jtype-web/src/handlers/sync.rs b/services/jtype-web/src/handlers/sync.rs index a40264d..0c48534 100644 --- a/services/jtype-web/src/handlers/sync.rs +++ b/services/jtype-web/src/handlers/sync.rs @@ -407,6 +407,11 @@ pub async fn resolve_conflict( .execute(&state.pool) .await?; + // A resolved conflict is a real card change. `save_merged_document` does not + // fire the kanban webhook itself (unlike the `save_document_version` wrapper), + // so notify explicitly here. Best-effort; a non-card document is a no-op. + crate::handlers::document::fire_card_webhook(&state.pool, &workspace_id, &saved, &user.username).await; + // Broadcast the resolved document so other connected clients refresh. state .hub diff --git a/services/jtype-web/src/handlers/tickets.rs b/services/jtype-web/src/handlers/tickets.rs new file mode 100644 index 0000000..6140a11 --- /dev/null +++ b/services/jtype-web/src/handlers/tickets.rs @@ -0,0 +1,242 @@ +//! Per-card ticket links (`OCCSV-3371`) — see internal-docs/kanban/ticket-links.md. +//! +//! The per-card number lives ONLY in the cloud index (`card_tickets`), keyed by the +//! card's `documents.id`; `board_sequences` is the sole, monotonic allocator. The +//! `ticket_key` + `number` are snapshotted so a minted id is stable forever. +//! +//! Tickets are scoped to a workspace (`ticket_key` is unique within a workspace, +//! not globally), so every resolve route carries the workspace — there is no +//! workspace-agnostic lookup, which would be ambiguous across workspaces. +//! POST /api/v1/workspaces/:workspace_id/tickets/allocate {relativePath, ticketKey} +//! GET /api/v1/workspaces/:workspace_id/tickets → all tickets (doc→ticket map) +//! GET /api/v1/workspaces/:workspace_id/tickets/:ticket → resolve OCCSV-3371 → card + +use axum::{ + extract::{Path, State}, + http::HeaderMap, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use uuid::Uuid; + +use crate::error::AppError; +use crate::handlers::workspace::require_workspace_role; +use crate::middleware::auth::extract_user; +use crate::AppState; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AllocateRequest { + pub relative_path: String, + pub ticket_key: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Ticket { + pub document_id: String, + pub relative_path: Option, + pub ticket_key: String, + pub number: i64, + /// The full id, e.g. `OCCSV-3371`. + pub ticket: String, +} + +/// A board ticket key: 2–16 uppercase letters/digits, leading letter. +fn validate_ticket_key(raw: &str) -> Result { + let key = raw.trim().to_ascii_uppercase(); + let ok = key.len() >= 2 + && key.len() <= 16 + && key.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && key.chars().all(|c| c.is_ascii_alphanumeric()); + if !ok { + return Err(AppError::BadRequest("invalid ticketKey".into())); + } + Ok(key) +} + +/// Resolve a document id, enforcing that the target is a board CARD and nothing +/// else. A ticket is a per-card identity, so only `.md` notes carrying non-empty +/// `board:` frontmatter may be allocated one — `.board` files and ordinary notes +/// are rejected. This is the same card test [`fire_card_webhook`] uses, kept in +/// lock-step so "what is a card" has a single definition. +async fn resolve_card_document_id( + pool: &sqlx::Pool, + workspace_id: &str, + relative_path: &str, +) -> Result { + let row = sqlx::query("SELECT id, content FROM documents WHERE workspace_id = ? AND relative_path = ?") + .bind(workspace_id) + .bind(relative_path) + .fetch_optional(pool) + .await? + .ok_or(AppError::NotFound)?; + if !relative_path.to_ascii_lowercase().ends_with(".md") { + return Err(AppError::BadRequest("ticket target must be a card (.md) document".into())); + } + let content: String = row.try_get("content")?; + let is_card = jtype_core::parse_frontmatter(&content) + .get("board") + .is_some_and(|b| !b.trim().is_empty()); + if !is_card { + return Err(AppError::BadRequest("ticket target must be a card with board: frontmatter".into())); + } + Ok(row.try_get("id")?) +} + +pub async fn allocate( + State(state): State, + headers: HeaderMap, + Path(workspace_id): Path, + Json(payload): Json, +) -> Result { + let user = extract_user(&state.pool, &headers).await?; + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor"]).await?; + + let ticket_key = validate_ticket_key(&payload.ticket_key)?; + let document_id = resolve_card_document_id(&state.pool, &workspace_id, &payload.relative_path).await?; + + let mut tx = state.pool.begin().await?; + + // Idempotent: a document is allocated exactly one ticket for its lifetime. + if let Some(row) = sqlx::query("SELECT ticket_key, number FROM card_tickets WHERE document_id = ?") + .bind(&document_id) + .fetch_optional(&mut *tx) + .await? + { + let key: String = row.try_get("ticket_key")?; + let number: i64 = row.try_get("number")?; + tx.commit().await?; + return Ok(Json(Ticket { + relative_path: Some(payload.relative_path), + ticket: format!("{key}-{number}"), + ticket_key: key, + number, + document_id, + }) + .into_response()); + } + + // Atomic monotonic counter (LAST_INSERT_ID is connection-scoped; the tx pins it). + sqlx::query( + "INSERT INTO board_sequences (workspace_id, ticket_key, last_number) VALUES (?, ?, LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE last_number = LAST_INSERT_ID(last_number + 1)", + ) + .bind(&workspace_id) + .bind(&ticket_key) + .execute(&mut *tx) + .await?; + // CAST AS SIGNED: LAST_INSERT_ID() is BIGINT UNSIGNED, which sqlx refuses to + // decode into i64 (mirrors next_workspace_clock in document.rs). + let number: i64 = sqlx::query_scalar("SELECT CAST(LAST_INSERT_ID() AS SIGNED)").fetch_one(&mut *tx).await?; + + let id = Uuid::new_v4().to_string(); + let insert = sqlx::query("INSERT INTO card_tickets (id, workspace_id, document_id, ticket_key, number) VALUES (?, ?, ?, ?, ?)") + .bind(&id) + .bind(&workspace_id) + .bind(&document_id) + .bind(&ticket_key) + .bind(number) + .execute(&mut *tx) + .await; + + match insert { + Ok(_) => { + tx.commit().await?; + Ok(Json(Ticket { + relative_path: Some(payload.relative_path), + ticket: format!("{ticket_key}-{number}"), + ticket_key, + number, + document_id, + }) + .into_response()) + } + // Lost a concurrent allocation for the same document_id: roll back our + // counter bump (so no number is wasted) and return the winner's ticket — + // a document is allocated exactly one ticket for its lifetime. + Err(sqlx::Error::Database(db)) if db.is_unique_violation() => { + tx.rollback().await?; + let row = sqlx::query("SELECT ticket_key, number FROM card_tickets WHERE document_id = ?") + .bind(&document_id) + .fetch_one(&state.pool) + .await?; + let key: String = row.try_get("ticket_key")?; + let num: i64 = row.try_get("number")?; + Ok(Json(Ticket { + relative_path: Some(payload.relative_path), + ticket: format!("{key}-{num}"), + ticket_key: key, + number: num, + document_id, + }) + .into_response()) + } + Err(e) => Err(e.into()), + } +} + +pub async fn list_tickets( + State(state): State, + headers: HeaderMap, + Path(workspace_id): Path, +) -> Result { + let user = extract_user(&state.pool, &headers).await?; + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor", "viewer"]).await?; + + let rows = sqlx::query( + r#"SELECT t.document_id, t.ticket_key, t.number, d.relative_path + FROM card_tickets t + JOIN documents d ON d.id = t.document_id + WHERE t.workspace_id = ?"#, + ) + .bind(&workspace_id) + .fetch_all(&state.pool) + .await?; + let out = rows + .iter() + .map(row_to_ticket) + .collect::, _>>()?; + Ok(Json(out).into_response()) +} + +pub async fn resolve_ticket( + State(state): State, + headers: HeaderMap, + Path((workspace_id, ticket)): Path<(String, String)>, +) -> Result { + let user = extract_user(&state.pool, &headers).await?; + require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin", "editor", "viewer"]).await?; + + let (key, number) = ticket + .rsplit_once('-') + .and_then(|(k, n)| n.parse::().ok().map(|num| (k.to_ascii_uppercase(), num))) + .ok_or_else(|| AppError::BadRequest("invalid ticket".into()))?; + + let row = sqlx::query( + r#"SELECT t.document_id, t.ticket_key, t.number, d.relative_path + FROM card_tickets t + JOIN documents d ON d.id = t.document_id + WHERE t.workspace_id = ? AND t.ticket_key = ? AND t.number = ?"#, + ) + .bind(&workspace_id) + .bind(&key) + .bind(number) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(row_to_ticket(&row)?).into_response()) +} + +fn row_to_ticket(r: &sqlx::mysql::MySqlRow) -> Result { + let key: String = r.try_get("ticket_key")?; + let number: i64 = r.try_get("number")?; + Ok(Ticket { + document_id: r.try_get("document_id")?, + relative_path: r.try_get("relative_path")?, + ticket: format!("{key}-{number}"), + ticket_key: key, + number, + }) +} diff --git a/services/jtype-web/src/handlers/kanban/webhook.rs b/services/jtype-web/src/handlers/webhooks.rs similarity index 62% rename from services/jtype-web/src/handlers/kanban/webhook.rs rename to services/jtype-web/src/handlers/webhooks.rs index ae26ae3..abf9522 100644 --- a/services/jtype-web/src/handlers/kanban/webhook.rs +++ b/services/jtype-web/src/handlers/webhooks.rs @@ -1,13 +1,15 @@ -//! Kanban webhook registration + outbound delivery enqueue (DB board). +//! Webhook registration + outbound delivery enqueue (document-backed board). //! //! Endpoints (owner/admin only): -//! GET /api/v1/workspaces/:workspace_id/kanban/webhooks -//! POST /api/v1/workspaces/:workspace_id/kanban/webhooks -//! DELETE /api/v1/workspaces/:workspace_id/kanban/webhooks/:webhook_id +//! GET /api/v1/workspaces/:workspace_id/webhooks +//! POST /api/v1/workspaces/:workspace_id/webhooks +//! DELETE /api/v1/workspaces/:workspace_id/webhooks/:webhook_id //! //! On create the plaintext `secret` is returned ONCE; thereafter only a mask. -//! `enqueue_event` is called from the card handlers' broadcast sites to queue -//! deliveries; the `tasks::webhook_delivery` worker signs (HMAC-SHA256) and POSTs. +//! `enqueue_event` is called from `handlers::document::save_document` when a card +//! `.md` (with `board:` frontmatter) is written; the `tasks::webhook_delivery` +//! worker signs (HMAC-SHA256) and POSTs. Board scope is the card's LOGICAL board +//! id (frontmatter `board`), or all boards when `board_ref` is NULL. use axum::{ extract::{Path, State}, @@ -20,7 +22,6 @@ use serde_json::Value as JsonValue; use sqlx::{MySql, Pool, Row}; use uuid::Uuid; -use super::clamp_str; use crate::error::AppError; use crate::handlers::workspace::require_workspace_role; use crate::middleware::auth::extract_user; @@ -30,11 +31,24 @@ const MAX_NAME: usize = 160; const MAX_URL: usize = 2048; const MAX_WEBHOOKS_PER_WORKSPACE: i64 = 20; +/// Truncate to at most `max` bytes without splitting a UTF-8 char. +fn clamp_str(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut idx = max; + while idx > 0 && !s.is_char_boundary(idx) { + idx -= 1; + } + s[..idx].to_string() +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct KanbanWebhook { +pub struct Webhook { pub id: String, - pub board_id: Option, + /// Board logical id this webhook is scoped to; null = all boards. + pub board_ref: Option, pub name: String, pub target_url: String, pub event_types: Vec, @@ -47,33 +61,33 @@ pub struct KanbanWebhook { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct KanbanWebhookCreated { +pub struct WebhookCreated { #[serde(flatten)] - pub webhook: KanbanWebhook, + pub webhook: Webhook, /// Plaintext secret — returned only on create. pub secret: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CreateKanbanWebhookRequest { +pub struct CreateWebhookRequest { pub name: String, pub target_url: String, #[serde(default)] - pub board_id: Option, + pub board_ref: Option, #[serde(default)] pub event_types: Vec, } -fn row_to_webhook(r: &sqlx::mysql::MySqlRow) -> Result { +fn row_to_webhook(r: &sqlx::mysql::MySqlRow) -> Result { let event_types: JsonValue = r.try_get("event_types")?; let events = match event_types { JsonValue::Array(a) => a.into_iter().filter_map(|v| v.as_str().map(String::from)).collect(), _ => Vec::new(), }; - Ok(KanbanWebhook { + Ok(Webhook { id: r.try_get("id")?, - board_id: r.try_get("board_id")?, + board_ref: r.try_get("board_ref")?, name: r.try_get("name")?, target_url: r.try_get("target_url")?, event_types: events, @@ -85,30 +99,55 @@ fn row_to_webhook(r: &sqlx::mysql::MySqlRow) -> Result }) } -const SELECT_WEBHOOK: &str = r#"SELECT id, board_id, name, target_url, event_types, enabled, +const SELECT_WEBHOOK: &str = r#"SELECT id, board_ref, name, target_url, event_types, enabled, CAST(last_delivery_at AS CHAR) AS last_delivery_at, last_status, CAST(created_at AS CHAR) AS created_at -FROM kanban_webhooks"#; +FROM webhooks"#; -fn is_blocked_v4(ip: std::net::Ipv4Addr) -> bool { - // loopback 127/8, private 10/8 + 172.16/12 + 192.168/16, link-local 169.254/16, - // unspecified 0.0.0.0, broadcast, and the 0/8 reserved block. +// We can't use the nicer `Ipv4Addr::is_global` / `is_shared` / `is_benchmarking` +// helpers — they're still unstable (feature `ip`) on the stable toolchain — so +// the non-global ranges are spelled out with bitmasks. `is_multicast` / +// `is_documentation` ARE stable and used directly. +pub(crate) fn is_blocked_v4(ip: std::net::Ipv4Addr) -> bool { + let o = ip.octets(); ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() || ip.is_broadcast() - || ip.octets()[0] == 0 + || ip.is_multicast() // 224.0.0.0/4 + || ip.is_documentation() // 192.0.2/24, 198.51.100/24, 203.0.113/24 + || o[0] == 0 // 0.0.0.0/8 + || (o[0] == 100 && (o[1] & 0xc0) == 0x40) // CGNAT 100.64.0.0/10 + || (o[0] == 198 && (o[1] & 0xfe) == 0x12) // benchmark 198.18.0.0/15 + || (o[0] & 0xf0) == 0xf0 // reserved 240.0.0.0/4 +} + +pub(crate) fn is_blocked_v6(ip: std::net::Ipv6Addr) -> bool { + // Normalize IPv4-mapped (`::ffff:a.b.c.d`) and compat (`::a.b.c.d`) forms and + // apply the v4 rules — otherwise `::ffff:169.254.169.254` etc. slip past. + if let Some(v4) = ip.to_ipv4_mapped().or_else(|| ip.to_ipv4()) { + return is_blocked_v4(v4); + } + let s = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() // ff00::/8 + || (s[0] & 0xfe00) == 0xfc00 // ULA fc00::/7 + || (s[0] & 0xffc0) == 0xfe80 // link-local fe80::/10 + || (s[0] == 0x2001 && s[1] == 0x0db8) // documentation 2001:db8::/32 } -fn is_blocked_v6(ip: std::net::Ipv6Addr) -> bool { - // loopback ::1, unspecified ::, and ULA fc00::/7. - ip.is_loopback() || ip.is_unspecified() || (ip.segments()[0] & 0xfe00) == 0xfc00 +/// True when an IP literal is an SSRF target we refuse. Shared with the delivery +/// worker for a resolve-time re-check (DNS-rebinding defense). +pub(crate) fn is_blocked_ip(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v4) => is_blocked_v4(v4), + std::net::IpAddr::V6(v6) => is_blocked_v6(v6), + } } /// Reject SSRF targets (internal/loopback/private hosts) and non-HTTPS URLs. -/// Parses the URL structurally so userinfo (`https://user@127.0.0.1/`) and IPv6 -/// literals can't slip past a prefix check, and classifies IP literals properly. fn validate_target_url(raw: &str) -> Result<(), AppError> { let parsed = url::Url::parse(raw).map_err(|_| AppError::BadRequest("invalid target_url".into()))?; if parsed.scheme() != "https" { @@ -128,14 +167,9 @@ fn validate_target_url(raw: &str) -> Result<(), AppError> { } url::Host::Domain(d) => { let d = d.to_ascii_lowercase(); - if d == "localhost" - || d.ends_with(".localhost") - || d.ends_with(".local") - || d.ends_with(".internal") - { + if d == "localhost" || d.ends_with(".localhost") || d.ends_with(".local") || d.ends_with(".internal") { return Err(blocked()); } - // A bare IP that url parsed as a domain (defensive). match d.parse::() { Ok(std::net::IpAddr::V4(v4)) if is_blocked_v4(v4) => return Err(blocked()), Ok(std::net::IpAddr::V6(v6)) if is_blocked_v6(v6) => return Err(blocked()), @@ -165,7 +199,7 @@ pub async fn create_webhook( State(state): State, headers: HeaderMap, Path(workspace_id): Path, - Json(payload): Json, + Json(payload): Json, ) -> Result { let user = extract_user(&state.pool, &headers).await?; require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin"]).await?; @@ -185,17 +219,16 @@ pub async fn create_webhook( if events.is_empty() { return Err(AppError::BadRequest("event_types cannot be empty".into())); } - if let Some(b) = &payload.board_id { - let ok: Option = sqlx::query_scalar("SELECT id FROM kanban_boards WHERE id = ? AND workspace_id = ?") - .bind(b) - .bind(&workspace_id) - .fetch_optional(&state.pool) - .await?; - if ok.is_none() { - return Err(AppError::BadRequest("board_id not found in workspace".into())); - } - } - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM kanban_webhooks WHERE workspace_id = ?") + // board_ref is the board's logical id (frontmatter `board`); an empty string + // means "all boards" → store NULL. + let board_ref = payload + .board_ref + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM webhooks WHERE workspace_id = ?") .bind(&workspace_id) .fetch_one(&state.pool) .await?; @@ -207,11 +240,11 @@ pub async fn create_webhook( let secret = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let events_json = serde_json::to_value(&events).unwrap_or(JsonValue::Array(vec![])); sqlx::query( - "INSERT INTO kanban_webhooks (id, workspace_id, board_id, name, target_url, secret, event_types, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO webhooks (id, workspace_id, board_ref, name, target_url, secret, event_types, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&workspace_id) - .bind(&payload.board_id) + .bind(&board_ref) .bind(&name) .bind(&target_url) .bind(&secret) @@ -224,7 +257,7 @@ pub async fn create_webhook( .bind(&id) .fetch_one(&state.pool) .await?; - Ok(Json(KanbanWebhookCreated { webhook: row_to_webhook(&row)?, secret }).into_response()) + Ok(Json(WebhookCreated { webhook: row_to_webhook(&row)?, secret }).into_response()) } pub async fn delete_webhook( @@ -234,7 +267,7 @@ pub async fn delete_webhook( ) -> Result { let user = extract_user(&state.pool, &headers).await?; require_workspace_role(&state.pool, &workspace_id, &user.id, &["owner", "admin"]).await?; - let res = sqlx::query("DELETE FROM kanban_webhooks WHERE id = ? AND workspace_id = ?") + let res = sqlx::query("DELETE FROM webhooks WHERE id = ? AND workspace_id = ?") .bind(&webhook_id) .bind(&workspace_id) .execute(&state.pool) @@ -245,21 +278,21 @@ pub async fn delete_webhook( Ok(axum::http::StatusCode::NO_CONTENT.into_response()) } -/// Queue a delivery for every enabled webhook in the workspace that subscribes -/// to `event_type` (exact match or `"*"`) and is scoped to this board (or all -/// boards). Best-effort: errors are logged, never propagated to the caller. +/// Queue a delivery for every enabled webhook in the workspace that subscribes to +/// `event_type` (exact match or `"*"`) and is scoped to this board (`board_ref`, +/// the card's logical board id) or all boards. Best-effort: errors are logged. pub async fn enqueue_event( pool: &Pool, workspace_id: &str, - board_id: &str, + board_ref: Option<&str>, event_type: &str, payload: JsonValue, ) { let rows = match sqlx::query( - "SELECT id, event_types FROM kanban_webhooks WHERE workspace_id = ? AND enabled = 1 AND (board_id IS NULL OR board_id = ?)", + "SELECT id, event_types FROM webhooks WHERE workspace_id = ? AND enabled = 1 AND (board_ref IS NULL OR board_ref = ?)", ) .bind(workspace_id) - .bind(board_id) + .bind(board_ref) .fetch_all(pool) .await { @@ -281,7 +314,7 @@ pub async fn enqueue_event( } let delivery_id = Uuid::new_v4().to_string(); if let Err(e) = sqlx::query( - "INSERT INTO kanban_webhook_deliveries (id, webhook_id, workspace_id, event_type, payload) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO webhook_deliveries (id, webhook_id, workspace_id, event_type, payload) VALUES (?, ?, ?, ?, ?)", ) .bind(&delivery_id) .bind(&webhook_id) diff --git a/services/jtype-web/src/hub.rs b/services/jtype-web/src/hub.rs index 5c67bdd..c357af9 100644 --- a/services/jtype-web/src/hub.rs +++ b/services/jtype-web/src/hub.rs @@ -92,112 +92,6 @@ pub enum WorkspaceEvent { document_id: String, is_published: bool, }, - // ── Kanban events ── - #[serde(rename = "kanban:board-updated", rename_all = "camelCase")] - KanbanBoardUpdated { - workspace_id: String, - source_session_id: Option, - board_id: String, - name: String, - position: i32, - updated_clock: i64, - edited_by: String, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:board-deleted", rename_all = "camelCase")] - KanbanBoardDeleted { - workspace_id: String, - source_session_id: Option, - board_id: String, - deleted_clock: i64, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:column-updated", rename_all = "camelCase")] - KanbanColumnUpdated { - workspace_id: String, - source_session_id: Option, - board_id: String, - column_id: String, - name: String, - position: i32, - updated_clock: i64, - edited_by: String, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:column-deleted", rename_all = "camelCase")] - KanbanColumnDeleted { - workspace_id: String, - source_session_id: Option, - board_id: String, - column_id: String, - deleted_clock: i64, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:card-updated", rename_all = "camelCase")] - KanbanCardUpdated { - workspace_id: String, - source_session_id: Option, - board_id: String, - column_id: String, - card_id: String, - title: String, - position: i32, - priority: String, - updated_clock: i64, - edited_by: String, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:card-deleted", rename_all = "camelCase")] - KanbanCardDeleted { - workspace_id: String, - source_session_id: Option, - board_id: String, - card_id: String, - column_id: String, - deleted_clock: i64, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:card-archived", rename_all = "camelCase")] - KanbanCardArchived { - workspace_id: String, - source_session_id: Option, - board_id: String, - card_id: String, - column_id: String, - archived_clock: i64, - source: String, - device_id: Option, - }, - #[serde(rename = "kanban:card-restored", rename_all = "camelCase")] - KanbanCardRestored { - workspace_id: String, - source_session_id: Option, - board_id: String, - card_id: String, - column_id: String, - restored_clock: i64, - source: String, - device_id: Option, - }, - /// Board-scoped labels changed (created / updated / deleted). Subscribers - /// refetch the board's label set. Distinct from `kanban:board-updated` so a - /// label edit is never mistaken for a board rename. - #[serde(rename = "kanban:label-changed", rename_all = "camelCase")] - KanbanLabelChanged { - workspace_id: String, - source_session_id: Option, - board_id: String, - updated_clock: i64, - edited_by: String, - source: String, - device_id: Option, - }, } #[allow(dead_code)] diff --git a/services/jtype-web/src/lib.rs b/services/jtype-web/src/lib.rs index 8218d99..7e15eb8 100644 --- a/services/jtype-web/src/lib.rs +++ b/services/jtype-web/src/lib.rs @@ -15,7 +15,7 @@ use axum::{ extract::DefaultBodyLimit, http::{header, StatusCode, Uri}, response::{Html, IntoResponse, Response}, - routing::{delete, get, patch, post, put}, + routing::{delete, get, post, put}, Router, }; use rust_embed::Embed; @@ -93,7 +93,7 @@ pub async fn run_from_env() -> Result<(), AppError> { .await .map_err(|e| AppError::Server(e.to_string()))?; println!("jtype-web listening on http://{}", bind_addr); - // Spawn periodic trash cleanup (document_trash + kanban_card_trash) + // Spawn periodic trash cleanup (document_trash) + the webhook delivery worker. tasks::webhook_delivery::spawn(pool.clone()); tasks::cleanup_trash::spawn(pool); axum::serve(listener, app) @@ -286,6 +286,38 @@ pub fn build_app( "/api/v1/workspaces/:workspace_id/documents/:document_id/versions", get(handlers::document::list_versions), ) + // Card comments (document-backed board) + .route( + "/api/v1/workspaces/:workspace_id/documents/:document_id/comments", + get(handlers::comments::list_comments).post(handlers::comments::create_comment), + ) + .route( + "/api/v1/workspaces/:workspace_id/comments/:comment_id", + delete(handlers::comments::delete_comment), + ) + // Webhooks (document-backed board) + .route( + "/api/v1/workspaces/:workspace_id/webhooks", + get(handlers::webhooks::list_webhooks).post(handlers::webhooks::create_webhook), + ) + .route( + "/api/v1/workspaces/:workspace_id/webhooks/:webhook_id", + delete(handlers::webhooks::delete_webhook), + ) + // Ticket links (OCCSV-3371) — always workspace-scoped; resolution is per + // workspace because ticket_key is unique within a workspace, not globally. + .route( + "/api/v1/workspaces/:workspace_id/tickets/allocate", + post(handlers::tickets::allocate), + ) + .route( + "/api/v1/workspaces/:workspace_id/tickets", + get(handlers::tickets::list_tickets), + ) + .route( + "/api/v1/workspaces/:workspace_id/tickets/:ticket", + get(handlers::tickets::resolve_ticket), + ) // Sync API .route( "/api/v1/workspaces/:workspace_id/sync/pull", @@ -316,93 +348,6 @@ pub fn build_app( "/api/v1/workspaces/:workspace_id/trash/:trash_id", delete(handlers::trash::permanent_delete), ) - // Kanban API - // Boards - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards", - get(handlers::kanban::board::list_boards).post(handlers::kanban::board::create_board), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/reorder", - post(handlers::kanban::board::reorder_boards), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id", - get(handlers::kanban::board::get_board) - .patch(handlers::kanban::board::patch_board) - .delete(handlers::kanban::board::delete_board), - ) - // Columns - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id/columns", - post(handlers::kanban::column::create_column), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/columns/reorder", - post(handlers::kanban::column::reorder_columns), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/columns/:column_id", - patch(handlers::kanban::column::patch_column).delete(handlers::kanban::column::delete_column), - ) - // Cards - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id/cards", - get(handlers::kanban::card::list_cards).post(handlers::kanban::card::create_card), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id/trash", - get(handlers::kanban::card::list_card_trash), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id/cards/move", - post(handlers::kanban::card::move_card), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id", - patch(handlers::kanban::card::patch_card) - .delete(handlers::kanban::card::delete_card), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/archive", - post(handlers::kanban::card::archive_card), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/restore", - post(handlers::kanban::card::restore_card), - ) - // Comments - .route( - "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/comments", - get(handlers::kanban::comment::list_comments).post(handlers::kanban::comment::create_comment), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/comments/:comment_id", - axum::routing::delete(handlers::kanban::comment::delete_comment), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/cards/:card_id/activity", - get(handlers::kanban::card::card_activity), - ) - // Webhooks - .route( - "/api/v1/workspaces/:workspace_id/kanban/webhooks", - get(handlers::kanban::webhook::list_webhooks).post(handlers::kanban::webhook::create_webhook), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/webhooks/:webhook_id", - axum::routing::delete(handlers::kanban::webhook::delete_webhook), - ) - // Labels - .route( - "/api/v1/workspaces/:workspace_id/kanban/boards/:board_id/labels", - get(handlers::kanban::label::list_labels).post(handlers::kanban::label::create_label), - ) - .route( - "/api/v1/workspaces/:workspace_id/kanban/labels/:label_id", - patch(handlers::kanban::label::patch_label) - .delete(handlers::kanban::label::delete_label), - ) // Domains API .route( "/api/v1/domains", diff --git a/services/jtype-web/src/mcp/kanban_tools.rs b/services/jtype-web/src/mcp/kanban_tools.rs new file mode 100644 index 0000000..b42453f --- /dev/null +++ b/services/jtype-web/src/mcp/kanban_tools.rs @@ -0,0 +1,367 @@ +//! Tool catalog + dispatcher for the **separate kanban MCP server** (mounted at +//! its own `/mcp/kanban` endpoint). Operates on the SAME document-backed boards +//! as desktop/CLI: a board is a `.board` JSON view file and cards are `.md` notes +//! carrying `board:` frontmatter, all stored in the vault `documents` table. +//! +//! There is no cloud kanban DB — these tools read/write `documents` (list + get + +//! save) and do the board grouping in-layer, reusing the canonical frontmatter +//! logic from `jtype-core` so a card created here parses identically on desktop. +//! Board/card scans are N+1 over the `.md`/`.board` docs (one content fetch each); +//! fine for an AI tool over a modest vault. + +use serde_json::{json, Value}; + +use super::tools::{api_get, api_post, get_doc, opt, pretty, req}; +use super::McpState; + +fn tool(name: &str, description: &str, properties: Value, required: &[&str]) -> Value { + json!({ + "name": name, + "description": description, + "inputSchema": { "type": "object", "properties": properties, "required": required } + }) +} + +fn p_str(desc: &str) -> Value { + json!({ "type": "string", "description": desc }) +} + +/// The kanban server's tool catalog (`tools/list`). +pub fn catalog() -> Value { + json!([ + tool( + "list_workspaces", + "List the JType workspaces you can access. Start here to obtain a workspace_id.", + json!({}), + &[] + ), + tool( + "list_boards", + "List kanban boards in a workspace (the .board view files). Start here to get a board id.", + json!({ "workspace_id": p_str("Workspace id from list_workspaces") }), + &["workspace_id"] + ), + tool( + "get_board", + "Get a board with its columns and its cards grouped by column.", + json!({ + "workspace_id": p_str("Workspace id"), + "board": p_str("Board id from list_boards"), + }), + &["workspace_id", "board"] + ), + tool( + "list_cards", + "List a board's cards, optionally filtered to one column (status key).", + json!({ + "workspace_id": p_str("Workspace id"), + "board": p_str("Board id"), + "status": p_str("Optional column key to filter by"), + }), + &["workspace_id", "board"] + ), + tool( + "create_card", + "Create a card-note in a column. Writes a Markdown note with board/status frontmatter.", + json!({ + "workspace_id": p_str("Workspace id"), + "board": p_str("Board id"), + "status": p_str("Target column key"), + "title": p_str("Card title"), + "priority": json!({ "type": "string", "enum": ["none","low","medium","high","urgent"], "description": "Optional priority" }), + "assignee": p_str("Optional assignee (free text or member handle)"), + "due": p_str("Optional due date (YYYY-MM-DD)"), + }), + &["workspace_id", "board", "status", "title"] + ), + tool( + "move_card", + "Move a card to another column — the kanban equivalent of changing status.", + json!({ + "workspace_id": p_str("Workspace id"), + "path": p_str("Card note path (from list_cards)"), + "to": p_str("Destination column key"), + "position": json!({ "type": "integer", "description": "Optional 0-based position in the column" }), + }), + &["workspace_id", "path", "to"] + ), + tool( + "update_card", + "Update a card's fields (status/priority/assignee/due). An empty string clears a field.", + json!({ + "workspace_id": p_str("Workspace id"), + "path": p_str("Card note path"), + "status": p_str("New column key"), + "priority": json!({ "type": "string", "enum": ["none","low","medium","high","urgent"] }), + "assignee": p_str("New assignee, or empty string to clear"), + "due": p_str("New due date, or empty string to clear"), + }), + &["workspace_id", "path"] + ), + ]) +} + +/// Execute a kanban tool call, returning an MCP `CallToolResult`. +pub async fn call(st: &McpState, token: &str, name: &str, args: Value) -> Value { + match run(st, token, name, &args).await { + Ok(text) => json!({ "content": [{ "type": "text", "text": text }], "isError": false }), + Err(msg) => json!({ "content": [{ "type": "text", "text": msg }], "isError": true }), + } +} + +async fn run(st: &McpState, token: &str, name: &str, args: &Value) -> Result { + match name { + "list_workspaces" => { + let body = api_get(st, token, "/api/v1/workspaces").await?; + let ws = body.get("workspaces").cloned().unwrap_or(body); + pretty(&ws) + } + "list_boards" => { + let ws = req(args, "workspace_id")?; + let boards = collect_boards(st, token, &ws).await?; + pretty(&json!(boards)) + } + "get_board" => { + let ws = req(args, "workspace_id")?; + let board = req(args, "board")?; + let boards = collect_boards(st, token, &ws).await?; + let cfg = boards + .iter() + .find(|b| b.get("id").and_then(|v| v.as_str()) == Some(board.as_str())) + .ok_or_else(|| format!("no board with id '{board}'"))? + .clone(); + let cards = collect_cards(st, token, &ws, &board).await?; + pretty(&json!({ "board": cfg, "cards": cards })) + } + "list_cards" => { + let ws = req(args, "workspace_id")?; + let board = req(args, "board")?; + let mut cards = collect_cards(st, token, &ws, &board).await?; + if let Some(status) = opt(args, "status") { + cards.retain(|c| c.get("status").and_then(|v| v.as_str()) == Some(status.as_str())); + } + pretty(&json!(cards)) + } + "create_card" => { + let ws = req(args, "workspace_id")?; + let board = req(args, "board")?; + let status = req(args, "status")?; + let title = req(args, "title")?; + let boards = collect_boards(st, token, &ws).await?; + let b = boards + .iter() + .find(|b| b.get("id").and_then(|v| v.as_str()) == Some(board.as_str())) + .ok_or_else(|| format!("no board with id '{board}'"))?; + let bpath = b.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let dir = bpath.strip_suffix(".board").unwrap_or(bpath); + let cards = collect_cards(st, token, &ws, &board).await?; + let next_pos = cards + .iter() + .filter(|c| c.get("status").and_then(|v| v.as_str()) == Some(status.as_str())) + .filter_map(|c| c.get("position").and_then(|v| v.as_i64())) + .max() + .map(|m| m + 1) + .unwrap_or(0); + + let mut content = format!("# {title}\n"); + content = jtype_core::set_frontmatter_field(&content, "board", Some(&board)); + content = jtype_core::set_frontmatter_field(&content, "status", Some(&status)); + content = jtype_core::set_frontmatter_field(&content, "position", Some(&next_pos.to_string())); + if let Some(v) = opt(args, "priority") { + content = jtype_core::set_frontmatter_field(&content, "priority", Some(&v)); + } + if let Some(v) = opt(args, "assignee") { + content = jtype_core::set_frontmatter_field(&content, "assignee", Some(&v)); + } + if let Some(v) = opt(args, "due") { + content = jtype_core::set_frontmatter_field(&content, "due", Some(&v)); + } + + // Don't clobber an existing card whose title slugifies the same: the + // save path overwrites by relative_path, so probe and suffix -2, -3, … + let slug = slugify(&title); + let mut rel = format!("{dir}/{slug}.md"); + let mut n = 2; + while get_doc(st, token, &ws, &rel).await.is_ok() { + rel = format!("{dir}/{slug}-{n}.md"); + n += 1; + } + let res = api_post( + st, + token, + &format!("/api/v1/workspaces/{ws}/documents/save"), + json!({ "relativePath": rel, "content": content }), + ) + .await?; + Ok(format!("Created card '{title}' at {rel}.\n{}", pretty(&res)?)) + } + "move_card" => { + let ws = req(args, "workspace_id")?; + let path = req(args, "path")?; + let to = req(args, "to")?; + let doc = get_doc(st, token, &ws, &path).await?; + let content = doc.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let rel = doc + .get("relativePath") + .and_then(|v| v.as_str()) + .unwrap_or(&path) + .to_string(); + let mut updated = jtype_core::set_frontmatter_field(content, "status", Some(&to)); + if let Some(p) = args.get("position").and_then(|v| v.as_i64()) { + updated = jtype_core::set_frontmatter_field(&updated, "position", Some(&p.to_string())); + } + let res = api_post( + st, + token, + &format!("/api/v1/workspaces/{ws}/documents/save"), + json!({ "relativePath": rel, "content": updated }), + ) + .await?; + Ok(format!("Moved card {rel} → {to}.\n{}", pretty(&res)?)) + } + "update_card" => { + let ws = req(args, "workspace_id")?; + let path = req(args, "path")?; + let doc = get_doc(st, token, &ws, &path).await?; + let mut content = doc + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let rel = doc + .get("relativePath") + .and_then(|v| v.as_str()) + .unwrap_or(&path) + .to_string(); + let mut touched = false; + // Key off presence (not opt(), which drops empty strings) so an + // explicit "" clears the field, as documented; an omitted key is a no-op. + for key in ["status", "priority", "assignee", "due"] { + if let Some(v) = args.get(key).and_then(|v| v.as_str()) { + let set = if v.is_empty() { None } else { Some(v) }; + content = jtype_core::set_frontmatter_field(&content, key, set); + touched = true; + } + } + if !touched { + return Err("update_card: provide at least one of status/priority/assignee/due".into()); + } + let res = api_post( + st, + token, + &format!("/api/v1/workspaces/{ws}/documents/save"), + json!({ "relativePath": rel, "content": content }), + ) + .await?; + Ok(format!("Updated card {rel}.\n{}", pretty(&res)?)) + } + other => Err(format!("unknown tool: {other}")), + } +} + +/// Fetch every `.board` doc, parse its JSON config, and return id/title/columns/path. +async fn collect_boards(st: &McpState, token: &str, ws: &str) -> Result, String> { + let docs = api_get(st, token, &format!("/api/v1/workspaces/{ws}/documents")).await?; + let mut boards = Vec::new(); + if let Some(arr) = docs.as_array() { + for d in arr { + let path = d.get("relativePath").and_then(|v| v.as_str()).unwrap_or(""); + if !path.ends_with(".board") { + continue; + } + let doc = get_doc(st, token, ws, path).await?; + let content = doc.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let cfg: Value = serde_json::from_str(content).unwrap_or(Value::Null); + let stem = path.rsplit('/').next().unwrap_or(path).trim_end_matches(".board"); + let id = cfg + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(stem) + .to_string(); + let title = cfg + .get("title") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(&id) + .to_string(); + boards.push(json!({ + "id": id, + "title": title, + "path": path, + "columns": cfg.get("columns").cloned().unwrap_or(json!([])), + })); + } + } + Ok(boards) +} + +/// Fetch every `.md` doc, keep those with `board == board_id`, return card rows +/// sorted by status then position. +async fn collect_cards(st: &McpState, token: &str, ws: &str, board_id: &str) -> Result, String> { + let docs = api_get(st, token, &format!("/api/v1/workspaces/{ws}/documents")).await?; + let mut cards = Vec::new(); + if let Some(arr) = docs.as_array() { + for d in arr { + let path = d.get("relativePath").and_then(|v| v.as_str()).unwrap_or(""); + if !path.ends_with(".md") { + continue; + } + let doc = get_doc(st, token, ws, path).await?; + let content = doc.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let fm = jtype_core::parse_frontmatter(content); + if fm.get("board").map(String::as_str) != Some(board_id) { + continue; + } + let title = fm + .get("title") + .cloned() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| card_title(content, path)); + let position = fm.get("position").and_then(|v| v.parse::().ok()).unwrap_or(0); + cards.push(json!({ + "path": path, + "title": title, + "status": fm.get("status").cloned().unwrap_or_default(), + "position": position, + "priority": fm.get("priority").cloned(), + "assignee": fm.get("assignee").cloned(), + "due": fm.get("due").cloned(), + })); + } + } + cards.sort_by(|a, b| { + let sa = a.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let sb = b.get("status").and_then(|v| v.as_str()).unwrap_or(""); + sa.cmp(sb).then_with(|| { + let pa = a.get("position").and_then(|v| v.as_i64()).unwrap_or(0); + let pb = b.get("position").and_then(|v| v.as_i64()).unwrap_or(0); + pa.cmp(&pb) + }) + }); + Ok(cards) +} + +/// Card title = first `# ` heading in the body, else the filename stem. +fn card_title(content: &str, path: &str) -> String { + for line in content.lines() { + if let Some(h) = line.strip_prefix("# ") { + return h.trim().to_string(); + } + } + path.rsplit('/').next().unwrap_or(path).trim_end_matches(".md").to_string() +} + +/// Filename slug from a card title (alphanumerics kept lowercased, rest → hyphens). +fn slugify(title: &str) -> String { + let mapped: String = title + .chars() + .map(|ch| if ch.is_alphanumeric() { ch.to_ascii_lowercase() } else { '-' }) + .collect(); + let collapsed = mapped.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + if collapsed.is_empty() { + "card".to_string() + } else { + collapsed + } +} diff --git a/services/jtype-web/src/mcp/mod.rs b/services/jtype-web/src/mcp/mod.rs index 4de52c1..c8c393e 100644 --- a/services/jtype-web/src/mcp/mod.rs +++ b/services/jtype-web/src/mcp/mod.rs @@ -10,6 +10,7 @@ //! This reuses all tested handler logic (RBAC, sqlx CAST handling, lamport //! clocks, version history, WS broadcasts) with zero duplication. +pub mod kanban_tools; pub mod oauth; pub mod tools; @@ -40,10 +41,20 @@ pub struct McpState { pub public_base_url: String, } +/// Which tool surface an MCP endpoint exposes. Notes and kanban are mounted as +/// physically separate servers (`/mcp` vs `/mcp/kanban`) so an agent can be +/// scoped to one without seeing the other; both share auth + JSON-RPC plumbing. +#[derive(Clone, Copy)] +enum ServerKind { + Notes, + Kanban, +} + /// Build the MCP + OAuth-discovery router. Merge this with the API router. pub fn router(state: McpState) -> Router { Router::new() - .route("/mcp", post(handle_mcp).get(mcp_get).delete(mcp_delete)) + .route("/mcp", post(handle_notes).get(mcp_get).delete(mcp_delete)) + .route("/mcp/kanban", post(handle_kanban).get(mcp_get).delete(mcp_delete)) .route( "/.well-known/oauth-protected-resource", get(oauth::protected_resource_metadata), @@ -106,9 +117,19 @@ fn bearer_from(headers: &HeaderMap) -> Option { .filter(|t| !t.is_empty()) } -/// `POST /mcp` — JSON-RPC 2.0 over Streamable HTTP. Returns a single JSON -/// response (we never need to open an SSE stream for these tools). -async fn handle_mcp(State(st): State, headers: HeaderMap, body: Bytes) -> Response { +/// `POST /mcp` — the notes tool surface. +async fn handle_notes(state: State, headers: HeaderMap, body: Bytes) -> Response { + handle_mcp(ServerKind::Notes, state, headers, body).await +} + +/// `POST /mcp/kanban` — the separate kanban tool surface. +async fn handle_kanban(state: State, headers: HeaderMap, body: Bytes) -> Response { + handle_mcp(ServerKind::Kanban, state, headers, body).await +} + +/// JSON-RPC 2.0 over Streamable HTTP. Returns a single JSON response (we never +/// need to open an SSE stream for these tools). +async fn handle_mcp(kind: ServerKind, State(st): State, headers: HeaderMap, body: Bytes) -> Response { // Authenticate the whole endpoint (MCP resource server). let Some(token) = bearer_from(&headers) else { return unauthorized(&st.public_base_url); @@ -130,7 +151,7 @@ async fn handle_mcp(State(st): State, headers: HeaderMap, body: Bytes) } let mut out = Vec::new(); for item in items { - if let Some(resp) = dispatch(&st, &token, item).await { + if let Some(resp) = dispatch(kind, &st, &token, item).await { out.push(resp); } } @@ -141,7 +162,7 @@ async fn handle_mcp(State(st): State, headers: HeaderMap, body: Bytes) return Json(Value::Array(out)).into_response(); } - match dispatch(&st, &token, parsed).await { + match dispatch(kind, &st, &token, parsed).await { Some(resp) => Json(resp).into_response(), // Notifications get no body. None => StatusCode::ACCEPTED.into_response(), @@ -149,7 +170,7 @@ async fn handle_mcp(State(st): State, headers: HeaderMap, body: Bytes) } /// Dispatch a single JSON-RPC message. Returns `None` for notifications. -async fn dispatch(st: &McpState, token: &str, msg: Value) -> Option { +async fn dispatch(kind: ServerKind, st: &McpState, token: &str, msg: Value) -> Option { // A non-object message (bare scalar / nested array) is an Invalid Request. if !msg.is_object() { return Some(err(Value::Null, -32600, "invalid request")); @@ -165,17 +186,35 @@ async fn dispatch(st: &McpState, token: &str, msg: Value) -> Option { let id = id.unwrap(); match method { - "initialize" => Some(ok( - id, - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": { "listChanged": false } }, - "serverInfo": { "name": "jtype", "version": env!("CARGO_PKG_VERSION") }, - "instructions": "JType notes (Markdown documents) and kanban boards. Call list_workspaces first to get a workspace_id, then use note_* and card/board tools." - }), - )), + "initialize" => { + let (name, instructions) = match kind { + ServerKind::Notes => ( + "jtype", + "JType notes (Markdown documents). Call list_workspaces first to get a workspace_id, then use the note tools.", + ), + ServerKind::Kanban => ( + "jtype-kanban", + "JType kanban boards — document-backed (.board views over .md card-notes). Call list_workspaces for a workspace_id, then list_boards, then the card tools.", + ), + }; + Some(ok( + id, + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { "name": name, "version": env!("CARGO_PKG_VERSION") }, + "instructions": instructions, + }), + )) + } "ping" => Some(ok(id, json!({}))), - "tools/list" => Some(ok(id, json!({ "tools": tools::catalog() }))), + "tools/list" => { + let tools = match kind { + ServerKind::Notes => tools::catalog(), + ServerKind::Kanban => kanban_tools::catalog(), + }; + Some(ok(id, json!({ "tools": tools }))) + } "tools/call" => { let name = params .get("name") @@ -183,7 +222,10 @@ async fn dispatch(st: &McpState, token: &str, msg: Value) -> Option { .unwrap_or("") .to_string(); let args = params.get("arguments").cloned().unwrap_or(json!({})); - let result = tools::call(st, token, &name, args).await; + let result = match kind { + ServerKind::Notes => tools::call(st, token, &name, args).await, + ServerKind::Kanban => kanban_tools::call(st, token, &name, args).await, + }; Some(ok(id, result)) } _ => Some(err(id, -32601, &format!("method not found: {method}"))), diff --git a/services/jtype-web/src/mcp/tools.rs b/services/jtype-web/src/mcp/tools.rs index 1f7d603..c5f8e6c 100644 --- a/services/jtype-web/src/mcp/tools.rs +++ b/services/jtype-web/src/mcp/tools.rs @@ -95,72 +95,6 @@ pub fn catalog() -> Value { }), &["workspace_id", "path", "content"] ), - tool( - "list_boards", - "List kanban boards in a workspace.", - json!({ "workspace_id": p_str("Workspace id") }), - &["workspace_id"] - ), - tool( - "get_board", - "Get a kanban board with its columns, cards and labels.", - json!({ - "workspace_id": p_str("Workspace id"), - "board_id": p_str("Board id from list_boards"), - }), - &["workspace_id", "board_id"] - ), - tool( - "list_cards", - "List cards on a board, optionally filtered to one column.", - json!({ - "workspace_id": p_str("Workspace id"), - "board_id": p_str("Board id"), - "column_id": p_str("Optional column id to filter by"), - }), - &["workspace_id", "board_id"] - ), - tool( - "create_card", - "Create a kanban card in a column.", - json!({ - "workspace_id": p_str("Workspace id"), - "board_id": p_str("Board id"), - "column_id": p_str("Target column id"), - "title": p_str("Card title"), - "description": p_str("Optional Markdown description"), - "priority": json!({ "type": "string", "enum": ["none","low","medium","high","urgent"], "description": "Optional priority" }), - "assignee_user_id": p_str("Optional assignee user id (see list_members)"), - "due_at": p_str("Optional due date (YYYY-MM-DD HH:MM:SS)"), - }), - &["workspace_id", "board_id", "column_id", "title"] - ), - tool( - "update_card", - "Update a card's fields (title, description, priority, assignee, due date) in one call.", - json!({ - "workspace_id": p_str("Workspace id"), - "card_id": p_str("Card id"), - "title": p_str("New title"), - "description": p_str("New description"), - "priority": json!({ "type": "string", "enum": ["none","low","medium","high","urgent"] }), - "assignee_user_id": p_str("New assignee user id (empty string to unassign)"), - "due_at": p_str("New due date or empty string to clear"), - }), - &["workspace_id", "card_id"] - ), - tool( - "move_card", - "Move a card to a column (and optional position) — the kanban equivalent of changing status.", - json!({ - "workspace_id": p_str("Workspace id"), - "board_id": p_str("Board id"), - "card_id": p_str("Card id"), - "target_column_id": p_str("Destination column id"), - "target_position": json!({ "type": "integer", "description": "0-based position in the column (default 0 = top)" }), - }), - &["workspace_id", "board_id", "card_id", "target_column_id"] - ), tool( "list_members", "List workspace members (user ids, usernames, roles) — use to resolve assignee_user_id.", @@ -269,110 +203,6 @@ async fn run(st: &McpState, token: &str, name: &str, args: &Value) -> Result { - let ws = req(args, "workspace_id")?; - let boards = api_get(st, token, &format!("/api/v1/workspaces/{ws}/kanban/boards")).await?; - pretty(&boards) - } - "get_board" => { - let ws = req(args, "workspace_id")?; - let board = req(args, "board_id")?; - let b = api_get( - st, - token, - &format!("/api/v1/workspaces/{ws}/kanban/boards/{board}"), - ) - .await?; - pretty(&b) - } - "list_cards" => { - let ws = req(args, "workspace_id")?; - let board = req(args, "board_id")?; - let cards = api_get( - st, - token, - &format!("/api/v1/workspaces/{ws}/kanban/boards/{board}/cards"), - ) - .await?; - let cards = match opt(args, "column_id") { - Some(col) => filter_cards_by_column(cards, &col), - None => cards, - }; - pretty(&cards) - } - "create_card" => { - let ws = req(args, "workspace_id")?; - let board = req(args, "board_id")?; - let mut body = Map::new(); - body.insert("columnId".into(), json!(req(args, "column_id")?)); - body.insert("title".into(), json!(req(args, "title")?)); - copy_opt(args, "description", "description", &mut body); - copy_opt(args, "priority", "priority", &mut body); - copy_opt(args, "assignee_user_id", "assigneeUserId", &mut body); - copy_opt(args, "due_at", "dueAt", &mut body); - let card = api_post( - st, - token, - &format!("/api/v1/workspaces/{ws}/kanban/boards/{board}/cards"), - Value::Object(body), - ) - .await?; - Ok(format!( - "Created card '{}' (id {}).\n{}", - card.get("title").and_then(|v| v.as_str()).unwrap_or("?"), - card.get("id").and_then(|v| v.as_str()).unwrap_or("?"), - pretty(&card)? - )) - } - "update_card" => { - let ws = req(args, "workspace_id")?; - let card_id = req(args, "card_id")?; - let mut body = Map::new(); - copy_opt(args, "title", "title", &mut body); - copy_opt(args, "priority", "priority", &mut body); - // Clearable fields: an explicit empty string unsets them (JSON null). - copy_clearable(args, "description", "description", &mut body); - copy_clearable(args, "assignee_user_id", "assigneeUserId", &mut body); - copy_clearable(args, "due_at", "dueAt", &mut body); - if body.is_empty() { - return Err("update_card: provide at least one field to change".into()); - } - let card = api_patch( - st, - token, - &format!("/api/v1/workspaces/{ws}/kanban/cards/{card_id}"), - Value::Object(body), - ) - .await?; - Ok(format!("Updated card {}.\n{}", card_id, pretty(&card)?)) - } - "move_card" => { - let ws = req(args, "workspace_id")?; - let board = req(args, "board_id")?; - let card_id = req(args, "card_id")?; - let target_col = req(args, "target_column_id")?; - let position = args.get("target_position").and_then(|v| v.as_i64()).unwrap_or(0); - if position < 0 { - return Err("move_card: target_position must be >= 0".into()); - } - let card = api_post( - st, - token, - &format!("/api/v1/workspaces/{ws}/kanban/boards/{board}/cards/move"), - json!({ - "cardId": card_id, - "targetColumnId": target_col, - "targetPosition": position, - }), - ) - .await?; - Ok(format!( - "Moved card {} to column {}.\n{}", - card_id, - target_col, - pretty(&card)? - )) - } "list_members" => { let ws = req(args, "workspace_id")?; let members = api_get(st, token, &format!("/api/v1/workspaces/{ws}/members")).await?; @@ -384,45 +214,22 @@ async fn run(st: &McpState, token: &str, name: &str, args: &Value) -> Result Result { +pub(super) fn req(args: &Value, key: &str) -> Result { opt(args, key).ok_or_else(|| format!("missing required argument: {key}")) } -fn opt(args: &Value, key: &str) -> Option { +pub(super) fn opt(args: &Value, key: &str) -> Option { args.get(key) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .filter(|s| !s.is_empty()) } -/// Copy an optional string arg (snake_case) into a request body under a new key, -/// skipping absent or empty values (so `create_card` never sends an empty -/// priority/assignee/due that the backend would 400 on). -fn copy_opt(args: &Value, from: &str, to: &str, body: &mut Map) { - if let Some(v) = args - .get(from) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - body.insert(to.into(), json!(v)); - } -} - -/// Like [`copy_opt`] but an explicit empty string **clears** the field (JSON -/// `null`). The backend models description/assignee/due as `Option>`, -/// where the inner `None` (JSON null) is the clear sentinel — an empty string -/// would be rejected, so this is what makes "unassign / clear due date" work. -fn copy_clearable(args: &Value, from: &str, to: &str, body: &mut Map) { - if let Some(v) = args.get(from).and_then(|v| v.as_str()) { - body.insert(to.into(), if v.is_empty() { Value::Null } else { json!(v) }); - } -} - -fn pretty(v: &Value) -> Result { +pub(super) fn pretty(v: &Value) -> Result { serde_json::to_string_pretty(v).map_err(|e| e.to_string()) } -async fn api_get(st: &McpState, token: &str, uri: &str) -> Result { +pub(super) async fn api_get(st: &McpState, token: &str, uri: &str) -> Result { let (status, body) = call_api(&st.api, Method::GET, uri, token, None) .await .map_err(|e| e.to_string())?; @@ -433,7 +240,7 @@ async fn api_get(st: &McpState, token: &str, uri: &str) -> Result } } -async fn api_post(st: &McpState, token: &str, uri: &str, body: Value) -> Result { +pub(super) async fn api_post(st: &McpState, token: &str, uri: &str, body: Value) -> Result { let (status, resp) = call_api(&st.api, Method::POST, uri, token, Some(body)) .await .map_err(|e| e.to_string())?; @@ -444,17 +251,6 @@ async fn api_post(st: &McpState, token: &str, uri: &str, body: Value) -> Result< } } -async fn api_patch(st: &McpState, token: &str, uri: &str, body: Value) -> Result { - let (status, resp) = call_api(&st.api, Method::PATCH, uri, token, Some(body)) - .await - .map_err(|e| e.to_string())?; - if status.is_success() { - Ok(resp) - } else { - Err(api_err(status.as_u16(), &resp)) - } -} - fn api_err(status: u16, body: &Value) -> String { let msg = body .get("error") @@ -487,7 +283,7 @@ async fn resolve_doc_id( } /// Resolve a note by path and return its full `CloudDocument` (with content). -async fn get_doc(st: &McpState, token: &str, ws: &str, path: &str) -> Result { +pub(super) async fn get_doc(st: &McpState, token: &str, ws: &str, path: &str) -> Result { let id = resolve_doc_id(st, token, ws, path) .await? .ok_or_else(|| format!("note not found: {path}"))?; @@ -516,17 +312,6 @@ fn filter_by_folder(docs: Value, folder: &str) -> Value { } } -fn filter_cards_by_column(cards: Value, column_id: &str) -> Value { - match cards { - Value::Array(arr) => Value::Array( - arr.into_iter() - .filter(|c| c.get("columnId").and_then(|v| v.as_str()) == Some(column_id)) - .collect(), - ), - other => other, - } -} - /// Keyword search over title/path, then content (bounded fetch), with snippets. async fn search_notes( st: &McpState, diff --git a/services/jtype-web/src/tasks/cleanup_trash.rs b/services/jtype-web/src/tasks/cleanup_trash.rs index 2fe309c..3e7561f 100644 --- a/services/jtype-web/src/tasks/cleanup_trash.rs +++ b/services/jtype-web/src/tasks/cleanup_trash.rs @@ -2,7 +2,6 @@ //! //! Runs every hour. Purges: //! - `document_trash` whose `expires_at < NOW()` -//! - `kanban_card_trash` whose `expires_at < NOW()` //! //! Note: this is *physical* cleanup. Restored rows are not touched //! (they have `restored_at` set but are kept for audit history). @@ -29,11 +28,8 @@ pub fn spawn(pool: Pool) { continue; } match run_once(&pool).await { - Ok((doc, kanban)) => { - eprintln!( - "[cleanup_trash] purged {} document_trash rows, {} kanban_card_trash rows", - doc, kanban - ); + Ok(doc) => { + eprintln!("[cleanup_trash] purged {} document_trash rows", doc); } Err(e) => { eprintln!("[cleanup_trash] error: {}", e); @@ -44,14 +40,10 @@ pub fn spawn(pool: Pool) { }); } -pub async fn run_once(pool: &Pool) -> Result<(u64, u64), sqlx::Error> { +pub async fn run_once(pool: &Pool) -> Result { let docs = sqlx::query("DELETE FROM document_trash WHERE expires_at < CURRENT_TIMESTAMP") .execute(pool) .await? .rows_affected(); - let kanban = sqlx::query("DELETE FROM kanban_card_trash WHERE expires_at < CURRENT_TIMESTAMP") - .execute(pool) - .await? - .rows_affected(); - Ok((docs, kanban)) + Ok(docs) } diff --git a/services/jtype-web/src/tasks/mod.rs b/services/jtype-web/src/tasks/mod.rs index c3637d4..8ff7c81 100644 --- a/services/jtype-web/src/tasks/mod.rs +++ b/services/jtype-web/src/tasks/mod.rs @@ -1,8 +1,9 @@ //! Background tasks (cron-like loops). //! //! Currently: -//! - `cleanup_trash`: hourly, deletes rows from `document_trash` and -//! `kanban_card_trash` whose `expires_at < NOW()`. +//! - `cleanup_trash`: hourly, deletes rows from `document_trash` +//! whose `expires_at < NOW()`. +//! - `webhook_delivery`: every 10s, signs + POSTs due webhook deliveries. //! //! All tasks spawned from `lib.rs::run_from_env`. diff --git a/services/jtype-web/src/tasks/webhook_delivery.rs b/services/jtype-web/src/tasks/webhook_delivery.rs index 12f4580..7d88586 100644 --- a/services/jtype-web/src/tasks/webhook_delivery.rs +++ b/services/jtype-web/src/tasks/webhook_delivery.rs @@ -1,4 +1,4 @@ -//! Outbound kanban webhook delivery worker. +//! Outbound webhook delivery worker (document-backed board). //! //! Every tick it picks due deliveries (`pending`/`failed` with `next_retry_at` //! elapsed), signs the JSON body with HMAC-SHA256 (key = the webhook secret), @@ -35,6 +35,29 @@ pub fn spawn(pool: Pool) { }); } +/// Re-resolve the target host at delivery time and refuse internal addresses. +/// Conservative: an unparseable URL or a failed DNS lookup is treated as blocked. +async fn ssrf_blocked(target_url: &str) -> bool { + let Ok(url) = url::Url::parse(target_url) else { + return true; + }; + match url.host() { + Some(url::Host::Ipv4(ip)) => crate::handlers::webhooks::is_blocked_ip(std::net::IpAddr::V4(ip)), + Some(url::Host::Ipv6(ip)) => crate::handlers::webhooks::is_blocked_ip(std::net::IpAddr::V6(ip)), + Some(url::Host::Domain(domain)) => { + let port = url.port_or_known_default().unwrap_or(443); + match tokio::net::lookup_host((domain, port)).await { + Ok(addrs) => { + let addrs: Vec<_> = addrs.collect(); + addrs.is_empty() || addrs.iter().any(|a| crate::handlers::webhooks::is_blocked_ip(a.ip())) + } + Err(_) => true, + } + } + None => true, + } +} + fn hex_encode(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { @@ -57,8 +80,8 @@ pub async fn run_once(pool: &Pool) -> Result { let rows = sqlx::query( r#"SELECT d.id, d.webhook_id, d.event_type, d.payload, d.attempt_count, d.max_attempts, w.target_url, w.secret - FROM kanban_webhook_deliveries d - JOIN kanban_webhooks w ON w.id = d.webhook_id + FROM webhook_deliveries d + JOIN webhooks w ON w.id = d.webhook_id WHERE d.status IN ('pending', 'failed') AND (d.next_retry_at IS NULL OR d.next_retry_at <= NOW()) ORDER BY d.created_at ASC @@ -79,6 +102,35 @@ pub async fn run_once(pool: &Pool) -> Result { let target_url: String = r.try_get("target_url")?; let secret: String = r.try_get("secret")?; + // Delivery-time SSRF re-check: the create-time validator only saw the host + // once. Re-resolve now and refuse internal targets (DNS-rebinding/TOCTOU + // defense). A residual race vs reqwest's own resolve remains, but this + // closes the create→deliver rebinding window the redirect block can't. + if ssrf_blocked(&target_url).await { + let attempt = prev_attempts + 1; + let dead = attempt >= max_attempts; + let status = if dead { "dead" } else { "failed" }; + let backoff = 2_i64.saturating_pow(attempt.max(0) as u32).saturating_mul(30).min(3600); + sqlx::query( + "UPDATE webhook_deliveries SET status=?, attempt_count=?, last_error=?, next_retry_at=DATE_ADD(NOW(), INTERVAL ? SECOND) WHERE id=?", + ) + .bind(status) + .bind(attempt) + .bind("target resolves to a blocked (internal) address") + .bind(backoff) + .bind(&id) + .execute(pool) + .await?; + // Keep the denormalized webhook summary in step with the failure + // branch, else a repeatedly-blocked webhook keeps showing a stale "ok". + sqlx::query("UPDATE webhooks SET last_delivery_at=NOW(), last_status='failed' WHERE id=?") + .bind(&webhook_id) + .execute(pool) + .await?; + attempted += 1; + continue; + } + let body = serde_json::to_vec(&payload).unwrap_or_default(); let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { Ok(m) => m, @@ -102,14 +154,14 @@ pub async fn run_once(pool: &Pool) -> Result { if success { let code = resp.map(|r| r.status().as_u16() as i32).unwrap_or(0); sqlx::query( - "UPDATE kanban_webhook_deliveries SET status='succeeded', attempt_count=?, last_status_code=?, next_retry_at=NULL WHERE id=?", + "UPDATE webhook_deliveries SET status='succeeded', attempt_count=?, last_status_code=?, next_retry_at=NULL WHERE id=?", ) .bind(attempt) .bind(code) .bind(&id) .execute(pool) .await?; - sqlx::query("UPDATE kanban_webhooks SET last_delivery_at=NOW(), last_status='ok' WHERE id=?") + sqlx::query("UPDATE webhooks SET last_delivery_at=NOW(), last_status='ok' WHERE id=?") .bind(&webhook_id) .execute(pool) .await?; @@ -123,7 +175,7 @@ pub async fn run_once(pool: &Pool) -> Result { let backoff = 2_i64.saturating_pow(attempt.max(0) as u32).saturating_mul(30).min(3600); let err: String = err.chars().take(512).collect(); sqlx::query( - "UPDATE kanban_webhook_deliveries SET status=?, attempt_count=?, last_status_code=?, last_error=?, next_retry_at=DATE_ADD(NOW(), INTERVAL ? SECOND) WHERE id=?", + "UPDATE webhook_deliveries SET status=?, attempt_count=?, last_status_code=?, last_error=?, next_retry_at=DATE_ADD(NOW(), INTERVAL ? SECOND) WHERE id=?", ) .bind(status) .bind(attempt) @@ -133,7 +185,7 @@ pub async fn run_once(pool: &Pool) -> Result { .bind(&id) .execute(pool) .await?; - sqlx::query("UPDATE kanban_webhooks SET last_delivery_at=NOW(), last_status='failed' WHERE id=?") + sqlx::query("UPDATE webhooks SET last_delivery_at=NOW(), last_status='failed' WHERE id=?") .bind(&webhook_id) .execute(pool) .await?; diff --git a/services/jtype-web/tests/kanban_e2e_tests.rs b/services/jtype-web/tests/kanban_e2e_tests.rs deleted file mode 100644 index a51ff10..0000000 --- a/services/jtype-web/tests/kanban_e2e_tests.rs +++ /dev/null @@ -1,775 +0,0 @@ -//! Kanban end-to-end tests — multi-device realtime sync + the behaviours added -//! while hardening the module (deterministic restore, position compaction, -//! contract validation, the archive/trash list endpoint, force-override). -//! -//! These complement `kanban_tests.rs`: that file proves each endpoint in -//! isolation; this file proves cross-device propagation and the trickier -//! lifecycle/edge cases that a real client (web + desktop, multiple sessions) -//! exercises. -//! -//! Run with: -//! cargo test --manifest-path services/jtype-web/Cargo.toml --test kanban_e2e_tests -//! Requires a running MySQL (see tests/common/mod.rs). - -mod common; - -use axum::http::StatusCode; -use axum::Router; -use jtype_web::hub::WorkspaceEvent; -use serde_json::{json, Value}; -use sqlx::{MySql, Pool}; -use std::time::Duration; - -// ── helpers ────────────────────────────────────────────────────────────────── - -/// Drain up to `max` events from a subscriber within a short window. -async fn drain(rx: &mut tokio::sync::mpsc::Receiver, max: usize) -> Vec { - let mut out = Vec::new(); - for _ in 0..max { - match tokio::time::timeout(Duration::from_millis(500), rx.recv()).await { - Ok(Some(ev)) => out.push(serde_json::to_value(&ev).unwrap()), - _ => break, - } - } - out -} - -/// The `type` discriminator of each event. -fn types(events: &[Value]) -> Vec { - events - .iter() - .filter_map(|e| e["type"].as_str().map(str::to_string)) - .collect() -} - -fn count_type(events: &[Value], t: &str) -> usize { - types(events).iter().filter(|x| *x == t).count() -} - -/// Create a board; return (board_id, [column_ids]). -async fn make_board(app: &Router, token: &str, ws_id: &str, name: &str) -> (String, Vec) { - let (status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(token), - Some(json!({ "name": name })), - ) - .await; - assert_eq!(status, StatusCode::OK, "make_board failed: {board}"); - let bid = board["id"].as_str().unwrap().to_string(); - let cols = board["columns"] - .as_array() - .unwrap() - .iter() - .map(|c| c["id"].as_str().unwrap().to_string()) - .collect(); - (bid, cols) -} - -/// Create a card in a column; return its id. -async fn make_card(app: &Router, token: &str, ws_id: &str, board_id: &str, col_id: &str, title: &str) -> String { - let (status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(token), - Some(json!({ "columnId": col_id, "title": title })), - ) - .await; - assert_eq!(status, StatusCode::OK, "make_card failed: {card}"); - card["id"].as_str().unwrap().to_string() -} - -/// List active cards in a column (id -> position), as an ordered Vec of ids. -async fn column_order(app: &Router, token: &str, ws_id: &str, board_id: &str, col_id: &str) -> Vec { - let (_s, list) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(token), - None, - ) - .await; - let mut cards: Vec<(i64, String)> = list - .as_array() - .unwrap() - .iter() - .filter(|c| c["columnId"].as_str() == Some(col_id)) - .map(|c| (c["position"].as_i64().unwrap(), c["id"].as_str().unwrap().to_string())) - .collect(); - cards.sort_by_key(|(p, _)| *p); - cards.into_iter().map(|(_, id)| id).collect() -} - -/// Insert a workspace member with the given role; return the user id. -async fn add_member(pool: &Pool, ws_id: &str, username: &str, role: &str) -> String { - let uid: String = sqlx::query_scalar("SELECT id FROM users WHERE username = ?") - .bind(username) - .fetch_one(pool) - .await - .unwrap(); - sqlx::query( - "INSERT INTO workspace_members (workspace_id, user_id, role, status, joined_at) - VALUES (?, ?, ?, 'active', CURRENT_TIMESTAMP)", - ) - .bind(ws_id) - .bind(&uid) - .bind(role) - .execute(pool) - .await - .unwrap(); - uid -} - -// ── 1. Multi-device realtime sync ──────────────────────────────────────────── - -#[tokio::test] -async fn ws_board_create_propagates_board_and_three_columns() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - // Two other devices watching the same workspace. - let (_s1, mut dev1) = hub.subscribe_for_test(&ws_id).await; - let (_s2, mut dev2) = hub.subscribe_for_test(&ws_id).await; - - let _ = make_board(&app, &token, &ws_id, "Sprint").await; - - for rx in [&mut dev1, &mut dev2] { - let evs = drain(rx, 10).await; - assert_eq!(count_type(&evs, "kanban:board-updated"), 1, "each device sees board-updated: {evs:?}"); - assert_eq!(count_type(&evs, "kanban:column-updated"), 3, "each device sees 3 seeded columns: {evs:?}"); - } -} - -#[tokio::test] -async fn ws_card_lifecycle_propagates_to_other_device() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - - // Subscribe AFTER board setup so we only capture card lifecycle events. - let (_s, mut dev) = hub.subscribe_for_test(&ws_id).await; - - // create - let card_id = make_card(&app, &token, &ws_id, &board_id, &cols[0], "task").await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:card-updated".to_string())); - - // patch - let _ = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "priority": "high" })), - ) - .await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:card-updated".to_string())); - - // move - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_id, "targetColumnId": cols[1], "targetPosition": 0 })), - ) - .await; - let mv = drain(&mut dev, 5).await; - assert!(types(&mv).contains(&"kanban:card-updated".to_string())); - assert_eq!(mv.iter().find(|e| e["type"] == "kanban:card-updated").unwrap()["columnId"], cols[1]); - - // archive - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:card-archived".to_string())); - - // restore - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/restore"), - Some(&token), - None, - ) - .await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:card-restored".to_string())); - - // hard delete - let _ = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - None, - ) - .await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:card-deleted".to_string())); -} - -#[tokio::test] -async fn ws_label_changes_emit_label_changed_not_board_updated() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, _cols) = make_board(&app, &token, &ws_id, "B").await; - - let (_s, mut dev) = hub.subscribe_for_test(&ws_id).await; - - // create label - let (_s1, label) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "bug", "color": "#ef4444" })), - ) - .await; - let label_id = label["id"].as_str().unwrap().to_string(); - let evs = drain(&mut dev, 5).await; - assert!(types(&evs).contains(&"kanban:label-changed".to_string()), "got {evs:?}"); - assert!(!types(&evs).contains(&"kanban:board-updated".to_string()), "label edits must NOT masquerade as board renames"); - assert_eq!(evs[0]["boardId"], board_id); - - // patch + delete also emit label-changed - let _ = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/labels/{label_id}"), - Some(&token), - Some(json!({ "name": "defect" })), - ) - .await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:label-changed".to_string())); - - let _ = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/labels/{label_id}"), - Some(&token), - None, - ) - .await; - assert!(types(&drain(&mut dev, 5).await).contains(&"kanban:label-changed".to_string())); -} - -#[tokio::test] -async fn ws_board_delete_propagates() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, _cols) = make_board(&app, &token, &ws_id, "B").await; - - let (_s, mut dev) = hub.subscribe_for_test(&ws_id).await; - - let (status, _b) = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT); - let evs = drain(&mut dev, 5).await; - assert!(types(&evs).contains(&"kanban:board-deleted".to_string()), "got {evs:?}"); - assert_eq!(evs[0]["boardId"], board_id); -} - -#[tokio::test] -async fn ws_originating_session_excluded_but_peer_receives() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - // Device A is the originator (its session id is echoed via X-Session-Id), - // device B is a passive peer. - let (sess_a, mut dev_a) = hub.subscribe_for_test(&ws_id).await; - let (_sess_b, mut dev_b) = hub.subscribe_for_test(&ws_id).await; - - let (status, _b) = common::req_with_session( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "Solo" })), - Some(&sess_a), - ) - .await; - assert_eq!(status, StatusCode::OK); - - // A excluded, B receives. - assert!(drain(&mut dev_a, 5).await.is_empty(), "originating session must be excluded"); - assert!(!drain(&mut dev_b, 5).await.is_empty(), "peer device must receive the event"); -} - -// ── 2. Deterministic restore (multi-archive) ───────────────────────────────── - -#[tokio::test] -async fn restore_after_archive_restore_archive_uses_latest_trash_row() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - let card_id = make_card(&app, &token, &ws_id, &board_id, &cols[0], "loop").await; - - let archive = |c: String| { - let app = app.clone(); - let token = token.clone(); - let ws_id = ws_id.clone(); - async move { - common::req(app, "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{c}/archive"), Some(&token), None).await - } - }; - let restore = |c: String| { - let app = app.clone(); - let token = token.clone(); - let ws_id = ws_id.clone(); - async move { - common::req(app, "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{c}/restore"), Some(&token), None).await - } - }; - - // Cycle 1: archive then restore (leaves a restored trash row behind). - assert_eq!(archive(card_id.clone()).await.0, StatusCode::OK); - assert_eq!(restore(card_id.clone()).await.0, StatusCode::OK); - - // Move the card to a DIFFERENT column so the second archival captures col[1]. - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_id, "targetColumnId": cols[1], "targetPosition": 0 })), - ) - .await; - - // Cycle 2: archive (captures col[1]) then restore — must return to col[1], - // NOT the stale col[0] recorded in the first (already-restored) trash row. - assert_eq!(archive(card_id.clone()).await.0, StatusCode::OK); - let (status, body) = restore(card_id.clone()).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["columnId"], cols[1], "restore must use the most recent archival's column"); - assert!(body["archivedAt"].is_null()); -} - -#[tokio::test] -async fn restore_non_archived_card_is_404() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - let card_id = make_card(&app, &token, &ws_id, &board_id, &cols[0], "active").await; - - let (status, _b) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/restore"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn restore_appends_to_end_of_column() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - - let a = make_card(&app, &token, &ws_id, &board_id, &cols[0], "A").await; - let _b = make_card(&app, &token, &ws_id, &board_id, &cols[0], "B").await; - - // Archive A → column compacts to [B@0]. - let _ = common::req(app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{a}/archive"), Some(&token), None).await; - // Add C → [B@0, C@1]. - let c = make_card(&app, &token, &ws_id, &board_id, &cols[0], "C").await; - // Restore A → should append at the end (position 2), not collide. - let _ = common::req(app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{a}/restore"), Some(&token), None).await; - - let order = column_order(&app, &token, &ws_id, &board_id, &cols[0]).await; - assert_eq!(order.len(), 3); - assert_eq!(order[2], a, "restored card should be appended at the end: {order:?}"); - assert_eq!(order[1], c); -} - -// ── 3. Archive compaction ──────────────────────────────────────────────────── - -#[tokio::test] -async fn archive_compacts_source_column_positions() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - - let a = make_card(&app, &token, &ws_id, &board_id, &cols[0], "A").await; - let b = make_card(&app, &token, &ws_id, &board_id, &cols[0], "B").await; - let c = make_card(&app, &token, &ws_id, &board_id, &cols[0], "C").await; - - // Archive the middle card. - let _ = common::req(app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{b}/archive"), Some(&token), None).await; - - let order = column_order(&app, &token, &ws_id, &board_id, &cols[0]).await; - assert_eq!(order, vec![a, c], "remaining cards compacted with no gap"); -} - -// ── 4. Contract validation ─────────────────────────────────────────────────── - -#[tokio::test] -async fn duplicate_board_name_returns_400_not_500() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let _ = make_board(&app, &token, &ws_id, "Dup").await; - - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "Dup" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "got {body}"); -} - -#[tokio::test] -async fn duplicate_column_name_returns_400_not_500() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, _cols) = make_board(&app, &token, &ws_id, "B").await; - - // Default columns include "To do"; creating another "To do" collides. - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/columns"), - Some(&token), - Some(json!({ "name": "To do" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "got {body}"); -} - -#[tokio::test] -async fn assignee_must_be_workspace_member() { - let (app, pool) = common::setup().await; - let (owner_token, _o) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &owner_token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &owner_token, &ws_id, "B").await; - - // A user that exists but is NOT a member of this workspace. - let (_outsider_token, outsider_name) = common::register_user(app.clone(), &common::uid()).await; - let outsider_id: String = sqlx::query_scalar("SELECT id FROM users WHERE username = ?") - .bind(&outsider_name) - .fetch_one(&pool) - .await - .unwrap(); - - // Assigning to the outsider is rejected with 400 (not an opaque FK 500). - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&owner_token), - Some(json!({ "columnId": cols[0], "title": "x", "assigneeUserId": outsider_id })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "outsider assignee should be rejected: {body}"); - - // Add an editor member, then assigning to them succeeds. - let (_e_token, editor_name) = common::register_user(app.clone(), &common::uid()).await; - let editor_id = add_member(&pool, &ws_id, &editor_name, "editor").await; - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&owner_token), - Some(json!({ "columnId": cols[0], "title": "y", "assigneeUserId": editor_id })), - ) - .await; - assert_eq!(status, StatusCode::OK, "member assignee should be accepted: {body}"); - assert_eq!(body["assigneeUserId"], editor_id); -} - -#[tokio::test] -async fn due_at_accepts_iso8601_and_rejects_garbage() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - - // ISO-8601 with T/Z is normalized and stored. - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": cols[0], "title": "due", "dueAt": "2026-12-31T23:59:59Z" })), - ) - .await; - assert_eq!(status, StatusCode::OK, "ISO-8601 dueAt should be accepted: {body}"); - assert_eq!(body["dueAt"], "2026-12-31 23:59:59"); - - // Garbage is a clean 400, not a DB 500. - let (status, _b) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": cols[0], "title": "bad", "dueAt": "next tuesday" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -// ── 5. Archive/trash list endpoint ─────────────────────────────────────────── - -#[tokio::test] -async fn trash_list_shows_archived_cards_with_metadata() { - let (app, _pool) = common::setup().await; - let (token, uname) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - let _ = uname; - - let c1 = make_card(&app, &token, &ws_id, &board_id, &cols[0], "one").await; - let c2 = make_card(&app, &token, &ws_id, &board_id, &cols[0], "two").await; - for c in [&c1, &c2] { - let _ = common::req(app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{c}/archive"), Some(&token), None).await; - } - - let (status, trash) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/trash"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::OK); - let arr = trash.as_array().unwrap(); - assert_eq!(arr.len(), 2, "both archived cards listed: {trash}"); - // Audit metadata is present. - for item in arr { - assert!(item["archivedByUserId"].is_string()); - assert!(item["expiresAt"].is_string()); - assert!(item["archivedAt"].is_string()); - assert!(item["restoredAt"].is_null()); - } - - // Restoring one removes it from the (un-restored) trash list. - let _ = common::req(app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{c1}/restore"), Some(&token), None).await; - let (_s, trash2) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/trash"), - Some(&token), - None, - ) - .await; - assert_eq!(trash2.as_array().unwrap().len(), 1); - assert_eq!(trash2[0]["cardId"], c2); -} - -// ── 5b. Client-supplied ids (local↔cloud convergence, design §11.11) ───────── - -#[tokio::test] -async fn create_honors_client_supplied_ids() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - // Board + its three seeded columns with client-generated ids. - let bid = uuid::Uuid::new_v4().to_string(); - let cols = [ - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - uuid::Uuid::new_v4().to_string(), - ]; - let (status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "id": bid, "name": "Local Board", "columnIds": cols })), - ) - .await; - assert_eq!(status, StatusCode::OK, "{board}"); - assert_eq!(board["id"], bid, "board keeps client id"); - let got_cols: Vec<&str> = board["columns"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect(); - for c in &cols { - assert!(got_cols.contains(&c.as_str()), "seeded column keeps client id {c}"); - } - - // Card with a client id into the first client-id column. - let cid = uuid::Uuid::new_v4().to_string(); - let (status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{bid}/cards"), - Some(&token), - Some(json!({ "id": cid, "columnId": cols[0], "title": "from desktop" })), - ) - .await; - assert_eq!(status, StatusCode::OK, "{card}"); - assert_eq!(card["id"], cid, "card keeps client id"); - - // Malformed id is rejected with 400 (not a junk PK). - let (status, _b) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "id": "not-a-uuid", "name": "Bad" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); - - // Wrong column-id count is rejected. - let (status, _b) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "TwoCols", "columnIds": [uuid::Uuid::new_v4().to_string(), uuid::Uuid::new_v4().to_string()] })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -// ── 6. Optimistic-lock force override ──────────────────────────────────────── - -#[tokio::test] -async fn patch_with_force_overrides_stale_clock() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - - let (_s, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": cols[0], "title": "v" })), - ) - .await; - let card_id = card["id"].as_str().unwrap().to_string(); - let base = card["updatedClock"].as_i64().unwrap(); - - // Advance the clock once. - let _ = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "v2", "baseUpdatedClock": base })), - ) - .await; - - // Stale write WITHOUT force → 409. - let (status, _b) = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "v3", "baseUpdatedClock": base })), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT); - - // Same stale base WITH force → 200, write applied. - let (status, body) = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "forced", "baseUpdatedClock": base, "force": true })), - ) - .await; - assert_eq!(status, StatusCode::OK, "force should override conflict: {body}"); - assert_eq!(body["title"], "forced"); -} - -// ── 7. N+1 batch label loading correctness ─────────────────────────────────── - -#[tokio::test] -async fn get_board_returns_correct_labels_per_card() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (board_id, cols) = make_board(&app, &token, &ws_id, "B").await; - - let mk_label = |name: &'static str, color: &'static str| { - let app = app.clone(); - let token = token.clone(); - let ws_id = ws_id.clone(); - let board_id = board_id.clone(); - async move { - let (_s, l) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": name, "color": color })), - ) - .await; - l["id"].as_str().unwrap().to_string() - } - }; - let l1 = mk_label("a", "#111111").await; - let l2 = mk_label("b", "#222222").await; - let l3 = mk_label("c", "#333333").await; - - // card1 -> {l1,l2}, card2 -> {l3}, card3 -> {} - let (_s, c1) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": cols[0], "title": "c1", "labelIds": [l1, l2] })), - ) - .await; - let (_s, c2) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": cols[0], "title": "c2", "labelIds": [l3] })), - ) - .await; - let _ = make_card(&app, &token, &ws_id, &board_id, &cols[0], "c3").await; - let c1_id = c1["id"].as_str().unwrap(); - let c2_id = c2["id"].as_str().unwrap(); - - let (status, board) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::OK); - let cards = board["cards"].as_array().unwrap(); - let labels_of = |id: &str| -> Vec { - let card = cards.iter().find(|c| c["id"].as_str() == Some(id)).unwrap(); - let mut v: Vec = card["labelIds"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect(); - v.sort(); - v - }; - assert_eq!(labels_of(c1_id).len(), 2, "card1 keeps both labels (batch load correct)"); - assert_eq!(labels_of(c2_id).len(), 1, "card2 keeps its single label"); -} diff --git a/services/jtype-web/tests/kanban_tests.rs b/services/jtype-web/tests/kanban_tests.rs deleted file mode 100644 index 5510b71..0000000 --- a/services/jtype-web/tests/kanban_tests.rs +++ /dev/null @@ -1,1865 +0,0 @@ -//! Kanban integration tests — covers all 11 categories from the design doc. -//! -//! Run with: -//! cargo test --manifest-path services/jtype-web/Cargo.toml --test kanban_tests -//! -//! Requires a running MySQL with `TEST_DATABASE_URL` set (see tests/common/mod.rs). -//! Each `#[tokio::test]` calls `common::setup().await`. - -mod common; -use axum::http::StatusCode; -use serde_json::{json, Value}; - -// ── 1. Board CRUD ──────────────────────────────────────────────────────────── - -#[tokio::test] -async fn create_board_seeds_three_default_columns() { - let (app, _pool) = common::setup().await; - let (token, _user) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "Sprint 1" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["name"], "Sprint 1"); - assert_eq!(body["columns"].as_array().unwrap().len(), 3); - let col_names: Vec<&str> = body["columns"] - .as_array() - .unwrap() - .iter() - .map(|c| c["name"].as_str().unwrap()) - .collect(); - assert!(col_names.contains(&"To do")); - assert!(col_names.contains(&"Doing")); - assert!(col_names.contains(&"Done")); -} - -#[tokio::test] -async fn list_boards_excludes_none() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - // Empty workspace - let (status, body) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body.as_array().unwrap().len(), 0); - - // After 2 creates - for name in ["A", "B"] { - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": name })), - ) - .await; - } - let (_status, body) = common::req( - app, - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - None, - ) - .await; - assert_eq!(body.as_array().unwrap().len(), 2); -} - -#[tokio::test] -async fn patch_board_updates_name() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "Old" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - - let (status, body) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - Some(json!({ "name": "New" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["name"], "New"); -} - -#[tokio::test] -async fn delete_board_cascades_to_columns_and_cards_including_archived() { - let (app, _pool) = common::setup().await; - // The workspace creator has the implicit "owner" role (see - // require_workspace_role: COALESCE(m.role, 'owner')), which satisfies the - // admin+ gate on board delete — no separate promotion needed. - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "To Delete" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - // Create + archive a card - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "x" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - - // Delete the board - let (status, _body) = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT); - - // Verify the board is gone - let (status, _body) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); - - // Verify the archived card is also gone (cascade to trash) - // We don't have a way to assert directly without listing trash, so we - // assert the card endpoint is 404: - let (status, _body) = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "still here?" })), - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn reorder_boards_atomic() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let mut board_ids = Vec::new(); - for name in ["A", "B", "C"] { - let (_status, b) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": name })), - ) - .await; - board_ids.push(b["id"].as_str().unwrap().to_string()); - } - - // Reverse - let reversed: Vec = board_ids.iter().rev().cloned().collect(); - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/reorder"), - Some(&token), - Some(json!({ "boardIds": reversed })), - ) - .await; - assert_eq!(status, StatusCode::OK); - - let (_status, list) = common::req( - app, - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - None, - ) - .await; - let got: Vec = list - .as_array() - .unwrap() - .iter() - .map(|b| b["id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(got, reversed); -} - -// ── 2. Column CRUD ─────────────────────────────────────────────────────────── - -#[tokio::test] -async fn create_column_persists() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - - let (status, col) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/columns"), - Some(&token), - Some(json!({ "name": "Review", "wipLimit": 5, "color": "#10b981" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(col["name"], "Review"); - assert_eq!(col["wipLimit"], 5); - assert_eq!(col["color"], "#10b981"); -} - -#[tokio::test] -async fn patch_column_renames_and_repositions() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let _board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - let (status, col) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/columns/{col_id}"), - Some(&token), - Some(json!({ "name": "Renamed", "wipLimit": 10 })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(col["name"], "Renamed"); - assert_eq!(col["wipLimit"], 10); -} - -#[tokio::test] -async fn reorder_columns_atomic() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let mut col_ids: Vec = board["columns"] - .as_array() - .unwrap() - .iter() - .map(|c| c["id"].as_str().unwrap().to_string()) - .collect(); - col_ids.reverse(); - - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/columns/reorder"), - Some(&token), - Some(json!({ "boardId": board_id, "columnIds": col_ids })), - ) - .await; - assert_eq!(status, StatusCode::OK); - - let (_status, board2) = common::req( - app, - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - None, - ) - .await; - let got: Vec = board2["columns"] - .as_array() - .unwrap() - .iter() - .map(|c| c["id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(got, col_ids); -} - -#[tokio::test] -async fn delete_column_merges_cards_into_fallback() { - // Deleting a column moves its cards into the first remaining column (by - // position) rather than cascade-deleting them. - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap().to_string(); - let col0 = board["columns"][0]["id"].as_str().unwrap().to_string(); - let col1 = board["columns"][1]["id"].as_str().unwrap().to_string(); - - // Two cards in the column we will delete. - for title in ["C1", "C2"] { - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col0, "title": title })), - ) - .await; - } - - let (status, _body) = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/columns/{col0}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT); - - let (_status, board2) = common::req( - app, - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}"), - Some(&token), - None, - ) - .await; - // Column is gone; the board now has 2 columns. - let cols = board2["columns"].as_array().unwrap(); - assert_eq!(cols.len(), 2); - assert!(cols.iter().all(|c| c["id"].as_str().unwrap() != col0)); - // Both cards survived and moved to the fallback (first remaining column). - let cards = board2["cards"].as_array().unwrap(); - assert_eq!(cards.len(), 2); - assert!(cards.iter().all(|c| c["columnId"].as_str().unwrap() == col1)); -} - -#[tokio::test] -async fn delete_last_column_is_refused() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let cols: Vec = board["columns"] - .as_array() - .unwrap() - .iter() - .map(|c| c["id"].as_str().unwrap().to_string()) - .collect(); - - // Delete the first two — both succeed. - for col in &cols[..2] { - let (status, _b) = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/columns/{col}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT); - } - // The last column cannot be deleted. - let (status, _b) = common::req( - app, - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/columns/{}", cols[2]), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -// ── 3. Card CRUD + archive ────────────────────────────────────────────────── - -#[tokio::test] -async fn create_card_persists_to_specified_column() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - let (status, card) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "Hello" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(card["title"], "Hello"); - assert_eq!(card["columnId"], col_id); - assert_eq!(card["priority"], "none"); -} - -#[tokio::test] -async fn patch_card_updates_fields() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "Old" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (status, body) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "New", "priority": "high" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["title"], "New"); - assert_eq!(body["priority"], "high"); -} - -#[tokio::test] -async fn move_card_between_columns() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let cols = board["columns"].as_array().unwrap(); - let col_a = cols[0]["id"].as_str().unwrap(); - let col_b = cols[1]["id"].as_str().unwrap(); - - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_a, "title": "Mover" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (status, body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_id, "targetColumnId": col_b, "targetPosition": 0 })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["columnId"], col_b); - assert_eq!(body["position"], 0); -} - -#[tokio::test] -async fn move_card_within_column() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - // Create 3 cards in same column - let mut card_ids = Vec::new(); - for i in 0..3 { - let (_status, c) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": format!("c{}", i) })), - ) - .await; - card_ids.push(c["id"].as_str().unwrap().to_string()); - } - - // Move first card to position 2 (last) - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_ids[0], "targetColumnId": col_id, "targetPosition": 2 })), - ) - .await; - assert_eq!(status, StatusCode::OK); - - // List cards in column — should be in new order - let (_status, cards) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - None, - ) - .await; - let got: Vec = cards - .as_array() - .unwrap() - .iter() - .map(|c| c["id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(got[0], card_ids[1]); - assert_eq!(got[1], card_ids[2]); - assert_eq!(got[2], card_ids[0]); -} - -#[tokio::test] -async fn archive_card_soft_deletes() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "To archive" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::OK); - - // Default list excludes archived - let (_status, list) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - None, - ) - .await; - assert_eq!(list.as_array().unwrap().len(), 0); -} - -#[tokio::test] -async fn restore_card_returns_to_original_column_with_labels() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - // Create label - let (_status, label) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "bug", "color": "#ef4444" })), - ) - .await; - let label_id = label["id"].as_str().unwrap(); - - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "Tagged", "labelIds": [label_id] })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - // Archive - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - - // Restore - let (status, body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/restore"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["columnId"], col_id); - assert!(body["archivedAt"].is_null() || body["archivedAt"] == Value::Null); - let label_ids: Vec<&str> = body["labelIds"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!(label_ids.contains(&label_id)); -} - -#[tokio::test] -async fn hard_delete_card_removes_physically() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "Bye" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (status, _body) = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT); - - // Patch should 404 - let (status, _body) = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "still here?" })), - ) - .await; - assert_eq!(status, StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn list_cards_excludes_archived_by_default() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - for title in ["a", "b", "c"] { - let (_status, c) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": title })), - ) - .await; - if title == "b" { - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{}/archive", c["id"].as_str().unwrap()), - Some(&token), - None, - ) - .await; - } - } - - let (_status, list) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - None, - ) - .await; - assert_eq!(list.as_array().unwrap().len(), 2); - - let (_status, list) = common::req( - app, - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards?includeArchived=true"), - Some(&token), - None, - ) - .await; - assert_eq!(list.as_array().unwrap().len(), 3); -} - -// ── 4. Label CRUD ─────────────────────────────────────────────────────────── - -#[tokio::test] -async fn create_label_with_valid_hex() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - - let (status, label) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "feat", "color": "#10b981" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(label["color"], "#10b981"); -} - -#[tokio::test] -async fn create_label_rejects_invalid_hex() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - - for bad in ["10b981", "#abc", "#xyz123", "red"] { - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "L", "color": bad })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST, "expected 400 for color={}", bad); - } -} - -#[tokio::test] -async fn create_label_enforces_50_per_board() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - - for i in 0..50 { - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": format!("L{}", i), "color": "#10b981" })), - ) - .await; - assert_eq!(status, StatusCode::OK, "label #{} should succeed", i); - } - // 51st should fail - let (status, _body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "L50", "color": "#10b981" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn create_label_rejects_duplicate_name() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "dup", "color": "#10b981" })), - ) - .await; - let (status, _body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "dup", "color": "#ef4444" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn patch_label_updates_fields() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let (_status, label) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "old", "color": "#10b981" })), - ) - .await; - let label_id = label["id"].as_str().unwrap(); - - let (status, body) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/labels/{label_id}"), - Some(&token), - Some(json!({ "name": "new", "color": "#ef4444" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["name"], "new"); - assert_eq!(body["color"], "#ef4444"); -} - -#[tokio::test] -async fn delete_label_cascades_to_card_labels() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, label) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/labels"), - Some(&token), - Some(json!({ "name": "tag", "color": "#10b981" })), - ) - .await; - let label_id = label["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "x", "labelIds": [label_id] })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (status, _body) = common::req( - app.clone(), - "DELETE", - &format!("/api/v1/workspaces/{ws_id}/kanban/labels/{label_id}"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::NO_CONTENT); - - // Card should still exist but with no labels - let (_status, body) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "still" })), - ) - .await; - assert_eq!(body["labelIds"].as_array().unwrap().len(), 0); -} - -// ── 5. Priority 枚举 ─────────────────────────────────────────────────────── - -#[tokio::test] -async fn priority_accepts_all_five_values() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - for p in ["none", "low", "medium", "high", "urgent"] { - let (status, body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": p, "priority": p })), - ) - .await; - assert_eq!(status, StatusCode::OK, "priority={} should succeed", p); - assert_eq!(body["priority"], p); - } -} - -#[tokio::test] -async fn priority_rejects_unknown_value() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - let (status, _body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "x", "priority": "banana" })), - ) - .await; - assert_eq!(status, StatusCode::BAD_REQUEST); -} - -// ── 6. 30 天自动清理 ──────────────────────────────────────────────────────── - -#[tokio::test] -async fn cleanup_removes_expired_kanban_trash() { - use jtype_web::tasks::cleanup_trash; - let (app, pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "to be purged" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - - // Force-expire the row - sqlx::query("UPDATE kanban_card_trash SET expires_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE card_id = ?") - .bind(card_id) - .execute(&pool) - .await - .unwrap(); - - let (_kanban, _docs) = cleanup_trash::run_once(&pool).await.unwrap(); - - // The card should be hard-gone (trash row was deleted; the kanban_cards - // row was already marked archived_at, so the card itself is also gone - // because kanban_card_trash CASCADE-fk-deletes it... no wait, we keep - // the kanban_cards row even when archived. So the card is still findable - // but PATCH should still work. The trash row, however, should be gone.) - let trash_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM kanban_card_trash WHERE card_id = ?") - .bind(card_id) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(trash_count, 0, "expired trash row should be purged"); - let _ = app; // silence unused -} - -#[tokio::test] -async fn cleanup_preserves_non_expired_trash() { - use jtype_web::tasks::cleanup_trash; - let (app, pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "alive" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - - let _ = cleanup_trash::run_once(&pool).await.unwrap(); - - let trash_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM kanban_card_trash WHERE card_id = ?") - .bind(card_id) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(trash_count, 1, "non-expired trash row should be kept"); - let _ = app; -} - -#[tokio::test] -async fn cleanup_runs_against_both_document_and_kanban_trash() { - use jtype_web::tasks::cleanup_trash; - let (app, pool) = common::setup().await; - // Insert a fake expired document_trash row - let user_id = common::uid(); - let (token, _uname) = common::register_user(app.clone(), &user_id).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let _ = sqlx::query( - r#"INSERT INTO document_trash (id, workspace_id, document_id, relative_path, title, content, - content_hash, version_id, deleted_by_user_id, expires_at) - VALUES (?, ?, ?, 'x.md', 'x', '', 'h', ?, ?, - DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY))"#, - ) - .bind(uuid::Uuid::new_v4().to_string()) - .bind(&ws_id) - .bind(uuid::Uuid::new_v4().to_string()) - .bind(uuid::Uuid::new_v4().to_string()) - .bind(&user_id) - .execute(&pool) - .await - .unwrap(); - - let (docs, _kanban) = cleanup_trash::run_once(&pool).await.unwrap(); - assert!(docs >= 1, "expired document_trash row should be purged"); - let _ = app; -} - -// ── 7. 权限 ────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn viewer_cannot_create_board() { - let (app, pool) = common::setup().await; - // Owner creates workspace - let (owner_token, _owner_name) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &owner_token, &common::wname()).await; - // Register viewer and add as member - let (viewer_token, viewer_name) = common::register_user(app.clone(), &common::uid()).await; - let viewer_id: String = sqlx::query_scalar("SELECT id FROM users WHERE username = ?") - .bind(&viewer_name) - .fetch_one(&pool) - .await - .unwrap(); - sqlx::query("INSERT INTO workspace_members (workspace_id, user_id, role, status, joined_at) VALUES (?, ?, 'viewer', 'active', CURRENT_TIMESTAMP)") - .bind(&ws_id) - .bind(&viewer_id) - .execute(&pool) - .await - .unwrap(); - - let (status, _body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&viewer_token), - Some(json!({ "name": "X" })), - ) - .await; - assert_eq!(status, StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn priority_default_is_none() { - // Smoke test: when priority is omitted, it defaults to "none" - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - - let (status, body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "x" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["priority"], "none"); -} - -// ── 10. Archive 列表默认行为 ──────────────────────────────────────────────── -// Covered by list_cards_excludes_archived_by_default - -// ── 11. 跨列移动 + position 重排 ──────────────────────────────────────────── -// Covered by move_card_between_columns and move_card_within_column - -// ── 8. WS 事件投递 ─────────────────────────────────────────────────────────── - -#[tokio::test] -async fn ws_broadcasts_board_updated_on_create() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (_session_id, mut rx) = hub.subscribe_for_test(&ws_id).await; - - let (status, _body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - assert_eq!(status, StatusCode::OK); - - // Receive events - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for board-updated event") - .expect("hub channel closed"); - let json: serde_json::Value = serde_json::to_value(&event).unwrap(); - assert_eq!(json["type"], "kanban:board-updated"); - assert!(json["boardId"].is_string()); -} - -#[tokio::test] -async fn ws_broadcasts_card_archived_on_archive() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "x" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (_session_id, mut rx) = hub.subscribe_for_test(&ws_id).await; - - let _ = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout") - .expect("closed"); - let json: serde_json::Value = serde_json::to_value(&event).unwrap(); - assert_eq!(json["type"], "kanban:card-archived"); - assert_eq!(json["cardId"], card_id); -} - -#[tokio::test] -async fn ws_broadcasts_card_moved_on_move() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_a = board["columns"][0]["id"].as_str().unwrap(); - let col_b = board["columns"][1]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_a, "title": "M" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - - let (_session_id, mut rx) = hub.subscribe_for_test(&ws_id).await; - - let _ = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_id, "targetColumnId": col_b, "targetPosition": 0 })), - ) - .await; - - let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout") - .expect("closed"); - let json: serde_json::Value = serde_json::to_value(&event).unwrap(); - assert_eq!(json["type"], "kanban:card-updated"); - assert_eq!(json["cardId"], card_id); - assert_eq!(json["columnId"], col_b); -} - -#[tokio::test] -async fn ws_excludes_source_session() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (session_id, mut rx) = hub.subscribe_for_test(&ws_id).await; - - // Simulate a request that includes X-Session-Id matching the subscriber - let (status, _body) = common::req_with_session( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "X" })), - Some(&session_id), - ) - .await; - assert_eq!(status, StatusCode::OK); - - // Subscriber should NOT receive the event (sender is excluded) - let got = tokio::time::timeout( - std::time::Duration::from_millis(500), - rx.recv(), - ) - .await; - assert!(got.is_err(), "source session should be excluded; got event"); -} - -#[tokio::test] -async fn ws_delivers_to_multiple_subscribers() { - let (app, _pool, hub) = common::setup_with_hub().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (_s1, mut rx1) = hub.subscribe_for_test(&ws_id).await; - let (_s2, mut rx2) = hub.subscribe_for_test(&ws_id).await; - - let _ = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "X" })), - ) - .await; - - let e1 = tokio::time::timeout(std::time::Duration::from_secs(2), rx1.recv()) - .await - .expect("rx1 timeout") - .expect("rx1 closed"); - let e2 = tokio::time::timeout(std::time::Duration::from_secs(2), rx2.recv()) - .await - .expect("rx2 timeout") - .expect("rx2 closed"); - let j1: serde_json::Value = serde_json::to_value(&e1).unwrap(); - let j2: serde_json::Value = serde_json::to_value(&e2).unwrap(); - assert_eq!(j1["type"], "kanban:board-updated"); - assert_eq!(j2["type"], "kanban:board-updated"); -} - -// ── 9. 409 冲突 ────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn concurrent_card_patch_returns_409_for_stale_writer() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "Original" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - let base_clock = card["updatedClock"].as_i64().unwrap(); - - // First writer updates - let (status, _body) = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "By A", "baseUpdatedClock": base_clock })), - ) - .await; - assert_eq!(status, StatusCode::OK); - - // Stale writer (B) tries with old base_clock - let (status, body) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "By B", "baseUpdatedClock": base_clock })), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT); - assert_eq!(body["error"], "conflict"); - assert_eq!(body["cardId"], card_id); - assert!(body["latest"].is_object()); -} - -#[tokio::test] -async fn concurrent_card_move_returns_409_for_stale_writer() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let cols = board["columns"].as_array().unwrap(); - let col_a = cols[0]["id"].as_str().unwrap(); - let col_b = cols[1]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_a, "title": "M" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - let base_clock = card["updatedClock"].as_i64().unwrap(); - - // First move (consumes clock) - let (status, _body) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_id, "targetColumnId": col_b, "targetPosition": 0, "baseUpdatedClock": base_clock })), - ) - .await; - assert_eq!(status, StatusCode::OK); - - // Stale move - let (status, body) = common::req( - app, - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards/move"), - Some(&token), - Some(json!({ "cardId": card_id, "targetColumnId": col_a, "targetPosition": 0, "baseUpdatedClock": base_clock })), - ) - .await; - assert_eq!(status, StatusCode::CONFLICT); - assert_eq!(body["error"], "conflict"); -} - -#[tokio::test] -async fn conflict_response_includes_latest_card_snapshot() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap(); - let col_id = board["columns"][0]["id"].as_str().unwrap(); - let (_status, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col_id, "title": "snap" })), - ) - .await; - let card_id = card["id"].as_str().unwrap(); - let base_clock = card["updatedClock"].as_i64().unwrap(); - - // First writer updates title - let _ = common::req( - app.clone(), - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "Latest", "baseUpdatedClock": base_clock })), - ) - .await; - - // Stale writer receives snapshot - let (_status, body) = common::req( - app, - "PATCH", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}"), - Some(&token), - Some(json!({ "title": "Stale", "baseUpdatedClock": base_clock })), - ) - .await; - assert_eq!(body["latest"]["title"], "Latest"); - assert!(body["latest"]["updatedClock"].as_i64().unwrap() > base_clock); - assert_eq!(body["baseUpdatedClock"], base_clock); -} - -#[tokio::test] -async fn webhook_crud_and_enqueue() { - let (app, pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_s, board) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), Some(json!({ "name": "B" })), - ).await; - let board_id = board["id"].as_str().unwrap().to_string(); - let col = board["columns"][0]["id"].as_str().unwrap().to_string(); - - // create a webhook subscribed to card-updated - let (status, wh) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/webhooks"), - Some(&token), - Some(json!({ "name": "CI", "targetUrl": "https://example.com/hook", "eventTypes": ["kanban:card-updated"] })), - ).await; - assert_eq!(status, StatusCode::OK); - assert!(wh["secret"].is_string(), "plaintext secret returned once"); - assert_eq!(wh["secretMasked"], "whsec_••••"); - let wh_id = wh["id"].as_str().unwrap().to_string(); - - // reject non-https and SSRF targets (incl. userinfo / private-IP / IPv6 bypasses) - for bad_url in [ - "http://localhost/hook", - "https://user@127.0.0.1/hook", - "https://10.0.0.5/hook", - "https://192.168.1.10/hook", - "https://169.254.169.254/latest", - "https://[::1]/hook", - "https://foo.internal/hook", - ] { - let (bad, _b) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/webhooks"), - Some(&token), Some(json!({ "name": "x", "targetUrl": bad_url, "eventTypes": ["*"] })), - ).await; - assert_eq!(bad, StatusCode::BAD_REQUEST, "should reject {bad_url}"); - } - - // list → 1 - let (_s, list) = common::req( - app.clone(), "GET", &format!("/api/v1/workspaces/{ws_id}/kanban/webhooks"), Some(&token), None, - ).await; - assert_eq!(list.as_array().unwrap().len(), 1); - - // creating a card enqueues a delivery (synchronously, before the worker runs) - let _ = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), Some(json!({ "columnId": col, "title": "X" })), - ).await; - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM kanban_webhook_deliveries WHERE webhook_id = ?") - .bind(&wh_id).fetch_one(&pool).await.unwrap(); - assert!(count >= 1, "card create enqueued a webhook delivery"); - - // delete webhook → 204 (cascade removes deliveries) - let (status, _) = common::req( - app, "DELETE", &format!("/api/v1/workspaces/{ws_id}/kanban/webhooks/{wh_id}"), Some(&token), None, - ).await; - assert_eq!(status, StatusCode::NO_CONTENT); -} - -#[tokio::test] -async fn card_comments_crud() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_s, board) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), Some(json!({ "name": "B" })), - ).await; - let board_id = board["id"].as_str().unwrap().to_string(); - let col = board["columns"][0]["id"].as_str().unwrap().to_string(); - let (_s, card) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), Some(json!({ "columnId": col, "title": "X" })), - ).await; - let card_id = card["id"].as_str().unwrap().to_string(); - - // create - let (status, c) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/comments"), - Some(&token), Some(json!({ "body": "Looks good" })), - ).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(c["body"], "Looks good"); - assert!(c["author"].is_string()); - let comment_id = c["id"].as_str().unwrap().to_string(); - - // list → 1 - let (_s, list) = common::req( - app.clone(), "GET", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/comments"), - Some(&token), None, - ).await; - assert_eq!(list.as_array().unwrap().len(), 1); - - // delete (author) → 204, then list → 0 - let (status, _) = common::req( - app.clone(), "DELETE", &format!("/api/v1/workspaces/{ws_id}/kanban/comments/{comment_id}"), - Some(&token), None, - ).await; - assert_eq!(status, StatusCode::NO_CONTENT); - let (_s, list2) = common::req( - app, "GET", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/comments"), - Some(&token), None, - ).await; - assert_eq!(list2.as_array().unwrap().len(), 0); -} - -#[tokio::test] -async fn card_activity_reports_created_and_archived() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_s, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap().to_string(); - let col = board["columns"][0]["id"].as_str().unwrap().to_string(); - let (_s, card) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), - Some(json!({ "columnId": col, "title": "X" })), - ) - .await; - let card_id = card["id"].as_str().unwrap().to_string(); - - // After create: a "created" event carrying the creator's username. - let (status, acts) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/activity"), - Some(&token), - None, - ) - .await; - assert_eq!(status, StatusCode::OK); - let created = acts.as_array().unwrap().iter().find(|e| e["kind"] == "created").expect("created event"); - assert!(created["by"].is_string()); - - // After archive: an "archived" event appears. - let _ = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), - None, - ) - .await; - let (_s, acts2) = common::req( - app, - "GET", - &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/activity"), - Some(&token), - None, - ) - .await; - assert!(acts2.as_array().unwrap().iter().any(|e| e["kind"] == "archived")); -} - -#[tokio::test] -async fn delete_column_keeps_archived_cards_restorable() { - let (app, _pool) = common::setup().await; - let (token, _) = common::register_user(app.clone(), &common::uid()).await; - let ws_id = common::create_workspace(app.clone(), &token, &common::wname()).await; - let (_s, board) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards"), - Some(&token), Some(json!({ "name": "B" })), - ).await; - let board_id = board["id"].as_str().unwrap().to_string(); - let col0 = board["columns"][0]["id"].as_str().unwrap().to_string(); - let col1 = board["columns"][1]["id"].as_str().unwrap().to_string(); - - // card in col0, then archive it - let (_s, card) = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/boards/{board_id}/cards"), - Some(&token), Some(json!({ "columnId": col0, "title": "X" })), - ).await; - let card_id = card["id"].as_str().unwrap().to_string(); - let _ = common::req( - app.clone(), "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/archive"), - Some(&token), None, - ).await; - - // delete the archived card's column - let (status, _) = common::req( - app.clone(), "DELETE", &format!("/api/v1/workspaces/{ws_id}/kanban/columns/{col0}"), - Some(&token), None, - ).await; - assert_eq!(status, StatusCode::NO_CONTENT); - - // the archived card must still be restorable — into the fallback column - let (status, restored) = common::req( - app, "POST", &format!("/api/v1/workspaces/{ws_id}/kanban/cards/{card_id}/restore"), - Some(&token), None, - ).await; - assert_eq!(status, StatusCode::OK, "archived card should still restore after column delete"); - assert_eq!(restored["columnId"].as_str().unwrap(), col1, "restored into the fallback column"); -} diff --git a/services/jtype-web/tests/mcp_tests.rs b/services/jtype-web/tests/mcp_tests.rs index b9d5fa5..dba29b6 100644 --- a/services/jtype-web/tests/mcp_tests.rs +++ b/services/jtype-web/tests/mcp_tests.rs @@ -116,13 +116,75 @@ async fn mcp_tools_list_has_all_tools() { .collect(); for expected in [ "list_workspaces", "list_notes", "get_note", "search_notes", "create_note", - "update_note", "append_note", "list_boards", "get_board", "list_cards", - "create_card", "update_card", "move_card", "list_members", + "update_note", "append_note", "list_members", ] { assert!(names.contains(&expected), "missing tool {expected}; got {names:?}"); } } +// ── Kanban catalog (separate `/mcp/kanban` server) ────────────────────────── + +/// Send a JSON-RPC message to `/mcp/kanban` with a bearer token; return (status, json). +async fn mcp_kanban(app: Router, token: Option<&str>, body: Value) -> (StatusCode, Value) { + common::req(app, "POST", "/mcp/kanban", token, Some(body)).await +} + +/// The tool names from a `tools/list` response body. +fn tool_names(body: &Value) -> Vec<&str> { + body["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect() +} + +/// Board/card tools — exposed by `/mcp/kanban`, must be absent from `/mcp`. +const KANBAN_TOOLS: [&str; 6] = + ["list_boards", "get_board", "list_cards", "create_card", "move_card", "update_card"]; + +#[tokio::test] +async fn mcp_notes_catalog_omits_kanban_tools() { + let (app, _pool) = common::setup().await; + let username = common::uid(); + let (token, _) = common::register_user(app.clone(), &username).await; + + let (status, body) = mcp( + app, + Some(&token), + json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + let names = tool_names(&body); + for kanban in KANBAN_TOOLS { + assert!(!names.contains(&kanban), "/mcp must not expose kanban tool {kanban}; got {names:?}"); + } +} + +#[tokio::test] +async fn mcp_kanban_catalog_has_board_card_tools() { + let (app, _pool) = common::setup().await; + let username = common::uid(); + let (token, _) = common::register_user(app.clone(), &username).await; + + let (status, body) = mcp_kanban( + app, + Some(&token), + json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + let names = tool_names(&body); + for expected in KANBAN_TOOLS { + assert!(names.contains(&expected), "missing kanban tool {expected}; got {names:?}"); + } + // The kanban server must not leak the note-editing tools. + for note in ["create_note", "search_notes", "append_note", "get_note"] { + assert!(!names.contains(¬e), "/mcp/kanban leaked note tool {note}; got {names:?}"); + } +} + // ── Notes happy path ──────────────────────────────────────────────────────── #[tokio::test] @@ -228,183 +290,6 @@ async fn mcp_notes_roundtrip() { assert!(tool_text(&r).contains("Rewritten") && !tool_text(&r).contains("pineapple")); } -// ── Kanban happy path ─────────────────────────────────────────────────────── - -#[tokio::test] -async fn mcp_kanban_roundtrip() { - let (app, _pool) = common::setup().await; - let username = common::uid(); - let (token, _) = common::register_user(app.clone(), &username).await; - let ws = common::create_workspace(app.clone(), &token, &common::wname()).await; - - // Seed a board via REST (auto-creates To do / Doing / Done columns). - let (status, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws}/kanban/boards"), - Some(&token), - Some(json!({ "name": "Roadmap" })), - ) - .await; - assert_eq!(status, StatusCode::OK, "create board: {board}"); - let board_id = board["id"].as_str().unwrap().to_string(); - let cols = board["columns"].as_array().unwrap(); - let todo_col = cols[0]["id"].as_str().unwrap().to_string(); - let doing_col = cols[1]["id"].as_str().unwrap().to_string(); - - // list_boards - let r = tool_call(app.clone(), &token, "list_boards", json!({ "workspace_id": ws })).await; - assert_ok(&r, "list_boards"); - assert!(tool_text(&r).contains("Roadmap")); - - // get_board - let r = tool_call( - app.clone(), - &token, - "get_board", - json!({ "workspace_id": ws, "board_id": board_id }), - ) - .await; - assert_ok(&r, "get_board"); - assert!(tool_text(&r).contains("Doing")); - - // list_members (resolve assignee) - let r = tool_call(app.clone(), &token, "list_members", json!({ "workspace_id": ws })).await; - assert_ok(&r, "list_members"); - assert!(tool_text(&r).contains(&username)); - - // create_card - let r = tool_call( - app.clone(), - &token, - "create_card", - json!({ "workspace_id": ws, "board_id": board_id, "column_id": todo_col, - "title": "Wire up OAuth", "priority": "high" }), - ) - .await; - assert_ok(&r, "create_card"); - let text = tool_text(&r); - // Extract the created card id from the embedded JSON. - let card_json: Value = serde_json::from_str(text.splitn(2, '\n').nth(1).unwrap_or("{}")) - .unwrap_or(json!({})); - let card_id = card_json["id"].as_str().expect("card id").to_string(); - - // list_cards (filtered to the To-do column) - let r = tool_call( - app.clone(), - &token, - "list_cards", - json!({ "workspace_id": ws, "board_id": board_id, "column_id": todo_col }), - ) - .await; - assert_ok(&r, "list_cards"); - assert!(tool_text(&r).contains("Wire up OAuth")); - - // update_card (change priority + assign) - let r = tool_call( - app.clone(), - &token, - "update_card", - json!({ "workspace_id": ws, "card_id": card_id, "priority": "urgent" }), - ) - .await; - assert_ok(&r, "update_card"); - assert!(tool_text(&r).contains("urgent")); - - // move_card to "Doing" (status change) - let r = tool_call( - app.clone(), - &token, - "move_card", - json!({ "workspace_id": ws, "board_id": board_id, "card_id": card_id, - "target_column_id": doing_col }), - ) - .await; - assert_ok(&r, "move_card"); - - // Confirm the move via list_cards on the Doing column. - let r = tool_call( - app, - &token, - "list_cards", - json!({ "workspace_id": ws, "board_id": board_id, "column_id": doing_col }), - ) - .await; - assert!(tool_text(&r).contains("Wire up OAuth"), "card not in Doing: {}", tool_text(&r)); -} - -// Regression: empty-string args on update_card must CLEAR (JSON null), not 400. -#[tokio::test] -async fn mcp_update_card_clears_assignee_and_due() { - let (app, _pool) = common::setup().await; - let username = common::uid(); - let (token, _) = common::register_user(app.clone(), &username).await; - let ws = common::create_workspace(app.clone(), &token, &common::wname()).await; - - let (_s, members) = common::req( - app.clone(), - "GET", - &format!("/api/v1/workspaces/{ws}/members"), - Some(&token), - None, - ) - .await; - let uid = members[0]["userId"].as_str().unwrap().to_string(); - - let (_s, board) = common::req( - app.clone(), - "POST", - &format!("/api/v1/workspaces/{ws}/kanban/boards"), - Some(&token), - Some(json!({ "name": "B" })), - ) - .await; - let board_id = board["id"].as_str().unwrap().to_string(); - let col = board["columns"][0]["id"].as_str().unwrap().to_string(); - - // Create a card WITH an assignee and a due date. - let r = tool_call( - app.clone(), - &token, - "create_card", - json!({ "workspace_id": ws, "board_id": board_id, "column_id": col, - "title": "Assigned", "assignee_user_id": uid, "due_at": "2026-12-31 09:00:00" }), - ) - .await; - assert_ok(&r, "create_card w/ assignee"); - let card: Value = serde_json::from_str(tool_text(&r).splitn(2, '\n').nth(1).unwrap_or("{}")) - .unwrap_or(json!({})); - let card_id = card["id"].as_str().unwrap().to_string(); - assert_eq!(card["assigneeUserId"].as_str(), Some(uid.as_str())); - - // Clear both via explicit empty strings (the documented unset path). - let r = tool_call( - app.clone(), - &token, - "update_card", - json!({ "workspace_id": ws, "card_id": card_id, "assignee_user_id": "", "due_at": "" }), - ) - .await; - assert_ok(&r, "update_card clear"); - - let r = tool_call( - app, - &token, - "list_cards", - json!({ "workspace_id": ws, "board_id": board_id }), - ) - .await; - let cards: Value = serde_json::from_str(&tool_text(&r)).unwrap(); - let c = cards - .as_array() - .unwrap() - .iter() - .find(|c| c["id"] == json!(card_id)) - .unwrap(); - assert!(c["assigneeUserId"].is_null(), "assignee not cleared: {c}"); - assert!(c["dueAt"].is_null(), "due not cleared: {c}"); -} - // Regression: JSON-RPC envelope — a batch of only notifications → 202 (no body); // an empty batch → a single Invalid Request (-32600). #[tokio::test] diff --git a/shared/components/board/BoardSurface.tsx b/shared/components/board/BoardSurface.tsx index 39e5e40..1a6ec25 100644 --- a/shared/components/board/BoardSurface.tsx +++ b/shared/components/board/BoardSurface.tsx @@ -717,6 +717,11 @@ export function BoardSurface({
+ {card.ticket && ( + + {card.ticket} + + )} {card.icon && {card.icon}} {card.title} diff --git a/shared/components/board/controls.tsx b/shared/components/board/controls.tsx index fc3ba31..18a7045 100644 --- a/shared/components/board/controls.tsx +++ b/shared/components/board/controls.tsx @@ -28,7 +28,10 @@ export function ListboxSelect({ options: BoardOption[]; onChange: (v: string) => void; }) { - const current = options.find((o) => o.value === value) ?? options[0]; + // No `?? options[0]` fallback: an unmatched non-empty value (e.g. an assignee + // who isn't in the current member roster) must render itself, not collapse to + // the first option — see `{current?.label ?? value}` below. + const current = options.find((o) => o.value === value); return ( diff --git a/shared/lib/board.ts b/shared/lib/board.ts index aa65c76..260cc04 100644 --- a/shared/lib/board.ts +++ b/shared/lib/board.ts @@ -33,6 +33,15 @@ export type BoardViewConfig = { calendarMode?: CalendarMode; /** User-defined custom fields shown/edited on cards (board-level schema). */ fields?: BoardFieldDef[]; + /** + * Board-level label definitions giving tags an explicit color. A card references + * a label by its `label` text in frontmatter `tags`; a tag with no matching + * definition (or a definition with no color) falls back to a deterministic + * auto-color, so tags are colored with zero config. + */ + labels?: BoardLabelDef[]; + /** Board ticket-id prefix (e.g. `OCCSV`) for per-card `OCCSV-3371` ticket links. */ + ticketKey?: string; /** * Second grouping dimension rendered as horizontal swimlanes (rows) in the * board view. Must differ from `groupBy`; unset = no swimlanes. @@ -42,9 +51,14 @@ export type BoardViewConfig = { export type BoardTag = { id?: string; label: string; color?: string | null }; +/** A board-level label definition: a tag's text + its display color. */ +export type BoardLabelDef = { label: string; color?: string | null }; + export type BoardViewCard = { /** Stable id — desktop: file path; web: card id. */ id: string; + /** Allocated ticket id (e.g. `OCCSV-3371`), cloud-indexed; shown as a card badge. */ + ticket?: string | null; /** The grouping value under the default (status) grouping. */ columnKey: string; position: number; @@ -203,6 +217,44 @@ export function parseTagList(raw: string): string[] { .filter(Boolean); } +/** + * Palette for auto-assigned tag colors (deterministic by label). These are + * intentional categorical hues, not theme colors — the shared token system only + * defines brand-accent semantics (no 10-way categorical scale), so raw hex is the + * right tool here. (Exempt from the shared "no hardcoded hex" rule, which targets + * brand/neutral surfaces.) + */ +export const TAG_COLORS = ["#ef4444", "#f59e0b", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9", "#6366f1", "#a855f7", "#ec4899", "#78716c"]; + +/** + * A stable color for a tag label: a deterministic palette pick from the label's + * hash, so the same tag is always the same color across cards/boards with zero + * configuration. An explicit `.board` label color overrides this (see resolveTags). + */ +export function autoTagColor(label: string): string { + let h = 0; + for (let i = 0; i < label.length; i++) h = (Math.imul(h, 31) + label.charCodeAt(i)) >>> 0; + return TAG_COLORS[h % TAG_COLORS.length]!; +} + +/** Resolve raw tag labels into colored {@link BoardTag}s: an explicit board label + * definition's color wins, else a deterministic auto-color. */ +export function resolveTags(rawLabels: string[], labels?: BoardLabelDef[]): BoardTag[] { + return rawLabels.map((label) => { + const def = labels?.find((l) => l.label === label); + return { label, color: def?.color ?? autoTagColor(label) }; + }); +} + +/** The tag options for the peek multiselect: every defined label plus every tag + * currently in use on a card, each with its resolved color (deduped by label). */ +export function collectTagOptions(cards: BoardViewCard[], labels?: BoardLabelDef[]): BoardTag[] { + const byLabel = new Map(); + for (const l of labels ?? []) byLabel.set(l.label, l.color ?? autoTagColor(l.label)); + for (const c of cards) for (const t of c.tags) if (!byLabel.has(t.label)) byLabel.set(t.label, t.color ?? autoTagColor(t.label)); + return [...byLabel].map(([label, color]) => ({ label, color })); +} + /** Parse a dependency value (`[[a]], [[b]]` or `a, b`) into card slugs. */ export function parseLinks(raw: string): string[] { return raw diff --git a/src/components/BoardView.tsx b/src/components/BoardView.tsx index 05cd449..8981bd5 100644 --- a/src/components/BoardView.tsx +++ b/src/components/BoardView.tsx @@ -9,6 +9,7 @@ import type { BoardActions } from "@shared/components/board"; import { DEFAULT_DONE_COLUMN, pickCustomFields, + resolveTags, serializeAttachments, serializeLinks, slugify, @@ -85,7 +86,7 @@ export function BoardView({ boardPath, boardRelativePath }: { boardPath: string; priority: c.priority ?? null, assignee: c.assignee ?? null, due: c.due ?? null, - tags: (c.tags ?? []).map((label) => ({ label })), + tags: resolveTags(c.tags ?? [], config?.labels), taskDone: c.taskDone, taskTotal: c.taskTotal, excerpt: c.excerpt ?? null, @@ -95,7 +96,7 @@ export function BoardView({ boardPath, boardRelativePath }: { boardPath: string; blocks: c.blocks ?? [], relates: c.relates ?? [], })), - [rawCards, config?.fields], + [rawCards, config?.fields, config?.labels], ); const viewConfig: BoardViewConfig = useMemo( @@ -109,6 +110,8 @@ export function BoardView({ boardPath, boardRelativePath }: { boardPath: string; viewType: config.viewType, calendarMode: config.calendarMode, fields: config.fields, + labels: config.labels, + ticketKey: config.ticketKey, swimlaneBy: config.swimlaneBy as BoardViewConfig["swimlaneBy"], groupBy: (config.groupBy as BoardViewConfig["groupBy"]) || "status", } diff --git a/src/lib/types.ts b/src/lib/types.ts index 54ca705..b773c40 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,6 +25,10 @@ export type BoardConfig = { calendarMode?: "month" | "agenda"; /** User-defined custom fields shown/edited on this board's cards. */ fields?: { key: string; label: string; type?: "text" | "number" | "date" }[]; + /** Board-level label definitions giving tags an explicit color (else auto-colored). */ + labels?: { label: string; color?: string | null }[]; + /** Board ticket-id prefix (e.g. OCCSV) for ticket links; ticket numbers are cloud-only. */ + ticketKey?: string; /** Optional second grouping dimension rendered as swimlane rows in the board view. */ swimlaneBy?: "status" | "priority" | "assignee"; };