PhoenixKitCRM.Contacts (PhoenixKitCRM v0.3.3)

Copy Markdown View Source

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.

Summary

Functions

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

Functions

change_contact(contact, attrs \\ %{})

@spec change_contact(PhoenixKitCRM.Schemas.Contact.t(), map()) :: Ecto.Changeset.t()

connect_user(contact, email)

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

count_contacts(opts \\ [])

@spec count_contacts(keyword()) :: non_neg_integer()

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

create_contact(attrs)

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

delete_contact(contact)

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

disconnect_user(contact)

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

find_or_create_user_by_email(email)

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

get_by_user_uuid(user_uuid)

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

get_contact(uuid)

@spec get_contact(UUIDv7.t() | String.t() | nil) ::
  PhoenixKitCRM.Schemas.Contact.t() | nil

list_by_email(email)

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

list_by_uuids(uuids)

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

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

list_contacts(opts \\ [])

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

Options

  • :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

list_duplicate_email_groups()

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

primary_membership(contact)

The contact's primary company membership (or the first), or nil.

restore_contact(contact)

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

search_contacts(query, limit \\ 8, exclude_uuids \\ [])

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

set_primary_company(contact, company_uuid, role, department)

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

trash_contact(contact)

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

update_contact(contact, attrs)

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