Skip to main content

ghl_sdk/
contacts.rs

1//! Contacts — create, read, update, delete, and stream contact records.
2//!
3//! Access via [`Ghl::contacts`](crate::Ghl::contacts). This is the busiest module
4//! in most integrations; the [full contacts reference][ref] documents all 32 v2
5//! endpoints, of which the five below are typed.
6//!
7//! | Method | Endpoint | Scope |
8//! |---|---|---|
9//! | [`ContactsService::create`] | `POST /contacts/` | `contacts.write` |
10//! | [`ContactsService::get`] | `GET /contacts/{id}` | `contacts.readonly` |
11//! | [`ContactsService::update`] | `PUT /contacts/{id}` | `contacts.write` |
12//! | [`ContactsService::delete`] | `DELETE /contacts/{id}` | `contacts.write` |
13//! | [`ContactsService::list`] | `GET /contacts/` | `contacts.readonly` |
14//!
15//! # Examples
16//!
17//! Create a contact (at least an email or phone is required by the API):
18//!
19//! ```no_run
20//! # use ghl_sdk::{Ghl, contacts::CreateContact};
21//! # async fn demo(ghl: Ghl, loc: String) -> Result<(), ghl_sdk::Error> {
22//! let contact = ghl.contacts().create(CreateContact {
23//!     location_id: loc,
24//!     email: Some("ada@example.com".into()),
25//!     phone: Some("+15551234567".into()),
26//!     first_name: Some("Ada".into()),
27//!     tags: vec!["newsletter".into()],
28//!     ..Default::default()
29//! }).await?;
30//! # Ok(()) }
31//! ```
32//!
33//! Update only the fields you set — omitted fields are left untouched:
34//!
35//! ```no_run
36//! # use ghl_sdk::{Ghl, contacts::UpdateContact};
37//! # async fn demo(ghl: Ghl, id: &str) -> Result<(), ghl_sdk::Error> {
38//! ghl.contacts().update(id, UpdateContact {
39//!     last_name: Some("Lovelace".into()),
40//!     ..Default::default()
41//! }).await?;
42//! # Ok(()) }
43//! ```
44//!
45//! Stream every contact, following GoHighLevel's `startAfterId` cursor
46//! automatically:
47//!
48//! ```no_run
49//! # use ghl_sdk::Ghl;
50//! use futures_util::TryStreamExt;
51//!
52//! # async fn demo(ghl: Ghl, loc: &str) -> Result<(), ghl_sdk::Error> {
53//! let mut stream = ghl.contacts().list(loc).limit(100).stream();
54//! while let Some(contact) = stream.try_next().await? {
55//!     println!("{} {:?}", contact.id, contact.email);
56//! }
57//! # Ok(()) }
58//! ```
59//!
60//! [ref]: https://github.com/Shahroz/ghl-rs/blob/main/docs/api/contacts.md
61
62use futures_util::stream::{self, Stream, StreamExt, TryStreamExt};
63use reqwest::Method;
64use serde::{Deserialize, Serialize};
65
66use crate::client::Ghl;
67use crate::error::Result;
68
69/// A GoHighLevel contact.
70///
71/// Unknown fields are preserved in [`Contact::extra`] so payload drift upstream
72/// never breaks deserialization.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75#[allow(missing_docs)] // fields mirror the API wire format 1:1
76pub struct Contact {
77    pub id: String,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub location_id: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub email: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub phone: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub first_name: Option<String>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub last_name: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub contact_name: Option<String>,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub tags: Vec<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub source: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub date_added: Option<String>,
96    /// Any fields this SDK doesn't model yet.
97    #[serde(flatten)]
98    pub extra: serde_json::Map<String, serde_json::Value>,
99}
100
101/// Payload for [`ContactsService::create`].
102#[derive(Debug, Clone, Default, Serialize)]
103#[serde(rename_all = "camelCase")]
104#[allow(missing_docs)] // fields mirror the API wire format 1:1
105pub struct CreateContact {
106    pub location_id: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub email: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub phone: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub first_name: Option<String>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub last_name: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub name: Option<String>,
117    #[serde(skip_serializing_if = "Vec::is_empty", default)]
118    pub tags: Vec<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub source: Option<String>,
121}
122
123/// Payload for [`ContactsService::update`]. Only set fields are sent.
124#[derive(Debug, Clone, Default, Serialize)]
125#[serde(rename_all = "camelCase")]
126#[allow(missing_docs)] // fields mirror the API wire format 1:1
127pub struct UpdateContact {
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub email: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub phone: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub first_name: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub last_name: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub name: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub tags: Option<Vec<String>>,
140}
141
142#[derive(Deserialize)]
143struct ContactEnvelope {
144    contact: Contact,
145}
146
147/// One page of contacts plus the cursor for the next page.
148#[derive(Debug, Clone, Deserialize)]
149pub struct ContactPage {
150    /// The contacts on this page.
151    #[serde(default)]
152    pub contacts: Vec<Contact>,
153    /// Cursor metadata; `meta.start_after_id` feeds the next page request.
154    #[serde(default)]
155    pub meta: Option<ListMeta>,
156}
157
158/// Cursor metadata returned by list endpoints.
159#[derive(Debug, Clone, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct ListMeta {
162    /// Cursor: pass as `startAfterId` to fetch the next page.
163    #[serde(default)]
164    pub start_after_id: Option<String>,
165    /// Cursor timestamp companion to `start_after_id`.
166    #[serde(default)]
167    pub start_after: Option<i64>,
168    /// Total matching records, when the API reports it.
169    #[serde(default)]
170    pub total: Option<i64>,
171}
172
173/// Access to the Contacts API. Obtained via [`Ghl::contacts`].
174pub struct ContactsService {
175    pub(crate) client: Ghl,
176}
177
178impl ContactsService {
179    pub(crate) fn new(client: Ghl) -> Self {
180        Self { client }
181    }
182
183    /// `POST /contacts/`
184    pub async fn create(&self, contact: CreateContact) -> Result<Contact> {
185        let envelope: ContactEnvelope = self
186            .client
187            .send(Method::POST, "/contacts/", &[], Some(&contact))
188            .await?;
189        Ok(envelope.contact)
190    }
191
192    /// `GET /contacts/{id}`
193    pub async fn get(&self, contact_id: &str) -> Result<Contact> {
194        let envelope: ContactEnvelope = self
195            .client
196            .send(
197                Method::GET,
198                &format!("/contacts/{contact_id}"),
199                &[],
200                None::<&()>,
201            )
202            .await?;
203        Ok(envelope.contact)
204    }
205
206    /// `PUT /contacts/{id}`
207    pub async fn update(&self, contact_id: &str, update: UpdateContact) -> Result<Contact> {
208        let envelope: ContactEnvelope = self
209            .client
210            .send(
211                Method::PUT,
212                &format!("/contacts/{contact_id}"),
213                &[],
214                Some(&update),
215            )
216            .await?;
217        Ok(envelope.contact)
218    }
219
220    /// `DELETE /contacts/{id}`
221    pub async fn delete(&self, contact_id: &str) -> Result<()> {
222        let _: serde_json::Value = self
223            .client
224            .send(
225                Method::DELETE,
226                &format!("/contacts/{contact_id}"),
227                &[],
228                None::<&()>,
229            )
230            .await?;
231        Ok(())
232    }
233
234    /// `GET /contacts/` — returns a lazy request builder.
235    pub fn list(&self, location_id: impl Into<String>) -> ListContacts {
236        ListContacts {
237            client: self.client.clone(),
238            location_id: location_id.into(),
239            limit: 20,
240            query: None,
241            start_after_id: None,
242            start_after: None,
243        }
244    }
245}
246
247/// Builder for listing contacts. Finish with [`ListContacts::page`] (one page)
248/// or [`ListContacts::stream`] (auto-pagination).
249#[derive(Clone)]
250pub struct ListContacts {
251    client: Ghl,
252    location_id: String,
253    limit: u32,
254    query: Option<String>,
255    start_after_id: Option<String>,
256    start_after: Option<i64>,
257}
258
259impl ListContacts {
260    /// Page size, 1–100 (API default 20).
261    pub fn limit(mut self, limit: u32) -> Self {
262        self.limit = limit.clamp(1, 100);
263        self
264    }
265
266    /// Free-text search query.
267    pub fn query(mut self, query: impl Into<String>) -> Self {
268        self.query = Some(query.into());
269        self
270    }
271
272    /// Resume from a previous page's cursor.
273    pub fn start_after_id(mut self, cursor: impl Into<String>) -> Self {
274        self.start_after_id = Some(cursor.into());
275        self
276    }
277
278    /// Fetch a single page.
279    pub async fn page(&self) -> Result<ContactPage> {
280        let mut query: Vec<(String, String)> = vec![
281            ("locationId".into(), self.location_id.clone()),
282            ("limit".into(), self.limit.to_string()),
283        ];
284        if let Some(q) = &self.query {
285            query.push(("query".into(), q.clone()));
286        }
287        if let Some(id) = &self.start_after_id {
288            query.push(("startAfterId".into(), id.clone()));
289        }
290        if let Some(ts) = self.start_after {
291            query.push(("startAfter".into(), ts.to_string()));
292        }
293        self.client
294            .send(Method::GET, "/contacts/", &query, None::<&()>)
295            .await
296    }
297
298    /// Auto-paginating stream of contacts, following `meta.startAfterId` cursors.
299    pub fn stream(self) -> impl Stream<Item = Result<Contact>> {
300        stream::try_unfold(Some(self), |state| async move {
301            let Some(mut request) = state else {
302                return Ok::<_, crate::Error>(None);
303            };
304            let page = request.page().await?;
305            let full_page = page.contacts.len() as u32 >= request.limit;
306            let cursor = page.meta.as_ref().and_then(|m| m.start_after_id.clone());
307            let start_after = page.meta.as_ref().and_then(|m| m.start_after);
308
309            let next = match (full_page, cursor) {
310                (true, Some(cursor)) => {
311                    request.start_after_id = Some(cursor);
312                    request.start_after = start_after;
313                    Some(request)
314                }
315                _ => None,
316            };
317            Ok(Some((
318                stream::iter(page.contacts.into_iter().map(Ok)),
319                next,
320            )))
321        })
322        .try_flatten()
323        .boxed()
324    }
325}