mascarpone/src/web/contact/mod.rs
2026-02-14 13:35:59 -06:00

677 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use axum::{
Router,
extract::{State, path::Path},
http::HeaderMap,
response::IntoResponse,
routing::{delete, get, post, put},
};
use axum_extra::extract::Form;
use cache_bust::asset;
use chrono::DateTime;
use maud::{Markup, html};
use serde::Deserialize;
use serde_json::json;
use slug::slugify;
use sqlx::{QueryBuilder, Sqlite};
use super::Layout;
use super::home::journal_section;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{HydratedContact, JournalEntry};
use crate::switchboard::{MentionHost, MentionHostType, insert_mentions};
use crate::{AppError, AppState};
pub mod fields;
#[derive(serde::Serialize, Debug)]
pub struct PhoneNumber {
pub contact_id: DbId,
pub label: Option<String>,
pub phone_number: String,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/contact/new", post(self::post::contact))
.route("/contact/{contact_id}", get(self::get::contact))
.route("/contact/{contact_id}", put(self::put::contact))
.route("/contact/{contact_id}", delete(self::delete::contact))
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
}
fn human_delta(delta: &chrono::TimeDelta) -> String {
if delta.num_days() == 0 {
return "today".to_string();
}
let mut result = "in ".to_string();
let mut rem = delta.clone();
if rem.num_days().abs() >= 7 {
let weeks = rem.num_days() / 7;
rem -= chrono::TimeDelta::days(weeks * 7);
result.push_str(&format!("{}w ", weeks));
}
if rem.num_days().abs() > 0 {
result.push_str(&format!("{}d ", rem.num_days()));
}
result.trim().to_string()
}
mod get {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contact = HydratedContact::load(contact_id, pool).await?;
let entries: Vec<JournalEntry> = sqlx::query_as(
"select distinct j.id, j.value, j.date from journal_entries j
join mentions m on j.id = m.entity_id
where m.entity_type = $1 and (m.url = '/contact/'||$2 or m.url in (
select '/group/'||slug from groups
where contact_id = $2
))
order by j.date desc
",
)
.bind(MentionHostType::JournalEntry as DbId)
.bind(contact_id)
.fetch_all(pool)
.await?;
let freshened = std::cmp::max(
contact.manually_freshened_at.map(|when| when.date_naive()),
entries.get(0).map(|entry| entry.date),
);
let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
PhoneNumber,
"select * from phone_numbers where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
let lives_with = if contact.lives_with.len() > 1 {
let mention_host = MentionHost {
entity_id: contact_id,
entity_type: MentionHostType::ContactLivesWith as DbId,
input: &contact.lives_with,
};
Some(mention_host.format_pool(pool).await?)
} else {
None
};
let text_body: Option<String> =
sqlx::query!("select text_body from contacts where id = $1", contact_id)
.fetch_one(pool)
.await?
.text_body;
Ok(layout.render(
Some(vec![asset!("contact.css"), asset!("journal.css")]),
html! {
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
div id="fields" {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div {
@for name in &contact.names {
div { (name) }
}
}
@if let Some(bday) = &contact.birthday {
label { "birthday" }
div {
(bday.to_string())
@if let Some(delta) = &bday.until_next() {
" ("
(human_delta(delta))
@if let Some(age) = &bday.age() {
", turning " (age + 1)
}
")"
}
}
}
label { "freshened" }
div {
@if let Some(freshened) = freshened {
(freshened.to_string())
} @else {
"(never)"
}
}
@if phone_numbers.len() > 0 {
label { "phone" }
#phone_numbers {
@for phone_number in phone_numbers {
@let lbl = phone_number.label.unwrap_or(String::new());
.label data-is-empty=(lbl.len() == 0) { (lbl) }
.phone_nunber {
a href=(format!("tel:{}", phone_number.phone_number)) { (phone_number.phone_number) }
}
}
}
}
@if let Some(lives_with) = lives_with {
label { "lives with" }
div { (lives_with) }
}
(fields::addresses::get(pool, contact_id).await?)
(fields::groups::get(pool, contact_id).await?)
}
@if let Some(text_body) = text_body {
@if text_body.len() > 0 {
#text_body { (MentionHost {
entity_id: contact_id,
entity_type: MentionHostType::ContactTextBody as DbId,
input: &text_body
}.format_pool(pool).await?) }
}
}
(journal_section(pool, &entries).await?)
},
))
}
pub async fn contact_edit(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact = HydratedContact::load(contact_id, pool).await?;
let phone_numbers: Vec<PhoneNumber> = sqlx::query_as!(
PhoneNumber,
"select * from phone_numbers where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
let cid_url = format!("/contact/{}", contact.id);
let mfresh_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| m.to_rfc3339());
let text_body: String =
sqlx::query!("select text_body from contacts where id = $1", contact_id)
.fetch_one(pool)
.await?
.text_body
.unwrap_or(String::new());
Ok(layout.render(Some(vec![asset!("contact.css")]), html! {
form hx-ext="response-targets" {
div {
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
input type="button" value="Delete" hx-delete=(cid_url) hx-target-error="#error";
div #error;
}
div #fields {
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
template x-for="(name, idx) in names" {
div {
input name="name" x-model="name";
input type="button" value="×" x-bind:disabled="idx == 0" x-on:click="names.splice(idx, 1)";
input type="button" value="" x-bind:disabled="idx == 0" x-on:click="[names[idx-1], names[idx]] = [names[idx], names[idx-1]]";
input type="button" value="" x-bind:disabled="idx == names.length - 1" x-on:click="[names[idx+1], names[idx]] = [names[idx], names[idx+1]]";
}
}
div {
input name="name" x-model="new_name" placeholder="New name";
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
}
}
label { "birthday" }
div {
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
}
label { "freshened" }
div x-data=(json!({ "date": mfresh_str })) {
input type="hidden" name="manually_freshened_at" x-model="date";
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
}
label { "phone" }
#phone_numbers x-data=(json!({ "phones": phone_numbers, "new_label": "", "new_number": "" })) {
template x-for="(phone, index) in phones" x-bind:key="index" {
.phone_input {
input name="phone_label" x-model="phone.label" placeholder="home/work/mobile";
input name="phone_number" x-model="phone.phone_number" placeholder="number";
}
}
.phone_input {
input name="phone_label" x-model="new_label" placeholder="home/work/mobile";
input name="phone_number" x-model="new_number" placeholder="number";
}
input type="button" value="Add" x-on:click="phones.push({ label: new_label, phone_number: new_number }); new_label=''; new_number = ''";
}
label { "lives with" }
div {
input name="lives_with" value=(contact.lives_with);
}
(fields::addresses::edit(pool, contact_id).await?)
(fields::groups::edit(pool, contact_id).await?)
}
div #text_body {
div { "Free text (supports markdown)" }
.grow-wrap data-replicated-value=(text_body) {
textarea name="text_body"
onInput="this.parentNode.dataset.replicatedValue = this.value"
{ (text_body) }
}
}
}
}))
}
}
mod post {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contact_id: (DbId,) =
sqlx::query_as("insert into contacts (birthday) values (null) returning id")
.fetch_one(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert(
"HX-Redirect",
format!("/contact/{}/edit", contact_id.0).parse()?,
);
Ok((headers, "ok"))
}
}
mod put {
use super::*;
#[derive(Deserialize)]
pub struct PutContact {
name: Option<Vec<String>>,
birthday: String,
manually_freshened_at: String,
lives_with: String,
phone_label: Option<Vec<String>>,
phone_number: Option<Vec<String>>,
address_label: Option<Vec<String>>,
address_value: Option<Vec<String>>,
group: Option<Vec<String>>,
text_body: String,
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
Form(payload): Form<PutContact>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
let birthday = if payload.birthday.is_empty() {
None
} else {
Some(payload.birthday)
};
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
None
} else {
Some(
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
.to_utc()
.to_rfc3339(),
)
};
let text_body = if payload.text_body.is_empty() {
None
} else {
Some(payload.text_body)
};
let old_contact = sqlx::query!("select * from contacts where id = $1", contact_id)
.fetch_one(pool)
.await?;
sqlx::query!(
"update contacts set
(birthday, manually_freshened_at, lives_with, text_body) =
($1, $2, $3, $4)
where id = $5",
birthday,
manually_freshened_at,
payload.lives_with,
text_body,
contact_id
)
.execute(pool)
.await?;
if old_contact.lives_with != payload.lives_with {
sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = $2",
contact_id,
MentionHostType::ContactLivesWith as DbId
)
.execute(pool)
.await?;
let mention_host = MentionHost {
entity_id: contact_id,
entity_type: MentionHostType::ContactLivesWith as DbId,
input: &payload.lives_with,
};
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(mention_host)
};
insert_mentions(&mentions, pool).await?;
}
if old_contact.text_body != text_body {
sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = $2",
contact_id,
MentionHostType::ContactTextBody as DbId
)
.execute(pool)
.await?;
if text_body.is_some() {
let mention_host = MentionHost {
entity_id: contact_id,
entity_type: MentionHostType::ContactTextBody as DbId,
input: &text_body.unwrap(),
};
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(mention_host)
};
insert_mentions(&mentions, pool).await?;
}
}
// these blocks are not in functions because payload gets progressively
// partially moved as we handle each field and i don't want to deal with it
{
// update phone numbers
let new_numbers = payload.phone_number.clone().map_or(vec![], |numbers| {
let labels: Vec<String> = payload.phone_label.clone().unwrap();
// TODO sanitize down to linkable on input
labels
.into_iter()
.zip(numbers)
.filter(|(_, val)| val.len() > 0)
.collect::<Vec<(String, String)>>()
});
let old_numbers: Vec<(String, String)> = sqlx::query_as(
"select label, phone_number from phone_numbers where contact_id = $1",
)
.bind(contact_id)
.fetch_all(pool)
.await?;
if new_numbers != old_numbers {
sqlx::query!(
"delete from phone_numbers where contact_id = $1",
contact_id
)
.execute(pool)
.await?;
// trailing space in query intentional
QueryBuilder::new("insert into phone_numbers (contact_id, label, phone_number) ")
.push_values(new_numbers, |mut b, (label, phone_number)| {
b.push_bind(contact_id)
.push_bind(label)
.push_bind(phone_number);
})
.build()
.execute(pool)
.await?;
}
}
{
// update addresses
let new_addresses = payload.address_value.clone().map(|values| {
let labels: Vec<String> = if values.len() == 1 {
vec![String::new()]
} else {
payload.address_label.clone().unwrap_or(vec![])
};
labels
.into_iter()
.zip(values)
.filter(|(_, val)| val.len() > 0)
.collect::<Vec<(String, String)>>()
});
let new_addresses = new_addresses.unwrap_or(vec![]);
let old_addresses: Vec<(String, String)> =
sqlx::query_as("select label, value from addresses where contact_id = $1")
.bind(contact_id)
.fetch_all(pool)
.await?;
if new_addresses != old_addresses {
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
.execute(pool)
.await?;
// trailing space in query intentional
QueryBuilder::new("insert into addresses (contact_id, label, value) ")
.push_values(new_addresses, |mut b, (label, value)| {
b.push_bind(contact_id).push_bind(label).push_bind(value);
})
.build()
.persistent(false)
.execute(pool)
.await?;
}
}
{
// recalculate all contact mentions and name trie if name-list changed
let new_names: Vec<String> = payload
.name
.unwrap_or(vec![])
.into_iter()
.filter(|n| n.len() > 0)
.collect();
let old_names: Vec<(String,)> =
sqlx::query_as("select name from names where contact_id = $1")
.bind(contact_id)
.fetch_all(pool)
.await?;
let old_names: Vec<String> = old_names.into_iter().map(|(s,)| s).collect();
if old_names != new_names {
// delete and regen *all* mentions, not just the ones for the current
// contact, since changing *this* contact's names can change, *globally*,
// which names have n=1 and thus are eligible for mentioning
sqlx::query!(
"delete from mentions; delete from names where contact_id = $1",
contact_id
)
.execute(pool)
.await?;
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
"select name, contact_id from (
select name, contact_id, count(name) as ct from names where name in (",
);
let mut name_list = recalc_counts.separated(", ");
for name in &old_names {
name_list.push_bind(name);
}
if !new_names.is_empty() {
for name in &new_names {
name_list.push_bind(name.clone());
}
let mut name_insert: QueryBuilder<Sqlite> =
QueryBuilder::new("insert into names (contact_id, sort, name) ");
name_insert.push_values(
new_names.iter().enumerate(),
|mut builder, (sort, name)| {
builder
.push_bind(contact_id)
.push_bind(DbId::try_from(sort).unwrap())
.push_bind(name);
},
);
name_insert.build().persistent(false).execute(pool).await?;
}
name_list.push_unseparated(") group by name) where ct = 1");
let recalc_names: Vec<(String, DbId)> = recalc_counts
.build_query_as()
.persistent(false)
.fetch_all(pool)
.await?;
{
let mut switchboard = sw_lock.write().unwrap();
for name in &old_names {
switchboard.remove(name);
}
for name in recalc_names {
switchboard.add_mentionable(name.0, format!("/contact/{}", name.1));
}
}
}
let new_groups: Vec<String> = payload
.group
.unwrap_or(vec![])
.into_iter()
.filter(|n| n.len() > 0)
.collect();
let old_groups: Vec<(String,)> =
sqlx::query_as("select name from groups where contact_id = $1")
.bind(contact_id)
.fetch_all(pool)
.await?;
let old_groups: Vec<String> = old_groups.into_iter().map(|(s,)| s).collect();
if new_groups != old_groups {
sqlx::query!(
"delete from mentions; delete from groups where contact_id = $1",
contact_id
)
.execute(pool)
.await?;
if new_groups.len() > 0 {
QueryBuilder::new("insert into groups (contact_id, name, slug) ")
.push_values(&new_groups, |mut b, name| {
b.push_bind(contact_id)
.push_bind(name)
.push_bind(slugify(name));
})
.build()
.persistent(false)
.execute(pool)
.await?;
}
{
let mut switchboard = sw_lock.write().unwrap();
for name in &old_groups {
// TODO i think we care about group name vs contact name counts,
// otherwise this will cause a problem (or we want to disallow
// setting group names that are contact names or vice versa?)
switchboard.remove(name);
}
for group in &new_groups {
switchboard
.add_mentionable(group.clone(), format!("/group/{}", slugify(group)));
}
}
}
if new_names != old_names || new_groups != old_groups {
let journal_entries: Vec<JournalEntry> =
sqlx::query_as("select * from journal_entries")
.fetch_all(pool)
.await?;
for entry in journal_entries {
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(&entry)
};
insert_mentions(&mentions, pool).await?;
}
}
}
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", format!("/contact/{}", contact_id).parse()?);
Ok((headers, "ok"))
}
}
mod delete {
use super::*;
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query!("delete from contacts where id = $1", contact_id)
.execute(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/".parse()?);
Ok((headers, "ok"))
}
}