cratefield-module-crm
Contacts, organisations and the tags that label them, with an idempotent upsert API other modules and a venture's own code can call.
use Crm;
let module = new;
Mounted at /v1/crm. It requires the Db, IdGen and Clock ports and
declares Auth as optional.
Configuration
None. The module reads no config keys of its own: every default is a
constant, every route is admin-gated, and Module::validate_config accepts
whatever it is given. The one key that decides whether the module is usable
is the harness-wide ADMIN_TOKEN, because every route it declares is an
admin action — with no token configured, all of them answer 401
(admin-unauthorized) rather than open.
In a venture manifest
The module is a catalog slug, so a venture that wants it lists "crm" in
its manifest and fz build wires it up:
Or, by hand, through the facade (the facade crate is not a dependency of this one, so the snippet is not compiled):
use Crm;
let module = new;
Idempotent by natural key
store::upsert_contact keys a contact on its normalized email address, and
store::upsert_organisation keys an organisation on its lowered domain. The
same address filed twice is one contact: the second call updates the fields it
supplies, leaves the ones it does not, bumps the row's generation, and
reports whether it inserted or updated. That is what makes the call safe from
an event handler that may run twice, which is the reason the API is public
rather than only reachable through the admin routes.
Optimistic concurrency
Every row carries a generation that starts at 1 and is bumped by each write.
A PATCH must carry the generation it read; the update is a single
UPDATE … WHERE id = ? AND generation = ?, so a caller working from a stale
copy changes nothing and is answered 409 with the crm-stale-generation
problem instead of silently overwriting a newer edit.
Routes
Everything is an admin action behind the harness ADMIN_TOKEN bearer:
GET /v1/crm/admin/contacts.csvandGET /v1/crm/admin/organisations.csvexport a page of rows as CSV (limit/offset, at most 5000 rows per page, withx-cf-export-more: truewhen there is more to fetch).POST /v1/crm/admin/contactsfiles a contact (upsert by address);PATCH /v1/crm/admin/contacts/{id}andDELETE …/{id}edit and remove one.POST /v1/crm/admin/contacts/mergefolds one contact into another, filling the survivor's empty fields, merging their structured data and moving their tags, all in one atomic batch.POST /v1/crm/admin/organisationsfiles an organisation (upsert by domain);PATCH …/{id}andDELETE …/{id}edit and remove one.POST /v1/crm/admin/tagscreates a tag;POST …/tags/tagandPOST …/tags/untagfile a tag against a subject and take it off again.
Events
| event | emitted when |
|---|---|
crm.contact.created |
An upsert inserted a contact. |
crm.contact.updated |
An upsert matched an existing contact, a PATCH changed one, or a merge folded another into it. |
crm.organisation.created |
An upsert inserted an organisation. |
Nothing else emits: a delete is silent, and an organisation PATCH has no
event because the module declares no crm.organisation.updated — inventing
one per edit would be a promise no subscription list carries.
Personal data
The privacy module reads the declarations in Module::personal_data:
| table | subject | kind | disposition |
|---|---|---|---|
crm_contacts |
id |
contact | erase |
crm_organisations |
— | — | retained: a business record, not a person's |
crm_tags |
— | — | not personal: a name and a colour |
crm_taggings |
subject_id, via crm_contacts |
identifier | erase |
So a contact is erased with its taggings — the tables are declared
parent-first because the privacy module erases in reverse catalog order, and
crm_taggings' polymorphic subject_id is matched through crm_contacts
rather than a foreign key. An organisation survives: it is a business record,
and deleting one would take every contact linked to it with it. Review that
before relying on it — an organisation's email and phone may hold an
individual's details rather than a switchboard, and a sole trader's record is
a person's data in practice.