PhoenixKitCRM.Contacts (PhoenixKitCRM v0.7.0)

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

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

Functions

apply_mirror_resolution(contact, user, map)

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

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

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

create_mirror_user(contact)

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

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

linked_user_uuids()

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

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.

map_by_user_uuids(user_uuids)

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

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