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    /// List IDs of tickets that were merged into this ticket.
161    pub async fn merged_ticket_ids(&self) -> Result<Vec<TicketID>, Error> {
162        self.client.merged_ticket_ids(self.id).await
163    }
164
165    /// Edit ticket fields.
166    pub async fn edit(&self, data: &EditTicketData) -> Result<(), Error> {
167        self.client.edit(self.id, data).await
168    }
169
170    /// Close ticket with a note.
171    pub async fn close_with_note(&self, comment: &str) -> Result<(), Error> {
172        self.client
173            .add_note(
174                self.id,
175                &NoteData {
176                    description: comment.to_string(),
177                    ..Default::default()
178                },
179            )
180            .await?;
181        self.client.close_ticket(self.id, comment).await
182    }
183}
184
185/// Builder for searching tickets.
186///
187/// All filter methods are optional. Default limit is 100 results.
188pub struct TicketSearchBuilder<'a> {
189    client: &'a ServiceDesk,
190    root_criteria: Option<Criteria>,
191    children: Vec<Criteria>,
192    row_count: u32,
193}
194
195/// Ticket status filter values.
196#[derive(Debug, PartialEq, Eq)]
197pub enum TicketStatus {
198    Open,
199    Closed,
200    Cancelled,
201    OnHold,
202}
203
204impl std::fmt::Display for TicketStatus {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        let status_str = match self {
207            TicketStatus::Open => "Open",
208            TicketStatus::Closed => "Closed",
209            TicketStatus::Cancelled => "Cancelled",
210            TicketStatus::OnHold => "On Hold",
211        };
212        write!(f, "{}", status_str)
213    }
214}
215
216impl<'a> TicketSearchBuilder<'a> {
217    /// Filter by ticket status.
218    pub fn status(mut self, status: &str) -> Self {
219        self.root_criteria = Some(Criteria {
220            field: "status.name".to_string(),
221            condition: Condition::Is,
222            value: status.into(),
223            children: vec![],
224            logical_operator: None,
225        });
226        self
227    }
228
229    /// Filter by ticket status using the [`TicketStatus`] enum.
230    pub fn filter(self, filter: &TicketStatus) -> Self {
231        self.status(&filter.to_string())
232    }
233
234    /// Filter by open tickets.
235    pub fn open(self) -> Self {
236        self.status("Open")
237    }
238
239    /// Filter by closed tickets.
240    pub fn closed(self) -> Self {
241        self.status("Closed")
242    }
243
244    /// Filter tickets created after a given time.
245    pub fn created_after(mut self, time: DateTime<Local>) -> Self {
246        self.children.push(Criteria {
247            field: "created_time".to_string(),
248            condition: Condition::GreaterThan,
249            value: time.timestamp_millis().to_string().into(),
250            children: vec![],
251            logical_operator: Some(LogicalOp::And),
252        });
253        self
254    }
255
256    /// Filter tickets last updated after a given time.
257    pub fn updated_after(mut self, time: DateTime<Local>) -> Self {
258        self.children.push(Criteria {
259            field: "last_updated_time".to_string(),
260            condition: Condition::GreaterThan,
261            value: time.timestamp_millis().to_string().into(),
262            children: vec![],
263            logical_operator: Some(LogicalOp::And),
264        });
265        self
266    }
267
268    /// Filter by subject containing a value.
269    pub fn subject_contains(mut self, value: &str) -> Self {
270        self.children.push(Criteria {
271            field: "subject".to_string(),
272            condition: Condition::Contains,
273            value: value.into(),
274            children: vec![],
275            logical_operator: Some(LogicalOp::And),
276        });
277        self
278    }
279
280    /// Filter by a custom field containing a value.
281    pub fn field_contains(mut self, field: &str, value: impl Into<Value>) -> Self {
282        self.children.push(Criteria {
283            field: field.to_string(),
284            condition: Condition::Contains,
285            value: value.into(),
286            children: vec![],
287            logical_operator: Some(LogicalOp::And),
288        });
289        self
290    }
291
292    /// Filter by a custom field matching exactly.
293    pub fn field_equals(mut self, field: &str, value: impl Into<Value>) -> Self {
294        self.children.push(Criteria {
295            field: field.to_string(),
296            condition: Condition::Is,
297            value: value.into(),
298            children: vec![],
299            logical_operator: Some(LogicalOp::And),
300        });
301        self
302    }
303
304    /// Set maximum number of results. Default: 100.
305    pub fn limit(mut self, count: u32) -> Self {
306        self.row_count = count;
307        self
308    }
309
310    /// Add a raw [`Criteria`] for complex queries.
311    pub fn criteria(mut self, criteria: Criteria) -> Self {
312        if self.root_criteria.is_none() {
313            self.root_criteria = Some(criteria);
314        } else {
315            self.children.push(criteria);
316        }
317        self
318    }
319
320    /// Execute the search and return results.
321    pub async fn fetch(self) -> Result<Vec<DetailedTicket>, Error> {
322        let mut root = self.root_criteria.unwrap_or_else(|| Criteria {
323            field: "id".to_string(),
324            condition: Condition::GreaterThan,
325            value: "0".into(),
326            children: vec![],
327            logical_operator: None,
328        });
329
330        root.children = self.children;
331
332        let body = SearchRequest {
333            list_info: ListInfo {
334                row_count: self.row_count,
335                search_criteria: root,
336            },
337        };
338
339        let resp: Value = self
340            .client
341            .request_input_data(Method::GET, "/api/v3/requests", &body)
342            .await?;
343
344        let ticket_response: TicketSearchResponse = serde_json::from_value(resp)?;
345        Ok(ticket_response.requests)
346    }
347
348    /// Execute the search and return the first result.
349    pub async fn first(mut self) -> Result<Option<DetailedTicket>, Error> {
350        self.row_count = 1;
351        let results = self.fetch().await?;
352        Ok(results.into_iter().next())
353    }
354}
355
356/// Builder for creating tickets.
357///
358/// Required: [`subject`](Self::subject), [`requester`](Self::requester).
359/// Default priority: "Low".
360pub struct TicketCreateBuilder<'a> {
361    client: &'a ServiceDesk,
362    subject: Option<String>,
363    description: Option<String>,
364    requester: Option<String>,
365    priority: String,
366    account: Option<String>,
367    template: Option<String>,
368    udf_fields: Option<Value>,
369}
370
371impl<'a> TicketCreateBuilder<'a> {
372    /// Set the ticket subject (required).
373    pub fn subject(mut self, subject: impl Into<String>) -> Self {
374        self.subject = Some(subject.into());
375        self
376    }
377
378    /// Set the ticket description.
379    pub fn description(mut self, description: impl Into<String>) -> Self {
380        self.description = Some(description.into());
381        self
382    }
383
384    /// Set the requester name (required).
385    pub fn requester(mut self, requester: impl Into<String>) -> Self {
386        self.requester = Some(requester.into());
387        self
388    }
389
390    /// Set the priority. Default: "Low".
391    pub fn priority(mut self, priority: impl Into<String>) -> Self {
392        self.priority = priority.into();
393        self
394    }
395
396    /// Set the account name.
397    pub fn account(mut self, account: impl Into<String>) -> Self {
398        self.account = Some(account.into());
399        self
400    }
401
402    /// Set the template name.
403    pub fn template(mut self, template: impl Into<String>) -> Self {
404        self.template = Some(template.into());
405        self
406    }
407
408    /// Set custom UDF fields.
409    pub fn udf_fields(mut self, fields: Value) -> Self {
410        self.udf_fields = Some(fields);
411        self
412    }
413
414    /// Create the ticket.
415    pub async fn send(self) -> Result<TicketData, Error> {
416        let subject = self
417            .subject
418            .ok_or_else(|| Error::Other("subject is required".to_string()))?;
419        let requester = self
420            .requester
421            .ok_or_else(|| Error::Other("requester is required".to_string()))?;
422
423        let data = CreateTicketData {
424            subject,
425            description: self.description.unwrap_or_default(),
426            requester,
427            priority: self.priority,
428            account: self.account.unwrap_or_default(),
429            template: self.template.unwrap_or_default(),
430            udf_fields: self.udf_fields.unwrap_or(serde_json::json!({})),
431        };
432
433        self.client.create_ticket(&data).await
434    }
435}
436
437/// Builder for adding notes with custom settings.
438///
439/// All boolean options default to `false`.
440pub struct NoteBuilder<'a> {
441    client: &'a ServiceDesk,
442    ticket_id: TicketID,
443    description: String,
444    mark_first_response: bool,
445    add_to_linked_requests: bool,
446    notify_technician: bool,
447    show_to_requester: bool,
448}
449
450impl<'a> NoteBuilder<'a> {
451    /// Set the note content.
452    pub fn description(mut self, description: impl Into<String>) -> Self {
453        self.description = description.into();
454        self
455    }
456
457    /// Mark as first response.
458    pub fn mark_first_response(mut self) -> Self {
459        self.mark_first_response = true;
460        self
461    }
462
463    /// Add to linked requests.
464    pub fn add_to_linked_requests(mut self) -> Self {
465        self.add_to_linked_requests = true;
466        self
467    }
468
469    /// Notify the assigned technician.
470    pub fn notify_technician(mut self) -> Self {
471        self.notify_technician = true;
472        self
473    }
474
475    /// Make visible to the requester.
476    pub fn show_to_requester(mut self) -> Self {
477        self.show_to_requester = true;
478        self
479    }
480
481    /// Add the note.
482    pub async fn send(self) -> Result<Note, Error> {
483        let note = NoteData {
484            description: self.description,
485            mark_first_response: self.mark_first_response,
486            add_to_linked_requests: self.add_to_linked_requests,
487            notify_technician: self.notify_technician,
488            show_to_requester: self.show_to_requester,
489        };
490
491        let note = self.client.add_note(self.ticket_id, &note).await?;
492        Ok(note)
493    }
494}
495
496impl ServiceDesk {
497    /// Get a client for ticket collection operations.
498    pub fn tickets(&self) -> TicketsClient<'_> {
499        TicketsClient { client: self }
500    }
501
502    /// Get a client for single ticket operations.
503    pub fn ticket(&self, id: impl Into<TicketID>) -> TicketClient<'_> {
504        TicketClient {
505            client: self,
506            id: id.into(),
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn ticket_status_display() {
517        assert_eq!(TicketStatus::Open.to_string(), "Open");
518        assert_eq!(TicketStatus::Closed.to_string(), "Closed");
519        assert_eq!(TicketStatus::Cancelled.to_string(), "Cancelled");
520        assert_eq!(TicketStatus::OnHold.to_string(), "On Hold");
521    }
522}