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:
Sojan Jose
2026-09-23 15:40:46 -07:00
committed by GitHub
parent 0466fe91e5
commit cdfaa255dc
54 changed files with 66 additions and 86 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) }