Skip to main content

Crate cratefield_module_crm

Crate cratefield_module_crm 

Source
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.csv and GET /v1/crm/admin/organisations.csv export a page of rows as CSV (limit/offset, at most 5000 rows per page, with x-cf-export-more: true when there is more to fetch).
  • POST /v1/crm/admin/contacts files a contact (upsert by address); PATCH /v1/crm/admin/contacts/{id} and DELETE …/{id} edit and remove one.
  • POST /v1/crm/admin/contacts/merge folds 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/organisations files an organisation (upsert by domain); PATCH …/{id} and DELETE …/{id} edit and remove one.
  • POST /v1/crm/admin/tags creates a tag; POST …/tags/tag and POST …/tags/untag file a tag against a subject and take it off again.

§Events

eventemitted when
crm.contact.createdAn upsert inserted a contact.
crm.contact.updatedAn upsert matched an existing contact, a PATCH changed one, or a merge folded another into it.
crm.organisation.createdAn 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:

tablesubjectkinddisposition
crm_contactsidcontacterase
crm_organisations——retained: a business record, not a person’s
crm_tags——not personal: a name and a colour
crm_taggingssubject_id, via crm_contactsidentifiererase

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 prefixed CRM_.