diff --git a/app/controllers/api/v1/accounts/captain/units/reservations_sync_controller.rb b/app/controllers/api/v1/accounts/captain/units/reservations_sync_controller.rb
new file mode 100644
index 0000000..ba8225c
--- /dev/null
+++ b/app/controllers/api/v1/accounts/captain/units/reservations_sync_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/accounts/captain/units_controller.rb b/app/controllers/api/v1/accounts/captain/units_controller.rb
new file mode 100644
index 0000000..39f0f5d
--- /dev/null
+++ b/app/controllers/api/v1/accounts/captain/units_controller.rb
@@ -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
diff --git a/app/javascript/dashboard/api/captain/inboxes.js b/app/javascript/dashboard/api/captain/inboxes.js
index 2a7438c..b9bdbee 100755
--- a/app/javascript/dashboard/api/captain/inboxes.js
+++ b/app/javascript/dashboard/api/captain/inboxes.js
@@ -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 },
});
}
}
diff --git a/app/javascript/dashboard/api/captain/units.js b/app/javascript/dashboard/api/captain/units.js
index cfe4ae0..47375a4 100644
--- a/app/javascript/dashboard/api/captain/units.js
+++ b/app/javascript/dashboard/api/captain/units.js
@@ -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();
diff --git a/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue b/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue
index e381a04..7ba4a91 100755
--- a/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue
+++ b/app/javascript/dashboard/components-next/captain/assistant/InboxCard.vue
@@ -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"
/>
+
+
+
+ {{ unitName }}
+
+
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue
index a7620c9..9d5a4c4 100755
--- a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue
@@ -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)"
/>
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/HeaderRow.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/HeaderRow.vue
index f2dce8f..b8d33e0 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/HeaderRow.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/HeaderRow.vue
@@ -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 });
-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 });
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/inbox/ConnectInboxForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/inbox/ConnectInboxForm.vue
index 5e48ef5..2a4b920 100755
--- a/app/javascript/dashboard/components-next/captain/pageComponents/inbox/ConnectInboxForm.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/inbox/ConnectInboxForm.vue
@@ -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 => ({
- value: unit.id,
- label: unit.name,
- }));
+ 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"
/>
@@ -169,7 +219,11 @@ watch(accountId, () => {
/>
-
+
{{ guestName }}
+
+ {{ sourceTag }}
+
@@ -206,6 +213,7 @@ const showMenu = ref(false);
{{ formatCurrency(reservation.total_amount) }}
+
{
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"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/tools/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/tools/Index.vue
index 6f8ef87..9535553 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/tools/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/tools/Index.vue
@@ -236,7 +236,7 @@ onMounted(() => {
:placeholder="
$t('CAPTAIN.ASSISTANTS.SKILLS.FALLBACK.PLACEHOLDER')
"
- :max-length="400"
+ :max-length="800"
show-character-count
@blur="handleFallbackUpdate(tool)"
/>
diff --git a/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue
index 43089c9..9b59c0a 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue
@@ -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"
/>
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/units/UnitModal.vue b/app/javascript/dashboard/routes/dashboard/captain/units/UnitModal.vue
index 73fda6b..565f863 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/units/UnitModal.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/units/UnitModal.vue
@@ -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') : ''"
/>
+
+
+
+
+
+ {{ 'Integração Config' }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/models/captain/reservation.rb b/app/models/captain/reservation.rb
new file mode 100644
index 0000000..779b64a
--- /dev/null
+++ b/app/models/captain/reservation.rb
@@ -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
diff --git a/app/models/captain/unit.rb b/app/models/captain/unit.rb
new file mode 100644
index 0000000..720b934
--- /dev/null
+++ b/app/models/captain/unit.rb
@@ -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
diff --git a/app/models/captain_assistant.rb b/app/models/captain_assistant.rb
index 5af3c11..ab66bf1 100644
--- a/app/models/captain_assistant.rb
+++ b/app/models/captain_assistant.rb
@@ -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
diff --git a/app/models/captain_document.rb b/app/models/captain_document.rb
index 117870b..0d74e82 100644
--- a/app/models/captain_document.rb
+++ b/app/models/captain_document.rb
@@ -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'
diff --git a/app/models/captain_scenario.rb b/app/models/captain_scenario.rb
index 9df0f61..da55177 100644
--- a/app/models/captain_scenario.rb
+++ b/app/models/captain_scenario.rb
@@ -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'
diff --git a/app/services/captain/reservations/sync_service.rb b/app/services/captain/reservations/sync_service.rb
new file mode 100644
index 0000000..5552797
--- /dev/null
+++ b/app/services/captain/reservations/sync_service.rb
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index ff91ed4..a1716a7 100755
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/db/migrate/20260121165034_add_plug_play_to_captain_units.rb b/db/migrate/20260121165034_add_plug_play_to_captain_units.rb
new file mode 100644
index 0000000..b8ac8dc
--- /dev/null
+++ b/db/migrate/20260121165034_add_plug_play_to_captain_units.rb
@@ -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
diff --git a/db/migrate/20260121165043_add_unique_index_to_captain_reservations.rb b/db/migrate/20260121165043_add_unique_index_to_captain_reservations.rb
new file mode 100644
index 0000000..7bf452b
--- /dev/null
+++ b/db/migrate/20260121165043_add_unique_index_to_captain_reservations.rb
@@ -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
diff --git a/db/migrate/20260210120000_add_leader_whatsapp_to_captain_units.rb b/db/migrate/20260210120000_add_leader_whatsapp_to_captain_units.rb
new file mode 100644
index 0000000..84d0191
--- /dev/null
+++ b/db/migrate/20260210120000_add_leader_whatsapp_to_captain_units.rb
@@ -0,0 +1,5 @@
+class AddLeaderWhatsappToCaptainUnits < ActiveRecord::Migration[7.0]
+ def change
+ add_column :captain_units, :leader_whatsapp, :string
+ end
+end
diff --git a/db/migrate/20260210123000_add_reservation_source_tag_to_captain_units.rb b/db/migrate/20260210123000_add_reservation_source_tag_to_captain_units.rb
new file mode 100644
index 0000000..0cc8ef0
--- /dev/null
+++ b/db/migrate/20260210123000_add_reservation_source_tag_to_captain_units.rb
@@ -0,0 +1,5 @@
+class AddReservationSourceTagToCaptainUnits < ActiveRecord::Migration[7.0]
+ def change
+ add_column :captain_units, :reservation_source_tag, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0045eb6..62a9ea2 100755
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_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"
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb
index 621b6b6..8a8ef28 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb
@@ -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
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 35420ae..686ef88 100755
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -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')
diff --git a/enterprise/app/models/captain/reservation.rb b/enterprise/app/models/captain/reservation.rb
index eb43e56..8a5e00e 100644
--- a/enterprise/app/models/captain/reservation.rb
+++ b/enterprise/app/models/captain/reservation.rb
@@ -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
diff --git a/enterprise/app/models/captain/unit.rb b/enterprise/app/models/captain/unit.rb
index 62a63dd..b3134d9 100644
--- a/enterprise/app/models/captain/unit.rb
+++ b/enterprise/app/models/captain/unit.rb
@@ -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'
diff --git a/enterprise/app/services/captain/tools/tool_runner.rb b/enterprise/app/services/captain/tools/tool_runner.rb
index 431078a..30c4456 100644
--- a/enterprise/app/services/captain/tools/tool_runner.rb
+++ b/enterprise/app/services/captain/tools/tool_runner.rb
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/captain/inboxes/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/inboxes/create.json.jbuilder
index ab5bad2..54d9174 100755
--- a/enterprise/app/views/api/v1/accounts/captain/inboxes/create.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/inboxes/create.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/captain/inboxes/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/inboxes/index.json.jbuilder
index 9984172..ef543ff 100755
--- a/enterprise/app/views/api/v1/accounts/captain/inboxes/index.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/inboxes/index.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/accounts/captain/inboxes/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/inboxes/update.json.jbuilder
index ab5bad2..54d9174 100644
--- a/enterprise/app/views/api/v1/accounts/captain/inboxes/update.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/captain/inboxes/update.json.jbuilder
@@ -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
diff --git a/enterprise/app/views/api/v1/models/captain/_reservation.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_reservation.json.jbuilder
index 986aef4..64bd092 100644
--- a/enterprise/app/views/api/v1/models/captain/_reservation.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_reservation.json.jbuilder
@@ -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