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 conflict — diff/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 (:emailfor both kinds;:name↔:organization_namefor:company).%{crm: :name, user: {:split, :first_name, :last_name}, label: ...}—:contactonly.Userhas no single "name" column;Contact.nameis the JOIN offirst_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/2andresolve/4, contact only):String.trim("#{first_name} #{last_name}"), collapsing tonilwhen both sides are blank. - split (CRM →
User,attrs_from/2andresolve/4, contact only): tokenize on whitespace (any run) — the LAST token becomeslast_name, every token before it rejoins with a single space to becomefirst_name. This collapses internal multi-space/tab runs to a single space; it does not preserve original in-between spacing. A single token setsfirst_nameand leaveslast_namenil. A blank name yieldsnilfor 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
Functions
@spec attrs_from( kind(), PhoenixKitCRM.Schemas.Company.t() | PhoenixKitCRM.Schemas.Contact.t() ) :: map()
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.
@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.
@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.
@spec field_map(kind()) :: [field_map_entry()]
The mapped fields for kind — only fields present on both sides.
@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.
@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
:crmon a genuinely diverging field → that field's CRM value wins; the delta needed to bringUserin line goes into:user(split intofirst_name/last_namefor the contact name mapping, a plain%{field => value}otherwise). The:crmside of the result is untouched for that field — it already holds the winning value. - choice
:useron 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),:useruntouched for that field.
Returns %{crm: map(), user: map()} — attrs to pass into the
Companies/Contacts and Auth update calls respectively.
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.