feat: group conversations (#228)
* feat: add group and conversation types to contacts and conversations, and implement conversation group membership model * chore: add factory and specs for conversation group member model * chore: add group type checks and associations for contacts and conversations * refactor: remove scopes from ConversationGroupMember model * refactor: remove scopes from ConversationGroupMember model specs * refactor: enhance conversation type migration with concurrent indexing * feat: add is_active index and scopes to ConversationGroupMember model * feat: implement GroupConversationHandler for managing group conversations * feat: add group_type attribute to contact creation * fix: update WHATSAPP_CHANNEL_REGEX to allow up to 20 digits to handle group jid * feat: handle group JID format in remote_jid method * chore: update group contact info when finding or creating group contact * chore: refactor and implement contact message handling and message creation logic for baileys single contact conversation * feat: implement group message handling and metadata fetching in WhatsApp service * chore: add spec for group type handling in contact creation for individual and group contacts * chore: add specs for test scopes in conversation group members * chore: update documentation for sender phone extraction in group conversation handler * chore: move GroupConversationHandler concern to correct dir * chore: implement specs for recipient_id handling to individual and group contacts * chore: add group message handling specs for incoming messages * chore: enhance group message handling to prevent race conditions * chore: add group_metadata method to with error handling * chore: add test for sending messages to group recipients in WhatsappBaileysService * chore: raise error for unsuccessful response in group_metadata method * chore: adds tests for group metadata retrieval and error handling * chore: refactor build_sender_contact_attributes to avoid double call methods * chore: update error handling for attachment download failure in message creation * chore: optimize update_contact_info method to use compact hash for updates * chore: simplify find_or_create_sender_contact method return values * chore: rename group and individual contact message handlers * chore: remove pointless comments from group contact message handler methods * chore: refine sender JID extraction logic to remove unnecessary checks * chore: remove phone number in spec for group contact attributes * chore: implement sync_group route * chore: implement get group_members route * fix: sync_group participants creation handling * chore: update contact avatar handling in group message processing * chore: move sync_group functionality for conversation model * feat: add sync_group action to ConversationsController and route * fix: set contact name to phone in group message processing * chore: refine group member retrieval logic in sync_group service and view * feat: implement group participants update handling * feat: implement group updates handling and localization for group activities * chore: add handling for group membership requests and icon changes * chore: add authorization for sync_group action in ContactsController * chore: add sync_group endpoint specs for contact management * chore: add authorization for sync_group action in ConversationsController * chore: add specs for sync_group endpoint in ConversationsController * chore: refactor index action in GroupMembersController for improved conversation filtering * chore: add request specs for group_members endpoint in ContactsController * chore: add specs for sync_group method in Conversation model * chore: add specs for sync_group method in Channel::Whatsapp model * chore: remove comments in find_or_create_group_conversation method * chore: add specs for Contacts::SyncGroupService to validate group contact behavior * chore: add specs for Whatsapp::BaileysHandlers::GroupsUpdate to validate group updates * chore: add specs for Whatsapp::BaileysHandlers::GroupParticipantsUpdate to handle group participant actions * chore: add fallback for identifier when contact has no phone_number in SendOnWhatsappService * chore: add specs for group membership request and icon change handling in MessagesUpsert * chore: add specs for sync_group method to handle group metadata and participant updates * chore: update sync_group method to retrieve group members and adjust JSON response * chore: update conversation query to filter by group type in GroupMembersController * chore: update conversation creation in group_members_controller_spec to specify conversation_type as group * chore: update find_or_create_group_conversation to include pending conversations * chore: refactor sync_group method and enhance specs for group conversation handling * feat: add GroupEventHelper module for managing group activities and contacts * chore: refactor group contact inbox and conversation creation methods in group handlers * chore: remove unnecessary check for blank participant contacts in sync_group_members method * feat: implement message receipt update handling for WhatsApp integration * chore: resolve rubocop rule for update_last_seen_at method * chore: update swagger with endpoints for syncing group information and listing group members * chore: integrate Contacts::SyncGroupService in group members controller, enhance error handling and update swagger * chore: include participant information in reaction and quoted message keys for send message in group conversations * chore: enhance whatsapp_baileys_service with participant handling for message keys * feat: add skill for writing RSpec tests in the project * fix: update recipient_id logic to directly use contact identifier for group contacts * chore: implement group stub message handling for membership requests and icon changes * fix: update whatsapp inbox source_id validation regex spec * chore: fix spec for contact syncing group * chore: remove readTimestamp handling and related tests for message read updates in group * Cayo oliveira/cu 86af01932/4 backend gerenciamento dos grupos (#221) * feat: add is_active index and scopes to ConversationGroupMember model * feat: implement GroupConversationHandler for managing group conversations * feat: add group_type attribute to contact creation * fix: update WHATSAPP_CHANNEL_REGEX to allow up to 20 digits to handle group jid * feat: handle group JID format in remote_jid method * chore: update group contact info when finding or creating group contact * chore: refactor and implement contact message handling and message creation logic for baileys single contact conversation * feat: implement group message handling and metadata fetching in WhatsApp service * chore: add spec for group type handling in contact creation for individual and group contacts * chore: add specs for test scopes in conversation group members * chore: update documentation for sender phone extraction in group conversation handler * chore: move GroupConversationHandler concern to correct dir * chore: implement specs for recipient_id handling to individual and group contacts * chore: add group message handling specs for incoming messages * chore: enhance group message handling to prevent race conditions * chore: add group_metadata method to with error handling * chore: add test for sending messages to group recipients in WhatsappBaileysService * chore: raise error for unsuccessful response in group_metadata method * chore: adds tests for group metadata retrieval and error handling * chore: refactor build_sender_contact_attributes to avoid double call methods * chore: update error handling for attachment download failure in message creation * chore: optimize update_contact_info method to use compact hash for updates * chore: simplify find_or_create_sender_contact method return values * chore: rename group and individual contact message handlers * chore: remove pointless comments from group contact message handler methods * chore: refine sender JID extraction logic to remove unnecessary checks * chore: remove phone number in spec for group contact attributes * chore: implement sync_group route * chore: implement get group_members route * fix: sync_group participants creation handling * chore: update contact avatar handling in group message processing * chore: move sync_group functionality for conversation model * feat: add sync_group action to ConversationsController and route * fix: set contact name to phone in group message processing * chore: refine group member retrieval logic in sync_group service and view * feat: implement group participants update handling * feat: implement group updates handling and localization for group activities * chore: add handling for group membership requests and icon changes * chore: add authorization for sync_group action in ContactsController * chore: add sync_group endpoint specs for contact management * chore: add authorization for sync_group action in ConversationsController * chore: add specs for sync_group endpoint in ConversationsController * chore: refactor index action in GroupMembersController for improved conversation filtering * chore: add request specs for group_members endpoint in ContactsController * chore: add specs for sync_group method in Conversation model * chore: add specs for sync_group method in Channel::Whatsapp model * chore: remove comments in find_or_create_group_conversation method * chore: add specs for Contacts::SyncGroupService to validate group contact behavior * chore: add specs for Whatsapp::BaileysHandlers::GroupsUpdate to validate group updates * chore: add specs for Whatsapp::BaileysHandlers::GroupParticipantsUpdate to handle group participant actions * chore: add fallback for identifier when contact has no phone_number in SendOnWhatsappService * chore: add specs for group membership request and icon change handling in MessagesUpsert * chore: add specs for sync_group method to handle group metadata and participant updates * chore: update sync_group method to retrieve group members and adjust JSON response * chore: update conversation query to filter by group type in GroupMembersController * chore: update conversation creation in group_members_controller_spec to specify conversation_type as group * chore: update find_or_create_group_conversation to include pending conversations * chore: refactor sync_group method and enhance specs for group conversation handling * feat: add GroupEventHelper module for managing group activities and contacts * chore: refactor group contact inbox and conversation creation methods in group handlers * chore: remove unnecessary check for blank participant contacts in sync_group_members method * chore: update swagger with endpoints for syncing group information and listing group members * chore: integrate Contacts::SyncGroupService in group members controller, enhance error handling and update swagger * fix: update recipient_id logic to directly use contact identifier for group contacts * chore: implement group stub message handling for membership requests and icon changes * fix: update whatsapp inbox source_id validation regex spec * chore: fix spec for contact syncing group * fix: optimize update_last_seen_at method to use update_columns * feat: Implement full frontend and backend support for group conversations - Added PRD for group conversations detailing frontend and backend requirements. - Created new Baileys TypeScript definitions for group-related functions. - Renamed `conversation_type` to `group_type` in the database and updated all references. - Implemented API serialization for `group_type` in conversation and contact responses. - Developed Vuex store module for managing group members. - Created UI components for group management, including group creation, member management, and metadata editing. - Integrated @mention functionality for group conversations and real-time updates via ActionCable. * feat: [US-001] - Rename conversation_type to group_type on conversations - Add migration to rename column and indexes - Update Conversation model enum to group_type - Update GroupConversationHandler concern - Update controllers (contacts, group_members) - Update all backend specs * chore: mark US-001 complete, update progress log, fix rubocop annotation * feat: [US-002] - Serialize group_type fields in API responses * feat: [US-003] - Add group_type filter to conversations index * feat: [US-004] - Add group_type to filter_keys.yml and FilterService * feat: US-005 - Backend group creation endpoint - Add POST /api/v1/accounts/:account_id/groups endpoint - Add Groups::CreateService to orchestrate Baileys group creation - Extend WhatsappBaileysService and BaseService with group management methods - Add routes for group members, metadata, invite, and join requests - Returns 403 when agent lacks inbox access, 422 when provider is unavailable * feat: US-006 - Backend add/remove members and role management endpoints - Add create/destroy/update actions to GroupMembersController - Delegate group management methods from Channel::Whatsapp to provider_service - create adds members via Baileys and creates ConversationGroupMember records - destroy removes a member by ID and sets is_active false - update promotes/demotes a member and updates their role * feat: US-007 - Backend group metadata update endpoint - Add PATCH /contacts/:id/group_metadata endpoint - Updates group subject via Baileys and syncs contact name - Updates group description via Baileys and syncs additional_attributes.description - Returns 422 when provider is unavailable * feat: US-008 - Backend invite link management endpoints - Add GET /contacts/:id/group_invite to retrieve current invite code/url - Add POST /contacts/:id/group_invite/revoke to revoke and get new invite code/url - Returns 422 when provider is unavailable * feat: US-009 - Backend join request management endpoints - Add GET /contacts/:id/group_join_requests to list pending join requests - Add POST /contacts/:id/group_join_requests/handle to approve/reject requests - Uses request_action param to avoid conflict with Rails reserved params[:action] - Returns 422 when provider is unavailable * feat: US-010 - Extend MentionService for contact mentions - Extract mention://contact/ID/Name URIs from message content - Store mentioned contact IDs in message.content_attributes[mentioned_contacts] - Existing user/team mention handling unchanged * feat: US-011 - Frontend API clients for all group endpoints - Add app/javascript/dashboard/api/groupMembers.js - Exports 11 methods: getGroupMembers, syncGroup, createGroup, updateGroupMetadata, addMembers, removeMembers, updateMemberRole, getInviteLink, revokeInviteLink, getPendingRequests, handleJoinRequest * feat: US-012 - Frontend Vuex store module groupMembers - Add groupMembers store module with fetch, sync, addMembers, removeMembers, updateMemberRole actions - Add SET_GROUP_MEMBERS and SET_GROUP_MEMBERS_UI_FLAG mutation types - Register module in store index * feat: US-013 - Frontend i18n keys for group features - Add groups.json with keys for group info, filter, creation modal, metadata editing, invite link, member management, join requests, and mention dropdown - Register groups.json in i18n locale en/index.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: US-014 - Frontend group_type filter in ConversationBasicFilter - Add chatGroupTypeFilter state, getter, mutation, and action to conversations store - Add getChatGroupTypeFilter getter - Add group_type param to ConversationApi.get() - Add Type filter section to ConversationBasicFilter with All/Individual/Group options - Persist group_type to UI settings under conversations_filter_by.group_type - Restore group_type from UI settings on page load - Include groupType in conversationFilters and pass as group_type param to API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: US-013 - Frontend — i18n keys for group features (en + pt-BR) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: [US-014] - Frontend — add group_type filter to ConversationBasicFilter All implementation was already in place from prior work: - ConversationBasicFilter.vue has Type section with All/Individual/Group options - ChatList.vue handles group_type in conversationFilters and restores from UI settings - Store has setChatGroupTypeFilter action, getChatGroupTypeFilter getter - API maps groupType → group_type query param Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-015 - Frontend — add group_type to advanced filter system - Add GROUP_TYPE to CONVERSATION_ATTRIBUTES in filterHelper.js - Add group_type filter definition in provider.js (components-next) - Add group_type to legacy advancedFilterItems/index.js and filterAttributeGroups - Add group_type to automationHelper conditionFilterMaps - Add group_type to customViewsHelper getValuesForFilter - Add group_type options to ChatList setParamsForEditFolderModal - Add GROUP_TYPE i18n key in en and pt_BR advancedFilters.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-016 - Frontend — GroupContactInfo basic display Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-017 - Frontend — GroupContactInfo sync button Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-018 - integrate GroupContactInfo in ContactPanel - Import GroupContactInfo component - Conditionally render GroupContactInfo when group_type === 'group' - Keep ContactInfo for individual conversations (no regression) - Dynamic sidebar title: 'Group' for groups, 'Contact' for individual - contact_notes and contact_attributes accordion sections unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-019 - Frontend — group creation UI modal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-020 - Frontend — member management UI in GroupContactInfo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-021 - Frontend — group metadata editing UI Add inline editing for group name, description, and avatar in GroupContactInfo: - Click group name to edit inline, save on Enter/blur - Click description to edit inline with textarea, save on blur - Click avatar to open file picker for upload via contacts/update - Loading states on all fields during save - Success/error alerts for all operations - updateGroupMetadata action added to groupMembers store Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-022 - Frontend — invite link management UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-023 - Frontend — join request management UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-024 - Frontend — group message bubbles: sender name with color - Add sender name display above incoming message bubbles in group conversations - Deterministic color per sender using AVATAR_COLORS palette (name.length % 6) - Sender name hidden for consecutive messages from the same sender - Individual conversation bubbles unchanged - Pass groupWithPrevious and isGroupConversation props through MessageList → Message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-025 - Frontend — group message bubbles: sender avatar Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: Add Ralph Wiggum AI agent script for managing tool execution and progress tracking * feat: US-026 - Frontend — @mention dropdown for group conversations - Create TagGroupMembers.vue component for group member mention suggestions - Modify Editor.vue: add isGroupConversation/groupContactId props, render TagGroupMembers for group non-private context - Modify ReplyBox.vue: compute isGroupConversation and groupContactId from currentChat, pass to WootMessageEditor - @ mention plugin isAllowed now triggers for group conversations - In individual conversations or private notes, existing behavior unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-027 - Frontend — mention rendering in group message bubbles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-028 - Frontend ActionCable handler for contact.group_synced event - Backend: Include group_members data in contact.group_synced ActionCable payload - Frontend: Register contact.group_synced handler in ActionCableConnector - Frontend: Add setGroupMembers action to groupMembers store for direct commits - Tests: ActionCable handler spec + groupMembers store spec for new action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: Update progress tracking for group conversations feature - mark tests as passing * fix: sender click case mismatch and filter dropdown spacing - Message.vue: use case-insensitive comparison for sender type check (Contact.push_event_data returns 'contact' but SENDER_TYPES.CONTACT is 'Contact') - ConversationBasicFilter.vue: replace last:mt-4 with flex-col gap-4 for consistent spacing between all three filter sections * fix: four bugs found during manual testing review - ContactPanel.vue: fix i18n key GROUP.INFO.SIDEBAR_TITLE → GROUP.SIDEBAR_TITLE - groupMembers.js API: fix syncGroup HTTP method GET → POST to match backend route - group_members_controller.rb: remove SyncGroupService from index action - filterHelpers.js: add missing group_type case to getValueFromConversation * docs: update progress with bug fix learnings * chore: implement group creation functionality in UI components * chore: add copy invite link functionality and update UI components * feat: US-041 - Backend — ensure group_type is set on existing contacts and conversations GroupConversationHandler#update_group_contact_info now sets group_type: :group on contacts that are incorrectly typed as individual. GroupConversationHandler#find_or_create_group_conversation updates existing conversation's group_type to :group if it is currently :individual. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: mark US-041 as complete Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-029 - i18n keys for You badge and group settings (en + pt-BR) All i18n keys already existed from prior iterations. Verified presence and updated PRD status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-030 - fix Baileys API route/method mismatches Fix on_whatsapp to dig('data') before accessing first element. Update spec stubs to match { data: [...] } response envelope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-031 - group_leave, group_setting_update, group_join_approval_mode methods All methods, delegates, and error handling already implemented. Verified specs pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-032 - persist group settings, invite code, and profile picture during sync Add try_update_group_avatar to fetch and attach group profile picture during sync_group. Update spec stubs for profile-picture-url endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-033 - GroupSettingsController with leave, update, toggle Controller and routes already implemented. Verified rubocop passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-034 - remove inbox_contact_id from provider_config and jbuilder Already removed in prior iterations. Verified no references remain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-035 - refactor TagGroupMembers to phone_number matching Already implemented. Verified excludePhoneNumber prop and filtering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-036 - remove InboxContact.vue and settings tab Already removed in prior iterations. Verified no references remain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-037 - add You badge in GroupContactInfo member list Already implemented with isOwnMember check and blue badge styling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-038 - fix inline edit for group name and description Already implemented with phone number normalization. Verified code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-039 - group settings section UI with toggles Already implemented. Settings toggles, API calls, and i18n verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-040 - leave group UI with confirmation and auto-resolve Already implemented. Leave button, confirmation, and API call verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-050 - Create GroupMember model and migration New group_members table with group_contact_id, contact_id, role, is_active. Unique index on (group_contact_id, contact_id). Associations added to Contact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-064 - Helper method to find channel from group contact Add Contact#group_channel to decouple channel lookup from conversations. Update GroupMembersController and GroupSettingsController to use it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-052 - Update GroupConversationHandler to use GroupMember Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-057 - Update GroupMembersController to query GroupMember Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-058 - Update GroupSettingsController to not depend on conversations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-060 - Update group_members jbuilder views Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-059 - Remove group_members association from Conversation model Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: US-051 - Remove ConversationGroupMember model and table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: mark all stories complete, update progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(groups): real-time group panel, avatar refresh on icon change, editable name/description - Add group_type to Conversations::EventDataPresenter#push_data and Contact#push_event_data so WebSocket events carry the field, enabling the frontend to switch to GroupContactInfo in real-time - Update handle_icon_change_stub to call try_update_group_avatar with force: true, purging the cached avatar and fetching the new one - Add force parameter to try_update_group_avatar to support re-fetching - Remove isInboxAdmin gate from name/description editing in GroupContactInfo so any user can click to edit (server validates) * fix(groups): rewrite SyncGroupService and simplify group metadata channel lookup - Rewrite SyncGroupService to use contact.group_channel directly instead of iterating conversations; find or create a conversation for sync - Simplify GroupMetadataController to use @contact.group_channel instead of querying conversations; remove local contact/attribute updates since the Baileys API handles persistence via webhook events * feat(groups): resolve conversations when inbox phone leaves or is removed - Add resolve_conversations_if_inbox_left to GroupParticipantsUpdate - Resolves all open/pending conversations when the inbox phone number is removed from or leaves a group * feat(groups): add paginated member list with infinite scroll - Backend: add pagination to GroupMembersController (page/per_page, default 10, ordered admins first); add meta with pagination info to the jbuilder response - Frontend: update groupMembers API to accept page param; add APPEND_GROUP_MEMBERS and SET_GROUP_MEMBERS_META mutations; implement paginated fetch with append and isFetchingMore flag in store * feat(groups): support Ctrl+Click on group message sender to open in new tab - navigateToGroupSender now accepts the event and checks for Ctrl/Cmd+Click to open the sender contact in a new tab * chore(i18n): update leave group confirmation text in en and pt_BR * fix(groups): handle phone format differences in You badge and admin detection - Extract phonesMatch helper that compares last 8 digits as fallback, handling Brazilian 9th digit discrepancy (e.g. +5587988465072 vs +558788465072) - Apply to both isOwnMember and isInboxAdmin computed properties * feat(groups): auto-sync members on mount, show existing members immediately - On mount, fetch existing DB members first so they display instantly - Then silently attempt a background sync to refresh from WhatsApp - If sync fails (e.g. WhatsApp disconnected), existing members remain displayed without any user-facing error * fix(groups): pin own member on first page and return inbox phone in meta The "You" badge was not appearing because the inbox's own member could be missing from the first paginated page in large groups (admins sorted first). Backend: - Pin the inbox's own member at the top of page 1 regardless of sort order - Return inbox_phone_number in the group members meta response - Use last-8-digit SQL fallback for Brazilian 9th-digit phone mismatches Frontend: - Use meta.inbox_phone_number for the inboxPhone computed - Fix declaration order to satisfy no-use-before-define lint rule * fix(groups): fix member action dropdown clipped by overflow container The promote/demote/remove dropdown menu was invisible because the member list had `overflow-y-auto max-h-80`, clipping any absolutely-positioned dropdown rendered inside it. - Remove overflow container from member list; let the sidebar scroll - Replace scroll-based infinite loading with IntersectionObserver on a sentinel element for cleaner pagination trigger - All member action logic (promote, demote, remove) was already wired; the dropdown is now visible on hover * fix(groups): keep member action dropdown visible when menu is open The opacity-0/group-hover classes on the action menu wrapper caused the DropdownMenu to become invisible as soon as the mouse left the row. Now the wrapper stays fully opaque while the menu is active. * fix(groups): move clickaway to member list wrapper to prevent instant close v-on-clickaway was bound to every member's action div individually. Clicking the three-dot button on one member fired closeMemberMenu from all other members' clickaway handlers, closing the menu instantly. Moved the directive to the single member list container instead. * feat: add WhatsApp mention conversion (incoming + outgoing) - New MentionConverterService for bidirectional mention handling - Incoming: converts @phone/mentionedJid to mention://contact/ URIs - Outgoing: extracts mention://contact/ URIs into WhatsApp mentions array - Supports @everyone/todos group mentions - WhatsApp renderer preserves mention display text instead of raw URI * fix: preserve mention display text in WhatsApp renderer mention:// URIs now render as display name text instead of the raw URL when converting markdown to WhatsApp format * feat: add @everyone mention option in group conversations - Everyone item shown at top of mention dropdown - Searchable by 'all', 'todos', 'everyone' keywords - i18n keys added for en and pt-BR * refactor: use Switch component for group settings toggles - Add disabled prop to Switch component - Replace custom toggle buttons in GroupContactInfo with Switch - Loading spinner shown alongside toggle while toggling * feat(whatsapp): add group sync status tracking (group_left, group_last_synced_at) * feat(whatsapp): hide group management UI when group_left is true * fix(groupMembers): include inbox phone number in group members state and sync event * feat(whatsapp): wrap group settings and leave in Accordion component * feat(groupMembers): handle group creator modification errors and update error messages * feat(groupMembers): enhance invite link functionality and clean up UI state after copying * refactor: remove sync_group functionality from conversations and related specs * feat(GroupContactInfo): implement scroll-based loading for group members * docs(swagger): add group API endpoints and remove conversation sync_group - Remove dead conversation/{id}/sync_group swagger entry and file - Update group_members.yml with pagination params, POST operation, and $ref schema - Add swagger for: group_members_member (PATCH/DELETE), group_metadata, group_invite, group_invite_revoke, group_join_requests, group_join_requests_handle, group_settings, group_settings_leave, group_settings_toggle_join_approval, groups/create - Add group_member schema definition - Add Groups tag to application tag_groups - Register all 12 group endpoints in paths/index.yml * feat(WhatsappBaileysService): enhance mention handling by replacing @DisplayName with @lid/@phone in outgoing text * feat(groups): move group sync to background job with 15-min cooldown - Create Contacts::SyncGroupJob that checks group_last_synced_at before calling SyncGroupService (skips if < 15 min) - Controller sync_group now enqueues the job and returns 202 Accepted - Delete sync_group.json.jbuilder (no longer needed) - Frontend sync action is fire-and-forget; results via ActionCable - Auto-trigger sync on conversation select and panel mount - Remove manual sync button from GroupContactInfo * fix: show group members list even after leaving group\n\nKeep the members section visible in read-only mode when\ngroup_left is true. Admin actions (add member, promote,\ndemote, remove) remain hidden. Pending Join Requests and\nAdvanced Options also stay hidden. * fix: disable group name/description/avatar editing when group_left is true * fix: remove @all mention and fix Enter key in group mention dropdown\n\n- Remove the @all/everyone special mention from TagGroupMembers since\n no channel provider currently supports mentioning all participants\n- Fix Enter key sending message instead of inserting selected mention\n in group conversations. The root cause was Editor.vue only emitting\n toggleUserMention=true for private notes (isPrivate), leaving\n ReplyBox unaware the group mention dropdown was open. Now also\n emits for isGroupConversation.\n- Add TagGroupMembers spec covering filtering, exclusion, and emission" * fix: address PR review feedback for group conversations - Fix nil safety in group_invites and group_join_requests controllers by replacing group_conversation.inbox.channel with @contact.group_channel - Add before_action guard in group_members_controller to validate contact is a group with identifier before create/update/destroy - Persist metadata locally in group_metadata_controller after provider calls (subject -> name, description -> additional_attributes) - Add server-side allow_group_creation? check in groups_controller - Add word boundary to mention regex to prevent matching inside words - Remove useless catch clauses in groupMembers store (try/finally only) - Default groupType to [] in customViewsHelper to prevent crash - Fix swagger parameter name mismatch (contact_id -> id) across all group endpoint YML files for consistency * fix: address PR #228 review feedback - strong params, guards, and safety fixes * fix: dispatch real-time events for Baileys group participant and metadata updates Both group-participants.update and groups.update handlers were updating backend data (GroupMember records, Contact attributes) but never dispatching ActionCable events, leaving the frontend member list and group metadata stale until manual sync. Changes: - Add dispatch_group_synced_event helper to GroupEventHelper concern - Dispatch CONTACT_GROUP_SYNCED after participant add/remove/promote/demote - Dispatch CONTACT_GROUP_SYNCED after group subject/description/settings changes - Frontend: onContactGroupSynced also dispatches contacts/updateContact to refresh group name, description and settings in the sidebar * fix: enhance member menu positioning and close behavior on sidebar scroll * feat: implement group property updates and enhance toast notifications * fix: update WhatsApp channel regex to allow optional hyphenated numbers * feat: implement group admin functionalities including leave, update properties, and toggle join approval * refactor: simplify group message handling by removing metadata fetching and syncing methods * chore: remove raph files * feat: update Portuguese translations for 'Read More' and 'Insert Read More' phrases * feat: enhance group admin functionalities with join approval and member add modes * feat: enhance group join request handling by adding removal of handled requests and updating pending join requests * feat: restrict message sending in announcement mode groups When a Baileys WhatsApp group has announcement mode enabled (announce=true), only admin members can send messages. This adds: - Frontend: disabled editor + banner for non-admin inbox in announcement groups - Backend: validation in SendOnWhatsappService to reject messages - Shared phone helper utility extracted from GroupContactInfo - i18n keys for en and pt_BR * feat: add group sync job enqueueing and improve avatar update handling * feat: add functionality to reset invite link and confirm member addition restrictions * feat: update group name extraction logic to handle nil values * feat: add inbox admin status handling and update related components * feat: remove group conversation resolution on leave action * feat: enhance group sender avatar interaction with tooltip and cursor pointer * feat: add force option to SyncGroupJob and update related specs * feat: enhance invite link handling and avatar update logic in group conversations * chore: remove prd.json * fix: change group sender name display from block to inline-block for better layout * feat: add group members loading check and fetch logic in MessagesView and ReplyBox components * feat: allow id and firstUnreadId props to accept both Number and String types feat: add vOnClickOutside import to Editor component feat: enhance Portuguese translations for integrations and settings fix: change button color in GroupContactInfo component from green to teal * feat: soft-disabled group conversations with activity tracking Groups start in a soft-disabled state by default when using Baileys. Chatwoot still creates group conversations but does not process every incoming message. Instead, Baileys accumulates group messages and sends periodic groups.activity webhook events to update last_activity_at. Backend: - Add WHATSAPP_GROUPS_ENABLED env var and groups_enabled? class method - Send groupsEnabled in Baileys connection setup - Create groups.activity handler to update conversation last_activity_at - Gate group message processing behind groups_enabled? check - Expose groups_enabled via inbox API Frontend: - Add warning banner with CTA to app.fazer.ai on disabled group conversations - Disable reply editor for non-private-note mode when groups disabled - Add i18n strings for en and pt_BR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use method for groups disabled banner action to avoid window scope issue Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: broadcast conversation update after groups.activity event update_columns bypasses ActiveRecord callbacks, so the ActionCable broadcast was never triggered when last_activity_at changed. Dispatch a CONVERSATION_UPDATED event explicitly so the sidebar updates in real-time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: show unread dot for soft-disabled group conversations with activity Since soft-disabled groups don't create messages, unread_count is always 0 and the standard badge won't show. Detect unread state by comparing last_activity_at > agent_last_seen_at for these groups and display a teal dot indicator instead of a count badge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: clear unread dot when agent opens soft-disabled group conversation The update_last_seen endpoint skipped updating agent_last_seen_at when there were no unread messages (the throttle path). For soft-disabled groups that never create messages, this meant the dot indicator could never be cleared. Add an unseen_activity? check that bypasses the throttle when last_activity_at > agent_last_seen_at. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: group avatar upload to provider and fix icon change sync - Route avatar upload through GroupMetadataController to push to WhatsApp provider before saving locally - Add update_group_picture to baileys service and base service - Fix buildContactFormData crash when social_profiles is undefined - Make try_update_group_avatar public so GROUP_CHANGE_ICON stub handler can call it from outside the service class Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update specs for group conversations feature changes - Add groupsEnabled param to setup_channel_provider and handle_channel_error WebMock stubs - Add group-request-participants-list stub for sync_group tests - Add group_type to push_event_data expected hash - Set last_activity_at in throttle tests to prevent unseen_activity? bypass - Update sync_group delegation expectation to include soft: false - Stub groups_enabled? in group message handling tests - Update WhatsApp source_id regex expectation for group contact IDs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add settings file for additional directories configuration * chore: undo unrelated changes * chore: remove planning doc, fix migration version, fix swagger param consistency - Remove planejamento-chat-interno.md (unrelated planning document) - Fix CreateGroupMembers migration API version from 7.0 to 7.1 - Fix swagger.json: normalize group endpoint paths from {contact_id} to {id} to match YAML sources and existing contact sub-resource conventions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: CayoPOliveira <cayoproliveira@gmail.com> Co-authored-by: Cayo P. R. Oliveira <cayo@fazer.ai> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
05e0d355dd
commit
a996b920e8
3
.claude/settings.json
Normal file
3
.claude/settings.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"additionalDirectories": ["../baileys-api"]
|
||||
}
|
||||
@ -293,5 +293,7 @@ AZURE_APP_SECRET=
|
||||
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
|
||||
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
|
||||
BAILEYS_PROVIDER_DEFAULT_API_KEY=
|
||||
# Enable WhatsApp group conversations for Baileys provider (default: false)
|
||||
BAILEYS_WHATSAPP_GROUPS_ENABLED=false
|
||||
|
||||
RESEND_API_KEY=
|
||||
|
||||
@ -55,7 +55,8 @@ class ContactInboxWithContactBuilder
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
additional_attributes: contact_attributes[:additional_attributes],
|
||||
custom_attributes: contact_attributes[:custom_attributes]
|
||||
custom_attributes: contact_attributes[:custom_attributes],
|
||||
group_type: contact_attributes[:group_type] || :individual
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
class Api::V1::Accounts::Contacts::GroupAdminController < Api::V1::Accounts::Contacts::BaseController
|
||||
VALID_PROPERTIES = %w[announce restrict join_approval_mode member_add_mode].freeze
|
||||
|
||||
def leave
|
||||
authorize @contact, :update?
|
||||
channel.group_leave(@contact.identifier)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contact, :update?
|
||||
property = property_params[:property]
|
||||
enabled = ActiveModel::Type::Boolean.new.cast(property_params[:enabled])
|
||||
return render json: { error: 'invalid_property' }, status: :unprocessable_entity unless property.in?(VALID_PROPERTIES)
|
||||
|
||||
apply_property_change(property, enabled)
|
||||
update_contact_attribute(property, enabled)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_property_change(property, enabled)
|
||||
case property
|
||||
when 'announce', 'restrict'
|
||||
channel.group_setting_update(@contact.identifier, property, enabled)
|
||||
when 'join_approval_mode'
|
||||
channel.group_join_approval_mode(@contact.identifier, enabled ? 'on' : 'off')
|
||||
when 'member_add_mode'
|
||||
channel.group_member_add_mode(@contact.identifier, enabled ? 'all_member_add' : 'admin_add')
|
||||
end
|
||||
end
|
||||
|
||||
def property_params
|
||||
params.permit(:property, :enabled)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def resolve_group_conversations
|
||||
Current.account.conversations
|
||||
.where(contact_id: @contact.id, group_type: :group, status: %i[open pending])
|
||||
.find_each { |c| c.update!(status: :resolved) }
|
||||
end
|
||||
|
||||
def update_contact_attribute(key, value)
|
||||
new_attrs = (@contact.additional_attributes || {}).merge(key => value)
|
||||
@contact.update!(additional_attributes: new_attrs)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,27 @@
|
||||
class Api::V1::Accounts::Contacts::GroupInvitesController < Api::V1::Accounts::Contacts::BaseController
|
||||
def show
|
||||
authorize @contact, :show?
|
||||
code = channel.group_invite_code(@contact.identifier)
|
||||
render json: invite_response(code)
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def revoke
|
||||
authorize @contact, :update?
|
||||
code = channel.revoke_group_invite(@contact.identifier)
|
||||
render json: invite_response(code)
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def invite_response(code)
|
||||
{ invite_code: code, invite_url: "https://chat.whatsapp.com/#{code}" }
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,37 @@
|
||||
class Api::V1::Accounts::Contacts::GroupJoinRequestsController < Api::V1::Accounts::Contacts::BaseController
|
||||
def index
|
||||
authorize @contact, :show?
|
||||
requests = channel.group_join_requests(@contact.identifier)
|
||||
render json: { payload: requests }
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def handle
|
||||
authorize @contact, :update?
|
||||
channel.handle_group_join_requests(@contact.identifier, handle_params[:participants], handle_params[:request_action])
|
||||
remove_handled_requests(handle_params[:participants])
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_params
|
||||
params.permit(:request_action, participants: [])
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def remove_handled_requests(participants)
|
||||
return if participants.blank?
|
||||
|
||||
current_requests = @contact.additional_attributes&.dig('pending_join_requests') || []
|
||||
updated_requests = current_requests.reject { |r| participants.include?(r['jid']) }
|
||||
new_attrs = (@contact.additional_attributes || {}).merge('pending_join_requests' => updated_requests)
|
||||
@contact.update!(additional_attributes: new_attrs)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,155 @@
|
||||
class Api::V1::Accounts::Contacts::GroupMembersController < Api::V1::Accounts::Contacts::BaseController
|
||||
DEFAULT_PER_PAGE = 10
|
||||
|
||||
before_action :ensure_group_contact, only: %i[create update destroy]
|
||||
|
||||
def index
|
||||
authorize @contact, :show?
|
||||
|
||||
base_query = GroupMember.active
|
||||
.where(group_contact: @contact)
|
||||
.includes(:contact)
|
||||
|
||||
@total_count = base_query.count
|
||||
@page = [(params[:page] || 1).to_i, 1].max
|
||||
@per_page = (params[:per_page] || DEFAULT_PER_PAGE).to_i.clamp(1, 100)
|
||||
@inbox_phone_number = inbox_phone_number
|
||||
@is_inbox_admin = inbox_admin?
|
||||
|
||||
paginated = base_query.order(role: :desc, id: :asc)
|
||||
.offset((@page - 1) * @per_page)
|
||||
.limit(@per_page)
|
||||
|
||||
@group_members = pin_own_member_on_first_page(paginated)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @contact, :update?
|
||||
participants = create_params[:participants]
|
||||
return render json: { error: 'participants_required' }, status: :unprocessable_entity if participants.blank?
|
||||
|
||||
channel.update_group_participants(@contact.identifier, format_participants(participants), 'add')
|
||||
add_group_members(participants)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @contact, :update?
|
||||
role = update_params[:role]
|
||||
return render json: { error: 'invalid_role' }, status: :unprocessable_entity unless %w[admin member].include?(role)
|
||||
|
||||
member = group_members.find(params[:member_id])
|
||||
action = role == 'admin' ? 'promote' : 'demote'
|
||||
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], action)
|
||||
member.update!(role: role)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
|
||||
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @contact, :update?
|
||||
|
||||
member = group_members.find(params[:id])
|
||||
channel.update_group_participants(@contact.identifier, [jid_for_member(member)], 'remove')
|
||||
member.update!(is_active: false)
|
||||
head :ok
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::GroupParticipantNotAllowedError
|
||||
render json: { error: 'group_creator_not_modifiable' }, status: :unprocessable_entity
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_group_contact
|
||||
return if @contact.group_type_group? && @contact.identifier.present?
|
||||
|
||||
render json: { error: 'Contact is not a valid group' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def group_members
|
||||
GroupMember.where(group_contact: @contact)
|
||||
end
|
||||
|
||||
def create_params
|
||||
params.permit(participants: [])
|
||||
end
|
||||
|
||||
def update_params
|
||||
params.permit(:role)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
|
||||
def inbox_phone_number
|
||||
channel&.phone_number
|
||||
end
|
||||
|
||||
def inbox_admin?
|
||||
return false if @inbox_phone_number.blank?
|
||||
|
||||
find_own_member&.role == 'admin'
|
||||
end
|
||||
|
||||
def pin_own_member_on_first_page(paginated)
|
||||
return paginated unless @page == 1 && @inbox_phone_number.present?
|
||||
|
||||
ids = paginated.pluck(:id)
|
||||
own = find_own_member
|
||||
return paginated if own.blank? || ids.include?(own.id)
|
||||
|
||||
# Prepend own member; drop the last one so total per-page stays consistent
|
||||
[own] + paginated.where.not(id: own.id).limit(@per_page - 1).to_a
|
||||
end
|
||||
|
||||
def find_own_member
|
||||
clean = @inbox_phone_number.delete('+')
|
||||
GroupMember.active
|
||||
.where(group_contact: @contact)
|
||||
.joins(:contact)
|
||||
.where('REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
|
||||
clean, clean)
|
||||
.includes(:contact)
|
||||
.first
|
||||
end
|
||||
|
||||
def format_participants(phone_numbers)
|
||||
Array(phone_numbers).map { |phone| "#{phone.to_s.delete('+')}@s.whatsapp.net" }
|
||||
end
|
||||
|
||||
def jid_for_member(member)
|
||||
"#{member.contact.phone_number.to_s.delete('+')}@s.whatsapp.net"
|
||||
end
|
||||
|
||||
def add_group_members(phone_numbers)
|
||||
inbox = @contact.contact_inboxes.first&.inbox
|
||||
Array(phone_numbers).each do |phone|
|
||||
normalized = normalize_phone(phone)
|
||||
next if normalized.blank?
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: normalized.delete('+'),
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: normalized, phone_number: normalized }
|
||||
).perform
|
||||
next if contact_inbox.blank?
|
||||
|
||||
member = GroupMember.find_or_initialize_by(group_contact: @contact, contact: contact_inbox.contact)
|
||||
member.update!(role: :member, is_active: true) unless member.persisted? && member.is_active?
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_phone(phone)
|
||||
cleaned = phone.to_s.strip
|
||||
return nil if cleaned.blank?
|
||||
|
||||
cleaned.start_with?('+') ? cleaned : "+#{cleaned}"
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,39 @@
|
||||
class Api::V1::Accounts::Contacts::GroupMetadataController < Api::V1::Accounts::Contacts::BaseController
|
||||
def update
|
||||
authorize @contact, :update?
|
||||
update_subject if metadata_params[:subject].present?
|
||||
update_description if metadata_params[:description].present?
|
||||
update_picture if metadata_params[:avatar].present?
|
||||
render json: { id: @contact.id, name: @contact.name, additional_attributes: @contact.additional_attributes }
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metadata_params
|
||||
params.permit(:subject, :description, :avatar)
|
||||
end
|
||||
|
||||
def update_subject
|
||||
channel.update_group_subject(@contact.identifier, metadata_params[:subject])
|
||||
@contact.update!(name: metadata_params[:subject])
|
||||
end
|
||||
|
||||
def update_description
|
||||
channel.update_group_description(@contact.identifier, metadata_params[:description])
|
||||
attrs = @contact.additional_attributes.merge('description' => metadata_params[:description])
|
||||
@contact.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def update_picture
|
||||
avatar = metadata_params[:avatar]
|
||||
image_base64 = Base64.strict_encode64(avatar.read)
|
||||
channel.update_group_picture(@contact.identifier, image_base64)
|
||||
@contact.avatar.attach(avatar)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= @contact.group_channel
|
||||
end
|
||||
end
|
||||
@ -13,7 +13,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes, :sync_group]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
|
||||
|
||||
def index
|
||||
@ -82,6 +82,15 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contact.save!
|
||||
end
|
||||
|
||||
def sync_group
|
||||
authorize @contact, :sync_group?
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if @contact.group_type_individual?
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if @contact.identifier.blank?
|
||||
|
||||
Contacts::SyncGroupJob.perform_later(@contact)
|
||||
head :accepted
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
||||
|
||||
@ -166,7 +166,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def unseen_activity?
|
||||
@conversation.last_activity_at.present? &&
|
||||
(@conversation.agent_last_seen_at.blank? || @conversation.last_activity_at > @conversation.agent_last_seen_at)
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
# Always update when there's unseen activity (e.g. soft-disabled group conversations that don't create messages)
|
||||
return true if unseen_activity?
|
||||
|
||||
# Update if at least one relevant timestamp is older than 1 hour or not set
|
||||
# This prevents redundant DB writes when agents repeatedly view the same conversation
|
||||
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
|
||||
|
||||
26
app/controllers/api/v1/accounts/groups_controller.rb
Normal file
26
app/controllers/api/v1/accounts/groups_controller.rb
Normal file
@ -0,0 +1,26 @@
|
||||
class Api::V1::Accounts::GroupsController < Api::V1::Accounts::BaseController
|
||||
def create
|
||||
inbox = Current.account.inboxes.find_by(id: group_params[:inbox_id])
|
||||
return render json: { error: 'Access Denied' }, status: :forbidden unless inbox_accessible?(inbox)
|
||||
|
||||
result = Groups::CreateService.new(
|
||||
inbox: inbox,
|
||||
subject: group_params[:subject],
|
||||
participants: Array(group_params[:participants])
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def group_params
|
||||
params.permit(:inbox_id, :subject, participants: [])
|
||||
end
|
||||
|
||||
def inbox_accessible?(inbox)
|
||||
inbox.present? && Current.user.assigned_inboxes.exists?(id: inbox.id) && inbox.channel.try(:allow_group_creation?)
|
||||
end
|
||||
end
|
||||
@ -78,6 +78,7 @@ class DashboardController < ActionController::Base
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
BAILEYS_WHATSAPP_GROUPS_ENABLED: Whatsapp::Providers::WhatsappBaileysService.groups_enabled?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
|
||||
@ -64,6 +64,7 @@ class ConversationFinder
|
||||
|
||||
find_all_conversations
|
||||
filter_by_status unless params[:q]
|
||||
filter_by_group_type
|
||||
filter_by_team
|
||||
filter_by_labels
|
||||
filter_by_query
|
||||
@ -118,6 +119,12 @@ class ConversationFinder
|
||||
@conversations
|
||||
end
|
||||
|
||||
def filter_by_group_type
|
||||
return unless params[:group_type].present? && params[:group_type] != 'all'
|
||||
|
||||
@conversations = @conversations.where(group_type: params[:group_type])
|
||||
end
|
||||
|
||||
def filter_by_conversation_type
|
||||
case @params[:conversation_type]
|
||||
when 'mention'
|
||||
|
||||
@ -100,6 +100,10 @@ module Filters::FilterHelper
|
||||
values.map { |x| Conversation.priorities[x.to_sym] }
|
||||
end
|
||||
|
||||
def conversation_group_type_values(values)
|
||||
values.map { |x| Conversation.group_types[x.to_sym] }
|
||||
end
|
||||
|
||||
def message_type_values(values)
|
||||
values.map { |x| Message.message_types[x.to_sym] }
|
||||
end
|
||||
|
||||
71
app/javascript/dashboard/api/groupMembers.js
Normal file
71
app/javascript/dashboard/api/groupMembers.js
Normal file
@ -0,0 +1,71 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class GroupMembersAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('contacts', { accountScoped: true });
|
||||
}
|
||||
|
||||
getGroupMembers(contactId, page = 1) {
|
||||
return axios.get(`${this.url}/${contactId}/group_members`, {
|
||||
params: { page },
|
||||
});
|
||||
}
|
||||
|
||||
syncGroup(contactId) {
|
||||
return axios.post(`${this.url}/${contactId}/sync_group`);
|
||||
}
|
||||
|
||||
createGroup(params) {
|
||||
return axios.post(`${this.baseUrl()}/groups`, params);
|
||||
}
|
||||
|
||||
updateGroupMetadata(contactId, params) {
|
||||
return axios.patch(`${this.url}/${contactId}/group_metadata`, params);
|
||||
}
|
||||
|
||||
addMembers(contactId, participants) {
|
||||
return axios.post(`${this.url}/${contactId}/group_members`, {
|
||||
participants,
|
||||
});
|
||||
}
|
||||
|
||||
removeMembers(contactId, memberId) {
|
||||
return axios.delete(`${this.url}/${contactId}/group_members/${memberId}`);
|
||||
}
|
||||
|
||||
updateMemberRole(contactId, memberId, role) {
|
||||
return axios.patch(`${this.url}/${contactId}/group_members/${memberId}`, {
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
getInviteLink(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/group_invite`);
|
||||
}
|
||||
|
||||
revokeInviteLink(contactId) {
|
||||
return axios.post(`${this.url}/${contactId}/group_invite/revoke`);
|
||||
}
|
||||
|
||||
getPendingRequests(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/group_join_requests`);
|
||||
}
|
||||
|
||||
handleJoinRequest(contactId, params) {
|
||||
return axios.post(
|
||||
`${this.url}/${contactId}/group_join_requests/handle`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
leaveGroup(contactId) {
|
||||
return axios.post(`${this.url}/${contactId}/group_admin/leave`);
|
||||
}
|
||||
|
||||
updateGroupProperty(contactId, params) {
|
||||
return axios.patch(`${this.url}/${contactId}/group_admin`, params);
|
||||
}
|
||||
}
|
||||
|
||||
export default new GroupMembersAPI();
|
||||
@ -16,6 +16,7 @@ class ConversationApi extends ApiClient {
|
||||
conversationType,
|
||||
sortBy,
|
||||
updatedWithin,
|
||||
groupType,
|
||||
}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
@ -28,6 +29,7 @@ class ConversationApi extends ApiClient {
|
||||
conversation_type: conversationType,
|
||||
sort_by: sortBy,
|
||||
updated_within: updatedWithin,
|
||||
group_type: groupType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { reactive, ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
|
||||
@ -19,9 +20,12 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { pendingGroupNavigation } from 'dashboard/helper/pendingGroupNavigation';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
import ComposeNewGroupForm from 'dashboard/components-next/NewConversation/components/ComposeNewGroupForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
alignPosition: {
|
||||
@ -42,6 +46,8 @@ const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
@ -59,6 +65,8 @@ const isCreatingContact = ref(false);
|
||||
const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
const composeMode = ref('conversation');
|
||||
const groupFormRef = ref(null);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
@ -84,6 +92,66 @@ const globalConfig = useMapGetter('globalConfig/get');
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
const messageSignature = useMapGetter('getMessageSignature');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const groupUiFlags = useMapGetter('groupMembers/getUIFlags');
|
||||
|
||||
const groupCreationInboxes = computed(() =>
|
||||
inboxesList.value.filter(inbox => inbox.allow_group_creation)
|
||||
);
|
||||
|
||||
const isGroupMode = computed(() => composeMode.value === 'group');
|
||||
const hasGroupInboxes = computed(() => groupCreationInboxes.value.length > 0);
|
||||
const isGroupsDisabled = computed(
|
||||
() => !globalConfig.value.baileysWhatsappGroupsEnabled
|
||||
);
|
||||
|
||||
const resetContacts = () => {
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
composeMode.value = 'conversation';
|
||||
if (!props.contactId) {
|
||||
selectedContact.value = null;
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
groupFormRef.value?.resetForm();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const switchMode = mode => {
|
||||
if (composeMode.value === mode) return;
|
||||
composeMode.value = mode;
|
||||
selectedContact.value = null;
|
||||
targetInbox.value = null;
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
resetContacts();
|
||||
groupFormRef.value?.resetForm();
|
||||
};
|
||||
|
||||
const createGroup = async ({ inboxId, subject, participants }) => {
|
||||
try {
|
||||
const data = await store.dispatch('groupMembers/createGroup', {
|
||||
inbox_id: inboxId,
|
||||
subject,
|
||||
participants,
|
||||
});
|
||||
pendingGroupNavigation.set(data.group_jid);
|
||||
groupFormRef.value?.resetForm();
|
||||
discardCompose();
|
||||
useAlert(t('GROUP.CREATE.SUCCESS_MESSAGE'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.CREATE.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
fetchInboxSignatures,
|
||||
@ -138,10 +206,6 @@ const onContactSearch = debounce(
|
||||
false
|
||||
);
|
||||
|
||||
const resetContacts = () => {
|
||||
contacts.value = [];
|
||||
};
|
||||
|
||||
const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
let contact;
|
||||
if (action === 'create') {
|
||||
@ -186,24 +250,6 @@ const clearSelectedContact = () => {
|
||||
clearFormState();
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
selectedContact.value = null;
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
try {
|
||||
const data = await store.dispatch('contactConversations/create', {
|
||||
@ -269,7 +315,24 @@ const onModalBackdropClick = () => {
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
const navigateToGroup = ({ conversationId }) => {
|
||||
const url = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: conversationId,
|
||||
})
|
||||
);
|
||||
router.push({ path: url });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
resetContacts();
|
||||
emitter.on(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(BUS_EVENTS.NAVIGATE_TO_GROUP, navigateToGroup);
|
||||
});
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
@ -312,31 +375,106 @@ useKeyboardEvents(keyboardEvents);
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
<div
|
||||
v-if="!isGroupMode"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="resolvedMessageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:signature-settings="resolvedSignatureSettings"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
class="w-[42rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
v-if="hasGroupInboxes"
|
||||
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
!isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('conversation')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('group')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
|
||||
</button>
|
||||
</div>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="{ '!rounded-t-none !border-t-0': hasGroupInboxes }"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
:current-user="currentUser"
|
||||
:selected-contact="selectedContact"
|
||||
:target-inbox="targetInbox"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
:is-direct-uploads-enabled="directUploadsEnabled"
|
||||
:contact-conversations-ui-flags="uiFlags"
|
||||
:contacts-ui-flags="contactsUiFlags"
|
||||
:message-signature="resolvedMessageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:signature-settings="resolvedSignatureSettings"
|
||||
@search-contacts="onContactSearch"
|
||||
@reset-contact-search="resetContacts"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
class="w-[42rem] flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex gap-1 px-4 pt-3 pb-0 bg-n-alpha-3 border border-b-0 border-n-strong backdrop-blur-[100px] rounded-t-xl"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
!isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('conversation')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_CONVERSATION') }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-t-lg border-b-2 transition-colors"
|
||||
:class="
|
||||
isGroupMode
|
||||
? 'text-n-brand border-n-brand bg-n-alpha-2'
|
||||
: 'text-n-slate-11 border-transparent hover:text-n-slate-12'
|
||||
"
|
||||
@click="switchMode('group')"
|
||||
>
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.TAB_GROUP') }}
|
||||
</button>
|
||||
</div>
|
||||
<ComposeNewGroupForm
|
||||
ref="groupFormRef"
|
||||
class="!rounded-t-none !border-t-0"
|
||||
:inboxes="groupCreationInboxes"
|
||||
:is-creating="groupUiFlags.isCreating"
|
||||
:is-groups-disabled="isGroupsDisabled"
|
||||
@create-group="createGroup"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,305 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import ContactsAPI from 'dashboard/api/contacts';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isCreating: { type: Boolean, default: false },
|
||||
isGroupsDisabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['createGroup', 'discard']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const groupName = ref('');
|
||||
const selectedInbox = ref(null);
|
||||
const showInboxDropdown = ref(false);
|
||||
const participants = ref([]);
|
||||
const contactResults = ref([]);
|
||||
const showContactsDropdown = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const nameTouched = ref(false);
|
||||
const participantsTouched = ref(false);
|
||||
const participantsFocused = ref(false);
|
||||
|
||||
const inboxMenuItems = computed(() =>
|
||||
props.inboxes.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
action: 'select',
|
||||
}))
|
||||
);
|
||||
|
||||
const contactMenuItems = computed(() =>
|
||||
contactResults.value.map(contact => ({
|
||||
id: contact.id,
|
||||
label: contact.phone_number
|
||||
? `${contact.name} (${contact.phone_number})`
|
||||
: contact.name,
|
||||
value: contact.id,
|
||||
action: 'contact',
|
||||
thumbnail: { name: contact.name, src: contact.thumbnail },
|
||||
phoneNumber: contact.phone_number,
|
||||
name: contact.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const participantTags = computed(() =>
|
||||
participants.value.map(p => p.name || p.phone_number)
|
||||
);
|
||||
|
||||
const showNameError = computed(
|
||||
() => nameTouched.value && !groupName.value.trim()
|
||||
);
|
||||
const showParticipantsError = computed(
|
||||
() => participantsTouched.value && participants.value.length === 0
|
||||
);
|
||||
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
selectedInbox.value &&
|
||||
groupName.value.trim() &&
|
||||
participants.value.length > 0
|
||||
);
|
||||
|
||||
const searchContacts = debounce(
|
||||
async query => {
|
||||
if (!query || query.length < 2) {
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
return;
|
||||
}
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const { data } = await ContactsAPI.search(query);
|
||||
const selectedIds = participants.value.map(p => p.id);
|
||||
contactResults.value = (data.payload || []).filter(
|
||||
contact => contact.phone_number && !selectedIds.includes(contact.id)
|
||||
);
|
||||
showContactsDropdown.value = contactResults.value.length > 0;
|
||||
} catch {
|
||||
contactResults.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
},
|
||||
300,
|
||||
false
|
||||
);
|
||||
|
||||
const handleInboxAction = item => {
|
||||
const inbox = props.inboxes.find(i => i.id === item.value);
|
||||
selectedInbox.value = inbox;
|
||||
showInboxDropdown.value = false;
|
||||
};
|
||||
|
||||
const clearInbox = () => {
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
const handleAddParticipant = item => {
|
||||
const contact = contactResults.value.find(c => c.id === item.value);
|
||||
if (contact) {
|
||||
participants.value = [...participants.value, contact];
|
||||
participantsTouched.value = true;
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveParticipant = index => {
|
||||
participants.value = participants.value.filter((_, i) => i !== index);
|
||||
participantsTouched.value = true;
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
nameTouched.value = true;
|
||||
};
|
||||
|
||||
const handleParticipantsFocus = () => {
|
||||
participantsFocused.value = true;
|
||||
};
|
||||
|
||||
const handleParticipantsBlur = () => {
|
||||
showContactsDropdown.value = false;
|
||||
if (participantsFocused.value && participants.value.length === 0) {
|
||||
participantsTouched.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
groupName.value = '';
|
||||
selectedInbox.value = null;
|
||||
participants.value = [];
|
||||
contactResults.value = [];
|
||||
showContactsDropdown.value = false;
|
||||
showInboxDropdown.value = false;
|
||||
nameTouched.value = false;
|
||||
participantsTouched.value = false;
|
||||
participantsFocused.value = false;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isFormValid.value) return;
|
||||
emit('createGroup', {
|
||||
inboxId: selectedInbox.value.id,
|
||||
subject: groupName.value.trim(),
|
||||
participants: participants.value.map(p => p.phone_number),
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ resetForm });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<div class="flex-1 divide-y divide-n-strong overflow-visible">
|
||||
<div
|
||||
v-if="isGroupsDisabled"
|
||||
class="flex items-center gap-2 mx-4 mt-3 px-3 py-2 rounded-lg text-sm text-n-amber-11 bg-n-amber-2"
|
||||
>
|
||||
<span class="i-lucide-triangle-alert text-base flex-shrink-0" />
|
||||
<span>
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED') }}
|
||||
<a
|
||||
:href="wootConstants.FAZER_AI_GUIDES_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline font-medium"
|
||||
>
|
||||
{{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
{{ t('GROUP.CREATE.INBOX_LABEL') }}
|
||||
</label>
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
v-if="selectedInbox"
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ selectedInbox.name }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="flex-shrink-0"
|
||||
@click="clearInbox"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<Button
|
||||
:label="t('GROUP.CREATE.INBOX_PLACEHOLDER')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="hover:!no-underline"
|
||||
@click="showInboxDropdown = !showInboxDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showInboxDropdown"
|
||||
:menu-items="inboxMenuItems"
|
||||
class="z-[100] top-9 w-full max-h-48 overflow-y-auto dark:!outline-n-slate-5"
|
||||
@action="handleInboxAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-start flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
|
||||
>
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap mt-1"
|
||||
:class="showNameError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ t('GROUP.CREATE.NAME_LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<input
|
||||
v-model="groupName"
|
||||
type="text"
|
||||
class="w-full px-2 py-1 text-sm rounded-md bg-transparent text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none border"
|
||||
:class="showNameError ? 'border-n-ruby-9' : 'border-transparent'"
|
||||
:placeholder="t('GROUP.CREATE.NAME_PLACEHOLDER')"
|
||||
@blur="handleNameBlur"
|
||||
/>
|
||||
<span v-if="showNameError" class="text-xs text-n-ruby-9 mt-0.5 px-2">
|
||||
{{ t('GROUP.CREATE.NAME_REQUIRED') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-1 px-4 py-3">
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium whitespace-nowrap"
|
||||
:class="showParticipantsError ? 'text-n-ruby-9' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ t('GROUP.CREATE.PARTICIPANTS_LABEL') }}
|
||||
</label>
|
||||
<TagInput
|
||||
:model-value="participantTags"
|
||||
:placeholder="t('GROUP.CREATE.PARTICIPANTS_PLACEHOLDER')"
|
||||
mode="multiple"
|
||||
:menu-items="contactMenuItems"
|
||||
:show-dropdown="showContactsDropdown"
|
||||
:is-loading="isSearching"
|
||||
skip-label-dedup
|
||||
:auto-open-dropdown="false"
|
||||
:class="showParticipantsError ? '!border-n-ruby-9' : ''"
|
||||
@input="searchContacts"
|
||||
@focus="handleParticipantsFocus"
|
||||
@on-click-outside="handleParticipantsBlur"
|
||||
@add="handleAddParticipant"
|
||||
@remove="handleRemoveParticipant"
|
||||
/>
|
||||
<span v-if="showParticipantsError" class="text-xs text-n-ruby-9">
|
||||
{{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<div />
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="
|
||||
resetForm();
|
||||
emit('discard');
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="t('GROUP.CREATE.SUBMIT_BUTTON')"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:disabled="!isFormValid || isCreating || isGroupsDisabled"
|
||||
:is-loading="isCreating"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -14,6 +14,7 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
REFERER: 'referer',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
GROUP_TYPE: 'group_type',
|
||||
};
|
||||
|
||||
export const CONTACT_ATTRIBUTES = {
|
||||
|
||||
@ -247,6 +247,20 @@ export function useConversationFilterContext() {
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.GROUP_TYPE,
|
||||
value: CONVERSATION_ATTRIBUTES.GROUP_TYPE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.GROUP_TYPE'),
|
||||
label: t('FILTER.ATTRIBUTES.GROUP_TYPE'),
|
||||
inputType: 'multiSelect',
|
||||
options: ['individual', 'group'].map(id => ({
|
||||
id,
|
||||
name: t(`GROUP.FILTER.${id.toUpperCase()}`),
|
||||
})),
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@ -102,7 +102,7 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
id: { type: [Number, String], required: true },
|
||||
messageType: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@ -127,11 +127,13 @@ const props = defineProps({
|
||||
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
groupWithPrevious: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inboxSupportsEdit: { type: Boolean, default: false },
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
isGroupConversation: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
@ -148,6 +150,7 @@ const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const router = useRouter();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
/**
|
||||
@ -245,7 +248,21 @@ const flexOrientationClass = computed(() => {
|
||||
return map[orientation.value];
|
||||
});
|
||||
|
||||
const isGroupIncoming = computed(() => {
|
||||
return (
|
||||
props.isGroupConversation && props.messageType === MESSAGE_TYPES.INCOMING
|
||||
);
|
||||
});
|
||||
|
||||
const showGroupSenderAvatar = computed(() => {
|
||||
return isGroupIncoming.value && !props.groupWithPrevious;
|
||||
});
|
||||
|
||||
const gridClass = computed(() => {
|
||||
if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) {
|
||||
return 'grid grid-cols-[24px_1fr]';
|
||||
}
|
||||
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
|
||||
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
|
||||
@ -255,6 +272,13 @@ const gridClass = computed(() => {
|
||||
});
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
if (orientation.value === ORIENTATION.LEFT && isGroupIncoming.value) {
|
||||
return `
|
||||
"avatar bubble"
|
||||
"spacer meta"
|
||||
`;
|
||||
}
|
||||
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: `
|
||||
"bubble"
|
||||
@ -502,6 +526,47 @@ const avatarTooltip = computed(() => {
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
|
||||
// Colors for group sender names, matching AVATAR_COLORS from Avatar component
|
||||
const SENDER_NAME_COLORS = {
|
||||
light: ['#C2298A', '#99543A', '#60646C', '#008573', '#4747C2', '#3A5BC7'],
|
||||
dark: ['#FF8DCC', '#FFA366', '#ADB1B8', '#0BD8B6', '#A19EFF', '#9EB1FF'],
|
||||
};
|
||||
|
||||
const showGroupSenderName = computed(() => {
|
||||
return (
|
||||
props.isGroupConversation &&
|
||||
props.messageType === MESSAGE_TYPES.INCOMING &&
|
||||
!props.groupWithPrevious &&
|
||||
props.sender?.name
|
||||
);
|
||||
});
|
||||
|
||||
const senderNameStyle = computed(() => {
|
||||
if (!showGroupSenderName.value) return {};
|
||||
const name = props.sender?.name || '';
|
||||
const index = name.length % SENDER_NAME_COLORS.light.length;
|
||||
return {
|
||||
color: SENDER_NAME_COLORS.light[index],
|
||||
'--dark-sender-color': SENDER_NAME_COLORS.dark[index],
|
||||
};
|
||||
});
|
||||
|
||||
const navigateToGroupSender = event => {
|
||||
if (
|
||||
!isGroupIncoming.value ||
|
||||
!props.sender?.id ||
|
||||
props.sender.type?.toLowerCase() !== 'contact'
|
||||
)
|
||||
return;
|
||||
const accountId = route.params.accountId;
|
||||
const url = `/app/accounts/${accountId}/contacts/${props.sender.id}`;
|
||||
if (event?.ctrlKey || event?.metaKey) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
};
|
||||
|
||||
const setupHighlightTimer = () => {
|
||||
if (Number(route.query.messageId) !== Number(props.id)) {
|
||||
return;
|
||||
@ -558,6 +623,18 @@ provideMessageContext({
|
||||
gridTemplateAreas: gridTemplate,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="showGroupSenderAvatar"
|
||||
class="[grid-area:avatar] flex items-end"
|
||||
>
|
||||
<Avatar
|
||||
v-tooltip.right-end="avatarTooltip"
|
||||
v-bind="avatarInfo"
|
||||
:size="24"
|
||||
class="cursor-pointer"
|
||||
@click="navigateToGroupSender($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!shouldGroupWithNext && shouldShowAvatar"
|
||||
v-tooltip.left-end="avatarTooltip"
|
||||
@ -565,16 +642,25 @@ provideMessageContext({
|
||||
>
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<div class="[grid-area:bubble]" @contextmenu="openContextMenu($event)">
|
||||
<span
|
||||
v-if="showGroupSenderName"
|
||||
class="text-xs font-medium mb-0.5 inline-block ltr:mr-8 rtl:ml-8 cursor-pointer hover:underline dark:!text-[var(--dark-sender-color)]"
|
||||
:style="senderNameStyle"
|
||||
@click="navigateToGroupSender($event)"
|
||||
>
|
||||
{{ sender?.name }}
|
||||
</span>
|
||||
<div
|
||||
class="flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
</div>
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@ -23,7 +23,7 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
firstUnreadId: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
isAnEmailChannel: {
|
||||
@ -55,6 +55,10 @@ const allMessages = computed(() => {
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
const isGroupConversation = computed(
|
||||
() => currentChat.value?.group_type === 'group'
|
||||
);
|
||||
|
||||
// Cache for fetched reply messages to avoid duplicate API calls
|
||||
const fetchedReplyMessages = reactive(new Map());
|
||||
|
||||
@ -180,6 +184,10 @@ const getInReplyToMessage = parentMessage => {
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
:in-reply-to="getInReplyToMessage(message)"
|
||||
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
||||
:group-with-previous="
|
||||
index > 0 && shouldGroupWithNext(index - 1, allMessages)
|
||||
"
|
||||
:is-group-conversation="isGroupConversation"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:inbox-supports-edit="inboxSupportsEdit"
|
||||
:current-user-id="currentUserId"
|
||||
|
||||
@ -139,7 +139,10 @@ const iconColorClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<span class="inline">
|
||||
<span v-dompurify-html="formattedContent" class="prose prose-bubble" />
|
||||
<span
|
||||
v-dompurify-html="formattedContent"
|
||||
class="prose prose-bubble [&_.prosemirror-mention-contact]:bg-n-blue-3 [&_.prosemirror-mention-contact]:rounded [&_.prosemirror-mention-contact]:px-1 [&_.prosemirror-mention-contact]:font-medium"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowScheduledIndicator"
|
||||
v-tooltip.top="{
|
||||
|
||||
@ -6,6 +6,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
@ -29,6 +33,7 @@ const updateValue = () => {
|
||||
type="button"
|
||||
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
|
||||
:disabled="props.disabled"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
@click="updateValue"
|
||||
|
||||
@ -98,6 +98,7 @@ provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
|
||||
const activeGroupType = ref('');
|
||||
const showAdvancedFilters = ref(false);
|
||||
// chatsOnView is to store the chats that are currently visible on the screen,
|
||||
// which mirrors the conversationList.
|
||||
@ -285,6 +286,7 @@ const conversationFilters = computed(() => {
|
||||
labels: props.label ? [props.label] : undefined,
|
||||
teamId: props.teamId || undefined,
|
||||
conversationType: props.conversationType || undefined,
|
||||
groupType: activeGroupType.value || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@ -373,13 +375,14 @@ const uniqueInboxes = computed(() => {
|
||||
// ---------------------- Methods -----------------------
|
||||
function setFiltersFromUISettings() {
|
||||
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
|
||||
const { status, order_by: orderBy } = filterBy;
|
||||
const { status, order_by: orderBy, group_type: groupType } = filterBy;
|
||||
activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
|
||||
activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
|
||||
orderBy
|
||||
)
|
||||
? orderBy
|
||||
: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
|
||||
activeGroupType.value = groupType || '';
|
||||
}
|
||||
|
||||
function emitConversationLoaded() {
|
||||
@ -488,6 +491,10 @@ function setParamsForEditFolderModal() {
|
||||
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
|
||||
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
|
||||
],
|
||||
group_type: [
|
||||
{ id: 'individual', name: t('GROUP.FILTER.INDIVIDUAL') },
|
||||
{ id: 'group', name: t('GROUP.FILTER.GROUP') },
|
||||
],
|
||||
filterTypes: advancedFilterTypes.value,
|
||||
allCustomAttributes: conversationCustomAttributes.value,
|
||||
};
|
||||
@ -632,6 +639,8 @@ function updateAssigneeTab(selectedTab) {
|
||||
function onBasicFilterChange(value, type) {
|
||||
if (type === 'status') {
|
||||
activeStatus.value = value;
|
||||
} else if (type === 'group_type') {
|
||||
activeGroupType.value = value;
|
||||
} else {
|
||||
activeSortBy.value = value;
|
||||
}
|
||||
@ -829,6 +838,7 @@ onMounted(() => {
|
||||
setFiltersFromUISettings();
|
||||
store.dispatch('setChatStatusFilter', activeStatus.value);
|
||||
store.dispatch('setChatSortFilter', activeSortBy.value);
|
||||
store.dispatch('setChatGroupTypeFilter', activeGroupType.value);
|
||||
resetAndFetchData();
|
||||
if (hasActiveFolders.value) {
|
||||
store.dispatch('campaigns/get');
|
||||
|
||||
@ -31,26 +31,35 @@ const showPopover = () => {
|
||||
const onNewToastMessage = ({ message: originalMessage, action }) => {
|
||||
const message = action?.usei18n ? t(originalMessage) : originalMessage;
|
||||
const duration = action?.duration || props.duration;
|
||||
const key = action?.key || Date.now();
|
||||
|
||||
snackMessages.value.push({
|
||||
key: Date.now(),
|
||||
key,
|
||||
message,
|
||||
action,
|
||||
});
|
||||
|
||||
nextTick(showPopover);
|
||||
|
||||
setTimeout(() => {
|
||||
snackMessages.value.shift();
|
||||
}, duration);
|
||||
if (!action?.persistent) {
|
||||
setTimeout(() => {
|
||||
snackMessages.value = snackMessages.value.filter(m => m.key !== key);
|
||||
}, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const onDismissToastMessage = ({ key }) => {
|
||||
snackMessages.value = snackMessages.value.filter(m => m.key !== key);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on('newToastMessage', onNewToastMessage);
|
||||
emitter.on('dismissToastMessage', onDismissToastMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off('newToastMessage', onNewToastMessage);
|
||||
emitter.off('dismissToastMessage', onDismissToastMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white woot-banner"
|
||||
class="flex items-center justify-center min-h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white woot-banner"
|
||||
:class="bannerClasses"
|
||||
>
|
||||
<span class="banner-message">
|
||||
@ -152,7 +152,7 @@ export default {
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
@apply flex items-center;
|
||||
@apply inline;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import CannedResponse from '../conversation/CannedResponse.vue';
|
||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import TagGroupMembers from '../conversation/TagGroupMembers.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import TagTools from '../conversation/TagTools.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
@ -27,6 +28,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
@ -96,6 +98,9 @@ const props = defineProps({
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
enableCopilot: { type: Boolean, default: true },
|
||||
isGroupConversation: { type: Boolean, default: false },
|
||||
groupContactId: { type: [Number, String], default: null },
|
||||
inboxPhoneNumber: { type: String, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@ -292,7 +297,10 @@ const plugins = computed(() => {
|
||||
trigger: '@',
|
||||
showMenu: showUserMentions,
|
||||
searchTerm: mentionSearchKey,
|
||||
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
|
||||
isAllowed: () =>
|
||||
props.isPrivate ||
|
||||
props.isGroupConversation ||
|
||||
!props.enableCaptainTools,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '/',
|
||||
@ -350,7 +358,10 @@ const formattedSignature = computed(() => {
|
||||
});
|
||||
|
||||
watch(showUserMentions, updatedValue => {
|
||||
emit('toggleUserMention', props.isPrivate && updatedValue);
|
||||
emit(
|
||||
'toggleUserMention',
|
||||
(props.isPrivate || props.isGroupConversation) && updatedValue
|
||||
);
|
||||
});
|
||||
watch(showCannedMenu, updatedValue => {
|
||||
emit('toggleCannedMenu', !props.isPrivate && updatedValue);
|
||||
@ -818,6 +829,13 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
'opacity-50 cursor-not-allowed pointer-events-none': disabled,
|
||||
}"
|
||||
>
|
||||
<TagGroupMembers
|
||||
v-if="showUserMentions && isGroupConversation && !isPrivate"
|
||||
:search-key="mentionSearchKey"
|
||||
:group-contact-id="groupContactId"
|
||||
:exclude-phone-number="inboxPhoneNumber"
|
||||
@select-agent="content => insertSpecialContent('mention', content)"
|
||||
/>
|
||||
<TagAgents
|
||||
v-if="showUserMentions && isPrivate"
|
||||
:search-key="mentionSearchKey"
|
||||
|
||||
@ -25,6 +25,7 @@ const { updateUISettings } = useUISettings();
|
||||
|
||||
const chatStatusFilter = useMapGetter('getChatStatusFilter');
|
||||
const chatSortFilter = useMapGetter('getChatSortFilter');
|
||||
const chatGroupTypeFilter = useMapGetter('getChatGroupTypeFilter');
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
@ -38,6 +39,8 @@ const currentSortBy = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const currentGroupType = computed(() => chatGroupTypeFilter.value || '');
|
||||
|
||||
const chatStatusOptions = computed(() => [
|
||||
{
|
||||
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
|
||||
@ -96,6 +99,12 @@ const chatSortOptions = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const chatGroupTypeOptions = computed(() => [
|
||||
{ label: t('GROUP.FILTER.ALL'), value: '' },
|
||||
{ label: t('GROUP.FILTER.INDIVIDUAL'), value: 'individual' },
|
||||
{ label: t('GROUP.FILTER.GROUP'), value: 'group' },
|
||||
]);
|
||||
|
||||
const activeChatStatusLabel = computed(
|
||||
() =>
|
||||
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
|
||||
@ -108,11 +117,18 @@ const activeChatSortLabel = computed(
|
||||
''
|
||||
);
|
||||
|
||||
const activeGroupTypeLabel = computed(
|
||||
() =>
|
||||
chatGroupTypeOptions.value.find(m => m.value === chatGroupTypeFilter.value)
|
||||
?.label || t('GROUP.FILTER.ALL')
|
||||
);
|
||||
|
||||
const saveSelectedFilter = (type, value) => {
|
||||
updateUISettings({
|
||||
conversations_filter_by: {
|
||||
status: type === 'status' ? value : currentStatusFilter.value,
|
||||
order_by: type === 'sort' ? value : currentSortBy.value,
|
||||
group_type: type === 'group_type' ? value : currentGroupType.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -128,6 +144,12 @@ const handleSortChange = value => {
|
||||
store.dispatch('setChatSortFilter', value);
|
||||
saveSelectedFilter('sort', value);
|
||||
};
|
||||
|
||||
const handleGroupTypeChange = value => {
|
||||
emit('changeFilter', value, 'group_type');
|
||||
store.dispatch('setChatGroupTypeFilter', value);
|
||||
saveSelectedFilter('group_type', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -143,13 +165,13 @@ const handleSortChange = value => {
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-click-outside="() => toggleDropdown()"
|
||||
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
|
||||
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full flex flex-col gap-4"
|
||||
:class="{
|
||||
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
|
||||
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between last:mt-4 gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ $t('CHAT_LIST.CHAT_SORT.STATUS') }}
|
||||
</span>
|
||||
@ -161,7 +183,7 @@ const handleSortChange = value => {
|
||||
@update:model-value="handleStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between last:mt-4 gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ $t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}
|
||||
</span>
|
||||
@ -173,6 +195,18 @@ const handleSortChange = value => {
|
||||
@update:model-value="handleSortChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ $t('GROUP.FILTER.TYPE_LABEL') }}
|
||||
</span>
|
||||
<SelectMenu
|
||||
:model-value="chatGroupTypeFilter"
|
||||
:options="chatGroupTypeOptions"
|
||||
:label="activeGroupTypeLabel"
|
||||
:sub-menu-position="isOnExpandedLayout ? 'left' : 'right'"
|
||||
@update:model-value="handleGroupTypeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -59,6 +59,7 @@ const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
|
||||
@ -78,7 +79,23 @@ const isActiveChat = computed(() => {
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const isGroupsDisabled = computed(() => {
|
||||
return (
|
||||
props.chat.group_type === 'group' &&
|
||||
!globalConfig.value.baileysWhatsappGroupsEnabled
|
||||
);
|
||||
});
|
||||
|
||||
const hasGroupActivity = computed(() => {
|
||||
if (!isGroupsDisabled.value) return false;
|
||||
const lastActivity = props.chat.last_activity_at;
|
||||
const agentSeen = props.chat.agent_last_seen_at;
|
||||
return lastActivity > 0 && (!agentSeen || lastActivity > agentSeen);
|
||||
});
|
||||
|
||||
const hasUnread = computed(
|
||||
() => unreadCount.value > 0 || hasGroupActivity.value
|
||||
);
|
||||
|
||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
@ -355,11 +372,15 @@ const deleteConversation = () => {
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="hasUnread && unreadCount > 0"
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="hasUnread"
|
||||
class="shadow-lg rounded-full ltr:ml-auto rtl:mr-auto mt-1 size-2 bg-n-teal-9"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
|
||||
@ -38,6 +38,7 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import WhatsappLinkDeviceModal from '../../../routes/dashboard/settings/inbox/components/WhatsappLinkDeviceModal.vue';
|
||||
import { isInboxAdminInGroup } from 'dashboard/helper/phoneHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -100,6 +101,7 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
currentInbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.currentChat.inbox_id);
|
||||
@ -258,6 +260,66 @@ export default {
|
||||
// Currently only Baileys WhatsApp channel supports message editing
|
||||
return this.isAWhatsAppBaileysChannel;
|
||||
},
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isGroupConversation() {
|
||||
return this.currentChat?.group_type === 'group';
|
||||
},
|
||||
groupContactId() {
|
||||
return this.currentChat?.meta?.sender?.id || null;
|
||||
},
|
||||
groupMembers() {
|
||||
if (!this.groupContactId) return [];
|
||||
return (
|
||||
this.$store.getters['groupMembers/getGroupMembers'](
|
||||
this.groupContactId
|
||||
) || []
|
||||
);
|
||||
},
|
||||
groupMembersMeta() {
|
||||
if (!this.groupContactId) return {};
|
||||
return (
|
||||
this.$store.getters['groupMembers/getGroupMembersMeta'](
|
||||
this.groupContactId
|
||||
) || {}
|
||||
);
|
||||
},
|
||||
isInboxAdminInCurrentGroup() {
|
||||
const meta = this.groupMembersMeta;
|
||||
if (meta.is_inbox_admin != null) return meta.is_inbox_admin;
|
||||
const inboxPhone = meta.inbox_phone_number || this.inbox?.phone_number;
|
||||
return isInboxAdminInGroup(inboxPhone, this.groupMembers);
|
||||
},
|
||||
isGroupMembersLoaded() {
|
||||
const meta = this.groupMembersMeta;
|
||||
return meta.is_inbox_admin != null || this.groupMembers.length > 0;
|
||||
},
|
||||
isAnnouncementModeRestricted() {
|
||||
return (
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
this.currentContact?.additional_attributes?.announce === true &&
|
||||
this.isGroupMembersLoaded &&
|
||||
!this.isInboxAdminInCurrentGroup
|
||||
);
|
||||
},
|
||||
isGroupLeft() {
|
||||
return (
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
this.currentContact?.additional_attributes?.group_left === true
|
||||
);
|
||||
},
|
||||
isGroupsDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
!this.globalConfig.baileysWhatsappGroupsEnabled
|
||||
);
|
||||
},
|
||||
inboxProviderConnection() {
|
||||
return this.currentInbox.provider_connection?.connection;
|
||||
},
|
||||
@ -272,6 +334,21 @@ export default {
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
},
|
||||
groupContactId: {
|
||||
immediate: true,
|
||||
handler(contactId) {
|
||||
if (
|
||||
contactId &&
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
!this.isGroupMembersLoaded
|
||||
) {
|
||||
this.$store.dispatch('groupMembers/fetch', {
|
||||
contactId,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
@ -466,6 +543,9 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onOpenGroupsEnabledLink() {
|
||||
window.open(wootConstants.FAZER_AI_GUIDES_URL, '_blank');
|
||||
},
|
||||
onOpenLinkDeviceModal() {
|
||||
this.showLinkDeviceModal = true;
|
||||
},
|
||||
@ -537,6 +617,27 @@ export default {
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="isGroupLeft"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.GROUP_LEFT_BANNER')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="isAnnouncementModeRestricted"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.ANNOUNCEMENT_MODE_BANNER')"
|
||||
/>
|
||||
<Banner
|
||||
v-if="isGroupsDisabled"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.GROUPS_DISABLED_BANNER')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.GROUPS_DISABLED_CTA')"
|
||||
@primary-action="onOpenGroupsEnabledLink"
|
||||
/>
|
||||
<MessageList
|
||||
ref="conversationPanelRef"
|
||||
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
|
||||
|
||||
@ -53,6 +53,7 @@ import { appendSignature } from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||
import { isInboxAdminInGroup } from 'dashboard/helper/phoneHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@ -169,6 +170,64 @@ export default {
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isGroupConversation() {
|
||||
return this.currentChat?.group_type === 'group';
|
||||
},
|
||||
groupContactId() {
|
||||
return this.currentChat?.meta?.sender?.id || null;
|
||||
},
|
||||
inboxPhoneNumber() {
|
||||
return this.inbox?.phone_number || null;
|
||||
},
|
||||
groupMembers() {
|
||||
if (!this.groupContactId) return [];
|
||||
return (
|
||||
this.$store.getters['groupMembers/getGroupMembers'](
|
||||
this.groupContactId
|
||||
) || []
|
||||
);
|
||||
},
|
||||
groupMembersMeta() {
|
||||
if (!this.groupContactId) return {};
|
||||
return (
|
||||
this.$store.getters['groupMembers/getGroupMembersMeta'](
|
||||
this.groupContactId
|
||||
) || {}
|
||||
);
|
||||
},
|
||||
isInboxAdminInCurrentGroup() {
|
||||
const meta = this.groupMembersMeta;
|
||||
if (meta.is_inbox_admin != null) return meta.is_inbox_admin;
|
||||
const inboxPhone = meta.inbox_phone_number || this.inboxPhoneNumber;
|
||||
return isInboxAdminInGroup(inboxPhone, this.groupMembers);
|
||||
},
|
||||
isGroupMembersLoaded() {
|
||||
const meta = this.groupMembersMeta;
|
||||
return meta.is_inbox_admin != null || this.groupMembers.length > 0;
|
||||
},
|
||||
isAnnouncementModeRestricted() {
|
||||
return (
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
this.currentContact?.additional_attributes?.announce === true &&
|
||||
this.isGroupMembersLoaded &&
|
||||
!this.isInboxAdminInCurrentGroup
|
||||
);
|
||||
},
|
||||
isGroupLeft() {
|
||||
return (
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
this.currentContact?.additional_attributes?.group_left === true
|
||||
);
|
||||
},
|
||||
isGroupsDisabled() {
|
||||
return (
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
!this.globalConfig.baileysWhatsappGroupsEnabled
|
||||
);
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
this.inReplyTo?.id &&
|
||||
@ -211,6 +270,15 @@ export default {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
if (this.isGroupsDisabled && !this.isOnPrivateNote) {
|
||||
return this.$t('CONVERSATION.FOOTER.GROUPS_DISABLED_RESTRICTED');
|
||||
}
|
||||
if (this.isGroupLeft && !this.isOnPrivateNote) {
|
||||
return this.$t('CONVERSATION.FOOTER.GROUP_LEFT_RESTRICTED');
|
||||
}
|
||||
if (this.isAnnouncementModeRestricted && !this.isOnPrivateNote) {
|
||||
return this.$t('CONVERSATION.FOOTER.ANNOUNCEMENT_MODE_RESTRICTED');
|
||||
}
|
||||
if (this.isEditorDisabled) {
|
||||
return this.isAWhatsAppChannel
|
||||
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
|
||||
@ -446,6 +514,15 @@ export default {
|
||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||
},
|
||||
isEditorDisabled() {
|
||||
if (this.isGroupsDisabled && !this.isOnPrivateNote) {
|
||||
return true;
|
||||
}
|
||||
if (this.isGroupLeft && !this.isOnPrivateNote) {
|
||||
return true;
|
||||
}
|
||||
if (this.isAnnouncementModeRestricted && !this.isOnPrivateNote) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
this.isAWhatsAppChannel &&
|
||||
!this.isOnPrivateNote &&
|
||||
@ -519,6 +596,21 @@ export default {
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
groupContactId: {
|
||||
immediate: true,
|
||||
handler(contactId) {
|
||||
if (
|
||||
contactId &&
|
||||
this.isAWhatsAppBaileysChannel &&
|
||||
this.isGroupConversation &&
|
||||
!this.isGroupMembersLoaded
|
||||
) {
|
||||
this.$store.dispatch('groupMembers/fetch', {
|
||||
contactId,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
replyType(updatedReplyType, oldReplyType) {
|
||||
this.setToDraft(this.conversationIdByRoute, oldReplyType);
|
||||
this.getFromDraft();
|
||||
@ -1369,6 +1461,9 @@ export default {
|
||||
:signature-separator-override="signatureSeparator"
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
:is-group-conversation="isGroupConversation"
|
||||
:group-contact-id="groupContactId"
|
||||
:inbox-phone-number="inboxPhoneNumber"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
|
||||
@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
groupContactId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
excludePhoneNumber: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectAgent']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const groupMembers = computed(() =>
|
||||
getters['groupMembers/getGroupMembers'].value(props.groupContactId)
|
||||
);
|
||||
|
||||
const tagGroupMembersRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const items = computed(() => {
|
||||
const search = props.searchKey?.trim().toLowerCase() || '';
|
||||
|
||||
const memberItems = groupMembers.value
|
||||
.filter(member => member.is_active)
|
||||
.filter(member => member.contact?.phone_number !== props.excludePhoneNumber)
|
||||
.map(member => ({
|
||||
id: member.contact?.id || member.id,
|
||||
type: 'contact',
|
||||
displayName: member.contact?.name || member.contact?.phone_number || '',
|
||||
displayInfo: member.contact?.phone_number || '',
|
||||
thumbnail: member.contact?.thumbnail || '',
|
||||
name: member.contact?.name || member.contact?.phone_number || '',
|
||||
}))
|
||||
.filter(item =>
|
||||
search
|
||||
? item.displayName.toLowerCase().includes(search) ||
|
||||
item.displayInfo.toLowerCase().includes(search)
|
||||
: true
|
||||
);
|
||||
|
||||
const allItems = [...memberItems];
|
||||
|
||||
if (!allItems.length) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'header',
|
||||
title: t('GROUP.MENTION.DROPDOWN_HEADER'),
|
||||
id: 'group-members-header',
|
||||
},
|
||||
...allItems,
|
||||
];
|
||||
});
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
return items.value.filter(item => item.type !== 'header');
|
||||
});
|
||||
|
||||
const getSelectableIndex = item => {
|
||||
return selectableItems.value.findIndex(
|
||||
selectableItem =>
|
||||
selectableItem.type === item.type && selectableItem.id === item.id
|
||||
);
|
||||
};
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
if (tagGroupMembersRef.value) {
|
||||
const selectedElement = tagGroupMembersRef.value.querySelector(
|
||||
`#mention-item-${selectedIndex.value}`
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
emit('selectAgent', selectableItems.value[selectedIndex.value]);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items: selectableItems,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
watch(selectableItems, newList => {
|
||||
if (newList.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const onHover = index => {
|
||||
selectedIndex.value = index;
|
||||
};
|
||||
|
||||
const onMemberSelect = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
ref="tagGroupMembersRef"
|
||||
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
|
||||
role="listbox"
|
||||
>
|
||||
<li
|
||||
v-for="item in items"
|
||||
:id="
|
||||
item.type === 'header'
|
||||
? undefined
|
||||
: `mention-item-${getSelectableIndex(item)}`
|
||||
"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div
|
||||
v-if="item.type === 'header'"
|
||||
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<!-- Selectable Item -->
|
||||
<div
|
||||
v-else
|
||||
:class="{
|
||||
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
|
||||
role="option"
|
||||
@click="onMemberSelect(getSelectableIndex(item))"
|
||||
@mouseover="onHover(getSelectableIndex(item))"
|
||||
>
|
||||
<div class="ltr:mr-2 rtl:ml-2">
|
||||
<Avatar
|
||||
:src="item.thumbnail"
|
||||
:name="item.displayName"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
<h5
|
||||
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
|
||||
:class="{
|
||||
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.displayName }}
|
||||
</h5>
|
||||
<div
|
||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
|
||||
:class="{
|
||||
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.displayInfo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@ -110,6 +110,14 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_5,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'group_type',
|
||||
attributeI18nKey: 'GROUP_TYPE',
|
||||
inputType: 'multi_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
|
||||
export const filterAttributeGroups = [
|
||||
@ -153,6 +161,10 @@ export const filterAttributeGroups = [
|
||||
key: 'last_activity_at',
|
||||
i18nKey: 'LAST_ACTIVITY',
|
||||
},
|
||||
{
|
||||
key: 'group_type',
|
||||
i18nKey: 'GROUP_TYPE',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import TagGroupMembers from '../TagGroupMembers.vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
|
||||
useKeyboardNavigableList: vi.fn(),
|
||||
}));
|
||||
|
||||
const MEMBERS = [
|
||||
{
|
||||
id: 1,
|
||||
is_active: true,
|
||||
contact: {
|
||||
id: 10,
|
||||
name: 'Alice Silva',
|
||||
phone_number: '+5511999990001',
|
||||
thumbnail: 'alice.jpg',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
is_active: true,
|
||||
contact: {
|
||||
id: 20,
|
||||
name: 'Bob Santos',
|
||||
phone_number: '+5511999990002',
|
||||
thumbnail: 'bob.jpg',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
is_active: false,
|
||||
contact: {
|
||||
id: 30,
|
||||
name: 'Charlie Inactive',
|
||||
phone_number: '+5511999990003',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
const getGroupMembersFn = vi.fn(contactId => {
|
||||
if (contactId === 100) return MEMBERS;
|
||||
return [];
|
||||
});
|
||||
|
||||
useStoreGetters.mockReturnValue({
|
||||
'groupMembers/getGroupMembers': { value: getGroupMembersFn },
|
||||
});
|
||||
|
||||
return mount(TagGroupMembers, {
|
||||
props: {
|
||||
groupContactId: 100,
|
||||
searchKey: '',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: { Avatar: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('TagGroupMembers', () => {
|
||||
it('does not include an @all/everyone item in the list', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const listItems = wrapper.findAll('[role="option"]');
|
||||
const names = listItems.map(el => el.text());
|
||||
const hasEveryone = names.some(
|
||||
n => n.includes('all') && !n.includes('Alice')
|
||||
);
|
||||
|
||||
expect(hasEveryone).toBe(false);
|
||||
});
|
||||
|
||||
it('renders only active members excluding the inbox phone number', () => {
|
||||
const wrapper = mountComponent({
|
||||
excludePhoneNumber: '+5511999990001',
|
||||
});
|
||||
|
||||
const listItems = wrapper.findAll('[role="option"]');
|
||||
|
||||
expect(listItems).toHaveLength(1);
|
||||
expect(listItems[0].text()).toContain('Bob Santos');
|
||||
});
|
||||
|
||||
it('filters members by search key matching name', () => {
|
||||
const wrapper = mountComponent({ searchKey: 'alice' });
|
||||
|
||||
const listItems = wrapper.findAll('[role="option"]');
|
||||
|
||||
expect(listItems).toHaveLength(1);
|
||||
expect(listItems[0].text()).toContain('Alice Silva');
|
||||
});
|
||||
|
||||
it('filters members by search key matching phone number', () => {
|
||||
const wrapper = mountComponent({ searchKey: '0002' });
|
||||
|
||||
const listItems = wrapper.findAll('[role="option"]');
|
||||
|
||||
expect(listItems).toHaveLength(1);
|
||||
expect(listItems[0].text()).toContain('Bob Santos');
|
||||
});
|
||||
|
||||
it('shows no dropdown when no members match the search', () => {
|
||||
const wrapper = mountComponent({ searchKey: 'nonexistent' });
|
||||
|
||||
const list = wrapper.find('ul');
|
||||
|
||||
expect(list.exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('emits selectAgent with the correct member on click', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const firstOption = wrapper.findAll('[role="option"]')[0];
|
||||
await firstOption.trigger('click');
|
||||
|
||||
expect(wrapper.emitted('selectAgent')).toBeTruthy();
|
||||
expect(wrapper.emitted('selectAgent')[0][0]).toMatchObject({
|
||||
id: 10,
|
||||
type: 'contact',
|
||||
name: 'Alice Silva',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a section header', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
const header = wrapper.find(
|
||||
'.text-xs.font-medium.tracking-wide.capitalize'
|
||||
);
|
||||
|
||||
expect(header.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -22,3 +22,21 @@ export const useTrack = (...args) => {
|
||||
export const useAlert = (message, action = null) => {
|
||||
emitter.emit('newToastMessage', { message, action });
|
||||
};
|
||||
|
||||
let pendingAlertCounter = 0;
|
||||
|
||||
/**
|
||||
* Shows a persistent toast that stays visible until explicitly dismissed.
|
||||
* Useful for long-running operations (e.g. "Adding member...").
|
||||
* @param {string} message - The message to display while the operation is in progress.
|
||||
* @returns {Function} dismiss - Call this function to remove the persistent toast.
|
||||
*/
|
||||
export const usePendingAlert = message => {
|
||||
pendingAlertCounter += 1;
|
||||
const key = `pending-${Date.now()}-${pendingAlertCounter}`;
|
||||
emitter.emit('newToastMessage', {
|
||||
message,
|
||||
action: { persistent: true, key },
|
||||
});
|
||||
return () => emitter.emit('dismissToastMessage', { key });
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import analyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { useTrack, useAlert } from '../index';
|
||||
import { useTrack, useAlert, usePendingAlert } from '../index';
|
||||
|
||||
vi.mock('shared/helpers/mitt', () => ({
|
||||
emitter: {
|
||||
@ -48,3 +48,63 @@ describe('useAlert', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePendingAlert', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should emit a persistent newToastMessage and return a dismiss function', () => {
|
||||
const message = 'Adding member...';
|
||||
const dismiss = usePendingAlert(message);
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
'newToastMessage',
|
||||
expect.objectContaining({
|
||||
message,
|
||||
action: expect.objectContaining({
|
||||
persistent: true,
|
||||
key: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(typeof dismiss).toBe('function');
|
||||
});
|
||||
|
||||
it('should emit dismissToastMessage with the matching key when dismiss is called', () => {
|
||||
const dismiss = usePendingAlert('Processing...');
|
||||
const emittedCall = emitter.emit.mock.calls.find(
|
||||
c => c[0] === 'newToastMessage'
|
||||
);
|
||||
const { key } = emittedCall[1].action;
|
||||
|
||||
dismiss();
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith('dismissToastMessage', { key });
|
||||
});
|
||||
|
||||
it('should generate unique keys for each call', () => {
|
||||
const dismiss1 = usePendingAlert('First');
|
||||
const dismiss2 = usePendingAlert('Second');
|
||||
|
||||
const calls = emitter.emit.mock.calls.filter(
|
||||
c => c[0] === 'newToastMessage'
|
||||
);
|
||||
const key1 = calls[0][1].action.key;
|
||||
const key2 = calls[1][1].action.key;
|
||||
|
||||
expect(key1).not.toBe(key2);
|
||||
|
||||
// Each dismiss should only dismiss its own toast
|
||||
dismiss1();
|
||||
expect(emitter.emit).toHaveBeenCalledWith('dismissToastMessage', {
|
||||
key: key1,
|
||||
});
|
||||
|
||||
dismiss2();
|
||||
expect(emitter.emit).toHaveBeenCalledWith('dismissToastMessage', {
|
||||
key: key2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,6 +38,7 @@ export default {
|
||||
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
|
||||
FAZER_AI_GUIDES_URL: 'https://app.fazer.ai/#/dashboard#guides',
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
|
||||
@ -4,6 +4,7 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import { pendingGroupNavigation } from 'dashboard/helper/pendingGroupNavigation';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@ -26,6 +27,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'presence.update': this.onPresenceUpdate,
|
||||
'contact.deleted': this.onContactDelete,
|
||||
'contact.updated': this.onContactUpdate,
|
||||
'contact.group_synced': this.onContactGroupSynced,
|
||||
'conversation.mentioned': this.onConversationMentioned,
|
||||
'notification.created': this.onNotificationCreated,
|
||||
'notification.deleted': this.onNotificationDeleted,
|
||||
@ -87,6 +89,13 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
onConversationCreated = data => {
|
||||
this.app.$store.dispatch('addConversation', data);
|
||||
this.fetchConversationStats();
|
||||
|
||||
const pendingJid = pendingGroupNavigation.consume();
|
||||
if (pendingJid && data.meta?.sender?.identifier === pendingJid) {
|
||||
emitter.emit(BUS_EVENTS.NAVIGATE_TO_GROUP, { conversationId: data.id });
|
||||
} else if (pendingJid) {
|
||||
pendingGroupNavigation.set(pendingJid);
|
||||
}
|
||||
};
|
||||
|
||||
onConversationRead = data => {
|
||||
@ -193,6 +202,16 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('contacts/updateContact', data);
|
||||
};
|
||||
|
||||
onContactGroupSynced = data => {
|
||||
this.app.$store.dispatch('groupMembers/setGroupMembers', {
|
||||
contactId: data.id,
|
||||
members: data.group_members,
|
||||
inboxPhoneNumber: data.inbox_phone_number,
|
||||
isInboxAdmin: data.is_inbox_admin,
|
||||
});
|
||||
this.app.$store.dispatch('contacts/updateContact', data);
|
||||
};
|
||||
|
||||
onNotificationCreated = data => {
|
||||
this.app.$store.dispatch('notifications/addNotification', data);
|
||||
};
|
||||
|
||||
@ -151,6 +151,10 @@ export const getConditionOptions = ({
|
||||
country_code: countries,
|
||||
message_type: messageTypeOptions,
|
||||
priority: priorityOptions,
|
||||
group_type: [
|
||||
{ id: 'individual', name: 'Individual' },
|
||||
{ id: 'group', name: 'Group' },
|
||||
],
|
||||
labels: generateConditionOptions(labels, 'title'),
|
||||
};
|
||||
|
||||
|
||||
@ -73,6 +73,10 @@ const getValuesForPriority = (values, priority) => {
|
||||
return priority.filter(option => values.includes(option.id));
|
||||
};
|
||||
|
||||
const getValuesForGroupType = (values, groupType) => {
|
||||
return groupType.filter(option => values.includes(option.id));
|
||||
};
|
||||
|
||||
export const getValuesForFilter = (filter, params) => {
|
||||
const { attribute_key, values } = filter;
|
||||
const {
|
||||
@ -84,6 +88,7 @@ export const getValuesForFilter = (filter, params) => {
|
||||
campaigns,
|
||||
labels,
|
||||
priority,
|
||||
group_type: groupType = [],
|
||||
} = params;
|
||||
switch (attribute_key) {
|
||||
case 'status':
|
||||
@ -104,6 +109,8 @@ export const getValuesForFilter = (filter, params) => {
|
||||
return getValuesForLanguages(values, languages);
|
||||
case 'country_code':
|
||||
return getValuesForCountries(values, countries);
|
||||
case 'group_type':
|
||||
return getValuesForGroupType(values, groupType);
|
||||
default:
|
||||
return { id: values[0], name: values[0] };
|
||||
}
|
||||
|
||||
26
app/javascript/dashboard/helper/pendingGroupNavigation.js
Normal file
26
app/javascript/dashboard/helper/pendingGroupNavigation.js
Normal file
@ -0,0 +1,26 @@
|
||||
// Simple module-level store for pending group creation navigation.
|
||||
// When a group is created, the group JID is stored here. When the
|
||||
// conversation.created websocket event arrives matching that JID,
|
||||
// the ActionCable handler emits a NAVIGATE_TO_GROUP bus event.
|
||||
|
||||
let pendingJid = null;
|
||||
let timeout = null;
|
||||
|
||||
const TIMEOUT_MS = 30_000; // 30 seconds to receive the GROUP_CREATE event
|
||||
|
||||
export const pendingGroupNavigation = {
|
||||
set(groupJid) {
|
||||
pendingJid = groupJid;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
pendingJid = null;
|
||||
}, TIMEOUT_MS);
|
||||
},
|
||||
|
||||
consume() {
|
||||
const jid = pendingJid;
|
||||
pendingJid = null;
|
||||
clearTimeout(timeout);
|
||||
return jid;
|
||||
},
|
||||
};
|
||||
24
app/javascript/dashboard/helper/phoneHelper.js
Normal file
24
app/javascript/dashboard/helper/phoneHelper.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compare phone numbers flexibly to handle format differences
|
||||
* (e.g. Brazilian 9th digit: +5587988465072 vs +558788465072)
|
||||
*/
|
||||
export const phonesMatch = (phoneA, phoneB) => {
|
||||
const a = phoneA?.replace(/\D/g, '');
|
||||
const b = phoneB?.replace(/\D/g, '');
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
return a.length >= 8 && b.length >= 8 && a.slice(-8) === b.slice(-8);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a given inbox phone number is admin in a group.
|
||||
* @param {string} inboxPhone - The inbox phone number
|
||||
* @param {Array} members - Array of group members with contact.phone_number and role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isInboxAdminInGroup = (inboxPhone, members) => {
|
||||
if (!inboxPhone) return false;
|
||||
return members.some(
|
||||
m => phonesMatch(inboxPhone, m.contact?.phone_number) && m.role === 'admin'
|
||||
);
|
||||
};
|
||||
@ -36,6 +36,60 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
|
||||
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
||||
});
|
||||
describe('contact.group_synced event handler', () => {
|
||||
it('should register the contact.group_synced event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain('contact.group_synced');
|
||||
expect(actionCable.events['contact.group_synced']).toBe(
|
||||
actionCable.onContactGroupSynced
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch groupMembers/setGroupMembers with contact id and members from payload', () => {
|
||||
const groupSyncedData = {
|
||||
id: 42,
|
||||
name: 'Test Group',
|
||||
account_id: 1,
|
||||
group_members: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
contact: {
|
||||
id: 10,
|
||||
name: 'Alice',
|
||||
phone_number: '+1234567890',
|
||||
thumbnail: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: 'member',
|
||||
is_active: true,
|
||||
contact: {
|
||||
id: 11,
|
||||
name: 'Bob',
|
||||
phone_number: '+0987654321',
|
||||
thumbnail: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'contact.group_synced',
|
||||
data: groupSyncedData,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
'groupMembers/setGroupMembers',
|
||||
{
|
||||
contactId: 42,
|
||||
members: groupSyncedData.group_members,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copilot event handlers', () => {
|
||||
it('should register the copilot.message.created event handler', () => {
|
||||
expect(Object.keys(actionCable.events)).toContain(
|
||||
|
||||
@ -62,7 +62,8 @@
|
||||
"CUSTOM_ATTRIBUTE_LINK": "Link",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity"
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"GROUP_TYPE": "Group type"
|
||||
},
|
||||
"ERRORS": {
|
||||
"VALUE_REQUIRED": "Value is required",
|
||||
|
||||
@ -606,6 +606,8 @@
|
||||
},
|
||||
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
"TAB_CONVERSATION": "Conversation",
|
||||
"TAB_GROUP": "Group",
|
||||
"CONTACT_SEARCH": {
|
||||
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
|
||||
},
|
||||
|
||||
@ -43,6 +43,10 @@
|
||||
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"ANNOUNCEMENT_MODE_BANNER": "Only administrators are allowed to send messages in this group",
|
||||
"GROUP_LEFT_BANNER": "You are no longer part of this group and cannot send messages in it",
|
||||
"GROUPS_DISABLED_BANNER": "Group messages are disabled. Enable full group support (free) to view and send messages.",
|
||||
"GROUPS_DISABLED_CTA": "Learn how to enable (free)",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
@ -197,6 +201,9 @@
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"ANNOUNCEMENT_MODE_RESTRICTED": "Only administrators are allowed to send messages in this group",
|
||||
"GROUP_LEFT_RESTRICTED": "You are no longer part of this group and cannot send messages in it",
|
||||
"GROUPS_DISABLED_RESTRICTED": "Group messages are disabled — enable for free",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
|
||||
126
app/javascript/dashboard/i18n/locale/en/groups.json
Normal file
126
app/javascript/dashboard/i18n/locale/en/groups.json
Normal file
@ -0,0 +1,126 @@
|
||||
{
|
||||
"GROUP": {
|
||||
"SIDEBAR_TITLE": "Group",
|
||||
"INFO": {
|
||||
"HEADER": "Group Info",
|
||||
"MEMBER_COUNT": "{count} members",
|
||||
"MEMBER_LIST_TITLE": "Members",
|
||||
"ADMIN_BADGE": "Admin",
|
||||
"YOU_BADGE": "You",
|
||||
"SYNC_BUTTON": "Sync",
|
||||
"SYNC_SUCCESS": "Group members synced successfully.",
|
||||
"SYNC_ERROR": "Failed to sync group members. Please try again.",
|
||||
"EMPTY_STATE": "No members found."
|
||||
},
|
||||
"FILTER": {
|
||||
"TYPE_LABEL": "Type",
|
||||
"ALL": "All",
|
||||
"INDIVIDUAL": "Individual",
|
||||
"GROUP": "Group"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create New Group",
|
||||
"INBOX_LABEL": "Inbox:",
|
||||
"INBOX_PLACEHOLDER": "Select a inbox",
|
||||
"NAME_LABEL": "Group Name:",
|
||||
"NAME_PLACEHOLDER": "Enter group name",
|
||||
"PARTICIPANTS_LABEL": "Participants:",
|
||||
"PARTICIPANTS_PLACEHOLDER": "Search contacts to add",
|
||||
"NAME_REQUIRED": "Group name is required",
|
||||
"PARTICIPANTS_REQUIRED": "Add at least one participant",
|
||||
"SUBMIT_BUTTON": "Create Group",
|
||||
"SUCCESS_MESSAGE": "Group created successfully.",
|
||||
"ERROR_MESSAGE": "Failed to create group. Please try again.",
|
||||
"GROUPS_DISABLED": "Group creation is disabled. Enable WhatsApp group support (free) to create groups.",
|
||||
"GROUPS_DISABLED_CTA": "Learn how to enable (free)"
|
||||
},
|
||||
"METADATA": {
|
||||
"EDIT_NAME_LABEL": "Group Name",
|
||||
"EDIT_NAME_PLACEHOLDER": "Enter group name",
|
||||
"EDIT_DESCRIPTION_LABEL": "Description",
|
||||
"EDIT_DESCRIPTION_PLACEHOLDER": "Enter group description",
|
||||
"EDIT_AVATAR_LABEL": "Group Photo",
|
||||
"SAVE_SUCCESS": "Group info updated successfully.",
|
||||
"SAVE_ERROR": "Failed to update group info. Please try again."
|
||||
},
|
||||
"INVITE": {
|
||||
"SECTION_TITLE": "Invite Link",
|
||||
"COPY_BUTTON": "Copy Link",
|
||||
"COPY_INVITE_LINK": "Copy Invite Link",
|
||||
"COPY_SUCCESS": "Invite link copied to clipboard.",
|
||||
"FETCH_ERROR": "Failed to load invite link."
|
||||
},
|
||||
"MEMBERS": {
|
||||
"ADD_BUTTON": "Add Member",
|
||||
"ADDING": "Adding member...",
|
||||
"ADD_SUCCESS": "Member added successfully.",
|
||||
"ADD_ERROR": "Failed to add member. Please try again.",
|
||||
"REMOVE_BUTTON": "Remove",
|
||||
"REMOVING": "Removing member...",
|
||||
"REMOVE_SUCCESS": "Member removed successfully.",
|
||||
"REMOVE_ERROR": "Failed to remove member. Please try again.",
|
||||
"PROMOTE_BUTTON": "Promote to Admin",
|
||||
"PROMOTING": "Promoting member...",
|
||||
"PROMOTE_SUCCESS": "Member promoted to admin.",
|
||||
"PROMOTE_ERROR": "Failed to promote member. Please try again.",
|
||||
"DEMOTE_BUTTON": "Demote to Member",
|
||||
"DEMOTING": "Demoting member...",
|
||||
"DEMOTE_SUCCESS": "Member demoted to member.",
|
||||
"DEMOTE_ERROR": "Failed to demote member. Please try again.",
|
||||
"GROUP_CREATOR_NOT_MODIFIABLE": "This member is the group creator and cannot be removed or demoted."
|
||||
},
|
||||
"JOIN_REQUESTS": {
|
||||
"SECTION_TITLE": "Pending Requests",
|
||||
"PENDING_COUNT": "{count} pending",
|
||||
"APPROVE_BUTTON": "Approve",
|
||||
"REJECT_BUTTON": "Reject",
|
||||
"PROCESSING": "Processing request...",
|
||||
"APPROVE_SUCCESS": "Join request approved.",
|
||||
"REJECT_SUCCESS": "Join request rejected.",
|
||||
"ACTION_ERROR": "Failed to process join request. Please try again."
|
||||
},
|
||||
"MENTION": {
|
||||
"DROPDOWN_HEADER": "Group Members",
|
||||
"EVERYONE": "Everyone",
|
||||
"EVERYONE_DESCRIPTION": "Notify all members"
|
||||
},
|
||||
"BAILEYS_OPTIONS": {
|
||||
"MEMBERS_CAN": "Group members can:",
|
||||
"ADMINS_CAN": "Group admins can:",
|
||||
"EDIT_GROUP_SETTINGS": "Edit group settings",
|
||||
"EDIT_GROUP_SETTINGS_DESCRIPTION": "Includes group name, image, description, disappearing messages duration, advanced conversation privacy, and allows pinning and saving messages in the conversation or reverting this action.",
|
||||
"SEND_MESSAGES": "Send new messages",
|
||||
"ADD_MEMBERS": "Add members",
|
||||
"RESET_INVITE_LINK": "Reset invite link",
|
||||
"RESET_INVITE_LINK_SUCCESS": "Invite link has been reset successfully.",
|
||||
"RESET_INVITE_LINK_ERROR": "Failed to reset invite link. Please try again.",
|
||||
"RESETTING_INVITE_LINK": "Resetting...",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_TITLE": "Restrict member additions?",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_DESCRIPTION": "By disabling this option, only admins will be able to add members and the current invite link will be reset. Do you want to continue?",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_YES": "Confirm",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_NO": "Cancel",
|
||||
"APPROVE_MEMBERS": "Approve new members",
|
||||
"APPROVE_MEMBERS_DESCRIPTION": "While this option is enabled, admins must approve new members joining the group."
|
||||
},
|
||||
"SETTINGS": {
|
||||
"SECTION_TITLE": "Group Settings",
|
||||
"ANNOUNCEMENT_MODE": "Announcement Mode",
|
||||
"ANNOUNCEMENT_MODE_DESCRIPTION": "Only admins can send messages",
|
||||
"LOCKED_MODE": "Locked Mode",
|
||||
"LOCKED_MODE_DESCRIPTION": "Only admins can edit group info",
|
||||
"JOIN_APPROVAL": "Admin Approval to Join",
|
||||
"JOIN_APPROVAL_DESCRIPTION": "Admins must approve new members",
|
||||
"ADVANCED_OPTIONS": "Advanced Options",
|
||||
"GROUP_LEFT_BANNER": "You are no longer a member of this group",
|
||||
"LEAVE_GROUP": "Leave Group",
|
||||
"LEAVING": "Leaving group...",
|
||||
"LEAVE_CONFIRM": "Are you sure you want to leave this group?",
|
||||
"LEAVE_CONFIRM_YES": "Leave",
|
||||
"LEAVE_CONFIRM_NO": "Cancel",
|
||||
"LEAVE_SUCCESS": "You have left the group.",
|
||||
"LEAVE_ERROR": "Failed to leave the group. Please try again.",
|
||||
"UPDATE_SUCCESS": "Group setting updated successfully.",
|
||||
"UPDATE_ERROR": "Failed to update group setting. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import groups from './groups.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
import attributesMgmt from './attributesMgmt.json';
|
||||
@ -43,6 +44,7 @@ import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
...groups,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
...attributesMgmt,
|
||||
|
||||
@ -62,7 +62,8 @@
|
||||
"CUSTOM_ATTRIBUTE_LINK": "Link",
|
||||
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
|
||||
"CREATED_AT": "Criado em",
|
||||
"LAST_ACTIVITY": "Última atividade"
|
||||
"LAST_ACTIVITY": "Última atividade",
|
||||
"GROUP_TYPE": "Tipo de conversa"
|
||||
},
|
||||
"ERRORS": {
|
||||
"VALUE_REQUIRED": "Valor obrigatório",
|
||||
|
||||
@ -603,6 +603,8 @@
|
||||
}
|
||||
},
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
"TAB_CONVERSATION": "Conversa",
|
||||
"TAB_GROUP": "Grupo",
|
||||
"CONTACT_SEARCH": {
|
||||
"ERROR_MESSAGE": "Não foi possível completar a pesquisa. Por favor, tente novamente."
|
||||
},
|
||||
|
||||
@ -43,6 +43,10 @@
|
||||
"BOT_HANDOFF_ERROR": "Falha ao resolver conversas. Por favor, tente novamente.",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "Você só pode responder a esta conversa usando um modelo de mensagem devido a",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "Restrições de janela de mensagem de 24 horas",
|
||||
"ANNOUNCEMENT_MODE_BANNER": "Apenas administradores têm permissão para enviar mensagens neste grupo",
|
||||
"GROUP_LEFT_BANNER": "Você não faz mais parte deste grupo e não pode enviar mensagens nele",
|
||||
"GROUPS_DISABLED_BANNER": "As mensagens de grupo estão desativadas. Ative o suporte completo a grupos (gratuito) para ver e enviar mensagens.",
|
||||
"GROUPS_DISABLED_CTA": "Saiba como ativar (gratuito)",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "Esta conta do Instagram foi migrada para a nova caixa de entrada do canal do Instagram. Todas as novas mensagens serão mostradas lá. Você não poderá mais enviar mensagens desta conversa.",
|
||||
"REPLYING_TO": "Você está respondendo a:",
|
||||
"REMOVE_SELECTION": "Remover seleção",
|
||||
@ -192,14 +196,17 @@
|
||||
"PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.",
|
||||
"CLICK_HERE": "Clique aqui para atualizar",
|
||||
"WHATSAPP_TEMPLATES": "Templates do Whatsapp"
|
||||
"WHATSAPP_TEMPLATES": "Templates do Whatsapp",
|
||||
"ANNOUNCEMENT_MODE_RESTRICTED": "Apenas administradores têm permissão para enviar mensagens neste grupo",
|
||||
"GROUP_LEFT_RESTRICTED": "Você não faz mais parte deste grupo e não pode enviar mensagens nele",
|
||||
"GROUPS_DISABLED_RESTRICTED": "As mensagens de grupo estão desativadas — ative gratuitamente"
|
||||
},
|
||||
"REPLYBOX": {
|
||||
"REPLY": "Responder",
|
||||
"PRIVATE_NOTE": "Mensagem Privada",
|
||||
"SEND": "Enviar",
|
||||
"CREATE": "Enviar",
|
||||
"INSERT_READ_MORE": "Saiba mais",
|
||||
"INSERT_READ_MORE": "Ler mais",
|
||||
"DISMISS_REPLY": "Dispensar resposta",
|
||||
"REPLYING_TO": "Respondendo a:",
|
||||
"TIP_EMOJI_ICON": "Mostrar seletor de emoji",
|
||||
|
||||
126
app/javascript/dashboard/i18n/locale/pt_BR/groups.json
Normal file
126
app/javascript/dashboard/i18n/locale/pt_BR/groups.json
Normal file
@ -0,0 +1,126 @@
|
||||
{
|
||||
"GROUP": {
|
||||
"SIDEBAR_TITLE": "Grupo",
|
||||
"INFO": {
|
||||
"HEADER": "Informações do Grupo",
|
||||
"MEMBER_COUNT": "{count} membros",
|
||||
"MEMBER_LIST_TITLE": "Membros",
|
||||
"ADMIN_BADGE": "Admin",
|
||||
"YOU_BADGE": "Você",
|
||||
"SYNC_BUTTON": "Sincronizar",
|
||||
"SYNC_SUCCESS": "Membros do grupo sincronizados com sucesso.",
|
||||
"SYNC_ERROR": "Falha ao sincronizar membros do grupo. Por favor, tente novamente.",
|
||||
"EMPTY_STATE": "Nenhum membro encontrado."
|
||||
},
|
||||
"FILTER": {
|
||||
"TYPE_LABEL": "Tipo",
|
||||
"ALL": "Todos",
|
||||
"INDIVIDUAL": "Individual",
|
||||
"GROUP": "Grupo"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Criar Novo Grupo",
|
||||
"INBOX_LABEL": "Caixa de entrada:",
|
||||
"INBOX_PLACEHOLDER": "Selecione uma caixa de entrada",
|
||||
"NAME_LABEL": "Nome do Grupo:",
|
||||
"NAME_PLACEHOLDER": "Digite o nome do grupo",
|
||||
"PARTICIPANTS_LABEL": "Participantes:",
|
||||
"PARTICIPANTS_PLACEHOLDER": "Pesquisar contatos para adicionar",
|
||||
"NAME_REQUIRED": "O nome do grupo é obrigatório",
|
||||
"PARTICIPANTS_REQUIRED": "Adicione pelo menos um participante",
|
||||
"SUBMIT_BUTTON": "Criar Grupo",
|
||||
"SUCCESS_MESSAGE": "Grupo criado com sucesso.",
|
||||
"ERROR_MESSAGE": "Falha ao criar grupo. Por favor, tente novamente.",
|
||||
"GROUPS_DISABLED": "A criação de grupos está desativada. Ative o suporte a grupos do WhatsApp (gratuito) para criar grupos.",
|
||||
"GROUPS_DISABLED_CTA": "Saiba como ativar (gratuito)"
|
||||
},
|
||||
"METADATA": {
|
||||
"EDIT_NAME_LABEL": "Nome do Grupo",
|
||||
"EDIT_NAME_PLACEHOLDER": "Digite o nome do grupo",
|
||||
"EDIT_DESCRIPTION_LABEL": "Descrição",
|
||||
"EDIT_DESCRIPTION_PLACEHOLDER": "Digite a descrição do grupo",
|
||||
"EDIT_AVATAR_LABEL": "Foto do Grupo",
|
||||
"SAVE_SUCCESS": "Informações do grupo atualizadas com sucesso.",
|
||||
"SAVE_ERROR": "Falha ao atualizar informações do grupo. Por favor, tente novamente."
|
||||
},
|
||||
"INVITE": {
|
||||
"SECTION_TITLE": "Link de Convite",
|
||||
"COPY_BUTTON": "Copiar Link",
|
||||
"COPY_INVITE_LINK": "Copiar Link de Convite",
|
||||
"COPY_SUCCESS": "Link de convite copiado para a área de transferência.",
|
||||
"FETCH_ERROR": "Falha ao carregar o link de convite."
|
||||
},
|
||||
"MEMBERS": {
|
||||
"ADD_BUTTON": "Adicionar Membro",
|
||||
"ADDING": "Adicionando membro...",
|
||||
"ADD_SUCCESS": "Membro adicionado com sucesso.",
|
||||
"ADD_ERROR": "Falha ao adicionar membro. Por favor, tente novamente.",
|
||||
"REMOVE_BUTTON": "Remover",
|
||||
"REMOVING": "Removendo membro...",
|
||||
"REMOVE_SUCCESS": "Membro removido com sucesso.",
|
||||
"REMOVE_ERROR": "Falha ao remover membro. Por favor, tente novamente.",
|
||||
"PROMOTE_BUTTON": "Promover a Admin",
|
||||
"PROMOTING": "Promovendo membro...",
|
||||
"PROMOTE_SUCCESS": "Membro promovido a admin.",
|
||||
"PROMOTE_ERROR": "Falha ao promover membro. Por favor, tente novamente.",
|
||||
"DEMOTE_BUTTON": "Rebaixar para Membro",
|
||||
"DEMOTING": "Rebaixando membro...",
|
||||
"DEMOTE_SUCCESS": "Membro rebaixado para membro.",
|
||||
"DEMOTE_ERROR": "Falha ao rebaixar membro. Por favor, tente novamente.",
|
||||
"GROUP_CREATOR_NOT_MODIFIABLE": "Este membro é o criador do grupo e não pode ser removido ou rebaixado."
|
||||
},
|
||||
"JOIN_REQUESTS": {
|
||||
"SECTION_TITLE": "Solicitações Pendentes",
|
||||
"PENDING_COUNT": "{count} pendentes",
|
||||
"APPROVE_BUTTON": "Aprovar",
|
||||
"REJECT_BUTTON": "Rejeitar",
|
||||
"PROCESSING": "Processando solicitação...",
|
||||
"APPROVE_SUCCESS": "Solicitação de entrada aprovada.",
|
||||
"REJECT_SUCCESS": "Solicitação de entrada rejeitada.",
|
||||
"ACTION_ERROR": "Falha ao processar solicitação de entrada. Por favor, tente novamente."
|
||||
},
|
||||
"MENTION": {
|
||||
"DROPDOWN_HEADER": "Membros do Grupo",
|
||||
"EVERYONE": "Todos",
|
||||
"EVERYONE_DESCRIPTION": "Notificar todos os membros"
|
||||
},
|
||||
"BAILEYS_OPTIONS": {
|
||||
"MEMBERS_CAN": "Os membros do grupo podem:",
|
||||
"ADMINS_CAN": "Os admins do grupo podem:",
|
||||
"EDIT_GROUP_SETTINGS": "Editar configurações do grupo",
|
||||
"EDIT_GROUP_SETTINGS_DESCRIPTION": "Inclui o nome do grupo, a imagem, a descrição, a duração das mensagens temporárias, a privacidade avançada da conversa e permite fixar e salvar mensagens na conversa ou reverter essa ação.",
|
||||
"SEND_MESSAGES": "Enviar novas mensagens",
|
||||
"ADD_MEMBERS": "Adicionar membros",
|
||||
"RESET_INVITE_LINK": "Redefinir link de convite",
|
||||
"RESET_INVITE_LINK_SUCCESS": "O link de convite foi redefinido com sucesso.",
|
||||
"RESET_INVITE_LINK_ERROR": "Falha ao redefinir o link de convite. Por favor, tente novamente.",
|
||||
"RESETTING_INVITE_LINK": "Redefinindo...",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_TITLE": "Restringir adição de membros?",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_DESCRIPTION": "Ao desabilitar esta opção, apenas admins poderão adicionar membros e o link de convite atual será redefinido. Deseja continuar?",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_YES": "Confirmar",
|
||||
"DISABLE_ADD_MEMBERS_CONFIRM_NO": "Cancelar",
|
||||
"APPROVE_MEMBERS": "Aprovar novos membros",
|
||||
"APPROVE_MEMBERS_DESCRIPTION": "Enquanto essa opção estiver ativada, os admins deverão aprovar a entrada de membros no grupo."
|
||||
},
|
||||
"SETTINGS": {
|
||||
"SECTION_TITLE": "Configurações do Grupo",
|
||||
"ANNOUNCEMENT_MODE": "Modo Anúncio",
|
||||
"ANNOUNCEMENT_MODE_DESCRIPTION": "Apenas admins podem enviar mensagens",
|
||||
"LOCKED_MODE": "Modo Trancado",
|
||||
"LOCKED_MODE_DESCRIPTION": "Apenas admins podem editar informações do grupo",
|
||||
"JOIN_APPROVAL": "Aprovação do Admin para Entrar",
|
||||
"JOIN_APPROVAL_DESCRIPTION": "Admins devem aprovar novos membros",
|
||||
"ADVANCED_OPTIONS": "Opções Avançadas",
|
||||
"GROUP_LEFT_BANNER": "Você não é mais membro deste grupo",
|
||||
"LEAVE_GROUP": "Sair do Grupo",
|
||||
"LEAVING": "Saindo do grupo...",
|
||||
"LEAVE_CONFIRM": "Tem certeza de que deseja sair deste grupo?",
|
||||
"LEAVE_CONFIRM_YES": "Sair",
|
||||
"LEAVE_CONFIRM_NO": "Cancelar",
|
||||
"LEAVE_SUCCESS": "Você saiu do grupo.",
|
||||
"LEAVE_ERROR": "Falha ao sair do grupo. Por favor, tente novamente.",
|
||||
"UPDATE_SUCCESS": "Configuração do grupo atualizada com sucesso.",
|
||||
"UPDATE_ERROR": "Falha ao atualizar configuração do grupo. Por favor, tente novamente."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import groups from './groups.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
import attributesMgmt from './attributesMgmt.json';
|
||||
@ -39,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
...groups,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
...attributesMgmt,
|
||||
|
||||
@ -155,7 +155,11 @@
|
||||
"EXPAND": "Expandir",
|
||||
"MAKE_FRIENDLY": "Alterar o tom de mensagem para amigável",
|
||||
"MAKE_FORMAL": "Usar tom formal",
|
||||
"SIMPLIFY": "Simplificar"
|
||||
"SIMPLIFY": "Simplificar",
|
||||
"CONFIDENT": "Usar tom confiante",
|
||||
"PROFESSIONAL": "Usar tom profissional",
|
||||
"CASUAL": "Usar tom casual",
|
||||
"STRAIGHTFORWARD": "Usar tom direto"
|
||||
},
|
||||
"ASSISTANCE_MODAL": {
|
||||
"DRAFT_TITLE": "Conteúdo do rascunho",
|
||||
@ -186,7 +190,10 @@
|
||||
"TITLE": "Tom",
|
||||
"OPTIONS": {
|
||||
"PROFESSIONAL": "Profissional",
|
||||
"FRIENDLY": "Amigável"
|
||||
"FRIENDLY": "Amigável",
|
||||
"CASUAL": "Casual",
|
||||
"STRAIGHTFORWARD": "Direto",
|
||||
"CONFIDENT": "Confiante"
|
||||
}
|
||||
},
|
||||
"BUTTONS": {
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
"MOST_RECENT": "Mais recentes",
|
||||
"EMPTY_STATE_DEFAULT": "Procurar por ID de conversa, e-mail, número de telefone, mensagens para melhores resultados de busca.",
|
||||
"BOT_LABEL": "Robôs",
|
||||
"READ_MORE": "Saiba mais",
|
||||
"READ_MORE": "Ler mais",
|
||||
"READ_LESS": "Ler menos",
|
||||
"WROTE": "escreveu:",
|
||||
"FROM": "De",
|
||||
|
||||
@ -159,7 +159,7 @@
|
||||
"CONDITION_TWO": "Enviar alertas a cada 30 segundos até que todas as conversas atribuídas sejam lidas"
|
||||
},
|
||||
"SOUND_PERMISSION_ERROR": "A reprodução automática está desativada no seu navegador. Para ouvir alertas automaticamente, habilite a permissão de som nas configurações do seu navegador ou interaja com a página.",
|
||||
"READ_MORE": "Saiba mais"
|
||||
"READ_MORE": "Ler mais"
|
||||
},
|
||||
"EMAIL_NOTIFICATIONS_SECTION": {
|
||||
"TITLE": "Notificações por e-mail",
|
||||
@ -408,7 +408,9 @@
|
||||
"INFO_SHORT": "Marcar off-line automaticamente quando não estiver usando o aplicativo."
|
||||
},
|
||||
"DOCS": "Ler documentos",
|
||||
"SECURITY": "Segurança"
|
||||
"SECURITY": "Segurança",
|
||||
"CAPTAIN_AI": "Captain",
|
||||
"CONVERSATION_WORKFLOW": "Fluxo de Conversa"
|
||||
},
|
||||
"BILLING_SETTINGS": {
|
||||
"TITLE": "Cobrança",
|
||||
@ -822,5 +824,57 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Excluir",
|
||||
"CANCEL_BUTTON_LABEL": "Cancelar"
|
||||
}
|
||||
},
|
||||
"CONVERSATION_WORKFLOW": {
|
||||
"INDEX": {
|
||||
"HEADER": {
|
||||
"TITLE": "Fluxos de Conversa",
|
||||
"DESCRIPTION": "Configure regras e campos obrigatórios para resolução de conversas."
|
||||
}
|
||||
},
|
||||
"REQUIRED_ATTRIBUTES": {
|
||||
"TITLE": "Atributos obrigatórios na resolução",
|
||||
"DESCRIPTION": "Ao resolver uma conversa, os agentes serão solicitados a preencher esses atributos se ainda não o fizeram.",
|
||||
"NO_ATTRIBUTES": "Nenhum atributo adicionado ainda",
|
||||
"ADD": {
|
||||
"TITLE": "Adicionar Atributos",
|
||||
"SEARCH_PLACEHOLDER": "Buscar atributos"
|
||||
},
|
||||
"SAVE": {
|
||||
"SUCCESS": "Atributos obrigatórios atualizados",
|
||||
"ERROR": "Não foi possível atualizar os atributos obrigatórios, tente novamente"
|
||||
},
|
||||
"MODAL": {
|
||||
"TITLE": "Resolver conversa",
|
||||
"DESCRIPTION": "Por favor, preencha os seguintes atributos personalizados antes de resolver esta conversa",
|
||||
"ACTIONS": {
|
||||
"RESOLVE": "Resolver conversa",
|
||||
"CANCEL": "Cancelar"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"TEXT": "Escreva uma nota...",
|
||||
"NUMBER": "Insira um número",
|
||||
"LINK": "Adicione um link",
|
||||
"DATE": "Escolha uma data",
|
||||
"LIST": "Selecione uma opção"
|
||||
},
|
||||
"CHECKBOX": {
|
||||
"YES": "Sim",
|
||||
"NO": "Não"
|
||||
}
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Faça upgrade para usar atributos obrigatórios",
|
||||
"AVAILABLE_ON": "O recurso de atributos obrigatórios de conversa está disponível nos planos Business e Enterprise.",
|
||||
"UPGRADE_PROMPT": "Faça upgrade do seu plano para solicitar que os agentes preencham atributos obrigatórios antes da resolução da conversa.",
|
||||
"UPGRADE_NOW": "Fazer upgrade agora",
|
||||
"CANCEL_ANYTIME": "Você pode alterar ou cancelar seu plano a qualquer momento"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "O recurso de atributos obrigatórios de conversa está disponível nos planos pagos.",
|
||||
"UPGRADE_PROMPT": "Faça upgrade para um plano pago para exigir atributos obrigatórios antes da resolução da conversa.",
|
||||
"ASK_ADMIN": "Entre em contato com seu administrador para o upgrade."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import ContactConversations from './ContactConversations.vue';
|
||||
import ConversationAction from './ConversationAction.vue';
|
||||
import ConversationParticipant from './ConversationParticipant.vue';
|
||||
import ContactInfo from './contact/ContactInfo.vue';
|
||||
import GroupContactInfo from './contact/GroupContactInfo.vue';
|
||||
import ContactNotes from './contact/ContactNotes.vue';
|
||||
import ScheduledMessages from './scheduledMessages/ScheduledMessages.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
@ -88,6 +89,14 @@ const conversationAdditionalAttributes = computed(
|
||||
);
|
||||
|
||||
const channelType = computed(() => currentChat.value.meta?.channel);
|
||||
const isGroupConversation = computed(
|
||||
() => currentChat.value.group_type === 'group'
|
||||
);
|
||||
const sidebarTitle = computed(() =>
|
||||
isGroupConversation.value
|
||||
? 'GROUP.SIDEBAR_TITLE'
|
||||
: 'CONVERSATION.SIDEBAR.CONTACT'
|
||||
);
|
||||
|
||||
const contactGetter = useMapGetter('contacts/getContact');
|
||||
const contactId = computed(() => currentChat.value.meta?.sender?.id);
|
||||
@ -102,9 +111,16 @@ const getContactDetails = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const triggerGroupSync = () => {
|
||||
if (isGroupConversation.value && contactId.value) {
|
||||
store.dispatch('groupMembers/sync', { contactId: contactId.value });
|
||||
}
|
||||
};
|
||||
|
||||
watch(contactId, (newContactId, prevContactId) => {
|
||||
if (newContactId && newContactId !== prevContactId) {
|
||||
getContactDetails();
|
||||
triggerGroupSync();
|
||||
}
|
||||
});
|
||||
|
||||
@ -125,6 +141,7 @@ const closeContactPanel = () => {
|
||||
onMounted(() => {
|
||||
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
|
||||
getContactDetails();
|
||||
triggerGroupSync();
|
||||
store.dispatch('attributes/get', 0);
|
||||
// Load integrations to ensure linear integration state is available
|
||||
store.dispatch('integrations/get', 'linear');
|
||||
@ -134,10 +151,11 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<SidebarActionsHeader
|
||||
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
:title="$t(sidebarTitle)"
|
||||
@close="closeContactPanel"
|
||||
/>
|
||||
<ContactInfo :contact="contact" :channel-type="channelType" />
|
||||
<GroupContactInfo v-if="isGroupConversation" :contact="contact" />
|
||||
<ContactInfo v-else :contact="contact" :channel-type="channelType" />
|
||||
<div class="px-2 pb-8 list-group">
|
||||
<Draggable
|
||||
:list="conversationSidebarItems"
|
||||
|
||||
@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import GroupMembersAPI from 'dashboard/api/groupMembers';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// NOTE: Computed: read from additional_attributes (stored as restriction-active booleans)
|
||||
// NOTE: WhatsApp shows "members CAN do X" so we invert restrict/announce for display
|
||||
const canEditGroupSettings = computed(
|
||||
() => props.contact.additional_attributes?.restrict !== true
|
||||
);
|
||||
const canSendMessages = computed(
|
||||
() => props.contact.additional_attributes?.announce !== true
|
||||
);
|
||||
const canAddMembers = computed(
|
||||
() => props.contact.additional_attributes?.member_add_mode !== false
|
||||
);
|
||||
const isJoinApprovalEnabled = computed(
|
||||
() => props.contact.additional_attributes?.join_approval_mode === true
|
||||
);
|
||||
|
||||
const isTogglingRestrict = ref(false);
|
||||
const isTogglingAnnounce = ref(false);
|
||||
const isTogglingMemberAdd = ref(false);
|
||||
const isTogglingJoinApproval = ref(false);
|
||||
const isResettingInviteLink = ref(false);
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const updateContactAttribute = async (key, value) => {
|
||||
await store.dispatch('contacts/update', {
|
||||
id: props.contact.id,
|
||||
additional_attributes: {
|
||||
...props.contact.additional_attributes,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEditGroupSettings = async () => {
|
||||
isTogglingRestrict.value = true;
|
||||
try {
|
||||
// NOTE: restrict=true means members CANNOT edit; flip to the opposite of current
|
||||
const currentValue = props.contact.additional_attributes?.restrict === true;
|
||||
const newValue = !currentValue;
|
||||
await GroupMembersAPI.updateGroupProperty(props.contact.id, {
|
||||
property: 'restrict',
|
||||
enabled: newValue,
|
||||
});
|
||||
await updateContactAttribute('restrict', newValue);
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
|
||||
} finally {
|
||||
isTogglingRestrict.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSendMessages = async () => {
|
||||
isTogglingAnnounce.value = true;
|
||||
try {
|
||||
// NOTE: announce=true means only admins can send; flip to the opposite of current
|
||||
const currentValue = props.contact.additional_attributes?.announce === true;
|
||||
const newValue = !currentValue;
|
||||
await GroupMembersAPI.updateGroupProperty(props.contact.id, {
|
||||
property: 'announce',
|
||||
enabled: newValue,
|
||||
});
|
||||
await updateContactAttribute('announce', newValue);
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
|
||||
} finally {
|
||||
isTogglingAnnounce.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetInviteLink = async () => {
|
||||
isResettingInviteLink.value = true;
|
||||
try {
|
||||
const { data } = await GroupMembersAPI.revokeInviteLink(props.contact.id);
|
||||
if (data.invite_code) {
|
||||
store.dispatch('contacts/updateContact', {
|
||||
...props.contact,
|
||||
additional_attributes: {
|
||||
...props.contact.additional_attributes,
|
||||
invite_code: data.invite_code,
|
||||
},
|
||||
});
|
||||
}
|
||||
useAlert(t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK_ERROR'));
|
||||
} finally {
|
||||
isResettingInviteLink.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAddMembers = async () => {
|
||||
// NOTE: member_add_mode: true = all members can add, false = only admins
|
||||
const currentValue =
|
||||
props.contact.additional_attributes?.member_add_mode !== false;
|
||||
const newValue = !currentValue;
|
||||
|
||||
// When disabling (restricting to admins only), confirm and also reset invite link
|
||||
if (!newValue) {
|
||||
const confirmed = await confirmDialog.value.showConfirmation();
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
isTogglingMemberAdd.value = true;
|
||||
try {
|
||||
await GroupMembersAPI.updateGroupProperty(props.contact.id, {
|
||||
property: 'member_add_mode',
|
||||
enabled: newValue,
|
||||
});
|
||||
|
||||
// Also revoke invite link when restricting member additions
|
||||
if (!newValue) {
|
||||
await GroupMembersAPI.revokeInviteLink(props.contact.id);
|
||||
}
|
||||
|
||||
await updateContactAttribute('member_add_mode', newValue);
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
|
||||
} finally {
|
||||
isTogglingMemberAdd.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleJoinApproval = async () => {
|
||||
isTogglingJoinApproval.value = true;
|
||||
try {
|
||||
// NOTE: join_approval_mode=true means admins must approve; flip to opposite
|
||||
const currentValue =
|
||||
props.contact.additional_attributes?.join_approval_mode === true;
|
||||
const newValue = !currentValue;
|
||||
await GroupMembersAPI.updateGroupProperty(props.contact.id, {
|
||||
property: 'join_approval_mode',
|
||||
enabled: newValue,
|
||||
});
|
||||
await updateContactAttribute('join_approval_mode', newValue);
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('GROUP.SETTINGS.UPDATE_ERROR'));
|
||||
} finally {
|
||||
isTogglingJoinApproval.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="isAdmin" class="flex flex-col gap-3">
|
||||
<div>
|
||||
<h4 class="mb-2 text-xs font-semibold text-n-slate-10">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.MEMBERS_CAN') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex flex-col pr-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.EDIT_GROUP_SETTINGS') }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.EDIT_GROUP_SETTINGS_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
v-if="isTogglingRestrict"
|
||||
class="i-lucide-loader-2 animate-spin size-3 text-n-slate-10"
|
||||
/>
|
||||
<Switch
|
||||
:model-value="canEditGroupSettings"
|
||||
:disabled="isTogglingRestrict"
|
||||
@change="toggleEditGroupSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex flex-col pr-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.SEND_MESSAGES') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
v-if="isTogglingAnnounce"
|
||||
class="i-lucide-loader-2 animate-spin size-3 text-n-slate-10"
|
||||
/>
|
||||
<Switch
|
||||
:model-value="canSendMessages"
|
||||
:disabled="isTogglingAnnounce"
|
||||
@change="toggleSendMessages"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex flex-col pr-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.ADD_MEMBERS') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
v-if="isTogglingMemberAdd"
|
||||
class="i-lucide-loader-2 animate-spin size-3 text-n-slate-10"
|
||||
/>
|
||||
<Switch
|
||||
:model-value="canAddMembers"
|
||||
:disabled="isTogglingMemberAdd"
|
||||
@change="toggleAddMembers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex flex-col pr-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<NextButton
|
||||
size="xs"
|
||||
faded
|
||||
:is-loading="isResettingInviteLink"
|
||||
:label="t('GROUP.BAILEYS_OPTIONS.RESET_INVITE_LINK')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
@click="resetInviteLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="mb-2 text-xs font-semibold text-n-slate-10">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.ADMINS_CAN') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex flex-col pr-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.APPROVE_MEMBERS') }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ t('GROUP.BAILEYS_OPTIONS.APPROVE_MEMBERS_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
v-if="isTogglingJoinApproval"
|
||||
class="i-lucide-loader-2 animate-spin size-3 text-n-slate-10"
|
||||
/>
|
||||
<Switch
|
||||
:model-value="isJoinApprovalEnabled"
|
||||
:disabled="isTogglingJoinApproval"
|
||||
@change="toggleJoinApproval"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
ref="confirmDialog"
|
||||
:title="t('GROUP.BAILEYS_OPTIONS.DISABLE_ADD_MEMBERS_CONFIRM_TITLE')"
|
||||
:description="
|
||||
t('GROUP.BAILEYS_OPTIONS.DISABLE_ADD_MEMBERS_CONFIRM_DESCRIPTION')
|
||||
"
|
||||
:confirm-label="
|
||||
t('GROUP.BAILEYS_OPTIONS.DISABLE_ADD_MEMBERS_CONFIRM_YES')
|
||||
"
|
||||
:cancel-label="t('GROUP.BAILEYS_OPTIONS.DISABLE_ADD_MEMBERS_CONFIRM_NO')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@ -32,6 +32,7 @@ import customViews from './modules/customViews';
|
||||
import dashboardApps from './modules/dashboardApps';
|
||||
import draftMessages from './modules/draftMessages';
|
||||
import globalConfig from 'shared/store/globalConfig';
|
||||
import groupMembers from './modules/groupMembers';
|
||||
import inboxAssignableAgents from './modules/inboxAssignableAgents';
|
||||
import inboxes from './modules/inboxes';
|
||||
import inboxMembers from './modules/inboxMembers';
|
||||
@ -96,6 +97,7 @@ export default createStore({
|
||||
dashboardApps,
|
||||
draftMessages,
|
||||
globalConfig,
|
||||
groupMembers,
|
||||
inboxAssignableAgents,
|
||||
inboxes,
|
||||
inboxMembers,
|
||||
|
||||
@ -17,7 +17,7 @@ const buildContactFormData = contactParams => {
|
||||
formData.append(key, contactProperties[key]);
|
||||
}
|
||||
});
|
||||
const { social_profiles, ...additionalAttributesProperties } =
|
||||
const { social_profiles = {}, ...additionalAttributesProperties } =
|
||||
additional_attributes;
|
||||
Object.keys(additionalAttributesProperties).forEach(key => {
|
||||
formData.append(
|
||||
|
||||
@ -440,6 +440,10 @@ const actions = {
|
||||
commit(types.CHANGE_CHAT_SORT_FILTER, data);
|
||||
},
|
||||
|
||||
setChatGroupTypeFilter({ commit }, data) {
|
||||
commit(types.CHANGE_CHAT_GROUP_TYPE_FILTER, data);
|
||||
},
|
||||
|
||||
updateAssignee({ commit }, data) {
|
||||
commit(types.UPDATE_ASSIGNEE, data);
|
||||
},
|
||||
|
||||
@ -141,6 +141,7 @@ const getters = {
|
||||
},
|
||||
getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter,
|
||||
getChatSortFilter: ({ chatSortFilter }) => chatSortFilter,
|
||||
getChatGroupTypeFilter: ({ chatGroupTypeFilter }) => chatGroupTypeFilter,
|
||||
getSelectedInbox: ({ currentInbox }) => currentInbox,
|
||||
getConversationById: _state => conversationId => {
|
||||
return _state.allConversations.find(
|
||||
|
||||
@ -64,6 +64,7 @@ const getValueFromConversation = (conversation, attributeKey) => {
|
||||
switch (attributeKey) {
|
||||
case 'status':
|
||||
case 'priority':
|
||||
case 'group_type':
|
||||
case 'labels':
|
||||
case 'created_at':
|
||||
case 'last_activity_at':
|
||||
|
||||
@ -14,6 +14,7 @@ const state = {
|
||||
listLoadingStatus: true,
|
||||
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
|
||||
chatSortFilter: wootConstants.SORT_BY_TYPE.LATEST,
|
||||
chatGroupTypeFilter: '',
|
||||
currentInbox: null,
|
||||
selectedChatId: null,
|
||||
appliedFilters: [],
|
||||
@ -285,6 +286,10 @@ export const mutations = {
|
||||
_state.chatSortFilter = data;
|
||||
},
|
||||
|
||||
[types.CHANGE_CHAT_GROUP_TYPE_FILTER](_state, data) {
|
||||
_state.chatGroupTypeFilter = data;
|
||||
},
|
||||
|
||||
// Update assignee on action cable message
|
||||
[types.UPDATE_ASSIGNEE](_state, payload) {
|
||||
const chat = getConversationById(_state)(payload.id);
|
||||
|
||||
169
app/javascript/dashboard/store/modules/groupMembers.js
Normal file
169
app/javascript/dashboard/store/modules/groupMembers.js
Normal file
@ -0,0 +1,169 @@
|
||||
import types from '../mutation-types';
|
||||
import GroupMembersAPI from '../../api/groupMembers';
|
||||
|
||||
export const state = {
|
||||
records: {},
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isFetchingMore: false,
|
||||
isSyncing: false,
|
||||
isUpdating: false,
|
||||
isCreating: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getGroupMembers: _state => contactId => {
|
||||
return _state.records[contactId] || [];
|
||||
},
|
||||
getGroupMembersMeta: _state => contactId => {
|
||||
return _state.meta[contactId] || {};
|
||||
},
|
||||
getUIFlags(_state) {
|
||||
return _state.uiFlags;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
setGroupMembers(
|
||||
{ commit },
|
||||
{ contactId, members, inboxPhoneNumber, isInboxAdmin }
|
||||
) {
|
||||
commit(types.SET_GROUP_MEMBERS, { contactId, members });
|
||||
commit(types.SET_GROUP_MEMBERS_META, {
|
||||
contactId,
|
||||
meta: {
|
||||
total_count: members.length,
|
||||
page: 1,
|
||||
per_page: members.length,
|
||||
inbox_phone_number: inboxPhoneNumber || null,
|
||||
is_inbox_admin: isInboxAdmin ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async createGroup({ commit }, params) {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isCreating: true });
|
||||
try {
|
||||
const { data } = await GroupMembersAPI.createGroup(params);
|
||||
return data;
|
||||
} finally {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isCreating: false });
|
||||
}
|
||||
},
|
||||
|
||||
async fetch({ commit }, { contactId, page = 1 }) {
|
||||
const isFirstPage = page === 1;
|
||||
commit(
|
||||
types.SET_GROUP_MEMBERS_UI_FLAG,
|
||||
isFirstPage ? { isFetching: true } : { isFetchingMore: true }
|
||||
);
|
||||
try {
|
||||
const { data } = await GroupMembersAPI.getGroupMembers(contactId, page);
|
||||
if (isFirstPage) {
|
||||
commit(types.SET_GROUP_MEMBERS, { contactId, members: data.payload });
|
||||
} else {
|
||||
commit(types.APPEND_GROUP_MEMBERS, {
|
||||
contactId,
|
||||
members: data.payload,
|
||||
});
|
||||
}
|
||||
commit(types.SET_GROUP_MEMBERS_META, { contactId, meta: data.meta });
|
||||
} finally {
|
||||
commit(
|
||||
types.SET_GROUP_MEMBERS_UI_FLAG,
|
||||
isFirstPage ? { isFetching: false } : { isFetchingMore: false }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async sync({ commit }, { contactId }) {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isSyncing: true });
|
||||
try {
|
||||
await GroupMembersAPI.syncGroup(contactId);
|
||||
} catch (error) {
|
||||
// fire-and-forget: sync runs in background, results arrive via ActionCable
|
||||
} finally {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isSyncing: false });
|
||||
}
|
||||
},
|
||||
|
||||
async addMembers({ commit, dispatch }, { contactId, participants }) {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
await GroupMembersAPI.addMembers(contactId, participants);
|
||||
await dispatch('fetch', { contactId });
|
||||
} finally {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
|
||||
async removeMembers({ commit, dispatch }, { contactId, memberId }) {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
await GroupMembersAPI.removeMembers(contactId, memberId);
|
||||
await dispatch('fetch', { contactId });
|
||||
} finally {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
|
||||
async updateGroupMetadata({ commit }, { contactId, params }) {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
await GroupMembersAPI.updateGroupMetadata(contactId, params);
|
||||
} finally {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
|
||||
async updateMemberRole({ commit, dispatch }, { contactId, memberId, role }) {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
await GroupMembersAPI.updateMemberRole(contactId, memberId, role);
|
||||
await dispatch('fetch', { contactId });
|
||||
} finally {
|
||||
commit(types.SET_GROUP_MEMBERS_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[types.SET_GROUP_MEMBERS_UI_FLAG](_state, data) {
|
||||
_state.uiFlags = {
|
||||
..._state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
|
||||
[types.SET_GROUP_MEMBERS](_state, { contactId, members }) {
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[contactId]: members,
|
||||
};
|
||||
},
|
||||
|
||||
[types.APPEND_GROUP_MEMBERS](_state, { contactId, members }) {
|
||||
const existing = _state.records[contactId] || [];
|
||||
_state.records = {
|
||||
..._state.records,
|
||||
[contactId]: [...existing, ...members],
|
||||
};
|
||||
},
|
||||
|
||||
[types.SET_GROUP_MEMBERS_META](_state, { contactId, meta }) {
|
||||
_state.meta = {
|
||||
..._state.meta,
|
||||
[contactId]: meta,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
155
app/javascript/dashboard/store/modules/groupMembers.spec.js
Normal file
155
app/javascript/dashboard/store/modules/groupMembers.spec.js
Normal file
@ -0,0 +1,155 @@
|
||||
import axios from 'axios';
|
||||
import { actions, getters, mutations, state } from './groupMembers';
|
||||
import * as types from '../mutation-types';
|
||||
|
||||
const commit = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
vi.mock('../../api/groupMembers', () => ({
|
||||
default: {
|
||||
getGroupMembers: vi.fn(),
|
||||
syncGroup: vi.fn(),
|
||||
addMembers: vi.fn(),
|
||||
removeMembers: vi.fn(),
|
||||
updateMemberRole: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import GroupMembersAPI from '../../api/groupMembers';
|
||||
|
||||
const sampleMembers = [
|
||||
{ id: 1, role: 'admin', is_active: true, contact: { id: 10, name: 'Alice' } },
|
||||
{ id: 2, role: 'member', is_active: true, contact: { id: 11, name: 'Bob' } },
|
||||
];
|
||||
|
||||
describe('groupMembers store', () => {
|
||||
beforeEach(() => {
|
||||
commit.mockClear();
|
||||
dispatch.mockClear();
|
||||
});
|
||||
|
||||
describe('getters', () => {
|
||||
it('getGroupMembers returns members for a contactId', () => {
|
||||
const localState = { records: { 42: sampleMembers } };
|
||||
expect(getters.getGroupMembers(localState)(42)).toEqual(sampleMembers);
|
||||
});
|
||||
|
||||
it('getGroupMembers returns empty array for unknown contactId', () => {
|
||||
const localState = { records: {} };
|
||||
expect(getters.getGroupMembers(localState)(99)).toEqual([]);
|
||||
});
|
||||
|
||||
it('getUIFlags returns uiFlags', () => {
|
||||
const localState = {
|
||||
uiFlags: { isFetching: true, isSyncing: false, isUpdating: false },
|
||||
};
|
||||
expect(getters.getUIFlags(localState)).toEqual(localState.uiFlags);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutations', () => {
|
||||
it('SET_GROUP_MEMBERS_UI_FLAG merges flags', () => {
|
||||
const localState = { ...state };
|
||||
mutations[types.default.SET_GROUP_MEMBERS_UI_FLAG](localState, {
|
||||
isFetching: true,
|
||||
});
|
||||
expect(localState.uiFlags.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it('SET_GROUP_MEMBERS stores members keyed by contactId', () => {
|
||||
const localState = { records: {} };
|
||||
mutations[types.default.SET_GROUP_MEMBERS](localState, {
|
||||
contactId: 42,
|
||||
members: sampleMembers,
|
||||
});
|
||||
expect(localState.records[42]).toEqual(sampleMembers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
describe('setGroupMembers', () => {
|
||||
it('commits SET_GROUP_MEMBERS directly', () => {
|
||||
actions.setGroupMembers(
|
||||
{ commit },
|
||||
{ contactId: 42, members: sampleMembers }
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(types.default.SET_GROUP_MEMBERS, {
|
||||
contactId: 42,
|
||||
members: sampleMembers,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
it('commits SET_GROUP_MEMBERS on success', async () => {
|
||||
const meta = { total_count: 2, page: 1, per_page: 15 };
|
||||
GroupMembersAPI.getGroupMembers.mockResolvedValue({
|
||||
data: { payload: sampleMembers, meta },
|
||||
});
|
||||
await actions.fetch({ commit }, { contactId: 42 });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_GROUP_MEMBERS_UI_FLAG, { isFetching: true }],
|
||||
[
|
||||
types.default.SET_GROUP_MEMBERS,
|
||||
{ contactId: 42, members: sampleMembers },
|
||||
],
|
||||
[types.default.SET_GROUP_MEMBERS_META, { contactId: 42, meta }],
|
||||
[types.default.SET_GROUP_MEMBERS_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on API error', async () => {
|
||||
GroupMembersAPI.getGroupMembers.mockRejectedValue(new Error('fail'));
|
||||
await expect(
|
||||
actions.fetch({ commit }, { contactId: 42 })
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync', () => {
|
||||
it('calls syncGroup without re-fetching (fire-and-forget)', async () => {
|
||||
GroupMembersAPI.syncGroup.mockResolvedValue({});
|
||||
await actions.sync({ commit }, { contactId: 42 });
|
||||
expect(GroupMembersAPI.syncGroup).toHaveBeenCalledWith(42);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMembers', () => {
|
||||
it('calls addMembers and re-fetches on success', async () => {
|
||||
GroupMembersAPI.addMembers.mockResolvedValue({});
|
||||
dispatch.mockResolvedValue();
|
||||
await actions.addMembers(
|
||||
{ commit, dispatch },
|
||||
{ contactId: 42, participants: ['+5511999'] }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('fetch', { contactId: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMembers', () => {
|
||||
it('calls removeMembers and re-fetches on success', async () => {
|
||||
GroupMembersAPI.removeMembers.mockResolvedValue({});
|
||||
dispatch.mockResolvedValue();
|
||||
await actions.removeMembers(
|
||||
{ commit, dispatch },
|
||||
{ contactId: 42, memberId: 1 }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('fetch', { contactId: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMemberRole', () => {
|
||||
it('calls updateMemberRole and re-fetches on success', async () => {
|
||||
GroupMembersAPI.updateMemberRole.mockResolvedValue({});
|
||||
dispatch.mockResolvedValue();
|
||||
await actions.updateMemberRole(
|
||||
{ commit, dispatch },
|
||||
{ contactId: 42, memberId: 1, role: 'admin' }
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith('fetch', { contactId: 42 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -18,6 +18,7 @@ export default {
|
||||
CLEAR_ALL_MESSAGES_LOADED: 'CLEAR_ALL_MESSAGES_LOADED',
|
||||
CHANGE_CHAT_STATUS_FILTER: 'CHANGE_CHAT_STATUS_FILTER',
|
||||
CHANGE_CHAT_SORT_FILTER: 'CHANGE_CHAT_SORT_FILTER',
|
||||
CHANGE_CHAT_GROUP_TYPE_FILTER: 'CHANGE_CHAT_GROUP_TYPE_FILTER',
|
||||
UPDATE_ASSIGNEE: 'UPDATE_ASSIGNEE',
|
||||
UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT',
|
||||
CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS',
|
||||
@ -237,6 +238,12 @@ export default {
|
||||
EDIT_CAMPAIGN: 'EDIT_CAMPAIGN',
|
||||
DELETE_CAMPAIGN: 'DELETE_CAMPAIGN',
|
||||
|
||||
// Group members
|
||||
SET_GROUP_MEMBERS_UI_FLAG: 'SET_GROUP_MEMBERS_UI_FLAG',
|
||||
SET_GROUP_MEMBERS: 'SET_GROUP_MEMBERS',
|
||||
APPEND_GROUP_MEMBERS: 'APPEND_GROUP_MEMBERS',
|
||||
SET_GROUP_MEMBERS_META: 'SET_GROUP_MEMBERS_META',
|
||||
|
||||
// Contact notes
|
||||
SET_CONTACT_NOTES_UI_FLAG: 'SET_CONTACT_NOTES_UI_FLAG',
|
||||
SET_CONTACT_NOTES: 'SET_CONTACT_NOTES',
|
||||
|
||||
@ -13,4 +13,5 @@ export const BUS_EVENTS = {
|
||||
NEW_CONVERSATION_MODAL: 'newConversationModal',
|
||||
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
|
||||
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
|
||||
NAVIGATE_TO_GROUP: 'navigateToGroup',
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Process [@mention](mention://user/1/Pranav)
|
||||
const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/(.+)/gm;
|
||||
// Process [@mention](mention://user/1/Pranav) and [@mention](mention://contact/1/Name)
|
||||
const USER_MENTIONS_REGEX = /mention:\/\/(user|team|contact)\/(\d+)\/(.+)/gm;
|
||||
|
||||
const buildMentionTokens = () => (state, silent) => {
|
||||
var label;
|
||||
@ -51,6 +51,8 @@ const buildMentionTokens = () => (state, silent) => {
|
||||
token = state.push('mention', '');
|
||||
token.href = href;
|
||||
token.content = label;
|
||||
const mentionMatch = href.match(/mention:\/\/(user|team|contact)\//);
|
||||
token.mentionType = mentionMatch ? mentionMatch[1] : 'user';
|
||||
}
|
||||
|
||||
state.pos = pos;
|
||||
@ -60,7 +62,11 @@ const buildMentionTokens = () => (state, silent) => {
|
||||
};
|
||||
|
||||
const renderMentions = () => (tokens, idx) => {
|
||||
return `<span class="prosemirror-mention-node">${tokens[idx].content}</span>`;
|
||||
const token = tokens[idx];
|
||||
if (token.mentionType === 'contact') {
|
||||
return `<span class="prosemirror-mention-node prosemirror-mention-contact">${token.content}</span>`;
|
||||
}
|
||||
return `<span class="prosemirror-mention-node">${token.content}</span>`;
|
||||
};
|
||||
|
||||
export default function mentionPlugin(md) {
|
||||
|
||||
@ -24,6 +24,7 @@ const {
|
||||
WIDGET_BRAND_URL: widgetBrandURL,
|
||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||
DEPLOYMENT_ENV: deploymentEnv,
|
||||
BAILEYS_WHATSAPP_GROUPS_ENABLED: baileysWhatsappGroupsEnabled,
|
||||
} = window.globalConfig || {};
|
||||
|
||||
const state = {
|
||||
@ -49,6 +50,7 @@ const state = {
|
||||
termsURL,
|
||||
widgetBrandURL,
|
||||
isEnterprise: parseBoolean(isEnterprise),
|
||||
baileysWhatsappGroupsEnabled: parseBoolean(baileysWhatsappGroupsEnabled),
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
|
||||
@ -27,6 +27,8 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
|
||||
dispatch_contact_update(avatarable)
|
||||
|
||||
rescue Down::NotFound
|
||||
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||
rescue Down::Error => e
|
||||
@ -83,4 +85,14 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def dispatch_contact_update(avatarable)
|
||||
return unless avatarable.is_a?(Contact)
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
Events::Types::CONTACT_UPDATED,
|
||||
Time.zone.now,
|
||||
contact: avatarable
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
22
app/jobs/contacts/sync_group_job.rb
Normal file
22
app/jobs/contacts/sync_group_job.rb
Normal file
@ -0,0 +1,22 @@
|
||||
class Contacts::SyncGroupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
SYNC_COOLDOWN = 15.minutes
|
||||
|
||||
def perform(contact, force: false, soft: false)
|
||||
return if !force && recently_synced?(contact)
|
||||
|
||||
Contacts::SyncGroupService.new(contact: contact, soft: soft).perform
|
||||
rescue Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError => e
|
||||
Rails.logger.error "SyncGroupJob failed for contact #{contact.id}: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recently_synced?(contact)
|
||||
last_synced = contact.additional_attributes&.dig('group_last_synced_at')
|
||||
return false if last_synced.blank?
|
||||
|
||||
Time.zone.at(last_synced) > SYNC_COOLDOWN.ago
|
||||
end
|
||||
end
|
||||
@ -190,6 +190,18 @@ class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength
|
||||
broadcast(account, [account_token(account)], CONTACT_DELETED, contact_data)
|
||||
end
|
||||
|
||||
def contact_group_synced(event)
|
||||
contact, account = extract_contact_and_account(event)
|
||||
inbox_phone = contact.group_channel&.phone_number
|
||||
payload = contact.push_event_data.merge(
|
||||
group_members: group_members_data(contact, account),
|
||||
inbox_phone_number: inbox_phone,
|
||||
is_inbox_admin: inbox_admin_in_group?(contact, inbox_phone)
|
||||
)
|
||||
|
||||
broadcast(account, [account_token(account)], CONTACT_GROUP_SYNCED, payload)
|
||||
end
|
||||
|
||||
def conversation_mentioned(event)
|
||||
conversation, account = extract_conversation_and_account(event)
|
||||
user = event.data[:user]
|
||||
@ -228,6 +240,27 @@ class ActionCableListener < BaseListener # rubocop:disable Metrics/ClassLength
|
||||
contact_inbox.hmac_verified? ? contact.contact_inboxes.where(hmac_verified: true).filter_map(&:pubsub_token) : [contact_inbox.pubsub_token]
|
||||
end
|
||||
|
||||
def group_members_data(contact, _account)
|
||||
GroupMember.active.where(group_contact: contact).includes(:contact).map do |member|
|
||||
{
|
||||
id: member.id, role: member.role, is_active: member.is_active, group_contact_id: member.group_contact_id,
|
||||
contact: { id: member.contact.id, name: member.contact.name, phone_number: member.contact.phone_number,
|
||||
identifier: member.contact.identifier, thumbnail: member.contact.avatar_url }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def inbox_admin_in_group?(contact, inbox_phone)
|
||||
return false if inbox_phone.blank?
|
||||
|
||||
clean = inbox_phone.delete('+')
|
||||
GroupMember.active
|
||||
.where(group_contact: contact, role: :admin)
|
||||
.joins(:contact)
|
||||
.exists?(['REPLACE(contacts.phone_number, \'+\', \'\') = ? OR RIGHT(REPLACE(contacts.phone_number, \'+\', \'\'), 8) = RIGHT(?, 8)',
|
||||
clean, clean])
|
||||
end
|
||||
|
||||
def broadcast(account, tokens, event_name, data)
|
||||
return if tokens.blank?
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
# rubocop:disable Layout/LineLength
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: channel_whatsapp
|
||||
@ -16,8 +17,9 @@
|
||||
# Indexes
|
||||
#
|
||||
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
|
||||
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin # rubocop:disable Layout/LineLength
|
||||
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
|
||||
#
|
||||
# rubocop:enable Layout/LineLength
|
||||
|
||||
class Channel::Whatsapp < ApplicationRecord
|
||||
include Channelable
|
||||
@ -159,12 +161,35 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
provider_service.edit_message(recipient_id, message, new_content)
|
||||
end
|
||||
|
||||
def sync_group(conversation, soft: false)
|
||||
return unless provider_service.respond_to?(:sync_group)
|
||||
|
||||
provider_service.sync_group(conversation, soft: soft)
|
||||
end
|
||||
|
||||
def allow_group_creation?
|
||||
provider_service.respond_to?(:allow_group_creation?) && provider_service.allow_group_creation?
|
||||
end
|
||||
|
||||
delegate :setup_channel_provider, to: :provider_service
|
||||
delegate :send_message, to: :provider_service
|
||||
delegate :send_template, to: :provider_service
|
||||
delegate :sync_templates, to: :provider_service
|
||||
delegate :media_url, to: :provider_service
|
||||
delegate :api_headers, to: :provider_service
|
||||
delegate :create_group, to: :provider_service
|
||||
delegate :update_group_subject, to: :provider_service
|
||||
delegate :update_group_description, to: :provider_service
|
||||
delegate :update_group_picture, to: :provider_service
|
||||
delegate :update_group_participants, to: :provider_service
|
||||
delegate :group_invite_code, to: :provider_service
|
||||
delegate :revoke_group_invite, to: :provider_service
|
||||
delegate :group_join_requests, to: :provider_service
|
||||
delegate :handle_group_join_requests, to: :provider_service
|
||||
delegate :group_leave, to: :provider_service
|
||||
delegate :group_setting_update, to: :provider_service
|
||||
delegate :group_join_approval_mode, to: :provider_service
|
||||
delegate :group_member_add_mode, to: :provider_service
|
||||
|
||||
def setup_webhooks
|
||||
perform_webhook_setup
|
||||
|
||||
172
app/models/concerns/group_conversation_handler.rb
Normal file
172
app/models/concerns/group_conversation_handler.rb
Normal file
@ -0,0 +1,172 @@
|
||||
module GroupConversationHandler # rubocop:disable Metrics/ModuleLength
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# This concern provides the base logic for handling group conversations across all channels.
|
||||
# Channel-specific handlers should include this concern and implement the required abstract methods.
|
||||
#
|
||||
# Abstract methods that must be implemented by including modules:
|
||||
# - extract_group_identifier: Returns a unique identifier for the group
|
||||
# - extract_group_source_id: Returns the source_id for the group contact_inbox
|
||||
# - extract_group_name: Returns the display name of the group
|
||||
# - extract_sender_identifier: Returns a unique identifier for the message sender
|
||||
# - extract_sender_source_id: Returns the source_id for the sender contact_inbox
|
||||
# - extract_sender_name: Returns the display name of the sender
|
||||
# - extract_sender_phone: Returns the phone number of the sender
|
||||
# - build_sender_contact_attributes: Returns a hash of attributes for the sender contact
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_group_contact
|
||||
group_contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: extract_group_source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: extract_group_name || extract_group_source_id,
|
||||
identifier: extract_group_identifier,
|
||||
group_type: :group
|
||||
}
|
||||
).perform
|
||||
|
||||
contact = group_contact_inbox.contact
|
||||
update_group_contact_info(contact)
|
||||
|
||||
[group_contact_inbox, contact]
|
||||
end
|
||||
|
||||
def update_group_contact_info(contact)
|
||||
update_params = {}
|
||||
group_name = extract_group_name
|
||||
update_params[:name] = group_name if group_name.present? && contact.name != group_name
|
||||
update_params[:group_type] = :group unless contact.group_type_group?
|
||||
contact.update!(update_params) if update_params.present?
|
||||
end
|
||||
|
||||
def find_or_create_sender_contact
|
||||
source_id = extract_sender_source_id
|
||||
return nil if source_id.blank?
|
||||
|
||||
sender_contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: build_sender_contact_attributes
|
||||
).perform
|
||||
|
||||
sender_contact_inbox.contact
|
||||
end
|
||||
|
||||
def find_or_create_group_conversation(group_contact_inbox)
|
||||
@conversation = group_contact_inbox.conversations.where(status: %i[open pending]).last
|
||||
if @conversation.present?
|
||||
@conversation.update!(group_type: :group) unless @conversation.group_type_group?
|
||||
return @conversation
|
||||
end
|
||||
|
||||
@conversation = ::Conversation.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: group_contact_inbox.contact_id,
|
||||
contact_inbox_id: group_contact_inbox.id,
|
||||
group_type: :group
|
||||
)
|
||||
end
|
||||
|
||||
def add_group_member(group_contact, contact, role: :member)
|
||||
return if group_contact.blank?
|
||||
return if contact.blank?
|
||||
|
||||
member = GroupMember.find_or_initialize_by(
|
||||
group_contact: group_contact,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
member.update!(role: role, is_active: true) if member.new_record? || !member.is_active? || member.role != role.to_s
|
||||
member
|
||||
end
|
||||
|
||||
def remove_group_member(group_contact, contact)
|
||||
return if group_contact.blank?
|
||||
return if contact.blank?
|
||||
|
||||
member = GroupMember.find_by(group_contact: group_contact, contact: contact)
|
||||
member&.update!(is_active: false)
|
||||
member
|
||||
end
|
||||
|
||||
def update_group_member_role(group_contact, contact, role)
|
||||
return if group_contact.blank?
|
||||
return if contact.blank?
|
||||
return if role.blank?
|
||||
|
||||
member = GroupMember.find_by(group_contact: group_contact, contact: contact)
|
||||
member&.update!(role: role)
|
||||
member
|
||||
end
|
||||
|
||||
def sync_group_members(group_contact, contacts, admins: [])
|
||||
contacts.each do |contact|
|
||||
role = admins.include?(contact) ? :admin : :member
|
||||
add_group_member(group_contact, contact, role: role)
|
||||
end
|
||||
|
||||
current_member_ids = group_contact.group_memberships.active.pluck(:contact_id)
|
||||
new_contact_ids = contacts.map(&:id)
|
||||
removed_ids = current_member_ids - new_contact_ids
|
||||
|
||||
group_contact.group_memberships.where(contact_id: removed_ids).find_each do |member|
|
||||
member.update!(is_active: false)
|
||||
end
|
||||
end
|
||||
|
||||
def create_group_message(conversation:, sender_contact:, content:, message_type: :incoming, **options)
|
||||
return if conversation.blank?
|
||||
|
||||
message_params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
conversation_id: conversation.id,
|
||||
message_type: message_type,
|
||||
content: content,
|
||||
sender: sender_contact
|
||||
}.merge(options)
|
||||
|
||||
Message.create!(message_params)
|
||||
end
|
||||
|
||||
def extract_group_identifier
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_group_identifier"
|
||||
end
|
||||
|
||||
def extract_group_source_id
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_group_source_id"
|
||||
end
|
||||
|
||||
def extract_group_name
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_group_name"
|
||||
end
|
||||
|
||||
def extract_sender_identifier
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_sender_identifier"
|
||||
end
|
||||
|
||||
def extract_sender_source_id
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_sender_source_id"
|
||||
end
|
||||
|
||||
def extract_sender_name
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_sender_name"
|
||||
end
|
||||
|
||||
def extract_sender_phone
|
||||
raise NotImplementedError, "#{self.class} must implement #extract_sender_phone"
|
||||
end
|
||||
|
||||
def build_sender_contact_attributes
|
||||
phone = extract_sender_phone
|
||||
identifier = extract_sender_identifier
|
||||
|
||||
attrs = { name: extract_sender_name }
|
||||
attrs[:phone_number] = phone if phone.present?
|
||||
attrs[:identifier] = identifier if identifier.present?
|
||||
attrs
|
||||
end
|
||||
end
|
||||
@ -11,6 +11,7 @@
|
||||
# country_code :string default("")
|
||||
# custom_attributes :jsonb
|
||||
# email :string
|
||||
# group_type :integer default("individual"), not null
|
||||
# identifier :string
|
||||
# last_activity_at :datetime
|
||||
# last_name :string default("")
|
||||
@ -27,6 +28,7 @@
|
||||
#
|
||||
# index_contacts_on_account_id (account_id)
|
||||
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
|
||||
# index_contacts_on_account_id_and_group_type (account_id,group_type)
|
||||
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
|
||||
# index_contacts_on_blocked (blocked)
|
||||
# index_contacts_on_company_id (company_id)
|
||||
@ -41,7 +43,7 @@
|
||||
|
||||
# rubocop:enable Layout/LineLength
|
||||
|
||||
class Contact < ApplicationRecord
|
||||
class Contact < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
||||
include Avatarable
|
||||
include AvailabilityStatusable
|
||||
include Labelable
|
||||
@ -62,6 +64,10 @@ class Contact < ApplicationRecord
|
||||
has_many :inboxes, through: :contact_inboxes
|
||||
has_many :messages, as: :sender, dependent: :destroy_async
|
||||
has_many :notes, dependent: :destroy_async
|
||||
has_many :group_memberships, class_name: 'GroupMember', foreign_key: :group_contact_id, dependent: :destroy,
|
||||
inverse_of: :group_contact
|
||||
has_many :group_member_contacts, through: :group_memberships, source: :contact
|
||||
has_many :group_participations, class_name: 'GroupMember', dependent: :destroy, inverse_of: :contact
|
||||
before_validation :prepare_contact_attributes
|
||||
after_create_commit :dispatch_create_event, :ip_lookup
|
||||
after_update_commit :dispatch_update_event
|
||||
@ -69,6 +75,7 @@ class Contact < ApplicationRecord
|
||||
before_save :sync_contact_attributes
|
||||
|
||||
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
|
||||
enum group_type: { individual: 0, group: 1 }, _prefix: true
|
||||
|
||||
scope :order_on_last_activity_at, lambda { |direction|
|
||||
order(
|
||||
@ -147,11 +154,16 @@ class Contact < ApplicationRecord
|
||||
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
|
||||
end
|
||||
|
||||
def group_channel
|
||||
contact_inboxes.first&.inbox&.channel
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
additional_attributes: additional_attributes,
|
||||
custom_attributes: custom_attributes,
|
||||
email: email,
|
||||
group_type: group_type,
|
||||
id: id,
|
||||
identifier: identifier,
|
||||
name: name,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
# contact_last_seen_at :datetime
|
||||
# custom_attributes :jsonb
|
||||
# first_reply_created_at :datetime
|
||||
# group_type :integer default("individual"), not null
|
||||
# identifier :string
|
||||
# last_activity_at :datetime not null
|
||||
# priority :integer
|
||||
@ -27,6 +28,7 @@
|
||||
# contact_inbox_id :bigint
|
||||
# display_id :integer not null
|
||||
# inbox_id :integer not null
|
||||
# kanban_task_id :bigint
|
||||
# sla_policy_id :bigint
|
||||
# team_id :bigint
|
||||
#
|
||||
@ -35,6 +37,7 @@
|
||||
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
|
||||
# index_conversations_on_account_id (account_id)
|
||||
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
|
||||
# index_conversations_on_account_id_and_group_type (account_id,group_type)
|
||||
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
|
||||
# index_conversations_on_campaign_id (campaign_id)
|
||||
# index_conversations_on_contact_id (contact_id)
|
||||
@ -43,6 +46,8 @@
|
||||
# index_conversations_on_id_and_account_id (account_id,id)
|
||||
# index_conversations_on_identifier_and_account_id (identifier,account_id)
|
||||
# index_conversations_on_inbox_id (inbox_id)
|
||||
# index_conversations_on_inbox_id_and_group_type (inbox_id,group_type)
|
||||
# index_conversations_on_kanban_task_id (kanban_task_id)
|
||||
# index_conversations_on_priority (priority)
|
||||
# index_conversations_on_status_and_account_id (status,account_id)
|
||||
# index_conversations_on_status_and_priority (status,priority)
|
||||
@ -50,6 +55,10 @@
|
||||
# index_conversations_on_uuid (uuid) UNIQUE
|
||||
# index_conversations_on_waiting_since (waiting_since)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (kanban_task_id => kanban_tasks.id)
|
||||
#
|
||||
|
||||
class Conversation < ApplicationRecord
|
||||
include Labelable
|
||||
@ -74,6 +83,7 @@ class Conversation < ApplicationRecord
|
||||
|
||||
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
|
||||
enum priority: { low: 0, medium: 1, high: 2, urgent: 3 }
|
||||
enum group_type: { individual: 0, group: 1 }, _prefix: true
|
||||
|
||||
scope :unassigned, -> { where(assignee_id: nil) }
|
||||
scope :assigned, -> { where.not(assignee_id: nil) }
|
||||
|
||||
@ -2,24 +2,28 @@
|
||||
#
|
||||
# Table name: csat_survey_responses
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# feedback_message :text
|
||||
# rating :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assigned_agent_id :bigint
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# csat_review_notes :text
|
||||
# feedback_message :text
|
||||
# rating :integer not null
|
||||
# review_notes_updated_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assigned_agent_id :bigint
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# review_notes_updated_by_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_csat_survey_responses_on_account_id (account_id)
|
||||
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
|
||||
# index_csat_survey_responses_on_contact_id (contact_id)
|
||||
# index_csat_survey_responses_on_conversation_id (conversation_id)
|
||||
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
|
||||
# index_csat_survey_responses_on_account_id (account_id)
|
||||
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
|
||||
# index_csat_survey_responses_on_contact_id (contact_id)
|
||||
# index_csat_survey_responses_on_conversation_id (conversation_id)
|
||||
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
|
||||
# index_csat_survey_responses_on_review_notes_updated_by_id (review_notes_updated_by_id)
|
||||
#
|
||||
class CsatSurveyResponse < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
35
app/models/group_member.rb
Normal file
35
app/models/group_member.rb
Normal file
@ -0,0 +1,35 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: group_members
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# is_active :boolean default(TRUE), not null
|
||||
# role :integer default("member"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# contact_id :bigint not null
|
||||
# group_contact_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_group_members_on_contact_id (contact_id)
|
||||
# index_group_members_on_group_contact_id (group_contact_id)
|
||||
# index_group_members_on_group_contact_id_and_contact_id (group_contact_id,contact_id) UNIQUE
|
||||
# index_group_members_on_group_contact_id_and_is_active (group_contact_id,is_active)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (contact_id => contacts.id)
|
||||
# fk_rails_... (group_contact_id => contacts.id)
|
||||
#
|
||||
class GroupMember < ApplicationRecord
|
||||
belongs_to :group_contact, class_name: 'Contact'
|
||||
belongs_to :contact
|
||||
|
||||
enum role: { member: 0, admin: 1 }
|
||||
|
||||
validates :group_contact_id, uniqueness: { scope: :contact_id }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
scope :inactive, -> { where(is_active: false) }
|
||||
end
|
||||
@ -17,13 +17,14 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
#
|
||||
|
||||
class ReportingEvent < ApplicationRecord
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
# message_signature :text
|
||||
# name :string not null
|
||||
# otp_backup_codes :text
|
||||
# otp_required_for_login :boolean default(FALSE)
|
||||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# otp_secret :string
|
||||
# provider :string default("email"), not null
|
||||
# pubsub_token :string
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
# message_signature :text
|
||||
# name :string not null
|
||||
# otp_backup_codes :text
|
||||
# otp_required_for_login :boolean default(FALSE)
|
||||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# otp_secret :string
|
||||
# provider :string default("email"), not null
|
||||
# pubsub_token :string
|
||||
|
||||
@ -47,6 +47,10 @@ class ContactPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def sync_group?
|
||||
true
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
class Conversations::EventDataPresenter < SimpleDelegator
|
||||
def push_data
|
||||
def push_data # rubocop:disable Metrics/MethodLength
|
||||
{
|
||||
additional_attributes: additional_attributes,
|
||||
can_reply: can_reply?,
|
||||
channel: inbox.try(:channel_type),
|
||||
contact_inbox: contact_inbox,
|
||||
group_type: group_type,
|
||||
id: display_id,
|
||||
inbox_id: inbox_id,
|
||||
messages: push_messages,
|
||||
|
||||
53
app/services/contacts/sync_group_service.rb
Normal file
53
app/services/contacts/sync_group_service.rb
Normal file
@ -0,0 +1,53 @@
|
||||
class Contacts::SyncGroupService
|
||||
pattr_initialize [:contact!, { soft: false }]
|
||||
|
||||
def perform
|
||||
validate_group_contact!
|
||||
|
||||
channel = contact.group_channel
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_supported_inbox') if channel.blank? || !channel.respond_to?(:sync_group)
|
||||
|
||||
conversation = find_or_create_sync_conversation
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_supported_inbox') if conversation.blank?
|
||||
|
||||
channel.sync_group(conversation, soft: soft)
|
||||
|
||||
contact.reload
|
||||
dispatch_group_synced_event
|
||||
contact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_sync_conversation
|
||||
contact_inbox = contact.contact_inboxes.first
|
||||
return nil if contact_inbox.blank?
|
||||
|
||||
contact_inbox.conversations.where(status: %i[open pending]).last ||
|
||||
contact_inbox.conversations.order(created_at: :desc).first ||
|
||||
create_group_conversation(contact_inbox)
|
||||
end
|
||||
|
||||
def create_group_conversation(contact_inbox)
|
||||
Conversation.create!(
|
||||
account_id: contact_inbox.inbox.account_id,
|
||||
inbox_id: contact_inbox.inbox_id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
group_type: :group
|
||||
)
|
||||
end
|
||||
|
||||
def validate_group_contact!
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.not_a_group') if contact.group_type_individual?
|
||||
raise ActionController::BadRequest, I18n.t('contacts.sync_group.no_identifier') if contact.identifier.blank?
|
||||
end
|
||||
|
||||
def dispatch_group_synced_event
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
Events::Types::CONTACT_GROUP_SYNCED,
|
||||
Time.zone.now,
|
||||
contact: contact
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -48,6 +48,7 @@ class FilterService
|
||||
|
||||
return conversation_status_values(values) if attribute_key == 'status'
|
||||
return conversation_priority_values(values) if attribute_key == 'priority'
|
||||
return conversation_group_type_values(values) if attribute_key == 'group_type'
|
||||
return message_type_values(values) if attribute_key == 'message_type'
|
||||
return downcase_array_values(values) if attribute_key == 'content'
|
||||
|
||||
|
||||
21
app/services/groups/create_service.rb
Normal file
21
app/services/groups/create_service.rb
Normal file
@ -0,0 +1,21 @@
|
||||
class Groups::CreateService
|
||||
pattr_initialize [:inbox!, :subject!, :participants!]
|
||||
|
||||
def perform
|
||||
group_data = channel.create_group(subject, format_participants)
|
||||
group_jid = group_data&.dig(:id)
|
||||
raise Whatsapp::Providers::WhatsappBaileysService::ProviderUnavailableError, 'Group JID missing from response' if group_jid.blank?
|
||||
|
||||
{ group_jid: group_jid }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
inbox.channel
|
||||
end
|
||||
|
||||
def format_participants
|
||||
participants.map { |phone| "#{phone.delete('+')}@s.whatsapp.net" }
|
||||
end
|
||||
end
|
||||
@ -17,7 +17,11 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
if node.url.start_with?('mention://')
|
||||
out(:children)
|
||||
else
|
||||
out(node.url)
|
||||
end
|
||||
end
|
||||
|
||||
def list(node)
|
||||
|
||||
@ -2,7 +2,14 @@ class Messages::MentionService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return unless valid_mention_message?(message)
|
||||
process_user_team_mentions
|
||||
process_contact_mentions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_user_team_mentions
|
||||
return unless valid_user_mention_message?
|
||||
|
||||
validated_mentioned_ids = filter_mentioned_ids_by_inbox
|
||||
return if validated_mentioned_ids.blank?
|
||||
@ -12,12 +19,23 @@ class Messages::MentionService
|
||||
add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
end
|
||||
|
||||
private
|
||||
def process_contact_mentions
|
||||
contact_ids = contact_mentioned_ids
|
||||
return if contact_ids.blank?
|
||||
|
||||
def valid_mention_message?(message)
|
||||
message.update!(content_attributes: message.content_attributes.merge('mentioned_contacts' => contact_ids))
|
||||
end
|
||||
|
||||
def valid_user_mention_message?
|
||||
message.private? && message.content.present? && mentioned_ids.present?
|
||||
end
|
||||
|
||||
def contact_mentioned_ids
|
||||
return [] if message.content.blank?
|
||||
|
||||
message.content.scan(%r{\(mention://contact/(\d+)/(.+?)\)}).map(&:first).uniq
|
||||
end
|
||||
|
||||
def mentioned_ids
|
||||
user_mentions = message.content.scan(%r{\(mention://user/(\d+)/(.+?)\)}).map(&:first)
|
||||
team_mentions = message.content.scan(%r{\(mention://team/(\d+)/(.+?)\)}).map(&:first)
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
module Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler # rubocop:disable Metrics/ModuleLength
|
||||
extend ActiveSupport::Concern
|
||||
include GroupConversationHandler
|
||||
include Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
|
||||
|
||||
private
|
||||
|
||||
def handle_group_contact_message
|
||||
@lock_acquired = acquire_message_processing_lock
|
||||
return unless @lock_acquired
|
||||
|
||||
# Lock by group jid to prevent race conditions when multiple messages
|
||||
# from the same group arrive simultaneously (e.g., Multiple contacts sending messages at the same time).
|
||||
with_contact_lock(extract_group_jid) do
|
||||
# Re-check after acquiring lock to handle race conditions where:
|
||||
# 1. An agent sends a message from Chatwoot (slow API call)
|
||||
# 2. WhatsApp sends webhook before source_id is saved
|
||||
# 3. Webhook handler times out waiting for channel lock and proceeds
|
||||
# 4. By now, source_id should be set, so we can find the message
|
||||
return if find_message_by_source_id(raw_message_id)
|
||||
|
||||
process_group_message
|
||||
end
|
||||
ensure
|
||||
clear_message_source_id_from_redis if @lock_acquired
|
||||
end
|
||||
|
||||
def process_group_message
|
||||
@group_contact_inbox, @group_contact = find_or_create_group_contact
|
||||
|
||||
consolidate_contact(baileys_sender_phone, baileys_sender_lid, baileys_sender_identifier)
|
||||
@sender_contact = find_or_create_sender_contact
|
||||
if @sender_contact
|
||||
update_contact_whatsapp_info(@sender_contact, baileys_sender_phone, baileys_sender_identifier, name: extract_sender_name)
|
||||
try_update_contact_avatar(@sender_contact)
|
||||
end
|
||||
|
||||
@conversation = find_or_create_group_conversation(@group_contact_inbox)
|
||||
add_group_member(@group_contact, @sender_contact) if @sender_contact
|
||||
|
||||
build_and_save_message(
|
||||
conversation: @conversation,
|
||||
sender: @sender_contact,
|
||||
attach_media: should_attach_media?
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_participant_contact(participant)
|
||||
lid = extract_lid_from_participant(participant)
|
||||
phone = extract_phone_from_participant(participant)
|
||||
identifier = lid ? "#{lid}@lid" : nil
|
||||
source_id = lid || phone
|
||||
|
||||
return nil if source_id.blank?
|
||||
|
||||
consolidate_contact(phone, lid, identifier)
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: phone,
|
||||
phone_number: ("+#{phone}" if phone),
|
||||
identifier: identifier
|
||||
}
|
||||
).perform
|
||||
|
||||
update_contact_whatsapp_info(contact_inbox.contact, phone, identifier)
|
||||
end
|
||||
|
||||
def consolidate_contact(phone, lid, identifier)
|
||||
return unless phone || lid
|
||||
|
||||
Whatsapp::ContactInboxConsolidationService.new(
|
||||
inbox: inbox, phone: phone, lid: lid, identifier: identifier
|
||||
).perform
|
||||
end
|
||||
|
||||
def update_contact_whatsapp_info(contact, phone, identifier, name: nil)
|
||||
update_params = {
|
||||
phone_number: ("+#{phone}" if should_update_contact_phone?(contact, phone)),
|
||||
identifier: (identifier if should_update_contact_identifier?(contact, identifier)),
|
||||
name: (name if should_update_contact_name?(contact, name))
|
||||
}.compact
|
||||
|
||||
contact.update!(update_params) if update_params.present?
|
||||
contact
|
||||
end
|
||||
|
||||
def should_update_contact_phone?(contact, phone)
|
||||
phone && contact.phone_number.blank?
|
||||
end
|
||||
|
||||
def should_update_contact_identifier?(contact, identifier)
|
||||
identifier && contact.identifier.blank?
|
||||
end
|
||||
|
||||
def should_update_contact_name?(contact, name)
|
||||
name && (contact.name.blank? || contact.name.match?(/^\d+/))
|
||||
end
|
||||
|
||||
def extract_lid_from_participant(participant)
|
||||
return nil if participant[:id].blank?
|
||||
|
||||
jid_part, jid_suffix = participant[:id].split('@')
|
||||
jid_part if jid_suffix == 'lid' && jid_part.match?(/^\d+$/)
|
||||
end
|
||||
|
||||
def extract_phone_from_participant(participant)
|
||||
return nil if participant[:phoneNumber].blank?
|
||||
|
||||
phone = participant[:phoneNumber].split('@').first
|
||||
phone if phone.match?(/^\d+$/)
|
||||
end
|
||||
|
||||
def extract_group_identifier
|
||||
extract_group_jid
|
||||
end
|
||||
|
||||
def extract_group_source_id
|
||||
extract_group_jid.split('@').first
|
||||
end
|
||||
|
||||
def extract_group_name
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_sender_identifier
|
||||
baileys_sender_identifier
|
||||
end
|
||||
|
||||
def extract_sender_source_id
|
||||
baileys_sender_lid || baileys_sender_phone
|
||||
end
|
||||
|
||||
def extract_sender_name
|
||||
@raw_message[:pushName] || baileys_sender_phone || baileys_sender_lid
|
||||
end
|
||||
|
||||
def extract_sender_phone
|
||||
phone = baileys_sender_phone
|
||||
"+#{phone}" if phone.present?
|
||||
end
|
||||
|
||||
def extract_group_jid
|
||||
@raw_message[:key][:remoteJid]
|
||||
end
|
||||
|
||||
def extract_sender_jid
|
||||
return if @raw_message[:key][:participant].blank?
|
||||
|
||||
@raw_message[:key][:participant]
|
||||
end
|
||||
|
||||
def extract_sender_jid_alt
|
||||
@raw_message[:key][:participantAlt]
|
||||
end
|
||||
|
||||
def baileys_sender_phone
|
||||
alt_jid = extract_sender_jid_alt
|
||||
if alt_jid.present?
|
||||
phone = alt_jid.split('@').first
|
||||
return phone if phone.match?(/^\d+$/)
|
||||
end
|
||||
|
||||
sender_jid = extract_sender_jid
|
||||
return if sender_jid.blank?
|
||||
|
||||
jid_part = sender_jid.split('@').first
|
||||
parts = jid_part.split(':')
|
||||
parts.first if parts.first.match?(/^\d+$/)
|
||||
end
|
||||
|
||||
def baileys_sender_lid
|
||||
sender_jid = extract_sender_jid
|
||||
return if sender_jid.blank?
|
||||
|
||||
jid_part, jid_suffix = sender_jid.split('@')
|
||||
return jid_part if jid_suffix == 'lid' && jid_part.match?(/^\d+$/)
|
||||
|
||||
parts = jid_part.split(':')
|
||||
parts.last if parts.length > 1 && parts.last.match?(/^\d+$/)
|
||||
end
|
||||
|
||||
def baileys_sender_identifier
|
||||
lid = baileys_sender_lid
|
||||
lid ? "#{lid}@lid" : nil
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,49 @@
|
||||
module Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
|
||||
private
|
||||
|
||||
def find_or_create_group_contact_inbox_by_jid(group_jid)
|
||||
source_id = group_jid.split('@').first
|
||||
|
||||
::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: source_id,
|
||||
identifier: group_jid,
|
||||
group_type: :group
|
||||
}
|
||||
).perform
|
||||
end
|
||||
|
||||
def create_group_activity(conversation, action, **params)
|
||||
locale = inbox.account.locale || I18n.default_locale
|
||||
|
||||
content = I18n.with_locale(locale) { I18n.t("conversations.activity.groups_update.#{action}", **params) }
|
||||
|
||||
conversation.messages.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
|
||||
def resolve_author_name(author_jid)
|
||||
return author_jid if author_jid.blank?
|
||||
|
||||
lid = author_jid.split('@').first
|
||||
contact_inbox = inbox.contact_inboxes.find_by(source_id: lid)
|
||||
resolved_contact = contact_inbox&.contact
|
||||
|
||||
resolved_contact&.name.presence || resolved_contact&.phone_number || lid
|
||||
end
|
||||
|
||||
def dispatch_group_synced_event(group_contact)
|
||||
group_contact.reload
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
Events::Types::CONTACT_GROUP_SYNCED,
|
||||
Time.zone.now,
|
||||
contact: group_contact
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,170 @@
|
||||
module Whatsapp::BaileysHandlers::Concerns::GroupStubMessageHandler # rubocop:disable Metrics/ModuleLength
|
||||
MEMBERSHIP_REQUEST_STUB = 'GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD'.freeze
|
||||
ICON_CHANGE_STUB = 'GROUP_CHANGE_ICON'.freeze
|
||||
GROUP_CREATE_STUB = 'GROUP_CREATE'.freeze
|
||||
|
||||
private
|
||||
|
||||
def handle_membership_request_stub
|
||||
stub_params = @raw_message[:messageStubParameters]
|
||||
return if stub_params.blank?
|
||||
|
||||
action = parse_membership_request_action(stub_params)
|
||||
return unless action
|
||||
|
||||
group_jid = @raw_message[:key][:remoteJid]
|
||||
contact_name = resolve_membership_request_contact_name(stub_params)
|
||||
|
||||
with_contact_lock(group_jid) do
|
||||
group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
|
||||
conversation = find_or_create_group_conversation(group_contact_inbox)
|
||||
create_group_activity(conversation, action, contact_name: contact_name)
|
||||
update_pending_join_requests(group_contact_inbox.contact, stub_params, action)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_icon_change_stub
|
||||
group_jid = @raw_message[:key][:remoteJid]
|
||||
participant_jid = @raw_message[:key][:participant]
|
||||
|
||||
with_contact_lock(group_jid) do
|
||||
group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
|
||||
conversation = find_or_create_group_conversation(group_contact_inbox)
|
||||
author_name = resolve_author_name(participant_jid)
|
||||
create_group_activity(conversation, 'icon_changed', author_name: author_name)
|
||||
update_group_avatar(group_contact_inbox.contact)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_group_create_stub
|
||||
group_jid = @raw_message[:key][:remoteJid]
|
||||
group_name = @raw_message[:messageStubParameters]&.first
|
||||
|
||||
with_contact_lock(group_jid) do
|
||||
group_contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: group_jid.split('@').first,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: group_name || group_jid,
|
||||
identifier: group_jid,
|
||||
group_type: :group
|
||||
}
|
||||
).perform
|
||||
|
||||
group_contact = group_contact_inbox.contact
|
||||
was_group_left = group_contact.additional_attributes&.dig('group_left').present?
|
||||
reset_group_left_flag(group_contact)
|
||||
find_or_create_group_conversation(group_contact_inbox)
|
||||
handle_group_rejoin(group_contact) if was_group_left
|
||||
enqueue_group_sync(group_contact, force: was_group_left)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_group_rejoin(group_contact)
|
||||
add_inbox_contact_as_member(group_contact)
|
||||
dispatch_group_synced_event(group_contact)
|
||||
end
|
||||
|
||||
def enqueue_group_sync(group_contact, force: false)
|
||||
Contacts::SyncGroupJob.set(wait: 5.seconds).perform_later(group_contact, force: force)
|
||||
end
|
||||
|
||||
def add_inbox_contact_as_member(group_contact)
|
||||
inbox_phone = inbox.channel.phone_number&.delete('+')
|
||||
return if inbox_phone.blank?
|
||||
|
||||
contact = Contact.where(account_id: inbox.account_id)
|
||||
.where("REPLACE(phone_number, '+', '') = ?", inbox_phone)
|
||||
.first
|
||||
return if contact.blank?
|
||||
|
||||
add_group_member(group_contact, contact)
|
||||
end
|
||||
|
||||
def reset_group_left_flag(group_contact)
|
||||
return unless group_contact.additional_attributes&.dig('group_left')
|
||||
|
||||
new_attrs = (group_contact.additional_attributes || {}).merge('group_left' => false)
|
||||
group_contact.update!(additional_attributes: new_attrs)
|
||||
end
|
||||
|
||||
def update_group_avatar(group_contact)
|
||||
provider = group_contact.group_channel&.provider_service
|
||||
return if provider.blank?
|
||||
|
||||
provider.try_update_group_avatar(group_contact, force: true)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[GROUP_ICON] Failed to update avatar for #{group_contact.identifier}: #{e.message}"
|
||||
end
|
||||
|
||||
def parse_membership_request_action(stub_params)
|
||||
if stub_params.include?('created')
|
||||
'membership_request_created'
|
||||
elsif stub_params.include?('revoked')
|
||||
'membership_request_revoked'
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_membership_request_contact_name(stub_params)
|
||||
participant_data = JSON.parse(stub_params.first)
|
||||
lid = extract_jid_user(participant_data['lid'])
|
||||
phone = extract_jid_user(participant_data['pn'])
|
||||
|
||||
find_contact_display_name(lid, phone) || format_fallback_name(lid, phone)
|
||||
rescue JSON::ParserError, TypeError
|
||||
extract_jid_user(@raw_message[:key][:participant])
|
||||
end
|
||||
|
||||
def extract_jid_user(jid)
|
||||
jid&.split('@')&.first
|
||||
end
|
||||
|
||||
def find_contact_display_name(lid, phone)
|
||||
source_id = lid || phone
|
||||
return unless source_id
|
||||
|
||||
contact = inbox.contact_inboxes.find_by(source_id: source_id)&.contact
|
||||
return unless contact
|
||||
|
||||
contact.name.presence || contact.phone_number
|
||||
end
|
||||
|
||||
def format_fallback_name(lid, phone)
|
||||
phone ? "+#{phone}" : lid
|
||||
end
|
||||
|
||||
def update_pending_join_requests(group_contact, stub_params, action)
|
||||
participant_data = JSON.parse(stub_params.first)
|
||||
lid = participant_data['lid']
|
||||
current_requests = group_contact.additional_attributes&.dig('pending_join_requests') || []
|
||||
updated = current_requests.reject { |r| r['jid'] == lid }
|
||||
updated << build_join_request_entry(participant_data) if action == 'membership_request_created'
|
||||
|
||||
new_attrs = (group_contact.additional_attributes || {}).merge('pending_join_requests' => updated)
|
||||
group_contact.update!(additional_attributes: new_attrs)
|
||||
rescue JSON::ParserError, TypeError => e
|
||||
Rails.logger.error "[GROUP_STUB] Failed to update pending join requests: #{e.message}"
|
||||
end
|
||||
|
||||
def build_join_request_entry(participant_data)
|
||||
contact = find_or_create_requester_contact(participant_data['lid'], participant_data['pn'])
|
||||
{ 'jid' => participant_data['lid'], 'contact_id' => contact&.id, 'request_time' => Time.current.to_i.to_s }
|
||||
end
|
||||
|
||||
def find_or_create_requester_contact(lid_jid, phone_jid)
|
||||
lid = extract_jid_user(lid_jid)
|
||||
phone = extract_jid_user(phone_jid)
|
||||
source_id = lid || phone
|
||||
return if source_id.blank?
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id, inbox: inbox,
|
||||
contact_attributes: requester_contact_attributes(lid, phone)
|
||||
).perform
|
||||
contact_inbox&.contact
|
||||
end
|
||||
|
||||
def requester_contact_attributes(lid, phone)
|
||||
{ name: phone ? "+#{phone}" : lid, phone_number: ("+#{phone}" if phone), identifier: ("#{lid}@lid" if lid) }.compact
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,80 @@
|
||||
module Whatsapp::BaileysHandlers::Concerns::IndividualContactMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
include Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
|
||||
|
||||
private
|
||||
|
||||
def handle_individual_contact_message
|
||||
return unless extract_from_jid(type: 'lid')
|
||||
|
||||
@lock_acquired = acquire_message_processing_lock
|
||||
return unless @lock_acquired
|
||||
|
||||
# Lock by contact phone to prevent race conditions when multiple messages
|
||||
# from the same contact arrive simultaneously (e.g., WhatsApp albums).
|
||||
contact_phone = extract_from_jid(type: 'pn') || extract_from_jid(type: 'lid')
|
||||
with_contact_lock(contact_phone) do
|
||||
# Re-check after acquiring lock to handle race conditions where:
|
||||
# 1. An agent sends a message from Chatwoot (slow API call)
|
||||
# 2. WhatsApp sends webhook before source_id is saved
|
||||
# 3. Webhook handler times out waiting for channel lock and proceeds
|
||||
# 4. By now, source_id should be set, so we can find the message
|
||||
return if find_message_by_source_id(raw_message_id)
|
||||
|
||||
set_contact
|
||||
|
||||
unless @contact
|
||||
Rails.logger.warn "Contact not found for message: #{raw_message_id}"
|
||||
return
|
||||
end
|
||||
|
||||
set_conversation
|
||||
handle_create_message
|
||||
end
|
||||
ensure
|
||||
clear_message_source_id_from_redis if @lock_acquired
|
||||
end
|
||||
|
||||
def set_contact
|
||||
phone = extract_from_jid(type: 'pn')
|
||||
source_id = extract_from_jid(type: 'lid')
|
||||
identifier = "#{source_id}@lid"
|
||||
|
||||
Whatsapp::ContactInboxConsolidationService.new(
|
||||
inbox: inbox,
|
||||
phone: phone,
|
||||
lid: source_id,
|
||||
identifier: identifier
|
||||
).perform
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: { name: contact_name, phone_number: ("+#{phone}" if phone), identifier: identifier }
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
update_contact_info(phone, source_id, identifier)
|
||||
end
|
||||
|
||||
def update_contact_info(phone, source_id, identifier)
|
||||
update_params = {
|
||||
phone_number: ("+#{phone}" if phone),
|
||||
identifier: (identifier if @contact.identifier != identifier),
|
||||
name: (contact_name if @contact.name.in?([phone, source_id, identifier]))
|
||||
}.compact
|
||||
|
||||
@contact.update!(update_params) if update_params.present?
|
||||
try_update_contact_avatar
|
||||
end
|
||||
|
||||
def handle_create_message
|
||||
build_and_save_message(
|
||||
conversation: @conversation,
|
||||
sender: @contact,
|
||||
attach_media: should_attach_media?
|
||||
)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,78 @@
|
||||
module Whatsapp::BaileysHandlers::Concerns::MessageCreationHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def build_and_save_message(conversation:, sender:, attach_media: false)
|
||||
@message = conversation.messages.build(
|
||||
content: message_content,
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
source_id: raw_message_id,
|
||||
sender: incoming? ? sender : nil,
|
||||
message_type: incoming? ? :incoming : :outgoing,
|
||||
content_attributes: build_message_content_attributes
|
||||
)
|
||||
|
||||
attach_media_to_message if attach_media
|
||||
|
||||
@message.save!
|
||||
|
||||
inbox.channel.received_messages([@message], conversation) if incoming?
|
||||
|
||||
@message
|
||||
end
|
||||
|
||||
def build_message_content_attributes
|
||||
type = message_type
|
||||
msg = unwrap_ephemeral_message(@raw_message[:message])
|
||||
content_attributes = { external_created_at: baileys_extract_message_timestamp(@raw_message[:messageTimestamp]) }
|
||||
content_attributes[:external_sender_name] = 'WhatsApp' unless incoming?
|
||||
|
||||
if type == 'reaction'
|
||||
content_attributes[:in_reply_to_external_id] = msg.dig(:reactionMessage, :key, :id)
|
||||
content_attributes[:is_reaction] = true
|
||||
elsif reply_to_message_id
|
||||
content_attributes[:in_reply_to_external_id] = reply_to_message_id
|
||||
elsif type == 'unsupported'
|
||||
content_attributes[:is_unsupported] = true
|
||||
end
|
||||
|
||||
content_attributes
|
||||
end
|
||||
|
||||
def attach_media_to_message
|
||||
attachment_file = download_attachment_file
|
||||
msg = unwrap_ephemeral_message(@raw_message[:message])
|
||||
|
||||
attachment = @message.attachments.build(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type.to_s,
|
||||
file: { io: attachment_file, filename: build_attachment_filename, content_type: message_mimetype }
|
||||
)
|
||||
attachment.meta = { is_recorded_audio: true } if msg.dig(:audioMessage, :ptt)
|
||||
rescue Down::Error => e
|
||||
@message.is_unsupported = true
|
||||
Rails.logger.error "Failed to download attachment for message #{raw_message_id}: #{e.message}"
|
||||
end
|
||||
|
||||
def download_attachment_file
|
||||
Down.download(
|
||||
inbox.channel.media_url(@raw_message.dig(:key, :id)),
|
||||
headers: inbox.channel.api_headers
|
||||
)
|
||||
end
|
||||
|
||||
def build_attachment_filename
|
||||
msg = unwrap_ephemeral_message(@raw_message[:message])
|
||||
filename = msg.dig(:documentMessage, :fileName) || msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :fileName)
|
||||
return filename if filename.present?
|
||||
|
||||
ext = ".#{message_mimetype.split(';').first.split('/').last}" if message_mimetype.present?
|
||||
"#{file_content_type}_#{raw_message_id}_#{Time.current.strftime('%Y%m%d')}#{ext}"
|
||||
end
|
||||
|
||||
def should_attach_media?
|
||||
%w[image file video audio sticker].include?(message_type)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,118 @@
|
||||
module Whatsapp::BaileysHandlers::GroupParticipantsUpdate
|
||||
include Whatsapp::BaileysHandlers::Helpers
|
||||
include Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler
|
||||
include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
|
||||
|
||||
private
|
||||
|
||||
def process_group_participants_update
|
||||
data = processed_params[:data]
|
||||
return if data.blank?
|
||||
|
||||
group_jid, author, action, participants = data.values_at(:id, :author, :action, :participants)
|
||||
return unless valid_participant_update?(group_jid, action, participants)
|
||||
|
||||
with_contact_lock(group_jid) do
|
||||
group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
|
||||
conversation = find_or_create_group_conversation(group_contact_inbox)
|
||||
group_contact = group_contact_inbox.contact
|
||||
|
||||
contacts = participants.filter_map { |participant| find_or_create_participant_contact(participant) }
|
||||
return if contacts.empty?
|
||||
|
||||
contacts.each { |contact| apply_participant_action(action, group_contact, contact) }
|
||||
create_participant_activity(conversation, action, contacts, author)
|
||||
dispatch_group_synced_event(group_contact)
|
||||
|
||||
resolve_conversations_if_inbox_left(action, author, contacts, group_contact_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def valid_participant_update?(group_jid, action, participants)
|
||||
group_jid.present? && action.present? && participants.present? && action.in?(%w[add remove promote demote])
|
||||
end
|
||||
|
||||
def apply_participant_action(action, group_contact, contact)
|
||||
case action
|
||||
when 'add'
|
||||
add_group_member(group_contact, contact, role: :member)
|
||||
when 'remove'
|
||||
remove_group_member(group_contact, contact)
|
||||
when 'promote'
|
||||
update_group_member_role(group_contact, contact, :admin)
|
||||
when 'demote'
|
||||
update_group_member_role(group_contact, contact, :member)
|
||||
end
|
||||
end
|
||||
|
||||
def create_participant_activity(conversation, action, contacts, author_jid)
|
||||
locale = inbox.account.locale || I18n.default_locale
|
||||
action = resolve_effective_action(action, author_jid, contacts)
|
||||
|
||||
content = I18n.with_locale(locale) { build_activity_content(action, contacts, resolve_author_name(author_jid)) }
|
||||
|
||||
conversation.messages.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
|
||||
def resolve_effective_action(action, author_jid, contacts)
|
||||
return 'join' if action == 'add' && author_jid.blank?
|
||||
return 'leave' if action == 'remove' && author_is_participant?(author_jid, contacts)
|
||||
|
||||
action
|
||||
end
|
||||
|
||||
def author_is_participant?(author_jid, contacts)
|
||||
return false if author_jid.blank?
|
||||
|
||||
author_lid = author_jid.split('@').first
|
||||
contacts.any? { |c| c.identifier&.start_with?(author_lid) || c.phone_number&.delete('+') == author_lid }
|
||||
end
|
||||
|
||||
def build_activity_content(action, contacts, author_name)
|
||||
names = contacts.map { |c| c.name.presence || c.phone_number || c.identifier }
|
||||
|
||||
return I18n.t("conversations.activity.group_participants.#{action}", contact_name: names.first) if action.in?(%w[join leave])
|
||||
|
||||
params = { author_name: author_name }
|
||||
|
||||
if names.one?
|
||||
params[:contact_name] = names.first
|
||||
I18n.t("conversations.activity.group_participants.#{action}.single", **params)
|
||||
else
|
||||
params[:contact_names] = names[..-2].join(', ')
|
||||
params[:last_contact_name] = names.last
|
||||
I18n.t("conversations.activity.group_participants.#{action}.multiple", **params)
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_conversations_if_inbox_left(action, author_jid, contacts, group_contact_inbox)
|
||||
return unless action == 'remove'
|
||||
return unless inbox_phone_in_participants?(contacts)
|
||||
|
||||
effective_action = resolve_effective_action(action, author_jid, contacts)
|
||||
return unless effective_action.in?(%w[leave remove])
|
||||
|
||||
mark_group_as_left(group_contact_inbox.contact)
|
||||
|
||||
group_contact_inbox.conversations.where(status: %i[open pending]).find_each do |conversation|
|
||||
conversation.update!(status: :resolved)
|
||||
end
|
||||
end
|
||||
|
||||
def mark_group_as_left(group_contact)
|
||||
new_attrs = (group_contact.additional_attributes || {}).merge('group_left' => true)
|
||||
group_contact.update!(additional_attributes: new_attrs) if new_attrs != group_contact.additional_attributes
|
||||
end
|
||||
|
||||
def inbox_phone_in_participants?(contacts)
|
||||
inbox_phone = inbox.channel.phone_number&.delete('+')
|
||||
return false if inbox_phone.blank?
|
||||
|
||||
contacts.any? { |c| c.phone_number&.delete('+') == inbox_phone }
|
||||
end
|
||||
end
|
||||
26
app/services/whatsapp/baileys_handlers/groups_activity.rb
Normal file
26
app/services/whatsapp/baileys_handlers/groups_activity.rb
Normal file
@ -0,0 +1,26 @@
|
||||
module Whatsapp::BaileysHandlers::GroupsActivity
|
||||
include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
|
||||
include GroupConversationHandler
|
||||
|
||||
private
|
||||
|
||||
def process_groups_activity
|
||||
activities = processed_params[:data]
|
||||
return if activities.blank?
|
||||
|
||||
activities.each do |activity|
|
||||
jid = activity[:jid]
|
||||
next if jid.blank?
|
||||
|
||||
with_contact_lock(jid) do
|
||||
group_contact_inbox = find_or_create_group_contact_inbox_by_jid(jid)
|
||||
conversation = find_or_create_group_conversation(group_contact_inbox)
|
||||
|
||||
Contacts::SyncGroupJob.perform_later(group_contact_inbox.contact, soft: true)
|
||||
|
||||
conversation.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.dispatch_conversation_updated_event
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
95
app/services/whatsapp/baileys_handlers/groups_update.rb
Normal file
95
app/services/whatsapp/baileys_handlers/groups_update.rb
Normal file
@ -0,0 +1,95 @@
|
||||
module Whatsapp::BaileysHandlers::GroupsUpdate
|
||||
include Whatsapp::BaileysHandlers::Helpers
|
||||
include Whatsapp::BaileysHandlers::Concerns::GroupContactMessageHandler
|
||||
include Whatsapp::BaileysHandlers::Concerns::GroupEventHelper
|
||||
|
||||
TRACKED_SETTINGS = %w[restrict announce memberAddMode joinApprovalMode].freeze
|
||||
|
||||
private
|
||||
|
||||
def process_groups_update
|
||||
updates = processed_params[:data]
|
||||
return if updates.blank?
|
||||
|
||||
updates.each { |update| process_single_group_update(update) }
|
||||
end
|
||||
|
||||
def process_single_group_update(update)
|
||||
group_jid = update[:id]
|
||||
return if group_jid.blank?
|
||||
|
||||
with_contact_lock(group_jid) do
|
||||
group_contact_inbox = find_or_create_group_contact_inbox_by_jid(group_jid)
|
||||
conversation = find_or_create_group_conversation(group_contact_inbox)
|
||||
author_name = resolve_author_name(update[:author])
|
||||
|
||||
update_group_subject(group_contact_inbox, update[:subject], conversation, author_name) if update.key?(:subject)
|
||||
update_group_description(conversation, update, author_name) if update.key?(:desc)
|
||||
persist_invite_code_update(conversation, update) if update.key?(:inviteCode)
|
||||
create_group_activity(conversation, 'invite_link_reset', author_name: author_name) if update.key?(:inviteCode)
|
||||
persist_settings_changes(conversation, update)
|
||||
process_group_settings_changes(conversation, update, author_name)
|
||||
|
||||
dispatch_group_synced_event(group_contact_inbox.contact)
|
||||
end
|
||||
end
|
||||
|
||||
def update_group_subject(group_contact_inbox, subject, conversation, author_name)
|
||||
return if subject.blank?
|
||||
|
||||
contact = group_contact_inbox.contact
|
||||
contact.update!(name: subject)
|
||||
|
||||
create_group_activity(conversation, 'subject_changed', author_name: author_name, value: subject)
|
||||
end
|
||||
|
||||
def update_group_description(conversation, update, author_name)
|
||||
desc = update[:desc]
|
||||
contact = conversation.contact
|
||||
|
||||
current_attrs = contact.additional_attributes || {}
|
||||
new_attrs = current_attrs.merge('description' => desc.presence)
|
||||
contact.update!(additional_attributes: new_attrs) if current_attrs != new_attrs
|
||||
|
||||
if desc.present?
|
||||
create_group_activity(conversation, 'description_changed', author_name: author_name)
|
||||
else
|
||||
create_group_activity(conversation, 'description_removed', author_name: author_name)
|
||||
end
|
||||
end
|
||||
|
||||
def process_group_settings_changes(conversation, update, author_name)
|
||||
TRACKED_SETTINGS.each do |setting|
|
||||
next unless update.key?(setting.to_sym)
|
||||
|
||||
value = update[setting.to_sym]
|
||||
setting_key = setting.underscore
|
||||
i18n_key = value ? "#{setting_key}_enabled" : "#{setting_key}_disabled"
|
||||
|
||||
create_group_activity(conversation, i18n_key, author_name: author_name)
|
||||
end
|
||||
end
|
||||
|
||||
def persist_settings_changes(conversation, update)
|
||||
contact = conversation.contact
|
||||
settings = {}
|
||||
TRACKED_SETTINGS.each do |setting|
|
||||
next unless update.key?(setting.to_sym)
|
||||
|
||||
settings[setting.underscore] = update[setting.to_sym]
|
||||
end
|
||||
return if settings.blank?
|
||||
|
||||
new_attrs = (contact.additional_attributes || {}).merge(settings)
|
||||
contact.update!(additional_attributes: new_attrs) if new_attrs != contact.additional_attributes
|
||||
end
|
||||
|
||||
def persist_invite_code_update(conversation, update)
|
||||
contact = conversation.contact
|
||||
invite_code = update[:inviteCode]
|
||||
return if invite_code.blank?
|
||||
|
||||
new_attrs = (contact.additional_attributes || {}).merge('invite_code' => invite_code)
|
||||
contact.update!(additional_attributes: new_attrs) if new_attrs != contact.additional_attributes
|
||||
end
|
||||
end
|
||||
@ -73,7 +73,9 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
|
||||
msg = unwrap_ephemeral_message(@raw_message[:message])
|
||||
case message_type
|
||||
when 'text'
|
||||
msg[:conversation] || msg.dig(:extendedTextMessage, :text)
|
||||
text = msg[:conversation] || msg.dig(:extendedTextMessage, :text)
|
||||
context_info = msg.dig(:extendedTextMessage, :contextInfo)
|
||||
convert_incoming_mentions(text, context_info)
|
||||
when 'image'
|
||||
msg.dig(:imageMessage, :caption)
|
||||
when 'video'
|
||||
@ -183,13 +185,14 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
|
||||
nil
|
||||
end
|
||||
|
||||
def try_update_contact_avatar
|
||||
def try_update_contact_avatar(contact = nil)
|
||||
# TODO: Current logic will never update the contact avatar if their profile picture changes on WhatsApp.
|
||||
return if @contact.avatar.attached?
|
||||
target_contact = contact || @contact
|
||||
return if target_contact.avatar.attached?
|
||||
|
||||
phone = extract_from_jid(type: 'pn')
|
||||
phone = contact ? target_contact.phone_number&.delete('+') : extract_from_jid(type: 'pn')
|
||||
profile_pic_url = fetch_profile_picture_url(phone) if phone
|
||||
::Avatar::AvatarFromUrlJob.perform_later(@contact, profile_pic_url) if profile_pic_url
|
||||
::Avatar::AvatarFromUrlJob.perform_later(target_contact, profile_pic_url) if profile_pic_url
|
||||
end
|
||||
|
||||
def message_under_process?
|
||||
@ -206,4 +209,10 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength
|
||||
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{raw_message_id}")
|
||||
::Redis::Alfred.delete(key)
|
||||
end
|
||||
|
||||
def convert_incoming_mentions(text, context_info)
|
||||
return text if text.blank? || context_info.blank?
|
||||
|
||||
Whatsapp::MentionConverterService.convert_incoming_mentions(text, context_info, inbox.account, inbox)
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user