PhoenixKitCRM.PartyRoles (PhoenixKitCRM v0.7.2)

Copy Markdown View Source

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

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_map(type, uuids)

@spec active_roles_map(String.t(), [UUIDv7.t()]) :: %{
  required(UUIDv7.t()) => [String.t()]
}

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

count_companies_with_role(role, opts \\ [])

@spec count_companies_with_role(
  String.t(),
  keyword()
) :: non_neg_integer()

Same filters as list_companies_with_role/2, minus :limit/:offset.

count_contacts_with_role(role, opts \\ [])

@spec count_contacts_with_role(
  String.t(),
  keyword()
) :: non_neg_integer()

Same filters as list_contacts_with_role/2, minus :limit/:offset.

count_legacy_client_roles()

@spec count_legacy_client_roles() :: non_neg_integer()

How many legacy "client" party-role rows are still present.

delete_roles_for(type, uuid)

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

get_manufacturer(uuid)

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

get_manufacturers(uuids)

@spec get_manufacturers([UUIDv7.t() | String.t()]) :: %{required(UUIDv7.t()) => map()}

Batch manufacturer resolution. See get_parties_with_role/2.

get_parties_with_role(uuids, role)

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

get_supplier(uuid)

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

get_suppliers(uuids)

@spec get_suppliers([UUIDv7.t() | String.t()]) :: %{required(UUIDv7.t()) => map()}

Batch supplier resolution. See get_parties_with_role/2.

grant_role(roleable, role, attrs \\ %{}, opts \\ [])

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

has_role?(roleable, role)

Whether the company/contact currently has an active role.

list_companies_with_role(role, opts \\ [])

@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

list_contacts_with_role(role, opts \\ [])

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

list_customers(opts \\ [])

@spec list_customers(keyword()) :: [map()]

Parties holding an active customer role. See list_parties_with_role/2.

list_manufacturers(opts \\ [])

@spec list_manufacturers(keyword()) :: [map()]

Parties holding an active manufacturer role. See list_parties_with_role/2.

list_parties_with_role(role, opts \\ [])

@spec list_parties_with_role(
  String.t(),
  keyword()
) :: [map()]

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.

list_roles(roleable)

All role rows (active and inactive) held by a company/contact, role ascending.

list_suppliers(opts \\ [])

@spec list_suppliers(keyword()) :: [map()]

Parties holding an active supplier role. See list_parties_with_role/2.

rename_legacy_client_roles()

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

revoke_role(roleable, role, opts \\ [])

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