PhoenixKitCRM.Lists (PhoenixKitCRM v0.3.3)

Copy Markdown View Source

Context for CRM contact lists (PhoenixKitCRM.Schemas.ContactList) and their memberships (PhoenixKitCRM.Schemas.ListMember).

Modeled on PhoenixKitCRM.PartyRoles (soft-toggle mutations, actor_uuid audit via PhoenixKitCRM.Activity) with one deliberate divergence: unlike PartyRoles, this context broadcasts over PubSub (PhoenixKitCRM.PubSub, topic crm:lists) after every list/membership mutation — the admin UI shows live subscriber counters, so a live-updating feed is worth the extra moving part here even though PartyRoles didn't need one.

Membership is never hard-deleted: remove_from_list/2,3 flips status to "removed" and stamps unsubscribed_at, same as a list's archive_list/1,2 only flips status rather than deleting the row. This keeps list history (and the idx_crm_list_members_list_email email slot — see the schema moduledoc) intact.

Opt-out/consent live on the contact, not the membership — an opt-out applies across every list the contact belongs to. The Stage-4 send path is expected to check membership status == "subscribed" AND contact.opted_out_at == nil.

subscriber_count is a maintained cache, kept in sync here via atomic UPDATE ... SET subscriber_count = subscriber_count + $1 on every membership add/remove; recount_list/1 is the repair function if it ever drifts.

Summary

Functions

Adds a contact to a list — writes a membership snapshotting the contact's current email (may be nil; the contact is just unsendable). Sets subscribed_at and bumps the list's subscriber_count.

Creates a brand-new contact and adds it to list, both in ONE transaction — used by PhoenixKitCRM.Lists.Import so a membership-uniqueness violation rolls back the just-created contact too (no orphan contacts on a failed/duplicate import row). Delegates to Contacts.create_contact/1 for the contact insert and add_contact_to_list/3 for the membership; never duplicates either's logic.

Bulk-writes the list's locale onto its "subscribed" members' contacts.

Archives a list (status flip, not delete). Idempotent if already archived.

Creates a list. Pass :actor_uuid in opts for the activity log entry.

The member (any status) currently holding email in list, if any, with :contact preloaded. Used by the importer to classify an idx_crm_list_members_list_email violation as :already_in_list (an active/pending member) vs :unsubscribed (a "removed" member still holding the slot), and by the manual add-by-email form to offer a "Resubscribe" affordance for the :unsubscribed case instead of a blocked add (add_new_contact_to_list/3 would just fail there — the slot is held).

Lists contact lists, name ascending.

Lists a list's memberships, newest first, contact preloaded.

Contacts with an active ("subscribed") membership on EVERY one of the given lists — the CRM comparison screen's cross-list overlap report. Requires at least 2 list uuids (an "overlap" of one list is just that list's members, not a comparison). Malformed uuids are dropped (returns [] if fewer than 2 valid ones remain) and trashed contacts are excluded.

Preview counts for apply_locale_to_members/3, over the list's currently "subscribed" members

Batched counterpart to get_member_by_email/2 — members (any status) holding any of the given emails in list, as an %{email => member} map. One query instead of N; built for Lists.Import's dry-run preview, which used to call get_member_by_email/2 once per row — fine for a handful of rows, but a file near the upload size limit could mean tens of thousands of sequential round trips inside a single, unyielding LiveView event. Unlike get_member_by_email/2, :contact is NOT preloaded here — the only caller (Import.preview_row/2) classifies purely on status.

Opts a contact back in — clears opted_out_at and appends a consent entry. Idempotent: a no-op if not currently opted out.

Opts a contact out — sets opted_out_at and appends an entry to consent (ts/action/actor_uuid/source). Applies across every list the contact belongs to (the send path checks this, not per-membership status). Idempotent: a no-op if already opted out.

Recomputes and stores subscriber_count from the actual subscribed-member count. Repair function. Returns :missing when the list row no longer exists by the time the write runs (deleted concurrently) — nothing left to repair, and no :list_recounted event is broadcast.

Removes a contact's membership from a list (soft: status"removed", stamps unsubscribed_at). Never deletes the row. Idempotent if already removed. opts accepts :actor_uuid.

Same as remove_from_list/2, but looks the membership up by contact + list instead of taking an existing ListMember directly. opts is required here (not defaulted) to avoid an arity clash with the single-struct form above — pass [] for no options.

Whether the contact currently has an active (subscribed) membership on the list.

Unarchives a list back to active. Idempotent if already active.

Updates a list. Pass :actor_uuid in opts for the activity log entry.

Functions

add_contact_to_list(contact, list, opts \\ [])

@spec add_contact_to_list(
  PhoenixKitCRM.Schemas.Contact.t(),
  PhoenixKitCRM.Schemas.ContactList.t(),
  keyword()
) ::
  {:ok, PhoenixKitCRM.Schemas.ListMember.t()}
  | {:error, :already_member | :email_already_in_list | Ecto.Changeset.t()}

Adds a contact to a list — writes a membership snapshotting the contact's current email (may be nil; the contact is just unsendable). Sets subscribed_at and bumps the list's subscriber_count.

idx_crm_list_members_list_contact has no status predicate — once a contact has ever had a row for a list (even a removed one), that (list_uuid, contact_uuid) slot is occupied at the DB level forever. So a blind insert here would raise :already_member for a contact that isn't currently a member at all. Mirrors PartyRoles.grant_role/3: looks the row up first — no row → insert; a removed/pending row → reactivate it in place (status → "subscribed", refreshed subscribed_at/source/email, unsubscribed_at cleared); an already "subscribed" row → {:error, :already_member} (a real no-op, not a reactivation).

Options

Returns {:error, :already_member} if the contact is already an active member, or {:error, :email_already_in_list} if a different contact already holds this email in the list (the idx_crm_list_members_list_email race-shaped case — a removed member still holds its email slot).

add_new_contact_to_list(contact_attrs, list, opts \\ [])

@spec add_new_contact_to_list(map(), PhoenixKitCRM.Schemas.ContactList.t(), keyword()) ::
  {:ok,
   {PhoenixKitCRM.Schemas.Contact.t(), PhoenixKitCRM.Schemas.ListMember.t()}}
  | {:error, :already_member | :email_already_in_list | Ecto.Changeset.t()}

Creates a brand-new contact and adds it to list, both in ONE transaction — used by PhoenixKitCRM.Lists.Import so a membership-uniqueness violation rolls back the just-created contact too (no orphan contacts on a failed/duplicate import row). Delegates to Contacts.create_contact/1 for the contact insert and add_contact_to_list/3 for the membership; never duplicates either's logic.

Note: add_contact_to_list/3's activity log + PubSub broadcast fire from inside this transaction (right before it commits, not after) — an acceptable, negligible-window tradeoff for reusing it here rather than duplicating its insert + counter-bump logic.

Returns {:error, :already_member} / {:error, :email_already_in_list} exactly like add_contact_to_list/3 (structurally :already_member can't actually happen here — the contact is always brand-new — but the type stays honest about what add_contact_to_list/3 can return), or {:error, changeset} from a failed contact insert.

apply_locale_to_members(list, mode, opts \\ [])

@spec apply_locale_to_members(
  PhoenixKitCRM.Schemas.ContactList.t(),
  :all | :missing_only,
  keyword()
) ::
  {:ok, non_neg_integer()} | {:error, :no_locale}

Bulk-writes the list's locale onto its "subscribed" members' contacts.

mode:

  • :missing_only — only contacts with no locale set yet (NULL or "").
  • :all — every subscribed member's contact, overwriting any existing locale — including one set by a DIFFERENT list this same contact also belongs to. locale lives on the contact, not the membership, so it is never list-scoped: the last list to apply its locale to a shared contact wins. That's expected, not a bug.

Pass :actor_uuid in opts for the activity log entry (logged once per call, with the affected count in metadata — not once per contact, same as the list-level mutations in this module). Also broadcasts a single :list_locale_applied event over crm:lists (payload: %{list_uuid:, locale:, mode:, updated_count:}) when updated_count > 0 — this bulk mutation used to be the only one in this context that didn't, unlike every other list/membership write here (see the module doc).

Returns {:ok, updated_count} (0 is a valid, non-error result — e.g. :missing_only against a list where every member already has a locale; no event fires in that case either, mirroring the activity log's own count > 0 guard), or {:error, :no_locale} if the list itself has no locale (defensive; the UI should already gate the triggering action on this).

archive_list(list, opts \\ [])

Archives a list (status flip, not delete). Idempotent if already archived.

change_list(list, attrs \\ %{})

create_list(attrs, opts \\ [])

@spec create_list(
  map(),
  keyword()
) :: {:ok, PhoenixKitCRM.Schemas.ContactList.t()} | {:error, Ecto.Changeset.t()}

Creates a list. Pass :actor_uuid in opts for the activity log entry.

get_list(uuid)

@spec get_list(UUIDv7.t() | String.t() | nil) ::
  PhoenixKitCRM.Schemas.ContactList.t() | nil

get_list!(uuid)

get_list_by_slug(slug)

@spec get_list_by_slug(String.t() | nil) ::
  PhoenixKitCRM.Schemas.ContactList.t() | nil

get_member_by_email(list, email)

The member (any status) currently holding email in list, if any, with :contact preloaded. Used by the importer to classify an idx_crm_list_members_list_email violation as :already_in_list (an active/pending member) vs :unsubscribed (a "removed" member still holding the slot), and by the manual add-by-email form to offer a "Resubscribe" affordance for the :unsubscribed case instead of a blocked add (add_new_contact_to_list/3 would just fail there — the slot is held).

list_lists(opts \\ [])

@spec list_lists(keyword()) :: [PhoenixKitCRM.Schemas.ContactList.t()]

Lists contact lists, name ascending.

Options

  • :status — filter to one status (default: all)
  • :subscribable — filter to subscribable == true/false (default: all)

list_members(list, opts \\ [])

Lists a list's memberships, newest first, contact preloaded.

Options

  • :status — filter to one status (default: all)
  • :search — case-insensitive match on the member's email or its contact's name
  • :limit / :offset — pagination

list_overlap(list_uuids)

@spec list_overlap([UUIDv7.t() | String.t()]) :: [PhoenixKitCRM.Schemas.Contact.t()]

Contacts with an active ("subscribed") membership on EVERY one of the given lists — the CRM comparison screen's cross-list overlap report. Requires at least 2 list uuids (an "overlap" of one list is just that list's members, not a comparison). Malformed uuids are dropped (returns [] if fewer than 2 valid ones remain) and trashed contacts are excluded.

locale_apply_preview(list)

@spec locale_apply_preview(PhoenixKitCRM.Schemas.ContactList.t()) :: %{
  total: non_neg_integer(),
  missing_locale: non_neg_integer(),
  different_locale: non_neg_integer()
}

Preview counts for apply_locale_to_members/3, over the list's currently "subscribed" members:

  • total — how many members :all mode would touch.
  • missing_locale — how many of those have no locale yet (NULL or "") — how many :missing_only mode would touch. Deliberately a SEPARATE count rather than deriving the modal's "will be affected" number from total alone: :missing_only is the default mode, and touches a strict subset of total whenever different_locale > 0 — showing total regardless of the selected mode overstates the impact for the common case.
  • different_locale — how many already carry a DIFFERENT, non-blank locale than the list's. What makes "all" (overwrite) a real, visible tradeoff in the confirm UI rather than a blind guess.

%{total: 0, missing_locale: 0, different_locale: 0} when the list itself has no locale set (nothing to preview — the UI should already gate the action on this).

members_by_email(list, emails, chunk_size \\ 10000)

@spec members_by_email(
  PhoenixKitCRM.Schemas.ContactList.t(),
  [String.t()],
  pos_integer()
) :: %{
  required(String.t()) => PhoenixKitCRM.Schemas.ListMember.t()
}

Batched counterpart to get_member_by_email/2 — members (any status) holding any of the given emails in list, as an %{email => member} map. One query instead of N; built for Lists.Import's dry-run preview, which used to call get_member_by_email/2 once per row — fine for a handful of rows, but a file near the upload size limit could mean tens of thousands of sequential round trips inside a single, unyielding LiveView event. Unlike get_member_by_email/2, :contact is NOT preloaded here — the only caller (Import.preview_row/2) classifies purely on status.

Map keys are downcased (matching citext's case-insensitive comparison, but the stored email column value keeps whatever case it was written in, so a raw Map.new(&{&1.email, &1}) could silently miss a lookup) — callers must downcase their own lookup key too.

emails is queried in chunks of chunk_size (default

  1. rather than one WHERE email IN (...) for the whole list: Ecto expands field in ^list into one bind parameter PER element, and Postgres caps a single query at 65,535 bind parameters — a file near the upload size limit (one address per line) would blow past that in a single query and raise Postgrex.Error instead of rendering the import preview. chunk_size is exposed so tests can exercise the chunking/merge behavior without a multi-thousand row fixture.

opt_in(contact, opts \\ [])

Opts a contact back in — clears opted_out_at and appends a consent entry. Idempotent: a no-op if not currently opted out.

opt_out(contact, opts \\ [])

Opts a contact out — sets opted_out_at and appends an entry to consent (ts/action/actor_uuid/source). Applies across every list the contact belongs to (the send path checks this, not per-membership status). Idempotent: a no-op if already opted out.

Options

  • :source — free-form origin tag (e.g. "admin", "unsubscribe_link"); default "manual"
  • :actor_uuid — for the activity log entry

recount_list(list)

Recomputes and stores subscriber_count from the actual subscribed-member count. Repair function. Returns :missing when the list row no longer exists by the time the write runs (deleted concurrently) — nothing left to repair, and no :list_recounted event is broadcast.

remove_from_list(member, opts \\ [])

Removes a contact's membership from a list (soft: status"removed", stamps unsubscribed_at). Never deletes the row. Idempotent if already removed. opts accepts :actor_uuid.

Accepts either an existing ListMember directly, or a contact + list pair to look one up.

remove_from_list(contact, list, opts)

@spec remove_from_list(
  PhoenixKitCRM.Schemas.Contact.t(),
  PhoenixKitCRM.Schemas.ContactList.t(),
  keyword()
) :: {:ok, PhoenixKitCRM.Schemas.ListMember.t()} | {:error, :not_member}

Same as remove_from_list/2, but looks the membership up by contact + list instead of taking an existing ListMember directly. opts is required here (not defaulted) to avoid an arity clash with the single-struct form above — pass [] for no options.

subscribed?(contact, list)

Whether the contact currently has an active (subscribed) membership on the list.

unarchive_list(list, opts \\ [])

Unarchives a list back to active. Idempotent if already active.

update_list(list, attrs, opts \\ [])

Updates a list. Pass :actor_uuid in opts for the activity log entry.