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 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, } 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 j.id, j.value, j.date from journal_entries j join contact_mentions cm on j.id = cm.entry_id where cm.contact_id = $1", ) .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 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 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 contact_mentions cms on cms.entry_id = jes.id where cms.contact_id = 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 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 = ''"; } } 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>, 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?; { // update addresses sqlx::query!("delete from addresses where contact_id = $1", contact_id) .execute(pool) .await?; if let Some(values) = payload.address_value { let labels = if values.len() == 1 { Some(vec![String::new()]) } else { payload.address_label }; if let Some(labels) = labels { let new_addresses = labels .into_iter() .zip(values) .filter(|(_, val)| val.len() > 0); for (label, value) in new_addresses { sqlx::query!( "insert into addresses (contact_id, label, value) values ($1, $2, $3)", contact_id, label, value ) .execute(pool) .await?; } } } } let old_names: Vec<(String,)> = sqlx::query_as( "delete from contact_mentions; delete from names where contact_id = $1 returning name;", ) .bind(contact_id) .fetch_all(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 let Some(names) = payload.name { let names: Vec = names.into_iter().filter(|n| n.len() > 0).collect(); if !names.is_empty() { for name in &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(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.0); } for name in recalc_names { trie.insert(name.0, name.1); } } 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 contact_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")) } }