feat: sistema de notificações de reserva com templates configuráveis

- Adiciona check_in_at/duration_hours ao schema do tool CreateReservationIntent
  para que a IA capture o horário EXATO de chegada informado pelo cliente
- Cria captain_notification_templates: label, content, timing_minutes,
  timing_direction (before/after), active, position
- Implementa SendNotificationService com interpolação de variáveis
  (guest_name, check_in_time, check_out_time, suite_name, unit_name)
- Implementa NotificationScannerJob (Sidekiq-cron a cada 5min) com
  janela de tolerância de ±5min e idempotência via metadata JSONB
- API REST: /captain/units/:unit_id/notification_templates (CRUD)
- Store Vuex captainNotificationTemplates + API client
- UI: página de gestão de templates com editor inline e botão '+'
- Configura rota captain_settings_notifications
- i18n PT/EN para todas as strings novas
- Rubocop e ESLint: zero offenses
This commit is contained in:
Rodrigo Borba 2026-03-01 21:53:11 -03:00
parent 9a7599d971
commit 44908f32d1
19 changed files with 2406 additions and 79 deletions

View File

@ -0,0 +1,30 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainNotificationTemplatesAPI extends ApiClient {
constructor() {
super('captain/units', { accountScoped: true });
}
getTemplates(unitId) {
return axios.get(`${this.url}/${unitId}/notification_templates`);
}
createTemplate(unitId, data) {
return axios.post(`${this.url}/${unitId}/notification_templates`, {
notification_template: data,
});
}
updateTemplate(unitId, id, data) {
return axios.patch(`${this.url}/${unitId}/notification_templates/${id}`, {
notification_template: data,
});
}
deleteTemplate(unitId, id) {
return axios.delete(`${this.url}/${unitId}/notification_templates/${id}`);
}
}
export default new CaptainNotificationTemplatesAPI();

View File

@ -352,6 +352,7 @@
"UNITS_GROUP": "Pix Units",
"INBOXES_GROUP": "Inboxes",
"TABS": {
"DASHBOARD": "Dashboard",
"INSIGHTS": "AI Insights",
"OPERATIONAL": "Operational"
},
@ -372,7 +373,22 @@
"AI_FAILURES": "AI Failures",
"BULLET": "•",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")"
"COUNT_SUFFIX": ")",
"TIMES": "x",
"SENTIMENT": "Sentiment",
"SENTIMENT_POSITIVE": "Positive",
"SENTIMENT_NEGATIVE": "Negative",
"SENTIMENT_NEUTRAL": "Neutral",
"PRAISES": "Customer Praises",
"COMPLAINTS": "Complaints",
"FAQ_GAPS": "FAQ Gaps",
"FAQ_GAPS_HINT": "Questions customers ask that the agent doesn't cover",
"MOST_REQUESTED_SUITES": "Most Requested Suites",
"PRICE_REACTIONS": "Price Reactions",
"PRICE_OBJECTIONS": "price objections",
"RECOMMENDATIONS": "Recommendations",
"SHOW_DETAILS": "View full analysis",
"HIDE_DETAILS": "Hide details"
},
"EMPTY": {
"TITLE": "No reports generated",
@ -393,6 +409,74 @@
"OPERATIONAL": {
"COMING_SOON": "Coming soon",
"COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon."
},
"DASHBOARD": {
"TOTAL_CONVERSATIONS": "Analyzed conversations",
"AVG_SENTIMENT": "Avg. positive sentiment",
"FAQ_GAPS_TOTAL": "FAQ gaps identified",
"WEEKS_ANALYZED": "weeks analyzed",
"NO_DATA": "Not enough data. Generate more AI reports to see the dashboard.",
"SENTIMENT_TREND": "Sentiment trend by week",
"FAILURES_RANKING": "Agent failure ranking",
"FAILURES_RANKING_HINT": "Most frequent situations where the AI couldn't respond well",
"FAQ_PRIORITY": "Priority FAQs to create",
"FAQ_PRIORITY_HINT": "Questions customers ask most that aren't in the FAQ yet",
"CUSTOMER_BEHAVIOR": "Customer behavior",
"TOP_TOPICS_TITLE": "Most discussed topics",
"SUITES_TITLE": "Most requested suites",
"COMPLAINTS_TREND": "Complaints volume by week",
"HANDOFFS_TITLE": "Estimated handoffs to human",
"HANDOFFS_HINT": "Based on AI failure frequency. Direct handoff tracking coming soon.",
"TREND_UP": "rising",
"TREND_DOWN": "falling",
"TREND_STABLE": "stable",
"WEEKS": "weeks"
},
"FAQ_QUICK_ADD": {
"BUTTON": "Create FAQ",
"TITLE": "Create FAQ from AI suggestion",
"QUESTION_LABEL": "Question (suggested by AI)",
"ANSWER_LABEL": "Answer",
"ANSWER_PLACEHOLDER": "Write the answer to this question...",
"ASSISTANT_LABEL": "AI Agent",
"ASSISTANT_PLACEHOLDER": "Select the agent",
"CANCEL": "Cancel",
"SAVE": "Save FAQ",
"SUCCESS": "FAQ created successfully!",
"ERROR": "Error creating FAQ. Please try again."
},
"NOTIFICATIONS": {
"TITLE": "Automatic Notifications",
"DESCRIPTION": "Configure messages sent automatically before or after the guest's arrival.",
"LOADING": "Loading notifications...",
"ADD": "Add notification",
"ACTIVE": "Active",
"INACTIVE": "Inactive",
"DIRECTION": {
"BEFORE": "before",
"AFTER": "after",
"OF_ARRIVAL": "arrival"
},
"FORM": {
"LABEL_PLACEHOLDER": "Template name (e.g. Arrival Instructions)",
"CONTENT_PLACEHOLDER": "Message to send... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
"SEND": "Send",
"MINUTES": "min",
"CANCEL": "Cancel",
"SAVE": "Save"
},
"CREATE": {
"SUCCESS": "Notification created successfully!",
"ERROR": "Error creating notification. Please try again."
},
"UPDATE": {
"SUCCESS": "Notification updated!",
"ERROR": "Error updating notification."
},
"DELETE": {
"SUCCESS": "Notification removed.",
"ERROR": "Error removing notification."
}
}
}
}

View File

@ -352,6 +352,7 @@
"UNITS_GROUP": "Unidades Pix",
"INBOXES_GROUP": "Caixas de Entrada",
"TABS": {
"DASHBOARD": "Dashboard",
"INSIGHTS": "Insights IA",
"OPERATIONAL": "Operacional"
},
@ -372,7 +373,22 @@
"AI_FAILURES": "Falhas da IA",
"BULLET": "•",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")"
"COUNT_SUFFIX": ")",
"TIMES": "x",
"SENTIMENT": "Sentimento",
"SENTIMENT_POSITIVE": "Positivo",
"SENTIMENT_NEGATIVE": "Negativo",
"SENTIMENT_NEUTRAL": "Neutro",
"PRAISES": "Elogios dos clientes",
"COMPLAINTS": "Reclamações",
"FAQ_GAPS": "Lacunas no FAQ",
"FAQ_GAPS_HINT": "Perguntas que os clientes fazem mas o agente não cobre",
"MOST_REQUESTED_SUITES": "Suítes mais pedidas",
"PRICE_REACTIONS": "Reação a preços",
"PRICE_OBJECTIONS": "objeções de preço",
"RECOMMENDATIONS": "Recomendações",
"SHOW_DETAILS": "Ver análise completa",
"HIDE_DETAILS": "Ocultar detalhes"
},
"EMPTY": {
"TITLE": "Nenhum relatório gerado",
@ -393,6 +409,75 @@
"OPERATIONAL": {
"COMING_SOON": "Em breve",
"COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve."
},
"DASHBOARD": {
"TOTAL_CONVERSATIONS": "Conversas analisadas",
"AVG_SENTIMENT": "Sentimento positivo médio",
"FAQ_GAPS_TOTAL": "Lacunas de FAQ identificadas",
"WEEKS_ANALYZED": "semanas analisadas",
"NO_DATA": "Dados insuficientes. Gere mais relatórios de IA para ver o dashboard.",
"SENTIMENT_TREND": "Tendência de sentimento por semana",
"FAILURES_RANKING": "Ranking de falhas do agente",
"FAILURES_RANKING_HINT": "Situações mais frequentes em que a IA não conseguiu responder bem",
"FAQ_PRIORITY": "FAQ prioritário para criar",
"FAQ_PRIORITY_HINT": "Perguntas que os clientes mais fazem e ainda não estão no FAQ",
"CUSTOMER_BEHAVIOR": "Comportamento dos clientes",
"TOP_TOPICS_TITLE": "Assuntos mais discutidos",
"SUITES_TITLE": "Suítes mais solicitadas",
"COMPLAINTS_TREND": "Volume de reclamações por semana",
"HANDOFFS_TITLE": "Estimativa de transferências para humano",
"HANDOFFS_HINT": "Baseado na frequência de falhas do agente. Rastreamento direto de transferências em breve.",
"TREND_UP": "em alta",
"TREND_DOWN": "em queda",
"TREND_STABLE": "estável",
"WEEKS": "semanas"
},
"FAQ_QUICK_ADD": {
"BUTTON": "Criar FAQ",
"TITLE": "Criar FAQ a partir da sugestão da IA",
"QUESTION_LABEL": "Pergunta (sugerida pela IA)",
"ANSWER_LABEL": "Resposta",
"ANSWER_PLACEHOLDER": "Escreva a resposta para esta pergunta...",
"ASSISTANT_LABEL": "Agente de IA",
"ASSISTANT_PLACEHOLDER": "Selecione o agente",
"CANCEL": "Cancelar",
"SAVE": "Salvar FAQ",
"SUCCESS": "FAQ criado com sucesso!",
"ERROR": "Erro ao criar FAQ. Tente novamente."
},
"NOTIFICATIONS": {
"TITLE": "Notificações Automáticas",
"DESCRIPTION": "Configure mensagens automáticas enviadas antes ou depois da chegada do hóspede.",
"LOADING": "Carregando notificações...",
"ADD": "Adicionar notificação",
"ACTIVE": "Ativo",
"INACTIVE": "Inativo",
"DIRECTION": {
"BEFORE": "antes",
"AFTER": "depois",
"OF_ARRIVAL": "da chegada"
},
"TIMING_LABEL": "da chegada",
"FORM": {
"LABEL_PLACEHOLDER": "Nome do template (ex: Orientações de Chegada)",
"CONTENT_PLACEHOLDER": "Mensagem a enviar... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
"SEND": "Enviar",
"MINUTES": "min",
"CANCEL": "Cancelar",
"SAVE": "Salvar"
},
"CREATE": {
"SUCCESS": "Notificação criada com sucesso!",
"ERROR": "Erro ao criar notificação. Tente novamente."
},
"UPDATE": {
"SUCCESS": "Notificação atualizada!",
"ERROR": "Erro ao atualizar notificação."
},
"DELETE": {
"SUCCESS": "Notificação removida.",
"ERROR": "Erro ao remover notificação."
}
}
}
}

View File

@ -8,6 +8,7 @@ import UnitEdit from './units/Edit.vue';
import GalleryIndex from './gallery/Index.vue';
import GalleryEdit from './gallery/Edit.vue';
const ReportsIndex = () => import('./reports/Index.vue');
const NotificationsIndex = () => import('./notifications/Index.vue');
export default {
routes: [
@ -77,6 +78,14 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'units/:unitId/notifications',
name: 'captain_settings_notifications',
component: NotificationsIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],

View File

@ -0,0 +1,361 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const unitId = computed(() => route.params.unitId);
const templates = useMapGetter('captainNotificationTemplates/getTemplates');
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
const editingId = ref(null);
const showNewForm = ref(false);
const emptyForm = () => ({
label: '',
content: '',
timing_minutes: 10,
timing_direction: 'before',
active: true,
});
const newForm = ref(emptyForm());
const editForm = ref(emptyForm());
const VARIABLES = [
'{{guest_name}}',
'{{check_in_time}}',
'{{check_out_time}}',
'{{suite_name}}',
'{{unit_name}}',
];
onMounted(async () => {
if (unitId.value) {
await store.dispatch('captainNotificationTemplates/fetch', unitId.value);
}
});
const openNewForm = () => {
newForm.value = emptyForm();
showNewForm.value = true;
};
const cancelNew = () => {
showNewForm.value = false;
newForm.value = emptyForm();
};
const saveNew = async () => {
if (!newForm.value.label || !newForm.value.content) return;
try {
await store.dispatch('captainNotificationTemplates/create', {
unitId: unitId.value,
...newForm.value,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
showNewForm.value = false;
newForm.value = emptyForm();
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.ERROR'));
}
};
const startEdit = template => {
editingId.value = template.id;
editForm.value = { ...template };
};
const cancelEdit = () => {
editingId.value = null;
editForm.value = emptyForm();
};
const saveEdit = async () => {
try {
await store.dispatch('captainNotificationTemplates/update', {
unitId: unitId.value,
id: editingId.value,
...editForm.value,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
editingId.value = null;
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
}
};
const toggleActive = async template => {
try {
await store.dispatch('captainNotificationTemplates/update', {
unitId: unitId.value,
id: template.id,
active: !template.active,
});
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
}
};
const deleteTemplate = async template => {
try {
await store.dispatch('captainNotificationTemplates/delete', {
unitId: unitId.value,
id: template.id,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.ERROR'));
}
};
const insertVariable = (variable, target) => {
if (target === 'new') {
newForm.value.content += variable;
} else {
editForm.value.content += variable;
}
};
const directionLabel = direction =>
direction === 'before'
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE')
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER');
const timingDisplay = template =>
`${template.timing_minutes} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')} ${directionLabel(template.timing_direction)} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')}`;
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="t('CAPTAIN_SETTINGS.NOTIFICATIONS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
:description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')"
/>
</template>
<template #body>
<div class="flex flex-col gap-3">
<!-- Template list -->
<div
v-for="template in templates"
:key="template.id"
class="rounded-lg border border-n-75 bg-white p-4"
>
<!-- View mode -->
<div
v-if="editingId !== template.id"
class="flex items-start justify-between gap-3"
>
<div class="flex flex-col gap-1 flex-1 min-w-0">
<span class="text-sm font-semibold text-n-900">{{
template.label
}}</span>
<span class="text-sm text-n-600 whitespace-pre-line">{{
template.content
}}</span>
<span class="text-xs text-n-500 mt-1">
{{ timingDisplay(template) }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="text-xs px-2 py-1 rounded"
:class="
template.active ? 'bg-g-100 text-g-700' : 'bg-n-75 text-n-500'
"
@click="toggleActive(template)"
>
{{
template.active
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
}}
</button>
<button
class="text-n-500 hover:text-n-700"
@click="startEdit(template)"
>
<span class="i-lucide-pencil w-4 h-4" />
</button>
<button
class="text-r-500 hover:text-r-700"
@click="deleteTemplate(template)"
>
<span class="i-lucide-trash-2 w-4 h-4" />
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else class="flex flex-col gap-3">
<input
v-model="editForm.label"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/>
<textarea
v-model="editForm.content"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
"
rows="3"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
/>
<!-- Variable chips -->
<div class="flex flex-wrap gap-1">
<button
v-for="v in VARIABLES"
:key="v"
class="text-xs bg-n-75 text-n-700 px-2 py-0.5 rounded hover:bg-n-100"
@click="insertVariable(v, 'edit')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm">
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="editForm.timing_minutes"
type="number"
min="1"
class="w-16 rounded border border-n-200 px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="editForm.timing_direction"
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
</option>
<option value="after">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelEdit"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isUpdating"
@click="saveEdit"
/>
</div>
</div>
</div>
<!-- New form -->
<div
v-if="showNewForm"
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
>
<input
v-model="newForm.label"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/>
<textarea
v-model="newForm.content"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
"
rows="3"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
/>
<!-- Variable chips -->
<div class="flex flex-wrap gap-1">
<button
v-for="v in VARIABLES"
:key="v"
class="text-xs bg-n-75 text-n-700 px-2 py-0.5 rounded hover:bg-n-100"
@click="insertVariable(v, 'new')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm">
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="newForm.timing_minutes"
type="number"
min="1"
class="w-16 rounded border border-n-200 px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="newForm.timing_direction"
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
</option>
<option value="after">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelNew"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isCreating"
@click="saveNew"
/>
</div>
</div>
<!-- Add button -->
<button
v-if="!showNewForm"
class="flex items-center justify-center gap-2 rounded-lg border-2 border-dashed border-n-200 py-4 text-sm text-n-500 hover:border-w-400 hover:text-w-600 transition-colors"
@click="openNewForm"
>
<span class="i-lucide-plus w-4 h-4" />
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD') }}
</button>
</div>
</template>
</SettingsLayout>
</template>

View File

@ -0,0 +1,104 @@
import CaptainNotificationTemplatesAPI from 'dashboard/api/captain/notificationTemplates';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const state = {
templates: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
const getters = {
getTemplates: $state => $state.templates,
getUIFlags: $state => $state.uiFlags,
};
const mutations = {
SET_TEMPLATES($state, templates) {
$state.templates = templates;
},
ADD_TEMPLATE($state, template) {
$state.templates.push(template);
},
UPDATE_TEMPLATE($state, updated) {
const index = $state.templates.findIndex(t => t.id === updated.id);
if (index !== -1) $state.templates.splice(index, 1, updated);
},
DELETE_TEMPLATE($state, id) {
$state.templates = $state.templates.filter(t => t.id !== id);
},
SET_UI_FLAG($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
},
};
const actions = {
fetch: async ({ commit }, unitId) => {
commit('SET_UI_FLAG', { isFetching: true });
try {
const { data } =
await CaptainNotificationTemplatesAPI.getTemplates(unitId);
commit('SET_TEMPLATES', data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit('SET_UI_FLAG', { isFetching: false });
}
},
create: async ({ commit }, { unitId, ...templateData }) => {
commit('SET_UI_FLAG', { isCreating: true });
try {
const { data } = await CaptainNotificationTemplatesAPI.createTemplate(
unitId,
templateData
);
commit('ADD_TEMPLATE', data);
return data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit('SET_UI_FLAG', { isCreating: false });
}
},
update: async ({ commit }, { unitId, id, ...templateData }) => {
commit('SET_UI_FLAG', { isUpdating: true });
try {
const { data } = await CaptainNotificationTemplatesAPI.updateTemplate(
unitId,
id,
templateData
);
commit('UPDATE_TEMPLATE', data);
return data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit('SET_UI_FLAG', { isUpdating: false });
}
},
delete: async ({ commit }, { unitId, id }) => {
commit('SET_UI_FLAG', { isDeleting: true });
try {
await CaptainNotificationTemplatesAPI.deleteTemplate(unitId, id);
commit('DELETE_TEMPLATE', id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit('SET_UI_FLAG', { isDeleting: false });
}
},
};
export default {
namespaced: true,
state,
getters,
mutations,
actions,
};

View File

@ -63,6 +63,7 @@ import captainReservations from './captain/reservations';
import captainUnits from './modules/captainUnits';
import captainGalleryItems from './modules/captainGalleryItems';
import captainReports from './modules/captainReports';
import captainNotificationTemplates from './captain/notificationTemplates';
const plugins = [];
@ -131,6 +132,7 @@ export default createStore({
captainUnits,
captainGalleryItems,
captainReports,
captainNotificationTemplates,
},
plugins,
});

View File

@ -49,3 +49,8 @@
title: 'Enviar Fotos de Suíte'
description: 'Envia fotos da galeria da unidade para o cliente, com filtros por categoria/suíte'
icon: 'image'
- id: create_reservation_intent
title: 'Criar Reserva'
description: 'Cria uma reserva draft quando o cliente confirmar suíte, preço e horário de chegada'
icon: 'calendar-add'

View File

@ -90,7 +90,9 @@ Rails.application.routes.draw do
post :label_suggestion
post :follow_up
end
resources :units
resources :units do
resources :notification_templates, only: [:index, :create, :update, :destroy]
end
namespace :reports do
resource :operational, only: [:show], controller: 'reports/operational'
resources :insights, only: [:index, :show] do

View File

@ -87,3 +87,10 @@ remove_orphan_conversations_job:
cron: '0 */12 * * *'
class: 'Internal::RemoveOrphanConversationsJob'
queue: housekeeping
# executed every 5 minutes
# scans reservations and sends configured notification templates (pre/post arrival)
captain_notification_scanner_job:
cron: '*/5 * * * *'
class: 'Captain::Notifications::NotificationScannerJob'
queue: scheduled_jobs

View File

@ -0,0 +1,18 @@
class CreateCaptainNotificationTemplates < ActiveRecord::Migration[7.1]
def change
create_table :captain_notification_templates do |t|
t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
t.string :label, null: false
t.text :content, null: false
t.integer :timing_minutes, null: false, default: 10
t.integer :timing_direction, null: false, default: 0
t.boolean :active, null: false, default: true
t.integer :position, null: false, default: 0
t.timestamps
end
add_index :captain_notification_templates, [:captain_unit_id, :active],
name: 'idx_notif_templates_unit_active'
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_02_27_120000) do
ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -534,6 +534,20 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
create_table "captain_notification_templates", force: :cascade do |t|
t.bigint "captain_unit_id", null: false
t.string "label", null: false
t.text "content", null: false
t.integer "timing_minutes", default: 10, null: false
t.integer "timing_direction", default: 0, null: false
t.boolean "active", default: true, null: false
t.integer "position", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["captain_unit_id", "active"], name: "idx_notif_templates_unit_active"
t.index ["captain_unit_id"], name: "index_captain_notification_templates_on_captain_unit_id"
end
create_table "captain_pix_charges", force: :cascade do |t|
t.bigint "reservation_id", null: false
t.bigint "unit_id", null: false
@ -1964,6 +1978,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
add_foreign_key "captain_inbox_reminder_settings", "accounts"
add_foreign_key "captain_inbox_reminder_settings", "inboxes"
add_foreign_key "captain_inboxes", "captain_units"
add_foreign_key "captain_notification_templates", "captain_units"
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
add_foreign_key "captain_pricings", "accounts"

View File

@ -0,0 +1,52 @@
class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_unit
before_action :set_template, only: [:update, :destroy]
def index
@templates = @unit.notification_templates.ordered
render json: @templates
end
def create
@template = @unit.notification_templates.new(template_params)
@template.save!
render json: @template, status: :created
end
def update
@template.update!(template_params)
render json: @template
end
def destroy
@template.destroy!
head :no_content
end
private
def set_unit
@unit = Current.account.captain_units.find(params[:unit_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Unidade não encontrada' }, status: :not_found
end
def set_template
@template = @unit.notification_templates.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Template não encontrado' }, status: :not_found
end
def template_params
params.require(:notification_template).permit(
:label,
:content,
:timing_minutes,
:timing_direction,
:active,
:position
)
end
end

View File

@ -0,0 +1,40 @@
class Captain::Notifications::NotificationScannerJob < ApplicationJob
queue_as :scheduled_jobs
# Tolerance window around the target time (job runs every 5 min, so ±5 min ensures coverage)
WINDOW_MINUTES = 5
def perform
Captain::NotificationTemplate.active.find_each do |template|
eligible_reservations_for(template).find_each do |reservation|
Captain::Notifications::SendNotificationService.new(reservation, template).perform
end
end
end
private
def eligible_reservations_for(template)
target_time = compute_target_time(template)
window_start = target_time - WINDOW_MINUTES.minutes
window_end = target_time + WINDOW_MINUTES.minutes
Captain::Reservation
.where(captain_unit_id: template.captain_unit_id)
.where(status: Captain::Reservation.statuses.slice(:confirmed, :active).values)
.where(check_in_at: window_start..window_end)
.where.not(conversation_id: nil)
.where(
"NOT (metadata->'notified_templates' @> ?::jsonb)",
"[#{template.id}]"
)
end
def compute_target_time(template)
if template.before?
template.timing_minutes.minutes.from_now
else
template.timing_minutes.minutes.ago
end
end
end

View File

@ -0,0 +1,41 @@
# == Schema Information
#
# Table name: captain_notification_templates
#
# id :bigint not null, primary key
# active :boolean default(TRUE), not null
# content :text not null
# label :string not null
# position :integer default(0), not null
# timing_direction :integer default("before"), not null
# timing_minutes :integer default(10), not null
# created_at :datetime not null
# updated_at :datetime not null
# captain_unit_id :bigint not null
#
# Indexes
#
# idx_notif_templates_unit_active (captain_unit_id,active)
# index_captain_notification_templates_on_captain_unit_id (captain_unit_id)
#
# Foreign Keys
#
# fk_rails_... (captain_unit_id => captain_units.id)
#
class Captain::NotificationTemplate < ApplicationRecord
self.table_name = 'captain_notification_templates'
belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', inverse_of: :notification_templates
enum timing_direction: { before: 0, after: 1 }
validates :label, presence: true
validates :content, presence: true
validates :timing_minutes, presence: true, numericality: { greater_than: 0 }
validates :timing_direction, presence: true
validates :captain_unit_id, presence: true
scope :active, -> { where(active: true) }
scope :ordered, -> { order(:position, :id) }
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
end

View File

@ -54,6 +54,8 @@ class Captain::Unit < ApplicationRecord
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
dependent: :destroy
has_many :notification_templates, class_name: 'Captain::NotificationTemplate', foreign_key: :captain_unit_id,
inverse_of: :unit, dependent: :destroy
encrypts :inter_client_secret
encrypts :inter_account_number

View File

@ -0,0 +1,54 @@
class Captain::Notifications::SendNotificationService
VARIABLES = {
'{{guest_name}}' => ->(r) { r.contact.name.to_s },
'{{check_in_time}}' => ->(r) { r.check_in_at.strftime('%H:%M') },
'{{check_out_time}}' => ->(r) { r.check_out_at.strftime('%H:%M') },
'{{suite_name}}' => ->(r) { r.suite_identifier.to_s },
'{{unit_name}}' => ->(r) { r.unit&.name.to_s }
}.freeze
def initialize(reservation, template)
@reservation = reservation
@template = template
end
def perform
return unless @reservation.conversation_id?
rendered = render_content
send_message(rendered)
mark_template_sent
rescue StandardError => e
Rails.logger.error "[SendNotificationService] Failed for reservation #{@reservation.id}, template #{@template.id}: #{e.message}"
end
private
def render_content
content = @template.content.dup
VARIABLES.each do |placeholder, resolver|
content.gsub!(placeholder, resolver.call(@reservation))
end
content
end
def send_message(content)
conversation = @reservation.conversation
assistant = conversation.inbox&.captain_inbox&.assistant
conversation.messages.create!(
content: content,
message_type: :outgoing,
account: conversation.account,
inbox: conversation.inbox,
sender: assistant
)
end
def mark_template_sent
current_notified = @reservation.metadata.to_h.fetch('notified_templates', [])
updated = (current_notified + [@template.id]).uniq
new_metadata = @reservation.metadata.to_h.merge('notified_templates' => updated)
@reservation.update!(metadata: new_metadata)
end
end

View File

@ -0,0 +1,351 @@
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
# rubocop:disable Rails/SkipsModelValidations
# This class was migrated from reference/ with intentional complexity in execute/verify methods.
# Refactoring is tracked as tech debt rather than done inline.
class Captain::Tools::CreateReservationIntentTool < Captain::Tools::BaseTool
def name
'create_reservation_intent'
end
def description
'Cria uma reserva draft quando o cliente confirmar suíte, preço e horário de chegada. ' \
'IMPORTANTE: Extraia o horário EXATO de chegada da conversa e passe em check_in_at. ' \
'Se o cliente informar duração (ex: "3 horas"), passe em duration_hours para calcular o check-out automaticamente.'
end
def tool_parameters_schema
{
type: 'object',
properties: {
suite: {
type: 'string',
description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)'
},
price: {
type: 'number',
description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0'
},
deposit_value: {
type: 'number',
description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50'
},
check_in_at: {
type: 'string',
description: 'Data e horário EXATO de chegada do cliente, extraído da conversa. ' \
'Formatos aceitos: ISO8601 (ex: "2026-03-01T18:30:00"), ' \
'"HH:MM" para hoje (ex: "18:30"), ' \
'ou data completa (ex: "01/03/2026 18:30"). ' \
'OBRIGATÓRIO quando o cliente informar o horário de chegada.'
},
duration_hours: {
type: 'number',
description: 'Duração da estadia em horas (ex: 3.0). ' \
'Usado para calcular check_out = check_in + duração. ' \
'Passe este campo quando o cliente informar duração em vez de horário de saída.'
},
check_out_at: {
type: 'string',
description: 'Data e horário de saída, se informado explicitamente pelo cliente. Formato ISO8601 ou HH:MM.'
}
},
required: %w[suite price]
}
end
def execute(*args, **params)
actual_params = resolve_params(args, params)
Rails.logger.info "[CreateReservationIntentTool] Starting with params: #{actual_params}"
suite_category = actual_params[:suite]
price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
price = price_raw.to_f
deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
deposit_override = deposit_input.to_f if deposit_input.present?
last_availability = fetch_last_availability
if suite_category.blank? || price <= 0
inferred = infer_from_history
suite_category ||= inferred[:suite]
price = inferred[:price].to_f if price <= 0 && inferred[:price].present?
end
if (suite_category.blank? || price <= 0) && last_availability.present?
suite_category ||= last_availability[:suite]
price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present?
end
intent_error = verify_user_intent_barrier!(suite_category, @conversation)
return intent_error if intent_error
if price.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price,
last_availability[:price])
msg = "ATENÇÃO: Preço (R$ #{format('%.2f',
price)}) diverge da última cotação (R$ #{format('%.2f',
last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar."
Rails.logger.warn "[CreateReservationIntentTool] Price block: tried #{price} but last quote was #{last_availability[:price]}"
return msg
end
return "SYSTEM INFO: Você esqueceu de informar a 'suite'. Pergunte ao cliente qual suíte e duração ele deseja." if suite_category.blank?
return 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.' if price <= 0
ensure_conversation_context!
return "Erro Crítico: Contexto de conversa não disponível. Params: #{actual_params}" unless @conversation&.inbox
unit = infer_unit
return 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.' unless unit
check_in_at, check_out_at = resolve_check_in_and_out(actual_params)
recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft)
.where('created_at > ?', 5.minutes.ago)
.where(suite_identifier: suite_category)
.order(created_at: :desc)
.first
deposit_amount = if deposit_override&.positive?
deposit_override
else
price / 2.0
end
recent_draft_deposit = recent_draft&.metadata.to_h['deposit_amount'].to_f
if recent_draft && (recent_draft_deposit - deposit_amount).abs < 0.1
msg = "ATENÇÃO: A reserva JÁ FOI CRIADA anteriormente (ID: #{recent_draft.id}). NÃO crie novamente. Apenas CHAME A FERRAMENTA 'generate_pix' AGORA para finalizar."
Rails.logger.info "[CreateReservationIntentTool] Idempotency hit: reusing draft #{recent_draft.id}"
return msg
end
Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled)
begin
Captain::Reservation.create!(
conversation_id: @conversation.id,
account: @conversation.account,
contact: @conversation.contact,
contact_inbox: @conversation.contact_inbox,
inbox: @conversation.inbox,
captain_unit_id: unit.id,
captain_brand_id: unit.captain_brand_id,
suite_identifier: suite_category,
status: :draft,
total_amount: price,
check_in_at: check_in_at,
check_out_at: check_out_at,
metadata: {
full_amount: price,
deposit_amount: deposit_amount
}
)
update_sticky_state(
suite: suite_category,
price: deposit_amount,
check_in_at: check_in_at,
check_out_at: check_out_at
)
msg = "Reserva iniciada com sucesso! Check-in: #{check_in_at.strftime('%d/%m/%Y às %H:%M')}. " \
"O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount,
unit: 'R$ ', separator: ',', delimiter: '.')}. " \
'INSTRUÇÃO: Como a reserva foi criada com sucesso, avise o cliente e CHAME IMEDIATAMENTE a ferramenta generate_pix para entregar o código de pagamento.'
Rails.logger.info '[CreateReservationIntentTool] Reservation created successfully'
return msg
rescue StandardError => e
Rails.logger.error "[CreateReservationIntentTool] Creation failed: #{e.message} | #{e.backtrace&.first}"
return "Erro técnico ao criar reserva: #{e.message}"
end
end
private
def verify_user_intent_barrier!(suite_category, conversation)
return nil if suite_category.blank?
all_incoming = conversation&.messages&.incoming&.order(created_at: :asc)&.last(10) || []
last_reset_index = all_incoming.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..] : all_incoming
user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase
user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '')
aliases = {
'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem],
'stilo' => %w[stilo estilo],
'master' => %w[master],
'alexa' => %w[alexa]
}
suite_key = suite_category.to_s.downcase.strip
suite_key = ActiveSupport::Inflector.transliterate(suite_key)
valid_terms = aliases[suite_key] || [suite_key]
match_found = valid_terms.any? do |term|
term_clean = ActiveSupport::Inflector.transliterate(term)
user_text_post_reset.include?(term_clean)
end
Rails.logger.debug { "[CreateReservationIntentTool] Intent barrier: #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}" }
unless match_found
Rails.logger.info "[CreateReservationIntentTool] Intent blocked: Suite '#{suite_category}' not found after reset"
return "Atenção: O usuário ainda não escolheu a suíte '#{suite_category}' nesta nova conversa. Pergunte: 'Qual suíte você gostaria de reservar?'."
end
nil
end
def resolve_check_in_and_out(params)
c_in = params[:check_in_at] || params[:date] || params[:day]
c_out = params[:check_out_at]
dur = params[:duration_hours]&.to_f
check_in = parse_flexible_datetime(c_in) || Time.zone.now.tomorrow.change(hour: 14)
check_out = if c_out.present?
parse_flexible_datetime(c_out) || (check_in + 3.hours)
elsif dur&.positive?
check_in + dur.hours
else
check_in + 3.hours
end
[check_in, check_out]
end
# Interpreta múltiplos formatos de data/hora:
# - "HH:MM" → hoje nesse horário
# - "DD/MM/YYYY HH:MM" → data + hora
# - ISO8601 → parse direto
def parse_flexible_datetime(value)
return nil if value.blank?
str = value.to_s.strip
# Formato "HH:MM" → hoje nesse horário
if str.match?(/\A\d{1,2}:\d{2}\z/)
hour, min = str.split(':').map(&:to_i)
return Time.zone.now.change(hour: hour, min: min, sec: 0)
end
# Formato "DD/MM/YYYY HH:MM" ou "DD/MM/YYYY"
if str.match?(%r{\A\d{1,2}/\d{1,2}/\d{2,4}})
normalized = str.gsub(%r{(\d{1,2})/(\d{1,2})/(\d{2,4})}) do
"#{Regexp.last_match(3)}-#{Regexp.last_match(2).rjust(2, '0')}-#{Regexp.last_match(1).rjust(2, '0')}"
end
return Time.zone.parse(normalized)
end
Time.zone.parse(str)
rescue ArgumentError, TypeError
nil
end
def price_mismatch?(price_a, price_b)
(price_a.to_f - price_b.to_f).abs > 0.01
end
def ensure_conversation_context!
return if @conversation.present?
end
def infer_unit
@conversation&.inbox&.captain_inbox&.unit
end
def update_sticky_state(suite:, price:, check_in_at:, check_out_at:)
return unless @conversation.respond_to?(:active_scenario_state)
state = @conversation.active_scenario_state || {}
collected = (state['collected'] || {}).merge(
'suite' => suite,
'price' => price,
'check_in_at' => check_in_at&.iso8601,
'check_out_at' => check_out_at&.iso8601
).compact
@conversation.update!(
active_scenario_state: state.merge(
'stage' => 'reservation_intent_created',
'collected' => collected,
'updated_at' => Time.current.iso8601
)
)
rescue StandardError => e
Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}"
end
def fetch_last_availability
return nil unless @conversation
data = @conversation.custom_attributes&.fetch('last_availability', nil)
return nil unless data.is_a?(Hash)
captured_at = data['captured_at']
return nil if captured_at.blank?
if Time.zone.parse(captured_at) < 4.hours.ago
Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)'
return nil
end
data.with_indifferent_access
end
# Complexity is inherent: branches for reset detection + suite/price inference across messages
def infer_from_history
return {} if @conversation.blank?
suite_candidates = available_suite_categories
messages = @conversation.messages
.where(private: false)
.where('created_at >= ?', 4.hours.ago)
.order(created_at: :desc)
.limit(20).to_a
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
messages = messages.take_while { |m| m.id != reset_msg.id } if reset_msg
messages.each do |message|
content = message.content.to_s
suite = find_suite_in_text(content, suite_candidates)
price = extract_price_from_text(content)
return { suite: suite, price: price } if suite.present? || price.present?
end
{}
end
def available_suite_categories
unit = infer_unit
return %w[Stilo Master Hidromassagem] unless unit
Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq
end
def find_suite_in_text(content, suite_candidates)
return nil if content.blank?
suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) }
end
def extract_price_from_text(content)
return nil if content.blank?
match = content.match(/R\$\s*([\d\.]+,\d{2})/)
return nil unless match
match[1].tr('.', '').tr(',', '.').to_f
end
end
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
# rubocop:enable Rails/SkipsModelValidations