feat: Adiciona sincronização de reservas, novos campos para unidades Captain e associa inboxes a unidades."

This commit is contained in:
Rodrigo Borba 2026-01-21 16:58:59 -03:00
parent 18a4bebca1
commit 1f3d1dbcfe
39 changed files with 863 additions and 46 deletions

View File

@ -0,0 +1,21 @@
module Api
module V1
module Accounts
module Captain
module Units
class ReservationsSyncController < Api::V1::Accounts::BaseController
def create
unit = Current.account.captain_units.find(params[:unit_id])
::Captain::Reservations::SyncService.new(unit).perform
head :ok
rescue ActiveRecord::RecordNotFound
render_not_found_error('Unit not found')
rescue StandardError => e
render_error(e.message)
end
end
end
end
end
end
end

View File

@ -0,0 +1,65 @@
module Api
module V1
module Accounts
module Captain
class UnitsController < Api::V1::Accounts::BaseController
def index
@units = Current.account.captain_units
end
def show
@unit = Current.account.captain_units.find(params[:id])
end
def create
@unit = Current.account.captain_units.new(unit_params)
@unit.captain_brand = Current.account.captain_brands.first # Default brand logic for now
if @unit.save
render 'show', status: :created
else
render_error_response(@unit)
end
end
def update
@unit = Current.account.captain_units.find(params[:id])
if @unit.update(unit_params)
render 'show'
else
render_error_response(@unit)
end
end
def destroy
@unit = Current.account.captain_units.find(params[:id])
@unit.destroy
head :ok
end
private
def unit_params
params.require(:unit).permit(
:name,
:status,
:reservations_sync_enabled,
:plug_play_id,
:plug_play_token,
:webhook_url,
:leader_whatsapp,
:reservation_source_tag,
:inter_client_id,
:inter_client_secret,
:inter_pix_key,
:inter_account_number,
visible_suite_categories: [],
suite_category_images: {}
)
end
end
end
end
end
end

View File

@ -11,9 +11,9 @@ class CaptainInboxes extends ApiClient {
}
create(params = {}) {
const { assistantId, inboxId } = params;
const { assistantId, inboxId, captain_unit_id } = params;
return axios.post(`${this.url}/${assistantId}/inboxes`, {
inbox: { inbox_id: inboxId },
inbox: { inbox_id: inboxId, captain_unit_id },
});
}
@ -23,9 +23,9 @@ class CaptainInboxes extends ApiClient {
}
update(inboxId, params = {}) {
const { assistantId, always_use_reminder_tool } = params;
const { assistantId, captain_unit_id, always_use_reminder_tool } = params;
return axios.patch(`${this.url}/${assistantId}/inboxes/${inboxId}`, {
inbox: { always_use_reminder_tool },
inbox: { captain_unit_id, always_use_reminder_tool },
});
}
}

View File

@ -12,6 +12,10 @@ class UnitsAPI extends ApiClient {
update(id, data) {
return window.axios.patch(`${this.url}/${id}`, data);
}
syncReservations(id) {
return window.axios.post(`${this.url}/${id}/reservations/sync`);
}
}
export default new UnitsAPI();

View File

@ -68,6 +68,12 @@ const inboxName = computed(() => {
});
const menuItems = computed(() => [
{
label: t('CAPTAIN.INBOXES.OPTIONS.EDIT'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.INBOXES.OPTIONS.DISCONNECT'),
value: 'delete',
@ -86,6 +92,33 @@ const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
};
const unitName = ref('');
const fetchUnit = async () => {
const unitId = props.inbox?.captain_inbox?.captain_unit_id;
if (!unitId) return;
try {
const accountId = window.chatwootConfig?.account_id;
if (!accountId) return;
const { data } = await window.axios.get(
`/api/v1/accounts/${accountId}/captain/units/${unitId}`
);
unitName.value = data.name;
} catch (error) {
// Ignore error
}
};
watch(
() => props.inbox?.captain_inbox?.captain_unit_id,
() => {
fetchUnit();
},
{ immediate: true }
);
const toggleReminderTool = async value => {
if (isUpdating.value) return;
isUpdating.value = true;
@ -147,6 +180,14 @@ const toggleReminderTool = async value => {
@update:model-value="toggleReminderTool"
/>
</div>
<div v-if="unitName" class="mt-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-100"
>
<i class="i-lucide-building-2 mr-1 text-xs" />
{{ unitName }}
</span>
</div>
<p class="mt-1 text-xs text-n-slate-10">
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
</p>

View File

@ -253,8 +253,8 @@ const handleSubmit = async () => {
v-for="(header, index) in customHeaders"
:key="index"
ref="headersRef"
v-model:key="header.key"
v-model:value="header.value"
v-model:header-key="header.key"
v-model:header-value="header.value"
@remove="removeHeader(index)"
/>
</ul>

View File

@ -8,27 +8,27 @@ const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const key = defineModel('key', {
const headerKey = defineModel('headerKey', {
type: String,
required: true,
});
const value = defineModel('value', {
const headerValue = defineModel('headerValue', {
type: String,
required: true,
});
const validationError = computed(() => {
if (!key.value || key.value.trim() === '') {
if (!headerKey.value || headerKey.value.trim() === '') {
return 'HEADER_KEY_REQUIRED';
}
if (!value.value || value.value.trim() === '') {
if (!headerValue.value || headerValue.value.trim() === '') {
return 'HEADER_VALUE_REQUIRED';
}
return null;
});
watch([key, value], () => {
watch([headerKey, headerValue], () => {
showErrors.value = false;
});
@ -52,11 +52,11 @@ defineExpose({ validate });
<div class="flex flex-col flex-1 gap-3">
<div class="grid grid-cols-2 gap-2">
<Input
v-model="key"
v-model="headerKey"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_KEY.PLACEHOLDER')"
/>
<Input
v-model="value"
v-model="headerValue"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_VALUE.PLACEHOLDER')
"

View File

@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
@ -7,11 +7,19 @@ import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConnectInboxForm from './ConnectInboxForm.vue';
defineProps({
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
type: {
type: String,
default: 'create',
},
inbox: {
type: Object,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
@ -20,15 +28,19 @@ const store = useStore();
const dialogRef = ref(null);
const connectForm = ref(null);
const i18nKey = 'CAPTAIN.INBOXES.CREATE';
const i18nKey = computed(() =>
props.type === 'edit' ? 'CAPTAIN.INBOXES.EDIT' : 'CAPTAIN.INBOXES.CREATE'
);
const handleSubmit = async payload => {
try {
await store.dispatch('captainInboxes/create', payload);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
const action =
props.type === 'edit' ? 'captainInboxes/update' : 'captainInboxes/create';
await store.dispatch(action, payload);
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey}.ERROR_MESSAGE`);
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
@ -48,7 +60,7 @@ defineExpose({ dialogRef });
<!-- eslint-disable vue/no-bare-strings-in-template -->
<Dialog
ref="dialogRef"
type="create"
:type="type"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.INBOXES.FORM_DESCRIPTION')"
:show-cancel-button="false"
@ -58,6 +70,7 @@ defineExpose({ dialogRef });
<ConnectInboxForm
ref="connectForm"
:assistant-id="assistantId"
:inbox="inbox"
@submit="handleSubmit"
@cancel="handleCancel"
/>

View File

@ -14,6 +14,10 @@ const props = defineProps({
type: Number,
required: true,
},
inbox: {
type: Object,
default: null,
},
});
const emit = defineEmits(['submit', 'cancel']);
@ -28,8 +32,8 @@ const formState = {
};
const initialState = {
inboxId: null,
captainUnitId: null,
inboxId: props.inbox?.captain_inbox?.inbox_id || null,
captainUnitId: props.inbox?.captain_inbox?.captain_unit_id || null,
};
const state = reactive({ ...initialState });
@ -46,21 +50,65 @@ const accountId = computed(() => {
const inboxList = computed(() => {
const captainInboxIds = formState.captainInboxes.value.map(inbox => inbox.id);
return formState.inboxes.value
// Filter available inboxes from the store
const availableInboxes = formState.inboxes.value
.filter(inbox => !captainInboxIds.includes(inbox.id))
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
// If we are editing, we MUST ensure the current inbox is in the list
if (props.inbox) {
const currentInboxId = props.inbox.id;
// Check if it's already in the list (it shouldn't be if it's in captainInboxes)
const alreadyInList = availableInboxes.find(
i => i.value === currentInboxId
);
if (!alreadyInList) {
// We use the name directly from props.inbox to avoid store lookup issues
const label =
props.inbox.name || props.inbox.phone_number || 'Caixa de Entrada';
// Add to the beginning of the list
availableInboxes.unshift({
value: currentInboxId,
label: label,
});
}
}
return availableInboxes;
});
const unitList = computed(() => {
return units.map(unit => ({
return [
{ value: null, label: 'Sem Unidade' },
...units.map(unit => ({
value: unit.id,
label: unit.name,
}));
})),
];
});
watch(
() => props.inbox,
newInbox => {
if (newInbox) {
// Use inbox.id directly for the inboxId
state.inboxId = newInbox.id;
// Use captain_inbox data for other fields if available
if (newInbox.captain_inbox) {
state.captainUnitId = newInbox.captain_inbox.captain_unit_id;
}
}
},
{ immediate: true, deep: true }
);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
@ -82,6 +130,7 @@ const prepareInboxPayload = () => ({
inboxId: state.inboxId,
captain_unit_id: state.captainUnitId,
assistantId: props.assistantId,
...(props.inbox ? { id: props.inbox.captain_inbox.id } : {}),
});
const handleSubmit = async () => {
@ -136,6 +185,7 @@ watch(accountId, () => {
:placeholder="t('CAPTAIN.INBOXES.FORM.INBOX.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.inboxId"
:disabled="!!inbox"
/>
</div>
@ -169,7 +219,11 @@ watch(accountId, () => {
/>
<Button
type="submit"
:label="t('CAPTAIN.FORM.CREATE')"
:label="
props.inbox
? t('CAPTAIN.INBOXES.EDIT.SAVE')
: t('CAPTAIN.FORM.CREATE')
"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"

View File

@ -37,6 +37,7 @@ const isPartial = computed(
() => props.reservation.payment_status === 'partial'
);
const isPending = computed(() => !isPaid.value && !isPartial.value);
const sourceTag = computed(() => props.reservation.source_tag || '');
// Relative Time Logic
const timeDisplay = computed(() => {
@ -190,7 +191,7 @@ const showMenu = ref(false);
</div>
<!-- Row 2: Guest Name -->
<div class="mb-2">
<div class="mb-2 flex items-center gap-2 flex-wrap">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span
class="font-bold text-slate-900 dark:text-slate-100 text-base leading-tight line-clamp-2"
@ -198,6 +199,12 @@ const showMenu = ref(false);
>
{{ guestName }}
</span>
<span
v-if="sourceTag"
class="text-[10px] uppercase font-bold px-2 py-0.5 rounded-md inline-flex items-center border border-emerald-600 bg-emerald-400 text-slate-900"
>
{{ sourceTag }}
</span>
</div>
<!-- Row 3: Financial + Channel -->
@ -206,6 +213,7 @@ const showMenu = ref(false);
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
{{ formatCurrency(reservation.total_amount) }}
</span>
<div
class="flex items-center gap-1 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm w-fit"
:class="{

View File

@ -43,6 +43,21 @@
"STAYS_LABEL": "Accepted Stay Durations",
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Overnight, Daily (separated by comma)"
}
},
"CUSTOM_TOOLS": {
"OPTIONS": {
"TEST_TOOL": "Test Tool",
"EDIT_TOOL": "Edit Tool",
"DELETE_TOOL": "Delete Tool"
},
"FORM": {
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
}
}
}
}
}

View File

@ -363,6 +363,16 @@
},
"CAPTAIN": {
"NAME": "Captain",
"UNITS": {
"FORM": {
"LEADER_WHATSAPP_LABEL": "Leader WhatsApp",
"LEADER_WHATSAPP_PLACEHOLDER": "e.g. 5561999999999",
"RESERVATION_SOURCE_TAG_LABEL": "Reservation tag (synced)",
"RESERVATION_SOURCE_TAG_PLACEHOLDER": "e.g. PlugPlay",
"WEBHOOK_TITLE": "Integration Webhook",
"WEBHOOK_URL": "Webhook URL"
}
},
"HEADER_KNOW_MORE": "Know more",
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
@ -1282,7 +1292,8 @@
"ERROR": "Could not update reminder tool preference"
},
"OPTIONS": {
"DISCONNECT": "Disconnect"
"DISCONNECT": "Disconnect",
"EDIT": "Edit"
},
"DELETE": {
"TITLE": "Are you sure to disconnect the inbox?",

View File

@ -43,6 +43,21 @@
"STAYS_LABEL": "Durações Aceitas",
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
}
},
"CUSTOM_TOOLS": {
"OPTIONS": {
"TEST_TOOL": "Testar Ferramenta",
"EDIT_TOOL": "Editar Ferramenta",
"DELETE_TOOL": "Excluir Ferramenta"
},
"FORM": {
"AUTH_TYPES": {
"NONE": "Nenhuma",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
}
}
}
}
}

View File

@ -390,6 +390,10 @@
"NAME_LABEL": "Nome da Unidade",
"NAME_PLACEHOLDER": "Ex: Unidade Centro",
"NAME_ERROR": "O nome é obrigatório",
"LEADER_WHATSAPP_LABEL": "WhatsApp do Responsável",
"LEADER_WHATSAPP_PLACEHOLDER": "Ex: 5561999999999",
"RESERVATION_SOURCE_TAG_LABEL": "Etiqueta da Reserva (sincronizadas)",
"RESERVATION_SOURCE_TAG_PLACEHOLDER": "Ex: PlugPlay",
"BRAND_LABEL": "Marca",
"BRAND_PLACEHOLDER": "Selecione uma marca",
"FORM_DESCRIPTION": "Preencha os dados da unidade e as credenciais do Pix (Banco Inter).",
@ -399,7 +403,9 @@
"CLIENT_SECRET": "Client Secret",
"CERT_PATH": "Caminho do Certificado (.pem)",
"KEY_PATH": "Caminho da Chave (.pem)",
"ACCOUNT_NUMBER": "Conta Corrente"
"ACCOUNT_NUMBER": "Conta Corrente",
"WEBHOOK_TITLE": "Webhook de Integração",
"WEBHOOK_URL": "URL do Webhook"
}
},
"HEADER_KNOW_MORE": "Know more",
@ -1457,7 +1463,12 @@
"ERROR": "Nao foi possivel atualizar a preferencia"
},
"OPTIONS": {
"DISCONNECT": "Desconectar"
"DISCONNECT": "Desconectar",
"EDIT": "Editar"
},
"EDIT": {
"TITLE": "Editar caixa de entrada",
"SAVE": "Atualizar"
},
"DELETE": {
"TITLE": "Tem certeza que deseja desconectar a caixa de entrada?",

View File

@ -35,6 +35,17 @@ const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => connectInboxDialog.value.dialogRef.open());
};
const handleCreateClose = () => {
dialogType.value = '';
selectedInbox.value = null;
};
// Handle edit action
const handleUpdate = () => {
dialogType.value = 'edit';
nextTick(() => connectInboxDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedInbox.value = captainInboxes.value.find(
inbox => id === inbox.captain_inbox.id
@ -42,15 +53,12 @@ const handleAction = ({ action, id }) => {
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'edit') {
handleUpdate();
}
});
};
const handleCreateClose = () => {
dialogType.value = '';
selectedInbox.value = null;
};
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId.value,
@ -103,6 +111,7 @@ onMounted(() =>
ref="connectInboxDialog"
:assistant-id="assistantId"
:type="dialogType"
:inbox="selectedInbox"
@close="handleCreateClose"
/>
</PageLayout>

View File

@ -236,7 +236,7 @@ onMounted(() => {
:placeholder="
$t('CAPTAIN.ASSISTANTS.SKILLS.FALLBACK.PLACEHOLDER')
"
:max-length="400"
:max-length="800"
show-character-count
@blur="handleFallbackUpdate(tool)"
/>

View File

@ -27,6 +27,7 @@ const isLoading = ref(false);
const isFetchingUnits = ref(false);
const isUpdating = ref(false);
const isCreating = ref(false);
const isSyncing = ref(false);
const reservations = ref([]);
const units = ref([]);
@ -226,6 +227,20 @@ const fetchReservations = async () => {
}
};
const handleSync = async () => {
if (!filters.unit_id) return;
isSyncing.value = true;
try {
await CaptainUnitsAPI.syncReservations(filters.unit_id);
alert('Sincronização iniciada com sucesso!');
fetchReservations();
} catch (error) {
alert('Erro ao sincronizar reservas.');
} finally {
isSyncing.value = false;
}
};
// --- Kanban Logic ---
const isInShiftWindow = dateString => {
@ -594,6 +609,18 @@ watch(
title="Atualizar"
@click="fetchReservations"
/>
<Button
v-if="filters.unit_id"
:is-loading="isSyncing"
icon="i-lucide-cloud-download"
size="sm"
variant="outline"
color="slate"
@click="handleSync"
>
Sincronizar
</Button>
</div>
</div>

View File

@ -41,10 +41,15 @@ export default {
inter_key_path: '',
inter_account_number: '',
webhook_url: '',
leader_whatsapp: '',
reservation_source_tag: '',
inbox_id: '',
brands: [],
inboxes: [],
isLoadingBrands: false,
plug_play_id: '',
plug_play_token: '',
reservations_sync_enabled: false,
};
},
validations() {
@ -68,7 +73,13 @@ export default {
this.inter_key_path = this.unit.inter_key_path;
this.inter_account_number = this.unit.inter_account_number;
this.webhook_url = this.unit.webhook_url;
this.leader_whatsapp = this.unit.leader_whatsapp || '';
this.reservation_source_tag = this.unit.reservation_source_tag || '';
this.inbox_id = this.unit.inbox_id;
this.plug_play_id = this.unit.plug_play_id || '';
this.plug_play_token = this.unit.plug_play_token || '';
this.reservations_sync_enabled =
this.unit.reservations_sync_enabled || false;
} else {
this.resetForm();
}
@ -86,7 +97,12 @@ export default {
this.inter_key_path = '';
this.inter_account_number = '';
this.webhook_url = '';
this.leader_whatsapp = '';
this.reservation_source_tag = '';
this.inbox_id = '';
this.plug_play_id = '';
this.plug_play_token = '';
this.reservations_sync_enabled = false;
this.v$.$reset();
},
async fetchInboxes() {
@ -126,7 +142,12 @@ export default {
inter_key_path: this.inter_key_path,
inter_account_number: this.inter_account_number,
webhook_url: this.webhook_url,
leader_whatsapp: this.leader_whatsapp,
reservation_source_tag: this.reservation_source_tag,
inbox_id: this.inbox_id,
plug_play_id: this.plug_play_id,
plug_play_token: this.plug_play_token,
reservations_sync_enabled: this.reservations_sync_enabled,
};
try {
@ -181,6 +202,12 @@ export default {
:error="v$.name.$error ? $t('CAPTAIN.UNITS.FORM.NAME_ERROR') : ''"
/>
<WootInput
v-model="leader_whatsapp"
:label="$t('CAPTAIN.UNITS.FORM.LEADER_WHATSAPP_LABEL')"
:placeholder="$t('CAPTAIN.UNITS.FORM.LEADER_WHATSAPP_PLACEHOLDER')"
/>
<div>
<label
class="block mb-1.5 text-sm font-medium text-slate-700 dark:text-slate-200"
@ -298,6 +325,48 @@ export default {
:label="$t('CAPTAIN.UNITS.FORM.WEBHOOK_URL')"
placeholder="https://webhook.n8n.cloud/webhook/..."
/>
<div class="my-1 h-px bg-slate-100 dark:bg-slate-800" />
<h4
class="text-sm font-semibold text-slate-800 dark:text-slate-100 uppercase tracking-wide"
>
{{ 'Integração Config' }}
</h4>
<div class="grid grid-cols-1 gap-4">
<WootInput
v-model="plug_play_id"
label="PlugPlay ID"
placeholder="ID da Unidade na PlugPlay"
/>
<WootInput
v-model="plug_play_token"
label="PlugPlay Token"
placeholder="Token de Acesso"
type="password"
/>
<WootInput
v-model="reservation_source_tag"
:label="$t('CAPTAIN.UNITS.FORM.RESERVATION_SOURCE_TAG_LABEL')"
:placeholder="
$t('CAPTAIN.UNITS.FORM.RESERVATION_SOURCE_TAG_PLACEHOLDER')
"
/>
<div class="flex items-center gap-2">
<input
id="sync_enabled"
v-model="reservations_sync_enabled"
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label
for="sync_enabled"
class="text-sm font-medium text-slate-700 dark:text-slate-200"
>
Ativar Sincronização de Reservas
</label>
</div>
</div>
</div>
<!-- Footer -->

View File

@ -0,0 +1,36 @@
module Captain
class Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
belongs_to :account
belongs_to :inbox
belongs_to :contact
belongs_to :contact_inbox
belongs_to :conversation, optional: true
belongs_to :captain_brand, optional: true
belongs_to :captain_unit, optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
# Validations
validates :check_in_at, presence: true
validates :check_out_at, presence: true
validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true
enum status: {
scheduled: 0,
active: 1,
completed: 2,
cancelled: 3,
no_show: 4,
pending_payment: 5,
expired: 6,
payment_confirmed: 7,
issues: 8,
awaiting_checkin: 9
}
scope :active_in_date_range, lambda { |start_date, end_date|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
}
end
end

View File

@ -0,0 +1,20 @@
module Captain
class Unit < ApplicationRecord
self.table_name = 'captain_units'
belongs_to :account
belongs_to :captain_brand
belongs_to :inbox, optional: true
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy
# Encrypted fields for PlugPlay Integration
# Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase.
# Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise.
# Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive.
# For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`.
# Let's check generally used pattern later, but for now defining relations is key.
validates :name, presence: true
end
end

View File

@ -1,3 +1,25 @@
# == Schema Information
#
# Table name: captain_assistants
#
# id :bigint not null, primary key
# api_key :text
# config :jsonb not null
# description :string
# guardrails :jsonb
# handoff_webhook_config :jsonb
# llm_model :string default("gpt-3.5-turbo")
# llm_provider :string default("openai")
# name :string not null
# response_guidelines :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_assistants_on_account_id (account_id)
#
class CaptainAssistant < ApplicationRecord
belongs_to :account
has_many :captain_tool_configs, dependent: :destroy

View File

@ -1,3 +1,25 @@
# == Schema Information
#
# Table name: captain_documents
#
# id :bigint not null, primary key
# content :text
# external_link :string not null
# metadata :jsonb
# name :string
# status :integer default("uploaded"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
# Indexes
#
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_status (status)
#
class CaptainDocument < ApplicationRecord
belongs_to :account
belongs_to :assistant, class_name: 'CaptainAssistant'

View File

@ -1,3 +1,26 @@
# == Schema Information
#
# Table name: captain_scenarios
#
# id :bigint not null, primary key
# description :text
# enabled :boolean default(TRUE), not null
# instruction :text
# title :string
# tools :jsonb
# trigger_keywords :text
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
# Indexes
#
# index_captain_scenarios_on_account_id (account_id)
# index_captain_scenarios_on_assistant_id (assistant_id)
# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled)
# index_captain_scenarios_on_enabled (enabled)
#
class CaptainScenario < ApplicationRecord
belongs_to :account
belongs_to :assistant, class_name: 'CaptainAssistant'

View File

@ -0,0 +1,179 @@
module Captain
module Reservations
class SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
def initialize(unit)
@unit = unit
@account = unit.account
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback
end
def perform
return unless @unit.reservations_sync_enabled?
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present?
page = 1
loop do
reservations_data = fetch_page(page)
break if reservations_data.empty?
reservations_data.each do |reservation_data|
process_reservation(reservation_data)
end
page += 1
# Safety break to avoid infinite loops in case of API issues
break if page > 50
end
@unit.update(last_synced_at: Time.current)
end
private
def fetch_page(page)
url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
response = HTTParty.get(url, headers: headers)
if response.success?
begin
JSON.parse(response.body)
rescue StandardError
[]
end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def headers
{
'PLUG-PLAY-ID' => @unit.plug_play_id,
'PLUG-PLAY-TOKEN' => @unit.plug_play_token,
'Content-Type' => 'application/json'
}
end
def process_reservation(data)
external_id = data['id']
return if external_id.blank?
reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id)
# Resolve Contact
contact = find_or_create_contact(data)
# Map Attributes
reservation.account = @account
reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
reservation.contact = contact
reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
# If contact_inbox missing (new contact created without association to this inbox), create it
if reservation.contact_inbox.nil?
reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id)
end
reservation.suite_identifier = data['suiteRef']
reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00
reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada'])
if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank?
Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}"
return
end
reservation.total_amount = data['totalAPagar']
# Status Mapping
reservation.status = map_status(data)
reservation.metadata ||= {}
reservation.metadata['raw_plug_play_data'] = data
reservation.metadata['guest_name'] = data['nome']
reservation.metadata['guest_email'] = data['email']
reservation.metadata['guest_phone'] = data['telefone']
reservation.metadata['notes'] = data['observacoes']
reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present?
reservation.save!
rescue StandardError => e
if e.is_a?(ActiveRecord::RecordInvalid) && e.record
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}"
Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}"
else
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}"
end
end
def find_or_create_contact(data)
phone = normalize_phone_number(data['telefone'])
email = data['email']
name = data['nome']
contact = nil
# Try finding by phone
contact = @account.contacts.find_by_phone_number(phone) if phone.present?
# Try finding by email
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
# Create if not found
if contact.nil?
contact = @account.contacts.create!(
name: name,
email: email,
phone_number: phone
)
end
contact
end
def normalize_phone_number(raw_phone)
digits = raw_phone.to_s.gsub(/[^\d]/, '')
return nil if digits.blank?
digits = "55#{digits}" if digits.length == 10 || digits.length == 11
return nil if digits.length < 10 || digits.length > 15
"+#{digits}"
end
def parse_date(date_string)
return nil if date_string.blank?
Time.zone.parse(date_string)
rescue StandardError
nil
end
def map_status(data)
# MVP Logic based on dates and 'cancelada'
return :cancelled if data['cancelada'] == true
check_in = parse_date(data['dataInicio'])
check_out = parse_date(data['saidaPrevistaOuNegociada'])
now = Time.current
return :scheduled unless check_in && check_out
if check_in.to_date == now.to_date
:scheduled # Or 'awaiting_checkin' if we want to be more specific, but MVP 'scheduled' is usually 'Entrada'
elsif now >= check_in && now < check_out
:active # 'Hospedada'
elsif now >= check_out
:completed # 'Saída' / checkout done
elsif now < check_in
:scheduled
else
:scheduled # Default
end
end
end
end
end

View File

@ -84,7 +84,9 @@ Rails.application.routes.draw do
resources :reminders, only: [:index, :show, :create, :destroy]
resources :inbox_automations, only: [:index, :create, :update, :destroy]
resources :payment_callbacks, only: [:update]
resources :units, only: [:index, :show, :create, :update, :destroy], param: :id
resources :units, only: [:index, :show, :create, :update, :destroy], param: :id do
post 'reservations/sync', to: 'units/reservations_sync#create'
end
resources :brands
resources :pricings
resources :extras

View File

@ -0,0 +1,8 @@
class AddPlugPlayToCaptainUnits < ActiveRecord::Migration[7.1]
def change
add_column :captain_units, :plug_play_id, :string
add_column :captain_units, :plug_play_token, :string
add_column :captain_units, :reservations_sync_enabled, :boolean
add_column :captain_units, :last_synced_at, :datetime
end
end

View File

@ -0,0 +1,5 @@
class AddUniqueIndexToCaptainReservations < ActiveRecord::Migration[7.1]
def change
add_index :captain_reservations, [:integracao_id, :captain_unit_id], unique: true, name: 'index_captain_reservations_on_integracao_id_and_unit_id'
end
end

View File

@ -0,0 +1,5 @@
class AddLeaderWhatsappToCaptainUnits < ActiveRecord::Migration[7.0]
def change
add_column :captain_units, :leader_whatsapp, :string
end
end

View File

@ -0,0 +1,5 @@
class AddReservationSourceTagToCaptainUnits < ActiveRecord::Migration[7.0]
def change
add_column :captain_units, :reservation_source_tag, :string
end
end

View File

@ -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_01_20_141736) do
ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -559,6 +559,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_141736) do
t.index ["contact_inbox_id"], name: "index_captain_reservations_on_contact_inbox_id"
t.index ["conversation_id"], name: "index_captain_reservations_on_conversation_id"
t.index ["inbox_id"], name: "index_captain_reservations_on_inbox_id"
t.index ["integracao_id", "captain_unit_id"], name: "index_captain_reservations_on_integracao_id_and_unit_id", unique: true
t.index ["integracao_id"], name: "index_captain_reservations_on_integracao_id"
end
@ -627,6 +628,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_141736) do
t.string "inter_account_number"
t.string "webhook_url"
t.bigint "inbox_id"
t.string "plug_play_id"
t.string "plug_play_token"
t.boolean "reservations_sync_enabled"
t.datetime "last_synced_at"
t.string "leader_whatsapp"
t.string "reservation_source_tag"
t.index ["account_id"], name: "index_captain_units_on_account_id"
t.index ["captain_brand_id"], name: "index_captain_units_on_captain_brand_id"
t.index ["inbox_id"], name: "index_captain_units_on_inbox_id"

View File

@ -39,10 +39,12 @@ module Api
def unit_params
params.require(:unit).permit(
:name, :status, :captain_brand_id,
:reservations_sync_enabled,
:plug_play_id, :plug_play_token,
:inter_client_id, :inter_client_secret,
:inter_pix_key, :inter_cert_path,
:inter_key_path, :inter_account_number,
:webhook_url, :inbox_id
:webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag
)
end
end

View File

@ -123,7 +123,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
case action
when 'handoff'
if handoff_allowed?
process_action('handoff')
process_action('handoff', trigger_key: trigger_key)
return true
else
@response['response'] = fallback_handoff_blocked_message
@ -320,7 +320,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
default: 'Desculpe, estou com dificuldades técnicas no momento. Por favor, tente novamente em alguns instantes.')
end
def process_action(action)
def process_action(action, trigger_key: nil)
case action
when 'handoff'
I18n.with_locale(@assistant.account.locale) do
@ -336,6 +336,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
@conversation.add_labels(['pausar_ia'])
@conversation.save!
apply_handoff_side_effects
handle_sentiment_handoff_alerts if trigger_key.to_s == 'sentiment'
deliver_handoff_webhook
log_handoff_event
send_out_of_office_message_if_applicable
@ -347,6 +348,107 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def handle_sentiment_handoff_alerts
trigger_excerpt = last_incoming_message_excerpt
summary = build_handoff_summary
create_private_note_for_handoff(trigger_excerpt, summary)
send_leader_whatsapp_alert(trigger_excerpt, summary)
end
def last_incoming_message_excerpt
message = @conversation.messages.where(message_type: :incoming, private: false).order(created_at: :desc).first
message&.content.to_s.strip[0, 400]
end
def build_handoff_summary
summary = @conversation.latest_crm_insight&.summary_text.to_s.strip
summary = build_conversation_summary if summary.blank?
summary.to_s.strip[0, 400]
end
def create_private_note_for_handoff(trigger_excerpt, summary)
return if trigger_excerpt.blank? && summary.blank?
note_parts = []
note_parts << 'Handoff automatico por sentimento negativo.'
note_parts << "Resumo: #{summary}" if summary.present?
note_parts << "Trecho: \"#{trigger_excerpt}\"" if trigger_excerpt.present?
content = note_parts.join("\n")
@conversation.messages.create!(
message_type: :outgoing,
account_id: account.id,
inbox_id: inbox.id,
sender: @assistant,
content: content,
private: true
)
end
def send_leader_whatsapp_alert(trigger_excerpt, summary)
unit = resolve_unit_for_conversation
return if unit.blank?
leader_phone = unit.leader_whatsapp.to_s.gsub(/[^\d]/, '')
return if leader_phone.blank?
return if unit.inbox.blank?
contact_inbox = ContactInboxWithContactBuilder.new(
inbox: unit.inbox,
contact_attributes: {
name: "Lider #{unit.name}",
phone_number: leader_phone
},
source_id: leader_phone
).perform
leader_conversation = contact_inbox.conversations.order(created_at: :desc).first
leader_conversation ||= Conversation.create!(
account_id: unit.inbox.account_id,
inbox_id: unit.inbox.id,
contact_id: contact_inbox.contact_id,
contact_inbox_id: contact_inbox.id,
status: :open
)
message_text = build_leader_alert_message(unit.name, summary, trigger_excerpt)
leader_conversation.messages.create!(
message_type: :outgoing,
account_id: unit.inbox.account_id,
inbox_id: unit.inbox.id,
sender: @assistant,
content: message_text
)
rescue StandardError => e
Rails.logger.warn "[CAPTAIN][handoff] Failed to alert leader: #{e.message}"
end
def resolve_unit_for_conversation
CaptainInbox.find_by(inbox_id: inbox.id, captain_assistant_id: @assistant.id)&.unit ||
Captain::Unit.find_by(inbox_id: inbox.id)
end
def build_leader_alert_message(unit_name, summary, trigger_excerpt)
link = conversation_link
parts = []
parts << "ALERTA: cliente irritado - Unidade #{unit_name}"
parts << 'O cliente precisa ser atendido para nao termos maiores problemas.'
parts << 'Valor: obsessao pelo cliente.'
parts << "Resumo: #{summary}" if summary.present?
parts << "Trecho: \"#{trigger_excerpt}\"" if trigger_excerpt.present?
parts << "Link da conversa: #{link}" if link.present?
parts.join("\n")
end
def conversation_link
base_url = ENV.fetch('FRONTEND_URL', '').to_s
base_url = base_url.gsub('0.0.0.0', '127.0.0.1')
return '' if base_url.blank?
"#{base_url}/app/accounts/#{account.id}/conversations/#{@conversation.id}"
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')

View File

@ -6,7 +6,7 @@ module Captain
belongs_to :inbox
belongs_to :contact
belongs_to :contact_inbox
belongs_to :conversation, class_name: '::Conversation'
belongs_to :conversation, class_name: '::Conversation', optional: true
belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true
belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true

View File

@ -5,6 +5,7 @@ class Captain::Unit < ApplicationRecord
belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id'
belongs_to :inbox, optional: true
has_many :reservations, class_name: 'Captain::Reservation', foreign_key: 'captain_unit_id'
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: 'captain_unit_id'
has_many :pix_charges, class_name: 'Captain::PixCharge'
has_many :captain_inboxes, class_name: 'CaptainInbox', foreign_key: 'captain_unit_id'

View File

@ -283,14 +283,25 @@ module Captain
end
def apply_fallback(result)
return result if result[:success]
failed = !result[:success]
# Treat generate_pix business failures as fallback-worthy.
if !failed && @tool_key == 'generate_pix'
body = result[:body]
body_success =
if body.is_a?(Hash)
body[:success].nil? ? body['success'] : body[:success]
end
failed = body_success == false
end
return result unless failed
return result unless fallback_configured?
{
success: true,
body: { message: @config.fallback_message.to_s },
fallback: true,
error: result[:error]
error: result[:error] || result.dig(:body, :error)
}
end

View File

@ -2,5 +2,6 @@ json.partial! 'api/v1/models/inbox', resource: @captain_inbox.inbox
json.captain_inbox do
json.id @captain_inbox.id
json.captain_assistant_id @captain_inbox.captain_assistant_id
json.captain_unit_id @captain_inbox.captain_unit_id
json.always_use_reminder_tool @captain_inbox.always_use_reminder_tool
end

View File

@ -4,6 +4,7 @@ json.payload do
json.captain_inbox do
json.id captain_inbox.id
json.captain_assistant_id captain_inbox.captain_assistant_id
json.captain_unit_id captain_inbox.captain_unit_id
json.always_use_reminder_tool captain_inbox.always_use_reminder_tool
end
end

View File

@ -2,5 +2,6 @@ json.partial! 'api/v1/models/inbox', resource: @captain_inbox.inbox
json.captain_inbox do
json.id @captain_inbox.id
json.captain_assistant_id @captain_inbox.captain_assistant_id
json.captain_unit_id @captain_inbox.captain_unit_id
json.always_use_reminder_tool @captain_inbox.always_use_reminder_tool
end

View File

@ -11,6 +11,7 @@ json.check_out_at reservation.check_out_at&.iso8601
json.status reservation.status
json.payment_status reservation.payment_status
json.total_amount reservation.total_amount
json.source_tag reservation.metadata&.fetch('source_tag', nil)
json.unit do
json.partial! 'api/v1/models/captain/unit', unit: reservation.unit if reservation.unit
end