use cratefield_core::{Database, DbError, Row, Statement, normalize_email, validation_error};
use sea_query::Value as SeaValue;
use serde_json::{Map, Value, json};
pub(crate) const SUBJECT_CONTACT: &str = "contact";
pub(crate) const SUBJECT_ORGANISATION: &str = "organisation";
#[must_use]
pub fn is_subject_type(subject_type: &str) -> bool {
matches!(subject_type, "contact" | "organisation" | "item")
}
#[derive(Debug, Clone, PartialEq)]
pub struct Contact {
pub id: String,
pub email: Option<String>,
pub email_normalized: Option<String>,
pub name: Option<String>,
pub phone: Option<String>,
pub locale: Option<String>,
pub organisation_id: Option<String>,
pub source: Option<String>,
pub data: Value,
pub created_at: String,
pub updated_at: String,
pub generation: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Organisation {
pub id: String,
pub name: String,
pub domain: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub address: Value,
pub data: Value,
pub created_at: String,
pub updated_at: String,
pub generation: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Tag {
pub id: String,
pub name: String,
pub color: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ContactUpsert {
pub email: String,
pub name: Option<String>,
pub phone: Option<String>,
pub locale: Option<String>,
pub organisation_id: Option<String>,
pub source: Option<String>,
pub data: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct OrganisationUpsert {
pub name: String,
pub domain: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub address: Option<Value>,
pub data: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Upserted<T> {
pub record: T,
pub created: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CrmError {
InvalidEmail(&'static str),
BlankName,
NotAnObject(&'static str),
Taken(&'static str),
UnknownOrganisation,
Db(DbError),
}
impl std::fmt::Display for CrmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidEmail(reason) => write!(f, "email: {reason}"),
Self::BlankName => f.write_str("name must not be blank"),
Self::NotAnObject(field) => write!(f, "{field} must be a JSON object"),
Self::Taken(field) => write!(f, "{field} is already in use"),
Self::UnknownOrganisation => f.write_str("organisation_id names no organisation"),
Self::Db(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for CrmError {}
impl From<DbError> for CrmError {
fn from(err: DbError) -> Self {
Self::Db(err)
}
}
#[allow(clippy::option_option)]
pub(crate) type Field<T> = Option<Option<T>>;
pub(crate) struct ContactUpdate {
pub email: Field<String>,
pub name: Field<String>,
pub phone: Field<String>,
pub locale: Field<String>,
pub organisation_id: Field<String>,
pub source: Field<String>,
pub data: Field<Value>,
}
pub(crate) struct OrganisationUpdate {
pub name: Field<String>,
pub domain: Field<String>,
pub website: Field<String>,
pub email: Field<String>,
pub phone: Field<String>,
pub address: Field<Value>,
pub data: Field<Value>,
}
fn text(value: &str) -> SeaValue {
SeaValue::String(Some(Box::new(value.to_owned())))
}
fn null() -> SeaValue {
SeaValue::String(None)
}
fn opt_text(value: Option<&str>) -> SeaValue {
value.map_or_else(null, text)
}
fn json_text(value: &Value) -> SeaValue {
text(&value.to_string())
}
fn opt_json(value: Option<&Value>) -> SeaValue {
value.map_or_else(null, json_text)
}
fn int(value: i64) -> SeaValue {
SeaValue::BigInt(Some(value))
}
fn limit(n: u64) -> SeaValue {
int(i64::try_from(n).unwrap_or(i64::MAX))
}
fn parse_json(raw: Option<String>) -> Value {
raw.and_then(|text| serde_json::from_str(&text).ok())
.filter(Value::is_object)
.unwrap_or_else(|| json!({}))
}
fn row_text(row: &Row, column: &str) -> Option<String> {
row.get::<Option<String>>(column).flatten()
}
fn row_id(row: &Row) -> String {
row.get::<String>("id").unwrap_or_default()
}
fn row_time(row: &Row, column: &str) -> String {
row.get::<String>(column).unwrap_or_default()
}
fn row_generation(row: &Row) -> i64 {
row.get::<i64>("generation").unwrap_or(1)
}
pub(crate) trait Record: Sized {
const TABLE: &'static str;
const COLUMNS: &'static str;
fn from_row(row: &Row) -> Self;
}
impl Record for Contact {
const TABLE: &'static str = "crm_contacts";
const COLUMNS: &'static str = "id, email, email_normalized, name, phone, locale, \
organisation_id, source, data, created_at, updated_at, generation";
fn from_row(row: &Row) -> Self {
Self {
id: row_id(row),
email: row_text(row, "email"),
email_normalized: row_text(row, "email_normalized"),
name: row_text(row, "name"),
phone: row_text(row, "phone"),
locale: row_text(row, "locale"),
organisation_id: row_text(row, "organisation_id"),
source: row_text(row, "source"),
data: parse_json(row_text(row, "data")),
created_at: row_time(row, "created_at"),
updated_at: row_time(row, "updated_at"),
generation: row_generation(row),
}
}
}
impl Record for Organisation {
const TABLE: &'static str = "crm_organisations";
const COLUMNS: &'static str = "id, name, domain, website, email, phone, address, data, \
created_at, updated_at, generation";
fn from_row(row: &Row) -> Self {
Self {
id: row_id(row),
name: row.get::<String>("name").unwrap_or_default(),
domain: row_text(row, "domain"),
website: row_text(row, "website"),
email: row_text(row, "email"),
phone: row_text(row, "phone"),
address: parse_json(row_text(row, "address")),
data: parse_json(row_text(row, "data")),
created_at: row_time(row, "created_at"),
updated_at: row_time(row, "updated_at"),
generation: row_generation(row),
}
}
}
impl Record for Tag {
const TABLE: &'static str = "crm_tags";
const COLUMNS: &'static str = "id, name, color";
fn from_row(row: &Row) -> Self {
Self {
id: row_id(row),
name: row.get::<String>("name").unwrap_or_default(),
color: row_text(row, "color"),
}
}
}
pub(crate) async fn find<T: Record>(
db: &dyn Database,
column: &str,
value: &str,
) -> Result<Option<T>, DbError> {
let rows = db
.query(&Statement::with_values(
format!("SELECT {} FROM {} WHERE {column} = ?", T::COLUMNS, T::TABLE),
vec![text(value)],
))
.await?;
Ok(rows.first().map(T::from_row))
}
pub(crate) async fn list<T: Record>(
db: &dyn Database,
fetch: u64,
offset: u64,
) -> Result<Vec<T>, DbError> {
let rows = db
.query(&Statement::with_values(
format!(
"SELECT {} FROM {} ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?",
T::COLUMNS,
T::TABLE
),
vec![limit(fetch), limit(offset)],
))
.await?;
Ok(rows.rows.iter().map(T::from_row).collect())
}
const CONTACT_UPSERT: &str = "INSERT INTO crm_contacts \
(id, email, email_normalized, name, phone, locale, organisation_id, source, data, created_at, \
updated_at, generation) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, '{}'), ?, ?, 1) \
ON CONFLICT(email_normalized) DO UPDATE SET \
email = excluded.email, \
name = COALESCE(?, crm_contacts.name), \
phone = COALESCE(?, crm_contacts.phone), \
locale = COALESCE(?, crm_contacts.locale), \
organisation_id = COALESCE(?, crm_contacts.organisation_id), \
source = COALESCE(?, crm_contacts.source), \
data = COALESCE(?, crm_contacts.data), \
updated_at = ?, \
generation = crm_contacts.generation + 1";
pub async fn upsert_contact(
db: &dyn Database,
id: &str,
contact: &ContactUpsert,
now: &str,
) -> Result<Upserted<Contact>, CrmError> {
let normalized = normalize_email(&contact.email);
validate_email(&normalized)?;
if let Some(data) = &contact.data
&& !data.is_object()
{
return Err(CrmError::NotAnObject("data"));
}
if let Some(organisation_id) = &contact.organisation_id {
require_organisation(db, organisation_id).await?;
}
let optional = [
opt_text(contact.name.as_deref()),
opt_text(contact.phone.as_deref()),
opt_text(contact.locale.as_deref()),
opt_text(contact.organisation_id.as_deref()),
opt_text(contact.source.as_deref()),
opt_json(contact.data.as_ref()),
];
let created = find::<Contact>(db, "email_normalized", &normalized)
.await?
.is_none();
let mut values = vec![text(id), text(contact.email.trim()), text(&normalized)];
values.extend(optional.iter().cloned());
values.push(text(now));
values.push(text(now));
values.extend(optional);
values.push(text(now));
db.execute(&Statement::with_values(CONTACT_UPSERT, values))
.await?;
let found = find::<Contact>(db, "email_normalized", &normalized).await?;
Ok(Upserted {
record: reread(found, "contact")?,
created,
})
}
const ORGANISATION_INSERT: &str = "INSERT INTO crm_organisations \
(id, name, domain, website, email, phone, address, data, created_at, updated_at, generation) \
VALUES (?, ?, ?, ?, ?, ?, COALESCE(?, '{}'), COALESCE(?, '{}'), ?, ?, 1)";
const ORGANISATION_CONFLICT: &str = " ON CONFLICT(domain) DO UPDATE SET \
name = excluded.name, \
website = COALESCE(?, crm_organisations.website), \
email = COALESCE(?, crm_organisations.email), \
phone = COALESCE(?, crm_organisations.phone), \
address = COALESCE(?, crm_organisations.address), \
data = COALESCE(?, crm_organisations.data), \
updated_at = ?, \
generation = crm_organisations.generation + 1";
pub async fn upsert_organisation(
db: &dyn Database,
id: &str,
organisation: &OrganisationUpsert,
now: &str,
) -> Result<Upserted<Organisation>, CrmError> {
let name = organisation.name.trim();
if name.is_empty() {
return Err(CrmError::BlankName);
}
for (field, value) in [
("address", &organisation.address),
("data", &organisation.data),
] {
if let Some(value) = value
&& !value.is_object()
{
return Err(CrmError::NotAnObject(field));
}
}
let domain = organisation
.domain
.as_deref()
.map(str::trim)
.map(str::to_lowercase)
.filter(|domain| !domain.is_empty());
let optional = [
opt_text(organisation.website.as_deref()),
opt_text(organisation.email.as_deref()),
opt_text(organisation.phone.as_deref()),
opt_json(organisation.address.as_ref()),
opt_json(organisation.data.as_ref()),
];
let created = match &domain {
Some(domain) => find::<Organisation>(db, "domain", domain).await?.is_none(),
None => true,
};
let mut values = vec![text(id), text(name), opt_text(domain.as_deref())];
values.extend(optional.iter().cloned());
values.push(text(now));
values.push(text(now));
let sql = match &domain {
Some(_) => {
values.extend(optional);
values.push(text(now));
format!("{ORGANISATION_INSERT}{ORGANISATION_CONFLICT}")
}
None => ORGANISATION_INSERT.to_owned(),
};
db.execute(&Statement::with_values(sql, values)).await?;
let found = match &domain {
Some(domain) => find::<Organisation>(db, "domain", domain).await?,
None => find::<Organisation>(db, "id", id).await?,
};
Ok(Upserted {
record: reread(found, "organisation")?,
created,
})
}
fn validate_email(normalized: &str) -> Result<(), CrmError> {
match validation_error(normalized) {
Some(reason) => Err(CrmError::InvalidEmail(reason)),
None => Ok(()),
}
}
async fn require_organisation(db: &dyn Database, id: &str) -> Result<(), CrmError> {
if find::<Organisation>(db, "id", id).await?.is_none() {
return Err(CrmError::UnknownOrganisation);
}
Ok(())
}
fn reread<T>(found: Option<T>, what: &str) -> Result<T, CrmError> {
found.ok_or_else(|| {
CrmError::Db(DbError::Query(format!(
"the {what} is gone after its own write"
)))
})
}
pub(crate) async fn update_contact(
db: &dyn Database,
id: &str,
generation: i64,
update: &ContactUpdate,
now: &str,
) -> Result<u64, CrmError> {
let mut normalized_email = None;
if let Some(Some(email)) = &update.email {
let normalized = normalize_email(email);
validate_email(&normalized)?;
normalized_email = Some(normalized);
}
if let Some(Some(organisation_id)) = &update.organisation_id {
require_organisation(db, organisation_id).await?;
}
if let Some(Some(value)) = &update.data
&& !value.is_object()
{
return Err(CrmError::NotAnObject("data"));
}
if let Some(normalized) = &normalized_email
&& let Some(existing) = find::<Contact>(db, "email_normalized", normalized).await?
&& existing.id != id
{
return Err(CrmError::Taken("email"));
}
let mut sql = Update::new(now);
if let Some(field) = &update.email {
sql.set("email", opt_text(field.as_deref().map(str::trim)));
sql.set("email_normalized", opt_text(normalized_email.as_deref()));
}
for (column, field) in [
("name", &update.name),
("phone", &update.phone),
("locale", &update.locale),
("organisation_id", &update.organisation_id),
("source", &update.source),
] {
sql.text(column, field);
}
sql.json("data", &update.data);
Ok(sql.run(db, Contact::TABLE, id, generation).await?)
}
pub(crate) async fn update_organisation(
db: &dyn Database,
id: &str,
generation: i64,
update: &OrganisationUpdate,
now: &str,
) -> Result<u64, CrmError> {
if let Some(Some(name)) = &update.name
&& name.trim().is_empty()
{
return Err(CrmError::BlankName);
}
for (field, value) in [("address", &update.address), ("data", &update.data)] {
if let Some(Some(value)) = value
&& !value.is_object()
{
return Err(CrmError::NotAnObject(field));
}
}
let normalized_domain = update.domain.as_ref().map(|field| {
field
.as_deref()
.map(str::trim)
.map(str::to_lowercase)
.filter(|domain| !domain.is_empty())
});
if let Some(Some(domain)) = &normalized_domain
&& let Some(existing) = find::<Organisation>(db, "domain", domain).await?
&& existing.id != id
{
return Err(CrmError::Taken("domain"));
}
let mut sql = Update::new(now);
for (column, field) in [
("name", &update.name),
("website", &update.website),
("email", &update.email),
("phone", &update.phone),
] {
sql.text(column, field);
}
if let Some(normalized) = &normalized_domain {
sql.set("domain", opt_text(normalized.as_deref()));
}
sql.json("address", &update.address);
sql.json("data", &update.data);
Ok(sql.run(db, Organisation::TABLE, id, generation).await?)
}
struct Update {
sets: Vec<String>,
values: Vec<SeaValue>,
}
impl Update {
fn new(now: &str) -> Self {
Self {
sets: vec![
"updated_at = ?".to_owned(),
"generation = generation + 1".to_owned(),
],
values: vec![text(now)],
}
}
#[allow(clippy::ref_option)]
fn text(&mut self, column: &str, field: &Field<String>) {
if let Some(value) = field {
self.set(column, opt_text(value.as_deref()));
}
}
#[allow(clippy::ref_option)] fn json(&mut self, column: &str, field: &Field<Value>) {
if let Some(value) = field {
self.set(column, value.as_ref().map_or_else(|| text("{}"), json_text));
}
}
fn set(&mut self, column: &str, value: SeaValue) {
self.sets.push(format!("{column} = ?"));
self.values.push(value);
}
async fn run(
self,
db: &dyn Database,
table: &str,
id: &str,
generation: i64,
) -> Result<u64, DbError> {
let mut values = self.values;
values.push(text(id));
values.push(int(generation));
let sql = format!(
"UPDATE {table} SET {} WHERE id = ? AND generation = ?",
self.sets.join(", ")
);
db.execute(&Statement::with_values(sql, values)).await
}
}
pub(crate) async fn delete_contact(db: &dyn Database, id: &str) -> Result<(), DbError> {
delete_with_taggings(db, Contact::TABLE, SUBJECT_CONTACT, id).await
}
pub(crate) async fn delete_organisation(db: &dyn Database, id: &str) -> Result<(), DbError> {
delete_with_taggings(db, Organisation::TABLE, SUBJECT_ORGANISATION, id).await
}
async fn delete_with_taggings(
db: &dyn Database,
table: &str,
subject_type: &str,
id: &str,
) -> Result<(), DbError> {
db.batch_atomic(&[
Statement::with_values(
"DELETE FROM crm_taggings WHERE subject_type = ? AND subject_id = ?",
vec![text(subject_type), text(id)],
),
Statement::with_values(format!("DELETE FROM {table} WHERE id = ?"), vec![text(id)]),
])
.await
}
pub(crate) async fn merge_contacts(
db: &dyn Database,
keep: &Contact,
merge: &Contact,
now: &str,
) -> Result<(), DbError> {
let data = merged_data(&keep.data, &merge.data);
let fill = |kept: Option<&str>, merged: Option<&str>| opt_text(kept.or(merged));
db.batch_atomic(&[
Statement::with_values(
"DELETE FROM crm_taggings WHERE subject_type = ? AND subject_id = ? AND tag_id IN \
(SELECT tag_id FROM crm_taggings WHERE subject_type = ? AND subject_id = ?)",
vec![
text(SUBJECT_CONTACT),
text(&keep.id),
text(SUBJECT_CONTACT),
text(&merge.id),
],
),
Statement::with_values(
"UPDATE crm_taggings SET subject_id = ? WHERE subject_type = ? AND subject_id = ?",
vec![text(&keep.id), text(SUBJECT_CONTACT), text(&merge.id)],
),
Statement::with_values(
"DELETE FROM crm_contacts WHERE id = ?",
vec![text(&merge.id)],
),
Statement::with_values(
"UPDATE crm_contacts SET email = ?, email_normalized = ?, name = ?, phone = ?, \
locale = ?, organisation_id = ?, source = ?, data = ?, updated_at = ?, \
generation = generation + 1 WHERE id = ?",
vec![
fill(keep.email.as_deref(), merge.email.as_deref()),
fill(
keep.email_normalized.as_deref(),
merge.email_normalized.as_deref(),
),
fill(keep.name.as_deref(), merge.name.as_deref()),
fill(keep.phone.as_deref(), merge.phone.as_deref()),
fill(keep.locale.as_deref(), merge.locale.as_deref()),
fill(
keep.organisation_id.as_deref(),
merge.organisation_id.as_deref(),
),
fill(keep.source.as_deref(), merge.source.as_deref()),
json_text(&data),
text(now),
text(&keep.id),
],
),
])
.await
}
fn merged_data(keep: &Value, merge: &Value) -> Value {
let (Value::Object(keep), Value::Object(merge)) = (keep, merge) else {
return keep.clone();
};
let mut merged: Map<String, Value> = merge.clone();
for (key, value) in keep {
merged.insert(key.clone(), value.clone());
}
Value::Object(merged)
}
pub(crate) async fn upsert_tag(
db: &dyn Database,
id: &str,
name: &str,
color: Option<&str>,
) -> Result<Upserted<Tag>, DbError> {
let created = find::<Tag>(db, "name", name).await?.is_none();
db.execute(&Statement::with_values(
"INSERT INTO crm_tags (id, name, color) VALUES (?, ?, ?) \
ON CONFLICT(name) DO UPDATE SET color = COALESCE(?, crm_tags.color)",
vec![text(id), text(name), opt_text(color), opt_text(color)],
))
.await?;
let record = find::<Tag>(db, "name", name)
.await?
.ok_or_else(|| DbError::Query("the tag is gone after its own upsert".to_owned()))?;
Ok(Upserted { record, created })
}
pub(crate) async fn tag_subject(
db: &dyn Database,
tag_id: &str,
subject_type: &str,
subject_id: &str,
) -> Result<(), DbError> {
db.execute(&Statement::with_values(
"INSERT INTO crm_taggings (tag_id, subject_type, subject_id) VALUES (?, ?, ?) \
ON CONFLICT(tag_id, subject_type, subject_id) DO NOTHING",
vec![text(tag_id), text(subject_type), text(subject_id)],
))
.await?;
Ok(())
}
pub(crate) async fn untag_subject(
db: &dyn Database,
tag_id: &str,
subject_type: &str,
subject_id: &str,
) -> Result<u64, DbError> {
db.execute(&Statement::with_values(
"DELETE FROM crm_taggings WHERE tag_id = ? AND subject_type = ? AND subject_id = ?",
vec![text(tag_id), text(subject_type), text(subject_id)],
))
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_data_merge_keeps_the_survivors_keys() {
let merged = merged_data(
&json!({"tier": "gold", "note": "keep"}),
&json!({"tier": "bronze", "tags": ["a"], "note": "merge"}),
);
assert_eq!(
merged,
json!({"tier": "gold", "note": "keep", "tags": ["a"]})
);
}
#[test]
fn subject_types_are_the_three_the_constraint_accepts() {
assert!(is_subject_type("contact"));
assert!(is_subject_type("organisation"));
assert!(is_subject_type("item"));
assert!(!is_subject_type("Contact"));
assert!(!is_subject_type("customer"));
}
}