feat(console): 分流台勾掉/還原(雙向銷案:console 勾掉=終局 checked_via:console,萃取端絕不復活)

leo 2026-07-08 拍板,PR#41 分流台的後續:

- lib/console-triage-model.ts:applyTriageCheck 純函式(check→status:done+
  checked_via:console+checked_at;restore→status:new+移除 checked_via/checked_at;
  content 非 JSON 殘資料包成 {"text":原文} 不丟字)。雙向銷案語意寫死在註解:
  console 勾掉=終局,即使 Logseq 原文還是 TODO 萃取端也絕不復活;Logseq 改 DONE
  由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。
- console-dashboard.ts:POST /console/triage-check(沿 triage-data 同款 session 驗證;
  瀏覽器沒 KBDB token 故 server 端做 PATCH——先 GET 原 entry 整串回寫防蓋掉別的欄位;
  守 owner_id=CONSOLE_TENANT 與 entry_type∈{todo,inbox} 兩道邊界)。
- console.ts:每筆待辦 44px 圓形勾掉鈕(手機好按)+done 清單「還原」鈕(誤勾救濟);
  後端回寫成功才改本地狀態移出三欄(done 預設隱藏既有機制),失敗誠實 toast 可重試。
- 測試:applyTriageCheck 7 例(先紅後綠)+ route 層 8 例(fetchMock 假 host
  kbdb.test + disableNetConnect,絕不外連;驗 session 閘/整串回寫/邊界/restore)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:30:13 +08:00
parent 1bc83c7124
commit e90688c608
6 changed files with 366 additions and 3 deletions
@@ -68,7 +68,7 @@ import {
sortWaitingItems,
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { buildTriageModel } from '../lib/console-triage-model';
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -473,6 +473,55 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
return c.json({ ...model, generated_at: new Date().toISOString() });
});
// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKENtoken 只能在
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
// triage-data 同款 session 驗證,server 端做 KBDB PATCHkbdbBase 慣例)。
//
// PATCH content 需**整串回寫**KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
//
// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:donechecked_via:"logseq")。
// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
consoleDashboardRouter.post('/console/triage-check', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const body = await c.req.json().catch(() => null);
const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
const tenant = c.env.CONSOLE_TENANT || 'leo';
const { base, headers } = kbdbBase(c.env);
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
// 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
// 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
`${base}/entries/${encodeURIComponent(entryId)}`,
headers,
);
const entry = got?.entry;
if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404);
if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性
if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400);
}
const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ content: newContent }),
});
if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status}` }, 502);
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
});
function renderDashboardHtml(): string {
return `<!doctype html>
<html lang="zh-Hant">