Context for CRM companies — CRUD, soft-delete, and search (for the contact form's company picker).
Summary
Functions
Applies a per-field conflict resolution — PhoenixKitCRM.Mirror.resolve/4's
%{crm: crm_deltas, user: user_deltas} — atomically: writes crm_deltas
onto company, user_deltas onto user (via Auth.update_user_profile/2,
the same changeset core's own profile edits use), then links them. All
three in one repo().transaction/1 so a failure at any step leaves
neither side partially rewritten.
Live-untrashed companies as %{value: uuid, label: name} picker options.
Links company to an EXISTING organization-User by uuid. Rejects a
user that isn't account_type: "organization" (Q3) with
{:error, :not_an_organization}, and a missing user with
{:error, :user_not_found}. A user already linked to another company
surfaces as {:error, changeset} via the partial unique index
(idx_crm_companies_user_uuid) rather than crashing. No-op-safe to call
on an already-linked company (re-links).
Same filters as list_companies/1 (:status/:include_trashed/:search); ignores :limit/:offset.
Reverse of create_mirror_user/2: given an organization-User, adopts
an existing UNLINKED company (matched by name first, then email —
mirrors Andi.CRMBridge's adopt-by-email precedent for contacts) or
creates a new one from Mirror.attrs_to_crm/2, then links it — the
create-then-link is wrapped in one transaction, so a connect_user/2
failure (a race against a concurrent caller linking the same user
first, most plausibly) rolls back the just-created company rather than
leaving an orphaned, unlinked row behind.
Creates a fresh organization-User from company (via
Mirror.attrs_from/2 — account_type: "organization",
organization_name: company.name, email: company.email), tagged
custom_fields.source = "crm_company", and links it — atomically: the
created user is rolled back if the link fails (a validation error, a
race — anything).
Permanently deletes a company (cascades its memberships).
Disconnects company from its mirror user (unlinks only; never deletes the user).
The (at most one) company linked to a given login user, or nil.
The set of user_uuids currently linked to any company — used by the
"Link existing…" picker (Task G) to exclude users who are already
someone else's mirror.
Companies for the given uuids (any status) — for comment back-link resolution.
Lists companies. Excludes trashed by default.
Memberships at a company (primary first), each with its contact preloaded. Excludes memberships whose contact is trashed so soft-deleted people don't linger in the roster or the company's interactions rollup.
Non-trashed companies with no mirror user linked — the candidate set for the "Link existing company…" picker (Task I, reverse direction: an organization-user picking a company to adopt).
%{user_uuid => company} for the given login users — the batched form
of get_by_user_uuid/1 (mirrors Contacts.map_by_user_uuids/1), for
table views (Task I's Organizations list) that would otherwise query
once per row. Users with no linked company are absent from the map.
Searches companies by name (case-insensitive) for the picker. Excludes trashed.
Soft-deletes a company (status → trashed, stashing the prior status).
Functions
@spec apply_mirror_resolution( PhoenixKitCRM.Schemas.Company.t(), PhoenixKit.Users.Auth.User.t(), %{ crm: map(), user: map() } ) :: {:ok, {PhoenixKitCRM.Schemas.Company.t(), PhoenixKit.Users.Auth.User.t()}} | {:error, term()}
Applies a per-field conflict resolution — PhoenixKitCRM.Mirror.resolve/4's
%{crm: crm_deltas, user: user_deltas} — atomically: writes crm_deltas
onto company, user_deltas onto user (via Auth.update_user_profile/2,
the same changeset core's own profile edits use), then links them. All
three in one repo().transaction/1 so a failure at any step leaves
neither side partially rewritten.
@spec change_company(PhoenixKitCRM.Schemas.Company.t(), map()) :: Ecto.Changeset.t()
Live-untrashed companies as %{value: uuid, label: name} picker options.
The lazy option source behind the projects hub's Client extension
config_schema select ({PhoenixKitCRM.Companies, :company_options}) —
so linking a client is picking a name, not pasting a uuid. Capped: the
hub always re-adds the STORED value even when it isn't offered, so a
large install degrades to "the current link still shows" rather than an
unbounded select. Degrades to [] when the DB is unreachable — the
panel must render even with CRM's storage down.
@spec connect_user(PhoenixKitCRM.Schemas.Company.t(), UUIDv7.t() | String.t()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, :not_an_organization | :user_not_found | Ecto.Changeset.t()}
Links company to an EXISTING organization-User by uuid. Rejects a
user that isn't account_type: "organization" (Q3) with
{:error, :not_an_organization}, and a missing user with
{:error, :user_not_found}. A user already linked to another company
surfaces as {:error, changeset} via the partial unique index
(idx_crm_companies_user_uuid) rather than crashing. No-op-safe to call
on an already-linked company (re-links).
@spec count_companies(keyword()) :: non_neg_integer()
Same filters as list_companies/1 (:status/:include_trashed/:search); ignores :limit/:offset.
@spec create_company(map()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}
@spec create_from_user(PhoenixKit.Users.Auth.User.t()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, {:already_linked, PhoenixKitCRM.Schemas.Company.t()} | :not_an_organization | term()}
Reverse of create_mirror_user/2: given an organization-User, adopts
an existing UNLINKED company (matched by name first, then email —
mirrors Andi.CRMBridge's adopt-by-email precedent for contacts) or
creates a new one from Mirror.attrs_to_crm/2, then links it — the
create-then-link is wrapped in one transaction, so a connect_user/2
failure (a race against a concurrent caller linking the same user
first, most plausibly) rolls back the just-created company rather than
leaving an orphaned, unlinked row behind.
Rejects {:error, {:already_linked, existing}} when user already has
a mirror company (checked up front, before touching anything) and
{:error, :not_an_organization} for a non-organization user.
@spec create_mirror_user( PhoenixKitCRM.Schemas.Company.t(), keyword() ) :: {:ok, {PhoenixKitCRM.Schemas.Company.t(), PhoenixKit.Users.Auth.User.t()}} | {:error, :already_linked | term()}
Creates a fresh organization-User from company (via
Mirror.attrs_from/2 — account_type: "organization",
organization_name: company.name, email: company.email), tagged
custom_fields.source = "crm_company", and links it — atomically: the
created user is rolled back if the link fails (a validation error, a
race — anything).
Rejects {:error, :already_linked} when company already has a mirror
user: without this guard a second call would silently mint and link a
NEW user, orphaning the previous one (still present, still an
organization account with an unrecoverable random password, linked to
nothing). disconnect_user/1 first, then create_mirror_user/2 again,
if that's genuinely what's wanted.
opts is currently unused — reserved for a future extension point
(e.g. actor attribution for the log line), kept in the signature so
adding one later isn't a breaking change.
@spec delete_company(PhoenixKitCRM.Schemas.Company.t()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}
Permanently deletes a company (cascades its memberships).
@spec disconnect_user(PhoenixKitCRM.Schemas.Company.t()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}
Disconnects company from its mirror user (unlinks only; never deletes the user).
@spec get_by_user_uuid(UUIDv7.t() | String.t() | nil) :: PhoenixKitCRM.Schemas.Company.t() | nil
The (at most one) company linked to a given login user, or nil.
@spec get_company(UUIDv7.t() | String.t() | nil) :: PhoenixKitCRM.Schemas.Company.t() | nil
@spec linked_user_uuids() :: MapSet.t()
The set of user_uuids currently linked to any company — used by the
"Link existing…" picker (Task G) to exclude users who are already
someone else's mirror.
@spec list_by_uuids([binary()]) :: [PhoenixKitCRM.Schemas.Company.t()]
Companies for the given uuids (any status) — for comment back-link resolution.
@spec list_companies(keyword()) :: [PhoenixKitCRM.Schemas.Company.t()]
Lists companies. Excludes trashed by default.
Options
:status—"trashed"for the Trash view, or any specific status:include_trashed—trueto include trashed alongside the rest:search— name/email ILIKE match:limit/:offset— pagination; both no-ops when absent
@spec list_memberships(UUIDv7.t() | String.t() | nil) :: [ PhoenixKitCRM.Schemas.CompanyMembership.t() ]
Memberships at a company (primary first), each with its contact preloaded. Excludes memberships whose contact is trashed so soft-deleted people don't linger in the roster or the company's interactions rollup.
@spec list_unlinked_companies() :: [PhoenixKitCRM.Schemas.Company.t()]
Non-trashed companies with no mirror user linked — the candidate set for the "Link existing company…" picker (Task I, reverse direction: an organization-user picking a company to adopt).
@spec map_by_user_uuids([UUIDv7.t() | String.t()]) :: %{ optional(String.t()) => PhoenixKitCRM.Schemas.Company.t() }
%{user_uuid => company} for the given login users — the batched form
of get_by_user_uuid/1 (mirrors Contacts.map_by_user_uuids/1), for
table views (Task I's Organizations list) that would otherwise query
once per row. Users with no linked company are absent from the map.
@spec restore_company(PhoenixKitCRM.Schemas.Company.t()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, atom() | Ecto.Changeset.t()}
@spec search_companies(String.t(), pos_integer()) :: [ PhoenixKitCRM.Schemas.Company.t() ]
Searches companies by name (case-insensitive) for the picker. Excludes trashed.
@spec trash_company(PhoenixKitCRM.Schemas.Company.t()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, atom() | Ecto.Changeset.t()}
Soft-deletes a company (status → trashed, stashing the prior status).
@spec update_company(PhoenixKitCRM.Schemas.Company.t(), map()) :: {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}