PhoenixKitCRM.Mirror (PhoenixKitCRM v0.7.0)

Copy Markdown View Source

Field maps, the per-field divergence-diff engine, and the resolver they feed — shared by both mirror sides: Company ↔ an account_type: "organization" User, Contact ↔ an account_type: "person" User (owner decisions Q1-Q6, L006).

Pure functions over structs: no DB access, no context calls. Every writing decision ("actually link/create/copy") lives in the Companies / Contacts contexts (Tasks D/E); this module only computes what would change, whether that change is safe to make silently, and — once a human has picked a side per field — exactly what to write.

The rule this module exists to enforce

"Master = the side the form you're on transfers FROM." When both sides already carry a NON-BLANK value and those values genuinely differ, that is a conflictdiff/2 surfaces it and the caller must ask (the conflict modal, Task F), never silently overwrite. When one side is blank, the copy just fills it — not a conflict. Values are compared trimmed (" Anna Kask " and "Anna Kask" are the same value, on either side), so incidental whitespace never manufactures a false conflict.

Field maps

field_map/1 returns one entry per mirrored field:

  • %{crm: atom(), user: atom(), label: String.t()} — a direct 1:1 copy (:email for both kinds; :name:organization_name for :company).
  • %{crm: :name, user: {:split, :first_name, :last_name}, label: ...}:contact only. User has no single "name" column; Contact.name is the JOIN of first_name + last_name (see below), so there is no single user-side atom this field's value lives in.

Only fields that exist on BOTH Contact/Company and User participate — Company.website/.phone/.address/.industry and Contact.phone/ .locale have no User counterpart (User has no phone, no locale) and are deliberately absent from both maps (Q4).

The :field key is symbolic, not a raw column

diff/2 and resolve/4 both key on the CRM-side atom (:name, :email) for BOTH kinds, uniformly — never a raw User column like :organization_name or :first_name, even for the direct mappings where crm and user atoms happen to share a name (:email). This is deliberate: a caller must never Ecto.Changeset.put_change(cs, field, value) a diff/2 entry directly (that would silently break the contact name mapping, which needs two columns written from one value). Callers pick a side per symbolic field and hand the choices to resolve/4, which owns all split/join + direction logic centrally — LiveViews (Tasks F/G/H) never see first_name/last_name/ organization_name at all.

Name join / split

User has no combined name field; Contact conflates the mirror's "name" concept differently (:company's name maps straight to organization_name, no join/split involved):

  • join (User → CRM, attrs_to_crm/2 and resolve/4, contact only): String.trim("#{first_name} #{last_name}"), collapsing to nil when both sides are blank.
  • split (CRM → User, attrs_from/2 and resolve/4, contact only): tokenize on whitespace (any run) — the LAST token becomes last_name, every token before it rejoins with a single space to become first_name. This collapses internal multi-space/tab runs to a single space; it does not preserve original in-between spacing. A single token sets first_name and leaves last_name nil. A blank name yields nil for both.

Summary

Functions

The User attrs to write when the CRM record is master (a "create mirror user" or "CRM wins this conflict" write). Values are normalized (trimmed + blanked-to-nil) the same way diff/2/resolve/4 normalize them, so a padded form value never persists untrimmed via this path while resolve/4 would have trimmed it.

The CRM attrs to write when the User is master (a "create CRM card from user" or "User wins this conflict" write). Same normalization guarantee as attrs_from/2 — see its @doc.

The per-field divergences between a CRM record and a User — only fields present (non-blank, trimmed) on BOTH sides AND unequal. :field is the symbolic key described in the moduledoc (:name / :email for both kinds) — feed it straight to resolve/4, never to put_change/3.

The mapped fields for kind — only fields present on both sides.

A random password meeting Auth.register_user/1's complexity rule (mixed-case + digit + symbol), for the "create mirror user" path (Companies.create_mirror_user/2, Contacts.create_mirror_user/1) — the human never sees or sets it; the account is reached through the CRM record via mirror_panel, not this password. Was duplicated byte-for-byte in both contexts; centralized here alongside the other attrs-shaping helpers this module already owns.

Turns a human's per-field side choice into the attr deltas each side needs written — the single place split/join + direction logic lives.

Atom-keyed map → string-keyed map, for handing attrs_to_crm/2's (or a mirror-user's) result to a context's create_*/1, which expects string keys the same way changeset-backed forms submit them. Same dedup rationale as random_password/0.

Types

field_map_entry()

@type field_map_entry() :: %{crm: atom(), user: user_field(), label: String.t()}

kind()

@type kind() :: :company | :contact

side()

@type side() :: :crm | :user

user_field()

@type user_field() :: atom() | {:split, atom(), atom()}

Functions

attrs_from(atom, company)

The User attrs to write when the CRM record is master (a "create mirror user" or "CRM wins this conflict" write). Values are normalized (trimmed + blanked-to-nil) the same way diff/2/resolve/4 normalize them, so a padded form value never persists untrimmed via this path while resolve/4 would have trimmed it.

attrs_to_crm(atom, user)

@spec attrs_to_crm(kind(), PhoenixKit.Users.Auth.User.t()) :: map()

The CRM attrs to write when the User is master (a "create CRM card from user" or "User wins this conflict" write). Same normalization guarantee as attrs_from/2 — see its @doc.

diff(company, user)

@spec diff(
  PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t(),
  PhoenixKit.Users.Auth.User.t()
) :: [%{field: atom(), label: String.t(), crm: term(), user: term()}]

The per-field divergences between a CRM record and a User — only fields present (non-blank, trimmed) on BOTH sides AND unequal. :field is the symbolic key described in the moduledoc (:name / :email for both kinds) — feed it straight to resolve/4, never to put_change/3.

field_map(atom)

@spec field_map(kind()) :: [field_map_entry()]

The mapped fields for kind — only fields present on both sides.

random_password()

@spec random_password() :: String.t()

A random password meeting Auth.register_user/1's complexity rule (mixed-case + digit + symbol), for the "create mirror user" path (Companies.create_mirror_user/2, Contacts.create_mirror_user/1) — the human never sees or sets it; the account is reached through the CRM record via mirror_panel, not this password. Was duplicated byte-for-byte in both contexts; centralized here alongside the other attrs-shaping helpers this module already owns.

resolve(kind, crm_struct, user, choices)

@spec resolve(
  kind(),
  PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t(),
  PhoenixKit.Users.Auth.User.t(),
  %{required(atom()) => side()}
) :: %{crm: map(), user: map()}

Turns a human's per-field side choice into the attr deltas each side needs written — the single place split/join + direction logic lives.

choices is %{symbolic_field => :crm | :user} (the same :field keys diff/2 emits, e.g. %{name: :crm, email: :user}). A field is a no-op — contributes nothing to either delta — when it's absent from choices (no conflict on it, or the caller chose not to touch it) OR when it's present but the two sides don't actually diverge (the same non-blank fields — a defensive guard, not just a caller contract, since blindly honoring a stray choice on a non-diverging field can WIPE a populated side: e.g. %Contact{name: nil} + %{name: :crm} would otherwise null out an already-set first_name/last_name).

  • choice :crm on a genuinely diverging field → that field's CRM value wins; the delta needed to bring User in line goes into :user (split into first_name/last_name for the contact name mapping, a plain %{field => value} otherwise). The :crm side of the result is untouched for that field — it already holds the winning value.
  • choice :user on a genuinely diverging field → the reverse: the delta needed to bring the CRM record in line goes into :crm (joined for the contact name mapping), :user untouched for that field.

Returns %{crm: map(), user: map()} — attrs to pass into the Companies/Contacts and Auth update calls respectively.

stringify_keys(map)

@spec stringify_keys(map()) :: map()

Atom-keyed map → string-keyed map, for handing attrs_to_crm/2's (or a mirror-user's) result to a context's create_*/1, which expects string keys the same way changeset-backed forms submit them. Same dedup rationale as random_password/0.