@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.
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. Rolls back a just-created placeholder if the link fails. 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.
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.
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 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. Rolls back a just-created placeholder if the link fails. 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 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 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()}