Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
// Documents: ADR-038 Task binding endpoints
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, isPostgres, safeJsonParse } from '../../../database/connection.js';
|
|
import { generateBaseId } from '../../../utils/baseId.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, created, error, badRequest, notFound } from '../../../utils/response.js';
|
|
import { requireEditorAccess } from './_helpers.js';
|
|
import { buildOrderClause } from '../../../services/orderClause.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* POST /projects/:projectId/documents/:docId/items/:itemId/link-task
|
|
* Link an existing task to a document item
|
|
*/
|
|
router.post('/projects/:projectId/documents/:docId/items/:itemId/link-task', async (req, res) => {
|
|
try {
|
|
const { projectId, docId, itemId } = req.params;
|
|
const { task_id } = req.body;
|
|
if (!(await requireEditorAccess(req, res, projectId))) return;
|
|
if (!task_id) return badRequest(res, 'MISSING_TASK_ID', 'task_id is required');
|
|
|
|
apiLogger.debug({ projectId, docId, itemId, task_id }, '[Documents] Linking task to document item');
|
|
|
|
const registryResult = await dbGet(`
|
|
SELECT t.id as table_id, t.name FROM tables t
|
|
JOIN table_rows reg ON reg.table_id = (
|
|
SELECT id FROM tables WHERE name = '_registry' AND project_id = ?
|
|
) WHERE t.id = (
|
|
SELECT json_extract(reg.data, '$.table_id') FROM table_rows reg WHERE reg.id = ?
|
|
)
|
|
`, [projectId, docId]);
|
|
if (!registryResult) return notFound(res, 'DOCUMENT_NOT_FOUND', 'Document not found');
|
|
|
|
const item = await dbGet(`SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`, [itemId, registryResult.table_id]);
|
|
if (!item) return notFound(res, 'ITEM_NOT_FOUND', 'Document item not found');
|
|
|
|
const itemData = safeJsonParse(item.data, {});
|
|
const previousTaskRef = itemData.task_ref || null;
|
|
itemData.task_ref = parseInt(task_id, 10);
|
|
|
|
await dbRun(`
|
|
UPDATE table_rows SET data = ?, updated_at = ${isPostgres ? 'NOW()' : 'datetime("now")'} WHERE id = ?
|
|
`, [JSON.stringify(itemData), itemId]);
|
|
|
|
apiLogger.info({ itemId, task_id, previousTaskRef }, '[Documents] Task linked to document item');
|
|
success(res, { item_id: parseInt(itemId, 10), task_ref: itemData.task_ref, previous_task_ref: previousTaskRef });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /documents/:docId/items/:itemId/link-task error');
|
|
error(res, 'LINK_TASK_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /projects/:projectId/documents/:docId/items/:itemId/create-task
|
|
* Create a new task from document item and link it
|
|
*/
|
|
router.post('/projects/:projectId/documents/:docId/items/:itemId/create-task', async (req, res) => {
|
|
try {
|
|
const { projectId, docId, itemId } = req.params;
|
|
const { table_id, data } = req.body;
|
|
if (!(await requireEditorAccess(req, res, projectId))) return;
|
|
if (!table_id) return badRequest(res, 'MISSING_TABLE_ID', 'table_id is required');
|
|
if (!data?.title) return badRequest(res, 'MISSING_TITLE', 'data.title is required');
|
|
|
|
apiLogger.debug({ projectId, docId, itemId, table_id }, '[Documents] Creating task from document item');
|
|
|
|
const docTable = await dbGet(`
|
|
SELECT t.id as table_id FROM table_rows reg
|
|
JOIN tables t ON t.id = (SELECT json_extract(reg.data, '$.table_id') FROM table_rows WHERE id = reg.id)
|
|
WHERE reg.id = ?
|
|
`, [docId]);
|
|
if (!docTable) return notFound(res, 'DOCUMENT_NOT_FOUND', 'Document not found');
|
|
|
|
const docItem = await dbGet(`SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`, [itemId, docTable.table_id]);
|
|
if (!docItem) return notFound(res, 'ITEM_NOT_FOUND', 'Document item not found');
|
|
|
|
const base_id = generateBaseId();
|
|
const taskData = {
|
|
title: data.title, description: data.description || '',
|
|
status: data.status || 'todo', due_date: data.due_date || null,
|
|
priority: data.priority || 'medium', progress: 0,
|
|
source_document_id: parseInt(docId, 10), source_item_id: parseInt(itemId, 10)
|
|
};
|
|
|
|
const insertResult = await dbRun(`
|
|
INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES (?, ?, ?, ${isPostgres ? 'NOW(), NOW()' : 'datetime("now"), datetime("now")'})
|
|
`, [table_id, base_id, JSON.stringify(taskData)]);
|
|
const taskId = insertResult.lastID || insertResult.id;
|
|
|
|
const itemData = safeJsonParse(docItem.data, {});
|
|
itemData.task_ref = taskId;
|
|
await dbRun(`
|
|
UPDATE table_rows SET data = ?, updated_at = ${isPostgres ? 'NOW()' : 'datetime("now")'} WHERE id = ?
|
|
`, [JSON.stringify(itemData), itemId]);
|
|
|
|
apiLogger.info({ itemId, taskId, table_id }, '[Documents] Task created from document item');
|
|
created(res, { task_id: taskId, item_id: parseInt(itemId, 10), task_ref: taskId, base_id });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /documents/:docId/items/:itemId/create-task error');
|
|
error(res, 'CREATE_TASK_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /projects/:projectId/documents/:docId/items/:itemId/unlink-task
|
|
* Remove task reference from document item (does NOT delete the task)
|
|
*/
|
|
router.delete('/projects/:projectId/documents/:docId/items/:itemId/unlink-task', async (req, res) => {
|
|
try {
|
|
const { projectId, docId, itemId } = req.params;
|
|
if (!(await requireEditorAccess(req, res, projectId))) return;
|
|
|
|
const docTable = await dbGet(`
|
|
SELECT t.id as table_id FROM table_rows reg
|
|
JOIN tables t ON t.id = (SELECT json_extract(reg.data, '$.table_id') FROM table_rows WHERE id = reg.id)
|
|
WHERE reg.id = ?
|
|
`, [docId]);
|
|
if (!docTable) return notFound(res, 'DOCUMENT_NOT_FOUND', 'Document not found');
|
|
|
|
const item = await dbGet(`SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`, [itemId, docTable.table_id]);
|
|
if (!item) return notFound(res, 'ITEM_NOT_FOUND', 'Document item not found');
|
|
|
|
const itemData = safeJsonParse(item.data, {});
|
|
const previousTaskRef = itemData.task_ref || null;
|
|
if (!previousTaskRef) return badRequest(res, 'NOT_LINKED', 'Item is not linked to any task');
|
|
|
|
itemData.task_ref = null;
|
|
await dbRun(`
|
|
UPDATE table_rows SET data = ?, updated_at = ${isPostgres ? 'NOW()' : 'datetime("now")'} WHERE id = ?
|
|
`, [JSON.stringify(itemData), itemId]);
|
|
|
|
apiLogger.info({ itemId, previousTaskRef }, '[Documents] Task unlinked from document item');
|
|
success(res, { item_id: parseInt(itemId, 10), previous_task_ref: previousTaskRef });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'DELETE /documents/:docId/items/:itemId/unlink-task error');
|
|
error(res, 'UNLINK_TASK_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /projects/:projectId/documents/:docId/export-tasks
|
|
* Bulk export document items to tasks
|
|
*/
|
|
router.post('/projects/:projectId/documents/:docId/export-tasks', async (req, res) => {
|
|
try {
|
|
const { projectId, docId } = req.params;
|
|
const { table_id, item_ids, options = {} } = req.body;
|
|
if (!(await requireEditorAccess(req, res, projectId))) return;
|
|
if (!table_id) return badRequest(res, 'MISSING_TABLE_ID', 'table_id is required');
|
|
if (!item_ids || !Array.isArray(item_ids) || item_ids.length === 0) return badRequest(res, 'MISSING_ITEM_IDS', 'item_ids array is required');
|
|
|
|
const { include_content = true, default_status = 'todo', default_priority = 'medium', skip_linked = true } = options;
|
|
|
|
const docTable = await dbGet(`
|
|
SELECT t.id as table_id FROM table_rows reg
|
|
JOIN tables t ON t.id = (SELECT json_extract(reg.data, '$.table_id') FROM table_rows WHERE id = reg.id)
|
|
WHERE reg.id = ?
|
|
`, [docId]);
|
|
if (!docTable) return notFound(res, 'DOCUMENT_NOT_FOUND', 'Document not found');
|
|
|
|
const placeholders = item_ids.map(() => '?').join(',');
|
|
const items = await dbAll(`
|
|
SELECT id, data FROM table_rows WHERE id IN (${placeholders}) AND table_id = ?
|
|
`, [...item_ids, docTable.table_id]);
|
|
|
|
const results = { created: [], skipped: [], errors: [] };
|
|
|
|
for (const item of items) {
|
|
const itemData = safeJsonParse(item.data, {});
|
|
if (skip_linked && itemData.task_ref) {
|
|
results.skipped.push({ item_id: item.id, reason: 'already_linked', task_ref: itemData.task_ref });
|
|
continue;
|
|
}
|
|
try {
|
|
let title = itemData.content_en || itemData.content || '';
|
|
title = title.replace(/^#+\s*/, '').trim();
|
|
if (!title) { results.errors.push({ item_id: item.id, reason: 'empty_title' }); continue; }
|
|
|
|
const base_id = generateBaseId();
|
|
const taskData = {
|
|
title, description: include_content ? (itemData.content_en || itemData.content || '') : '',
|
|
status: default_status, priority: default_priority, progress: 0,
|
|
source_document_id: parseInt(docId, 10), source_item_id: item.id
|
|
};
|
|
const insertResult = await dbRun(`
|
|
INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES (?, ?, ?, ${isPostgres ? 'NOW(), NOW()' : 'datetime("now"), datetime("now")'})
|
|
`, [table_id, base_id, JSON.stringify(taskData)]);
|
|
const taskId = insertResult.lastID || insertResult.id;
|
|
|
|
itemData.task_ref = taskId;
|
|
await dbRun(`
|
|
UPDATE table_rows SET data = ?, updated_at = ${isPostgres ? 'NOW()' : 'datetime("now")'} WHERE id = ?
|
|
`, [JSON.stringify(itemData), item.id]);
|
|
|
|
results.created.push({ item_id: item.id, task_id: taskId, title });
|
|
} catch (itemErr) {
|
|
apiLogger.error({ err: itemErr, itemId: item.id }, '[Documents] Error creating task for item');
|
|
results.errors.push({ item_id: item.id, reason: itemErr.message });
|
|
}
|
|
}
|
|
|
|
apiLogger.info({ docId, created: results.created.length, skipped: results.skipped.length, errors: results.errors.length }, '[Documents] Bulk export completed');
|
|
success(res, {
|
|
created_count: results.created.length, skipped_count: results.skipped.length, error_count: results.errors.length,
|
|
created: results.created, skipped: results.skipped, errors: results.errors
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /documents/:docId/export-tasks error');
|
|
error(res, 'EXPORT_TASKS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /projects/:projectId/documents/:docId/items/with-tasks
|
|
* Get document items enriched with linked task data
|
|
*/
|
|
router.get('/projects/:projectId/documents/:docId/items/with-tasks', async (req, res) => {
|
|
try {
|
|
const { projectId, docId } = req.params;
|
|
const { task_table_id } = req.query;
|
|
|
|
const docTable = await dbGet(`
|
|
SELECT t.id as table_id FROM table_rows reg
|
|
JOIN tables t ON t.id = (SELECT json_extract(reg.data, '$.table_id') FROM table_rows WHERE id = reg.id)
|
|
WHERE reg.id = ?
|
|
`, [docId]);
|
|
if (!docTable) return notFound(res, 'DOCUMENT_NOT_FOUND', 'Document not found');
|
|
|
|
const items = await dbAll(`
|
|
SELECT id, data FROM table_rows WHERE table_id = ?
|
|
ORDER BY ${buildOrderClause('order', { dialect: isPostgres() ? 'pg' : 'sqlite', mode: 'coalesce', coalesce: false, tieBreak: null })}
|
|
`, [docTable.table_id]);
|
|
|
|
const taskRefs = items.map(item => safeJsonParse(item.data, {}).task_ref).filter(Boolean);
|
|
let tasksMap = {};
|
|
if (taskRefs.length > 0 && task_table_id) {
|
|
const placeholders = taskRefs.map(() => '?').join(',');
|
|
const tasks = await dbAll(`SELECT id, data FROM table_rows WHERE id IN (${placeholders}) AND table_id = ?`, [...taskRefs, task_table_id]);
|
|
tasksMap = tasks.reduce((acc, task) => { acc[task.id] = { id: task.id, ...safeJsonParse(task.data, {}) }; return acc; }, {});
|
|
}
|
|
|
|
const enrichedItems = items.map(item => {
|
|
const itemData = safeJsonParse(item.data, {});
|
|
return { id: item.id, ...itemData, linked_task: itemData.task_ref && tasksMap[itemData.task_ref] ? tasksMap[itemData.task_ref] : null };
|
|
});
|
|
|
|
success(res, {
|
|
document_id: parseInt(docId, 10), table_id: docTable.table_id,
|
|
items: enrichedItems, count: enrichedItems.length,
|
|
linked_count: enrichedItems.filter(i => i.linked_task).length
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /documents/:docId/items/with-tasks error');
|
|
error(res, 'GET_ITEMS_WITH_TASKS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|