refactor: move notification templates de units para inboxes

- Arquitetura corrigida: templates agora pertencem à inbox (WhatsApp),
  não à unidade PIX (que é uma config financeira, não de mensagens)
- Migration: troca FK captain_unit_id -> inbox_id (up/down explícito)
- Model: belongs_to :inbox; scope for_inbox
- Controller: escopo via account.inboxes.find(inbox_id)
- Rotas: move de captain/units/:id → inboxes/:id/notification_templates
- Scanner job: joins(:conversation).where(conversations: {inbox_id:})
- UI: página /captain/notifications com seletor de inbox no topo
  (chips clicáveis, templates carregam por watch no selectedInboxId)
- i18n PT/EN: novas keys INBOX_LABEL, SELECT_INBOX_HINT, EMPTY
This commit is contained in:
Rodrigo Borba 2026-03-01 22:17:27 -03:00
parent ce2904e57f
commit 84fff38d94
13 changed files with 372 additions and 279 deletions

View File

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

View File

@ -476,6 +476,13 @@
"DELETE": {
"SUCCESS": "Notification removed.",
"ERROR": "Error removing notification."
},
"INBOX_LABEL": "Select inbox",
"NO_CAPTAIN_INBOXES": "No inboxes with Captain configured.",
"SELECT_INBOX_HINT": "Click an inbox above to view and configure its templates.",
"EMPTY": {
"TITLE": "No templates configured",
"DESC": "Create automatic message templates for this inbox."
}
}
}

View File

@ -477,6 +477,13 @@
"DELETE": {
"SUCCESS": "Notificação removida.",
"ERROR": "Erro ao remover notificação."
},
"INBOX_LABEL": "Selecione a caixa de entrada",
"NO_CAPTAIN_INBOXES": "Nenhuma caixa de entrada com Captain configurado.",
"SELECT_INBOX_HINT": "Clique em uma caixa de entrada acima para ver e configurar os templates.",
"EMPTY": {
"TITLE": "Nenhum template configurado",
"DESC": "Crie templates de mensagem automática para esta caixa de entrada."
}
}
}

View File

@ -79,7 +79,7 @@ export default {
},
},
{
path: 'units/:unitId/notifications',
path: 'notifications',
name: 'captain_settings_notifications',
component: NotificationsIndex,
meta: {

View File

@ -1,6 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, watch, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
@ -9,17 +8,24 @@ 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 inboxes = useMapGetter('inboxes/getInboxes');
const templates = useMapGetter('captainNotificationTemplates/getRecords');
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
const selectedInboxId = ref(null);
const editingId = ref(null);
const showNewForm = ref(false);
// Inboxes com Captain assistant
const captainInboxes = computed(() =>
(inboxes.value || []).filter(i => i.captain_assistant_id)
);
const hasInboxes = computed(() => captainInboxes.value.length > 0);
// Formulários
const emptyForm = () => ({
label: '',
content: '',
@ -39,12 +45,20 @@ const VARIABLES = [
'{{unit_name}}',
];
// Carregamento
onMounted(async () => {
if (unitId.value) {
await store.dispatch('captainNotificationTemplates/fetch', unitId.value);
await store.dispatch('inboxes/get');
});
watch(selectedInboxId, async id => {
if (id) {
await store.dispatch('captainNotificationTemplates/fetch', id);
showNewForm.value = false;
editingId.value = null;
}
});
// Novo template
const openNewForm = () => {
newForm.value = emptyForm();
showNewForm.value = true;
@ -59,8 +73,8 @@ const saveNew = async () => {
if (!newForm.value.label || !newForm.value.content) return;
try {
await store.dispatch('captainNotificationTemplates/create', {
unitId: unitId.value,
...newForm.value,
inboxId: selectedInboxId.value,
payload: newForm.value,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
showNewForm.value = false;
@ -70,6 +84,7 @@ const saveNew = async () => {
}
};
// Edição
const startEdit = template => {
editingId.value = template.id;
editForm.value = { ...template };
@ -83,9 +98,9 @@ const cancelEdit = () => {
const saveEdit = async () => {
try {
await store.dispatch('captainNotificationTemplates/update', {
unitId: unitId.value,
inboxId: selectedInboxId.value,
id: editingId.value,
...editForm.value,
payload: editForm.value,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
editingId.value = null;
@ -94,22 +109,24 @@ const saveEdit = async () => {
}
};
// Toggle ativo
const toggleActive = async template => {
try {
await store.dispatch('captainNotificationTemplates/update', {
unitId: unitId.value,
inboxId: selectedInboxId.value,
id: template.id,
active: !template.active,
payload: { active: !template.active },
});
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
}
};
// Exclusão
const deleteTemplate = async template => {
try {
await store.dispatch('captainNotificationTemplates/delete', {
unitId: unitId.value,
inboxId: selectedInboxId.value,
id: template.id,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
@ -118,6 +135,7 @@ const deleteTemplate = async template => {
}
};
// Variáveis
const insertVariable = (variable, target) => {
if (target === 'new') {
newForm.value.content += variable;
@ -144,107 +162,221 @@ const timingDisplay = template =>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
:description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')"
/>
>
<template #actions>
<Button
v-if="selectedInboxId && !showNewForm"
icon="i-lucide-plus"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
@click="openNewForm"
/>
</template>
</BaseSettingsHeader>
</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 class="flex flex-col gap-6 px-6 pb-8">
<!-- Seletor de inbox -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.INBOX_LABEL') }}
</label>
<div v-if="!hasInboxes" class="text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.NO_CAPTAIN_INBOXES') }}
</div>
<div v-else class="flex flex-wrap gap-2">
<button
v-for="inbox in captainInboxes"
:key="inbox.id"
class="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm transition-colors"
:class="
selectedInboxId === inbox.id
? 'border-w-500 bg-w-50 text-w-700 font-medium'
: 'border-n-weak text-n-slate-11 hover:border-n-300'
"
@click="selectedInboxId = inbox.id"
>
<span class="i-lucide-message-circle w-4 h-4" />
{{ inbox.name }}
</button>
</div>
</div>
<!-- Conteúdo: aparece após selecionar inbox -->
<div v-if="selectedInboxId" class="flex flex-col gap-3">
<!-- Template list -->
<div
v-if="editingId !== template.id"
class="flex items-start justify-between gap-3"
v-for="template in templates"
:key="template.id"
class="rounded-lg border border-n-75 bg-white p-4"
>
<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>
<!-- 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-slate-12">{{
template.label
}}</span>
<span class="text-sm text-n-slate-11 whitespace-pre-line">{{
template.content
}}</span>
<span class="text-xs text-n-slate-10 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-n-teal-2 text-n-teal-11'
: 'bg-n-slate-3 text-n-slate-11'
"
@click="toggleActive(template)"
>
{{
template.active
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
}}
</button>
<button
class="text-n-slate-10 hover:text-n-slate-12"
@click="startEdit(template)"
>
<span class="i-lucide-pencil w-4 h-4" />
</button>
<button
class="text-n-ruby-9 hover:text-n-ruby-11"
@click="deleteTemplate(template)"
>
<span class="i-lucide-trash-2 w-4 h-4" />
</button>
</div>
</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'
<!-- Edit mode -->
<div v-else class="flex flex-col gap-3">
<input
v-model="editForm.label"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
"
@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>
class="w-full rounded border border-n-weak 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-weak 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-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
@click="insertVariable(v, 'edit')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-n-slate-11">{{
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-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="editForm.timing_direction"
class="rounded border border-n-weak 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-slate-11">{{
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.isSaving"
@click="saveEdit"
/>
</div>
</div>
</div>
<!-- Edit mode -->
<div v-else class="flex flex-col gap-3">
<!-- 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="editForm.label"
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"
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/>
<textarea
v-model="editForm.content"
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"
class="w-full rounded border border-n-weak 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')"
class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
@click="insertVariable(v, 'new')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm">
<span class="text-n-600">{{
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="editForm.timing_minutes"
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"
class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-600">{{
<span class="text-n-slate-11">{{
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"
v-model="newForm.timing_direction"
class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
@ -253,7 +385,7 @@ const timingDisplay = template =>
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-600">{{
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
@ -261,100 +393,52 @@ const timingDisplay = template =>
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelEdit"
@click="cancelNew"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isUpdating"
@click="saveEdit"
:is-loading="uiFlags.isSaving"
@click="saveNew"
/>
</div>
</div>
<!-- Empty state (sem templates, sem form aberto) -->
<div
v-if="!templates.length && !showNewForm"
class="flex flex-col items-center justify-center gap-4 py-16 text-center"
>
<div
class="size-14 rounded-full bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-bell w-6 h-6 text-n-slate-10" />
</div>
<div class="flex flex-col gap-1">
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.TITLE') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.DESC') }}
</p>
</div>
<Button
icon="i-lucide-plus"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
@click="openNewForm"
/>
</div>
</div>
<!-- New form -->
<!-- Estado inicial: nenhuma inbox selecionada -->
<div
v-if="showNewForm"
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
v-else-if="hasInboxes"
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
>
<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>
<span class="i-lucide-mouse-pointer-click w-8 h-8 text-n-slate-9" />
<p class="mb-0 text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.SELECT_INBOX_HINT') }}
</p>
</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>

View File

@ -1,97 +1,77 @@
import CaptainNotificationTemplatesAPI from 'dashboard/api/captain/notificationTemplates';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import notificationTemplatesAPI from '../../api/captain/notificationTemplates';
const state = {
templates: [],
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
isSaving: false,
},
};
const getters = {
getTemplates: $state => $state.templates,
getRecords: $state => $state.records,
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) => {
async fetch({ commit }, inboxId) {
commit('SET_UI_FLAG', { isFetching: true });
try {
const { data } =
await CaptainNotificationTemplatesAPI.getTemplates(unitId);
commit('SET_TEMPLATES', data);
} catch (error) {
throwErrorMessage(error);
const { data } = await notificationTemplatesAPI.getAll(inboxId);
commit('SET_RECORDS', data);
} finally {
commit('SET_UI_FLAG', { isFetching: false });
}
},
create: async ({ commit }, { unitId, ...templateData }) => {
commit('SET_UI_FLAG', { isCreating: true });
async create({ commit }, { inboxId, payload }) {
commit('SET_UI_FLAG', { isSaving: true });
try {
const { data } = await CaptainNotificationTemplatesAPI.createTemplate(
unitId,
templateData
);
commit('ADD_TEMPLATE', data);
const { data } = await notificationTemplatesAPI.create(inboxId, payload);
commit('ADD_RECORD', data);
return data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit('SET_UI_FLAG', { isCreating: false });
commit('SET_UI_FLAG', { isSaving: false });
}
},
update: async ({ commit }, { unitId, id, ...templateData }) => {
commit('SET_UI_FLAG', { isUpdating: true });
async update({ commit }, { inboxId, id, payload }) {
commit('SET_UI_FLAG', { isSaving: true });
try {
const { data } = await CaptainNotificationTemplatesAPI.updateTemplate(
unitId,
const { data } = await notificationTemplatesAPI.update(
inboxId,
id,
templateData
payload
);
commit('UPDATE_TEMPLATE', data);
commit('UPDATE_RECORD', data);
return data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit('SET_UI_FLAG', { isUpdating: false });
commit('SET_UI_FLAG', { isSaving: 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 });
}
async delete({ commit }, { inboxId, id }) {
await notificationTemplatesAPI.delete(inboxId, id);
commit('DELETE_RECORD', id);
},
};
const mutations = {
SET_RECORDS($state, records) {
$state.records = records;
},
ADD_RECORD($state, record) {
$state.records.push(record);
},
UPDATE_RECORD($state, record) {
const idx = $state.records.findIndex(r => r.id === record.id);
if (idx !== -1) $state.records.splice(idx, 1, record);
},
DELETE_RECORD($state, id) {
$state.records = $state.records.filter(r => r.id !== id);
},
SET_UI_FLAG($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
},
};
@ -99,6 +79,6 @@ export default {
namespaced: true,
state,
getters,
mutations,
actions,
mutations,
};

View File

@ -90,9 +90,7 @@ Rails.application.routes.draw do
post :label_suggestion
post :follow_up
end
resources :units do
resources :notification_templates, only: [:index, :create, :update, :destroy]
end
resources :units
namespace :reports do
resource :operational, only: [:show], controller: 'reports/operational'
resources :insights, only: [:index, :show] do
@ -242,6 +240,8 @@ Rails.application.routes.draw do
post :sync_templates, on: :member
get :health, on: :member
post :on_whatsapp, on: :member
resources :notification_templates, only: [:index, :create, :update, :destroy],
module: 'captain'
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member

View File

@ -0,0 +1,26 @@
class ChangeNotificationTemplatesToInbox < ActiveRecord::Migration[7.1]
def up
remove_index :captain_notification_templates,
column: %i[captain_unit_id active],
name: 'idx_notif_templates_unit_active',
if_exists: true
remove_column :captain_notification_templates, :captain_unit_id, :bigint
add_column :captain_notification_templates, :inbox_id, :bigint
add_foreign_key :captain_notification_templates, :inboxes, column: :inbox_id
add_index :captain_notification_templates, %i[inbox_id active],
name: 'idx_notif_templates_inbox_active'
end
def down
remove_index :captain_notification_templates,
name: 'idx_notif_templates_inbox_active',
if_exists: true
remove_foreign_key :captain_notification_templates, :inboxes
remove_column :captain_notification_templates, :inbox_id, :bigint
add_column :captain_notification_templates, :captain_unit_id, :bigint
add_index :captain_notification_templates, %i[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_03_01_120000) do
ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -535,7 +535,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
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
@ -544,8 +543,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
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"
t.bigint "inbox_id", null: false
t.index ["inbox_id", "active"], name: "idx_notif_templates_inbox_active"
end
create_table "captain_pix_charges", force: :cascade do |t|
@ -1978,7 +1977,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_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_notification_templates", "inboxes"
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

@ -1,23 +1,27 @@
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_inbox
before_action :set_template, only: [:update, :destroy]
def index
@templates = @unit.notification_templates.ordered
render json: @templates
templates = @inbox.captain_notification_templates.ordered
render json: templates
end
def create
@template = @unit.notification_templates.new(template_params)
@template.save!
render json: @template, status: :created
template = @inbox.captain_notification_templates.new(template_params)
if template.save
render json: template, status: :created
else
render json: { error: template.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def update
@template.update!(template_params)
render json: @template
if @template.update(template_params)
render json: @template
else
render json: { error: @template.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
@ -27,26 +31,15 @@ class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Acc
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
def set_inbox
@inbox = current_account.inboxes.find(params[:inbox_id])
end
def set_template
@template = @unit.notification_templates.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Template não encontrado' }, status: :not_found
@template = @inbox.captain_notification_templates.find(params[:id])
end
def template_params
params.require(:notification_template).permit(
:label,
:content,
:timing_minutes,
:timing_direction,
:active,
:position
)
params.require(:notification_template).permit(:label, :content, :timing_minutes, :timing_direction, :active, :position)
end
end

View File

@ -20,12 +20,13 @@ class Captain::Notifications::NotificationScannerJob < ApplicationJob
window_end = target_time + WINDOW_MINUTES.minutes
Captain::Reservation
.where(captain_unit_id: template.captain_unit_id)
.joins(:conversation)
.where(conversations: { inbox_id: template.inbox_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)",
"NOT (captain_reservations.metadata->'notified_templates' @> ?::jsonb)",
"[#{template.id}]"
)
end

View File

@ -11,21 +11,20 @@
# timing_minutes :integer default(10), not null
# created_at :datetime not null
# updated_at :datetime not null
# captain_unit_id :bigint not null
# inbox_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)
# idx_notif_templates_inbox_active (inbox_id,active)
#
# Foreign Keys
#
# fk_rails_... (captain_unit_id => captain_units.id)
# fk_rails_... (inbox_id => inboxes.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
belongs_to :inbox, inverse_of: :captain_notification_templates
enum timing_direction: { before: 0, after: 1 }
@ -33,9 +32,9 @@ class Captain::NotificationTemplate < ApplicationRecord
validates :content, presence: true
validates :timing_minutes, presence: true, numericality: { greater_than: 0 }
validates :timing_direction, presence: true
validates :captain_unit_id, presence: true
validates :inbox_id, presence: true
scope :active, -> { where(active: true) }
scope :ordered, -> { order(:position, :id) }
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
scope :for_inbox, ->(inbox_id) { where(inbox_id: inbox_id) }
end

View File

@ -54,8 +54,6 @@ 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