Skip to main content

hey_sdk/services/
contacts.rs

1//! Writing contacts, and the two refusals a contact write answers with.
2
3use std::fmt;
4
5use serde::de::DeserializeOwned;
6
7use crate::error::Error;
8use crate::generated::routes;
9use crate::generated::types::{
10    ConflictErrorResponseContent, Contact, ContactDetail, ContactNote, ContactNotePayload,
11    ContactNoteRequestContent, ContactPayload, ContactRequestContent, CreateContactRequestContent,
12    UnprocessableEntityErrorResponseContent, UpdateContactClearanceRequestContent,
13};
14use crate::operation::Operation;
15use crate::services::clearances::ClearanceStatus;
16use crate::types::SensitiveString;
17
18pub use crate::generated::services::contacts::*;
19
20/// A contact, as its writes take it.
21#[derive(Debug, Clone, Default, PartialEq)]
22pub struct ContactParams {
23    /// What the contact is called.
24    pub name: String,
25    /// The contact's main address, the one HEY files them under.
26    pub email_address: String,
27    /// The other addresses that belong to the same person. Sending the list replaces it,
28    /// so an address left out stops being an alias; `None` leaves the current aliases
29    /// alone.
30    pub alias_email_addresses: Option<Vec<String>>,
31    /// The account to file the contact under, on a create. One identity can hold several
32    /// accounts, each with its own contacts; this is the identity's user on the one meant,
33    /// which the identity's `all_users` carries alongside its `account_id`. Left unset,
34    /// HEY files the contact under the first account. An update ignores it.
35    pub account_user_id: Option<i64>,
36}
37
38/// The contacts a refused write clashed with: HEY's web sends you to a merge form at this
39/// point, and these are the contacts it would have offered to merge with.
40///
41/// A create that clashes still creates the contact — the merge happens afterwards — so
42/// `contact_id` is the contact that was written, not one that failed to be.
43///
44/// It travels as the source of an [`Error`] with [`crate::ErrorCode::Conflict`], so a
45/// caller who only cares that the write was refused can ignore it.
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47#[non_exhaustive]
48pub struct ContactConflict {
49    /// The contact the write was for — on a create, the one it made.
50    pub contact_id: i64,
51    /// The contacts already holding one of the addresses.
52    pub conflicting_contact_ids: Vec<i64>,
53}
54
55impl ContactConflict {
56    /// The conflict a refused contact write carries, when the error is one.
57    pub fn from_error(error: &Error) -> Option<&ContactConflict> {
58        std::error::Error::source(error)?.downcast_ref::<ContactConflict>()
59    }
60}
61
62impl Contacts<'_> {
63    /// Adds a contact and answers it.
64    ///
65    /// On a client scoped to an account the contact is filed under that account, and an
66    /// `account_user_id` naming another one is refused rather than quietly overruled.
67    pub async fn create_contact(&self, params: &ContactParams) -> Result<Contact, Error> {
68        let body = CreateContactRequestContent {
69            acting_user_id: self.acting_user_id(params.account_user_id).await?,
70            contact: contact_payload(params),
71        };
72        let mut operation = self.client().operation(&routes::CREATE_CONTACT, &[]);
73        operation.json(&body)?;
74        self.write(operation).await
75    }
76
77    /// Edits a contact and answers it. Fields left empty are kept, as are the aliases when
78    /// `alias_email_addresses` is `None`.
79    ///
80    /// HEY's update is a full replacement — it rewrites the name and address and removes
81    /// any alias not submitted — so the contact is read first and the unset fields are
82    /// filled in from it before the write. That read-then-write is not atomic: a change
83    /// made to the contact in between is overwritten with what was read. Pass every field
84    /// explicitly when that matters.
85    ///
86    /// The contact that comes back is not always the one addressed: giving a contact one of
87    /// its own aliases as the main address promotes the alias, and the alias is what is
88    /// answered.
89    pub async fn update_contact(
90        &self,
91        contact_id: i64,
92        params: &ContactParams,
93    ) -> Result<Contact, Error> {
94        let current = self.get(contact_id, &GetContactParams::default()).await?;
95        let body = ContactRequestContent {
96            contact: merged_payload(params, &current),
97        };
98        let mut operation = self
99            .client()
100            .operation(&routes::UPDATE_CONTACT, &[&contact_id]);
101        operation.resource_id(contact_id);
102        operation.json(&body)?;
103        self.write(operation).await
104    }
105
106    /// Answers the Screener for a contact.
107    pub async fn screen(&self, contact_id: i64, status: ClearanceStatus) -> Result<(), Error> {
108        let body = UpdateContactClearanceRequestContent {
109            status: status.as_str().to_string(),
110        };
111        self.update_clearance(contact_id, &body).await
112    }
113
114    /// Writes the private note kept on a contact, replacing whatever was there, and answers
115    /// the note as it now reads.
116    pub async fn set_note(&self, contact_id: i64, note: &str) -> Result<ContactNote, Error> {
117        let body = ContactNoteRequestContent {
118            contact: ContactNotePayload {
119                note: note.to_string(),
120            },
121        };
122        let mut operation = self
123            .client()
124            .operation(&routes::UPDATE_CONTACT_NOTE, &[&contact_id]);
125        operation.json(&body)?;
126        self.write(operation).await
127    }
128
129    async fn acting_user_id(&self, chosen: Option<i64>) -> Result<Option<i64>, Error> {
130        match self.client().account_id() {
131            None => Ok(chosen),
132            Some(account_id) => {
133                let account_user_id = self.client().account_user_id().await?;
134                match chosen {
135                    Some(chosen) if chosen != account_user_id => Err(Error::usage(format!(
136                        "account user {chosen} does not belong to selected account {account_id}"
137                    ))),
138                    _ => Ok(Some(account_user_id)),
139                }
140            }
141        }
142    }
143
144    /// Sends a contact write, reading the two refusals it can answer with out of the body
145    /// they arrive in: an address that belongs to someone else, and a contact the model
146    /// itself rejected. Both are failures, and the hooks are told so; all that happens here
147    /// is that the failure is reworded from what the model said about it.
148    async fn write<T: DeserializeOwned>(&self, operation: Operation) -> Result<T, Error> {
149        match self.client().execute(operation).await {
150            Ok(response) => response.json(),
151            Err(error) if error.http_status() == Some(409) => Err(clash(&error)),
152            Err(error) if error.http_status() == Some(422) => Err(rejection(&error)),
153            Err(error) => Err(error),
154        }
155    }
156}
157
158fn contact_payload(params: &ContactParams) -> ContactPayload {
159    let email_address = if params.email_address.is_empty() {
160        None
161    } else {
162        Some(SensitiveString::new(params.email_address.as_str()))
163    };
164    ContactPayload {
165        name: params.name.clone(),
166        email_address,
167        alias_email_addresses: params.alias_email_addresses.clone(),
168    }
169}
170
171/// The write HEY is sent: the caller's fields, with the contact's own filled in wherever
172/// the caller named none.
173///
174/// The alias list always goes out, filled in from the contact when the caller left it
175/// unset. Go leaves an empty one off with `omitempty`, which makes clearing every alias
176/// impossible there — an empty list would be indistinguishable from saying nothing. Sending
177/// it means an explicit `Some(vec![])` clears the aliases, which is the one thing HEY's own
178/// full-replacement update is for.
179fn merged_payload(params: &ContactParams, current: &ContactDetail) -> ContactPayload {
180    let mut payload = contact_payload(params);
181    if payload.name.is_empty() {
182        payload.name = current.name.clone().unwrap_or_default();
183    }
184    if payload.email_address.is_none() {
185        payload.email_address.clone_from(&current.email_address);
186    }
187    if payload.alias_email_addresses.is_none() {
188        payload.alias_email_addresses = Some(current_aliases(current));
189    }
190    payload
191}
192
193fn current_aliases(current: &ContactDetail) -> Vec<String> {
194    current
195        .aliases
196        .iter()
197        .flatten()
198        .filter_map(|alias| alias.email_address.as_ref())
199        .map(|address| address.expose().to_string())
200        .collect()
201}
202
203fn clash(error: &Error) -> Error {
204    let payload: ConflictErrorResponseContent = error.body_json().unwrap_or_default();
205    Error::conflict(conflict_message(&payload)).with_source(ContactConflict {
206        contact_id: payload.contact_id.unwrap_or_default(),
207        conflicting_contact_ids: payload.conflicting_contact_ids.unwrap_or_default(),
208    })
209}
210
211fn rejection(error: &Error) -> Error {
212    let payload: UnprocessableEntityErrorResponseContent = error.body_json().unwrap_or_default();
213    Error::validation(&payload.errors.unwrap_or_default())
214}
215
216/// The server's own words out of a 409. Contact writes answer the `errors` list the other
217/// refusals use; elsewhere a 409 is a single message. A body neither of those still has to
218/// read as something.
219fn conflict_message(payload: &ConflictErrorResponseContent) -> String {
220    let messages = payload.errors.as_deref().unwrap_or_default();
221    if !messages.is_empty() {
222        messages.join("; ")
223    } else if let Some(message) = &payload.error {
224        message.clone()
225    } else {
226        "the contact conflicts with one that already exists".to_string()
227    }
228}
229
230impl fmt::Display for ContactConflict {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        if self.conflicting_contact_ids.is_empty() {
233            write!(
234                f,
235                "contact {} conflicts with one that already exists",
236                self.contact_id
237            )
238        } else {
239            let ids: Vec<String> = self
240                .conflicting_contact_ids
241                .iter()
242                .map(i64::to_string)
243                .collect();
244            write!(
245                f,
246                "contact {} conflicts with {}",
247                self.contact_id,
248                ids.join(", ")
249            )
250        }
251    }
252}
253
254impl std::error::Error for ContactConflict {}