691 lines
25 KiB
Rust
691 lines
25 KiB
Rust
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::*;
|
||
|
||
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 id="fields" {
|
||
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() })){
|
||
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 { "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 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"))
|
||
}
|
||
}
|