diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 14cdfd6..bfa059f 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1558,6 +1558,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!adminLibs.length) { $('ad-libs').innerHTML = '
同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。
'; return; } $('ad-libs').innerHTML = adminLibs.map(function (l) { var disabled = l.status === 'disabled'; + var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中 + var removeBtn = l.auto + ? '' + : ''; return '
' + '
' + '' + esc(l.display_name || l.name) + '' + @@ -1565,8 +1569,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { (l.auto ? '啟用中同步自動出現' : '' + (disabled ? '已停用' : '啟用中') + '') + + removeBtn + '
' + (!l.auto && l.description ? '
' + esc(l.description) + '
' : '') + + (notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') + '
'; }).join(''); } @@ -1682,6 +1688,43 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); return; } + // 移除已登記庫(有 record_id) + if (act === 'lib-remove') { + var libId = t.getAttribute('data-id') || ''; + var libName = t.getAttribute('data-name') || libId; + if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return; + t.disabled = true; + adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId)) + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; } + adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; }); + renderAdminLibs(); + toast('已從目錄移除'); + }) + .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); + return; + } + // 移除 auto 庫(無 record_id,entries 標 deprecated) + if (act === 'lib-remove-auto') { + var autoName = t.getAttribute('data-name') || ''; + var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:'); + if (input === null) return; // 取消 + if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; } + t.disabled = true; + adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName }) + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; } + adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; }); + renderAdminLibs(); + toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)'); + }) + .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); + return; + } }); // 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上) $('ad-otp').addEventListener('click', function (ev) { diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index 672e96b..c35c0c8 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -500,6 +500,26 @@ export class GraphExecutor { iterResults.push(itemResult); } + // t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤 + if (iterResults.length > 0) { + const failures = iterResults.filter( + r => r !== null && typeof r === 'object' && (r as Record).success === false + ); + if (failures.length === iterResults.length) { + const first = failures[0] as Record; + const errParts: string[] = []; + if (first.error) errParts.push(String(first.error)); + if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`); + const bodyData = first.data as { body?: string } | null | undefined; + if (bodyData && typeof bodyData.body === 'string' && bodyData.body) { + errParts.push(bodyData.body.slice(0, 200)); + } + throw new Error( + `FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join(';') || '未知錯誤'})` + ); + } + } + result = { ...(result as Record), results: iterResults }; break; } diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts index 7ea0aa4..6763cf1 100644 --- a/cypher-executor/src/lib/wasi-shim.ts +++ b/cypher-executor/src/lib/wasi-shim.ts @@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti const result = await hostFunctions!.http_request!(url, method, headers, body); // await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer) return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result)); - } catch { - return 1; + } catch (e) { + // t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情); + // 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。 + // writeOut 失敗(memory 壞)才 fallback return 1。 + const errDetail = e instanceof Error ? e.message : String(e); + const errEnv = new TextEncoder().encode( + JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' }) + ); + return writeOut(memory.buffer, outPtr, outLenPtr, errEnv); } }) : () => 1, diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 207eb0a..cf3ac55 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -184,6 +184,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record return body.record; } +async function deleteKbdbRecord(env: Bindings, recordId: string): Promise { + const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' }); + if (res.status === 404) return false; + if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`); + return true; +} + +/** KV key for daemon's most-recently-reported active library names(t135 daemon hint)。 */ +function daemonActiveKey(env: Bindings): string { + return `${portalTenant(env)}:portal:daemon_active_libs`; +} + export async function listRecordsByTemplate(env: Bindings, template: string): Promise { const ns = portalNamespace(env); const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`); @@ -751,6 +763,11 @@ portalRouter.post('/portal/daemon/libraries', (c) => created.push(name); } const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + // t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。 + const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean); + if (activeNames.length > 0) { + await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 }); + } return c.json({ success: true, created, libraries: after.map(toPublicLibrary) }); }), ); @@ -905,12 +922,23 @@ portalRouter.get('/portal/admin/extractor', (c) => // t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): // 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)—— // 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。 +// t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。 portalRouter.get('/portal/admin/libraries', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); - const out = libs.map(toPublicLibrary); + // 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint) + let daemonActive: Set | null = null; + try { + const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text'); + if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim())); + } catch { /* KV 不可達不擋主流程 */ } + const out = libs.map((rec) => { + const lib = toPublicLibrary(rec); + const watching = daemonActive === null ? undefined : daemonActive.has(lib.name); + return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) }; + }); const known = new Set(out.map((l) => l.name)); // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library) try { @@ -922,7 +950,13 @@ portalRouter.get('/portal/admin/libraries', (c) => // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 if (!n || n === 'general' || known.has(n)) continue; known.add(n); - out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true }); + const watching = daemonActive === null ? undefined : daemonActive.has(n); + out.push({ + record_id: '', name: n, display_name: n, + description: '資料同步時自動出現(可在此補顯示名)', + status: 'active', graph_source: false, auto: true, + ...(watching !== undefined ? { daemon_watching: watching } : {}), + }); } } } catch { @@ -1005,3 +1039,53 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) => return c.json({ success: true, library: toPublicLibrary(updated) }); }), ); + +// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。 +// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。 +// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。 +// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。 +portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const name = decodeURIComponent(c.req.param('name')); + const body = await c.req.json().catch(() => null); + const confirm = String(body?.confirm ?? '').trim(); + if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400); + if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400); + const ownerId = portalTenant(c.env); + const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', { + method: 'PATCH', + body: JSON.stringify({ owner_id: ownerId, library: name }), + }); + if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`); + const data = (await res.json()) as { deprecated_count?: number }; + return c.json({ + success: true, + deprecated_count: data.deprecated_count ?? 0, + message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`, + }); + }), +); + +// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。 +// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。 +// 資料若有的話,重新同步後會以 auto 庫重新出現。 +portalRouter.delete('/portal/admin/libraries/:id', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const recordId = c.req.param('id'); + // 成員資格驗(防憑空 id 打到不相干 record) + const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + const target = libs.find((l) => l.record_id === recordId); + if (!target) return c.json({ error: '庫不存在' }, 404); + const found = await deleteKbdbRecord(c.env, recordId); + if (!found) return c.json({ error: '庫不存在' }, 404); + return c.json({ + success: true, + name: target.values.name ?? '', + message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`, + }); + }), +); diff --git a/cypher-executor/tests/executor.test.ts b/cypher-executor/tests/executor.test.ts index e192244..bb7bbd1 100644 --- a/cypher-executor/tests/executor.test.ts +++ b/cypher-executor/tests/executor.test.ts @@ -1,6 +1,8 @@ // Cypher Executor 端到端測試 import { SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { GraphExecutor } from '../src/graph-executor'; +import type { ComponentRunner, ExecutionGraph } from '../src/types'; describe('GET /', () => { it('回傳服務狀態', async () => { @@ -191,4 +193,60 @@ describe('POST /execute', () => { }); expect(res.status).toBe(400); }); + +}); + +// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試) +describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => { + it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => { + // mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"} + const failLoader = async (_: string): Promise => + async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } }); + + const executor = new GraphExecutor(failLoader); + + const graph: ExecutionGraph = { + id: 'foreach-fail-t117', + name: 'FOREACH 全失敗', + nodes: [ + { id: 'input', type: 'Input', data: { items: ['a', 'b'] } }, + { id: 'writer', type: 'Component', componentId: 'http_request' }, + ], + edges: [ + { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' }, + ], + }; + + // t117 核心驗證:全部失敗 → throw(不再靜默) + await expect(executor.execute(graph, {})).rejects.toThrow(/401/); + }); + + it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => { + let callCount = 0; + // 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑) + const mixedLoader = async (_: string): Promise => + async () => { + callCount++; + if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' }; + return { success: true, data: { ok: true } }; + }; + + const executor = new GraphExecutor(mixedLoader); + + const graph: ExecutionGraph = { + id: 'foreach-mixed-t117', + name: 'FOREACH 部分失敗', + nodes: [ + { id: 'input', type: 'Input', data: { items: ['a', 'b'] } }, + { id: 'writer', type: 'Component', componentId: 'http_request' }, + ], + edges: [ + { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' }, + ], + }; + + // 部分失敗 → 不拋出,正常回傳 results 陣列 + const result = await executor.execute(graph, {}); + expect(result).toBeDefined(); + }); }); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 4476146..88ce2a1 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -395,6 +395,82 @@ describe('/portal/admin/libraries', () => { }); }); +// ═══════════════ t135 庫目錄移除 ═══════════════ + +describe('DELETE /portal/admin/libraries(t135)', () => { + it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + // 成員驗證:list by template 回有該 record + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, + ]); + let deleteCalled = false; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_lib1', method: 'DELETE' }) + .reply(200, () => { deleteCalled = true; return { success: true }; }); + const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; name: string; message: string }; + expect(data.success).toBe(true); + expect(data.name).toBe('finance'); + expect(deleteCalled).toBe(true); + }); + + it('DELETE /:id — 庫不在目錄 → 404', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', []); // 空目錄 + const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(404); + }); + + it('DELETE /:id — 非 admin → 403', async () => { + await seedAdminSession('tok-user', 'rec_u1'); + mockGetRecord('rec_u1', userValues()); + const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' }); + expect(res.status).toBe(403); + }); + + it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + let deprecateCalled = false; + fetchMock + .get(KBDB) + .intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' }) + .reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; }); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; deprecated_count: number }; + expect(data.success).toBe(true); + expect(data.deprecated_count).toBe(12); + expect(deprecateCalled).toBe(true); + }); + + it('DELETE /by-name/:name — 無 confirm → 400', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(400); + }); + + it('DELETE /by-name/:name — confirm 不符 → 400', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(400); + }); + + it('DELETE /by-name/:name — 非 admin → 403', async () => { + await seedAdminSession('tok-user', 'rec_u1'); + mockGetRecord('rec_u1', userValues()); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' }); + expect(res.status).toBe(403); + }); +}); + // ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => { diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index e443d86..548ba37 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise { await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run(); } +/** + * 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。 + * 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。 + * 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。 + */ +export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise { + const result = await db + .prepare( + `UPDATE entries + SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'), + updated_at = unixepoch() + WHERE owner_id = ? + AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ? + AND (json_extract(metadata_json, '$.status') IS NULL + OR json_extract(metadata_json, '$.status') != 'deprecated')`, + ) + .bind(ownerId, library) + .run(); + return (result.meta?.changes as number | undefined) ?? 0; +} + // 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。 // COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))—— // 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。 diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index 68f9cd3..83e181c 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i } return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r); } + +/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */ +export async function deleteRecord(db: D1Database, recordId: string): Promise { + const evRes = await db + .prepare('SELECT entry_id FROM entry_values WHERE record_id = ?') + .bind(recordId) + .all<{ entry_id: string }>(); + const rows = evRes.results ?? []; + if (rows.length === 0) return false; + await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run(); + for (const { entry_id } of rows) { + await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run(); + } + return true; +} diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index e02f928..99954cb 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { createEntry, + deprecateEntriesByLibrary, getEntry, listEntries, updateEntry, @@ -139,6 +140,18 @@ entryRoutes.get('/:id', async (c) => { return c.json({ success: true, entry }); }); +// PATCH /entries/deprecate-by-library — body {owner_id, library}。 +// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。 +// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。 +entryRoutes.patch('/deprecate-by-library', async (c) => { + const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null; + const ownerId = String(body?.owner_id ?? '').trim(); + const library = String(body?.library ?? '').trim(); + if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400); + const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library); + return c.json({ success: true, deprecated_count: count }); +}); + // PATCH /entries/:id entryRoutes.patch('/:id', async (c) => { const body = await c.req.json().catch(() => ({})); diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts index 7a2d891..cc24de0 100644 --- a/kbdb/src/routes/records.ts +++ b/kbdb/src/routes/records.ts @@ -1,7 +1,7 @@ // Records route — structured records (entry_values composed by a template). import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud'; +import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud'; export const recordRoutes = new Hono<{ Bindings: Bindings }>(); @@ -47,3 +47,10 @@ recordRoutes.patch('/:recordId', async (c) => { return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400); } }); + +// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。 +recordRoutes.delete('/:recordId', async (c) => { + const found = await deleteRecord(c.env.DB, c.req.param('recordId')); + if (!found) return c.json({ success: false, error: 'not found' }, 404); + return c.json({ success: true }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index dd043b2..bab03c6 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -277,6 +277,19 @@ 執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。 測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。 +- [x] **t135 庫目錄移除(2026-07-29,任務層小改)**: + 來源=leo 裁定「需要加移除按鈕,別人裝錯我沒辦法幫他弄,需要可以自主」。 + 後端:`DELETE /portal/admin/libraries/:id`(刪登記簿 record,資料不動)+ + `DELETE /portal/admin/libraries/by-name/:name`(auto 庫;body `confirm:<庫名>` 才執行; + 呼叫 KBDB `PATCH /entries/deprecate-by-library`,entries 標 deprecated → 從 auto 清單消失)。 + KBDB:`deleteRecord` action + `DELETE /records/:id` route; + `deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route(此路由在 `/:id` 之前)。 + Daemon hint(根治只增不減):`POST /portal/daemon/libraries` 存 active lib names 到 KV(TTL 48h); + `GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`(若 daemon 有回報但未含該庫)。 + 前端:每張庫卡片加「移除」按鈕(登記庫=`confirm()` 對話框;auto 庫=`prompt()` 輸入庫名確認); + 移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。 + 測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)