AgentBot-owned conversations are now treated as assigned across conversation lists, counts, pagination, permissions, unread membership, human auto-assignment, and advanced assignee filters. ## Closes - https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination ## Follow-ups - https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot tracks ownership refresh during AgentBot deletion. - https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes tracks the existing saved-filter header count refresh gap. ## Why The backend treated every conversation without a human assignee as unassigned, even when an AgentBot owned it. The frontend already hid AgentBot-owned conversations from the Unassigned list, so counts, pagination, filters, unread membership, direct-access permissions, and auto-assignment could disagree with the visible queue. ## What changed - Treat conversations with either a human assignee or AgentBot owner as assigned. - Keep conversation counts, pagination, unread memberships, advanced filters, and automation assignee conditions aligned with the shared ownership semantics. - Keep human assignee equality and not-equality filters human-only, even when a human and AgentBot have the same numeric ID. - Exclude AgentBot-owned conversations from both legacy and V2 human auto-assignment, and from unassigned-only Enterprise access. - Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse or accept an assigned conversation. - Emit ownership-change updates when only the AgentBot owner changes, so connected clients refresh queue state. ## Validation - AgentBot assignment changed ownership to the bot, moved the conversation to pending, and removed it from the open queue. - Assignee "is present" returned human- and AgentBot-owned conversations; "is not present" returned only genuinely unassigned conversations. - Automation assignee presence conditions treated AgentBot ownership as present and did not execute the absent-owner path. - Live human-assignee equality and not-equality filters excluded AgentBot-owned conversations, including numeric ID collisions. - A 32-conversation pending queue loaded across pagination with matching totals and no missing or duplicate rows. - AgentBot ownership changes and human takeover updated filtered rows immediately without a reload. - Human takeover opened the conversation and restored the public reply composer; subsequent unassignment kept the conversation open. - An unassigned-only custom-role agent saw only genuinely unassigned conversations and could not see AgentBot-owned conversations. - AgentBot-owned conversations showed the handled-by-bot banner, Take over action, and disabled public reply composer. - New conversations in the connected inbox were assigned to the AgentBot and excluded from human auto-assignment. - Opening an AgentBot-owned conversation did not let the legacy assignment callback or its locked recheck replace the bot. - Moving an AgentBot-owned conversation to an auto-assigning team preserved the bot and did not create a second human owner. - Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound reuse, and inbound WhatsApp acceptance preserved existing AgentBot owners. - Focused ownership, filters, pagination, permissions, unread-count, auto-assignment, frontend, and lint checks passed locally. - GitHub Actions, Docker builds, CircleCI, security checks, and the final Codex review are green on the final head.
77 lines
2.0 KiB
Ruby
77 lines
2.0 KiB
Ruby
class Conversations::UnreadCounts::Builder
|
|
BATCH_SIZE = 1000
|
|
|
|
attr_reader :account
|
|
|
|
def initialize(account)
|
|
@account = account
|
|
end
|
|
|
|
def build_base!
|
|
store.clear_account!(account.id)
|
|
write_memberships(assignment: false)
|
|
store.mark_base_ready!(account.id)
|
|
end
|
|
|
|
def build_assignment!
|
|
store.clear_assignment!(account.id)
|
|
write_memberships(assignment: true)
|
|
store.mark_assignment_ready!(account.id)
|
|
end
|
|
|
|
def build_all!
|
|
build_base!
|
|
build_assignment!
|
|
end
|
|
|
|
private
|
|
|
|
def write_memberships(assignment:)
|
|
unread_conversations.in_batches(of: BATCH_SIZE) do |relation|
|
|
relation = relation.where(assignee_agent_bot_id: nil) if assignment
|
|
columns = %i[id inbox_id assignee_id cached_label_list team_id]
|
|
memberships = relation.pluck(*columns).map do |id, inbox_id, assignee_id, cached_label_list, team_id|
|
|
{
|
|
conversation_id: id,
|
|
inbox_id: inbox_id,
|
|
assignee_id: assignee_id,
|
|
team_id: team_id,
|
|
label_ids: label_ids_for(cached_label_list)
|
|
}
|
|
end
|
|
|
|
store.add_memberships(account_id: account.id, memberships: memberships, assignment: assignment)
|
|
end
|
|
end
|
|
|
|
def unread_conversations
|
|
account.conversations
|
|
.open
|
|
.joins(:messages)
|
|
.merge(Message.incoming.reorder(nil))
|
|
.where(messages: { account_id: account.id })
|
|
.where(unread_since_last_seen_condition)
|
|
.distinct
|
|
end
|
|
|
|
def unread_since_last_seen_condition
|
|
conversations = Conversation.arel_table
|
|
messages = Message.arel_table
|
|
|
|
conversations[:agent_last_seen_at].eq(nil).or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
|
|
end
|
|
|
|
def label_ids_for(cached_label_list)
|
|
label_titles = cached_label_list.to_s.split(',').map(&:strip).compact_blank
|
|
labels_by_title.values_at(*label_titles).compact
|
|
end
|
|
|
|
def labels_by_title
|
|
@labels_by_title ||= account.labels.pluck(:title, :id).to_h
|
|
end
|
|
|
|
def store
|
|
::Conversations::UnreadCounts::Store
|
|
end
|
|
end
|