mascarpone/src/web/contact.rs
Robert Perce d42adbe274
Some checks failed
/ integration-test--firefox (push) Failing after 3m7s
feat: mentions in lives_with and text_body fields
2026-01-26 15:25:45 -06:00

708 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 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<String>,
pub value: String,
}
#[derive(serde::Serialize, Debug)]
pub struct Group {
pub contact_id: DbId,
pub name: String,
pub slug: 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(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<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 = sqlx::query_as(
"select *, (
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<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/'||$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 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<Address> = sqlx::query_as!(
Address,
"select * from addresses where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
let groups: Vec<Group> = sqlx::query_as!(
Group,
"select * from groups where contact_id = $1",
contact_id
)
.fetch_all(pool)
.await?;
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(
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 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<AppState>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let contact: HydratedContact = sqlx::query_as(
"select *, (
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 mentions m on m.entity_id = jes.id
where
m.entity_type = $1 and (
m.url = '/contact/'||c.id
or m.url in (
select '/group/'||name
from groups
where contact_id = c.id
))
order by jes.date desc limit 1
) as last_mention_date from contacts c
where c.id = $2",
)
.bind(MentionHostType::JournalEntry as DbId)
.bind(contact_id)
.fetch_one(pool)
.await?;
let addresses: Vec<Address> = 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<String> = 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 { "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<AppState>,
) -> Result<impl IntoResponse, AppError> {
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<Vec<String>>,
birthday: String,
manually_freshened_at: String,
lives_with: 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 = 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 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?;
}
}
{
// recalculate all contact mentions and name trie if name-list changed
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 {
// 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<Sqlite> = 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<Sqlite> =
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<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 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<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<u32>,
) -> 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"))
}
}