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}