godcrm/backend/routes/v3/chat/messageController.js
GOD CRM Release 065f2edd23
Some checks are pending
CI / Lint / Typecheck / Test / Build (push) Waiting to run
CI / PostgreSQL Integration Tests (push) Waiting to run
GOD CRM — public scrubbed snapshot (2026-08-30)
Refresh of the open-core distribution from the private tree.

Included since the previous snapshot:
- Mail module (ADR-158/159/160/169): composer, labels, scheduling,
  attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083
- Crawler-readable SSR for /blog and public spaces (ADR-190):
  blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages
- Registration policy + referral/promo settings (ADR-183/188)
- Message translation + language detection (ADR-185)
- Reddit connector for the agent-tool surface

Excised from the public distribution (unchanged policy): infrastructure
topology and host config, internal ops scripts, DB cleanup snapshots,
business documents, throwaway debug scripts, and two private product
lines (SC-SIM simulator, personal one-off tools). Real host addresses
are replaced with placeholders; credential-shaped literals are redacted.

Frontend build verified green on this tree.
2026-08-30 15:13:28 +03:00

1101 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Message routes: send message, get messages, update/delete messages.
*/
import {
dbRun, dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
success, created, error, badRequest, notFound, forbidden,
requireAuth, getAttachmentBaseUrl, conversationLock,
parseInvocationMentions, parseInvocationCommands,
} from './chatShared.js';
// ADR-0031 P5 / ADR-133 WP-20 — move-with-stub
import { moveMessages, MoveValidationError, MoveAuthError } from '../../../services/messageMoveService.js';
// ADR-0031 P6 — spawn ticket from criterion chat
import { spawnTicketFromCriterion, SpawnValidationError } from '../../../services/criterionTicketSpawnService.js';
import { canAdminister } from '../../../services/EffectiveRoleService.js';
import {
resolveMentionedUser, resolveAgentUser, findAiAgentByCommand,
autoJoinAgentToConversation, resolveAgentInfoForMessages,
} from './chatAgentHelpers.js';
// ADR-0068 WP-B — comment-thread child lookup (single source of truth).
import { getCommentThreadChildId } from '../../../services/chat/rowAttachment.js';
import { executeAgentResponse } from './chatAgentExecution.js';
import { getAutoRespondAgents, shouldAutoRespondWithAI, getDefaultAgentForConversation } from './chatAgentAutoRespond.js';
// ADR-0057-A WP-B — UNION _inflight_runs agent_jobs for the presence array.
// Adds reason/resume_at/paused_at; older agent_jobs-only fields stay populated.
import { queryActiveInflight } from '../../../services/inflight/queryActive.js';
// ADR-185 — backend-authoritative language detection + per-message translation.
import { detectLanguage } from '../../../utils/detectLanguage.js';
import { translateMessageText, isSupportedTargetLang } from '../../../services/translate/translateMessage.js';
// ADR-0031 WP-20+21 (T-141237): content_type whitelist for POST /messages.
// Adds 'widget_embed' so agents/users can embed live mini-widgets in chat.
// Unknown values are rejected with 400 — no DB migration, just an expanded validator.
const ALLOWED_POST_CONTENT_TYPES = new Set([
'text', 'thinking', 'tool_call', 'tool_result', 'tool_approval',
'plan', 'markdown', 'code', 'image', 'agent_status', 'system',
'call', 'row_mutation', 'moved', 'widget_embed', 'widget_row',
]);
// ADR-0068 WP-C — partial-text quote payload. Persisted in messages.metadata.reply_to.
// fragment + range describe a substring of `content`; missing means full-message reply.
// Returns { ok, value, error } so the caller can 400 with a precise message.
function validateReplyTo(rt) {
if (rt == null) return { ok: true, value: null };
if (typeof rt !== 'object' || Array.isArray(rt)) {
return { ok: false, error: 'reply_to must be an object' };
}
const { message_id, sender, content, fragment, range } = rt;
if (typeof message_id !== 'number' || !Number.isFinite(message_id) || message_id <= 0) {
return { ok: false, error: 'reply_to.message_id must be a positive number' };
}
if (typeof sender !== 'string' || sender.length === 0) {
return { ok: false, error: 'reply_to.sender must be a non-empty string' };
}
if (typeof content !== 'string') {
return { ok: false, error: 'reply_to.content must be a string' };
}
const out = { message_id, sender, content };
if (fragment !== undefined) {
if (typeof fragment !== 'string') {
return { ok: false, error: 'reply_to.fragment must be a string' };
}
if (fragment.length > content.length) {
return { ok: false, error: 'reply_to.fragment.length must be <= reply_to.content.length' };
}
out.fragment = fragment;
}
if (range !== undefined) {
if (!Array.isArray(range) || range.length !== 2
|| typeof range[0] !== 'number' || typeof range[1] !== 'number'
|| !Number.isFinite(range[0]) || !Number.isFinite(range[1])) {
return { ok: false, error: 'reply_to.range must be [number, number]' };
}
if (range[0] < 0 || range[1] > content.length || range[0] > range[1]) {
return { ok: false, error: 'reply_to.range is out of bounds' };
}
out.range = [range[0], range[1]];
}
return { ok: true, value: out };
}
export default function registerMessageRoutes(router) {
// POST /conversations/:id/messages - Send message
router.post('/conversations/:id/messages', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.userId;
const {
content, content_type = 'text', mentions = [], parent_id, attachments,
agent_mode, thinking_enabled,
// ADR-0068 WP-B4 — persona override. Default persona is 'user'.
// 'space' = owner posting as the space (sender_user_id retained for audit).
// 'agent' is reserved for server-side agent paths; rejected from REST input.
sender_kind: requestedSenderKind,
sender_space_id: requestedSenderSpaceId,
// ADR-0068 WP-C — reply with optional partial-text quote (fragment + range).
reply_to,
} = req.body;
// ADR-0031 WP-20+21: validate content_type against whitelist.
if (content_type != null && !ALLOWED_POST_CONTENT_TYPES.has(content_type)) {
return badRequest(res, `invalid content_type "${content_type}" — allowed: ${[...ALLOWED_POST_CONTENT_TYPES].join(', ')}`);
}
// ADR-0068 WP-C — validate reply_to shape (out-of-bounds rejected here, not later).
const replyToCheck = validateReplyTo(reply_to);
if (!replyToCheck.ok) {
return badRequest(res, replyToCheck.error);
}
// ADR-0068 WP-B — load the conversation up front for readonly + space
// ownership checks. The original handler only fetched it lazily when no
// participant row existed; moving the read here is a fixed-cost SELECT
// against an indexed PK that we already paid for in most code paths
// (e.g. the space_id read on line ~115).
const conversation = await dbGet(
isPostgres()
? `SELECT id, type, space_id, created_by, is_readonly, parent_conversation_id, purpose FROM conversations WHERE id = $1`
: `SELECT id, type, space_id, created_by, is_readonly, parent_conversation_id, purpose FROM conversations WHERE id = ?`,
[id]
);
if (!conversation) return notFound(res, 'Conversation not found');
// ADR-0068 WP-B4 — validate persona override. Only 'user' (default) and
// 'space' are accepted from REST input; 'agent' is server-side only and
// must come from the agent-execution path, never from a user request.
let senderKind = 'user';
let senderSpaceId = null;
if (requestedSenderKind != null && requestedSenderKind !== 'user') {
if (requestedSenderKind !== 'space') {
return badRequest(res, `invalid sender_kind "${requestedSenderKind}" — only 'user' and 'space' accepted via REST`);
}
const targetSpaceId = Number(requestedSenderSpaceId);
if (!Number.isFinite(targetSpaceId) || targetSpaceId <= 0) {
return badRequest(res, `sender_kind='space' requires a valid sender_space_id`);
}
// Persona scope: the space the caller is posting AS must match the
// conversation's space. Cross-space personas are out of scope for WP-B.
if (conversation.space_id && Number(conversation.space_id) !== targetSpaceId) {
return badRequest(res, `sender_space_id (${targetSpaceId}) must match the conversation's space (${conversation.space_id})`);
}
// Owner gate — caller must be the chat owner OR a space admin (same
// policy as messages/move and spawn-ticket, reusing EffectiveRoleService).
const isChatOwner = Number(conversation.created_by) === Number(userId);
const isSpaceAdmin = conversation.space_id
? await canAdminister(userId, { spaceId: conversation.space_id })
: false;
if (!isChatOwner && !isSpaceAdmin) {
return forbidden(res, 'send-as-space requires chat owner or space admin');
}
senderKind = 'space';
senderSpaceId = targetSpaceId;
}
// ADR-0068 WP-B3 — channel-style readonly guard. Non-owners are blocked
// from sending to a `is_readonly = true` parent. Owners posting as the
// space (validated above) bypass the lock — that's how a "channel"
// broadcaster reaches their audience. Children of a readonly parent
// (comment threads) remain writable regardless of the parent's flag.
if (conversation.is_readonly && !(senderKind === 'space')) {
// Surface the comment-thread child id (if any) so the client can
// redirect the user there instead of just bouncing them.
const childId = await getCommentThreadChildId(id);
return forbidden(res, JSON.stringify({
code: 'READONLY_CONVERSATION',
message: 'This conversation is read-only — non-owners cannot post here.',
comment_thread_child_id: childId,
}));
}
let participant = await dbGet(
isPostgres() ? `SELECT * FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2` : `SELECT * FROM conversation_participants WHERE conversation_id = ? AND user_id = ?`,
[id, userId]
);
if (!participant) {
if (conversation.type === 'task' || conversation.type === 'row') {
if (isPostgres()) await dbRun(`INSERT INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES ($1, $2, 'member', NOW()) ON CONFLICT (conversation_id, user_id) DO NOTHING`, [id, userId]);
else await dbRun(`INSERT OR IGNORE INTO conversation_participants (conversation_id, user_id, role, joined_at) VALUES (?, ?, 'member', datetime('now'))`, [id, userId]);
participant = { conversation_id: id, user_id: userId, role: 'member' };
apiLogger.info({ conversationId: id, userId, type: conversation.type }, 'Ticket #41349: Auto-added user to conversation');
} else {
return notFound(res, 'Conversation not found');
}
}
const mentionsJson = JSON.stringify(mentions);
const attachmentsJson = attachments ? JSON.stringify(attachments) : '[]';
const role = 'user';
const metadata = {};
if (agent_mode) metadata.agent_mode = agent_mode;
if (thinking_enabled !== undefined && thinking_enabled !== null) metadata.thinking_enabled = !!thinking_enabled;
if (replyToCheck.value) metadata.reply_to = replyToCheck.value;
const metadataJson = Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : '{}';
// ADR-185 — detect the original language backend-side (authoritative,
// cannot be spoofed by a client). Written to `detected_lang`; the
// matching content_<lang> slot is left empty (the original already
// carries it in `content`).
const detectedLang = detectLanguage(content);
// ADR-0068 WP-B4 — sender_id ALWAYS the real user (audit trail). Persona
// ships via sender_kind + sender_space_id, never by overwriting sender_id.
let result;
if (isPostgres()) {
result = await dbRun(`INSERT INTO messages (conversation_id, sender_id, role, content, content_type, mentions, attachments, parent_id, metadata, sender_kind, sender_space_id, detected_lang, created_at) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9::jsonb, $10, $11, $12, NOW())`,
[id, userId, role, content, content_type, mentionsJson, attachmentsJson, parent_id || null, metadataJson, senderKind, senderSpaceId, detectedLang]);
} else {
result = await dbRun(`INSERT INTO messages (conversation_id, sender_id, role, content, content_type, mentions, attachments, parent_id, metadata, sender_kind, sender_space_id, detected_lang, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
[id, userId, role, content, content_type, mentionsJson, attachmentsJson, parent_id || null, metadataJson, senderKind, senderSpaceId, detectedLang]);
}
const messageId = result.lastInsertRowid;
if (isPostgres()) await dbRun(`UPDATE conversations SET updated_at = NOW() WHERE id = $1`, [id]);
else await dbRun(`UPDATE conversations SET updated_at = datetime('now') WHERE id = ?`, [id]);
const message = await dbGet(isPostgres() ? `SELECT * FROM messages WHERE id = $1` : `SELECT * FROM messages WHERE id = ?`, [messageId]);
const agentMentions = [...new Set(parseInvocationMentions(content))];
const rawCommands = parseInvocationCommands(content);
// Deduplicate commands by slug (keep first occurrence with its commandIndex)
const seenCommandSlugs = new Set();
const agentCommands = [];
for (const cmd of rawCommands) {
if (!seenCommandSlugs.has(cmd.slug)) {
seenCommandSlugs.add(cmd.slug);
agentCommands.push(cmd);
}
}
// Cross-deduplicate: if same slug is both @mentioned and /commanded, only trigger once (via command)
const commandSlugs = new Set(agentCommands.map(c => c.slug));
const mentionsFiltered = agentMentions.filter(slug => !commandSlugs.has(slug));
const hasExplicitTriggers = agentCommands.length > 0 || mentionsFiltered.length > 0;
apiLogger.info({ conversationId: id, messageId, content: content?.substring(0, 100), agentCommands, agentMentions, hasExplicitTriggers, agent_mode, thinking_enabled },
'Ticket #41834: Single entry point — message saved, determining agents (@ and / separated)');
const parsedAttachments = attachments ? (typeof attachments === 'string' ? JSON.parse(attachments) : attachments) : [];
const attachmentBaseUrl = getAttachmentBaseUrl(req);
const agentOptions = { agent_mode: agent_mode || 'agent', thinking_enabled: !!thinking_enabled, attachments: parsedAttachments, attachmentBaseUrl };
const convForSpaceId = await dbGet(isPostgres() ? `SELECT space_id FROM conversations WHERE id = $1` : `SELECT space_id FROM conversations WHERE id = ?`, [id]);
const _convSpaceId = convForSpaceId?.space_id || null;
if (hasExplicitTriggers) {
(async () => {
let anyAgentResolved = false;
for (const cmd of agentCommands) {
const { slug, commandIndex } = cmd;
apiLogger.info({ slug, commandIndex, trigger: '/' }, 'Ticket #41834: Resolving /command agent');
let agent = await resolveAgentUser(slug, _convSpaceId);
if (!agent) {
agent = await findAiAgentByCommand(slug, _convSpaceId);
}
if (agent) {
anyAgentResolved = true;
if (agent.id) await autoJoinAgentToConversation(Number(id), agent.id, { source: 'command' });
// Resolve message content: use main_instruction from agent config
let commandContent = content;
const agentConfig = agent._agentConfig || {};
const mainInstruction = agentConfig.main_instruction || agentConfig.main_instructions || null;
if (mainInstruction) {
if (commandIndex != null) {
// <</slug/N>> — pick Nth quick command from JSON array
try {
const commands = typeof mainInstruction === 'string' ? JSON.parse(mainInstruction) : mainInstruction;
if (Array.isArray(commands) && commands[commandIndex] != null) {
const picked = commands[commandIndex];
commandContent = typeof picked === 'string' ? picked : (picked.content || picked.text || picked.instruction || JSON.stringify(picked));
apiLogger.info({ slug, commandIndex, picked: commandContent?.substring(0, 100) }, '/command: Using quick command from main_instruction array');
} else {
apiLogger.warn({ slug, commandIndex, isArray: Array.isArray(commands), length: Array.isArray(commands) ? commands.length : 0 }, '/command: Invalid commandIndex — falling back to full main_instruction');
commandContent = typeof mainInstruction === 'string' ? mainInstruction : JSON.stringify(mainInstruction);
}
} catch (parseErr) {
// Not JSON — use as plain string
commandContent = typeof mainInstruction === 'string' ? mainInstruction : String(mainInstruction);
apiLogger.info({ slug, commandIndex }, '/command: main_instruction is not JSON array — using as plain text');
}
} else {
// <</slug>> — use entire main_instruction as message
commandContent = typeof mainInstruction === 'string' ? mainInstruction : JSON.stringify(mainInstruction);
apiLogger.info({ slug, contentPreview: commandContent?.substring(0, 100) }, '/command: Using main_instruction as message content');
}
}
apiLogger.info({ conversationId: id, slug, agentName: agent.name, agentUserId: agent.id, hasMainInstruction: !!mainInstruction, commandIndex }, 'Ticket #41834: Triggering /command agent response');
await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), agent, userId, {
...agentOptions, message_content: commandContent, invocation_type: 'command',
}));
} else {
apiLogger.warn({ slug }, 'Ticket #41834: Agent not found for /command — will fallback to auto-respond if no other agents resolve');
}
}
for (const slug of mentionsFiltered) {
apiLogger.info({ slug, trigger: '@' }, 'Ticket #41834: Resolving @mention user');
const resolved = await resolveMentionedUser(slug);
if (resolved) {
const { user: mentionedUser, isAgent } = resolved;
anyAgentResolved = true;
if (mentionedUser.id) await autoJoinAgentToConversation(Number(id), mentionedUser.id, { source: 'mention' });
if (isAgent) {
apiLogger.info({ conversationId: id, slug, userName: mentionedUser.name, userId: mentionedUser.id, isAgent: true }, 'Ticket #41834: @mentioned agent — triggering response');
await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), mentionedUser, userId, { ...agentOptions, message_content: content, invocation_type: 'mention' }));
} else {
apiLogger.info({ conversationId: id, slug, userName: mentionedUser.name, userId: mentionedUser.id, isAgent: false }, 'Ticket #41834: @mentioned human user — added as participant');
}
} else {
const agent = await resolveAgentUser(slug, _convSpaceId);
if (agent) {
anyAgentResolved = true;
if (agent.id) await autoJoinAgentToConversation(Number(id), agent.id, { source: 'mention' });
apiLogger.info({ conversationId: id, slug, agentName: agent.name }, 'Ticket #41834: @mention fallback to agent resolution');
await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), agent, userId, { ...agentOptions, message_content: content, invocation_type: 'mention' }));
} else {
apiLogger.warn({ slug }, 'Ticket #41834: User not found for @mention');
try {
await dbRun(
isPostgres() ? `INSERT INTO messages (conversation_id, sender_type, role, content, content_type, created_at, updated_at) VALUES ($1, 'system', 'system', $2, 'system', NOW(), NOW())`
: `INSERT INTO messages (conversation_id, sender_type, role, content, content_type, created_at, updated_at) VALUES (?, 'system', 'system', ?, 'system', datetime('now'), datetime('now'))`,
[Number(id), `User '@${slug}' not found. Check the username and try again.`]
);
} catch (msgErr) { apiLogger.error({ err: msgErr }, 'Ticket #41834: Failed to save user-not-found message'); }
}
}
}
if (!anyAgentResolved) {
apiLogger.info({ conversationId: id, agentCommands, agentMentions }, 'Bug fix: No agents resolved from explicit triggers — falling back to auto-respond');
const autoRespondAgents = await getAutoRespondAgents(Number(id), userId, content);
if (autoRespondAgents.length > 0) {
for (const agent of autoRespondAgents) {
await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), agent, userId, { ...agentOptions, message_content: content }));
}
} else {
const autoRespond = await shouldAutoRespondWithAI(Number(id), userId);
if (autoRespond) {
const defaultAgent = await getDefaultAgentForConversation(Number(id));
if (defaultAgent) await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), defaultAgent, userId, { ...agentOptions, message_content: content }));
}
}
}
})().catch(err => { apiLogger.error({ err, conversationId: id }, 'Ticket #41834: Error processing mentions/commands'); });
} else {
(async () => {
const autoRespondAgents = await getAutoRespondAgents(Number(id), userId, content);
if (autoRespondAgents.length > 0) {
apiLogger.info({ conversationId: id, agentCount: autoRespondAgents.length, agentNames: autoRespondAgents.map(a => a.name) }, 'Ticket #41349: Auto-respond agents found via getAutoRespondAgents');
for (const agent of autoRespondAgents) {
await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), agent, userId, { ...agentOptions, message_content: content }));
}
} else {
const autoRespond = await shouldAutoRespondWithAI(Number(id), userId);
if (autoRespond) {
const defaultAgent = await getDefaultAgentForConversation(Number(id));
if (defaultAgent) {
apiLogger.info({ conversationId: id, agentName: defaultAgent.name }, 'Ticket #41349: Solo mode — default agent auto-respond');
await conversationLock.withLock(Number(id), () => executeAgentResponse(Number(id), defaultAgent, userId, { ...agentOptions, message_content: content }));
} else { apiLogger.debug({ conversationId: id }, 'Ticket #41349: Solo mode but no default agent configured'); }
}
}
})().catch(err => { apiLogger.error({ err, conversationId: id }, 'Ticket #41349: Error in auto-respond check'); });
}
return created(res, { ...message, mentions: safeJsonParse(message.mentions) || [], attachments: safeJsonParse(message.attachments) || [], metadata: safeJsonParse(message.metadata) || {} });
} catch (err) {
apiLogger.error('Error sending message:', err);
return error(res, 'SEND_MESSAGE_ERROR', err.message, 500);
}
});
// GET /conversations/:id/messages - Get messages with pagination
// Supports ?content_types=text,plan,tool_approval to exclude heavy tool/thinking messages (lazy loading)
router.get('/conversations/:id/messages', requireAuth, async (req, res) => {
try {
const { id } = req.params;
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 2000);
const before = req.query.before ? parseInt(req.query.before) : null;
let afterId = null, afterTimestamp = null;
if (req.query.after) {
const parsed = parseInt(req.query.after, 10);
if (!isNaN(parsed) && String(parsed) === req.query.after) { afterId = parsed; }
else {
const ts = new Date(req.query.after);
if (!isNaN(ts.getTime())) { afterTimestamp = req.query.after; }
else { return badRequest(res, 'Invalid `after` parameter: must be a message ID (integer) or ISO 8601 timestamp'); }
}
}
// Content type filtering — whitelist specific content_types to reduce payload
// e.g. ?content_types=text,plan,tool_approval (excludes thinking, tool_call, tool_result)
const VALID_CONTENT_TYPES = ['text', 'thinking', 'tool_call', 'tool_result', 'tool_approval', 'plan', 'markdown', 'code', 'image', 'agent_status', 'row_mutation', 'moved'];
let contentTypeFilter = '';
const contentTypesParam = req.query.content_types;
let filteringToolSteps = false;
if (contentTypesParam) {
const types = contentTypesParam.split(',').map(t => t.trim()).filter(t => VALID_CONTENT_TYPES.includes(t));
if (types.length > 0) {
// Include NULL content_type when 'text' is in the filter (legacy messages have NULL)
const includeNull = types.includes('text');
contentTypeFilter = ` AND (m.content_type IN (${types.map(t => `'${t}'`).join(',')})${includeNull ? ' OR m.content_type IS NULL' : ''})`;
filteringToolSteps = !types.includes('tool_call');
}
}
let messagesQuery, messagesParams;
const selectFields = isPostgres()
? `m.*, u.name as sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as sender_avatar, u.user_type as sender_user_type`
: `m.*, u.name as sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as sender_avatar, u.user_type as sender_user_type`;
const fromJoin = `FROM messages m LEFT JOIN users u ON m.sender_id = u.id`;
const deletedFilter = ` AND (m.is_deleted IS NULL OR m.is_deleted = 0)`;
if (afterId && before) {
// Range query: messages between afterId and before (exclusive on both ends)
// Used for lazy-loading tool steps between two known message IDs
messagesQuery = isPostgres()
? `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = $1 AND m.id > $2 AND m.id < $3${deletedFilter}${contentTypeFilter} ORDER BY m.id ASC LIMIT $4`
: `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = ? AND m.id > ? AND m.id < ?${deletedFilter}${contentTypeFilter} ORDER BY m.id ASC LIMIT ?`;
messagesParams = [id, afterId, before, limit];
} else if (afterId) {
messagesQuery = isPostgres()
? `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = $1 AND m.id > $2${deletedFilter}${contentTypeFilter} ORDER BY m.id ASC LIMIT $3`
: `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = ? AND m.id > ?${deletedFilter}${contentTypeFilter} ORDER BY m.id ASC LIMIT ?`;
messagesParams = [id, afterId, limit];
} else if (afterTimestamp) {
messagesQuery = isPostgres()
? `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = $1 AND m.created_at > $2${deletedFilter}${contentTypeFilter} ORDER BY m.created_at ASC, m.id ASC LIMIT $3`
: `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = ? AND m.created_at > ?${deletedFilter}${contentTypeFilter} ORDER BY m.created_at ASC, m.id ASC LIMIT ?`;
messagesParams = [id, afterTimestamp, limit];
} else if (before) {
messagesQuery = isPostgres()
? `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = $1 AND m.id < $2${deletedFilter}${contentTypeFilter} ORDER BY m.id DESC LIMIT $3`
: `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = ? AND m.id < ?${deletedFilter}${contentTypeFilter} ORDER BY m.id DESC LIMIT ?`;
messagesParams = [id, before, limit + 1];
} else {
messagesQuery = isPostgres()
? `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = $1${deletedFilter}${contentTypeFilter} ORDER BY m.id DESC LIMIT $2`
: `SELECT ${selectFields} ${fromJoin} WHERE m.conversation_id = ?${deletedFilter}${contentTypeFilter} ORDER BY m.id DESC LIMIT ?`;
messagesParams = [id, limit + 1];
}
const [rawMessages, conversation] = await Promise.all([
dbAll(messagesQuery, messagesParams).then(rows => {
if (contentTypesParam === 'agent_status') {
apiLogger.info({ conversationId: id, rowCount: rows?.length, query: messagesQuery, params: messagesParams }, 'DEBUG agent_status query result');
}
return rows;
}),
dbGet(isPostgres()
? `SELECT settings, bound_table_id, bound_row_id, is_processing, processing_started_at, processing_agent_id, processing_agent_name, parent_conversation_id, purpose, is_readonly FROM conversations WHERE id = $1`
: `SELECT settings, bound_table_id, bound_row_id, is_processing, processing_started_at, processing_agent_id, processing_agent_name, parent_conversation_id, purpose, is_readonly FROM conversations WHERE id = ?`, [id])
]);
// Auto-clear stale is_processing flags — but only if agent is truly dead.
// Agents (especially Orchestrator) can run for hours, so we check if the
// agent_status message was recently updated (within 5 min) before clearing.
// If agent_status was updated recently, the agent is alive — just long-running.
if (conversation?.is_processing && conversation.processing_started_at) {
const staleMs = Date.now() - new Date(conversation.processing_started_at).getTime();
if (staleMs > 10 * 60 * 1000) {
// Check if agent_status was recently updated (agent still alive)
const recentStatus = await dbGet(isPostgres()
? `SELECT updated_at FROM messages WHERE conversation_id = $1 AND content_type = 'agent_status' AND agent_id = $2 ORDER BY updated_at DESC LIMIT 1`
: `SELECT updated_at FROM messages WHERE conversation_id = ? AND content_type = 'agent_status' AND agent_id = ? ORDER BY updated_at DESC LIMIT 1`,
[id, conversation.processing_agent_id]);
const statusAge = recentStatus?.updated_at
? Date.now() - new Date(recentStatus.updated_at).getTime()
: Infinity;
if (statusAge > 5 * 60 * 1000) {
// Agent hasn't updated status in 5+ minutes AND processing started 10+ min ago → truly dead
await dbRun(isPostgres()
? `UPDATE conversations SET is_processing = false, processing_agent_id = NULL, processing_agent_name = NULL, processing_started_at = NULL WHERE id = $1`
: `UPDATE conversations SET is_processing = false, processing_agent_id = NULL, processing_agent_name = NULL, processing_started_at = NULL WHERE id = ?`, [id]);
conversation.is_processing = false;
conversation.processing_agent_name = null;
} else {
// Agent is still alive — refresh processing_started_at to prevent re-checking every poll
await dbRun(isPostgres()
? `UPDATE conversations SET processing_started_at = NOW() WHERE id = $1`
: `UPDATE conversations SET processing_started_at = datetime('now') WHERE id = ?`, [id]);
}
}
}
let messages, hasMore, nextCursor;
if (afterId || afterTimestamp) {
messages = rawMessages; hasMore = false; nextCursor = null;
} else {
hasMore = rawMessages.length > limit;
messages = hasMore ? rawMessages.slice(0, limit) : rawMessages;
messages.reverse();
nextCursor = hasMore && messages.length > 0 ? messages[0].id : null;
}
const parsed = messages.map(m => ({
...m, mentions: safeJsonParse(m.mentions) || [], attachments: safeJsonParse(m.attachments) || [],
contentType: m.content_type || 'text', senderType: m.sender_type || 'human',
toolResults: m.tool_results ? safeJsonParse(m.tool_results) : null,
metadata: typeof m.metadata === 'string' ? safeJsonParse(m.metadata, {}) : (m.metadata || {}),
parentId: m.parent_id || null, timestamp: m.created_at,
sender_name: m.sender_name || null, sender_avatar: m.sender_avatar || null, sender_user_type: m.sender_user_type || null,
pinned_at: m.pinned_at || null, // ADR-0068 WP-E
}));
// Annotate messages with hidden step counts (split by type: thinking vs tool)
// For each visible message, count hidden thinking/tool steps that belong to
// the same agent turn (between the previous visible message and this one).
// Also provide _hidden_range_start / _hidden_range_end so the frontend knows
// the exact ID boundaries to fetch when lazy-loading steps.
if (filteringToolSteps && parsed.length > 0) {
const minId = parsed[0].id;
const maxId = parsed[parsed.length - 1].id;
// Find the closest non-filtered message BEFORE minId (to catch steps before
// the first visible message) and AFTER maxId (steps after last visible).
// BUG FIX: agent_status and plan are NOT fetched by LAZY_CONTENT_TYPES (text,tool_approval),
// so they must be treated as invisible (like thinking/tool_call/tool_result) when computing
// range bounds. Otherwise, an agent_status message between the last visible text message
// and the agent's steps creates a false boundary, and steps after it are never found.
const boundsSql = isPostgres()
? `SELECT
(SELECT COALESCE(MAX(id), 0) FROM messages WHERE conversation_id = $1 AND id < $2 AND content_type NOT IN ('thinking','tool_call','tool_result','agent_status','plan')) AS prev_visible_id,
(SELECT COALESCE(MIN(id), 0) FROM messages WHERE conversation_id = $1 AND id > $3 AND content_type NOT IN ('thinking','tool_call','tool_result','agent_status','plan')) AS next_visible_id`
: `SELECT
(SELECT COALESCE(MAX(id), 0) FROM messages WHERE conversation_id = ? AND id < ? AND content_type NOT IN ('thinking','tool_call','tool_result','agent_status','plan')) AS prev_visible_id,
(SELECT COALESCE(MIN(id), 0) FROM messages WHERE conversation_id = ? AND id > ? AND content_type NOT IN ('thinking','tool_call','tool_result','agent_status','plan')) AS next_visible_id`;
const boundsParams = isPostgres() ? [id, minId, maxId] : [id, minId, id, maxId];
const bounds = await dbGet(boundsSql, boundsParams);
const rangeStart = bounds?.prev_visible_id || 0; // 0 means no previous visible message
const rangeEnd = bounds?.next_visible_id || 0; // 0 means no next visible message
// Fetch hidden steps in the expanded range with content preview for thinking
const expandedMin = rangeStart > 0 ? rangeStart : 0;
// When no next visible message exists (agent still working), fetch ALL hidden steps after last visible
const expandedMax = rangeEnd > 0 ? rangeEnd : 2147483647;
// ADR-129 WP-B6: Extend preview to ALL content types (was thinking-only, hiding tool_call/tool_result previews)
const hiddenStepsSql = isPostgres()
? `SELECT id, content_type, agent_id, LEFT(content, 150) AS preview, metadata->>'agent_name' AS agent_name, metadata->>'agent_icon' AS agent_icon, metadata->>'agent_color' AS agent_color FROM messages WHERE conversation_id = $1 AND content_type IN ('thinking', 'tool_call', 'tool_result') AND id > $2 AND id < $3 AND (is_deleted = 0 OR is_deleted IS NULL) ORDER BY id`
: `SELECT id, content_type, agent_id, SUBSTR(content, 1, 150) AS preview, json_extract(metadata, '$.agent_name') AS agent_name, json_extract(metadata, '$.agent_icon') AS agent_icon, json_extract(metadata, '$.agent_color') AS agent_color FROM messages WHERE conversation_id = ? AND content_type IN ('thinking', 'tool_call', 'tool_result') AND id > ? AND id < ? AND (is_deleted = 0 OR is_deleted IS NULL) ORDER BY id`;
const hiddenSteps = await dbAll(hiddenStepsSql, [id, expandedMin, expandedMax]);
// Build chronological step groups between visible messages
// Each group: { type: 'thinking'|'tools', count, first_id, last_id, preview?, agent_name?, agent_icon?, agent_color? }
const buildStepGroups = (steps) => {
if (steps.length === 0) return [];
const groups = [];
let currentType = null; // 'thinking' or 'tools'
let currentGroup = null;
for (const step of steps) {
const stepType = step.content_type === 'thinking' ? 'thinking' : 'tools';
if (stepType !== currentType) {
if (currentGroup) groups.push(currentGroup);
currentGroup = {
type: stepType,
count: 1,
first_id: step.id,
last_id: step.id,
};
if (stepType === 'thinking' && step.preview) {
currentGroup.preview = step.preview;
}
// Carry agent info from first step of the group
if (step.agent_name) currentGroup.agent_name = step.agent_name;
if (step.agent_icon) currentGroup.agent_icon = step.agent_icon;
if (step.agent_color) currentGroup.agent_color = step.agent_color;
currentType = stepType;
} else {
currentGroup.count++;
currentGroup.last_id = step.id;
// For thinking, concatenate previews (up to first few)
if (stepType === 'thinking' && step.preview && currentGroup.count <= 3) {
currentGroup.preview = (currentGroup.preview || '') + '\n' + step.preview;
}
}
}
if (currentGroup) groups.push(currentGroup);
return groups;
};
if (hiddenSteps.length > 0) {
// For each visible message, build step groups between previous visible and this one.
// FIX: If the current message is a user message but the previous is an agent message,
// attach the step groups as _step_groups_after on the agent message instead of
// _step_groups_before on the user message. TurnBody only processes step groups for
// agent turns, so annotations on user messages were silently ignored — causing
// "agent steps without final message" to disappear.
for (let i = 0; i < parsed.length; i++) {
const currentId = parsed[i].id;
const prevId = i > 0 ? parsed[i - 1].id : rangeStart;
const between = hiddenSteps.filter(h => h.id > prevId && h.id < currentId);
if (between.length > 0) {
const currentIsUser = parsed[i].role === 'user';
if (currentIsUser) {
// Only attach to the IMMEDIATELY preceding visible message if it's an agent message.
// Previously we searched all the way back, which could attach steps to an agent
// from a completely different turn — causing the steps to be lost and consecutive
// user messages to be incorrectly merged (no _step_groups annotations between them).
if (i > 0 && parsed[i - 1].role === 'assistant') {
const agentMsg = parsed[i - 1];
const existingGroups = agentMsg._step_groups_after || [];
agentMsg._step_groups_after = [...existingGroups, ...buildStepGroups(between)];
agentMsg._total_hidden_after = (agentMsg._total_hidden_after || 0) + between.length;
} else {
// Previous visible message is user or doesn't exist — attach to current user message
parsed[i]._step_groups_before = buildStepGroups(between);
parsed[i]._total_hidden_before = between.length;
}
} else {
parsed[i]._step_groups_before = buildStepGroups(between);
parsed[i]._total_hidden_before = between.length;
}
}
}
// Groups after the last visible message
const lastVisibleId = parsed[parsed.length - 1].id;
const afterLast = hiddenSteps.filter(h => h.id > lastVisibleId);
if (afterLast.length > 0) {
parsed[parsed.length - 1]._step_groups_after = buildStepGroups(afterLast);
parsed[parsed.length - 1]._total_hidden_after = afterLast.length;
}
}
}
// Truncate content for tool_result messages when truncate_content param is set
// Used for L3 progressive loading: show tool calls with preview of results
const truncateContent = parseInt(req.query.truncate_content) || 0;
if (truncateContent > 0) {
for (const msg of parsed) {
if (msg.content_type === 'tool_result' && msg.content && msg.content.length > truncateContent) {
msg._full_length = msg.content.length;
msg.content = msg.content.substring(0, truncateContent);
msg._truncated = true;
}
}
}
const resolvedMessages = await resolveAgentInfoForMessages(parsed);
// Context stats
const contextStatsSql = isPostgres()
? `SELECT COALESCE(SUM(tokens_in),0)::int as total_tokens_in, COALESCE(SUM(tokens_out),0)::int as total_tokens_out, COUNT(*)::int as total_messages, COUNT(CASE WHEN content_type='text' OR content_type IS NULL THEN 1 END)::int as text_messages, COUNT(CASE WHEN content_type='tool_call' THEN 1 END)::int as tool_calls, COUNT(CASE WHEN content_type='thinking' THEN 1 END)::int as thinking_steps FROM messages WHERE conversation_id=$1`
: `SELECT COALESCE(SUM(tokens_in),0) as total_tokens_in, COALESCE(SUM(tokens_out),0) as total_tokens_out, COUNT(*) as total_messages, COUNT(CASE WHEN content_type='text' OR content_type IS NULL THEN 1 END) as text_messages, COUNT(CASE WHEN content_type='tool_call' THEN 1 END) as tool_calls, COUNT(CASE WHEN content_type='thinking' THEN 1 END) as thinking_steps FROM messages WHERE conversation_id=?`;
const lastAgentMsgSql = isPostgres()
? `SELECT tokens_in, model_used FROM messages WHERE conversation_id=$1 AND tokens_in>0 AND model_used IS NOT NULL ORDER BY id DESC LIMIT 1`
: `SELECT tokens_in, model_used FROM messages WHERE conversation_id=? AND tokens_in>0 AND model_used IS NOT NULL ORDER BY id DESC LIMIT 1`;
const [contextStatsRow, lastAgentMsg] = await Promise.all([dbGet(contextStatsSql, [id]), dbGet(lastAgentMsgSql, [id])]);
const MODEL_CONTEXT_WINDOWS = { 'claude-3-5-sonnet': 200000, 'claude-3.5-sonnet': 200000, 'claude-sonnet-4': 200000, 'claude-4-sonnet': 200000, 'claude-opus-4': 200000, 'claude-4-opus': 200000, 'claude-3-opus': 200000, 'claude-3-haiku': 200000, 'claude-3.5-haiku': 200000, 'gpt-4o': 128000, 'gpt-4o-mini': 128000, 'gpt-4-turbo': 128000, 'gpt-4': 8192, 'o1': 200000, 'o3': 200000, 'o3-mini': 200000, 'o4-mini': 200000 };
const modelUsed = lastAgentMsg?.model_used || null;
let contextWindow = null;
if (modelUsed) {
const modelLower = modelUsed.toLowerCase();
contextWindow = MODEL_CONTEXT_WINDOWS[modelLower] || null;
if (!contextWindow) { for (const [key, value] of Object.entries(MODEL_CONTEXT_WINDOWS)) { if (modelLower.startsWith(key) || modelLower.includes(key)) { contextWindow = value; break; } } }
if (!contextWindow) contextWindow = 200000;
}
const lastPromptTokens = lastAgentMsg?.tokens_in || 0;
const contextUsagePercent = contextWindow && lastPromptTokens > 0 ? Math.round((lastPromptTokens / contextWindow) * 100) : null;
const context_stats = {
total_tokens_in: Number(contextStatsRow?.total_tokens_in) || 0, total_tokens_out: Number(contextStatsRow?.total_tokens_out) || 0,
total_messages: Number(contextStatsRow?.total_messages) || 0, text_messages: Number(contextStatsRow?.text_messages) || 0,
tool_calls: Number(contextStatsRow?.tool_calls) || 0, thinking_steps: Number(contextStatsRow?.thinking_steps) || 0,
last_prompt_tokens: lastPromptTokens, model_used: modelUsed, context_window: contextWindow, context_usage_percent: contextUsagePercent,
};
// ADR-0057 WP-C + ADR-0057-A WP-B: source-of-truth presence query.
// Every agent currently bound to this conversation maps to one badge.
// Frontend reads this array and ignores the legacy is_processing /
// processing_agent_* scalars. WP-B widens the source from agent_jobs
// alone to UNION(_inflight_runs agent_jobs) so paused runs surface
// with reason/resume_at/paused_at. See queryActive.js for the SQL.
let active_agents = [];
try {
active_agents = await queryActiveInflight(id);
} catch (activeErr) {
apiLogger.warn({ err: activeErr.message, conversationId: id }, 'ADR-0057-A WP-B: active_agents query failed (non-fatal)');
}
// Always include active agent_status + plan in response (even for incremental polls).
// This eliminates the need for separate frontend fetches and prevents race conditions.
let active_agent_status = null;
let active_plan = null;
try {
const statusRow = await dbGet(isPostgres()
? `SELECT m.*, u.name as sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as sender_avatar, u.user_type as sender_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = $1 AND m.content_type = 'agent_status' AND (m.is_deleted = 0 OR m.is_deleted IS NULL) ORDER BY m.id DESC LIMIT 1`
: `SELECT m.*, u.name as sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as sender_avatar, u.user_type as sender_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = ? AND m.content_type = 'agent_status' AND (m.is_deleted = 0 OR m.is_deleted IS NULL) ORDER BY m.id DESC LIMIT 1`,
[id]);
if (statusRow) {
const meta = typeof statusRow.metadata === 'string' ? safeJsonParse(statusRow.metadata, {}) : (statusRow.metadata || {});
if (meta.placeholder === true && meta.agent_status !== 'finished' && meta.agent_status !== 'error') {
active_agent_status = {
...statusRow,
contentType: 'agent_status', senderType: statusRow.sender_type || 'agent',
metadata: meta, timestamp: statusRow.created_at,
mentions: safeJsonParse(statusRow.mentions) || [], attachments: safeJsonParse(statusRow.attachments) || [],
};
}
}
const planRow = await dbGet(isPostgres()
? `SELECT m.*, u.name as sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as sender_avatar, u.user_type as sender_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = $1 AND m.content_type = 'plan' AND (m.is_deleted = 0 OR m.is_deleted IS NULL) ORDER BY m.id DESC LIMIT 1`
: `SELECT m.*, u.name as sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as sender_avatar, u.user_type as sender_user_type FROM messages m LEFT JOIN users u ON m.sender_id = u.id WHERE m.conversation_id = ? AND m.content_type = 'plan' AND (m.is_deleted = 0 OR m.is_deleted IS NULL) ORDER BY m.id DESC LIMIT 1`,
[id]);
if (planRow) {
const planMeta = typeof planRow.metadata === 'string' ? safeJsonParse(planRow.metadata, {}) : (planRow.metadata || {});
const planAge = planRow.updated_at ? Date.now() - new Date(planRow.updated_at).getTime() : Infinity;
if (planAge < 2 * 60 * 60 * 1000) {
active_plan = {
...planRow,
contentType: 'plan', senderType: planRow.sender_type || 'agent',
metadata: planMeta, timestamp: planRow.created_at,
mentions: safeJsonParse(planRow.mentions) || [], attachments: safeJsonParse(planRow.attachments) || [],
};
}
}
} catch (liveErr) {
apiLogger.warn({ err: liveErr.message, conversationId: id }, 'Failed to fetch active agent_status/plan (non-fatal)');
}
// ADR-0068 WP-B — surface child id when on a parent so clients can
// render the chip without a second round-trip.
let comment_thread_child_id = null;
try {
comment_thread_child_id = await getCommentThreadChildId(id);
} catch (_) { /* non-fatal */ }
return success(res, {
messages: resolvedMessages, hasMore, nextCursor,
settings: safeJsonParse(conversation?.settings) || {},
bound_table_id: conversation?.bound_table_id || null, bound_row_id: conversation?.bound_row_id || null,
// ADR-0068 WP-B — parent/child/readonly state for client banner + chip.
parent_conversation_id: conversation?.parent_conversation_id || null,
purpose: conversation?.purpose || null,
is_readonly: !!conversation?.is_readonly,
comment_thread_child_id,
is_processing: conversation?.is_processing ? true : false, processing_started_at: conversation?.processing_started_at || null,
processing_agent_id: conversation?.processing_agent_id || null, processing_agent_name: conversation?.processing_agent_name || null,
// ADR-0057 WP-C: source-of-truth presence array. Frontend uses this and
// ignores the legacy scalar fields above (kept for backward compat).
active_agents,
active_agent_status, active_plan,
context_stats,
});
} catch (err) {
apiLogger.error({ err }, 'Error getting messages');
return error(res, 'GET_MESSAGES_ERROR', err.message, 500);
}
});
// GET /conversations/:id/messages/by-ids?ids=1,2,3 — ADR-0031 §Z / WP-24
//
// Lightweight batch read for the ChatLinkCard inline-expand preview. The
// source-side card knows the target conversation id + the list of message
// ids that landed there (metadata.moved_to.message_ids). When the user
// clicks the chevron we fetch ONLY those ids — much cheaper than loading
// the entire target conversation.
//
// Auth: caller must be a participant of the conversation. Mirrors the
// summary endpoint policy.
router.get('/conversations/:id/messages/by-ids', requireAuth, async (req, res) => {
try {
const conversationId = Number(req.params.id);
if (!Number.isFinite(conversationId) || conversationId <= 0) {
return badRequest(res, 'Invalid conversation id');
}
const idsParam = String(req.query.ids || '').trim();
if (!idsParam) return badRequest(res, '`ids` query param is required');
const ids = idsParam.split(',')
.map(s => parseInt(s, 10))
.filter(n => Number.isFinite(n) && n > 0)
.slice(0, 50);
if (ids.length === 0) return badRequest(res, '`ids` must contain at least one positive integer');
const userId = req.user.userId;
const participant = await dbGet(
isPostgres()
? `SELECT user_id FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`
: `SELECT user_id FROM conversation_participants WHERE conversation_id = ? AND user_id = ?`,
[conversationId, userId]
);
if (!participant) return forbidden(res, 'No access to this conversation');
const rows = await dbAll(
isPostgres()
? `SELECT m.id, m.sender_id, m.sender_type, m.role, m.content, m.content_type,
m.attachments, m.metadata, m.created_at,
u.name AS sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END AS sender_avatar, u.user_type AS sender_user_type
FROM messages m
LEFT JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = $1
AND m.id = ANY($2::int[])
AND (m.is_deleted = 0 OR m.is_deleted IS NULL)
ORDER BY m.id ASC`
: `SELECT m.id, m.sender_id, m.sender_type, m.role, m.content, m.content_type,
m.attachments, m.metadata, m.created_at,
u.name AS sender_name, CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END AS sender_avatar, u.user_type AS sender_user_type
FROM messages m
LEFT JOIN users u ON u.id = m.sender_id
WHERE m.conversation_id = ?
AND m.id IN (${ids.map(() => '?').join(',')})
AND (m.is_deleted = 0 OR m.is_deleted IS NULL)
ORDER BY m.id ASC`,
isPostgres() ? [conversationId, ids] : [conversationId, ...ids]
);
const AVATAR_MAX_BYTES = 2048;
const messages = rows.map(r => ({
id: r.id,
sender_id: r.sender_id,
sender_type: r.sender_type || null,
role: r.role || null,
content: r.content,
content_type: r.content_type || null,
attachments: safeJsonParse(r.attachments) || [],
metadata: safeJsonParse(r.metadata) || {},
created_at: r.created_at,
sender_name: r.sender_name || null,
sender_avatar: typeof r.sender_avatar === 'string'
&& r.sender_avatar.length > 0
&& r.sender_avatar.length <= AVATAR_MAX_BYTES
? r.sender_avatar : null,
sender_user_type: r.sender_user_type || null,
}));
return success(res, { messages });
} catch (err) {
apiLogger.error({ err, conversationId: req.params.id }, 'Error in GET /conversations/:id/messages/by-ids');
return error(res, 'GET_MESSAGES_BY_IDS_ERROR', err.message, 500);
}
});
// GET /messages/:messageId/full - Get full content of a single message (L4 lazy load)
router.get('/messages/:messageId/full', requireAuth, async (req, res) => {
try {
const { messageId } = req.params;
const message = await dbGet(
isPostgres()
? `SELECT id, content, content_type FROM messages WHERE id = $1`
: `SELECT id, content, content_type FROM messages WHERE id = ?`,
[messageId]
);
if (!message) return notFound(res, 'Message not found');
return success(res, { id: message.id, content: message.content, content_type: message.content_type });
} catch (err) {
apiLogger.error({ err }, 'Error getting full message');
return error(res, 'GET_FULL_MESSAGE_ERROR', err.message, 500);
}
});
// ADR-185 — POST /messages/:messageId/translate { target_lang, force? }
// Translate one message into target_lang via the conversation's agent and
// persist it on the message row (content_<lang> + translation_meta). Shared:
// "one user translates, everyone on that language reads it" — the result
// lives on the message, so any client with myLang === target_lang reads it.
// Idempotent: an existing translation is returned without re-calling the
// agent unless `force` is passed.
router.post('/messages/:messageId/translate', requireAuth, async (req, res) => {
try {
const { messageId } = req.params;
const { target_lang, force = false } = req.body || {};
// Whitelist the target: this also makes the `content_<lang>` column name
// safe to interpolate (only the 15 known codes ever reach the SQL).
if (!isSupportedTargetLang(target_lang)) {
return badRequest(res, `invalid target_lang "${target_lang}" — must be one of the supported language codes`);
}
const col = `content_${target_lang}`;
const message = await dbGet(
isPostgres()
? `SELECT id, conversation_id, content, detected_lang, translation_meta, ${col} AS existing FROM messages WHERE id = $1`
: `SELECT id, conversation_id, content, detected_lang, translation_meta, ${col} AS existing FROM messages WHERE id = ?`,
[messageId]
);
if (!message) return notFound(res, 'Message not found');
const priorMeta = (typeof message.translation_meta === 'string'
? safeJsonParse(message.translation_meta, {})
: (message.translation_meta || {})) || {};
// Idempotent short-circuit — someone already translated this into target_lang.
if (message.existing && !force) {
return success(res, {
id: message.id,
conversation_id: message.conversation_id,
target_lang,
detected_lang: message.detected_lang || null,
[col]: message.existing,
translation_meta: priorMeta,
cached: true,
});
}
// No point translating into the language the original is already in.
if (message.detected_lang && message.detected_lang === target_lang) {
return badRequest(res, `message is already in "${target_lang}" (its original language)`);
}
const result = await translateMessageText({
conversationId: message.conversation_id,
sourceText: message.content,
targetLang: target_lang,
});
if (!result.success) {
// no_agent → 422 (configuration gap the caller must fix); everything
// else is an upstream/model failure → 502.
const status = result.error === 'no_agent' ? 422 : (result.error === 'empty_source' ? 400 : 502);
return error(res, `TRANSLATE_${(result.error || 'ERROR').toUpperCase()}`, result.message, status);
}
const nextMeta = {
...priorMeta,
[target_lang]: {
by: result.agent.row_id || null,
actor_type: 'agent',
agent_id: result.agent.row_id || null,
agent_name: result.agent.name || null,
requested_by: req.user.userId,
at: new Date().toISOString(),
},
};
const nextMetaJson = JSON.stringify(nextMeta);
if (isPostgres()) {
await dbRun(
`UPDATE messages SET ${col} = $1, translation_meta = $2::jsonb, updated_at = NOW() WHERE id = $3`,
[result.text, nextMetaJson, messageId]
);
} else {
await dbRun(
`UPDATE messages SET ${col} = ?, translation_meta = ?, updated_at = datetime('now') WHERE id = ?`,
[result.text, nextMetaJson, messageId]
);
}
return success(res, {
id: message.id,
conversation_id: message.conversation_id,
target_lang,
detected_lang: message.detected_lang || null,
[col]: result.text,
translation_meta: nextMeta,
cached: false,
});
} catch (err) {
apiLogger.error({ err, messageId: req.params.messageId }, 'ADR-185: translate message failed');
return error(res, 'TRANSLATE_MESSAGE_ERROR', err.message, 500);
}
});
// PATCH /messages/:messageId/content - Update message content (checkbox toggle)
router.patch('/messages/:messageId/content', requireAuth, async (req, res) => {
try {
const { messageId } = req.params;
const { content } = req.body;
if (!content || typeof content !== 'string') return badRequest(res, 'content is required and must be a string');
const message = await dbGet(isPostgres() ? 'SELECT id, conversation_id, sender_id, content, is_deleted FROM messages WHERE id = $1' : 'SELECT id, conversation_id, sender_id, content, is_deleted FROM messages WHERE id = ?', [messageId]);
if (!message) return notFound(res, 'Message not found');
if (message.is_deleted) return badRequest(res, 'Cannot update a deleted message');
// ADR-0068 WP-B4 — edit only by the real actor. sender_id retains the
// user who authored the message even when sender_kind='space', so this
// is the right column to gate on; persona doesn't transfer edit rights.
const callerId = req.user.userId || req.user.id;
if (Number(message.sender_id) !== Number(callerId)) {
return forbidden(res, 'You can only edit your own messages');
}
await dbRun(isPostgres() ? `UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2` : `UPDATE messages SET content = ?, updated_at = datetime('now') WHERE id = ?`, [content, messageId]);
apiLogger.info({ messageId, conversationId: message.conversation_id }, 'Message content updated (checkbox toggle)');
return success(res, { updated: true, message_id: Number(messageId) });
} catch (err) {
apiLogger.error('Error updating message content:', err);
return error(res, 'UPDATE_MESSAGE_CONTENT_ERROR', err.message, 500);
}
});
// POST /conversations/:id/messages/move — ADR-0031 P5 / ADR-133 WP-20.
// Move messages out of one chat to another, leaving stub-pointers in the source.
// Implementation lives in services/messageMoveService.js so it can be reused (e.g.
// by ADR-0031 P6 criterion → ticket continuation).
//
// Auth (ADR-0031 WP-24): caller must be the chat owner (conversations.created_by)
// OR hold admin-or-higher within the source chat's space (per EffectiveRoleService
// inheritance — owner_owner/owner/admin all qualify). Global users.role is NOT
// honored — admin is space-scoped (privilege bug fix; see WP-24).
router.post('/conversations/:id/messages/move', requireAuth, async (req, res) => {
try {
const sourceId = Number(req.params.id);
const { target_conversation_id, message_ids } = req.body || {};
const conv = await dbGet(
isPostgres()
? `SELECT created_by, space_id FROM conversations WHERE id = $1`
: `SELECT created_by, space_id FROM conversations WHERE id = ?`,
[sourceId]
);
if (!conv) return notFound(res, 'conversation not found');
const callerId = Number(req.user.userId);
const isChatOwner = Number(conv.created_by) === callerId;
const isSpaceAdmin = conv.space_id
? await canAdminister(callerId, { spaceId: conv.space_id })
: false;
if (!isChatOwner && !isSpaceAdmin) {
return forbidden(res, 'only the chat owner or a space admin can move messages out of this conversation');
}
const result = await moveMessages({
sourceConversationId: sourceId,
targetConversationId: target_conversation_id,
messageIds: message_ids,
userId: callerId,
actorIsChatOwner: isChatOwner || isSpaceAdmin,
});
return success(res, {
source_conversation_id: sourceId,
target_conversation_id: Number(target_conversation_id),
source_message_ids: result.source_message_ids,
target_message_ids: result.target_message_ids,
moved_count: result.moved_count,
});
} catch (err) {
if (err instanceof MoveValidationError) return badRequest(res, err.message);
if (err instanceof MoveAuthError) return forbidden(res, err.message);
apiLogger.error({ err }, 'ADR-0031 P5: move messages failed');
return error(res, 'MOVE_MESSAGES_ERROR', err.message, 500);
}
});
// POST /conversations/:id/spawn-ticket — ADR-0031 P6.
// Spawn a ticket from this conversation (typically a BDD-criterion chat),
// create the ticket's row-chat, and move the discussion into it leaving
// stubs in the source per move-with-stub (P5).
//
// Auth (ADR-0031 WP-24): same gate as /messages/move — chat owner OR
// space-scoped admin (per EffectiveRoleService); global users.role is NOT
// honored.
router.post('/conversations/:id/spawn-ticket', requireAuth, async (req, res) => {
try {
const sourceId = Number(req.params.id);
const { ticket_data, message_ids } = req.body || {};
const conv = await dbGet(
isPostgres()
? `SELECT created_by, space_id FROM conversations WHERE id = $1`
: `SELECT created_by, space_id FROM conversations WHERE id = ?`,
[sourceId]
);
if (!conv) return notFound(res, 'conversation not found');
const callerId = Number(req.user.userId);
const isChatOwner = Number(conv.created_by) === callerId;
const isSpaceAdmin = conv.space_id
? await canAdminister(callerId, { spaceId: conv.space_id })
: false;
if (!isChatOwner && !isSpaceAdmin) {
return forbidden(res, 'only the chat owner or a space admin can spawn a ticket from this conversation');
}
const result = await spawnTicketFromCriterion({
sourceConversationId: sourceId,
ticketData: ticket_data,
messageIds: message_ids,
userId: callerId,
actorIsChatOwner: isChatOwner || isSpaceAdmin,
});
return success(res, result);
} catch (err) {
if (err instanceof SpawnValidationError) return badRequest(res, err.message);
if (err instanceof MoveValidationError) return badRequest(res, err.message);
if (err instanceof MoveAuthError) return forbidden(res, err.message);
apiLogger.error({ err }, 'ADR-0031 P6: spawn ticket from criterion failed');
return error(res, 'SPAWN_TICKET_ERROR', err.message, 500);
}
});
// DELETE /messages/:messageId - Soft delete message
router.delete('/messages/:messageId', requireAuth, async (req, res) => {
try {
const { messageId } = req.params;
const userId = req.user.id;
const message = await dbGet(isPostgres() ? 'SELECT id, sender_id, is_deleted FROM messages WHERE id = $1' : 'SELECT id, sender_id, is_deleted FROM messages WHERE id = ?', [messageId]);
if (!message) return notFound(res, 'Message not found');
if (Number(message.sender_id) !== Number(userId)) return forbidden(res, 'You can only delete your own messages');
if (message.is_deleted) return success(res, { already_deleted: true });
await dbRun(isPostgres() ? `UPDATE messages SET is_deleted = 1, updated_at = NOW() WHERE id = $1` : `UPDATE messages SET is_deleted = 1, updated_at = datetime('now') WHERE id = ?`, [messageId]);
apiLogger.info({ messageId, userId }, 'Message soft deleted');
return success(res, { deleted: true, message_id: messageId });
} catch (err) {
apiLogger.error('Error deleting message:', err);
return error(res, 'DELETE_MESSAGE_ERROR', err.message, 500);
}
});
}