comms

Polymorphic conversation threads and messages for any entity

Comms

Conversation threads and messages that attach to any entity (contact, company, deal, ticket, task, or project) via a polymorphic relationship.

Tables

threads

ColumnTypeDescription
iduuidPrimary key, auto-generated
org_iduuidReferences organizations. CASCADE on delete
entity_typetextWhat this thread attaches to. Checked against allowed values
entity_iduuidID of the related entity. No FK enforced for polymorphism
subjecttextOptional thread subject line
channelchannel_typeCommunication channel (email, sms, etc.)
is_closedbooleanWhether the thread is closed, defaults to false
created_attimestamptzRow creation time
updated_attimestamptzAuto-updated on change via trigger
metadatajsonbFreeform JSON, defaults to empty object

Allowed entity_type values: contact, company, deal, ticket, task, project.

messages

ColumnTypeDescription
iduuidPrimary key, auto-generated
org_iduuidReferences organizations. CASCADE on delete
thread_iduuidReferences threads. CASCADE on delete
author_iduuidReferences users. SET NULL on delete
contact_iduuidReferences contacts. SET NULL on delete
directionmessage_directionInbound, outbound, or internal. Defaults to 'internal'
bodytextPlain text message body (required)
html_bodytextRich HTML version of the message body
external_idtextID from external system, e.g. email Message-ID
sent_attimestamptzWhen the message was sent, defaults to now()
created_attimestamptzRow creation time
updated_attimestamptzAuto-updated on change via trigger
metadatajsonbFreeform JSON, defaults to empty object

Enums

message_direction

ValueDescription
inboundMessage received from an external party
outboundMessage sent to an external party
internalInternal note or comment

channel_type

Shared with the support skill. Defined with a guard (CREATE TYPE IF NOT EXISTS pattern) so it works whether or not support is installed.

ValueDescription
emailEmail channel
smsSMS / text message
chatLive chat or messaging
phonePhone call
socialSocial media

Row-Level Security

Both tables have RLS enabled and scoped to org_id via get_user_org_id(). SELECT, INSERT, and UPDATE are open to all org members. DELETE requires admin privileges via is_admin().

Dependencies

  • crm (organizations, users, contacts)

Example Queries

-- Full conversation history for a contact
SELECT
  th.entity_type,
  th.entity_id,
  m.direction,
  coalesce(u.full_name, c.first_name || ' ' || coalesce(c.last_name, '')) AS sender,
  m.body,
  m.sent_at
FROM threads th
JOIN messages m ON m.thread_id = th.id
LEFT JOIN users u ON u.id = m.author_id
LEFT JOIN contacts c ON c.id = m.contact_id
WHERE th.entity_type = 'contact' AND th.entity_id = '<contact_id>'
ORDER BY m.sent_at ASC;
-- Recent inbound messages across all threads
SELECT
  th.entity_type,
  th.entity_id,
  th.subject,
  th.channel,
  m.body,
  m.sent_at,
  c.first_name || ' ' || coalesce(c.last_name, '') AS from_contact
FROM messages m
JOIN threads th ON th.id = m.thread_id
LEFT JOIN contacts c ON c.id = m.contact_id
WHERE m.direction = 'inbound'
ORDER BY m.sent_at DESC
LIMIT 50;
-- Threads with no reply (inbound message but no outbound response)
SELECT
  th.id AS thread_id,
  th.entity_type,
  th.entity_id,
  th.subject,
  th.channel,
  min(m.sent_at) AS first_message_at
FROM threads th
JOIN messages m ON m.thread_id = th.id
WHERE th.is_closed = false
  AND m.direction = 'inbound'
  AND NOT EXISTS (
    SELECT 1 FROM messages m2
    WHERE m2.thread_id = th.id
      AND m2.direction = 'outbound'
      AND m2.sent_at > m.sent_at
  )
GROUP BY th.id, th.entity_type, th.entity_id, th.subject, th.channel
ORDER BY first_message_at ASC;
-- Message volume by channel and direction (last 30 days)
SELECT
  th.channel,
  count(*) FILTER (WHERE m.direction = 'inbound') AS inbound,
  count(*) FILTER (WHERE m.direction = 'outbound') AS outbound,
  count(*) FILTER (WHERE m.direction = 'internal') AS internal,
  count(*) AS total
FROM messages m
JOIN threads th ON th.id = m.thread_id
WHERE m.sent_at >= now() - interval '30 days'
GROUP BY th.channel
ORDER BY total DESC;
-- Open threads per entity type with message counts
SELECT
  th.entity_type,
  count(DISTINCT th.id) AS open_threads,
  count(m.id) AS total_messages,
  max(m.sent_at) AS last_activity
FROM threads th
LEFT JOIN messages m ON m.thread_id = th.id
WHERE th.is_closed = false
GROUP BY th.entity_type
ORDER BY open_threads DESC;