@spec primary_membership(PhoenixKitCRM.Schemas.Contact.t()) :: PhoenixKitCRM.Schemas.CompanyMembership.t() | nil
The contact's primary company membership (or the first), or nil.
Context for CRM contacts — CRUD, soft-delete, the (v1 single) company membership, and the optional login-user connection.
The user connection mirrors phoenix_kit_staff's flow but is opt-in: a
contact has no user_uuid until connect_user/2 is called (driven by the
form's "allow login" checkbox). It uses find-or-create — an existing user
by email is linked; if none exists a placeholder is registered (tagged
custom_fields.source = "crm_contact"), which the person can later claim by
registering / signing in with that email.
Applies a per-field conflict resolution — PhoenixKitCRM.Mirror.resolve/4's
%{crm: crm_deltas, user: user_deltas} — atomically: writes crm_deltas
onto contact, 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.
Connects a contact to a login user by email (staff-style find-or-create).
Existing user by email → linked; otherwise a placeholder user is
registered. Atomic: the find-or-create + link run inside one
repo().transaction/1, so a just-registered placeholder is rolled back
automatically if the link fails — no separate compensating delete (the
prior implementation deleted by hand, which orphans the placeholder if
that delete itself fails or the process dies mid-way). No-op-safe to
call on an already-linked contact (re-links).
Same filters as list_contacts/1 (:status/:include_trashed/:search); ignores :limit/:offset.
Creates a fresh person-User from contact (via Mirror.attrs_from/2 —
account_type: "person", first_name/last_name split from
contact.name, email: contact.email), tagged
custom_fields.source = "crm_contact", and links it — atomically: the
created user is rolled back if the link fails.
Permanently deletes a contact (cascades memberships + interactions at
the DB level), keeping every affected list's subscriber_count in
sync.
Disconnects a contact from its login user (unlinks only; never deletes the user).
Finds an existing user by email, or registers a placeholder with no usable
password (tagged custom_fields.source = "crm_contact").
The (at most one) contact linked to a given login user, or nil.
Links contact to an EXISTING person-User by uuid — no find-or-create
(that's connect_user/2, above). Rejects a non-person-account user
with {:error, :not_a_person} (an ALLOWLIST on account_type == "person", matching Companies.connect_user/2's allowlist on
"organization" exactly rather than a denylist on "organization" —
today the two are equivalent since person/organization are the only
values in use, but the allowlist doesn't silently accept a future third
account_type the way a denylist would), and a missing user with
{:error, :user_not_found}. A user already linked to another contact
surfaces as {:error, changeset} via the partial unique index
(idx_crm_contacts_user_uuid) rather than crashing. No-op-safe to call
on an already-linked contact (re-links).
The set of user_uuids currently linked to any contact — used by the
"Link existing…" picker (Task H) to exclude users who are already
someone else's mirror.
Non-trashed contacts holding exactly this email — the drill-down for a list_duplicate_email_groups/0 row.
Contacts for the given uuids (any status) — for comment back-link resolution.
Lists contacts. Excludes trashed by default; preloads the primary company membership (with company) and the linked user.
Groups non-trashed contacts sharing the same email (case-insensitive, via the column's citext type), for the CRM comparison screen's directory-wide duplicate-email report. Only emails held by 2+ contacts; blank/nil emails are never a "duplicate" (many contacts legitimately have none). Ordered by group size, largest first.
%{user_uuid => contact} for the given login users — the batched form of
get_by_user_uuid/1, for table views that would otherwise query once per
row. Users with no linked contact are absent from the map.
The contact's primary company membership (or the first), or nil.
Searches contacts by name/email (case-insensitive) for the parties picker.
Excludes trashed and any uuids in exclude_uuids (e.g. the contact whose page
the interaction is being logged on — they're already the subject).
Sets the contact's primary company membership to the given company, with free-form role + department. v1 manages exactly one company per contact via the form, so this replaces the contact's membership set. A blank/nil company clears it.
Soft-deletes a contact (status → trashed, stashing the prior status).
@spec apply_mirror_resolution( PhoenixKitCRM.Schemas.Contact.t(), PhoenixKit.Users.Auth.User.t(), %{ crm: map(), user: map() } ) :: {:ok, {PhoenixKitCRM.Schemas.Contact.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 contact, 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_contact(PhoenixKitCRM.Schemas.Contact.t(), map()) :: Ecto.Changeset.t()
@spec connect_user(PhoenixKitCRM.Schemas.Contact.t(), String.t()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t(), :existing | :created} | {:error, atom() | Ecto.Changeset.t()}
Connects a contact to a login user by email (staff-style find-or-create).
Existing user by email → linked; otherwise a placeholder user is
registered. Atomic: the find-or-create + link run inside one
repo().transaction/1, so a just-registered placeholder is rolled back
automatically if the link fails — no separate compensating delete (the
prior implementation deleted by hand, which orphans the placeholder if
that delete itself fails or the process dies mid-way). No-op-safe to
call on an already-linked contact (re-links).
@spec count_contacts(keyword()) :: non_neg_integer()
Same filters as list_contacts/1 (:status/:include_trashed/:search); ignores :limit/:offset.
@spec create_contact(map()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, Ecto.Changeset.t()}
@spec create_mirror_user(PhoenixKitCRM.Schemas.Contact.t()) :: {:ok, {PhoenixKitCRM.Schemas.Contact.t(), PhoenixKit.Users.Auth.User.t()}} | {:error, :already_linked | term()}
Creates a fresh person-User from contact (via Mirror.attrs_from/2 —
account_type: "person", first_name/last_name split from
contact.name, email: contact.email), tagged
custom_fields.source = "crm_contact", and links it — atomically: the
created user is rolled back if the link fails.
Rejects {:error, :already_linked} when contact 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, an unrecoverable
random password, linked to nothing).
@spec delete_contact(PhoenixKitCRM.Schemas.Contact.t()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, Ecto.Changeset.t()}
Permanently deletes a contact (cascades memberships + interactions at
the DB level), keeping every affected list's subscriber_count in
sync.
The FK cascade removes ListMember rows entirely, bypassing
Lists.remove_from_list/2's atomic counter decrement — that path only
exists for a live status flip ("subscribed" → "removed"), not a
disappearing row. Without this, deleting a contact who was still
"subscribed" on a list leaves that list's subscriber_count
permanently overcounted (nothing else ever revisits it). Snapshots
which lists the contact was actually "subscribed" on before the
cascade (a "removed" membership was never counted, so it's excluded
— deleting it changes nothing), then recounts exactly those lists —
Lists.recount_list/1, the same repair function used for the
Settings-page "Recount" action — in the same transaction as the
delete itself.
The snapshot query runs inside the transaction, immediately before the
delete, rather than before repo().transaction/1 is even called — doing
it outside would leave a window between the snapshot and the delete where
a concurrent add_contact_to_list/3 could subscribe the contact to a new
list that the snapshot never saw, permanently stranding that list's
counter one over (the exact bug this function exists to fix, just via a
different door).
@spec disconnect_user(PhoenixKitCRM.Schemas.Contact.t()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, Ecto.Changeset.t()}
Disconnects a contact from its login user (unlinks only; never deletes the user).
@spec find_or_create_user_by_email(String.t()) :: {:ok, PhoenixKit.Users.Auth.User.t(), :existing | :created} | {:error, atom() | Ecto.Changeset.t()}
Finds an existing user by email, or registers a placeholder with no usable
password (tagged custom_fields.source = "crm_contact").
@spec get_by_user_uuid(UUIDv7.t() | String.t() | nil) :: PhoenixKitCRM.Schemas.Contact.t() | nil
The (at most one) contact linked to a given login user, or nil.
@spec get_contact(UUIDv7.t() | String.t() | nil) :: PhoenixKitCRM.Schemas.Contact.t() | nil
@spec link_user(PhoenixKitCRM.Schemas.Contact.t(), UUIDv7.t() | String.t()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, :not_a_person | :user_not_found | Ecto.Changeset.t()}
Links contact to an EXISTING person-User by uuid — no find-or-create
(that's connect_user/2, above). Rejects a non-person-account user
with {:error, :not_a_person} (an ALLOWLIST on account_type == "person", matching Companies.connect_user/2's allowlist on
"organization" exactly rather than a denylist on "organization" —
today the two are equivalent since person/organization are the only
values in use, but the allowlist doesn't silently accept a future third
account_type the way a denylist would), and a missing user with
{:error, :user_not_found}. A user already linked to another contact
surfaces as {:error, changeset} via the partial unique index
(idx_crm_contacts_user_uuid) rather than crashing. No-op-safe to call
on an already-linked contact (re-links).
@spec linked_user_uuids() :: MapSet.t()
The set of user_uuids currently linked to any contact — used by the
"Link existing…" picker (Task H) to exclude users who are already
someone else's mirror.
@spec list_by_email(String.t()) :: [PhoenixKitCRM.Schemas.Contact.t()]
Non-trashed contacts holding exactly this email — the drill-down for a list_duplicate_email_groups/0 row.
@spec list_by_uuids([binary()]) :: [PhoenixKitCRM.Schemas.Contact.t()]
Contacts for the given uuids (any status) — for comment back-link resolution.
@spec list_contacts(keyword()) :: [PhoenixKitCRM.Schemas.Contact.t()]
Lists contacts. Excludes trashed by default; preloads the primary company membership (with company) and the linked user.
:status / :include_trashed — see apply_status_scope/2:search — name/email ILIKE match (case-insensitive):limit / :offset — pagination; both no-ops when absent, so this
stays a full unpaginated list for any existing caller not passing them@spec list_duplicate_email_groups() :: [%{email: String.t(), count: pos_integer()}]
Groups non-trashed contacts sharing the same email (case-insensitive, via the column's citext type), for the CRM comparison screen's directory-wide duplicate-email report. Only emails held by 2+ contacts; blank/nil emails are never a "duplicate" (many contacts legitimately have none). Ordered by group size, largest first.
@spec map_by_user_uuids([UUIDv7.t() | String.t()]) :: %{ optional(String.t()) => PhoenixKitCRM.Schemas.Contact.t() }
%{user_uuid => contact} for the given login users — the batched form of
get_by_user_uuid/1, for table views that would otherwise query once per
row. Users with no linked contact are absent from the map.
@spec primary_membership(PhoenixKitCRM.Schemas.Contact.t()) :: PhoenixKitCRM.Schemas.CompanyMembership.t() | nil
The contact's primary company membership (or the first), or nil.
@spec restore_contact(PhoenixKitCRM.Schemas.Contact.t()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, atom() | Ecto.Changeset.t()}
@spec search_contacts(String.t(), pos_integer(), [binary()]) :: [ PhoenixKitCRM.Schemas.Contact.t() ]
Searches contacts by name/email (case-insensitive) for the parties picker.
Excludes trashed and any uuids in exclude_uuids (e.g. the contact whose page
the interaction is being logged on — they're already the subject).
@spec set_primary_company( PhoenixKitCRM.Schemas.Contact.t(), UUIDv7.t() | String.t() | nil, String.t() | nil, String.t() | nil ) :: {:ok, PhoenixKitCRM.Schemas.CompanyMembership.t() | nil} | {:error, Ecto.Changeset.t()}
Sets the contact's primary company membership to the given company, with free-form role + department. v1 manages exactly one company per contact via the form, so this replaces the contact's membership set. A blank/nil company clears it.
@spec trash_contact(PhoenixKitCRM.Schemas.Contact.t()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, atom() | Ecto.Changeset.t()}
Soft-deletes a contact (status → trashed, stashing the prior status).
@spec update_contact(PhoenixKitCRM.Schemas.Contact.t(), map()) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, Ecto.Changeset.t()}