godcrm/backend/routes/v3/chat/conversationExtrasController.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

195 lines
10 KiB
JavaScript

/**
* Conversation extras routes: bind, bind-lab, labs, settings, sub-agents.
* Extracted from conversationCrudController.js to keep files under 400 lines.
*/
import {
dbRun, dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
success, created, error, badRequest, notFound,
requireAuth,
} from './chatShared.js';
import {
resolveAgentUser, validateSubAgentRowIds, enrichSubAgents,
} from './chatAgentHelpers.js';
export default function registerConversationExtrasRoutes(router) {
// POST /conversations/:id/bind - Bind to task
router.post('/conversations/:id/bind', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const { table_id, row_id } = req.body;
if (isPostgres()) await dbRun(`UPDATE conversations SET bound_table_id = $1, bound_row_id = $2, updated_at = NOW() WHERE id = $3`, [table_id, row_id, id]);
else await dbRun(`UPDATE conversations SET bound_table_id = ?, bound_row_id = ?, updated_at = datetime('now') WHERE id = ?`, [table_id, row_id, id]);
const conversation = await dbGet(isPostgres() ? `SELECT * FROM conversations WHERE id = $1` : `SELECT * FROM conversations WHERE id = ?`, [id]);
return success(res, conversation);
} catch (err) {
apiLogger.error('Error binding conversation:', err);
return error(res, 'BIND_CONVERSATION_ERROR', err.message, 500);
}
});
// POST /conversations/:id/bind-lab - Bind to lab
router.post('/conversations/:id/bind-lab', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const { lab_id } = req.body;
if (!lab_id) return badRequest(res, 'lab_id is required');
if (isPostgres()) await dbRun(`UPDATE conversations SET lab_id = $1, updated_at = NOW() WHERE id = $2`, [lab_id, id]);
else await dbRun(`UPDATE conversations SET lab_id = ?, updated_at = datetime('now') WHERE id = ?`, [lab_id, id]);
const conversation = await dbGet(isPostgres() ? `SELECT * FROM conversations WHERE id = $1` : `SELECT * FROM conversations WHERE id = ?`, [id]);
apiLogger.info({ conversationId: id, labId: lab_id }, 'Conversation bound to lab');
return success(res, conversation);
} catch (err) {
apiLogger.error({ err }, 'Error binding conversation to lab');
return error(res, 'BIND_LAB_ERROR', err.message, 500);
}
});
// GET /labs/:labId/conversations - Get conversations for a lab
router.get('/labs/:labId/conversations', requireAuth, async (req, res) => {
try {
const { labId } = req.params;
const userId = req.user.userId;
const { limit = 500, offset = 0 } = req.query;
const conversations = await dbAll(
isPostgres()
? `SELECT c.* FROM conversations c JOIN conversation_participants cp ON c.id = cp.conversation_id WHERE c.lab_id = $1 AND cp.user_id = $2 ORDER BY c.updated_at DESC LIMIT $3 OFFSET $4`
: `SELECT c.* FROM conversations c JOIN conversation_participants cp ON c.id = cp.conversation_id WHERE c.lab_id = ? AND cp.user_id = ? ORDER BY c.updated_at DESC LIMIT ? OFFSET ?`,
[labId, userId, parseInt(limit), parseInt(offset)]
);
const conversationsWithParticipants = await Promise.all(
conversations.map(async (conv) => {
const participants = await dbAll(
isPostgres()
? `SELECT cp.user_id, cp.role, u.name, u.email, COALESCE(u.avatar, '') as avatar_url FROM conversation_participants cp JOIN users u ON cp.user_id = u.id WHERE cp.conversation_id = $1 ORDER BY cp.joined_at ASC`
: `SELECT cp.user_id, cp.role, u.name, u.email FROM conversation_participants cp JOIN users u ON cp.user_id = u.id WHERE cp.conversation_id = ? ORDER BY cp.joined_at ASC`,
[conv.id]
);
return { ...conv, participants };
})
);
return success(res, conversationsWithParticipants);
} catch (err) {
apiLogger.error({ err }, 'Error getting lab conversations');
return error(res, 'GET_LAB_CONVERSATIONS_ERROR', err.message, 500);
}
});
// PUT /conversations/:id/sub-agents - Update sub_agents
router.put('/conversations/:id/sub-agents', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const { sub_agents } = req.body;
if (!Array.isArray(sub_agents)) return badRequest(res, 'sub_agents must be an array');
let subAgentsJson = '[]';
if (sub_agents.length > 0) {
const validIds = await validateSubAgentRowIds(sub_agents);
if (validIds.length === 0) return badRequest(res, 'None of the provided sub_agents row_ids are valid AI Agents');
const normalizedSubAgents = sub_agents
.map(item => { const rowId = typeof item === 'object' ? item.row_id : item; if (!validIds.includes(rowId)) return null; if (typeof item === 'object') return { row_id: item.row_id, response_mode: item.response_mode || 'always' }; return { row_id: item, response_mode: 'always' }; })
.filter(Boolean);
subAgentsJson = JSON.stringify(normalizedSubAgents);
}
if (isPostgres()) await dbRun(`UPDATE conversations SET sub_agents = $1::jsonb, updated_at = NOW() WHERE id = $2`, [subAgentsJson, id]);
else await dbRun(`UPDATE conversations SET sub_agents = ?, updated_at = datetime('now') WHERE id = ?`, [subAgentsJson, id]);
const enriched = await enrichSubAgents(JSON.parse(subAgentsJson));
apiLogger.info({ conversationId: id, subAgentsCount: enriched.length }, 'Ticket #41053: Updated sub_agents');
return success(res, { sub_agents: enriched });
} catch (err) {
apiLogger.error({ err }, 'Error updating sub_agents');
return error(res, 'UPDATE_SUB_AGENTS_ERROR', err.message, 500);
}
});
// POST /conversations/:id/settings - Save chat settings
router.post('/conversations/:id/settings', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const newSettings = req.body;
const existing = await dbGet(isPostgres() ? `SELECT settings FROM conversations WHERE id = $1` : `SELECT settings FROM conversations WHERE id = ?`, [id]);
let currentSettings = {};
if (existing?.settings) { try { currentSettings = JSON.parse(existing.settings); } catch { /* ignore */ } }
const merged = { ...currentSettings, ...newSettings };
if (isPostgres()) await dbRun(`UPDATE conversations SET settings = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(merged), id]);
else await dbRun(`UPDATE conversations SET settings = ?, updated_at = datetime('now') WHERE id = ?`, [JSON.stringify(merged), id]);
return success(res, { saved: true });
} catch (err) {
apiLogger.error({ err }, 'Error saving settings');
return error(res, 'SAVE_SETTINGS_ERROR', err.message, 500);
}
});
// PATCH /conversations/:id/readonly — ADR-0068 WP-B (B3).
// Toggle channel-style lock. Only the conversation creator OR space owner can
// flip it. Owners always retain write access via sender_kind='space'; non-owners
// see a read-only banner with a deep link to the comment thread (resolved on
// the client via comment_thread_child_id in the list response).
router.patch('/conversations/:id/readonly', requireAuth, async (req, res) => {
const conversationId = Number(req.params.id);
if (!Number.isFinite(conversationId) || conversationId <= 0) {
return badRequest(res, 'invalid conversation id');
}
const desired = req.body?.is_readonly;
if (typeof desired !== 'boolean') {
return badRequest(res, 'is_readonly must be a boolean');
}
const userId = req.user.userId;
try {
const conv = await dbGet(
isPostgres()
? `SELECT c.id, c.created_by, c.space_id, s.owner_id AS space_owner_id
FROM conversations c LEFT JOIN spaces s ON s.id = c.space_id
WHERE c.id = $1`
: `SELECT c.id, c.created_by, c.space_id, s.owner_id AS space_owner_id
FROM conversations c LEFT JOIN spaces s ON s.id = c.space_id
WHERE c.id = ?`,
[conversationId]
);
if (!conv) return notFound(res, 'Conversation not found');
const isCreator = Number(conv.created_by) === Number(userId);
const isSpaceOwner = conv.space_owner_id != null
&& Number(conv.space_owner_id) === Number(userId);
if (!isCreator && !isSpaceOwner) {
return error(res, 'FORBIDDEN', 'Only the conversation creator or space owner can toggle readonly', 403);
}
await dbRun(
isPostgres()
? `UPDATE conversations SET is_readonly = $1, updated_at = NOW() WHERE id = $2`
: `UPDATE conversations SET is_readonly = ?, updated_at = datetime('now') WHERE id = ?`,
[desired, conversationId]
);
apiLogger.info({ conversationId, userId, desired }, 'ADR-0068 WP-B (B3): readonly toggled');
return success(res, { id: conversationId, is_readonly: desired });
} catch (err) {
apiLogger.error({ err, conversationId }, 'ADR-0068 WP-B (B3): readonly toggle failed');
return error(res, 'READONLY_TOGGLE_ERROR', err.message, 500);
}
});
// PATCH /conversations/:id/settings - Update conversation settings
router.patch('/conversations/:id/settings', requireAuth, async (req, res) => {
const conversationId = Number(req.params.id);
if (!conversationId) return badRequest(res, 'Invalid conversation ID');
try {
const conv = await dbGet(isPostgres() ? 'SELECT id, settings FROM conversations WHERE id = $1' : 'SELECT id, settings FROM conversations WHERE id = ?', [conversationId]);
if (!conv) return notFound(res, 'Conversation');
const existingSettings = safeJsonParse(conv.settings, {});
const mergedSettings = { ...existingSettings, ...req.body };
const settingsJson = JSON.stringify(mergedSettings);
await dbRun(isPostgres() ? 'UPDATE conversations SET settings = $1, updated_at = NOW() WHERE id = $2' : "UPDATE conversations SET settings = ?, updated_at = datetime('now') WHERE id = ?", [settingsJson, conversationId]);
return success(res, { settings: mergedSettings });
} catch (err) {
apiLogger.error({ err, conversationId }, 'Error updating conversation settings');
return error(res, 'SETTINGS_UPDATE_ERROR', err.message, 500);
}
});
}