Skip to main content

sdp_request_client/
builders.rs

1//! Fluent builders for SDP API operations.
2//!
3//! # Example
4//! ```no_run
5//! # use sdp_request_client::{ServiceDesk, ServiceDeskOptions, Credentials};
6//! # use reqwest::Url;
7//! # async fn example() -> Result<(), sdp_request_client::Error> {
8//! # let client = ServiceDesk::new(Url::parse("https://sdp.example.com").unwrap(), Credentials::Token { token: "".into() }, ServiceDeskOptions::default()).unwrap();
9//! // Search for open tickets (default limit: 100)
10//! let tickets = client.tickets()
11//!     .search()
12//!     .open()
13//!     .limit(50)
14//!     .fetch()
15//!     .await?;
16//!
17//! // Create a ticket (subject and requester required, priority defaults to "Low")
18//! let ticket = client.tickets()
19//!     .create()
20//!     .subject("[CLIENT] Alert Name")
21//!     .description("Alert details...")
22//!     .priority("High")
23//!     .requester("CLIENT")
24//!     .send()
25//!     .await?;
26//!
27//! // Single ticket operations
28//! client.ticket(12345).add_note("Resolved by automation").await?;
29//! client.ticket(12345).close("Closed by automation").await?;
30//! # Ok(())
31//! # }
32//! ```
33
34use chrono::{DateTime, Local};
35use reqwest::Method;
36use serde_json::Value;
37
38use crate::{
39    ServiceDesk, TicketID,
40    client::{
41        Condition, CreateTicketData, Criteria, DetailedTicket, EditTicketData, ListInfo, LogicalOp,
42        Note, NoteData, SearchRequest, TicketData, TicketSearchResponse,
43    },
44    error::Error,
45};
46
47/// Client for ticket collection operations (search, create, delete, update).
48pub struct TicketsClient<'a> {
49    pub(crate) client: &'a ServiceDesk,
50}
51
52impl<'a> TicketsClient<'a> {
53    /// Start building a ticket search query. Default limit is 100.
54    pub fn search(self) -> TicketSearchBuilder<'a> {
55        TicketSearchBuilder {
56            client: self.client,
57            root_criteria: None,
58            children: vec![],
59            row_count: 100,
60        }
61    }
62
63    /// Start building a new ticket.
64    pub fn create(self) -> TicketCreateBuilder<'a> {
65        TicketCreateBuilder {
66            client: self.client,
67            subject: None,
68            description: None,
69            requester: None,
70            priority: "Low".to_string(),
71            account: None,
72            template: None,
73            udf_fields: None,
74        }
75    }
76}
77
78/// Client for single ticket operations (get, close, assign, notes, merge).
79pub struct TicketClient<'a> {
80    pub(crate) client: &'a ServiceDesk,
81    pub(crate) id: TicketID,
82}
83
84impl<'a> TicketClient<'a> {
85    /// Get full ticket details.
86    pub async fn get(&self) -> Result<DetailedTicket, Error> {
87        self.client.ticket_details(self.id).await
88    }
89
90    /// Close the ticket with a comment.
91    pub async fn close(&self, comment: &str) -> Result<(), Error> {
92        self.client.close_ticket(self.id, comment).await
93    }
94
95    /// Assign the ticket to a technician.
96    pub async fn assign(&self, technician: &str) -> Result<(), Error> {
97        self.client.assign_ticket(self.id, technician).await
98    }
99
100    pub async fn conversations(&self) -> Result<Value, Error> {
101        self.client.get_conversations(self.id).await
102    }
103
104    pub async fn conversation_content(&self, content_url: &str) -> Result<Value, Error> {
105        self.client.get_conversation_content(content_url).await
106    }
107
108    /// Get all attachment links for the ticket, including conversation attachments
109    /// including attachments from merged tickets.
110    pub async fn all_attachment_links(&self) -> Result<Vec<String>, Error> {
111        let ticket = self.client.ticket(self.id).get().await?;
112        let mut links = Vec::new();
113        if let Some(attachments) = ticket.attachments {
114            for attachment in attachments {
115                links.push(format!(
116                    "{}{}",
117                    self.client.base_url, attachment.content_url
118                ));
119            }
120        }
121        if let Ok(attachments) = self.client.get_conversation_attachment_urls(self.id).await {
122            for url in attachments {
123                links.push(url);
124            }
125        }
126        Ok(links)
127    }
128
129    /// Add a note to the ticket with default settings.
130    pub async fn add_note(&self, description: &str) -> Result<Note, Error> {
131        self.client
132            .add_note(
133                self.id,
134                &NoteData {
135                    description: description.to_string(),
136                    ..Default::default()
137                },
138            )
139            .await
140    }
141
142    /// Start building a note with custom settings.
143    pub fn note(&self) -> NoteBuilder<'a> {
144        NoteBuilder {
145            client: self.client,
146            ticket_id: self.id,
147            description: String::new(),
148            mark_first_response: false,
149            add_to_linked_requests: false,
150            notify_technician: false,
151            show_to_requester: false,
152        }
153    }
154
155    /// Merge other tickets into this one.
156    pub async fn merge(&self, ticket_ids: &[TicketID]) -> Result<(), Error> {
157        self.client.merge(self.id, ticket_ids).await
158    }
159
160    /// Edit ticket fields.
161    pub async fn edit(&self, data: &EditTicketData) -> Result<(), Error> {
162        self.client.edit(self.id, data).await
163    }
164
165    /// Close ticket with a note.
166    pub async fn close_with_note(&self, comment: &str) -> Result<(), Error> {
167        self.client
168            .add_note(
169                self.id,
170                &NoteData {
171                    description: comment.to_string(),
172                    ..Default::default()
173                },
174            )
175            .await?;
176        self.client.close_ticket(self.id, comment).await
177    }
178}
179
180/// Builder for searching tickets.
181///
182/// All filter methods are optional. Default limit is 100 results.
183pub struct TicketSearchBuilder<'a> {
184    client: &'a ServiceDesk,
185    root_criteria: Option<Criteria>,
186    children: Vec<Criteria>,
187    row_count: u32,
188}
189
190/// Ticket status filter values.
191#[derive(Debug, PartialEq, Eq)]
192pub enum TicketStatus {
193    Open,
194    Closed,
195    Cancelled,
196    OnHold,
197}
198
199impl std::fmt::Display for TicketStatus {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        let status_str = match self {
202            TicketStatus::Open => "Open",
203            TicketStatus::Closed => "Closed",
204            TicketStatus::Cancelled => "Cancelled",
205            TicketStatus::OnHold => "On Hold",
206        };
207        write!(f, "{}", status_str)
208    }
209}
210
211impl<'a> TicketSearchBuilder<'a> {
212    /// Filter by ticket status.
213    pub fn status(mut self, status: &str) -> Self {
214        self.root_criteria = Some(Criteria {
215            field: "status.name".to_string(),
216            condition: Condition::Is,
217            value: status.into(),
218            children: vec![],
219            logical_operator: None,
220        });
221        self
222    }
223
224    /// Filter by ticket status using the [`TicketStatus`] enum.
225    pub fn filter(self, filter: &TicketStatus) -> Self {
226        self.status(&filter.to_string())
227    }
228
229    /// Filter by open tickets.
230    pub fn open(self) -> Self {
231        self.status("Open")
232    }
233
234    /// Filter by closed tickets.
235    pub fn closed(self) -> Self {
236        self.status("Closed")
237    }
238
239    /// Filter tickets created after a given time.
240    pub fn created_after(mut self, time: DateTime<Local>) -> Self {
241        self.children.push(Criteria {
242            field: "created_time".to_string(),
243            condition: Condition::GreaterThan,
244            value: time.timestamp_millis().to_string().into(),
245            children: vec![],
246            logical_operator: Some(LogicalOp::And),
247        });
248        self
249    }
250
251    /// Filter tickets last updated after a given time.
252    pub fn updated_after(mut self, time: DateTime<Local>) -> Self {
253        self.children.push(Criteria {
254            field: "last_updated_time".to_string(),
255            condition: Condition::GreaterThan,
256            value: time.timestamp_millis().to_string().into(),
257            children: vec![],
258            logical_operator: Some(LogicalOp::And),
259        });
260        self
261    }
262
263    /// Filter by subject containing a value.
264    pub fn subject_contains(mut self, value: &str) -> Self {
265        self.children.push(Criteria {
266            field: "subject".to_string(),
267            condition: Condition::Contains,
268            value: value.into(),
269            children: vec![],
270            logical_operator: Some(LogicalOp::And),
271        });
272        self
273    }
274
275    /// Filter by a custom field containing a value.
276    pub fn field_contains(mut self, field: &str, value: impl Into<Value>) -> Self {
277        self.children.push(Criteria {
278            field: field.to_string(),
279            condition: Condition::Contains,
280            value: value.into(),
281            children: vec![],
282            logical_operator: Some(LogicalOp::And),
283        });
284        self
285    }
286
287    /// Filter by a custom field matching exactly.
288    pub fn field_equals(mut self, field: &str, value: impl Into<Value>) -> Self {
289        self.children.push(Criteria {
290            field: field.to_string(),
291            condition: Condition::Is,
292            value: value.into(),
293            children: vec![],
294            logical_operator: Some(LogicalOp::And),
295        });
296        self
297    }
298
299    /// Set maximum number of results. Default: 100.
300    pub fn limit(mut self, count: u32) -> Self {
301        self.row_count = count;
302        self
303    }
304
305    /// Add a raw [`Criteria`] for complex queries.
306    pub fn criteria(mut self, criteria: Criteria) -> Self {
307        if self.root_criteria.is_none() {
308            self.root_criteria = Some(criteria);
309        } else {
310            self.children.push(criteria);
311        }
312        self
313    }
314
315    /// Execute the search and return results.
316    pub async fn fetch(self) -> Result<Vec<DetailedTicket>, Error> {
317        let mut root = self.root_criteria.unwrap_or_else(|| Criteria {
318            field: "id".to_string(),
319            condition: Condition::GreaterThan,
320            value: "0".into(),
321            children: vec![],
322            logical_operator: None,
323        });
324
325        root.children = self.children;
326
327        let body = SearchRequest {
328            list_info: ListInfo {
329                row_count: self.row_count,
330                search_criteria: root,
331            },
332        };
333
334        let resp: Value = self
335            .client
336            .request_input_data(Method::GET, "/api/v3/requests", &body)
337            .await?;
338
339        let ticket_response: TicketSearchResponse = serde_json::from_value(resp)?;
340        Ok(ticket_response.requests)
341    }
342
343    /// Execute the search and return the first result.
344    pub async fn first(mut self) -> Result<Option<DetailedTicket>, Error> {
345        self.row_count = 1;
346        let results = self.fetch().await?;
347        Ok(results.into_iter().next())
348    }
349}
350
351/// Builder for creating tickets.
352///
353/// Required: [`subject`](Self::subject), [`requester`](Self::requester).
354/// Default priority: "Low".
355pub struct TicketCreateBuilder<'a> {
356    client: &'a ServiceDesk,
357    subject: Option<String>,
358    description: Option<String>,
359    requester: Option<String>,
360    priority: String,
361    account: Option<String>,
362    template: Option<String>,
363    udf_fields: Option<Value>,
364}
365
366impl<'a> TicketCreateBuilder<'a> {
367    /// Set the ticket subject (required).
368    pub fn subject(mut self, subject: impl Into<String>) -> Self {
369        self.subject = Some(subject.into());
370        self
371    }
372
373    /// Set the ticket description.
374    pub fn description(mut self, description: impl Into<String>) -> Self {
375        self.description = Some(description.into());
376        self
377    }
378
379    /// Set the requester name (required).
380    pub fn requester(mut self, requester: impl Into<String>) -> Self {
381        self.requester = Some(requester.into());
382        self
383    }
384
385    /// Set the priority. Default: "Low".
386    pub fn priority(mut self, priority: impl Into<String>) -> Self {
387        self.priority = priority.into();
388        self
389    }
390
391    /// Set the account name.
392    pub fn account(mut self, account: impl Into<String>) -> Self {
393        self.account = Some(account.into());
394        self
395    }
396
397    /// Set the template name.
398    pub fn template(mut self, template: impl Into<String>) -> Self {
399        self.template = Some(template.into());
400        self
401    }
402
403    /// Set custom UDF fields.
404    pub fn udf_fields(mut self, fields: Value) -> Self {
405        self.udf_fields = Some(fields);
406        self
407    }
408
409    /// Create the ticket.
410    pub async fn send(self) -> Result<TicketData, Error> {
411        let subject = self
412            .subject
413            .ok_or_else(|| Error::Other("subject is required".to_string()))?;
414        let requester = self
415            .requester
416            .ok_or_else(|| Error::Other("requester is required".to_string()))?;
417
418        let data = CreateTicketData {
419            subject,
420            description: self.description.unwrap_or_default(),
421            requester,
422            priority: self.priority,
423            account: self.account.unwrap_or_default(),
424            template: self.template.unwrap_or_default(),
425            udf_fields: self.udf_fields.unwrap_or(serde_json::json!({})),
426        };
427
428        self.client.create_ticket(&data).await
429    }
430}
431
432/// Builder for adding notes with custom settings.
433///
434/// All boolean options default to `false`.
435pub struct NoteBuilder<'a> {
436    client: &'a ServiceDesk,
437    ticket_id: TicketID,
438    description: String,
439    mark_first_response: bool,
440    add_to_linked_requests: bool,
441    notify_technician: bool,
442    show_to_requester: bool,
443}
444
445impl<'a> NoteBuilder<'a> {
446    /// Set the note content.
447    pub fn description(mut self, description: impl Into<String>) -> Self {
448        self.description = description.into();
449        self
450    }
451
452    /// Mark as first response.
453    pub fn mark_first_response(mut self) -> Self {
454        self.mark_first_response = true;
455        self
456    }
457
458    /// Add to linked requests.
459    pub fn add_to_linked_requests(mut self) -> Self {
460        self.add_to_linked_requests = true;
461        self
462    }
463
464    /// Notify the assigned technician.
465    pub fn notify_technician(mut self) -> Self {
466        self.notify_technician = true;
467        self
468    }
469
470    /// Make visible to the requester.
471    pub fn show_to_requester(mut self) -> Self {
472        self.show_to_requester = true;
473        self
474    }
475
476    /// Add the note.
477    pub async fn send(self) -> Result<Note, Error> {
478        let note = NoteData {
479            description: self.description,
480            mark_first_response: self.mark_first_response,
481            add_to_linked_requests: self.add_to_linked_requests,
482            notify_technician: self.notify_technician,
483            show_to_requester: self.show_to_requester,
484        };
485
486        let note = self.client.add_note(self.ticket_id, &note).await?;
487        Ok(note)
488    }
489}
490
491impl ServiceDesk {
492    /// Get a client for ticket collection operations.
493    pub fn tickets(&self) -> TicketsClient<'_> {
494        TicketsClient { client: self }
495    }
496
497    /// Get a client for single ticket operations.
498    pub fn ticket(&self, id: impl Into<TicketID>) -> TicketClient<'_> {
499        TicketClient {
500            client: self,
501            id: id.into(),
502        }
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn ticket_status_display() {
512        assert_eq!(TicketStatus::Open.to_string(), "Open");
513        assert_eq!(TicketStatus::Closed.to_string(), "Closed");
514        assert_eq!(TicketStatus::Cancelled.to_string(), "Cancelled");
515        assert_eq!(TicketStatus::OnHold.to_string(), "On Hold");
516    }
517}