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