diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 18538e842..af96441f8 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -26,9 +26,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? # @portal.custom_domain = parsed_custom_domain process_attached_logo if params[:blob_id].present? - rescue StandardError => e - Rails.logger.error e - render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity + rescue ActiveRecord::RecordInvalid => e + render_record_invalid(e) end end diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index e7a1f3fa6..3e7d876c3 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,8 +1,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController before_action :validate_feature_enabled! + before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization - # Handles the embedded signup callback data from the Facebook SDK + # Handles both initial authorization and reauthorization + # If inbox_id is present in params, it performs reauthorization def create validate_embedded_signup_params! channel = process_embedded_signup @@ -16,21 +18,42 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, - code: params[:code], - business_id: params[:business_id], - waba_id: params[:waba_id], - phone_number_id: params[:phone_number_id] + params: params.permit(:code, :business_id, :waba_id, :phone_number_id).to_h.symbolize_keys, + inbox_id: params[:inbox_id] ) service.perform end - def render_success_response(inbox) + def fetch_and_validate_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + validate_reauthorization_required + end + + def validate_reauthorization_required + return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + render json: { + success: false, + message: I18n.t('inbox.reauthorization.not_required') + }, status: :unprocessable_entity + end + + def can_upgrade_to_embedded_signup? + channel = @inbox.channel + return false unless channel.provider == 'whatsapp_cloud' + + true + end + + def render_success_response(inbox) + response = { success: true, id: inbox.id, name: inbox.name, channel_type: 'whatsapp' } + response[:message] = I18n.t('inbox.reauthorization.success') if params[:inbox_id].present? + render json: response end def render_error_response(error) diff --git a/app/controllers/slack_uploads_controller.rb b/app/controllers/slack_uploads_controller.rb index 127e77649..6f157c7d5 100644 --- a/app/controllers/slack_uploads_controller.rb +++ b/app/controllers/slack_uploads_controller.rb @@ -17,7 +17,12 @@ class SlackUploadsController < ApplicationController end def blob_url - url_for(@blob.representation(resize_to_fill: [250, nil])) + # Only generate representations for images + if @blob.content_type.start_with?('image/') + url_for(@blob.representation(resize_to_fill: [250, nil])) + else + url_for(@blob) + end end def avatar_url diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index 455828228..d607ba151 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -30,7 +30,8 @@ class Twilio::CallbackController < ApplicationController :NumMedia, :Latitude, :Longitude, - :MessageType + :MessageType, + :ProfileName ) end end diff --git a/app/finders/notification_finder.rb b/app/finders/notification_finder.rb index ccfe470a0..e1958827a 100644 --- a/app/finders/notification_finder.rb +++ b/app/finders/notification_finder.rb @@ -15,7 +15,13 @@ class NotificationFinder end def unread_count - @notifications.where(read_at: nil).count + if type_included?('read') + # If we're including read notifications, filter to unread + @notifications.where(read_at: nil).count + else + # Already filtered to unread notifications, just count + @notifications.count + end end def count @@ -27,7 +33,7 @@ class NotificationFinder def set_up find_all_notifications filter_snoozed_notifications - fitler_read_notifications + filter_read_notifications end def find_all_notifications @@ -38,7 +44,7 @@ class NotificationFinder @notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed') end - def fitler_read_notifications + def filter_read_notifications @notifications = @notifications.where(read_at: nil) unless type_included?('read') end diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js index e1003b123..8f51f4878 100644 --- a/app/javascript/dashboard/api/channel/whatsappChannel.js +++ b/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -9,6 +9,13 @@ class WhatsappChannel extends ApiClient { createEmbeddedSignup(params) { return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params); } + + reauthorizeWhatsApp({ inboxId, ...params }) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, { + ...params, + inbox_id: inboxId, + }); + } } export default new WhatsappChannel(); diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue index 304e55347..05507fc89 100644 --- a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue +++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue @@ -76,8 +76,8 @@ const campaignStatus = computed(() => { const inboxName = computed(() => props.inbox?.name || ''); const inboxIcon = computed(() => { - const { phone_number: phoneNumber, channel_type: type } = props.inbox; - return getInboxIconByType(type, phoneNumber); + const { medium, channel_type: type } = props.inbox; + return getInboxIconByType(type, medium); }); diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue index 12a789fee..f4301375d 100644 --- a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue @@ -38,11 +38,13 @@ const handleClose = () => emit('close'); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0932a79c7..0e893b767 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id); :src="thumbnail" :size="48" :status="availabilityStatus" + hide-offline-status rounded-full />
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue index 36f611775..f9a2507a1 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue @@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox); const inboxName = computed(() => inbox.value?.name); const inboxIcon = computed(() => { - const { phoneNumber, channelType } = inbox.value; - return getInboxIconByType(channelType, phoneNumber); + const { channelType, medium } = inbox.value; + return getInboxIconByType(channelType, medium); }); const lastActivityAt = computed(() => { diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index 7995270b5..862a2cd67 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store'; import { uploadFile } from 'dashboard/helper/uploadHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { useVuelidate } from '@vuelidate/core'; -import { required, minLength, helpers } from '@vuelidate/validators'; -import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators'; +import { required, minLength, helpers, url } from '@vuelidate/validators'; +import { isValidSlug } from 'shared/helpers/Validators'; import Button from 'dashboard/components-next/button/Button.vue'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -71,7 +71,7 @@ const rules = { isValidSlug ), }, - homePageLink: { shouldBeUrl }, + homePageLink: { url }, }; const v$ = useVuelidate(rules, state); diff --git a/app/javascript/dashboard/components-next/Inbox/InboxCard.vue b/app/javascript/dashboard/components-next/Inbox/InboxCard.vue index 79a451404..958f25048 100644 --- a/app/javascript/dashboard/components-next/Inbox/InboxCard.vue +++ b/app/javascript/dashboard/components-next/Inbox/InboxCard.vue @@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt); const inbox = computed(() => props.stateInbox); const inboxIcon = computed(() => { - const { phoneNumber, channelType } = inbox.value; - return getInboxIconByType(channelType, phoneNumber); + const { channelType, medium } = inbox.value; + return getInboxIconByType(channelType, medium); }); const hasSlaThreshold = computed(() => { @@ -63,11 +63,12 @@ const lastActivityAt = computed(() => { }); const menuItems = computed(() => [ - { key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') }, { key: isUnread.value ? 'mark_as_read' : 'mark_as_unread', + icon: isUnread.value ? 'mail' : 'mail-unread', label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`), }, + { key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') }, ]); const messageClasses = computed(() => ({ diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js index 57e819245..cb79752f7 100644 --- a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js +++ b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js @@ -36,10 +36,11 @@ const transformInbox = ({ email, channelType, phoneNumber, + medium, ...rest }) => ({ id, - icon: getInboxIconByType(channelType, phoneNumber, 'line'), + icon: getInboxIconByType(channelType, medium, 'line'), label: generateLabelForContactableInboxesList({ name, email, diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.vue b/app/javascript/dashboard/components-next/avatar/Avatar.vue index 65859b1c7..a47566566 100644 --- a/app/javascript/dashboard/components-next/avatar/Avatar.vue +++ b/app/javascript/dashboard/components-next/avatar/Avatar.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { removeEmoji } from 'shared/helpers/emoji'; import Icon from 'dashboard/components-next/icon/Icon.vue'; +import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue'; import wootConstants from 'dashboard/constants/globals'; const props = defineProps({ @@ -33,10 +34,18 @@ const props = defineProps({ validator: value => !value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value), }, + inbox: { + type: Object, + default: null, + }, iconName: { type: String, default: null, }, + hideOfflineStatus: { + type: Boolean, + default: false, + }, }); const emit = defineEmits(['upload', 'delete']); @@ -66,11 +75,11 @@ const AVATAR_COLORS = { default: { bg: '#E8E8E8', text: '#60646C' }, }; -const STATUS_CLASSES = { +const STATUS_CLASSES = computed(() => ({ online: 'bg-n-teal-10', busy: 'bg-n-amber-10', - offline: 'bg-n-slate-10', -}; + ...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }), +})); const showDefaultAvatar = computed(() => !props.src && !props.name); @@ -174,15 +183,25 @@ watch( - + + diff --git a/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue b/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue index 30fe85b0a..f349720b0 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue @@ -57,9 +57,10 @@ const menuItems = computed(() => [ }, ]); -const icon = computed(() => - getInboxIconByType(props.inbox.channel_type, '', 'outline') -); +const icon = computed(() => { + const { medium, channel_type: type } = props.inbox; + return getInboxIconByType(type, medium, 'outline'); +}); const handleAction = ({ action, value }) => { toggleDropdown(false); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/AssistantForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/AssistantForm.vue index 8c26de2f5..ecf13c286 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/AssistantForm.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/AssistantForm.vue @@ -35,6 +35,7 @@ const initialState = { productName: '', featureFaq: false, featureMemory: false, + featureCitation: false, }; const state = reactive({ ...initialState }); @@ -70,6 +71,7 @@ const prepareAssistantDetails = () => ({ product_name: state.productName, feature_faq: state.featureFaq, feature_memory: state.featureMemory, + feature_citation: state.featureCitation, }, }); @@ -93,6 +95,7 @@ const updateStateFromAssistant = assistant => { productName: config.product_name, featureFaq: config.feature_faq || false, featureMemory: config.feature_memory || false, + featureCitation: config.feature_citation || false, }); }; @@ -151,6 +154,13 @@ watch( {{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }} + +
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue index 3134d7062..ad65f7511 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue @@ -41,6 +41,7 @@ const initialState = { features: { conversationFaqs: false, memories: false, + citations: false, }, temperature: 1, }; @@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => { state.features = { conversationFaqs: config.feature_faq || false, memories: config.feature_memory || false, + citations: config.feature_citation || false, }; state.temperature = config.temperature || 1; }; @@ -152,6 +154,7 @@ const handleFeaturesUpdate = () => { ...props.assistant.config, feature_faq: state.features.conversationFaqs, feature_memory: state.features.memories, + feature_citation: state.features.citations, }, }; @@ -314,6 +317,14 @@ watch( /> {{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }} +
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue index 236791793..faaf0a825 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue @@ -26,6 +26,7 @@ const initialState = { features: { conversationFaqs: false, memories: false, + citations: false, }, }; @@ -57,6 +58,7 @@ const updateStateFromAssistant = assistant => { state.features = { conversationFaqs: config.feature_faq || false, memories: config.feature_memory || false, + citations: config.feature_citation || false, }; }; @@ -76,6 +78,7 @@ const handleBasicInfoUpdate = async () => { product_name: state.productName, feature_faq: state.features.conversationFaqs, feature_memory: state.features.memories, + feature_citation: state.features.citations, }, }; @@ -138,6 +141,14 @@ watch( /> {{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }} + diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue index 11117cc18..68102dbd3 100644 --- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue +++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue @@ -1,4 +1,5 @@