use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, patch, post};
use cratefield_core::{
Action, Audience, Clock, Column, Database, IdGen, Json, MAX_EXPORT_ROWS, ModuleContext,
Outcome, Problem, ProblemDef, Scope, Surface, View, csv_row, require_admin,
};
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer};
use serde_json::{Value, json};
use time::format_description::well_known::Rfc3339;
use crate::store::{
self, Contact, ContactUpdate, ContactUpsert, CrmError, Field, Organisation, OrganisationUpdate,
OrganisationUpsert, Record,
};
pub(crate) const EVENT_CONTACT_CREATED: &str = "crm.contact.created";
pub(crate) const EVENT_CONTACT_UPDATED: &str = "crm.contact.updated";
pub(crate) const EVENT_ORGANISATION_CREATED: &str = "crm.organisation.created";
pub(crate) const STALE_GENERATION: ProblemDef = ProblemDef {
slug: "crm-stale-generation",
status: StatusCode::CONFLICT,
title: "The record has changed since you read it",
description: "The `generation` in the body is not the one the row holds now, so somebody \
else wrote to it in between. Nothing was changed: read the record again, \
reapply your edit to the current values and retry with the new generation.",
};
pub(crate) const NATURAL_KEY_TAKEN: ProblemDef = ProblemDef {
slug: "crm-already-exists",
status: StatusCode::CONFLICT,
title: "That value is already taken",
description: "A contact's normalized email address and an organisation's domain are each \
unique. Another row already holds the value you tried to set, so nothing was \
changed: use a different value, or merge the two records.",
};
pub(crate) fn router(ctx: Arc<ModuleContext>) -> axum::Router {
axum::Router::new()
.route("/admin/contacts.csv", get(admin_contacts_export))
.route("/admin/organisations.csv", get(admin_organisations_export))
.route("/admin/contacts", post(admin_contact_create))
.route("/admin/contacts/merge", post(admin_contact_merge))
.route(
"/admin/contacts/{id}",
patch(admin_contact_update).delete(admin_contact_delete),
)
.route("/admin/organisations", post(admin_organisation_create))
.route(
"/admin/organisations/{id}",
patch(admin_organisation_update).delete(admin_organisation_delete),
)
.route("/admin/tags", post(admin_tag_create))
.route("/admin/tags/tag", post(admin_tag_subject))
.route("/admin/tags/untag", post(admin_untag_subject))
.with_state(ctx)
}
struct Runtime {
db: Arc<dyn Database>,
clock: Arc<dyn Clock>,
id_gen: Arc<dyn IdGen>,
}
impl Runtime {
fn of(ctx: &ModuleContext) -> Option<Self> {
Some(Self {
db: ctx.ports.db.clone()?,
clock: ctx.ports.clock.clone()?,
id_gen: ctx.ports.id_gen.clone()?,
})
}
fn now(&self) -> String {
self.clock
.now()
.replace_nanosecond(0)
.expect("truncation stays in range")
.format(&Rfc3339)
.unwrap_or_default()
}
fn ulid(&self) -> String {
self.id_gen.ulid()
}
}
fn internal(scope: &Scope) -> Problem {
Problem::internal().instance(&scope.request_id)
}
fn ready(ctx: &ModuleContext, headers: &HeaderMap, scope: &Scope) -> Result<Runtime, Problem> {
require_admin(&*ctx.config, headers).map_err(|problem| problem.instance(&scope.request_id))?;
Runtime::of(ctx).ok_or_else(|| internal(scope))
}
fn store_problem(err: CrmError, scope: &Scope) -> Problem {
match err {
CrmError::InvalidEmail(reason) => {
Problem::validation_failed(format!("email: {reason}")).instance(&scope.request_id)
}
CrmError::BlankName => Problem::validation_failed("name must not be blank".to_owned())
.instance(&scope.request_id),
CrmError::NotAnObject(field) => {
Problem::validation_failed(format!("{field} must be a JSON object"))
.instance(&scope.request_id)
}
CrmError::Taken(field) => Problem::new(&NATURAL_KEY_TAKEN)
.with_detail(format!("that {field} is already in use"))
.instance(&scope.request_id),
CrmError::UnknownOrganisation => {
Problem::validation_failed("organisation_id names no organisation".to_owned())
.instance(&scope.request_id)
}
CrmError::Db(err) => {
tracing::error!(error = %err, "crm: a database statement failed");
internal(scope)
}
}
}
fn stale(scope: &Scope) -> Problem {
Problem::new(&STALE_GENERATION).instance(&scope.request_id)
}
fn not_found(scope: &Scope) -> Problem {
Problem::not_found().instance(&scope.request_id)
}
async fn by_id<T: Record>(rt: &Runtime, scope: &Scope, id: &str) -> Result<T, Problem> {
store::find::<T>(&*rt.db, "id", id)
.await
.map_err(|err| store_problem(err.into(), scope))?
.ok_or_else(|| not_found(scope))
}
fn upsert_status(created: bool) -> StatusCode {
if created {
StatusCode::CREATED
} else {
StatusCode::OK
}
}
fn ok_json() -> Response {
Json(json!({ "ok": true })).into_response()
}
#[allow(clippy::option_option)] fn explicit<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
async fn export<T: Record + CsvRow>(
rt: &Runtime,
scope: &Scope,
query: &ExportQuery,
) -> Result<Response, Problem> {
let cap = u32::try_from(MAX_EXPORT_ROWS).unwrap_or(u32::MAX);
let limit = query.limit.unwrap_or(cap).clamp(1, cap);
let offset = u64::from(query.offset.unwrap_or(0));
let rows = store::list::<T>(&*rt.db, u64::from(limit) + 1, offset)
.await
.map_err(|err| store_problem(err.into(), scope))?;
let limit = usize::try_from(limit).unwrap_or(usize::MAX);
let more = rows.len() > limit;
let rows = if more { &rows[..limit] } else { &rows[..] };
let mut body = String::from(T::HEADER);
for row in rows {
let cells = row.cells();
let cells: Vec<&str> = cells.iter().map(String::as_str).collect();
body.push_str(&csv_row(&cells));
}
Ok(csv_response(body, more))
}
fn csv_response(body: String, more: bool) -> Response {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/csv; charset=utf-8"),
);
if more {
headers.insert(
axum::http::HeaderName::from_static("x-cf-export-more"),
HeaderValue::from_static("true"),
);
}
(StatusCode::OK, headers, body).into_response()
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ContactBody {
#[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
email: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
phone: Option<String>,
#[serde(default)]
locale: Option<String>,
#[serde(default)]
organisation_id: Option<String>,
#[serde(default)]
source: Option<String>,
#[serde(default)]
data: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ContactPatch {
generation: i64,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
email: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
name: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
phone: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
locale: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
organisation_id: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
source: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<serde_json::Value>")]
data: Field<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct OrganisationBody {
name: String,
#[serde(default)]
domain: Option<String>,
#[serde(default)]
website: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
phone: Option<String>,
#[serde(default)]
address: Option<Value>,
#[serde(default)]
data: Option<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct OrganisationPatch {
generation: i64,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
name: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
domain: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
website: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
email: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<String>")]
phone: Field<String>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<serde_json::Value>")]
address: Field<Value>,
#[serde(default, deserialize_with = "explicit")]
#[schemars(with = "Option<serde_json::Value>")]
data: Field<Value>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct MergeBody {
keep: String,
#[serde(rename = "merge")]
merge: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TagBody {
name: String,
#[serde(default)]
color: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TagSubjectBody {
tag_id: String,
subject_type: String,
subject_id: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
struct ExportQuery {
limit: Option<u32>,
offset: Option<u32>,
}
impl ContactBody {
fn into_upsert(self) -> ContactUpsert {
ContactUpsert {
email: self.email,
name: self.name,
phone: self.phone,
locale: self.locale,
organisation_id: self.organisation_id,
source: self.source,
data: self.data,
}
}
}
impl ContactPatch {
fn into_update(self) -> ContactUpdate {
ContactUpdate {
email: self.email,
name: self.name,
phone: self.phone,
locale: self.locale,
organisation_id: self.organisation_id,
source: self.source,
data: self.data,
}
}
}
impl OrganisationBody {
fn into_upsert(self) -> OrganisationUpsert {
OrganisationUpsert {
name: self.name,
domain: self.domain,
website: self.website,
email: self.email,
phone: self.phone,
address: self.address,
data: self.data,
}
}
}
impl OrganisationPatch {
fn into_update(self) -> OrganisationUpdate {
OrganisationUpdate {
name: self.name,
domain: self.domain,
website: self.website,
email: self.email,
phone: self.phone,
address: self.address,
data: self.data,
}
}
}
fn contact_json(contact: &Contact) -> Value {
json!({
"id": contact.id,
"email": contact.email,
"emailNormalized": contact.email_normalized,
"name": contact.name,
"phone": contact.phone,
"locale": contact.locale,
"organisationId": contact.organisation_id,
"source": contact.source,
"data": contact.data,
"createdAt": contact.created_at,
"updatedAt": contact.updated_at,
"generation": contact.generation,
})
}
fn organisation_json(organisation: &Organisation) -> Value {
json!({
"id": organisation.id,
"name": organisation.name,
"domain": organisation.domain,
"website": organisation.website,
"email": organisation.email,
"phone": organisation.phone,
"address": organisation.address,
"data": organisation.data,
"createdAt": organisation.created_at,
"updatedAt": organisation.updated_at,
"generation": organisation.generation,
})
}
trait CsvRow {
const HEADER: &'static str;
fn cells(&self) -> Vec<String>;
}
impl CsvRow for Contact {
const HEADER: &'static str = "id,email,email_normalized,name,phone,locale,organisation_id,\
source,data,created_at,updated_at,generation\n";
fn cells(&self) -> Vec<String> {
vec![
self.id.clone(),
self.email.clone().unwrap_or_default(),
self.email_normalized.clone().unwrap_or_default(),
self.name.clone().unwrap_or_default(),
self.phone.clone().unwrap_or_default(),
self.locale.clone().unwrap_or_default(),
self.organisation_id.clone().unwrap_or_default(),
self.source.clone().unwrap_or_default(),
self.data.to_string(),
self.created_at.clone(),
self.updated_at.clone(),
self.generation.to_string(),
]
}
}
impl CsvRow for Organisation {
const HEADER: &'static str =
"id,name,domain,website,email,phone,address,data,created_at,updated_at,generation\n";
fn cells(&self) -> Vec<String> {
vec![
self.id.clone(),
self.name.clone(),
self.domain.clone().unwrap_or_default(),
self.website.clone().unwrap_or_default(),
self.email.clone().unwrap_or_default(),
self.phone.clone().unwrap_or_default(),
self.address.to_string(),
self.data.to_string(),
self.created_at.clone(),
self.updated_at.clone(),
self.generation.to_string(),
]
}
}
async fn admin_contact_create(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Json(body): Json<ContactBody>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
let upserted = store::upsert_contact(&*rt.db, &rt.ulid(), &body.into_upsert(), &rt.now())
.await
.map_err(|err| store_problem(err, &scope))?;
let event = if upserted.created {
EVENT_CONTACT_CREATED
} else {
EVENT_CONTACT_UPDATED
};
ctx.events
.emit_in(&scope, event, json!({ "contact_id": upserted.record.id }));
Ok((
upsert_status(upserted.created),
Json(contact_json(&upserted.record)),
)
.into_response())
}
async fn admin_contact_update(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<ContactPatch>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
let (generation, update) = (body.generation, body.into_update());
by_id::<Contact>(&rt, &scope, &id).await?;
let touched = store::update_contact(&*rt.db, &id, generation, &update, &rt.now())
.await
.map_err(|err| store_problem(err, &scope))?;
if touched == 0 {
return Err(stale(&scope));
}
let updated = by_id::<Contact>(&rt, &scope, &id).await?;
ctx.events.emit_in(
&scope,
EVENT_CONTACT_UPDATED,
json!({ "contact_id": updated.id }),
);
Ok(Json(contact_json(&updated)).into_response())
}
async fn admin_contact_delete(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
by_id::<Contact>(&rt, &scope, &id).await?;
store::delete_contact(&*rt.db, &id)
.await
.map_err(|err| store_problem(err.into(), &scope))?;
Ok(ok_json())
}
async fn admin_contact_merge(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Json(body): Json<MergeBody>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
if body.keep == body.merge {
return Err(
Problem::validation_failed("keep and merge must be two different contacts")
.instance(&scope.request_id),
);
}
let keep = by_id::<Contact>(&rt, &scope, &body.keep).await?;
let merge = by_id::<Contact>(&rt, &scope, &body.merge).await?;
store::merge_contacts(&*rt.db, &keep, &merge, &rt.now())
.await
.map_err(|err| store_problem(err.into(), &scope))?;
let kept = by_id::<Contact>(&rt, &scope, &keep.id).await?;
ctx.events.emit_in(
&scope,
EVENT_CONTACT_UPDATED,
json!({ "contact_id": kept.id, "merged_from": merge.id }),
);
Ok(Json(contact_json(&kept)).into_response())
}
async fn admin_contacts_export(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Query(query): Query<ExportQuery>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
export::<Contact>(&rt, &scope, &query).await
}
async fn admin_organisation_create(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Json(body): Json<OrganisationBody>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
let upserted = store::upsert_organisation(&*rt.db, &rt.ulid(), &body.into_upsert(), &rt.now())
.await
.map_err(|err| store_problem(err, &scope))?;
if upserted.created {
ctx.events.emit_in(
&scope,
EVENT_ORGANISATION_CREATED,
json!({ "organisation_id": upserted.record.id }),
);
}
Ok((
upsert_status(upserted.created),
Json(organisation_json(&upserted.record)),
)
.into_response())
}
async fn admin_organisation_update(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<OrganisationPatch>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
let (generation, update) = (body.generation, body.into_update());
by_id::<Organisation>(&rt, &scope, &id).await?;
let touched = store::update_organisation(&*rt.db, &id, generation, &update, &rt.now())
.await
.map_err(|err| store_problem(err, &scope))?;
if touched == 0 {
return Err(stale(&scope));
}
let updated = by_id::<Organisation>(&rt, &scope, &id).await?;
Ok(Json(organisation_json(&updated)).into_response())
}
async fn admin_organisation_delete(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
by_id::<Organisation>(&rt, &scope, &id).await?;
store::delete_organisation(&*rt.db, &id)
.await
.map_err(|err| store_problem(err.into(), &scope))?;
Ok(ok_json())
}
async fn admin_organisations_export(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Query(query): Query<ExportQuery>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
export::<Organisation>(&rt, &scope, &query).await
}
async fn admin_tag_create(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Json(body): Json<TagBody>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
let name = body.name.trim();
if name.is_empty() {
return Err(
Problem::validation_failed("name must not be blank").instance(&scope.request_id)
);
}
let tag = store::upsert_tag(&*rt.db, &rt.ulid(), name, body.color.as_deref())
.await
.map_err(|err| store_problem(err.into(), &scope))?;
Ok((
upsert_status(tag.created),
Json(json!({ "id": tag.record.id, "name": tag.record.name, "color": tag.record.color })),
)
.into_response())
}
async fn tag_and_subject(
rt: &Runtime,
scope: &Scope,
body: &TagSubjectBody,
) -> Result<(), Problem> {
if !store::is_subject_type(&body.subject_type) {
return Err(Problem::validation_failed(
"subject_type must be one of: contact, organisation, item",
)
.instance(&scope.request_id));
}
by_id::<store::Tag>(rt, scope, &body.tag_id).await?;
Ok(())
}
async fn admin_tag_subject(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Json(body): Json<TagSubjectBody>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
tag_and_subject(&rt, &scope, &body).await?;
store::tag_subject(&*rt.db, &body.tag_id, &body.subject_type, &body.subject_id)
.await
.map_err(|err| store_problem(err.into(), &scope))?;
Ok(ok_json())
}
async fn admin_untag_subject(
scope: Scope,
State(ctx): State<Arc<ModuleContext>>,
headers: HeaderMap,
Json(body): Json<TagSubjectBody>,
) -> Result<Response, Problem> {
let rt = ready(&ctx, &headers, &scope)?;
tag_and_subject(&rt, &scope, &body).await?;
let removed = store::untag_subject(&*rt.db, &body.tag_id, &body.subject_type, &body.subject_id)
.await
.map_err(|err| store_problem(err.into(), &scope))?;
Ok(Json(json!({ "ok": true, "removed": removed })).into_response())
}
fn admin(action: Action) -> Action {
action.audience(Audience::Admin).outcome(Outcome::Json)
}
fn table(key: &str, columns: &[(&'static str, &'static str)]) -> View {
View::table(
key,
columns
.iter()
.map(|(key, label)| Column::new(*key, *label))
.collect(),
)
}
pub(crate) fn surface() -> Surface {
Surface::new()
.action(admin(Action::get("contacts-export", "/admin/contacts.csv")).input::<ExportQuery>())
.action(
admin(Action::get(
"organisations-export",
"/admin/organisations.csv",
))
.input::<ExportQuery>(),
)
.action(admin(Action::post("contact-create", "/admin/contacts")).input::<ContactBody>())
.action(
admin(Action::new(
"contact-update",
Method::PATCH,
"/admin/contacts/{id}",
))
.input::<ContactPatch>(),
)
.action(Action::delete("contact-delete", "/admin/contacts/{id}"))
.action(admin(Action::post("contact-merge", "/admin/contacts/merge")).input::<MergeBody>())
.action(
admin(Action::post("organisation-create", "/admin/organisations"))
.input::<OrganisationBody>(),
)
.action(
admin(Action::new(
"organisation-update",
Method::PATCH,
"/admin/organisations/{id}",
))
.input::<OrganisationPatch>(),
)
.action(Action::delete(
"organisation-delete",
"/admin/organisations/{id}",
))
.action(admin(Action::post("tag-create", "/admin/tags")).input::<TagBody>())
.action(admin(Action::post("tag-subject", "/admin/tags/tag")).input::<TagSubjectBody>())
.action(admin(Action::post("untag-subject", "/admin/tags/untag")).input::<TagSubjectBody>())
.view(table(
"contacts-export",
&[
("id", "Id"),
("email", "Email"),
("email_normalized", "Normalized email"),
("name", "Name"),
("phone", "Phone"),
("locale", "Locale"),
("organisation_id", "Organisation"),
("source", "Source"),
("data", "Data"),
("created_at", "Created"),
("updated_at", "Updated"),
("generation", "Generation"),
],
))
.view(table(
"organisations-export",
&[
("id", "Id"),
("name", "Name"),
("domain", "Domain"),
("website", "Website"),
("email", "Email"),
("phone", "Phone"),
("address", "Address"),
("data", "Data"),
("created_at", "Created"),
("updated_at", "Updated"),
("generation", "Generation"),
],
))
}