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