PhoenixKitCRM.Companies (PhoenixKitCRM v0.7.1)

Copy Markdown View Source

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/2account_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

apply_mirror_resolution(company, user, map)

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

change_company(company, attrs \\ %{})

@spec change_company(PhoenixKitCRM.Schemas.Company.t(), map()) :: Ecto.Changeset.t()

company_options()

@spec company_options() :: [%{value: String.t(), label: String.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.

connect_user(company, user_uuid)

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

count_companies(opts \\ [])

@spec count_companies(keyword()) :: non_neg_integer()

Same filters as list_companies/1 (:status/:include_trashed/:search); ignores :limit/:offset.

create_company(attrs)

@spec create_company(map()) ::
  {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}

create_from_user(user)

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

create_mirror_user(company, opts \\ [])

@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/2account_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.

delete_company(company)

@spec delete_company(PhoenixKitCRM.Schemas.Company.t()) ::
  {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}

Permanently deletes a company (cascades its memberships).

disconnect_user(company)

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

get_by_user_uuid(user_uuid)

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

get_company(uuid)

@spec get_company(UUIDv7.t() | String.t() | nil) ::
  PhoenixKitCRM.Schemas.Company.t() | nil

linked_user_uuids()

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

list_by_uuids(uuids)

@spec list_by_uuids([binary()]) :: [PhoenixKitCRM.Schemas.Company.t()]

Companies for the given uuids (any status) — for comment back-link resolution.

list_companies(opts \\ [])

@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_trashedtrue to include trashed alongside the rest
  • :search — name/email ILIKE match
  • :limit / :offset — pagination; both no-ops when absent

list_memberships(company_uuid)

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

list_unlinked_companies()

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

map_by_user_uuids(user_uuids)

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

restore_company(company)

@spec restore_company(PhoenixKitCRM.Schemas.Company.t()) ::
  {:ok, PhoenixKitCRM.Schemas.Company.t()}
  | {:error, atom() | Ecto.Changeset.t()}

search_companies(query, limit \\ 8)

@spec search_companies(String.t(), pos_integer()) :: [
  PhoenixKitCRM.Schemas.Company.t()
]

Searches companies by name (case-insensitive) for the picker. Excludes trashed.

trash_company(company)

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

update_company(company, attrs)

@spec update_company(PhoenixKitCRM.Schemas.Company.t(), map()) ::
  {:ok, PhoenixKitCRM.Schemas.Company.t()} | {:error, Ecto.Changeset.t()}