Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
211 lines
12 KiB
JavaScript
211 lines
12 KiB
JavaScript
/**
|
|
* Task and row chat routes.
|
|
*/
|
|
|
|
import {
|
|
dbRun, dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
|
|
success, error, notFound,
|
|
requireAuth,
|
|
} from './chatShared.js';
|
|
|
|
export default function registerTaskRowRoutes(router) {
|
|
|
|
// GET /tasks/:tableId/stats - Batch conversation stats for all rows in a table
|
|
// ADR-077: Agent status indicators on Kanban cards
|
|
router.get('/tasks/:tableId/stats', requireAuth, async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
const tid = parseInt(tableId);
|
|
|
|
if (isNaN(tid)) return error(res, 'INVALID_TABLE_ID', 'tableId must be a number', 400);
|
|
|
|
const stats = await dbAll(
|
|
isPostgres()
|
|
? `SELECT
|
|
c.bound_row_id AS row_id,
|
|
COALESCE(SUM(c.messages_count), 0)::int AS total_messages,
|
|
COUNT(c.id)::int AS conversation_count,
|
|
MAX(c.last_message_at) AS last_message_at,
|
|
(array_agg(u.name ORDER BY c.last_message_at DESC NULLS LAST))[1] AS last_agent_name,
|
|
(array_agg(u.avatar ORDER BY c.last_message_at DESC NULLS LAST))[1] AS last_agent_avatar,
|
|
(array_agg(u.id ORDER BY c.last_message_at DESC NULLS LAST))[1] AS last_agent_user_id
|
|
FROM conversations c
|
|
LEFT JOIN LATERAL (
|
|
SELECT m.sender_id FROM messages m
|
|
WHERE m.conversation_id = c.id AND m.sender_type = 'agent'
|
|
ORDER BY m.created_at DESC LIMIT 1
|
|
) lm ON true
|
|
LEFT JOIN users u ON u.id = lm.sender_id
|
|
WHERE c.bound_table_id = $1
|
|
AND c.bound_row_id IS NOT NULL
|
|
GROUP BY c.bound_row_id`
|
|
: `SELECT
|
|
c.bound_row_id AS row_id,
|
|
COALESCE(SUM(c.messages_count), 0) AS total_messages,
|
|
COUNT(c.id) AS conversation_count,
|
|
MAX(c.last_message_at) AS last_message_at,
|
|
NULL AS last_agent_name,
|
|
NULL AS last_agent_avatar,
|
|
NULL AS last_agent_user_id
|
|
FROM conversations c
|
|
WHERE c.bound_table_id = ?
|
|
AND c.bound_row_id IS NOT NULL
|
|
GROUP BY c.bound_row_id`,
|
|
[tid]
|
|
);
|
|
|
|
return success(res, stats);
|
|
} catch (err) {
|
|
apiLogger.error('Error getting task stats:', err);
|
|
return error(res, 'GET_TASK_STATS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// GET /tasks/:tableId/:rowId - Get task chat
|
|
router.get('/tasks/:tableId/:rowId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { tableId, rowId } = req.params;
|
|
const { create } = req.query;
|
|
const userId = req.user.userId;
|
|
|
|
const allConversations = await dbAll(
|
|
isPostgres()
|
|
? `SELECT c.*, (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) as messages_count FROM conversations c WHERE c.bound_table_id = $1 AND c.bound_row_id = $2 ORDER BY c.updated_at DESC`
|
|
: `SELECT c.*, (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) as messages_count FROM conversations c WHERE c.bound_table_id = ? AND c.bound_row_id = ? ORDER BY c.updated_at DESC`,
|
|
[parseInt(tableId), parseInt(rowId)]
|
|
);
|
|
|
|
let conversation = allConversations.length > 0 ? allConversations[0] : null;
|
|
const isMulti = allConversations.length > 1;
|
|
|
|
if (!conversation && create === 'true') {
|
|
if (isPostgres()) {
|
|
const result = await dbRun(`INSERT INTO conversations (type, bound_table_id, bound_row_id, created_by, created_at, updated_at) VALUES ('task', $1, $2, $3, NOW(), NOW())`, [parseInt(tableId), parseInt(rowId), userId]);
|
|
const conversationId = result.lastInsertRowid;
|
|
await dbRun(`INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES ($1, $2, 'admin', NOW())`, [conversationId, userId]);
|
|
conversation = await dbGet(`SELECT * FROM conversations WHERE id = $1`, [conversationId]);
|
|
} else {
|
|
const result = await dbRun(`INSERT INTO conversations (type, bound_table_id, bound_row_id, created_by, created_at, updated_at) VALUES ('task', ?, ?, ?, datetime('now'), datetime('now'))`, [parseInt(tableId), parseInt(rowId), userId]);
|
|
const conversationId = result.lastInsertRowid;
|
|
await dbRun(`INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', datetime('now'))`, [conversationId, userId]);
|
|
conversation = await dbGet(`SELECT * FROM conversations WHERE id = ?`, [conversationId]);
|
|
}
|
|
}
|
|
|
|
if (!conversation) return notFound(res, 'Task chat not found');
|
|
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 1000, 1), 2000);
|
|
const before = req.query.before ? parseInt(req.query.before) : null;
|
|
|
|
let msgQuery, msgParams;
|
|
if (before) {
|
|
msgQuery = isPostgres()
|
|
? `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = $1 AND m.id < $2 ORDER BY m.created_at DESC, m.id DESC LIMIT $3`
|
|
: `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = ? AND m.id < ? ORDER BY m.created_at DESC, m.id DESC LIMIT ?`;
|
|
msgParams = [conversation.id, before, limit + 1];
|
|
} else {
|
|
msgQuery = isPostgres()
|
|
? `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = $1 ORDER BY m.created_at DESC, m.id DESC LIMIT $2`
|
|
: `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = ? ORDER BY m.created_at DESC, m.id DESC LIMIT ?`;
|
|
msgParams = [conversation.id, limit + 1];
|
|
}
|
|
|
|
const rawMessages = await dbAll(msgQuery, msgParams);
|
|
const hasMore = rawMessages.length > limit;
|
|
const slicedMessages = hasMore ? rawMessages.slice(0, limit) : rawMessages;
|
|
slicedMessages.reverse();
|
|
const nextCursor = hasMore && slicedMessages.length > 0 ? slicedMessages[0].id : null;
|
|
|
|
const messages = slicedMessages.map(m => ({
|
|
...m,
|
|
user: m.sender_id ? { id: m.sender_id, name: m.user_name || 'Unknown', avatar: m.user_avatar || null, user_type: m.user_user_type || (m.sender_type === 'agent' ? 'agent' : 'user') } : null,
|
|
tool_results: m.tool_results ? safeJsonParse(m.tool_results) : null,
|
|
}));
|
|
|
|
const responseData = { ...conversation, conversationId: conversation.id, messages, hasMore, nextCursor };
|
|
if (isMulti) {
|
|
responseData.multi = true;
|
|
responseData.conversations = allConversations.map(c => ({ id: c.id, title: c.title, type: c.type, created_at: c.created_at, updated_at: c.updated_at, messages_count: c.messages_count || 0 }));
|
|
}
|
|
|
|
return success(res, responseData);
|
|
} catch (err) {
|
|
apiLogger.error('Error getting task chat:', err);
|
|
return error(res, 'GET_TASK_CHAT_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// GET /rows/:tableId/:rowId - General row chat (any table)
|
|
router.get('/rows/:tableId/:rowId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { tableId, rowId } = req.params;
|
|
const { create } = req.query;
|
|
const userId = req.user.userId;
|
|
|
|
const allConversations = await dbAll(
|
|
isPostgres()
|
|
? `SELECT c.*, (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) as messages_count FROM conversations c WHERE c.bound_table_id = $1 AND c.bound_row_id = $2 ORDER BY c.updated_at DESC`
|
|
: `SELECT c.*, (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) as messages_count FROM conversations c WHERE c.bound_table_id = ? AND c.bound_row_id = ? ORDER BY c.updated_at DESC`,
|
|
[parseInt(tableId), parseInt(rowId)]
|
|
);
|
|
|
|
let conversation = allConversations.length > 0 ? allConversations[0] : null;
|
|
const isMulti = allConversations.length > 1;
|
|
|
|
if (!conversation && create === 'true') {
|
|
if (isPostgres()) {
|
|
const result = await dbRun(`INSERT INTO conversations (type, bound_table_id, bound_row_id, created_by, created_at, updated_at) VALUES ('row', $1, $2, $3, NOW(), NOW())`, [parseInt(tableId), parseInt(rowId), userId]);
|
|
const conversationId = result.lastInsertRowid;
|
|
await dbRun(`INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES ($1, $2, 'admin', NOW())`, [conversationId, userId]);
|
|
conversation = await dbGet(`SELECT * FROM conversations WHERE id = $1`, [conversationId]);
|
|
} else {
|
|
const result = await dbRun(`INSERT INTO conversations (type, bound_table_id, bound_row_id, created_by, created_at, updated_at) VALUES ('row', ?, ?, ?, datetime('now'), datetime('now'))`, [parseInt(tableId), parseInt(rowId), userId]);
|
|
const conversationId = result.lastInsertRowid;
|
|
await dbRun(`INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'admin', datetime('now'))`, [conversationId, userId]);
|
|
conversation = await dbGet(`SELECT * FROM conversations WHERE id = ?`, [conversationId]);
|
|
}
|
|
}
|
|
|
|
if (!conversation) return notFound(res, 'Row chat not found');
|
|
|
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 1000, 1), 2000);
|
|
const before = req.query.before ? parseInt(req.query.before) : null;
|
|
|
|
let msgQuery, msgParams;
|
|
if (before) {
|
|
msgQuery = isPostgres()
|
|
? `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = $1 AND m.id < $2 ORDER BY m.created_at DESC, m.id DESC LIMIT $3`
|
|
: `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = ? AND m.id < ? ORDER BY m.created_at DESC, m.id DESC LIMIT ?`;
|
|
msgParams = [conversation.id, before, limit + 1];
|
|
} else {
|
|
msgQuery = isPostgres()
|
|
? `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = $1 ORDER BY m.created_at DESC, m.id DESC LIMIT $2`
|
|
: `SELECT m.*, u.name as user_name, u.avatar as user_avatar, u.user_type as user_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = ? ORDER BY m.created_at DESC, m.id DESC LIMIT ?`;
|
|
msgParams = [conversation.id, limit + 1];
|
|
}
|
|
|
|
const rawMessages = await dbAll(msgQuery, msgParams);
|
|
const hasMore = rawMessages.length > limit;
|
|
const slicedMessages = hasMore ? rawMessages.slice(0, limit) : rawMessages;
|
|
slicedMessages.reverse();
|
|
const nextCursor = hasMore && slicedMessages.length > 0 ? slicedMessages[0].id : null;
|
|
|
|
const messages = slicedMessages.map(m => ({
|
|
...m,
|
|
user: m.sender_id ? { id: m.sender_id, name: m.user_name || 'Unknown', avatar: m.user_avatar || null, user_type: m.user_user_type || (m.sender_type === 'agent' ? 'agent' : 'user') } : null,
|
|
tool_results: m.tool_results ? safeJsonParse(m.tool_results) : null,
|
|
}));
|
|
|
|
const responseData = { ...conversation, conversationId: conversation.id, messages, hasMore, nextCursor };
|
|
if (isMulti) {
|
|
responseData.multi = true;
|
|
responseData.conversations = allConversations.map(c => ({ id: c.id, title: c.title, type: c.type, created_at: c.created_at, updated_at: c.updated_at, messages_count: c.messages_count || 0 }));
|
|
}
|
|
|
|
return success(res, responseData);
|
|
} catch (err) {
|
|
apiLogger.error('Error getting row chat:', err);
|
|
return error(res, 'GET_ROW_CHAT_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
}
|