godcrm/backend/services/chat/rowAttachment.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

108 lines
4.2 KiB
JavaScript

/**
* ADR-0068 WP-B — Single source of truth for the `bound_table_id = 0`
* sentinel that means "this conversation/message is bound to another
* CONVERSATION row" (not to a universal-tables row).
*
* Background — every conversation and message carries a
* (`bound_table_id`, `bound_row_id`) pair that points at the CRM row this
* chat is attached to. Until WP-B, `bound_table_id` always referenced a
* universal_tables.id. WP-B introduces a child-of-parent chat (comment
* thread): the child's bound row IS its parent conversation, so it needs
* a way to say "bound to a conversation, not a table row".
*
* Architect-locked rule (chat 2026-05-19): `bound_table_id = 0` is the
* sentinel for that case, no new sentinel registry. Every join /
* row-resolver MUST funnel through `resolveAttachedRow()` here — that way
* if the sentinel ever changes, exactly one place updates.
*
* Use cases today (post-WP-B):
* - Chat list "attached to" chip → fetch parent conversation title to
* render the chip on the child.
* - send_widget_message → resolve table_name/row_title for the
* attachment payload (universal path; sentinel rerouted to
* conversations.title).
*
* Callers should treat the result as best-effort metadata:
* - `{ table_name, row_title }` when the bound entity exists.
* - `null` when missing/deleted (caller decides whether to drop the chip
* or render a tombstone).
*/
import { dbGet } from '../../routes/v3/chat/chatShared.js';
export const CONVERSATIONS_SENTINEL = 0;
// Public alias — readable at call sites that aren't about resolution but about
// stamping the sentinel on outgoing inserts (e.g. POST /comment-thread).
export const BOUND_TABLE_ID_CONVERSATIONS = CONVERSATIONS_SENTINEL;
/**
* Resolve a (bound_table_id, bound_row_id) pair to display metadata.
* Handles the `bound_table_id = 0` sentinel by querying `conversations`
* instead of `universal_tables` + `table_rows`.
*
* @param {object} args
* @param {number|null} args.bound_table_id
* @param {number|null} args.bound_row_id
* @returns {Promise<{table_name: string, row_title: string|null, source: 'conversation'|'universal_table'} | null>}
*/
export async function resolveAttachedRow({ bound_table_id, bound_row_id } = {}) {
const tableId = Number(bound_table_id);
const rowId = Number(bound_row_id);
if (!Number.isFinite(rowId) || rowId <= 0) return null;
if (!Number.isFinite(tableId)) return null;
if (tableId === CONVERSATIONS_SENTINEL) {
const row = await dbGet(
`SELECT id, title, type FROM conversations WHERE id = $1`,
[rowId]
);
if (!row) return null;
return {
table_name: 'conversations',
row_title: row.title || `Conversation #${row.id}`,
source: 'conversation',
};
}
// Universal-table path — keep it minimal here; full row data resolution
// belongs to higher layers that already do JOINs against table_rows.
const ut = await dbGet(
`SELECT name FROM universal_tables WHERE id = $1`,
[tableId]
);
if (!ut) return null;
return {
table_name: ut.name,
row_title: null,
source: 'universal_table',
};
}
/**
* True when bound_table_id addresses a conversation row.
* Cheap predicate so callers can branch without going through dbGet.
* Distinguishes the sentinel (0) from null/undefined — `Number(null)` coerces
* to 0 in JS so we have to reject nullish inputs explicitly.
*/
export function isConversationSentinel(bound_table_id) {
if (bound_table_id == null) return false;
return Number(bound_table_id) === CONVERSATIONS_SENTINEL;
}
/**
* Find the active comment-thread child for a given parent conversation.
* Returns the child's id or null. Archived children (purpose='comments_archived',
* parent_conversation_id NULL after the cascade-archive trigger) are intentionally
* skipped — the find-or-create idempotency only matches live links.
*/
export async function getCommentThreadChildId(parentConversationId) {
const pid = Number(parentConversationId);
if (!Number.isFinite(pid) || pid <= 0) return null;
const row = await dbGet(
`SELECT id FROM conversations
WHERE parent_conversation_id = $1 AND purpose = 'comments'
LIMIT 1`,
[pid]
);
return row?.id || null;
}