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
@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
:source— one ofPhoenixKitCRM.Schemas.ListMember.sources/0(default"manual"):actor_uuid— for the activity log entry
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).
@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.
@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 (NULLor"").:all— every subscribed member's contact, overwriting any existing locale — including one set by a DIFFERENT list this same contact also belongs to.localelives 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).
@spec archive_list( PhoenixKitCRM.Schemas.ContactList.t(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.ContactList.t()} | {:error, Ecto.Changeset.t()}
Archives a list (status flip, not delete). Idempotent if already archived.
@spec change_list(PhoenixKitCRM.Schemas.ContactList.t(), map()) :: Ecto.Changeset.t()
@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.
@spec get_list(UUIDv7.t() | String.t() | nil) :: PhoenixKitCRM.Schemas.ContactList.t() | nil
@spec get_list!(UUIDv7.t() | String.t()) :: PhoenixKitCRM.Schemas.ContactList.t()
@spec get_list_by_slug(String.t() | nil) :: PhoenixKitCRM.Schemas.ContactList.t() | nil
@spec get_member_by_email(PhoenixKitCRM.Schemas.ContactList.t(), String.t()) :: PhoenixKitCRM.Schemas.ListMember.t() | nil
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).
@spec list_lists(keyword()) :: [PhoenixKitCRM.Schemas.ContactList.t()]
Lists contact lists, name ascending.
Options
:status— filter to one status (default: all):subscribable— filter tosubscribable == true/false(default: all)
@spec list_members( PhoenixKitCRM.Schemas.ContactList.t(), keyword() ) :: [PhoenixKitCRM.Schemas.ListMember.t()]
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
@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.
@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:allmode would touch.missing_locale— how many of those have no locale yet (NULLor"") — how many:missing_onlymode would touch. Deliberately a SEPARATE count rather than deriving the modal's "will be affected" number fromtotalalone::missing_onlyis the default mode, and touches a strict subset oftotalwheneverdifferent_locale> 0 — showingtotalregardless 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).
@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
- rather than one
WHERE email IN (...)for the whole list: Ecto expandsfield in ^listinto 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 raisePostgrex.Errorinstead of rendering the import preview.chunk_sizeis exposed so tests can exercise the chunking/merge behavior without a multi-thousand row fixture.
@spec opt_in( PhoenixKitCRM.Schemas.Contact.t(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, Ecto.Changeset.t()}
Opts a contact back in — clears opted_out_at and appends a consent
entry. Idempotent: a no-op if not currently opted out.
@spec opt_out( PhoenixKitCRM.Schemas.Contact.t(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.Contact.t()} | {:error, Ecto.Changeset.t()}
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
@spec recount_list(PhoenixKitCRM.Schemas.ContactList.t()) :: PhoenixKitCRM.Schemas.ContactList.t() | :missing
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.
@spec remove_from_list( PhoenixKitCRM.Schemas.ListMember.t(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.ListMember.t()}
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.
@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.
@spec subscribed?( PhoenixKitCRM.Schemas.Contact.t(), PhoenixKitCRM.Schemas.ContactList.t() ) :: boolean()
Whether the contact currently has an active (subscribed) membership on the list.
@spec unarchive_list( PhoenixKitCRM.Schemas.ContactList.t(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.ContactList.t()} | {:error, Ecto.Changeset.t()}
Unarchives a list back to active. Idempotent if already active.
@spec update_list(PhoenixKitCRM.Schemas.ContactList.t(), map(), keyword()) :: {:ok, PhoenixKitCRM.Schemas.ContactList.t()} | {:error, Ecto.Changeset.t()}
Updates a list. Pass :actor_uuid in opts for the activity log entry.