Expand description
cratefield-module-crm: contacts, organisations and tags (Cratefield
issue #572).
use cratefield_module_crm::Crm;
let module = Crm::new();Idempotent by natural key. The module exists so other modules and a
venture’s own code can file a person or a company without first asking
whether it is already there: store::upsert_contact keys a contact on
its normalized address and store::upsert_organisation keys an
organisation on its domain, so the same address twice is one contact and
the second call updates rather than duplicates.
Every write is optimistic. A row carries a generation that every
write bumps; a PATCH must carry the generation it read, and a mismatch is
a 409 (crm-stale-generation) rather than a lost update.
The routes are admin-only. Contacts, organisations, their CSV exports
and the tag operations all sit under /v1/crm/admin/ behind the harness
ADMIN_TOKEN bearer, so the module has no public write endpoint and needs
no captcha.
A contact is personal data and is erased with its taggings; an
organisation is a business record and survives. See Crm::personal_data
for the declarations the privacy module reads.
§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 cratefield_module_crm::Crm;
let module = Crm::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:
{
"name": "acme",
"host": "acme.factory0.dev",
"cors_origins": ["https://acme.factory0.dev"],
"modules": ["crm"]
}Or, by hand, through the facade (the facade crate is not a dependency of this one, so the snippet is not compiled):
use cratefield::crm::Crm;
let module = Crm::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.
Modules§
- store
- The CRM’s tables, read and written in the portable SQL subset (ADR 0004) — one statement set that SQLite, D1 and Postgres all accept.
Structs§
- Crm
- A CRM: contacts, organisations and the tags that label them.
Constants§
- MODULE_
NAME - The module’s name: it is mounted at
/v1/crm, and its config keys are prefixedCRM_.