feat: mentions in lives_with and text_body fields
Some checks failed
/ integration-test--firefox (push) Failing after 3m7s

This commit is contained in:
Robert Perce 2026-01-26 15:25:45 -06:00
parent fd5f1899c1
commit d42adbe274
10 changed files with 369 additions and 200 deletions

View file

@ -8,7 +8,7 @@ use axum::{
use axum_extra::extract::Form;
use cache_bust::asset;
use chrono::DateTime;
use maud::{Markup, PreEscaped, html};
use maud::{Markup, html};
use serde::Deserialize;
use serde_json::json;
use slug::slugify;
@ -19,6 +19,7 @@ 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)]
@ -69,10 +70,12 @@ mod get {
pub async fn contact(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
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)
@ -87,18 +90,30 @@ mod get {
let entries: Vec<JournalEntry> = 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 (
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 = $1
)
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",
@ -157,9 +172,9 @@ mod get {
}
}
@if contact.lives_with.len() > 0 {
@if let Some(lives_with) = lives_with {
label { "lives with" }
div { (contact.lives_with) }
div { (lives_with) }
}
@if addresses.len() == 1 {
@ -196,7 +211,11 @@ mod get {
@if let Some(text_body) = text_body {
@if text_body.len() > 0 {
#text_body { (PreEscaped(markdown::to_html(&text_body))) }
#text_body { (MentionHost {
entity_id: contact_id,
entity_type: MentionHostType::ContactTextBody as DbId,
input: &text_body
}.format_pool(pool).await?) }
}
}
@ -208,7 +227,7 @@ mod get {
pub async fn contact_edit(
auth_session: AuthSession,
State(state): State<AppState>,
Path(contact_id): Path<u32>,
Path(contact_id): Path<DbId>,
layout: Layout,
) -> Result<Markup, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
@ -218,17 +237,20 @@ mod get {
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
or cms.url in (
select '/group/'||name
from groups
where contact_id = c.id
)
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 = $1",
where c.id = $2",
)
.bind(MentionHostType::JournalEntry as DbId)
.bind(contact_id)
.fetch_one(pool)
.await?;
@ -391,6 +413,7 @@ mod put {
) -> 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
@ -415,8 +438,15 @@ mod put {
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",
"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,
@ -426,6 +456,52 @@ mod put {
.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
@ -485,11 +561,11 @@ mod put {
let old_names: Vec<String> = 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*,
// 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 journal_mentions; delete from names where contact_id = $1",
"delete from mentions; delete from names where contact_id = $1",
contact_id
)
.execute(pool)
@ -531,14 +607,13 @@ mod put {
.await?;
{
let trie_mutex = state.contact_search(&user);
let mut trie = trie_mutex.write().unwrap();
let mut switchboard = sw_lock.write().unwrap();
for name in &old_names {
trie.remove(name);
switchboard.remove(name);
}
for name in recalc_names {
trie.insert(name.0, format!("/contact/{}", name.1));
switchboard.add_mentionable(name.0, format!("/contact/{}", name.1));
}
}
}
@ -558,7 +633,7 @@ mod put {
if new_groups != old_groups {
sqlx::query!(
"delete from journal_mentions; delete from groups where contact_id = $1",
"delete from mentions; delete from groups where contact_id = $1",
contact_id
)
.execute(pool)
@ -576,17 +651,17 @@ mod put {
.await?;
{
let trie_mutex = state.contact_search(&user);
let mut trie = trie_mutex.write().unwrap();
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?)
trie.remove(name);
switchboard.remove(name);
}
for group in &new_groups {
trie.insert(group.clone(), format!("/group/{}", slugify(group)));
switchboard
.add_mentionable(group.clone(), format!("/group/{}", slugify(group)));
}
}
}
@ -598,9 +673,11 @@ mod put {
.await?;
for entry in journal_entries {
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(&entry)
};
insert_mentions(&mentions, pool).await?;
}
}
}
@ -620,14 +697,9 @@ mod delete {
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?;
sqlx::query!("delete from contacts where id = $1", contact_id)
.execute(pool)
.await?;
let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/".parse()?);

View file

@ -9,6 +9,7 @@ use super::Layout;
use crate::db::DbId;
use crate::models::user::AuthSession;
use crate::models::{Birthday, HydratedContact, JournalEntry};
use crate::switchboard::{MentionHost, MentionHostType};
use crate::{AppError, AppState};
#[derive(Debug, Clone)]
@ -109,7 +110,7 @@ pub async fn journal_section(
.entries {
@for entry in entries {
(entry.to_html(pool).await?)
(Into::<MentionHost>::into(entry).format_pool(pool).await?)
}
}
}
@ -124,18 +125,22 @@ pub mod get {
State(state): State<AppState>,
layout: Layout,
) -> Result<impl IntoResponse, AppError> {
let pool = &state.db(&auth_session.user.unwrap()).pool;
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let contacts: Vec<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
join mentions ms on ms.entity_id = jes.id
where ms.entity_type = $1
and ms.url = '/contact/'||c.id
order by jes.date desc limit 1
) as last_mention_date from contacts c",
)
.bind(MentionHostType::JournalEntry as DbId)
.fetch_all(pool)
.await?;

View file

@ -11,6 +11,7 @@ use serde::Deserialize;
use crate::models::JournalEntry;
use crate::models::user::AuthSession;
use crate::switchboard::{MentionHost, insert_mentions};
use crate::{AppError, AppState};
pub fn router() -> Router<AppState> {
@ -36,6 +37,8 @@ mod post {
) -> Result<Markup, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
let now = Local::now().date_naive();
let date = if payload.date.is_empty() {
@ -73,9 +76,11 @@ mod post {
.fetch_one(pool)
.await?;
entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(&entry)
};
insert_mentions(&mentions, pool).await?;
Ok(entry.to_html(pool).await?)
}
@ -84,6 +89,7 @@ mod post {
mod patch {
use super::*;
#[axum::debug_handler]
pub async fn entry(
auth_session: AuthSession,
State(state): State<AppState>,
@ -92,8 +98,10 @@ mod patch {
) -> Result<impl IntoResponse, AppError> {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
let sw_lock = state.switchboard(&user);
// not a macro query, we want to use JournalEntry's custom FromRow
let entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1")
let old_entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1")
.bind(entry_id)
.fetch_one(pool)
.await?;
@ -107,17 +115,24 @@ mod patch {
.fetch_one(pool)
.await?;
if entry.value != new_entry.value {
sqlx::query!("delete from journal_mentions where entry_id = $1", entry_id)
.execute(pool)
.await?;
if old_entry.value != new_entry.value {
sqlx::query!(
"delete from mentions where entity_id = $1 and entity_type = 'journal_entry'",
entry_id
)
.execute(pool)
.await?;
new_entry
.insert_mentions(state.contact_search(&user), pool)
.await?;
let mentions = {
let switchboard = sw_lock.read().unwrap();
switchboard.extract_mentions(&new_entry)
};
insert_mentions(&mentions, pool).await?;
}
Ok(new_entry.to_html(pool).await?)
Ok(Into::<MentionHost>::into(&new_entry)
.format_pool(pool)
.await?)
}
}
@ -132,14 +147,11 @@ mod delete {
let user = auth_session.user.unwrap();
let pool = &state.db(&user).pool;
sqlx::query(
"delete from journal_mentions where entry_id = $1;
delete from journal_entries where id = $2 returning id,date,value",
)
.bind(entry_id)
.bind(entry_id)
.execute(pool)
.await?;
sqlx::query("delete from journal_entries where id = $2 returning id,date,value")
.bind(entry_id)
.bind(entry_id)
.execute(pool)
.await?;
Ok(())
}