mascarpone/src/web/contact/mod.rs

706 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
pub phone_number: String,
}
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(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::*;
fn scroll_to(id: DbId) -> String {
format!(
"\
const top = document\
.getElementById('nav-link-{}')\
?.getBoundingClientRect()\
?.top;\
console.log({{ top }});\
top && document\
.getElementById('contacts-sidebar')\
.scrollTo({{top: top+window.innerHeight/2,left:0,behavior:'instant'}});",
id
)
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contact = HydratedContact::load(contact_id, pool).await?;
let entries: Vec<JournalEntry> = 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<PhoneNumber> = 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<String> =
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 #fields x-init=(scroll_to(contact_id)) {
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<AppState>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact = HydratedContact::load(contact_id, pool).await?;
let phone_numbers: Vec<PhoneNumber> = 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_on_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| {
m.to_zoned(TimeZone::UTC).date().to_string()
});
let mfresh_at_str = contact
.manually_freshened_at
.clone()
.map_or("".to_string(), |m| m.to_zoned(TimeZone::UTC).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() })) x-init=(scroll_to(contact_id)) {
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 for="status" { "status" }
div {
select #status name="status" x-model=("status") {
option value="normal" { "Normal" }
option value="permanent" { "Cannot go stale" }
option value="inactive" { "Inactive" }
}
}
label x-show="status === 'normal'" for="periodicity" { "minimum stale time" }
div x-show="status === 'normal'"{
input name="periodicity" id="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 for="birthday" { "birthday" }
div {
input name="birthday" id="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| format!("{b}")));
span .hint { code { "(yyyy-)?mm-dd" } " or free text" }
}
label for="manually_freshened_on" { "freshened" }
div x-data=(json!({ "date": mfresh_on_str, "stamp": mfresh_at_str })) x-init="today = () => (new Date().toISOString().split('T')[0])" {
input
type="hidden"
name="manually_freshened_at"
x-model="stamp";
input
type="date"
name="manually_freshened_on"
id="manually_freshened_on"
x-model="date"
x-bind:max="today()"
x-on:input="stamp = new Date(date).toISOString()";
input type="button" value="Mark fresh now" x-on:click="date = today(); stamp = new Date().toISOString()";
span .hint x-text="`max ${today()}`";
}
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<AppState>,
) -> Result<impl IntoResponse, AppError> {
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<Vec<String>>,
status: String,
periodicity: Option<String>,
birthday: String,
manually_freshened_at: String,
lives_with: String,
phone_label: Option<Vec<String>>,
phone_number: Option<Vec<String>>,
address_label: Option<Vec<String>>,
address_value: Option<Vec<String>>,
group: Option<Vec<String>>,
text_body: String,
}
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<DbId>,
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);
let birthday = if payload.birthday.is_empty() {
None
} else {
Some(payload.birthday)
};
let manually_freshened_at: Option<String> = if payload.manually_freshened_at.is_empty() {
None
} else {
Some(
payload
.manually_freshened_at
.parse::<Timestamp>()
.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<String> = payload.phone_label.clone().unwrap();
// TODO sanitize down to linkable on input
labels
.into_iter()
.zip(numbers)
.filter(|(_, val)| val.len() > 0)
.collect::<Vec<(String, String)>>()
});
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<String> = 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::<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?;
}
}
{
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 {
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<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 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<JournalEntry> =
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<AppState>,
Path(contact_id): Path<DbId>,
) -> 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?;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/".parse()?);
Ok((headers, "ok"))
}
}