feat(companies): make core CRM available to Startups and self-hosted (#15963)
Basic Companies is now available by default for new Community and self-hosted accounts, and included in Chatwoot Cloud Startups and higher. AI enrichment remains in Enterprise. Existing-account enablement will be handled manually, outside this PR. ## Why Company records and contact association are core CRM capabilities, but the previous premium flag and dashboard installation gate kept the core implementation unavailable to Community users. ## What this change does - Moves the existing Companies model, APIs, jobs, services, views, and tests from the Enterprise overlay into core without changing the API contract. - Makes `companies` a standard default feature for new self-hosted accounts. - Includes Companies in the Cloud Startups entitlement (and higher plans), while new Hacker accounts remain gated until billing assigns a plan. - Removes the dashboard's Cloud/Enterprise installation restriction. The account feature flag and API authorization still apply. - Does not backfill existing account flags or persisted installation feature defaults; those will be updated manually during rollout. ## Validation - In an isolated Community preview (`DISABLE_ENTERPRISE=true`), opened the Companies list and detail page, edited a company, and reassigned a contact through the company selector. - In an isolated Enterprise-enabled preview, created a company through the contact selector's Add company modal and saved the contact association. - Checked a new Cloud account through Hacker, Startups, and Hacker entitlement states; Companies was off, on, and off respectively. - Checked that disabling the account feature hides Companies navigation and the API rejects the list request.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Companies::BaseController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
class Api::V1::Accounts::Companies::BaseController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_companies_enabled!
|
||||
before_action :fetch_company
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::BaseController
|
||||
include Sift
|
||||
sort_on :name, type: :string
|
||||
sort_on :domain, type: :string
|
||||
@@ -171,7 +171,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :identifier, :email, :phone_number, :avatar, :blocked, :avatar_url, additional_attributes: {}, custom_attributes: {})
|
||||
permitted_params = params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, :blocked,
|
||||
additional_attributes: {}, custom_attributes: {})
|
||||
return permitted_params unless Current.account.feature_enabled?('companies') && params.key?(:company_id)
|
||||
|
||||
company_id = params[:company_id]
|
||||
permitted_params.merge(company_id: company_id.present? ? Current.account.companies.find(company_id).id : nil)
|
||||
end
|
||||
|
||||
def contact_custom_attributes
|
||||
|
||||
@@ -2,12 +2,10 @@ import { frontendURL } from '../../../helper/URLHelper';
|
||||
import CompaniesIndex from './pages/CompaniesIndex.vue';
|
||||
import CompanyDetailView from './pages/CompanyDetailView.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
|
||||
const commonMeta = {
|
||||
featureFlag: FEATURE_FLAGS.COMPANIES,
|
||||
permissions: ['administrator', 'agent'],
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
export const routes = [
|
||||
|
||||
@@ -2,8 +2,6 @@ class Migration::BackfillCompaniesContactsCountJob < ApplicationJob
|
||||
queue_as :async_database_migration
|
||||
|
||||
def perform
|
||||
return unless ChatwootApp.enterprise?
|
||||
|
||||
Company.find_in_batches(batch_size: 100) do |company_batch|
|
||||
company_batch.each do |company|
|
||||
Company.reset_counters(company.id, :contacts)
|
||||
|
||||
@@ -74,6 +74,7 @@ class Account < ApplicationRecord
|
||||
has_many :canned_responses, dependent: :destroy_async
|
||||
has_many :categories, dependent: :destroy_async, class_name: '::Category'
|
||||
has_many :contacts, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :csat_survey_responses, dependent: :destroy_async
|
||||
has_many :custom_attribute_definitions, dependent: :destroy_async
|
||||
|
||||
52
app/models/concerns/contact_company_association.rb
Normal file
52
app/models/concerns/contact_company_association.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
module ContactCompanyAssociation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :company, optional: true, counter_cache: true
|
||||
|
||||
before_save :sync_company_name_from_company, if: :will_save_change_to_company_id?
|
||||
after_commit :associate_company_from_email, on: [:create, :update], if: :should_associate_company?
|
||||
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
|
||||
|
||||
scope :order_on_company_name, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
sanitize_sql_for_order(
|
||||
"\"contacts\".\"additional_attributes\"->>'company_name' #{direction}
|
||||
NULLS LAST"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_associate_company?
|
||||
email.present? &&
|
||||
company_id.nil? &&
|
||||
saved_change_to_email? &&
|
||||
saved_change_to_email.first.nil? &&
|
||||
account.feature_enabled?('companies')
|
||||
end
|
||||
|
||||
def associate_company_from_email
|
||||
Contacts::CompanyAssociationService.new.associate_company_from_email(self)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
|
||||
end
|
||||
|
||||
def record_company_activity
|
||||
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
|
||||
end
|
||||
|
||||
def sync_company_name_from_company
|
||||
self.additional_attributes ||= {}
|
||||
|
||||
if company_id.present?
|
||||
additional_attributes['company_name'] = company&.name
|
||||
else
|
||||
additional_attributes.delete('company_name')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -67,6 +67,7 @@ class Contact < ApplicationRecord
|
||||
after_update_commit :dispatch_update_event
|
||||
after_destroy_commit :dispatch_destroy_event
|
||||
before_save :sync_contact_attributes
|
||||
include ContactCompanyAssociation
|
||||
|
||||
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
|
||||
|
||||
@@ -86,16 +87,6 @@ class Contact < ApplicationRecord
|
||||
)
|
||||
)
|
||||
}
|
||||
scope :order_on_company_name, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
sanitize_sql_for_order(
|
||||
"\"contacts\".\"additional_attributes\"->>'company_name' #{direction}
|
||||
NULLS LAST"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
scope :order_on_city, lambda { |direction|
|
||||
order(
|
||||
Arel::Nodes::SqlLiteral.new(
|
||||
|
||||
@@ -228,8 +228,7 @@
|
||||
chatwoot_internal: true
|
||||
- name: companies
|
||||
display_name: Companies
|
||||
enabled: false
|
||||
premium: true
|
||||
enabled: true
|
||||
- name: channel_tiktok
|
||||
display_name: TikTok Channel
|
||||
enabled: true
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
module Enterprise::Api::V1::Accounts::ContactsController
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params_with_company_id = super
|
||||
return params_with_company_id unless Current.account.feature_enabled?('companies')
|
||||
return params_with_company_id unless params.key?(:company_id)
|
||||
|
||||
params_with_company_id.merge(company_id: permitted_company_id)
|
||||
end
|
||||
|
||||
def permitted_company_id
|
||||
return nil if params[:company_id].blank?
|
||||
|
||||
Current.account.companies.find(params[:company_id]).id
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,6 @@ module Enterprise::Concerns::Account
|
||||
has_many :conversation_outcomes, dependent: :destroy_async
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
|
||||
@@ -1,52 +1,6 @@
|
||||
module Enterprise::Concerns::Contact
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
belongs_to :company, optional: true, counter_cache: true
|
||||
has_many :campaign_recipients, dependent: :destroy_async
|
||||
|
||||
after_commit :associate_company_from_email,
|
||||
on: [:create, :update],
|
||||
if: :should_associate_company?
|
||||
before_save :sync_company_name_from_company, if: :will_save_change_to_company_id?
|
||||
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def should_associate_company?
|
||||
# Only trigger if:
|
||||
# 1. Contact has an email
|
||||
# 2. Contact doesn't have a company yet
|
||||
# 3. Email was just set/changed
|
||||
# 4. Email was previously nil (first time getting email)
|
||||
# 5. The account has the Companies feature enabled
|
||||
# Feature check is last so unrelated contact updates short-circuit on the
|
||||
# cheap in-memory guards before touching the account (hot message-ingest path).
|
||||
email.present? &&
|
||||
company_id.nil? &&
|
||||
saved_change_to_email? &&
|
||||
saved_change_to_email.first.nil? &&
|
||||
account.feature_enabled?('companies')
|
||||
end
|
||||
|
||||
def associate_company_from_email
|
||||
Contacts::CompanyAssociationService.new.associate_company_from_email(self)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to associate company for contact #{id}: #{e.message}")
|
||||
# Don't fail the contact save if the company association fails
|
||||
end
|
||||
|
||||
def record_company_activity
|
||||
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
|
||||
end
|
||||
|
||||
def sync_company_name_from_company
|
||||
self.additional_attributes ||= {}
|
||||
|
||||
if company_id.present?
|
||||
additional_attributes['company_name'] = company&.name
|
||||
else
|
||||
additional_attributes.delete('company_name')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
whatsapp_embedded_signup_inbox_creation
|
||||
api_and_webhooks
|
||||
data_import
|
||||
companies
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[
|
||||
@@ -30,7 +31,6 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
conversation_required_attributes
|
||||
advanced_assignment
|
||||
custom_tools
|
||||
companies
|
||||
].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
@@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService
|
||||
def valid_feature_list
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
|
||||
%w[inbound_emails api_and_webhooks whatsapp_embedded_signup_inbox_creation]
|
||||
%w[inbound_emails api_and_webhooks whatsapp_embedded_signup_inbox_creation companies]
|
||||
end
|
||||
|
||||
# Account notes functionality removed for now
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Contacts API', type: :request do
|
||||
RSpec.describe 'Contacts company API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
Reference in New Issue
Block a user