feat: lead attribution tracking - landing page origin detection
- 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
This commit is contained in:
parent
7a84cb3433
commit
118f52e239
@ -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,
|
||||
|
||||
44
app/controllers/api/v1/accounts/landing_hosts_controller.rb
Normal file
44
app/controllers/api/v1/accounts/landing_hosts_controller.rb
Normal file
@ -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
|
||||
28
app/controllers/api/v1/tracking_controller.rb
Normal file
28
app/controllers/api/v1/tracking_controller.rb
Normal file
@ -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
|
||||
2
app/helpers/api/v1/accounts/landing_hosts_helper.rb
Normal file
2
app/helpers/api/v1/accounts/landing_hosts_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module Api::V1::Accounts::LandingHostsHelper
|
||||
end
|
||||
21
app/javascript/dashboard/api/landingHosts.js
Normal file
21
app/javascript/dashboard/api/landingHosts.js
Normal file
@ -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}`
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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') }}
|
||||
</label>
|
||||
</div>
|
||||
<woot-input
|
||||
v-if="messageSignatureEnabled"
|
||||
v-model="messageSignatureDefaultName"
|
||||
class="pb-4"
|
||||
label="Nome Geral (Se não houver plantão definido)"
|
||||
placeholder="Ex: Equipe Hotel"
|
||||
@blur="updateInbox"
|
||||
/>
|
||||
<div
|
||||
v-if="messageSignatureEnabled"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"
|
||||
>
|
||||
<woot-input
|
||||
v-model="messageSignatureDayName"
|
||||
label="Recepcionista do Dia (07h - 19h)"
|
||||
placeholder="Nome aqui"
|
||||
@blur="updateInbox"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="messageSignatureNightEvenName"
|
||||
label="Noite - Dias PARES (19h - 07h)"
|
||||
placeholder="Nome aqui"
|
||||
@blur="updateInbox"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="messageSignatureNightOddName"
|
||||
label="Noite - Dias ÍMPARES"
|
||||
placeholder="Nome aqui"
|
||||
@blur="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="messageSignatureEnabled"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4 border-t pt-4 mt-2"
|
||||
>
|
||||
<woot-input
|
||||
v-model="messageSignatureNightShiftStart"
|
||||
label="Início do Plantão Noturno"
|
||||
placeholder="19:00"
|
||||
@blur="updateInbox"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="messageSignatureNightShiftEnd"
|
||||
label="Fim do Plantão Noturno"
|
||||
placeholder="07:00"
|
||||
@blur="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-if="isAPIInbox"
|
||||
v-model="webhookUrl"
|
||||
@ -1046,6 +1129,9 @@ export default {
|
||||
<div v-if="selectedTabKey === 'whatsapp-health'">
|
||||
<AccountHealth :health-data="healthData" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'landing-hosts'">
|
||||
<LandingHostsConfig :inbox="inbox" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,215 @@
|
||||
<script>
|
||||
/* eslint-disable @intlify/vue-i18n/no-raw-text */
|
||||
import landingHostsApi from 'dashboard/api/landingHosts';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'LandingHostsConfig',
|
||||
components: { NextButton },
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
landingHosts: [],
|
||||
newHostname: '',
|
||||
newUnitCode: '',
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
labels: {
|
||||
title: 'Landing Pages (Tracking de Origem)',
|
||||
subtitle:
|
||||
'Defina os domínios de Landing Pages que enviam leads para esta caixa de entrada. O sistema usará esses domínios para identificar automaticamente a origem de cada conversa.',
|
||||
loading: 'Carregando...',
|
||||
empty: 'Nenhum domínio cadastrado ainda.',
|
||||
colHostname: 'Hostname',
|
||||
colCode: 'Código / Unidade',
|
||||
remove: 'Remover',
|
||||
addTitle: 'Adicionar Domínio',
|
||||
labelHostname: 'Hostname *',
|
||||
placeholderHostname: 'express.seuhotel.com.br',
|
||||
labelCode: 'Código Unidade',
|
||||
placeholderCode: 'EXPRESS',
|
||||
labelSaving: 'Salvando...',
|
||||
labelAdd: 'Adicionar',
|
||||
hint: 'Informe o domínio exato sem https://, ex: landing.meusite.com.br',
|
||||
errLoad: 'Erro ao carregar os domínios.',
|
||||
errAdd:
|
||||
'Erro ao adicionar domínio. Verifique se já existe ou o formato é válido.',
|
||||
errDel: 'Erro ao remover domínio.',
|
||||
successAdd: 'Domínio adicionado com sucesso!',
|
||||
successDel: 'Domínio removido.',
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ currentAccountId: 'getCurrentAccountId' }),
|
||||
addLabel() {
|
||||
return this.isSaving ? this.labels.labelSaving : this.labels.labelAdd;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchHosts();
|
||||
},
|
||||
methods: {
|
||||
async fetchHosts() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const { data } = await landingHostsApi.getHosts(
|
||||
this.currentAccountId,
|
||||
this.inbox.id
|
||||
);
|
||||
this.landingHosts = data;
|
||||
} catch {
|
||||
useAlert(this.labels.errLoad);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
async addHost() {
|
||||
if (!this.newHostname.trim()) return;
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const { data } = await landingHostsApi.createHost(
|
||||
this.currentAccountId,
|
||||
this.inbox.id,
|
||||
{
|
||||
hostname: this.newHostname.trim().toLowerCase(),
|
||||
unit_code: this.newUnitCode.trim().toUpperCase(),
|
||||
}
|
||||
);
|
||||
this.landingHosts.push(data);
|
||||
this.newHostname = '';
|
||||
this.newUnitCode = '';
|
||||
useAlert(this.labels.successAdd);
|
||||
} catch {
|
||||
useAlert(this.labels.errAdd);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
},
|
||||
async deleteHost(id) {
|
||||
try {
|
||||
await landingHostsApi.deleteHost(
|
||||
this.currentAccountId,
|
||||
this.inbox.id,
|
||||
id
|
||||
);
|
||||
this.landingHosts = this.landingHosts.filter(h => h.id !== id);
|
||||
useAlert(this.labels.successDel);
|
||||
} catch {
|
||||
useAlert(this.labels.errDel);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8 mt-4 pb-12">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-base font-semibold text-n-slate-12 mb-1">
|
||||
{{ labels.title }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ labels.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Lista de hosts cadastrados -->
|
||||
<div class="mb-6 border border-n-slate-3 rounded-lg overflow-hidden">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-8 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ labels.loading }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="landingHosts.length === 0"
|
||||
class="py-8 text-center text-sm text-n-slate-11"
|
||||
>
|
||||
{{ labels.empty }}
|
||||
</div>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead class="bg-n-slate-2 text-n-slate-11 uppercase text-xs">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium">
|
||||
{{ labels.colHostname }}
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium">
|
||||
{{ labels.colCode }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="host in landingHosts"
|
||||
:key="host.id"
|
||||
class="border-t border-n-slate-3 hover:bg-n-slate-1"
|
||||
>
|
||||
<td class="px-4 py-3 font-mono text-n-slate-12 text-xs">
|
||||
{{ host.hostname }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-n-slate-11">
|
||||
{{ host.unit_code || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors"
|
||||
@click="deleteHost(host.id)"
|
||||
>
|
||||
{{ labels.remove }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Formulário de adição -->
|
||||
<div class="border border-n-slate-3 rounded-lg p-4 bg-n-slate-1">
|
||||
<h3 class="text-sm font-semibold text-n-slate-12 mb-3">
|
||||
{{ labels.addTitle }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-n-slate-11 mb-1">
|
||||
{{ labels.labelHostname }}
|
||||
</label>
|
||||
<woot-input
|
||||
v-model="newHostname"
|
||||
:placeholder="labels.placeholderHostname"
|
||||
class="[&>input]:!mb-0"
|
||||
@keyup.enter="addHost"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<label class="block text-xs font-medium text-n-slate-11 mb-1">
|
||||
{{ labels.labelCode }}
|
||||
</label>
|
||||
<woot-input
|
||||
v-model="newUnitCode"
|
||||
:placeholder="labels.placeholderCode"
|
||||
class="[&>input]:!mb-0"
|
||||
@keyup.enter="addHost"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
:label="addLabel"
|
||||
:disabled="!newHostname.trim() || isSaving"
|
||||
class="flex-shrink-0 mb-px"
|
||||
@click="addHost"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-10 mt-2">
|
||||
{{ labels.hint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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
|
||||
|
||||
18
app/models/landing_host.rb
Normal file
18
app/models/landing_host.rb
Normal file
@ -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
|
||||
29
app/models/lead_click.rb
Normal file
29
app/models/lead_click.rb
Normal file
@ -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
|
||||
66
app/services/leads/attribution_matcher_service.rb
Normal file
66
app/services/leads/attribution_matcher_service.rb
Normal file
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
class AddMessageSignatureDefaultNameToInboxes < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :inboxes, :message_signature_default_name, :string
|
||||
end
|
||||
end
|
||||
@ -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
|
||||
@ -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
|
||||
20
db/migrate/20260302154630_create_lead_clicks.rb
Normal file
20
db/migrate/20260302154630_create_lead_clicks.rb
Normal file
@ -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
|
||||
14
db/migrate/20260302154737_create_landing_hosts.rb
Normal file
14
db/migrate/20260302154737_create_landing_hosts.rb
Normal file
@ -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
|
||||
34
db/schema.rb
34
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
|
||||
|
||||
8
spec/factories/landing_hosts.rb
Normal file
8
spec/factories/landing_hosts.rb
Normal file
@ -0,0 +1,8 @@
|
||||
FactoryBot.define do
|
||||
factory :landing_host do
|
||||
hostname { 'MyString' }
|
||||
unit_code { 'MyString' }
|
||||
inbox_id { 1 }
|
||||
active { false }
|
||||
end
|
||||
end
|
||||
10
spec/factories/lead_clicks.rb
Normal file
10
spec/factories/lead_clicks.rb
Normal file
@ -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
|
||||
15
spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb
Normal file
15
spec/helpers/api/v1/accounts/landing_hosts_helper_spec.rb
Normal file
@ -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
|
||||
5
spec/models/landing_host_spec.rb
Normal file
5
spec/models/landing_host_spec.rb
Normal file
@ -0,0 +1,5 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LandingHost, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
5
spec/models/lead_click_spec.rb
Normal file
5
spec/models/lead_click_spec.rb
Normal file
@ -0,0 +1,5 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe LeadClick, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
7
spec/requests/api/v1/accounts/landing_hosts_spec.rb
Normal file
7
spec/requests/api/v1/accounts/landing_hosts_spec.rb
Normal file
@ -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
|
||||
36
spec/requests/api/v1/tracking_spec.rb
Normal file
36
spec/requests/api/v1/tracking_spec.rb
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user