From 118f52e2394f8a27696ddd518d1c524ac5bb0726 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Mon, 2 Mar 2026 14:40:35 -0300 Subject: [PATCH] feat: lead attribution tracking - landing page origin detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cria modelo LeadClick para registrar cliques das landing pages - Cria modelo LandingHost para mapear hostname → inbox_id - Endpoint público POST /track/click para receber eventos de clique - Leads::AttributionMatcherService para correlacionar clique com conversa - Integração com IncomingMessageWuzapiService para atribuição automática - API REST para gerenciar LandingHosts por inbox (index/create/destroy) - UI: nova aba 'Landing Pages' nas configurações da caixa de entrada - Dashboard API client dedicado (landingHosts.js) - RuboCop: refatora shift_signature_name, TrackingController, AttributionMatcherService e WuzapiService --- .../api/v1/accounts/inboxes_controller.rb | 3 + .../v1/accounts/landing_hosts_controller.rb | 44 ++++ app/controllers/api/v1/tracking_controller.rb | 28 +++ .../api/v1/accounts/landing_hosts_helper.rb | 2 + app/javascript/dashboard/api/landingHosts.js | 21 ++ .../dashboard/settings/inbox/Settings.vue | 86 +++++++ .../inbox/settingsPage/LandingHostsConfig.vue | 215 ++++++++++++++++++ app/models/inbox.rb | 79 ++++--- app/models/landing_host.rb | 18 ++ app/models/lead_click.rb | 29 +++ .../leads/attribution_matcher_service.rb | 66 ++++++ .../incoming_message_wuzapi_service.rb | 15 +- .../whatsapp/providers/wuzapi_service.rb | 159 +++++++------ config/routes.rb | 2 + ...ssage_signature_default_name_to_inboxes.rb | 5 + ...2092000_add_shift_signatures_to_inboxes.rb | 7 + ...093000_add_night_shift_hours_to_inboxes.rb | 6 + .../20260302154630_create_lead_clicks.rb | 20 ++ .../20260302154737_create_landing_hosts.rb | 14 ++ db/schema.rb | 34 ++- spec/factories/landing_hosts.rb | 8 + spec/factories/lead_clicks.rb | 10 + .../v1/accounts/landing_hosts_helper_spec.rb | 15 ++ spec/models/landing_host_spec.rb | 5 + spec/models/lead_click_spec.rb | 5 + .../api/v1/accounts/landing_hosts_spec.rb | 7 + spec/requests/api/v1/tracking_spec.rb | 36 +++ 27 files changed, 840 insertions(+), 99 deletions(-) create mode 100644 app/controllers/api/v1/accounts/landing_hosts_controller.rb create mode 100644 app/controllers/api/v1/tracking_controller.rb create mode 100644 app/helpers/api/v1/accounts/landing_hosts_helper.rb create mode 100644 app/javascript/dashboard/api/landingHosts.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue create mode 100644 app/models/landing_host.rb create mode 100644 app/models/lead_click.rb create mode 100644 app/services/leads/attribution_matcher_service.rb create mode 100644 db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb create mode 100644 db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb create mode 100644 db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb create mode 100644 db/migrate/20260302154630_create_lead_clicks.rb create mode 100644 db/migrate/20260302154737_create_landing_hosts.rb create mode 100644 spec/factories/landing_hosts.rb create mode 100644 spec/factories/lead_clicks.rb create mode 100644 spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb create mode 100644 spec/models/landing_host_spec.rb create mode 100644 spec/models/lead_click_spec.rb create mode 100644 spec/requests/api/v1/accounts/landing_hosts_spec.rb create mode 100644 spec/requests/api/v1/tracking_spec.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index f647667c6..96ab324c9 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -215,6 +215,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :captain_unit_id, :typing_delay, + :message_signature_enabled, :message_signature_default_name, :message_signature_day_name, + :message_signature_night_even_name, :message_signature_night_odd_name, + :message_signature_night_shift_start, :message_signature_night_shift_end, { csat_config: [:display_type, :message, :button_text, :language, { survey_rules: [:operator, { values: [] }], template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, diff --git a/app/controllers/api/v1/accounts/landing_hosts_controller.rb b/app/controllers/api/v1/accounts/landing_hosts_controller.rb new file mode 100644 index 000000000..c1c23b6e7 --- /dev/null +++ b/app/controllers/api/v1/accounts/landing_hosts_controller.rb @@ -0,0 +1,44 @@ +class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseController + before_action :fetch_inbox, only: [:index, :create] + before_action :fetch_landing_host, only: [:destroy] + + def index + @landing_hosts = LandingHost.where(inbox_id: @inbox.id) + render json: @landing_hosts + end + + def create + @landing_host = LandingHost.new(landing_host_params.merge(inbox_id: @inbox.id, active: true)) + + if @landing_host.save + render json: @landing_host, status: :created + else + render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity + end + end + + def destroy + @landing_host.destroy! + head :no_content + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Inbox not found' }, status: :not_found + end + + def fetch_landing_host + # Garantimos que a pessoa só possa apagar LandingHosts de Inboxes que pertencem a ela + valid_inbox_ids = Current.account.inboxes.pluck(:id) + @landing_host = LandingHost.where(inbox_id: valid_inbox_ids).find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Landing Host not found' }, status: :not_found + end + + def landing_host_params + params.require(:landing_host).permit(:hostname, :unit_code, :active) + end +end diff --git a/app/controllers/api/v1/tracking_controller.rb b/app/controllers/api/v1/tracking_controller.rb new file mode 100644 index 000000000..228ef75f6 --- /dev/null +++ b/app/controllers/api/v1/tracking_controller.rb @@ -0,0 +1,28 @@ +class Api::V1::TrackingController < ActionController::API + def click + LeadClick.create!(click_params) + head :no_content + rescue StandardError => e + Rails.logger.error("Error processing tracking click: #{e.message}") + head :no_content + end + + private + + def resolved_inbox_id + LandingHost.find_by(hostname: params[:hostname].to_s.strip, active: true)&.inbox_id + end + + def click_params + { + inbox_id: resolved_inbox_id, + ip: params[:ip].presence || request.remote_ip, + user_agent: request.user_agent || params[:user_agent], + hostname: params[:hostname].to_s.strip, + source: params[:source], + campanha: params[:campanha], + lp: params[:lp], + status: :clicked + } + end +end diff --git a/app/helpers/api/v1/accounts/landing_hosts_helper.rb b/app/helpers/api/v1/accounts/landing_hosts_helper.rb new file mode 100644 index 000000000..9a8b8a323 --- /dev/null +++ b/app/helpers/api/v1/accounts/landing_hosts_helper.rb @@ -0,0 +1,2 @@ +module Api::V1::Accounts::LandingHostsHelper +end diff --git a/app/javascript/dashboard/api/landingHosts.js b/app/javascript/dashboard/api/landingHosts.js new file mode 100644 index 000000000..3895b78e1 --- /dev/null +++ b/app/javascript/dashboard/api/landingHosts.js @@ -0,0 +1,21 @@ +// API client para LandingHosts da caixa de entrada +/* global axios */ + +export default { + getHosts(accountId, inboxId) { + return axios.get( + `/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts` + ); + }, + createHost(accountId, inboxId, data) { + return axios.post( + `/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`, + { landing_host: data } + ); + }, + deleteHost(accountId, inboxId, id) { + return axios.delete( + `/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}` + ); + }, +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index d36ed7f1f..f8d09f095 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -33,6 +33,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { getInboxIconByType } from 'dashboard/helper/inbox'; import Editor from 'dashboard/components-next/Editor/Editor.vue'; +import LandingHostsConfig from './settingsPage/LandingHostsConfig.vue'; export default { components: { @@ -61,6 +62,7 @@ export default { WuzapiConfiguration, EvolutionGoConfiguration, InboxAutoResolve, + LandingHostsConfig, }, mixins: [inboxMixin], setup() { @@ -93,6 +95,12 @@ export default { isLoadingHealth: false, healthError: null, messageSignatureEnabled: false, + messageSignatureDefaultName: '', + messageSignatureDayName: '', + messageSignatureNightEvenName: '', + messageSignatureNightOddName: '', + messageSignatureNightShiftStart: '19:00', + messageSignatureNightShiftEnd: '07:00', typingDelay: 0, }; }, @@ -215,6 +223,14 @@ export default { ]; } + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'landing-hosts', + name: 'Landing Pages', + }, + ]; + return visibleToAllChannelTabs; }, currentInboxId() { @@ -451,6 +467,18 @@ export default { null; this.typingDelay = this.inbox.typing_delay || 0; this.messageSignatureEnabled = this.inbox.message_signature_enabled; + this.messageSignatureDefaultName = + this.inbox.message_signature_default_name || ''; + this.messageSignatureDayName = + this.inbox.message_signature_day_name || ''; + this.messageSignatureNightEvenName = + this.inbox.message_signature_night_even_name || ''; + this.messageSignatureNightOddName = + this.inbox.message_signature_night_odd_name || ''; + this.messageSignatureNightShiftStart = + this.inbox.message_signature_night_shift_start || '19:00'; + this.messageSignatureNightShiftEnd = + this.inbox.message_signature_night_shift_end || '07:00'; // Set initial tab after inbox data is loaded this.setTabFromRouteParam(); @@ -476,6 +504,13 @@ export default { sender_name_type: this.senderNameType, business_name: this.businessName || null, message_signature_enabled: this.messageSignatureEnabled, + message_signature_default_name: this.messageSignatureDefaultName, + message_signature_day_name: this.messageSignatureDayName, + message_signature_night_even_name: this.messageSignatureNightEvenName, + message_signature_night_odd_name: this.messageSignatureNightOddName, + message_signature_night_shift_start: + this.messageSignatureNightShiftStart, + message_signature_night_shift_end: this.messageSignatureNightShiftEnd, channel: { widget_color: this.inbox.widget_color, website_url: this.channelWebsiteUrl, @@ -632,6 +667,54 @@ export default { {{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }} + +
+ + + +
+
+ + +
+
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue new file mode 100644 index 000000000..45668be4a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue @@ -0,0 +1,215 @@ + + + diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 8b6d35382..ba5474ecd 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -4,32 +4,38 @@ # # Table name: inboxes # -# id :integer not null, primary key -# allow_messages_after_resolved :boolean default(TRUE) -# auto_assignment_config :jsonb -# auto_resolve_duration :integer -# business_name :string -# channel_type :string -# csat_config :jsonb not null -# csat_survey_enabled :boolean default(FALSE) -# email_address :string -# enable_auto_assignment :boolean default(TRUE) -# enable_email_collect :boolean default(TRUE) -# greeting_enabled :boolean default(FALSE) -# greeting_message :string -# lock_to_single_conversation :boolean default(FALSE), not null -# message_signature_enabled :boolean -# name :string not null -# out_of_office_message :string -# sender_name_type :integer default("friendly"), not null -# timezone :string default("UTC") -# typing_delay :integer default(0) -# working_hours_enabled :boolean default(FALSE) -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null -# channel_id :integer not null -# portal_id :bigint +# id :integer not null, primary key +# allow_messages_after_resolved :boolean default(TRUE) +# auto_assignment_config :jsonb +# auto_resolve_duration :integer +# business_name :string +# channel_type :string +# csat_config :jsonb not null +# csat_survey_enabled :boolean default(FALSE) +# email_address :string +# enable_auto_assignment :boolean default(TRUE) +# enable_email_collect :boolean default(TRUE) +# greeting_enabled :boolean default(FALSE) +# greeting_message :string +# lock_to_single_conversation :boolean default(FALSE), not null +# message_signature_day_name :string +# message_signature_default_name :string +# message_signature_enabled :boolean +# message_signature_night_even_name :string +# message_signature_night_odd_name :string +# message_signature_night_shift_end :string default("07:00") +# message_signature_night_shift_start :string default("19:00") +# name :string not null +# out_of_office_message :string +# sender_name_type :integer default("friendly"), not null +# timezone :string default("UTC") +# typing_delay :integer default(0) +# working_hours_enabled :boolean default(FALSE) +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# channel_id :integer not null +# portal_id :bigint # # Indexes # @@ -209,8 +215,29 @@ class Inbox < ApplicationRecord account.feature_enabled?('assignment_v2') end + def shift_signature_name + time_now = Time.current.in_time_zone(ENV.fetch('ACCOUNT_TIMEZONE', 'UTC')) + night_shift?(time_now) ? night_name(time_now.day) : day_name + end + private + def night_shift?(time_now) + current = time_now.strftime('%H:%M') + start_h = message_signature_night_shift_start.presence || '19:00' + end_h = message_signature_night_shift_end.presence || '07:00' + start_h < end_h ? (current >= start_h && current < end_h) : (current >= start_h || current < end_h) + end + + def night_name(day) + base = day.even? ? message_signature_night_even_name : message_signature_night_odd_name + base.presence || message_signature_default_name + end + + def day_name + message_signature_day_name.presence || message_signature_default_name + end + def default_name_for_blank_name email? ? display_name_from_email : '' end diff --git a/app/models/landing_host.rb b/app/models/landing_host.rb new file mode 100644 index 000000000..be345bd78 --- /dev/null +++ b/app/models/landing_host.rb @@ -0,0 +1,18 @@ +# == Schema Information +# +# Table name: landing_hosts +# +# id :bigint not null, primary key +# active :boolean +# hostname :string +# unit_code :string +# created_at :datetime not null +# updated_at :datetime not null +# inbox_id :integer +# +# Indexes +# +# index_landing_hosts_on_hostname (hostname) UNIQUE +# +class LandingHost < ApplicationRecord +end diff --git a/app/models/lead_click.rb b/app/models/lead_click.rb new file mode 100644 index 000000000..d0fd0cf61 --- /dev/null +++ b/app/models/lead_click.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: lead_clicks +# +# id :bigint not null, primary key +# campanha :string +# hostname :string +# ip :string +# lp :string +# source :string +# status :integer +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# contact_id :integer +# conversation_id :integer +# inbox_id :integer +# +# Indexes +# +# index_lead_clicks_on_inbox_id_and_ip_and_status_and_created_at (inbox_id,ip,status,created_at) +# +class LeadClick < ApplicationRecord + enum status: { clicked: 0, converted: 1 } + + belongs_to :inbox, optional: true + belongs_to :conversation, optional: true + belongs_to :contact, optional: true +end diff --git a/app/services/leads/attribution_matcher_service.rb b/app/services/leads/attribution_matcher_service.rb new file mode 100644 index 000000000..720b2ec4d --- /dev/null +++ b/app/services/leads/attribution_matcher_service.rb @@ -0,0 +1,66 @@ +class Leads::AttributionMatcherService + def initialize(conversation, inbound_ip = nil) + @conversation = conversation + @contact = conversation.contact + @inbox_id = conversation.inbox_id + @inbound_ip = inbound_ip + end + + def perform + return unless valid_for_matching? + + click = find_matching_click + return unless click + + apply_attribution(click) + end + + private + + def valid_for_matching? + @conversation.present? && @contact.present? && + @conversation.custom_attributes['link_de_origem'].blank? + end + + def find_matching_click + base_query = LeadClick + .where(status: :clicked, inbox_id: @inbox_id) + .where('created_at > ?', 10.minutes.ago) + + return base_query.where(ip: @inbound_ip).order(created_at: :desc).first if @inbound_ip.present? + + base_query.order(created_at: :desc).first + end + + def attribution_attrs(click) + { + 'link_de_origem' => click.source, + 'campanha' => click.campanha, + 'lp_hostname' => click.hostname, + 'click_id' => click.id.to_s + } + end + + def apply_attribution(click) + ActiveRecord::Base.transaction do + click.update!(status: :converted, conversation_id: @conversation.id, contact_id: @contact.id) + update_contact(click) + update_conversation(click) + apply_labels(click) + end + end + + def update_contact(click) + @contact.update!(custom_attributes: @contact.custom_attributes.to_h.merge(attribution_attrs(click))) + end + + def update_conversation(click) + @conversation.update!( + custom_attributes: @conversation.custom_attributes.to_h.merge(attribution_attrs(click)) + ) + end + + def apply_labels(click) + @conversation.add_labels(['lead_meta']) if click.source.to_s.downcase.include?('meta') + end +end diff --git a/app/services/whatsapp/incoming_message_wuzapi_service.rb b/app/services/whatsapp/incoming_message_wuzapi_service.rb index 568b0b88f..ba877575e 100644 --- a/app/services/whatsapp/incoming_message_wuzapi_service.rb +++ b/app/services/whatsapp/incoming_message_wuzapi_service.rb @@ -41,6 +41,9 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ @conversation = find_or_create_conversation + # Processar Match de Leads (Fingerprinting) caso a mensagem venha do Lead + Leads::AttributionMatcherService.new(@conversation).perform unless @parser.from_me? + return if @parser.from_me? && handle_echo_message create_new_message @@ -150,8 +153,18 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ def build_message(parser, conversation, clean_source_id) is_outgoing = parser.from_me? + content = parser.text_content + inbox_obj = inbox + + # Se a mensagem vier do celular (outgoing) e a assinatura estiver ativa, + # e o conteúdo não parecer já ter uma assinatura (evita duplicar em ecos) + if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*[') && !content.start_with?('*') + signature_name = inbox_obj.shift_signature_name + content = "*[ #{signature_name} ]*\n#{content}" if signature_name.present? + end + msg_params = { - content: parser.text_content, + content: content, account_id: inbox.account_id, inbox_id: inbox.id, message_type: is_outgoing ? :outgoing : :incoming, sender: is_outgoing ? nil : @contact, diff --git a/app/services/whatsapp/providers/wuzapi_service.rb b/app/services/whatsapp/providers/wuzapi_service.rb index cf8005990..ccd67461d 100644 --- a/app/services/whatsapp/providers/wuzapi_service.rb +++ b/app/services/whatsapp/providers/wuzapi_service.rb @@ -10,97 +10,46 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService def send_message(phone_number, message) user_token = whatsapp_channel.wuzapi_user_token - # Normalize phone number: remove +, space, -, (, ) - normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') + normalized_phone = normalize_phone(phone_number) + log_outgoing_message(message) + return send_reaction_message(normalized_phone, message) if reaction_message?(message) - Rails.logger.info "[WuzapiService] Sending Message: - Message ID: #{message.id} - Conversation ID: #{message.conversation_id} - Contact Inbox ID: #{message.conversation.contact_inbox_id} - Raw Phone (arg): #{phone_number} - Normalized Phone (Target): #{normalized_phone} - Content: #{message.content&.truncate(50)} - " - - return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction] - - response = if message.attachments.present? - send_attachment_message(user_token, normalized_phone, message) - else - params = {} - # Extract and clean reply ID (remove WAID: prefix if stored) - if (reply_id = message.content_attributes['in_reply_to_external_id']).present? - params['MessageId'] = reply_id.gsub(/^WAID:/, '') - elsif (reply_id = message.in_reply_to_external_id).present? - params['MessageId'] = reply_id.gsub(/^WAID:/, '') - end - - client.send_text(user_token, normalized_phone, message.content, **params) - end - - # Extract message ID from WuzAPI response and format as WAID:xxx + content_to_send = build_content_with_signature(message) + response = dispatch_message(user_token, normalized_phone, message, content_to_send) extract_message_id(response) end - def send_attachment_message(user_token, phone_number, message) + def send_attachment_message(user_token, phone_number, message, content_with_signature = nil) attachment = message.attachments.first - base64_data = Base64.strict_encode64(attachment.file.download) mime_type = attachment.file.content_type + caption = content_with_signature || message.content + + base64_data = attachment.file.blob.open { |tmp| Base64.strict_encode64(tmp.read) } data_uri = "data:#{mime_type};base64,#{base64_data}" if mime_type.start_with?('image/') - client.send_image(user_token, phone_number, data_uri, message.content) + client.send_image(user_token, phone_number, data_uri, caption) else client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s) end end def send_reaction_message(phone_number, message) - user_token = whatsapp_channel.wuzapi_user_token - normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') - - # Assuming message content is the emoji + user_token = whatsapp_channel.wuzapi_user_token reaction_emoji = message.content + message_id = resolve_reaction_message_id(message) + phone, mid = build_reaction_targets(phone_number, message_id, message) - # Resolve the correct external message ID - message_id = message.content_attributes['in_reply_to_external_id'] + Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{phone}, msg_id=#{mid}, emoji=#{reaction_emoji}" - # Fallback to internal ID resolution if external is missing - if message_id.blank? && message.content_attributes['in_reply_to'].present? - target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to']) - message_id = target_msg&.source_id - end - - # Strip WAID prefix if present - message_id = message_id.gsub(/^WAID:/, '') if message_id.present? - - use_me_prefix = reaction_to_own_message?(message) - - if use_me_prefix - normalized_phone = "me:#{normalized_phone}" unless normalized_phone.start_with?('me:') - message_id = "me:#{message_id}" if message_id.present? && !message_id.start_with?('me:') - else - # Enforce JID format for customer numbers - clean_number = normalized_phone.split('@').first - normalized_phone = "#{clean_number}@s.whatsapp.net" - end - - Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}" - - if message_id.present? - # Wuzapi client needs to implement send_reaction - # This assumes the client wrapper has this method. If not, we might need to add it or use raw request. - # Based on typical Wuzapi forks, it might be /send-reaction-message - - # We'll assume the client wrapper will have a send_reaction method. - # If not visible in the existing codebase, we might need to add it to the client class too. - # checking... - response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji) - Rails.logger.info "[WuzapiService] Reaction response: #{response}" - response - else + if mid.blank? Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID' + return end + + response = client.send_reaction(user_token, phone, mid, reaction_emoji) + Rails.logger.info "[WuzapiService] Reaction response: #{response}" + response end def send_template(_phone_number, _template_info) @@ -155,6 +104,74 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService private + def normalize_phone(phone_number) + phone_number.gsub(/[+\s\-()]/, '') + end + + def reaction_message?(message) + message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction] + end + + def log_outgoing_message(message) + Rails.logger.info "[WuzapiService] Sending Message: ID=#{message.id} Conv=#{message.conversation_id} Content=#{message.content&.truncate(50)}" + end + + def sender_name_for(message) + agent = message.sender + if agent.is_a?(User) + agent.display_name.presence || agent.name + elsif agent.is_a?(Captain::Assistant) + agent.name + else + message.inbox.shift_signature_name + end + end + + def build_content_with_signature(message) + content = message.content + return content unless message.inbox.message_signature_enabled? + + name = sender_name_for(message) + name.present? ? "*[ #{name} ]*\n#{content}" : content + end + + def reply_params(message) + params = {} + reply_id = message.content_attributes['in_reply_to_external_id'].presence || + message.in_reply_to_external_id.presence + params['MessageId'] = reply_id.gsub(/^WAID:/, '') if reply_id + params + end + + def dispatch_message(user_token, phone, message, content) + if message.attachments.present? + send_attachment_message(user_token, phone, message, content) + else + client.send_text(user_token, phone, content, **reply_params(message)) + end + end + + def resolve_reaction_message_id(message) + mid = message.content_attributes['in_reply_to_external_id'] + if mid.blank? && message.content_attributes['in_reply_to'].present? + target = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to']) + mid = target&.source_id + end + mid.present? ? mid.gsub(/^WAID:/, '') : nil + end + + def build_reaction_targets(phone_number, message_id, message) + phone = normalize_phone(phone_number) + mid = message_id + if reaction_to_own_message?(message) + phone = "me:#{phone}" unless phone.start_with?('me:') + mid = "me:#{mid}" if mid.present? && !mid.start_with?('me:') + else + phone = "#{phone.split('@').first}@s.whatsapp.net" + end + [phone, mid] + end + def client @client ||= ::Wuzapi::Client.new(@base_url) end diff --git a/config/routes.rb b/config/routes.rb index 971e0122d..29b730656 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,7 @@ Rails.application.routes.draw do get '/health', to: 'health#show' get '/api', to: 'api#index' + post '/track/click', to: 'api/v1/tracking#click' namespace :api, defaults: { format: 'json' } do namespace :v1 do # ---------------------------------- @@ -234,6 +235,7 @@ Rails.application.routes.draw do get :campaigns, on: :member get :agent_bot, on: :member post :set_agent_bot, on: :member + resources :landing_hosts, only: [:index, :create, :destroy] post :setup_channel_provider, on: :member post :disconnect_channel_provider, on: :member delete :avatar, on: :member diff --git a/db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb b/db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb new file mode 100644 index 000000000..5404d4d85 --- /dev/null +++ b/db/migrate/20260302091000_add_message_signature_default_name_to_inboxes.rb @@ -0,0 +1,5 @@ +class AddMessageSignatureDefaultNameToInboxes < ActiveRecord::Migration[7.0] + def change + add_column :inboxes, :message_signature_default_name, :string + end +end diff --git a/db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb b/db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb new file mode 100644 index 000000000..00cc4f259 --- /dev/null +++ b/db/migrate/20260302092000_add_shift_signatures_to_inboxes.rb @@ -0,0 +1,7 @@ +class AddShiftSignaturesToInboxes < ActiveRecord::Migration[7.0] + def change + add_column :inboxes, :message_signature_day_name, :string + add_column :inboxes, :message_signature_night_even_name, :string + add_column :inboxes, :message_signature_night_odd_name, :string + end +end diff --git a/db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb b/db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb new file mode 100644 index 000000000..791e5f189 --- /dev/null +++ b/db/migrate/20260302093000_add_night_shift_hours_to_inboxes.rb @@ -0,0 +1,6 @@ +class AddNightShiftHoursToInboxes < ActiveRecord::Migration[7.0] + def change + add_column :inboxes, :message_signature_night_shift_start, :string, default: '19:00' + add_column :inboxes, :message_signature_night_shift_end, :string, default: '07:00' + end +end diff --git a/db/migrate/20260302154630_create_lead_clicks.rb b/db/migrate/20260302154630_create_lead_clicks.rb new file mode 100644 index 000000000..01bd8b5c1 --- /dev/null +++ b/db/migrate/20260302154630_create_lead_clicks.rb @@ -0,0 +1,20 @@ +class CreateLeadClicks < ActiveRecord::Migration[7.1] + def change + create_table :lead_clicks do |t| + t.integer :inbox_id + t.string :ip + t.string :user_agent + t.string :hostname + t.string :source + t.string :campanha + t.string :lp + t.integer :status + t.integer :conversation_id + t.integer :contact_id + + t.timestamps + end + + add_index :lead_clicks, [:inbox_id, :ip, :status, :created_at] + end +end diff --git a/db/migrate/20260302154737_create_landing_hosts.rb b/db/migrate/20260302154737_create_landing_hosts.rb new file mode 100644 index 000000000..efaa6ac1b --- /dev/null +++ b/db/migrate/20260302154737_create_landing_hosts.rb @@ -0,0 +1,14 @@ +class CreateLandingHosts < ActiveRecord::Migration[7.1] + def change + create_table :landing_hosts do |t| + t.string :hostname + t.string :unit_code + t.integer :inbox_id + t.boolean :active, default: true, null: false + + t.timestamps + end + + add_index :landing_hosts, :hostname, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 450553dee..4e3105fb4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do +ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1405,6 +1405,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do t.integer "auto_resolve_duration" t.boolean "message_signature_enabled" t.integer "typing_delay", default: 0 + t.string "message_signature_default_name" + t.string "message_signature_day_name" + t.string "message_signature_night_even_name" + t.string "message_signature_night_odd_name" + t.string "message_signature_night_shift_start", default: "19:00" + t.string "message_signature_night_shift_end", default: "07:00" t.index ["account_id"], name: "index_inboxes_on_account_id" t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type" t.index ["portal_id"], name: "index_inboxes_on_portal_id" @@ -1547,6 +1553,32 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true end + create_table "landing_hosts", force: :cascade do |t| + t.string "hostname" + t.string "unit_code" + t.integer "inbox_id" + t.boolean "active" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true + end + + create_table "lead_clicks", force: :cascade do |t| + t.integer "inbox_id" + t.string "ip" + t.string "user_agent" + t.string "hostname" + t.string "source" + t.string "campanha" + t.string "lp" + t.integer "status" + t.integer "conversation_id" + t.integer "contact_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["inbox_id", "ip", "status", "created_at"], name: "index_lead_clicks_on_inbox_id_and_ip_and_status_and_created_at" + end + create_table "leaves", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "user_id", null: false diff --git a/spec/factories/landing_hosts.rb b/spec/factories/landing_hosts.rb new file mode 100644 index 000000000..46038705d --- /dev/null +++ b/spec/factories/landing_hosts.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :landing_host do + hostname { 'MyString' } + unit_code { 'MyString' } + inbox_id { 1 } + active { false } + end +end diff --git a/spec/factories/lead_clicks.rb b/spec/factories/lead_clicks.rb new file mode 100644 index 000000000..80b0816a8 --- /dev/null +++ b/spec/factories/lead_clicks.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :lead_click do + inbox_id { 1 } + ip { 'MyString' } + user_agent { 'MyString' } + hostname { 'MyString' } + source { 'MyString' } + status { 1 } + end +end diff --git a/spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb b/spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb new file mode 100644 index 000000000..e8648287a --- /dev/null +++ b/spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Api::V1::Accounts::LandingHostsHelper. For example: +# +# describe Api::V1::Accounts::LandingHostsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Api::V1::Accounts::LandingHostsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/landing_host_spec.rb b/spec/models/landing_host_spec.rb new file mode 100644 index 000000000..f8c5ec3ff --- /dev/null +++ b/spec/models/landing_host_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe LandingHost, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/lead_click_spec.rb b/spec/models/lead_click_spec.rb new file mode 100644 index 000000000..1c67d4256 --- /dev/null +++ b/spec/models/lead_click_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe LeadClick, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/api/v1/accounts/landing_hosts_spec.rb b/spec/requests/api/v1/accounts/landing_hosts_spec.rb new file mode 100644 index 000000000..dd7e60dd7 --- /dev/null +++ b/spec/requests/api/v1/accounts/landing_hosts_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::LandingHosts', type: :request do + describe 'GET /index' do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/spec/requests/api/v1/tracking_spec.rb b/spec/requests/api/v1/tracking_spec.rb new file mode 100644 index 000000000..b2155085c --- /dev/null +++ b/spec/requests/api/v1/tracking_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Tracking', type: :request do + describe 'POST /track/click' do + let(:valid_params) do + { + hostname: 'test.com', + source: 'facebook', + campanha: 'summer26', + lp: '/promo' + } + end + + context 'when tracking a click' do + it 'creates a lead click and returns no_content' do + expect do + post '/track/click', params: valid_params, as: :json + end.to change(LeadClick, :count).by(1) + + expect(response).to have_http_status(:no_content) + click = LeadClick.last + expect(click.hostname).to eq('test.com') + expect(click.source).to eq('facebook') + expect(click.status).to eq('clicked') + end + + it 'resolves the inbox if landing host exists' do + host = create(:landing_host, hostname: 'test.com', active: true) + + post '/track/click', params: valid_params, as: :json + + expect(LeadClick.last.inbox_id).to eq(host.inbox_id) + end + end + end +end