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 jiff::{Timestamp, Unit, tz::TimeZone}; use maud::{Markup, html}; use serde::Deserialize; use serde_json::json; use slug::slugify; use sqlx::QueryBuilder; 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, Switchboard, insert_mentions}; use crate::{AppError, AppState}; pub mod fields; #[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(span: &jiff::Span) -> String { let todate = Timestamp::now().to_zoned(TimeZone::UTC).date(); let span = span .round( jiff::SpanRound::new() .largest(Unit::Year) .smallest(Unit::Day) .relative(todate), ) .unwrap(); if span.is_zero() { "today".to_string() } else { format!("in {:#}", span) } } 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/'||$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.to_zoned(jiff::tz::TimeZone::UTC).date()), entries.get(0).map(|entry| entry.date), ); 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 text_body: Option = sqlx::query!("select text_body from contacts where id = $1", contact_id) .fetch_one(pool) .await? .text_body; Ok(layout.render( contact.names.get(0).unwrap_or(&String::from("(unknown)")), 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 contact.status() != "normal" { label { "status" } div { (contact.status()) } } @if contact.status() == "normal" && contact.periodicity.is_positive() { label { "periodicity" } div { (format!("{:#}", contact.periodicity)) } } @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, 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 cid_url = format!("/contact/{}", contact.id); let mfresh_str = contact .manually_freshened_at .clone() .map_or("".to_string(), |m| m.to_string()); 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( format!("Edit: {}", contact.names.get(0).unwrap_or(&String::from("(unknown)"))), 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; } #fields x-data=(json!({ "status": contact.status() })){ 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 { "status" } div { select name="status" x-model=("status") { option value="normal" { "Normal" } option value="permanent" { "Cannot go stale" } option value="inactive" { "Inactive" } } } label x-show="status === 'normal'" { "minimum stale time" } div x-show="status === 'normal'"{ input name="periodicity" value=(format!("{:#}", contact.periodicity)); span .hint { code { "[0-9]+ (yr|mo|wk|day|h|m|s)" } "(" a href="https://docs.rs/jiff/latest/jiff/struct.Span.html#parsing-and-printing" { "details" } ")" } } 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, ) -> Result { 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>, status: String, periodicity: 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: Option = if payload.manually_freshened_at.is_empty() { None } else { Some( payload .manually_freshened_at .parse::() .map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))? .to_string(), ) }; let active: bool = payload.status != "inactive"; let can_stale: bool = payload.status != "permanent"; let periodicity: String = payload.periodicity.unwrap_or("P0D".to_string()); 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, active, can_stale, periodicity ) = (?, ?, ?, ?, ?, ?, ?) where id = ?", birthday, manually_freshened_at, payload.lives_with, text_body, active, can_stale, periodicity, contact_id ) .execute(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?; } } { 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 { sqlx::query!("delete from names where contact_id = $1", contact_id) .execute(pool) .await?; if !new_names.is_empty() { QueryBuilder::new("insert into names (contact_id, sort, name) ") .push_values(new_names.iter().enumerate(), |mut b, (sort, name)| { b.push_bind(contact_id) .push_bind(DbId::try_from(sort).unwrap()) .push_bind(name); }) .build() .persistent(false) .execute(pool) .await?; } } 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 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 regen_all_mentions = { let trie = Switchboard::gen_trie(pool).await?; let mut swb = sw_lock.write().unwrap(); swb.check_and_assign(trie) }; let regen_lives_with = old_contact.lives_with != payload.lives_with; let regen_text_body = old_contact.text_body != text_body; if regen_all_mentions { sqlx::query("delete from mentions").execute(pool).await?; } else { if regen_lives_with { sqlx::query!( "delete from mentions where entity_id = $1 and entity_type = $2", contact_id, MentionHostType::ContactLivesWith as DbId ) .execute(pool) .await?; } if regen_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 regen_all_mentions || regen_lives_with { 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 regen_all_mentions || regen_text_body { 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?; } } if regen_all_mentions { 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")) } }