mascarpone/src/web/contact.rs

677 lines
24 KiB
Rust
Raw Normal View History

2025-11-27 13:45:21 -06:00
use axum::{
Router,
extract::{State, path::Path},
http::HeaderMap,
response::IntoResponse,
routing::{delete, get, post, put},
};
use axum_extra::extract::Form;
2025-12-01 15:23:56 -06:00
use cache_bust::asset;
2025-11-27 13:45:21 -06:00
use chrono::DateTime;
use maud::{Markup, html};
2025-11-27 13:45:21 -06:00
use serde::Deserialize;
use serde_json::json;
2026-01-23 21:20:27 -06:00
use slug::slugify;
2025-11-27 13:45:21 -06:00
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};
2025-11-27 13:45:21 -06:00
use crate::{AppError, AppState};
2025-11-28 17:05:06 -06:00
#[derive(serde::Serialize, Debug)]
2025-11-27 13:45:21 -06:00
pub struct Address {
pub id: DbId,
pub contact_id: DbId,
pub label: Option<String>,
pub value: String,
}
2026-01-16 23:19:40 -06:00
#[derive(serde::Serialize, Debug)]
pub struct Group {
pub contact_id: DbId,
pub name: String,
2026-01-23 21:20:27 -06:00
pub slug: String,
2026-01-16 23:19:40 -06:00
}
2025-11-27 13:45:21 -06:00
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>,
2025-11-27 13:45:21 -06:00
layout: Layout,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
2026-01-26 22:14:58 -06:00
let contact = HydratedContact::load(contact_id, pool).await?;
2025-11-27 13:45:21 -06:00
let entries: Vec<JournalEntry> = sqlx::query_as(
2026-01-23 21:20:27 -06:00
"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/'||$1 or m.url in (
2026-01-23 21:20:27 -06:00
select '/group/'||slug from groups
where contact_id = $2
))
2026-01-23 21:20:27 -06:00
order by j.date desc
",
2025-11-27 13:45:21 -06:00
)
.bind(MentionHostType::JournalEntry as DbId)
2025-11-27 13:45:21 -06:00
.bind(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
};
2025-11-27 13:45:21 -06:00
let addresses: Vec<Address> = sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
2026-01-16 23:19:40 -06:00
let groups: Vec<Group> = sqlx::query_as!(
Group,
"select * from groups where contact_id = $1",
2026-01-23 21:20:27 -06:00
contact_id
)
.fetch_all(pool)
.await?;
2025-11-27 15:03:22 -06:00
let text_body: Option<String> =
sqlx::query!("select text_body from contacts where id = $1", contact_id)
.fetch_one(pool)
.await?
.text_body;
2025-11-27 13:45:21 -06:00
Ok(layout.render(
2025-12-01 15:23:56 -06:00
Some(vec![asset!("contact.css"), asset!("journal.css")]),
2025-11-27 13:45:21 -06:00
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(when) = &contact.manually_freshened_at {
(when.date_naive().to_string())
} @else {
"(never)"
}
}
2026-01-24 11:13:27 -06:00
@if let Some(lives_with) = lives_with {
2026-01-24 11:13:27 -06:00
label { "lives with" }
div { (lives_with) }
2026-01-24 11:13:27 -06:00
}
2025-11-27 13:45:21 -06:00
@if addresses.len() == 1 {
label { "address" }
#addresses {
2025-11-28 08:40:43 -06:00
.label {}
2025-11-27 13:45:21 -06:00
.value { (addresses[0].value) }
}
} @else if addresses.len() > 0 {
label { "addresses" }
#addresses {
@for address in addresses {
2025-11-28 08:40:43 -06:00
@let lbl = address.label.unwrap_or(String::new());
.label data-is-empty=(lbl.len() == 0) {
(lbl)
2025-11-27 13:45:21 -06:00
}
.value { (address.value) }
}
}
}
2026-01-16 23:19:40 -06:00
@if groups.len() > 0 {
label { "in groups" }
#groups {
@for group in groups {
2026-01-23 21:20:27 -06:00
a .group href=(format!("/group/{}", group.slug)) {
(group.name)
}
2026-01-16 23:19:40 -06:00
}
}
}
2025-11-27 13:45:21 -06:00
}
2025-11-27 15:03:22 -06:00
@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?) }
2025-11-27 15:03:22 -06:00
}
}
2025-11-27 13:45:21 -06:00
(journal_section(pool, &entries).await?)
},
))
}
pub async fn contact_edit(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
2025-11-27 13:45:21 -06:00
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
2026-01-26 22:14:58 -06:00
let contact = HydratedContact::load(contact_id, pool).await?;
2025-11-27 13:45:21 -06:00
let addresses: Vec<Address> = sqlx::query_as!(
Address,
"select * from addresses 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());
2025-11-27 15:03:22 -06:00
2026-01-19 21:38:23 -06:00
let groups: Vec<String> = sqlx::query_as!(
Group,
"select * from groups where contact_id = $1",
2026-01-23 21:20:27 -06:00
contact_id
)
.fetch_all(pool)
.await?
.into_iter()
.map(|group| group.name)
.collect();
2026-01-19 21:38:23 -06:00
2025-11-27 15:03:22 -06:00
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());
2025-12-01 15:23:56 -06:00
Ok(layout.render(Some(vec![asset!("contact.css")]), html! {
2025-11-27 13:45:21 -06:00
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()";
}
2026-01-24 11:13:27 -06:00
label { "lives with" }
div {
input name="lives_with" value=(contact.lives_with);
}
2025-11-27 13:45:21 -06:00
label { "addresses" }
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) {
template x-for="(address, index) in addresses" x-bind:key="index" {
2025-11-28 17:05:06 -06:00
.address-input {
input name="address_label" x-show="addresses.length" x-model="address.label" placeholder="label";
.grow-wrap x-bind:data-replicated-value="address.value" {
textarea name="address_value" x-model="address.value" placeholder="address" {}
}
2025-11-27 13:45:21 -06:00
}
}
2025-11-28 17:05:06 -06:00
.address-input {
input x-show="addresses.length" name="address_label" x-model="new_label" placeholder="label";
.grow-wrap x-bind:data-replicated-value="new_address" {
textarea name="address_value" x-model="new_address" placeholder="new address" {}
}
2025-11-27 13:45:21 -06:00
}
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
}
2026-01-19 21:38:23 -06:00
label { "groups" }
#groups x-data=(json!({ "groups": groups, "new_group": "" })) {
template x-for="(group, index) in groups" x-bind:key="index" {
input name="group" x-model="group" placeholder="group name";
}
input name="group" x-model="new_group" placeholder="group name";
input type="button" value="Add" x-on:click="groups.push(new_group); new_group = ''";
}
2025-11-27 13:45:21 -06:00
}
2025-11-28 17:05:06 -06:00
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) }
}
2025-11-27 17:39:59 -06:00
}
2025-11-27 13:45:21 -06:00
}
}))
}
}
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: (u32,) =
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,
2026-01-24 11:13:27 -06:00
lives_with: String,
2025-11-27 13:45:21 -06:00
address_label: Option<Vec<String>>,
address_value: Option<Vec<String>>,
2026-01-23 21:20:27 -06:00
group: Option<Vec<String>>,
2025-11-27 15:03:22 -06:00
text_body: String,
2025-11-27 13:45:21 -06:00
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
2026-01-23 21:20:27 -06:00
Path(contact_id): Path<DbId>,
2025-11-27 13:45:21 -06:00
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);
2025-11-27 13:45:21 -06:00
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(),
)
};
2025-11-27 15:03:22 -06:00
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?;
2025-11-27 13:45:21 -06:00
sqlx::query!(
"update contacts set
(birthday, manually_freshened_at, lives_with, text_body) =
($1, $2, $3, $4)
where id = $5",
2025-11-27 13:45:21 -06:00
birthday,
manually_freshened_at,
2026-01-24 11:13:27 -06:00
payload.lives_with,
2025-11-27 15:03:22 -06:00
text_body,
2025-11-27 13:45:21 -06:00
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?;
}
}
2026-01-23 21:20:27 -06:00
// 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
2025-11-27 13:45:21 -06:00
{
// update addresses
2026-01-23 21:20:27 -06:00
let new_addresses = payload.address_value.clone().map(|values| {
let labels: Vec<String> = if values.len() == 1 {
vec![String::new()]
2025-11-27 13:45:21 -06:00
} else {
2026-01-23 21:20:27 -06:00
payload.address_label.clone().unwrap_or(vec![])
2025-11-27 13:45:21 -06:00
};
2026-01-23 21:20:27 -06:00
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?;
2025-11-27 13:45:21 -06:00
}
}
2026-01-23 21:20:27 -06:00
{
// 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*,
2026-01-23 21:20:27 -06:00
// which names have n=1 and thus are eligible for mentioning
sqlx::query!(
"delete from mentions; delete from names where contact_id = $1",
2026-01-23 21:20:27 -06:00
contact_id
)
.execute(pool)
.await?;
2025-11-27 13:45:21 -06:00
2026-01-23 21:20:27 -06:00
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
"select name, contact_id from (
2025-11-27 13:45:21 -06:00
select name, contact_id, count(name) as ct from names where name in (",
2026-01-23 21:20:27 -06:00
);
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());
}
2025-11-27 13:45:21 -06:00
2026-01-23 21:20:27 -06:00
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?;
2025-11-27 13:45:21 -06:00
}
2026-01-23 21:20:27 -06:00
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();
2026-01-23 21:20:27 -06:00
for name in &old_names {
switchboard.remove(name);
2026-01-23 21:20:27 -06:00
}
for name in recalc_names {
switchboard.add_mentionable(name.0, format!("/contact/{}", name.1));
2026-01-23 21:20:27 -06:00
}
}
2025-11-27 13:45:21 -06:00
}
2026-01-23 21:20:27 -06:00
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",
2026-01-23 21:20:27 -06:00
contact_id
)
.execute(pool)
.await?;
2025-11-27 13:45:21 -06:00
2026-01-23 21:20:27 -06:00
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();
2026-01-23 21:20:27 -06:00
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);
2026-01-23 21:20:27 -06:00
}
2025-11-27 13:45:21 -06:00
2026-01-23 21:20:27 -06:00
for group in &new_groups {
switchboard
.add_mentionable(group.clone(), format!("/group/{}", slugify(group)));
2026-01-23 21:20:27 -06:00
}
}
2025-11-27 13:45:21 -06:00
}
2026-01-23 21:20:27 -06:00
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?;
2025-11-27 13:45:21 -06:00
2026-01-23 21:20:27 -06:00
for entry in journal_entries {
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(&entry)
};
insert_mentions(&mentions, pool).await?;
2026-01-23 21:20:27 -06:00
}
}
2025-11-27 13:45:21 -06:00
}
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<u32>,
) -> 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?;
2025-11-27 13:45:21 -06:00
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/".parse()?);
Ok((headers, "ok"))
}
}