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}; #[derive(serde::Serialize, Debug)] pub struct Address { pub id: DbId, pub contact_id: DbId, pub label: Option, pub value: String, } #[derive(serde::Serialize, Debug)] pub struct Group { pub contact_id: DbId, pub name: String, pub slug: String, } #[derive(serde::Serialize, Debug)] pub struct PhoneNumber { pub contact_id: DbId, pub label: Option, pub phone_number: String, } pub fn router() -> Router { 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, Path(contact_id): Path, layout: Layout, ) -> Result { let user = auth_session.user.unwrap(); let pool = &state.db(&user).pool; let contact = HydratedContact::load(contact_id, pool).await?; let entries: Vec = 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/'||$1 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 phone_numbers: Vec = 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 addresses: Vec
= sqlx::query_as!( Address, "select * from addresses where contact_id = $1", contact_id ) .fetch_all(pool) .await?; let groups: Vec = sqlx::query_as!( Group, "select * from groups where contact_id = $1", contact_id ) .fetch_all(pool) .await?; let text_body: Option = 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(when) = &contact.manually_freshened_at { (when.date_naive().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) } } @if addresses.len() == 1 { label { "address" } #addresses { .label {} .value { (addresses[0].value) } } } @else if addresses.len() > 0 { label { "addresses" } #addresses { @for address in addresses { @let lbl = address.label.unwrap_or(String::new()); .label data-is-empty=(lbl.len() == 0) { (lbl) } .value { (address.value) } } } } @if groups.len() > 0 { label { "in groups" } #groups { @for group in groups { a .group href=(format!("/group/{}", group.slug)) { (group.name) } } } } } @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, Path(contact_id): Path, layout: Layout, ) -> Result { let pool = &state.db(&auth_session.user.unwrap()).pool; let contact = HydratedContact::load(contact_id, pool).await?; let phone_numbers: Vec = sqlx::query_as!( PhoneNumber, "select * from phone_numbers where contact_id = $1", contact_id ).fetch_all(pool).await?; let addresses: Vec
= 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()); let groups: Vec = sqlx::query_as!( Group, "select * from groups where contact_id = $1", contact_id ) .fetch_all(pool) .await? .into_iter() .map(|group| group.name) .collect(); 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); } label { "addresses" } div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) { template x-for="(address, index) in addresses" x-bind:key="index" { .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" {} } } } .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" {} } } input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''"; } 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 = ''"; } } 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, ) -> Result { 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>, birthday: String, manually_freshened_at: String, lives_with: String, phone_label: Option>, phone_number: Option>, address_label: Option>, address_value: Option>, group: Option>, text_body: String, } pub async fn contact( auth_session: AuthSession, State(state): State, Path(contact_id): Path, Form(payload): Form, ) -> Result { 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 = payload.phone_label.clone().unwrap(); // TODO sanitize down to linkable on input labels .into_iter() .zip(numbers) .filter(|(_, val)| val.len() > 0) .collect::>() }); 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 = 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::>() }); 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 = 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 = 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 = 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 = 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 = 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 = 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?; 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 = 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, Path(contact_id): Path, ) -> Result { 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")) } }