Compare commits

...
10 changed files with 1481 additions and 46 deletions
@@ -4,6 +4,7 @@ import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
@@ -40,6 +41,16 @@ const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const isHomeDashboardEnabled = computed(
() => !!uiSettings.value?.home_dashboard_enabled
);
const unreadInboxCount = useMapGetter('notifications/getUnreadCount');
const unreadInboxCountLabel = computed(() => {
const count = unreadInboxCount.value;
if (!count) return '';
return count < 100 ? String(count) : '99+';
});
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
@@ -212,16 +223,28 @@ const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => {
return [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
},
},
...(isHomeDashboardEnabled.value
? [
{
name: 'Home',
label: t('SIDEBAR.HOME'),
icon: 'i-lucide-gauge',
to: accountScopedRoute('home_dashboard'),
activeOn: ['home_dashboard', 'home_dashboard_admin'],
},
]
: [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
count: 'notifications/getUnreadCount',
},
},
]),
{
name: 'Conversation',
label: t('SIDEBAR.CONVERSATIONS'),
@@ -858,6 +881,20 @@ const menuItems = computed(() => {
:is-collapsed="isEffectivelyCollapsed"
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<RouterLink
v-if="isHomeDashboardEnabled"
:to="accountScopedRoute('inbox_view')"
:title="t('SIDEBAR.INBOX')"
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative text-n-slate-11"
>
<span class="i-lucide-bell size-4" />
<span
v-if="unreadInboxCountLabel"
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
>
{{ unreadInboxCountLabel }}
</span>
</RouterLink>
</div>
</section>
<!-- Resize Handle (desktop only) -->
@@ -1,7 +1,7 @@
{
"INBOX": {
"LIST": {
"TITLE": "My Inbox",
"TITLE": "Notifications",
"DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications",
"404": "There are no active notifications in this group.",
@@ -58,6 +58,10 @@
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
"USE_ACCOUNT_DEFAULT": "Use account default"
},
"HOME_DASHBOARD": {
"TITLE": "Home dashboard",
"NOTE": "Show a Home dashboard with team workload, KPIs, and a Captain prompt as your landing page. Hides the My Inbox sidebar item."
}
},
"MESSAGE_SIGNATURE_SECTION": {
@@ -228,6 +232,128 @@
}
}
},
"HOME_DASHBOARD_PAGE": {
"ROLE_SWITCH": {
"AGENT": "Agent",
"ADMIN": "Admin"
},
"GREETING": {
"MORNING": "Good morning",
"AFTERNOON": "Good afternoon",
"EVENING": "Good evening",
"LATE": "Working late",
"FALLBACK_NAME": "there",
"GREETING_LINE": "{greeting}, ",
"NAME_LINE": "{name}."
},
"TEAM_SUMMARY": {
"PREFIX": "Your team has ",
"OPEN_CONVERSATIONS": "{count} open conversations",
"MID": ", ",
"NEARING_SLA": "{count} nearing SLA",
"AND": ", and ",
"AGENTS_ONLINE": "{count} agents online",
"PERIOD": "."
},
"AGENT_SUMMARY": {
"PREFIX": "You have ",
"OPEN_CONVERSATIONS": "{count} open conversations",
"MID": ", ",
"UNATTENDED": "{count} unattended",
"AND": ", and ",
"NEARING_SLA": "{count} close to SLA breach",
"PERIOD": "."
},
"KPI": {
"OPEN_CONVERSATIONS": "Open conversations",
"AVG_FIRST_RESPONSE": "Avg first response",
"FIRST_RESPONSE": "First response",
"RESOLVED_TODAY": "Resolved today",
"NOTIFICATIONS": "Notifications",
"SLA_BREACHES_TODAY": "SLA breaches today",
"TEAM_CSAT_TODAY": "Team CSAT today",
"SEE_ALL": "See all"
},
"AI": {
"SHORTCUT": "⌘K"
},
"TEAM_WORKLOAD": {
"TITLE": "Team workload",
"SORT_BY": "Sort by",
"HEADERS": {
"AGENT": "Agent",
"WORKLOAD": "Workload",
"UNATTENDED_OPEN": "Unattended / Open",
"FRT": "FRT",
"CSAT": "CSAT"
},
"OFF_SHIFT": "Off shift",
"CAPACITY": "{percent}% capacity",
"YOU": "YOU"
},
"ATTENTION": {
"TITLE": "Needs your attention",
"SUBTITLE": "3 items, surfaced by Captain"
},
"SUGGESTIONS_ADMIN": {
"OVERLOADED": "Who's overloaded right now?",
"HIGH_VOLUME": "What's driving the {count} open conversations today?",
"CSAT_OUTLIERS": "Show CSAT outliers this week",
"TEAM_PERFORMANCE": "Summarize team performance today",
"COACH_AGENT": "Draft a coaching note for {name}",
"QUIET_DAY": "What did the team accomplish today?",
"FIND_PATTERNS": "Trending customer questions"
},
"SUGGESTIONS_AGENT": {
"SUMMARIZE_QUEUE": "Summarize my queue",
"TRIAGE_UNATTENDED": "Help me triage my {count} unattended",
"NEAR_SLA": "Which of my conversations are close to SLA breach?",
"URGENT": "What's most urgent right now?",
"SUMMARIZE_CONVERSATION": "Summarize my conversation with {name}",
"CATCH_UP": "Catch me up on what I missed",
"SLOW_DAY": "What can I work on?"
},
"AI_PLACEHOLDERS_ADMIN": {
"DEFAULT": "Ask Captain about your team…",
"SUMMARIZE": "Summarize the three most urgent tickets…",
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
"WHY_CSAT": "Why did CSAT slip today?",
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
},
"AI_PLACEHOLDERS_AGENT": {
"DEFAULT": "Ask anything about your queue…",
"SUMMARIZE": "Summarize the three most urgent tickets…",
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
"WHY_CSAT": "Why did CSAT slip today?",
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
},
"SORT_OPTIONS_ADMIN": {
"CAPACITY": "Capacity",
"FIRST_RESPONSE": "First response",
"CSAT_TODAY": "CSAT today",
"RESOLVED_TODAY": "Resolved today"
},
"SORT_OPTIONS_AGENT": {
"PRIORITY": "Priority",
"SLA_BREACH": "SLA breach",
"LATEST": "Latest activity",
"OLDEST": "Oldest first",
"CUSTOMER": "Customer name"
},
"TOP_CONVERSATIONS": {
"TITLE": "Top conversations",
"SLA_LEFT": "{time} left",
"EMPTY": "Nothing in your queue.",
"LOADING": "Loading…"
},
"COPILOT_THREAD": {
"BACK": "Back to home",
"SEND": "Send"
},
"RECENT_THREADS": {
"TITLE": "Recent threads"
}
},
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch account",
@@ -5,6 +5,7 @@ import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { routes as homeRoutes } from './home/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
@@ -21,6 +22,7 @@ export default {
component: AppContainer,
children: [
...captainRoutes,
...homeRoutes,
...inboxRoutes,
...conversation.routes,
...settings.routes,
@@ -0,0 +1,192 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import CopilotAgentMessage from 'dashboard/components-next/copilot/CopilotAgentMessage.vue';
import CopilotAssistantMessage from 'dashboard/components-next/copilot/CopilotAssistantMessage.vue';
import CopilotThinkingGroup from 'dashboard/components-next/copilot/CopilotThinkingGroup.vue';
import CopilotLoader from 'dashboard/components-next/copilot/CopilotLoader.vue';
import CopilotInput from 'dashboard/components-next/copilot/CopilotInput.vue';
import ToggleCopilotAssistant from 'dashboard/components-next/copilot/ToggleCopilotAssistant.vue';
const props = defineProps({
threadId: {
type: [String, Number],
required: true,
},
});
const store = useStore();
const router = useRouter();
const { uiSettings, updateUISettings } = useUISettings();
const { accountScopedRoute } = useAccount();
const assistants = useMapGetter('captainAssistants/getRecords');
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](props.threadId)
);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
if (preferredId) {
const found = assistants.value.find(a => a.id === preferredId);
if (found) return found;
}
return assistants.value[0];
});
const setAssistant = async assistant => {
await updateUISettings({
preferred_captain_assistant_id: assistant.id,
});
};
const sendMessage = async message => {
try {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value?.id,
threadId: props.threadId,
message,
});
} catch (error) {
useAlert(error.message);
}
};
const goHome = () => {
router.push(accountScopedRoute('home_dashboard'));
};
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
messages.value.forEach(message => {
if (message.message_type === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
const last = groupedMessages.value[groupedMessages.value.length - 1];
return last?.message_type === 'assistant';
});
const chatContainer = ref(null);
const scrollToBottom = async () => {
await nextTick();
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
};
watch(() => messages.value, scrollToBottom, { deep: true });
onMounted(async () => {
store.dispatch('captainAssistants/get');
try {
const { data } = await CopilotMessagesAPI.get(props.threadId);
const payload = data?.payload || [];
payload.forEach(message => {
store.dispatch('copilotMessages/upsert', message);
});
scrollToBottom();
} catch {
// best-effort fetch; rely on websocket if API errors
}
});
</script>
<template>
<section class="flex flex-col w-full h-full bg-n-surface-1 text-n-slate-12">
<div
class="flex items-center gap-2 px-6 py-3 border-b border-n-weak flex-shrink-0"
>
<button
type="button"
class="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md text-[13px] font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 transition-colors bg-transparent border-0 cursor-pointer"
@click="goHome"
>
<span class="i-lucide-chevron-left size-4" />
{{ $t('HOME_DASHBOARD_PAGE.COPILOT_THREAD.BACK') }}
</button>
</div>
<div
ref="chatContainer"
class="flex-1 min-h-0 w-full overflow-y-auto text-sm leading-6 tracking-tight"
>
<div class="w-full max-w-[820px] mx-auto px-6 py-6">
<div
v-if="groupedMessages.length"
class="space-y-6 flex flex-col w-full"
>
<template v-for="(item, index) in groupedMessages" :key="item.id">
<CopilotAgentMessage
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
conversation-inbox-type=""
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div>
<div
v-else
class="flex items-center justify-center h-full text-sm text-n-slate-10"
>
{{ $t('HOME_DASHBOARD_PAGE.TOP_CONVERSATIONS.LOADING') }}
</div>
</div>
</div>
<div class="w-full flex-shrink-0">
<div class="w-full max-w-[820px] mx-auto px-6 pt-px pb-4">
<div class="flex items-center justify-between gap-2 mb-1">
<ToggleCopilotAssistant
v-if="assistants.length > 1"
:assistants="assistants"
:active-assistant="activeAssistant || {}"
@set-assistant="setAssistant"
/>
<div v-else />
</div>
<CopilotInput
v-if="assistants.length"
class="w-full"
@send="sendMessage"
/>
</div>
</div>
</section>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import { ROLES } from 'dashboard/constants/permissions.js';
import HomeDashboard from './HomeDashboard.vue';
import HomeCopilotThread from './HomeCopilotThread.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/home'),
name: 'home_dashboard',
component: HomeDashboard,
meta: {
permissions: ROLES,
},
},
{
path: frontendURL('accounts/:accountId/home/admin'),
name: 'home_dashboard_admin',
component: HomeDashboard,
meta: {
permissions: ['administrator'],
},
},
{
path: frontendURL('accounts/:accountId/home/copilot/:threadId'),
name: 'home_copilot_thread',
component: HomeCopilotThread,
props: true,
meta: {
permissions: ROLES,
},
},
];
@@ -13,6 +13,7 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
@@ -43,13 +44,16 @@ export default {
AccessToken,
MfaSettingsCard,
BaseSettingsHeader,
SettingsToggleSection,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { uiSettings, isEditorHotKeyEnabled, updateUISettings } =
useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
uiSettings,
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
@@ -103,6 +107,14 @@ export default {
isMfaEnabled() {
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
},
homeDashboardEnabled: {
get() {
return !!this.uiSettings?.home_dashboard_enabled;
},
set(value) {
this.updateUISettings({ home_dashboard_enabled: value });
},
},
},
mounted() {
if (this.currentUserId) {
@@ -248,6 +260,16 @@ export default {
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
<SettingsToggleSection
v-model="homeDashboardEnabled"
class="w-full"
:header="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.TITLE')
"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.NOTE')
"
/>
</div>
</SectionLayout>
<SectionLayout
+3 -1
View File
@@ -39,7 +39,9 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding ? 'onboarding' : 'dashboard';
const homeDashboardEnabled = !!user?.ui_settings?.home_dashboard_enabled;
const landingTarget = homeDashboardEnabled ? 'home' : 'dashboard';
const target = needsOnboarding ? 'onboarding' : landingTarget;
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
+46 -33
View File
@@ -52,6 +52,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.boolean "auto_offline", default: true, null: false
t.bigint "custom_role_id"
t.bigint "agent_capacity_policy_id"
t.integer "status", default: 0, null: false
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
t.index ["account_id"], name: "index_account_users_on_account_id"
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
@@ -71,6 +72,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.jsonb "limits", default: {}
t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0
t.integer "contactable_contacts_count", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status"
@@ -381,11 +383,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.integer "sync_status"
t.datetime "last_synced_at"
t.datetime "last_sync_attempted_at"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
end
create_table "captain_inboxes", force: :cascade do |t|
@@ -611,13 +613,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "contacts_count"
t.integer "contacts_count", default: 0, null: false
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", force: :cascade do |t|
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
t.text "source_id", null: false
@@ -625,14 +627,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.datetime "updated_at", null: false
t.boolean "hmac_verified", default: false
t.string "pubsub_token"
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
end
create_table "contacts", id: :serial, force: :cascade do |t|
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
t.string "name", default: ""
t.string "email"
t.string "phone_number"
@@ -643,25 +645,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.string "identifier"
t.jsonb "custom_attributes", default: {}
t.datetime "last_activity_at", precision: nil
t.integer "contact_type", default: 0
t.boolean "resolved", default: false, null: false
t.integer "contact_type", default: 0, null: false
t.string "middle_name", default: ""
t.string "last_name", default: ""
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved"
t.index ["account_id"], name: "contacts2_account_id_idx"
t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
end
create_table "conversation_participants", force: :cascade do |t|
@@ -689,7 +693,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.datetime "agent_last_seen_at", precision: nil
t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
t.string "identifier"
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id"
@@ -703,6 +707,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.datetime "waiting_since"
t.text "cached_label_list"
t.bigint "assignee_agent_bot_id"
t.bigint "last_message_id"
t.bigint "last_incoming_message_id"
t.bigint "last_non_activity_message_id"
t.text "cached_summary"
t.datetime "cached_summary_at"
t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -714,6 +724,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id"
t.index ["last_message_id"], name: "index_conversations_on_last_message_id"
t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id"
t.index ["priority"], name: "index_conversations_on_priority"
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority"
@@ -780,7 +793,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.string "regex_pattern"
t.string "regex_cue"
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
end
create_table "custom_filters", force: :cascade do |t|
@@ -963,7 +975,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.integer "visibility", default: 0
t.bigint "created_by_id"
t.bigint "updated_by_id"
t.jsonb "actions", default: {}, null: false
t.jsonb "actions", default: "{}", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_macros_on_account_id"
@@ -982,7 +994,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.index ["user_id"], name: "index_mentions_on_user_id"
end
create_table "messages", id: :serial, force: :cascade do |t|
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
t.text "content"
t.integer "account_id", null: false
t.integer "inbox_id", null: false
@@ -1001,18 +1013,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.jsonb "additional_attributes", default: {}
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
t.index ["account_id"], name: "messages2_account_id_idx"
t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
t.index ["conversation_id"], name: "messages2_conversation_id_idx"
t.index ["created_at"], name: "messages2_created_at_idx"
t.index ["inbox_id"], name: "messages2_inbox_id_idx"
t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
t.index ["source_id"], name: "messages2_source_id_idx"
end
create_table "notes", force: :cascade do |t|
@@ -1144,6 +1156,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.float "value_in_business_hours"
t.datetime "event_start_time", precision: nil
t.datetime "event_end_time", precision: nil
t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox"
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
t.index ["account_id"], name: "index_reporting_events_on_account_id"
@@ -1277,7 +1290,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
t.text "message_signature"
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false
t.boolean "otp_required_for_login", default: false, null: false
t.text "otp_backup_codes"
t.index ["email"], name: "index_users_on_email"
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"