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,
|
[: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,
|
: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,
|
: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,
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
{ survey_rules: [:operator, { values: [] }],
|
{ survey_rules: [:operator, { values: [] }],
|
||||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
|
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 { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
|
import LandingHostsConfig from './settingsPage/LandingHostsConfig.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -61,6 +62,7 @@ export default {
|
|||||||
WuzapiConfiguration,
|
WuzapiConfiguration,
|
||||||
EvolutionGoConfiguration,
|
EvolutionGoConfiguration,
|
||||||
InboxAutoResolve,
|
InboxAutoResolve,
|
||||||
|
LandingHostsConfig,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
setup() {
|
setup() {
|
||||||
@ -93,6 +95,12 @@ export default {
|
|||||||
isLoadingHealth: false,
|
isLoadingHealth: false,
|
||||||
healthError: null,
|
healthError: null,
|
||||||
messageSignatureEnabled: false,
|
messageSignatureEnabled: false,
|
||||||
|
messageSignatureDefaultName: '',
|
||||||
|
messageSignatureDayName: '',
|
||||||
|
messageSignatureNightEvenName: '',
|
||||||
|
messageSignatureNightOddName: '',
|
||||||
|
messageSignatureNightShiftStart: '19:00',
|
||||||
|
messageSignatureNightShiftEnd: '07:00',
|
||||||
typingDelay: 0,
|
typingDelay: 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -215,6 +223,14 @@ export default {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visibleToAllChannelTabs = [
|
||||||
|
...visibleToAllChannelTabs,
|
||||||
|
{
|
||||||
|
key: 'landing-hosts',
|
||||||
|
name: 'Landing Pages',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return visibleToAllChannelTabs;
|
return visibleToAllChannelTabs;
|
||||||
},
|
},
|
||||||
currentInboxId() {
|
currentInboxId() {
|
||||||
@ -451,6 +467,18 @@ export default {
|
|||||||
null;
|
null;
|
||||||
this.typingDelay = this.inbox.typing_delay || 0;
|
this.typingDelay = this.inbox.typing_delay || 0;
|
||||||
this.messageSignatureEnabled = this.inbox.message_signature_enabled;
|
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
|
// Set initial tab after inbox data is loaded
|
||||||
this.setTabFromRouteParam();
|
this.setTabFromRouteParam();
|
||||||
@ -476,6 +504,13 @@ export default {
|
|||||||
sender_name_type: this.senderNameType,
|
sender_name_type: this.senderNameType,
|
||||||
business_name: this.businessName || null,
|
business_name: this.businessName || null,
|
||||||
message_signature_enabled: this.messageSignatureEnabled,
|
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: {
|
channel: {
|
||||||
widget_color: this.inbox.widget_color,
|
widget_color: this.inbox.widget_color,
|
||||||
website_url: this.channelWebsiteUrl,
|
website_url: this.channelWebsiteUrl,
|
||||||
@ -632,6 +667,54 @@ export default {
|
|||||||
{{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }}
|
{{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<woot-input
|
||||||
v-if="isAPIInbox"
|
v-if="isAPIInbox"
|
||||||
v-model="webhookUrl"
|
v-model="webhookUrl"
|
||||||
@ -1046,6 +1129,9 @@ export default {
|
|||||||
<div v-if="selectedTabKey === 'whatsapp-health'">
|
<div v-if="selectedTabKey === 'whatsapp-health'">
|
||||||
<AccountHealth :health-data="healthData" />
|
<AccountHealth :health-data="healthData" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedTabKey === 'landing-hosts'">
|
||||||
|
<LandingHostsConfig :inbox="inbox" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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
|
# Table name: inboxes
|
||||||
#
|
#
|
||||||
# id :integer not null, primary key
|
# id :integer not null, primary key
|
||||||
# allow_messages_after_resolved :boolean default(TRUE)
|
# allow_messages_after_resolved :boolean default(TRUE)
|
||||||
# auto_assignment_config :jsonb
|
# auto_assignment_config :jsonb
|
||||||
# auto_resolve_duration :integer
|
# auto_resolve_duration :integer
|
||||||
# business_name :string
|
# business_name :string
|
||||||
# channel_type :string
|
# channel_type :string
|
||||||
# csat_config :jsonb not null
|
# csat_config :jsonb not null
|
||||||
# csat_survey_enabled :boolean default(FALSE)
|
# csat_survey_enabled :boolean default(FALSE)
|
||||||
# email_address :string
|
# email_address :string
|
||||||
# enable_auto_assignment :boolean default(TRUE)
|
# enable_auto_assignment :boolean default(TRUE)
|
||||||
# enable_email_collect :boolean default(TRUE)
|
# enable_email_collect :boolean default(TRUE)
|
||||||
# greeting_enabled :boolean default(FALSE)
|
# greeting_enabled :boolean default(FALSE)
|
||||||
# greeting_message :string
|
# greeting_message :string
|
||||||
# lock_to_single_conversation :boolean default(FALSE), not null
|
# lock_to_single_conversation :boolean default(FALSE), not null
|
||||||
# message_signature_enabled :boolean
|
# message_signature_day_name :string
|
||||||
# name :string not null
|
# message_signature_default_name :string
|
||||||
# out_of_office_message :string
|
# message_signature_enabled :boolean
|
||||||
# sender_name_type :integer default("friendly"), not null
|
# message_signature_night_even_name :string
|
||||||
# timezone :string default("UTC")
|
# message_signature_night_odd_name :string
|
||||||
# typing_delay :integer default(0)
|
# message_signature_night_shift_end :string default("07:00")
|
||||||
# working_hours_enabled :boolean default(FALSE)
|
# message_signature_night_shift_start :string default("19:00")
|
||||||
# created_at :datetime not null
|
# name :string not null
|
||||||
# updated_at :datetime not null
|
# out_of_office_message :string
|
||||||
# account_id :integer not null
|
# sender_name_type :integer default("friendly"), not null
|
||||||
# channel_id :integer not null
|
# timezone :string default("UTC")
|
||||||
# portal_id :bigint
|
# 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
|
# Indexes
|
||||||
#
|
#
|
||||||
@ -209,8 +215,29 @@ class Inbox < ApplicationRecord
|
|||||||
account.feature_enabled?('assignment_v2')
|
account.feature_enabled?('assignment_v2')
|
||||||
end
|
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
|
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
|
def default_name_for_blank_name
|
||||||
email? ? display_name_from_email : ''
|
email? ? display_name_from_email : ''
|
||||||
end
|
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
|
@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
|
return if @parser.from_me? && handle_echo_message
|
||||||
|
|
||||||
create_new_message
|
create_new_message
|
||||||
@ -150,8 +153,18 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
|
|||||||
|
|
||||||
def build_message(parser, conversation, clean_source_id)
|
def build_message(parser, conversation, clean_source_id)
|
||||||
is_outgoing = parser.from_me?
|
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 = {
|
msg_params = {
|
||||||
content: parser.text_content,
|
content: content,
|
||||||
account_id: inbox.account_id, inbox_id: inbox.id,
|
account_id: inbox.account_id, inbox_id: inbox.id,
|
||||||
message_type: is_outgoing ? :outgoing : :incoming,
|
message_type: is_outgoing ? :outgoing : :incoming,
|
||||||
sender: is_outgoing ? nil : @contact,
|
sender: is_outgoing ? nil : @contact,
|
||||||
|
|||||||
@ -10,97 +10,46 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
|
|||||||
|
|
||||||
def send_message(phone_number, message)
|
def send_message(phone_number, message)
|
||||||
user_token = whatsapp_channel.wuzapi_user_token
|
user_token = whatsapp_channel.wuzapi_user_token
|
||||||
# Normalize phone number: remove +, space, -, (, )
|
normalized_phone = normalize_phone(phone_number)
|
||||||
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
|
log_outgoing_message(message)
|
||||||
|
return send_reaction_message(normalized_phone, message) if reaction_message?(message)
|
||||||
|
|
||||||
Rails.logger.info "[WuzapiService] Sending Message:
|
content_to_send = build_content_with_signature(message)
|
||||||
Message ID: #{message.id}
|
response = dispatch_message(user_token, normalized_phone, message, content_to_send)
|
||||||
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
|
|
||||||
extract_message_id(response)
|
extract_message_id(response)
|
||||||
end
|
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
|
attachment = message.attachments.first
|
||||||
base64_data = Base64.strict_encode64(attachment.file.download)
|
|
||||||
mime_type = attachment.file.content_type
|
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}"
|
data_uri = "data:#{mime_type};base64,#{base64_data}"
|
||||||
|
|
||||||
if mime_type.start_with?('image/')
|
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
|
else
|
||||||
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
|
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_reaction_message(phone_number, message)
|
def send_reaction_message(phone_number, message)
|
||||||
user_token = whatsapp_channel.wuzapi_user_token
|
user_token = whatsapp_channel.wuzapi_user_token
|
||||||
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
|
|
||||||
|
|
||||||
# Assuming message content is the emoji
|
|
||||||
reaction_emoji = message.content
|
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
|
Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{phone}, msg_id=#{mid}, emoji=#{reaction_emoji}"
|
||||||
message_id = message.content_attributes['in_reply_to_external_id']
|
|
||||||
|
|
||||||
# Fallback to internal ID resolution if external is missing
|
if mid.blank?
|
||||||
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
|
|
||||||
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
|
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
response = client.send_reaction(user_token, phone, mid, reaction_emoji)
|
||||||
|
Rails.logger.info "[WuzapiService] Reaction response: #{response}"
|
||||||
|
response
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_template(_phone_number, _template_info)
|
def send_template(_phone_number, _template_info)
|
||||||
@ -155,6 +104,74 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
|
|||||||
|
|
||||||
private
|
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
|
def client
|
||||||
@client ||= ::Wuzapi::Client.new(@base_url)
|
@client ||= ::Wuzapi::Client.new(@base_url)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -37,6 +37,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
get '/health', to: 'health#show'
|
get '/health', to: 'health#show'
|
||||||
get '/api', to: 'api#index'
|
get '/api', to: 'api#index'
|
||||||
|
post '/track/click', to: 'api/v1/tracking#click'
|
||||||
namespace :api, defaults: { format: 'json' } do
|
namespace :api, defaults: { format: 'json' } do
|
||||||
namespace :v1 do
|
namespace :v1 do
|
||||||
# ----------------------------------
|
# ----------------------------------
|
||||||
@ -234,6 +235,7 @@ Rails.application.routes.draw do
|
|||||||
get :campaigns, on: :member
|
get :campaigns, on: :member
|
||||||
get :agent_bot, on: :member
|
get :agent_bot, on: :member
|
||||||
post :set_agent_bot, on: :member
|
post :set_agent_bot, on: :member
|
||||||
|
resources :landing_hosts, only: [:index, :create, :destroy]
|
||||||
post :setup_channel_provider, on: :member
|
post :setup_channel_provider, on: :member
|
||||||
post :disconnect_channel_provider, on: :member
|
post :disconnect_channel_provider, on: :member
|
||||||
delete :avatar, 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.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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.integer "auto_resolve_duration"
|
||||||
t.boolean "message_signature_enabled"
|
t.boolean "message_signature_enabled"
|
||||||
t.integer "typing_delay", default: 0
|
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 ["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 ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
|
||||||
t.index ["portal_id"], name: "index_inboxes_on_portal_id"
|
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
|
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
|
||||||
end
|
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|
|
create_table "leaves", force: :cascade do |t|
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.bigint "user_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