Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-10-13 11:50:25 +05:30
committed by GitHub
38 changed files with 773 additions and 30 deletions
+1 -1
View File
@@ -644,7 +644,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.0)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
import sessionStorage from 'shared/helpers/sessionStorage';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
@@ -13,7 +15,8 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
const { currentAccount } = useAccount();
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
const {
captainEnabled,
captainLimits,
@@ -24,6 +27,12 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -61,11 +70,45 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
store.dispatch('accounts/subscription');
await store.dispatch('accounts/subscription');
fetchLimits();
}
};
const handleBillingPageLogic = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Check if we've already attempted a refresh for billing setup
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
// If cloud user, fetch account details first
await fetchAccountDetails();
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
if (!billingRefreshAttempted) {
isWaitingForBilling.value = true;
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
setTimeout(() => {
window.location.reload();
}, 5000);
} else {
// We've already tried refreshing, so just show the no billing message
// Clear the flag for future visits
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
} else {
// Billing plan found, clear any existing refresh flag
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -76,14 +119,18 @@ const onToggleChatWindow = () => {
}
};
onMounted(fetchAccountDetails);
onMounted(handleBillingPageLogic);
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
:no-records-found="!hasABillingPlan"
:is-loading="uiFlags.isFetchingItem || isWaitingForBilling"
:loading-message="
isWaitingForBilling
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -96,11 +96,12 @@ export default {
return parse(this.toTime, 'hh:mm a', new Date());
},
totalHours() {
if (this.timeSlot.openAllDay) {
return 24;
}
const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
return totalHours;
if (this.timeSlot.openAllDay) return '24h';
const totalMinutes = differenceInMinutes(this.toDate, this.fromDate);
const [h, m] = [Math.floor(totalMinutes / 60), totalMinutes % 60];
return [h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '0m';
},
hasError() {
return !this.timeSlot.valid;
@@ -211,7 +212,7 @@ export default {
v-if="isDayEnabled && !hasError"
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
>
{{ totalHours }} {{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
{{ totalHours }}
</span>
</div>
</div>
@@ -53,6 +53,7 @@ export const generateTimeSlots = (step = 15) => {
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
will be generated by adding `step` minutes to the previous one.
The list is generated by starting with a random day and adding step minutes till end of the same day.
Always includes 11:59 PM as the final slot to complete the day.
*/
const date = new Date(1970, 1, 1);
const slots = [];
@@ -66,6 +67,13 @@ export const generateTimeSlots = (step = 15) => {
);
date.setMinutes(date.getMinutes() + step);
}
// Always add 11:59 PM as the final slot if it's not already included
const lastSlot = '11:59 PM';
if (!slots.includes(lastSlot)) {
slots.push(lastSlot);
}
return slots;
};
@@ -7,10 +7,19 @@ import {
} from '../businessHour';
describe('#generateTimeSlots', () => {
it('returns correct number of time slots', () => {
expect(generateTimeSlots(15).length).toStrictEqual((60 / 15) * 24);
it('returns correct number of time slots for 15-minute intervals', () => {
const slots = generateTimeSlots(15);
// 24 hours * 4 slots per hour + 1 for 11:59 PM = 97 slots
expect(slots.length).toStrictEqual(97);
});
it('returns correct time slots', () => {
it('returns correct number of time slots for 30-minute intervals', () => {
const slots = generateTimeSlots(30);
// 24 hours * 2 slots per hour + 1 for 11:59 PM = 49 slots
expect(slots.length).toStrictEqual(49);
});
it('returns correct time slots for 4-hour intervals', () => {
expect(generateTimeSlots(240)).toStrictEqual([
'12:00 AM',
'04:00 AM',
@@ -18,8 +27,51 @@ describe('#generateTimeSlots', () => {
'12:00 PM',
'04:00 PM',
'08:00 PM',
'11:59 PM',
]);
});
it('always starts with 12:00 AM', () => {
expect(generateTimeSlots(15)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(30)[0]).toStrictEqual('12:00 AM');
expect(generateTimeSlots(60)[0]).toStrictEqual('12:00 AM');
});
it('always ends with 11:59 PM', () => {
const slots15 = generateTimeSlots(15);
const slots30 = generateTimeSlots(30);
const slots60 = generateTimeSlots(60);
expect(slots15[slots15.length - 1]).toStrictEqual('11:59 PM');
expect(slots30[slots30.length - 1]).toStrictEqual('11:59 PM');
expect(slots60[slots60.length - 1]).toStrictEqual('11:59 PM');
});
it('includes 11:59 PM even when it would not be in regular intervals', () => {
const slots = generateTimeSlots(30);
expect(slots).toContain('11:59 PM');
expect(slots).toContain('11:30 PM'); // Regular interval
});
it('does not duplicate 11:59 PM if it already exists in regular intervals', () => {
// Test with a step that would naturally include 11:59 PM
const slots = generateTimeSlots(1); // 1-minute intervals
const count11_59 = slots.filter(slot => slot === '11:59 PM').length;
expect(count11_59).toStrictEqual(1);
});
it('generates correct time format', () => {
const slots = generateTimeSlots(60);
expect(slots).toContain('01:00 AM');
expect(slots).toContain('12:00 PM');
expect(slots).toContain('01:00 PM');
expect(slots).toContain('11:00 PM');
});
it('handles edge case with very large step', () => {
const slots = generateTimeSlots(1440); // 24 hours
expect(slots).toStrictEqual(['12:00 AM', '11:59 PM']);
});
});
describe('#getTime', () => {
@@ -154,7 +154,10 @@ const equalTo = (filterValue, conversationValue) => {
* It only works with string values and returns false for non-string types.
*/
const contains = (filterValue, conversationValue) => {
if (typeof conversationValue === 'string') {
if (
typeof conversationValue === 'string' &&
typeof filterValue === 'string'
) {
return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
}
return false;
@@ -190,10 +193,8 @@ const compareDates = (conversationValue, filterValue, compareFn) => {
const matchesCondition = (conversationValue, filter) => {
const { filter_operator: filterOperator, values } = filter;
// Handle null/undefined values
if (conversationValue === null || conversationValue === undefined) {
return filterOperator === 'is_not_present';
}
const isNullish =
conversationValue === null || conversationValue === undefined;
const filterValue = Array.isArray(values)
? values.map(resolveValue)
@@ -213,10 +214,10 @@ const matchesCondition = (conversationValue, filter) => {
return !contains(filterValue, conversationValue);
case 'is_present':
return true; // We already handled null/undefined above
return !isNullish;
case 'is_not_present':
return false; // We already handled null/undefined above
return isNullish;
case 'is_greater_than':
return compareDates(conversationValue, filterValue, (a, b) => a > b);
@@ -225,6 +226,10 @@ const matchesCondition = (conversationValue, filter) => {
return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': {
if (isNullish) {
return false;
}
const today = new Date();
const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
const targetDate = new Date(today.getTime() - daysInMilliseconds);
@@ -192,6 +192,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with equal_to operator when assignee is null', () => {
const conversation = { meta: { assignee: null } };
const filters = [
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: { id: 1, name: 'John Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with not_equal_to operator when assignee is null', () => {
const conversation = { meta: { assignee: null } };
const filters = [
{
attribute_key: 'assignee_id',
filter_operator: 'not_equal_to',
values: { id: 1, name: 'John Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with is_not_present operator for assignee_id', () => {
const conversation = { meta: { assignee: null } };
const filters = [
@@ -285,6 +311,58 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should not match contains operator when display_id is null', () => {
const conversation = { id: null };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'contains',
values: '234',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should not match contains operator when filter value is null', () => {
const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'contains',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match does_not_contain operator when display_id is null', () => {
const conversation = { id: null };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'does_not_contain',
values: '234',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match does_not_contain operator when filter value is null', () => {
const conversation = { id: '12345' };
const filters = [
{
attribute_key: 'display_id',
filter_operator: 'does_not_contain',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with does_not_contain operator when value is not present', () => {
const conversation = { id: '12345' };
const filters = [
+3
View File
@@ -21,6 +21,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# company_id :bigint
#
# Indexes
#
@@ -28,6 +29,7 @@
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
@@ -244,3 +246,4 @@ class Contact < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end
Contact.include_mod_with('Concerns::Contact')
+6 -1
View File
@@ -39,7 +39,7 @@
#
class Message < ApplicationRecord
searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
searchkick callbacks: false if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
@@ -135,6 +135,7 @@ class Message < ApplicationRecord
after_create_commit :execute_after_create_commit_callbacks
after_update_commit :dispatch_update_event
after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
def channel_token
@token ||= inbox.channel.try(:page_access_token)
@@ -436,6 +437,10 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
def reindex_for_search
reindex(mode: :async)
end
end
Message.prepend_mod_with('Message')
+1 -1
View File
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
+1 -1
View File
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
@@ -47,6 +47,15 @@ module Whatsapp::IncomingMessageServiceHelpers
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
end
def argentina_phone_number?(phone_number)
phone_number.match(/^54/)
end
def normalised_argentina_mobil_number(phone_number)
# Remove 9 before country code
phone_number.sub(/^549/, '54')
end
def processed_waid(waid)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
end
@@ -0,0 +1,18 @@
# Handles Argentina phone number normalization
#
# Argentina phone numbers can appear with or without "9" after country code
# This normalizer removes the "9" when present to create consistent format: 54 + area + number
class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
def normalize(waid)
return waid unless handles_country?(waid)
# Remove "9" after country code if present (549 → 54)
waid.sub(/^549/, '54')
end
private
def country_code_pattern
/^54/
end
end
@@ -1,5 +1,5 @@
# Service to handle phone number normalization for WhatsApp messages
# Currently supports Brazil phone number format variations
# Currently supports Brazil and Argentina phone number format variations
# Designed to be extensible for additional countries in future PRs
#
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
@@ -34,6 +34,7 @@ class Whatsapp::PhoneNumberNormalizationService
end
NORMALIZERS = [
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer
Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
].freeze
end
+3
View File
@@ -76,6 +76,9 @@ en:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
companies:
domain:
invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
+1
View File
@@ -153,6 +153,7 @@ Rails.application.routes.draw do
end
end
resources :companies, only: [:index, :show, :create, :update, :destroy]
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
collection do
get :active
@@ -0,0 +1,14 @@
class CreateCompanies < ActiveRecord::Migration[7.1]
def change
create_table :companies do |t|
t.string :name, null: false
t.string :domain
t.text :description
t.references :account, null: false
t.timestamps
end
add_index :companies, [:name, :account_id]
add_index :companies, [:domain, :account_id]
end
end
@@ -0,0 +1,5 @@
class AddCompanyToContacts < ActiveRecord::Migration[7.1]
def change
add_reference :contacts, :company, null: true
end
end
+14
View File
@@ -570,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
create_table "companies", force: :cascade do |t|
t.string "name", null: false
t.string "domain"
t.text "description"
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_companies_on_account_id"
t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
end
create_table "contact_inboxes", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
@@ -602,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
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 ["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))"
@@ -609,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
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 ["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
@@ -0,0 +1,40 @@
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :check_authorization
before_action :fetch_company, only: [:show, :update, :destroy]
def index
@companies = Current.account.companies.ordered_by_name
end
def show; end
def create
@company = Current.account.companies.build(company_params)
@company.save!
end
def update
@company.update!(company_params)
end
def destroy
@company.destroy!
head :ok
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
authorize(Company)
end
def fetch_company
@company = Current.account.companies.find(params[:id])
end
def company_params
params.require(:company).permit(:name, :domain, :description, :avatar)
end
end
+33
View File
@@ -0,0 +1,33 @@
# == Schema Information
#
# Table name: companies
#
# id :bigint not null, primary key
# description :text
# domain :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_companies_on_account_id (account_id)
# index_companies_on_domain_and_account_id (domain,account_id)
# index_companies_on_name_and_account_id (name,account_id)
#
class Company < ApplicationRecord
include Avatarable
validates :account_id, presence: true
validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
validates :domain, allow_blank: true, format: {
with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
message: I18n.t('errors.companies.domain.invalid')
}
validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
belongs_to :account
has_many :contacts, dependent: :nullify
scope :ordered_by_name, -> { order(:name) }
end
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -0,0 +1,6 @@
module Enterprise::Concerns::Contact
extend ActiveSupport::Concern
included do
belongs_to :company, optional: true
end
end
+21
View File
@@ -0,0 +1,21 @@
class CompanyPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
true
end
def update?
true
end
def destroy?
@account_user.administrator?
end
end
@@ -0,0 +1,7 @@
json.id company.id
json.name company.name
json.domain company.domain
json.description company.description
json.avatar_url company.avatar_url
json.created_at company.created_at
json.updated_at company.updated_at
@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end
@@ -0,0 +1,5 @@
json.payload do
json.array! @companies do |company|
json.partial! 'company', company: company
end
end
@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end
@@ -0,0 +1,3 @@
json.payload do
json.partial! 'company', company: @company
end
@@ -70,7 +70,9 @@ module Integrations::Slack::SlackMessageHelper
case attachment[:filetype]
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
:image
when 'pdf'
when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
:video
else
:file
end
end
+2
View File
@@ -6,6 +6,8 @@ module Limits
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
COMPANY_NAME_LENGTH_LIMIT = 100
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
MAX_CUSTOM_FILTERS_PER_USER = 1000
def self.conversation_message_per_minute_limit
@@ -0,0 +1,141 @@
require 'rails_helper'
RSpec.describe 'Companies API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/companies' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/companies"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:company1) { create(:company, name: 'Company 1', account: account) }
let!(:company2) { create(:company, account: account) }
it 'returns all companies' do
get "/api/v1/accounts/#{account.id}/companies",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].size).to eq(2)
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'returns the company' do
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq(company.name)
expect(response_body['payload']['id']).to eq(company.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/companies' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) do
{
company: {
name: 'New Company',
domain: 'newcompany.com',
description: 'A new company'
}
}
end
it 'creates a new company' do
expect do
post "/api/v1/accounts/#{account.id}/companies",
params: valid_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(1)
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('New Company')
expect(response_body['payload']['domain']).to eq('newcompany.com')
end
it 'returns error for invalid params' do
invalid_params = { company: { name: '' } }
post "/api/v1/accounts/#{account.id}/companies",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
let(:update_params) do
{
company: {
name: 'Updated Company Name',
domain: 'updated.com'
}
}
end
it 'updates the company' do
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
params: update_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload']['name']).to eq('Updated Company Name')
expect(response_body['payload']['domain']).to eq('updated.com')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
context 'when it is an authenticated administrator' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, account: account) }
it 'deletes the company' do
company
expect do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Company, :count).by(-1)
expect(response).to have_http_status(:ok)
end
end
context 'when it is a regular agent' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:company) { create(:company, account: account) }
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end
+38
View File
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Company, type: :model do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
describe 'domain validation' do
it { is_expected.to allow_value('example.com').for(:domain) }
it { is_expected.to allow_value('sub.example.com').for(:domain) }
it { is_expected.to allow_value('').for(:domain) }
it { is_expected.to allow_value(nil).for(:domain) }
it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
it { is_expected.not_to allow_value('.example.com').for(:domain) }
end
end
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:contacts).dependent(:nullify) }
end
describe 'scopes' do
let(:account) { create(:account) }
let!(:company_b) { create(:company, name: 'B Company', account: account) }
let!(:company_a) { create(:company, name: 'A Company', account: account) }
let!(:company_c) { create(:company, name: 'C Company', account: account) }
describe '.ordered_by_name' do
it 'orders companies by name alphabetically' do
companies = described_class.where(account: account).ordered_by_name
expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
end
end
end
end
@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe CompanyPolicy, type: :policy do
subject(:company_policy) { described_class }
let(:account) { create(:account) }
let(:administrator) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account) }
let(:company) { create(:company, account: account) }
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :create?, :update? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).to permit(agent_context, company) }
end
end
permissions :destroy? do
context 'when administrator' do
it { expect(company_policy).to permit(administrator_context, company) }
end
context 'when agent' do
it { expect(company_policy).not_to permit(agent_context, company) }
end
end
end
+20
View File
@@ -0,0 +1,20 @@
FactoryBot.define do
factory :company do
sequence(:name) { |n| "Company #{n}" }
sequence(:domain) { |n| "company#{n}.com" }
description { 'A sample company description' }
account
trait :without_domain do
domain { nil }
end
trait :with_avatar do
avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
end
trait :with_long_description do
description { 'A' * 500 }
end
end
end
@@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.count).to eql(messages_count)
end
it 'handles different file types correctly' do
expect(hook).not_to be_nil
video_attachment_params = message_with_attachments.deep_dup
video_attachment_params[:event][:files][0][:filetype] = 'mp4'
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
builder = described_class.new(video_attachment_params)
allow(builder).to receive(:sender).and_return(nil)
expect { builder.perform }.not_to raise_error
expect(conversation.messages.last.attachments).to be_any
end
end
context 'when link shared' do
+56
View File
@@ -4,6 +4,12 @@ require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
before do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
end
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
@@ -678,4 +684,54 @@ RSpec.describe Message do
end
end
end
describe '#reindex_for_search callback' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search_indexing')
end
context 'when message should be indexed' do
it 'calls reindex_for_search for incoming message on create' do
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).to receive(:reindex_for_search)
message.save!
end
it 'calls reindex_for_search for outgoing message on update' do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
message = create(:message, conversation: conversation, account: account, message_type: :outgoing)
expect(message).to receive(:reindex_for_search).and_return(true)
message.update!(content: 'Updated content')
end
end
context 'when message should not be indexed' do
it 'does not call reindex_for_search for activity message' do
message = build(:message, conversation: conversation, account: account, message_type: :activity)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search for unpaid account on cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features('advanced_search_indexing')
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search when advanced search is not allowed' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
end
end
end
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
end
end
describe 'When the incoming waid is an Argentine number with 9 after country code' do
let(:wa_id) { '5491123456789' }
it 'creates appropriate conversations, message and contacts if contact does not exist' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Normalized format removes the 9 after country code
normalized_wa_id = '541123456789'
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
# should use the normalized wa_id from existing contact
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
end
end
describe 'When incoming waid is an Argentine number without 9 after country code' do
let(:wa_id) { '541123456789' }
context 'when a contact inbox exists with the same format' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
context 'when a contact inbox does not exist' do
it 'creates contact inbox with the incoming waid' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
end
end
describe 'when message processing is in progress' do
it 'ignores the current message creation request' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],