Context for CRM party roles — marks an existing company or contact as a
supplier, customer, or partner (see PhoenixKitCRM.Schemas.PartyRole).
Mutations are logged here (not in the LiveViews) because grant_role/3
and revoke_role/2 are called from both the company form and the contact
form's Roles section — a single log point keeps the audit trail consistent
regardless of caller, mirroring PhoenixKitCRM.Interactions. There is no
live-updating tab for roles yet, so unlike interactions this context does
not broadcast over PubSub.
Summary
Functions
Active roles for a batch of companies/contacts in one query — for role
badges on list pages. Returns %{roleable_uuid => [role, ...]} (uuids
with no active roles are absent).
Same filters as list_companies_with_role/2, minus :limit/:offset.
Same filters as list_contacts_with_role/2, minus :limit/:offset.
How many legacy "client" party-role rows are still present.
Deletes every role row belonging to a party that is being permanently removed. Returns the number deleted.
The manufacturer-role counterpart of get_supplier/1, with the identical
return shape — the contract
PhoenixKitCatalogue.Catalogue.Manufacturers.resolve/1 calls.
Batch manufacturer resolution. See get_parties_with_role/2.
Batch counterpart of get_supplier/1 / get_manufacturer/1: resolves many
party uuids holding an active role in ONE pair of queries, returning
%{uuid => party_map}. Uuids with no active role for role are absent.
Resolver entry point for the (future) Catalogue supplier facade: given a
company or contact uuid, returns %{uuid, name, email, phone, website, logo_url, source: :crm} if that party currently has an active supplier
role, or nil otherwise (unknown uuid, inactive role, or no supplier role
at all).
Batch supplier resolution. See get_parties_with_role/2.
Grants role to a company or contact. Idempotent: granting a role that is
currently IN FORCE is a no-op that returns the existing row unchanged —
including any attrs, which are ignored on that path, so this is not a way
to edit a live grant's window. Granting a previously-revoked role, or one
whose valid_to has passed, starts a fresh tenure (valid_from today,
valid_to cleared).
Whether the company/contact currently has an active role.
Companies holding an active role, name ascending. Excludes trashed
companies by default.
Contacts holding an active role, name ascending. Same options as list_companies_with_role/2.
Parties holding an active customer role. See list_parties_with_role/2.
Parties holding an active manufacturer role. See list_parties_with_role/2.
Every party holding an active role, companies first then contacts, each
normalized to the resolver's map shape (:uuid, :name, :email,
:phone, :website, :logo_url, :source).
All role rows (active and inactive) held by a company/contact, role ascending.
Parties holding an active supplier role. See list_parties_with_role/2.
One-time normalization of legacy "client" party-role rows to "customer".
Revokes role from a company or contact — sets is_active false and stamps
valid_to with today's date. Never deletes the row.
Functions
Active roles for a batch of companies/contacts in one query — for role
badges on list pages. Returns %{roleable_uuid => [role, ...]} (uuids
with no active roles are absent).
@spec count_companies_with_role( String.t(), keyword() ) :: non_neg_integer()
Same filters as list_companies_with_role/2, minus :limit/:offset.
@spec count_contacts_with_role( String.t(), keyword() ) :: non_neg_integer()
Same filters as list_contacts_with_role/2, minus :limit/:offset.
@spec count_legacy_client_roles() :: non_neg_integer()
How many legacy "client" party-role rows are still present.
@spec delete_roles_for(String.t(), UUIDv7.t()) :: non_neg_integer()
Deletes every role row belonging to a party that is being permanently removed. Returns the number deleted.
roleable_uuid is a soft reference with no foreign key, so nothing removes
these rows on its own: a deleted company left its roles behind forever.
They were invisible — the resolvers hydrate the party and get nil — but
they accumulated, and counts and list_roles/1 could still see them.
@spec get_manufacturer(UUIDv7.t() | String.t() | nil) :: %{ uuid: UUIDv7.t(), name: String.t(), email: String.t() | nil, phone: String.t() | nil, website: String.t() | nil, logo_url: String.t() | nil, source: :crm } | nil
The manufacturer-role counterpart of get_supplier/1, with the identical
return shape — the contract
PhoenixKitCatalogue.Catalogue.Manufacturers.resolve/1 calls.
logo_url is what a company granted the manufacturer role carries as its
brand mark now that manufacturers are managed here — see the V03 migration
comment. Contacts have no logo_url column, so it resolves to nil there.
Note what this does NOT mean: catalogue items still reference the local
phoenix_kit_cat_manufacturers row through a hard FK. This resolver
federates the manufacturer directory and pickers, not item references.
Batch manufacturer resolution. See get_parties_with_role/2.
@spec get_parties_with_role([UUIDv7.t() | String.t()], String.t()) :: %{ required(UUIDv7.t()) => map() }
Batch counterpart of get_supplier/1 / get_manufacturer/1: resolves many
party uuids holding an active role in ONE pair of queries, returning
%{uuid => party_map}. Uuids with no active role for role are absent.
This is the function a catalogue page renders 100 items through — resolving them one at a time across the module boundary is the N+1 this exists to prevent. Malformed uuids are dropped rather than raising, because the caller is feeding in soft cross-module references that nothing constrains.
@spec get_supplier(UUIDv7.t() | String.t() | nil) :: %{ uuid: UUIDv7.t(), name: String.t(), email: String.t() | nil, phone: String.t() | nil, website: String.t() | nil, logo_url: String.t() | nil, source: :crm } | nil
Resolver entry point for the (future) Catalogue supplier facade: given a
company or contact uuid, returns %{uuid, name, email, phone, website, logo_url, source: :crm} if that party currently has an active supplier
role, or nil otherwise (unknown uuid, inactive role, or no supplier role
at all).
This is the contract PhoenixKitCatalogue.Catalogue.Suppliers.resolve/1
will call in Phase 2 (see the CRM v2 parties design doc, §4.3) — keep the
return shape stable.
Batch supplier resolution. See get_parties_with_role/2.
@spec grant_role( PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t(), String.t(), map(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.PartyRole.t()} | {:error, Ecto.Changeset.t()}
Grants role to a company or contact. Idempotent: granting a role that is
currently IN FORCE is a no-op that returns the existing row unchanged —
including any attrs, which are ignored on that path, so this is not a way
to edit a live grant's window. Granting a previously-revoked role, or one
whose valid_to has passed, starts a fresh tenure (valid_from today,
valid_to cleared).
attrs may set :valid_from / :valid_to and they win over those defaults
on every path that writes, so a caller can still time-box a re-grant. A
valid_from in the future is honoured as a scheduled grant: the row exists
but no resolver reports the role until that date. Never pass caller-supplied
metadata here from a UI path.
Pass :actor_uuid in opts so the activity log entry records who granted
the role (mirrors every other logged CRM mutation).
@spec has_role?( PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t(), String.t() ) :: boolean()
Whether the company/contact currently has an active role.
@spec list_companies_with_role( String.t(), keyword() ) :: [PhoenixKitCRM.Schemas.Company.t()]
Companies holding an active role, name ascending. Excludes trashed
companies by default.
Options
:include_inactive— include revoked role rows too:include_trashed— include trashed companies too:search— name/email ILIKE match:limit/:offset— pagination; no-ops when absent
@spec list_contacts_with_role( String.t(), keyword() ) :: [PhoenixKitCRM.Schemas.Contact.t()]
Contacts holding an active role, name ascending. Same options as list_companies_with_role/2.
Parties holding an active customer role. See list_parties_with_role/2.
Parties holding an active manufacturer role. See list_parties_with_role/2.
Every party holding an active role, companies first then contacts, each
normalized to the resolver's map shape (:uuid, :name, :email,
:phone, :website, :logo_url, :source).
:source is the specific :crm_company / :crm_contact tag rather than
the generic :crm that get_supplier/1 returns — callers persisting a
source tag (the catalogue item form) need to know which side they picked.
Takes the same options as list_companies_with_role/2.
@spec list_roles( PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t() ) :: [ PhoenixKitCRM.Schemas.PartyRole.t() ]
All role rows (active and inactive) held by a company/contact, role ascending.
Parties holding an active supplier role. See list_parties_with_role/2.
@spec rename_legacy_client_roles() :: %{ renamed: non_neg_integer(), dropped: non_neg_integer() }
One-time normalization of legacy "client" party-role rows to "customer".
0.2.x shipped supplier/client/partner; the role was renamed to
customer after that, and nothing rewrote the rows already in a host's
database. A stranded "client" row is not visible under the Customers
filter (which queries "customer"), has no checkbox on the company/contact
form (PartyRole.roles/0 no longer lists it, so sync_roles/3 never
touches it) and renders as a raw grey badge. Run this once per database on
upgrade — see mix phoenix_kit_crm.rename_client_role.
A party that already holds "customer" would collide with the
(roleable_type, roleable_uuid, role) unique index, so its legacy row is
dropped instead of renamed: the newer row is the authoritative one. Both
steps run in one transaction, and the whole thing is idempotent — a second
run reports %{renamed: 0, dropped: 0}.
@spec revoke_role( PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t(), String.t(), keyword() ) :: {:ok, PhoenixKitCRM.Schemas.PartyRole.t()} | {:error, :not_found | Ecto.Changeset.t()}
Revokes role from a company or contact — sets is_active false and stamps
valid_to with today's date. Never deletes the row.
Note what "history" means here: the row records the CURRENT tenure, not every
tenure. The unique index allows only one row per party and role, so a
re-grant reuses it and stamps a fresh valid_from. The record of who
granted and revoked what, and when, is the activity log.
A no-op if the role isn't currently held (returns {:error, :not_found}) or
is already inactive.
Pass :actor_uuid in opts so the activity log entry records who revoked
the role (mirrors every other logged CRM mutation).