Skip to main content

cratefield_module_crm/
lib.rs

1//! `cratefield-module-crm`: contacts, organisations and tags (Cratefield
2//! issue #572).
3//!
4//! ```no_run
5//! use cratefield_module_crm::Crm;
6//!
7//! let module = Crm::new();
8//! ```
9//!
10//! **Idempotent by natural key.** The module exists so other modules and a
11//! venture's own code can file a person or a company without first asking
12//! whether it is already there: [`store::upsert_contact`] keys a contact on
13//! its normalized address and [`store::upsert_organisation`] keys an
14//! organisation on its domain, so the same address twice is one contact and
15//! the second call updates rather than duplicates.
16//!
17//! **Every write is optimistic.** A row carries a `generation` that every
18//! write bumps; a PATCH must carry the generation it read, and a mismatch is
19//! a `409` (`crm-stale-generation`) rather than a lost update.
20//!
21//! **The routes are admin-only.** Contacts, organisations, their CSV exports
22//! and the tag operations all sit under `/v1/crm/admin/` behind the harness
23//! `ADMIN_TOKEN` bearer, so the module has no public write endpoint and needs
24//! no captcha.
25//!
26//! A contact is personal data and is erased with its taggings; an
27//! organisation is a business record and survives. See [`Crm::personal_data`]
28//! for the declarations the privacy module reads.
29
30#![doc = include_str!("../README.md")]
31#![forbid(unsafe_code)]
32
33mod handlers;
34pub mod store;
35
36use std::sync::Arc;
37
38use cratefield_core::{
39    Config, ConfigError, DataKind, Disposition, Migrations, Module, ModuleContext, PersonalDataSet,
40    Port, SqlMigration, SubjectVia, Surface,
41};
42
43/// The module's name: it is mounted at `/v1/crm`, and its config keys are
44/// prefixed `CRM_`.
45pub const MODULE_NAME: &str = "crm";
46
47/// The module's one migration: its four tables in the portable SQL subset
48/// (ADR 0004). One file serves SQLite, D1 and Postgres, so there is no
49/// `migrations/postgres` override.
50const MIGRATION_INIT: SqlMigration = SqlMigration::new(
51    "0001",
52    "init",
53    include_str!("../migrations/sqlite/0001_init.sql"),
54);
55
56/// The module's four tables, in the order `tables()` lists them.
57const TABLES: &[&str] = &[
58    "crm_contacts",
59    "crm_organisations",
60    "crm_tags",
61    "crm_taggings",
62];
63
64/// A CRM: contacts, organisations and the tags that label them.
65#[derive(Debug, Default, Clone, Copy)]
66pub struct Crm;
67
68impl Crm {
69    /// A CRM. It takes no settings: every route is admin-gated, and there is
70    /// nothing to configure, so [`Module::validate_config`] accepts anything.
71    #[must_use]
72    pub const fn new() -> Self {
73        Self
74    }
75}
76
77impl Module for Crm {
78    fn name(&self) -> &'static str {
79        MODULE_NAME
80    }
81
82    fn version(&self) -> &'static str {
83        env!("CARGO_PKG_VERSION")
84    }
85
86    fn requires(&self) -> &'static [Port] {
87        &[Port::Db, Port::IdGen, Port::Clock]
88    }
89
90    /// `Auth` is declared because a venture may want to file contacts for the
91    /// signed-in caller, and declaring it here keeps that port visible to the
92    /// module rather than silently stripped. No route reads it yet; the
93    /// conformance kit allows a declared-but-unused optional port (issue
94    /// #450), so leaving it off the list until a route needs it would also be
95    /// correct — this is the deliberate side of that choice.
96    fn optional(&self) -> &'static [Port] {
97        &[Port::Auth]
98    }
99
100    fn tables(&self) -> &'static [&'static str] {
101        TABLES
102    }
103
104    /// What each table holds about a person.
105    ///
106    /// The order is load-bearing. `cratefield-module-privacy` erases in
107    /// **reverse** catalog order, so the tables are declared parent-first:
108    /// `crm_taggings` is last, and its rows — which reach a contact through
109    /// `crm_contacts` — are deleted before the contact they name.
110    ///
111    /// `crm_taggings` declares [`SubjectVia`] rather than a plain subject
112    /// because its `subject_id` is polymorphic and there is no foreign key to
113    /// filter on. The join cannot express `subject_type = 'contact'`, so it
114    /// matches a contact id in `subject_id` whatever the type says — safe
115    /// only because those ids are unique ULIDs that no organisation or item
116    /// row shares.
117    fn personal_data(&self) -> &'static [PersonalDataSet] {
118        const SETS: &[PersonalDataSet] = &[
119            PersonalDataSet {
120                table: "crm_contacts",
121                subject: "id",
122                kind: DataKind::Contact,
123                disposition: Disposition::Erase,
124                description: "One person or lead: their email address, name, phone number, \
125                              locale, an optional link to the organisation they belong to, the \
126                              source they arrived from, and any structured fields the venture \
127                              keeps alongside them.",
128                redacted: &[],
129                subject_via: None,
130            },
131            PersonalDataSet::none(
132                "crm_organisations",
133                "The companies this venture deals with, a business record rather than a \
134                 person's: name, domain, website and postal address. Review before relying on \
135                 this: `email` and `phone` may hold an individual's details rather than a \
136                 shared switchboard, and a sole trader's record is a person's data in \
137                 practice. Erasure does not reach these rows today.",
138            ),
139            PersonalDataSet::none(
140                "crm_tags",
141                "The labels a venture files its records under — a name and a colour. Nothing \
142                 in this table names a person.",
143            ),
144            PersonalDataSet {
145                table: "crm_taggings",
146                subject: "subject_id",
147                kind: DataKind::Identifier,
148                disposition: Disposition::Erase,
149                description: "Which labels are filed against which record. The row holds a tag \
150                              and the id of whatever it labels — a contact, an organisation, or \
151                              an item the venture owns — so it is erased with the contact it \
152                              names.",
153                redacted: &[],
154                subject_via: Some(SubjectVia {
155                    table: "crm_contacts",
156                    subject: "id",
157                    key: "id",
158                }),
159            },
160        ];
161        SETS
162    }
163
164    fn emits(&self) -> &'static [&'static str] {
165        &[
166            handlers::EVENT_CONTACT_CREATED,
167            handlers::EVENT_CONTACT_UPDATED,
168            handlers::EVENT_ORGANISATION_CREATED,
169        ]
170    }
171
172    fn migrations(&self) -> Migrations {
173        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
174        // The array is the apply order; this refuses a gap, a duplicate or an
175        // entry out of order at build time (issue #27).
176        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
177        Migrations {
178            sqlite: &MIGRATIONS,
179            postgres: &[],
180        }
181    }
182
183    fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
184        // No keys: every route is admin-gated and every default is a constant.
185        Ok(())
186    }
187
188    fn router(&self, ctx: ModuleContext) -> axum::Router {
189        handlers::router(Arc::new(ctx))
190    }
191
192    fn surface(&self) -> Surface {
193        handlers::surface()
194    }
195}