From 2e1fbd00be6e70a86e70eea460eaedeed1c10b8d Mon Sep 17 00:00:00 2001 From: Robert Perce Date: Sat, 31 Jan 2026 21:01:01 -0600 Subject: [PATCH] basic phone number support --- migrations/each_user/0011_phone_numbers.sql | 5 ++ src/models/contact.rs | 4 +- src/web/contact.rs | 83 +++++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 migrations/each_user/0011_phone_numbers.sql diff --git a/migrations/each_user/0011_phone_numbers.sql b/migrations/each_user/0011_phone_numbers.sql new file mode 100644 index 0000000..da8e95c --- /dev/null +++ b/migrations/each_user/0011_phone_numbers.sql @@ -0,0 +1,5 @@ +create table if not exists phone_numbers ( + contact_id integer not null references contacts(id) on delete cascade, + label text, + phone_number text not null +); diff --git a/src/models/contact.rs b/src/models/contact.rs index 580d910..bad0ff4 100644 --- a/src/models/contact.rs +++ b/src/models/contact.rs @@ -97,7 +97,7 @@ impl HydratedContact { let raw = sqlx::query_as!( RawHydratedContact, r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", ( - select string_agg(name,'\x1c' order by sort) + select string_agg(name,x'1c' order by sort) from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes @@ -124,7 +124,7 @@ impl HydratedContact { let contacts = sqlx::query_as!( RawHydratedContact, r#"select id, birthday, lives_with, manually_freshened_at as "manually_freshened_at: String", ( - select string_agg(name,'\x1c' order by sort) + select string_agg(name,x'1c' order by sort) from names where contact_id = c.id ) as names, ( select jes.date from journal_entries jes diff --git a/src/web/contact.rs b/src/web/contact.rs index 9cf7bcc..31f1c01 100644 --- a/src/web/contact.rs +++ b/src/web/contact.rs @@ -37,6 +37,13 @@ pub struct Group { pub slug: String, } +#[derive(serde::Serialize, Debug)] +pub struct PhoneNumber { + pub contact_id: DbId, + pub label: Option, + pub phone_number: String, +} + pub fn router() -> Router { Router::new() .route("/contact/new", post(self::post::contact)) @@ -93,6 +100,12 @@ mod get { .fetch_all(pool) .await?; + let phone_numbers: Vec = 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, @@ -162,6 +175,19 @@ mod get { } } + @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) } @@ -223,6 +249,12 @@ mod get { let pool = &state.db(&auth_session.user.unwrap()).pool; let contact = HydratedContact::load(contact_id, pool).await?; + let phone_numbers: Vec = sqlx::query_as!( + PhoneNumber, + "select * from phone_numbers where contact_id = $1", + contact_id + ).fetch_all(pool).await?; + let addresses: Vec
= sqlx::query_as!( Address, "select * from addresses where contact_id = $1", @@ -290,6 +322,20 @@ mod get { 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 { "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); @@ -367,6 +413,8 @@ mod put { birthday: String, manually_freshened_at: String, lives_with: String, + phone_label: Option>, + phone_number: Option>, address_label: Option>, address_value: Option>, group: Option>, @@ -473,6 +521,41 @@ mod put { // 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 = payload.phone_label.clone().unwrap(); + + // TODO sanitize down to linkable on input + labels + .into_iter() + .zip(numbers) + .filter(|(_, val)| val.len() > 0) + .collect::>() + }); + + 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| {