godcrm/backend/routes/v3/chat/conversationSummaryController.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

223 lines
8.5 KiB
JavaScript

/**
* GET /api/v3/chat/conversations/:id/summary — ADR-0031 §Z / WP-24
*
* Lightweight read-only metadata endpoint used by the new ChatLinkCard
* component (replaces MovedStubBubble + MovedFromBanner). Returns ONLY
* conversation metadata — no messages, no settings — so the frontend can
* cheaply render link cards in bulk without fanning out to the full
* GET /conversations/:id endpoint (which loads up to 1000 messages).
*
* Response shape:
* {
* id, title, type, // string identity
* participants: [{id,name,avatar}], // capped at 3
* participants_total: <int>, // full count
* message_count: <int>, // non-deleted messages in conversation
* agent: {id,name,icon} | null, // resolved from conversations.agent_id
* bound_row: {table_id,row_id,title} | null,
* icon: string, // from bound_table.icon when present
* deleted: false // (or true with stripped payload)
* }
*
* Soft-delete: conversations table does not (yet) carry an `is_deleted`
* column, so we treat `settings.deleted=true` as the soft-delete signal
* (forward-compatible). When set, response is { id, title, deleted: true }
* with no other fields. A truly missing row → 404.
*
* Auth: caller must be a participant of the conversation. Mirrors the
* access check used by GET /conversations/:id (conversation_participants
* membership only — there is no separate space-membership table in this
* schema; space membership is materialised as participant rows on creation).
*
* Avatar safety (memory: project_users_avatar_base64_mine): users.avatar may
* contain multi-MB base64 data-URLs. Strip any avatar > 2048 chars to null,
* otherwise mobile clients OOM when rendering many cards.
*/
import {
dbGet, dbAll, safeJsonParse, apiLogger,
success, error, notFound, forbidden,
requireAuth,
} from './chatShared.js';
const AVATAR_MAX_BYTES = 2048;
const PARTICIPANTS_PREVIEW_CAP = 3;
function safeAvatar(raw) {
if (raw == null) return null;
if (typeof raw !== 'string') return null;
if (raw.length === 0) return null;
if (raw.length > AVATAR_MAX_BYTES) return null;
return raw;
}
export default function registerConversationSummaryRoutes(router) {
router.get('/conversations/:id/summary', requireAuth, async (req, res) => {
const conversationId = Number(req.params.id);
if (!Number.isFinite(conversationId) || conversationId <= 0) {
return error(res, 'INVALID_ID', 'Invalid conversation id', 400);
}
try {
const userId = req.user.userId;
// 1. Fetch conversation core fields (incl. settings to detect soft-delete)
const conv = await dbGet(
`SELECT id, title, type, settings, agent_id, bound_table_id, bound_row_id, space_id, created_at
FROM conversations WHERE id = $1`,
[conversationId]
);
if (!conv) return notFound(res, 'Conversation not found');
// 2. Soft-delete short-circuit. Read settings (text or jsonb) and look
// for { deleted: true }. When present, return the trimmed payload so
// the frontend renders "Чат удалён".
const settings = safeJsonParse(conv.settings, {}) || {};
if (settings && settings.deleted === true) {
return success(res, {
id: conv.id,
title: conv.title || null,
deleted: true,
});
}
// 3. Access check — participant of the conversation. This mirrors
// GET /conversations/:id (the canonical read-side check). Admins
// aren't auto-granted here; if a use case needs admin override the
// parent route can wrap with that policy.
const participant = await dbGet(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id = $2`,
[conversationId, userId]
);
if (!participant) {
return forbidden(res, 'No access to this conversation');
}
// 4. Participants — cap to first N for the preview, plus full count.
// ORDER BY joined_at to keep the preview stable across calls.
const participantsRaw = await dbAll(
`SELECT cp.user_id AS id, u.name, u.avatar
FROM conversation_participants cp
JOIN users u ON u.id = cp.user_id
WHERE cp.conversation_id = $1
ORDER BY cp.joined_at ASC, cp.user_id ASC
LIMIT $2`,
[conversationId, PARTICIPANTS_PREVIEW_CAP]
);
const participants = participantsRaw.map(p => ({
id: p.id,
name: p.name || null,
avatar: safeAvatar(p.avatar),
}));
const totalRow = await dbGet(
`SELECT COUNT(*)::int AS total
FROM conversation_participants WHERE conversation_id = $1`,
[conversationId]
);
const participants_total = Number(totalRow?.total || 0);
// Message count — non-deleted messages in this conversation. Used by
// <ChatLinkCard> footer ("X участников · Y сообщений").
const msgCountRow = await dbGet(
`SELECT COUNT(*)::int AS total
FROM messages
WHERE conversation_id = $1
AND (is_deleted = 0 OR is_deleted IS NULL)`,
[conversationId]
);
const message_count = Number(msgCountRow?.total || 0);
// Unread count for the caller — mirrors the inbox list endpoint
// (excludes thinking/tool/agent_status/plan internals).
const unreadRow = await dbGet(
`SELECT COUNT(*)::int AS total
FROM messages m
JOIN conversation_participants cp
ON cp.conversation_id = m.conversation_id AND cp.user_id = $2
WHERE m.conversation_id = $1
AND m.sender_id != $2
AND (cp.last_read_at IS NULL OR m.created_at > cp.last_read_at)
AND (m.content_type IS NULL OR m.content_type NOT IN ('tool_call','tool_result','thinking','plan','agent_status'))`,
[conversationId, userId]
);
const unread_count = Number(unreadRow?.total || 0);
// 5. Agent — resolved from conversations.agent_id (a row in table_rows
// of the AI Agents table). icon defaults to null.
let agent = null;
if (conv.agent_id) {
const agentRow = await dbGet(
`SELECT id, data FROM table_rows WHERE id = $1`,
[conv.agent_id]
);
if (agentRow) {
const agentData = safeJsonParse(agentRow.data, {}) || {};
agent = {
id: agentRow.id,
name: agentData.name || null,
icon: agentData.icon || null,
};
}
}
// 6. Bound row — title resolved from common label fields on the row's
// JSONB data column (matches the projection used by the list endpoint).
let bound_row = null;
let icon = null;
if (conv.bound_table_id && conv.bound_row_id) {
const boundRow = await dbGet(
`SELECT
tr.id,
COALESCE(tr.data->>'name', tr.data->>'title', tr.data->>'what',
tr.data->>'subject', tr.data->>'label',
'#' || tr.id::text) AS row_title,
ut.icon AS table_icon,
COALESCE(ut.display_name, ut.name) AS table_name
FROM table_rows tr
LEFT JOIN universal_tables ut ON ut.id = tr.table_id
WHERE tr.table_id = $1 AND tr.id = $2
LIMIT 1`,
[conv.bound_table_id, conv.bound_row_id]
);
if (boundRow) {
bound_row = {
table_id: conv.bound_table_id,
row_id: conv.bound_row_id,
title: boundRow.row_title || null,
table_name: boundRow.table_name || null,
};
icon = boundRow.table_icon || null;
} else {
// Row referenced but missing — surface the binding without a title.
bound_row = {
table_id: conv.bound_table_id,
row_id: conv.bound_row_id,
title: null,
table_name: null,
};
}
}
return success(res, {
id: conv.id,
title: conv.title || null,
type: conv.type || null,
created_at: conv.created_at || null,
participants,
participants_total,
message_count,
unread_count,
agent,
bound_row,
icon,
deleted: false,
});
} catch (err) {
apiLogger.error({ err, conversationId: req.params.id }, 'Error in GET /conversations/:id/summary');
return error(res, 'CONVERSATION_SUMMARY_ERROR', err.message, 500);
}
});
}