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, PreEscaped, 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::{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, } 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 pool = &state.db(&auth_session.user.unwrap()).pool; let contact: HydratedContact = sqlx::query_as( "select id, birthday, manually_freshened_at, ( select string_agg(name,'\x1c' order by sort) from names where contact_id = c.id ) as names from contacts c where c.id = $1", ) .bind(contact_id) .fetch_one(pool) .await?; let entries: Vec = sqlx::query_as( "select distinct j.id, j.value, j.date from journal_entries j join journal_mentions cm on j.id = cm.entry_id where cm.url = '/contact/'||$1 or cm.url in ( select '/group/'||slug from groups where contact_id = $1 ) order by j.date desc ", ) .bind(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 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 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 { (PreEscaped(markdown::to_html(&text_body))) } } } (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 = sqlx::query_as( "select id, birthday, manually_freshened_at, ( select string_agg(name,'\x1c' order by sort) from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes join journal_mentions cms on cms.entry_id = jes.id where cms.url = '/contact/'||c.id order by jes.date desc limit 1 ) as last_mention_date from contacts c where c.id = $1", ) .bind(contact_id) .fetch_one(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 { "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, 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 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) }; sqlx::query!( "update contacts set (birthday, manually_freshened_at, text_body) = ($1, $2, $3) where id = $4", birthday, manually_freshened_at, text_body, 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 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* journal mentions, not just the ones for the // current user, since changing *this* user's names can change, *globally*, // which names have n=1 and thus are eligible for mentioning sqlx::query!( "delete from journal_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 trie_mutex = state.contact_search(&user); let mut trie = trie_mutex.write().unwrap(); for name in &old_names { trie.remove(name); } for name in recalc_names { trie.insert(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 journal_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 trie_mutex = state.contact_search(&user); let mut trie = trie_mutex.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?) trie.remove(name); } for group in &new_groups { trie.insert(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 { entry .insert_mentions(state.contact_search(&user), 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 journal_mentions where contact_id = $1; delete from names where contact_id = $1; delete from contacts where id = $1;", ) .bind(contact_id) .execute(pool) .await?; let mut headers = HeaderMap::new(); headers.insert("HX-Redirect", "/".parse()?); Ok((headers, "ok")) } }