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::{Deserialize, Serialize};
37use serde_json::Value;
38
39use crate::{
40    ServiceDesk, TicketID, UserInfo,
41    client::{
42        Condition, CreateTicketData, Criteria, DetailedTicket, EditTicketData, ListInfo, LogicalOp,
43        Note, NoteData, SearchRequest, TicketData, TicketSearchResponse,
44    },
45    error::Error,
46};
47
48/// Client for ticket collection operations (search, create, delete, update).
49pub struct TicketsClient<'a> {
50    pub(crate) client: &'a ServiceDesk,
51}
52
53impl<'a> TicketsClient<'a> {
54    /// Start building a ticket search query. Default limit is 100.
55    pub fn search(self) -> TicketSearchBuilder<'a> {
56        TicketSearchBuilder {
57            client: self.client,
58            root_criteria: None,
59            children: vec![],
60            row_count: 100,
61        }
62    }
63
64    /// Start building a new ticket.
65    pub fn create(self) -> TicketCreateBuilder<'a> {
66        TicketCreateBuilder {
67            client: self.client,
68            subject: None,
69            description: None,
70            requester: None,
71            priority: "Low".to_string(),
72            account: None,
73            template: None,
74            udf_fields: None,
75        }
76    }
77}
78
79/// Client for single ticket operations (get, close, assign, notes, merge).
80pub struct TicketClient<'a> {
81    pub(crate) client: &'a ServiceDesk,
82    pub(crate) id: TicketID,
83}
84
85impl<'a> TicketClient<'a> {
86    /// Get full ticket details.
87    pub async fn get(&self) -> Result<DetailedTicket, Error> {
88        self.client.ticket_details(self.id).await
89    }
90
91    /// Close the ticket with a comment.
92    pub async fn close(&self, comment: &str) -> Result<(), Error> {
93        self.client.close_ticket(self.id, comment).await
94    }
95
96    /// Assign the ticket to a technician.
97    pub async fn assign(&self, technician: &str) -> Result<(), Error> {
98        self.client.assign_ticket(self.id, technician).await
99    }
100
101    pub async fn conversations(&self) -> Result<Value, Error> {
102        self.client.get_conversations(self.id).await
103    }
104
105    pub async fn conversation_content(&self, content_url: &str) -> Result<Value, Error> {
106        self.client.get_conversation_content(content_url).await
107    }
108
109    /// Get all attachment links for the ticket, including conversation attachments
110    /// including attachments from merged tickets.
111    pub async fn all_attachment_links(&self) -> Result<Vec<String>, Error> {
112        let ticket = self.client.ticket(self.id).get().await?;
113        let mut links = Vec::new();
114        if let Some(attachments) = ticket.attachments {
115            for attachment in attachments {
116                links.push(format!(
117                    "{}{}",
118                    self.client.base_url, attachment.content_url
119                ));
120            }
121        }
122        if let Ok(attachments) = self.client.get_conversation_attachment_urls(self.id).await {
123            for url in attachments {
124                links.push(url);
125            }
126        }
127        Ok(links)
128    }
129
130    /// Add a note to the ticket with default settings.
131    pub async fn add_note(&self, description: &str) -> Result<Note, Error> {
132        self.client
133            .add_note(
134                self.id,
135                &NoteData {
136                    description: description.to_string(),
137                    ..Default::default()
138                },
139            )
140            .await
141    }
142
143    pub async fn add_worklog(&self, worklog: &WorklogData) -> Result<Value, Error> {
144        self.client.add_worklog(self.id, worklog).await
145    }
146
147    /// Start building a note with custom settings.
148    pub fn note(&self) -> NoteBuilder {
149        NoteBuilder {
150            description: String::new(),
151            mark_first_response: false,
152            add_to_linked_requests: false,
153            notify_technician: false,
154            show_to_requester: false,
155        }
156    }
157
158    /// Merge other tickets into this one.
159    pub async fn merge(&self, ticket_ids: &[TicketID]) -> Result<(), Error> {
160        self.client.merge(self.id, ticket_ids).await
161    }
162
163    /// List IDs of tickets that were merged into this ticket.
164    pub async fn merged_ticket_ids(&self) -> Result<Vec<TicketID>, Error> {
165        self.client.merged_ticket_ids(self.id).await
166    }
167
168    /// Edit ticket fields.
169    pub async fn edit(&self, data: &EditTicketData) -> Result<(), Error> {
170        self.client.edit(self.id, data).await
171    }
172
173    /// Close ticket with a note.
174    pub async fn close_with_note(&self, comment: &str) -> Result<(), Error> {
175        self.client
176            .add_note(
177                self.id,
178                &NoteData {
179                    description: comment.to_string(),
180                    ..Default::default()
181                },
182            )
183            .await?;
184        self.client.close_ticket(self.id, comment).await
185    }
186}
187
188/// Builder for searching tickets.
189///
190/// All filter methods are optional. Default limit is 100 results.
191pub struct TicketSearchBuilder<'a> {
192    client: &'a ServiceDesk,
193    root_criteria: Option<Criteria>,
194    children: Vec<Criteria>,
195    row_count: u32,
196}
197
198/// Ticket status filter values.
199#[derive(Debug, PartialEq, Eq)]
200pub enum TicketStatus {
201    Open,
202    Closed,
203    Cancelled,
204    OnHold,
205}
206
207impl std::fmt::Display for TicketStatus {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        let status_str = match self {
210            TicketStatus::Open => "Open",
211            TicketStatus::Closed => "Closed",
212            TicketStatus::Cancelled => "Cancelled",
213            TicketStatus::OnHold => "On Hold",
214        };
215        write!(f, "{}", status_str)
216    }
217}
218
219impl<'a> TicketSearchBuilder<'a> {
220    /// Filter by ticket status.
221    pub fn status(mut self, status: &str) -> Self {
222        self.root_criteria = Some(Criteria {
223            field: "status.name".to_string(),
224            condition: Condition::Is,
225            value: status.into(),
226            children: vec![],
227            logical_operator: None,
228        });
229        self
230    }
231
232    /// Filter by ticket status using the [`TicketStatus`] enum.
233    pub fn filter(self, filter: &TicketStatus) -> Self {
234        self.status(&filter.to_string())
235    }
236
237    /// Filter by open tickets.
238    pub fn open(self) -> Self {
239        self.status("Open")
240    }
241
242    /// Filter by closed tickets.
243    pub fn closed(self) -> Self {
244        self.status("Closed")
245    }
246
247    /// Filter tickets created after a given time.
248    pub fn created_after(mut self, time: DateTime<Local>) -> Self {
249        self.children.push(Criteria {
250            field: "created_time".to_string(),
251            condition: Condition::GreaterThan,
252            value: time.timestamp_millis().to_string().into(),
253            children: vec![],
254            logical_operator: Some(LogicalOp::And),
255        });
256        self
257    }
258
259    /// Filter tickets last updated after a given time.
260    pub fn updated_after(mut self, time: DateTime<Local>) -> Self {
261        self.children.push(Criteria {
262            field: "last_updated_time".to_string(),
263            condition: Condition::GreaterThan,
264            value: time.timestamp_millis().to_string().into(),
265            children: vec![],
266            logical_operator: Some(LogicalOp::And),
267        });
268        self
269    }
270
271    /// Filter by subject containing a value.
272    pub fn subject_contains(mut self, value: &str) -> Self {
273        self.children.push(Criteria {
274            field: "subject".to_string(),
275            condition: Condition::Contains,
276            value: value.into(),
277            children: vec![],
278            logical_operator: Some(LogicalOp::And),
279        });
280        self
281    }
282
283    /// Filter by a custom field containing a value.
284    pub fn field_contains(mut self, field: &str, value: impl Into<Value>) -> Self {
285        self.children.push(Criteria {
286            field: field.to_string(),
287            condition: Condition::Contains,
288            value: value.into(),
289            children: vec![],
290            logical_operator: Some(LogicalOp::And),
291        });
292        self
293    }
294
295    /// Filter by a custom field matching exactly.
296    pub fn field_equals(mut self, field: &str, value: impl Into<Value>) -> Self {
297        self.children.push(Criteria {
298            field: field.to_string(),
299            condition: Condition::Is,
300            value: value.into(),
301            children: vec![],
302            logical_operator: Some(LogicalOp::And),
303        });
304        self
305    }
306
307    /// Set maximum number of results. Default: 100.
308    pub fn limit(mut self, count: u32) -> Self {
309        self.row_count = count;
310        self
311    }
312
313    /// Add a raw [`Criteria`] for complex queries.
314    pub fn criteria(mut self, criteria: Criteria) -> Self {
315        if self.root_criteria.is_none() {
316            self.root_criteria = Some(criteria);
317        } else {
318            self.children.push(criteria);
319        }
320        self
321    }
322
323    /// Execute the search and return results.
324    pub async fn fetch(self) -> Result<Vec<DetailedTicket>, Error> {
325        let mut root = self.root_criteria.unwrap_or_else(|| Criteria {
326            field: "id".to_string(),
327            condition: Condition::GreaterThan,
328            value: "0".into(),
329            children: vec![],
330            logical_operator: None,
331        });
332
333        root.children = self.children;
334
335        let body = SearchRequest {
336            list_info: ListInfo {
337                row_count: self.row_count,
338                search_criteria: root,
339            },
340        };
341
342        let resp: Value = self
343            .client
344            .request_input_data(Method::GET, "/api/v3/requests", &body)
345            .await?;
346
347        let ticket_response: TicketSearchResponse = serde_json::from_value(resp)?;
348        Ok(ticket_response.requests)
349    }
350
351    /// Execute the search and return the first result.
352    pub async fn first(mut self) -> Result<Option<DetailedTicket>, Error> {
353        self.row_count = 1;
354        let results = self.fetch().await?;
355        Ok(results.into_iter().next())
356    }
357}
358
359/// Builder for creating tickets.
360///
361/// Required: [`subject`](Self::subject), [`requester`](Self::requester).
362/// Default priority: "Low".
363pub struct TicketCreateBuilder<'a> {
364    client: &'a ServiceDesk,
365    subject: Option<String>,
366    description: Option<String>,
367    requester: Option<String>,
368    priority: String,
369    account: Option<String>,
370    template: Option<String>,
371    udf_fields: Option<Value>,
372}
373
374impl<'a> TicketCreateBuilder<'a> {
375    /// Set the ticket subject (required).
376    pub fn subject(mut self, subject: impl Into<String>) -> Self {
377        self.subject = Some(subject.into());
378        self
379    }
380
381    /// Set the ticket description.
382    pub fn description(mut self, description: impl Into<String>) -> Self {
383        self.description = Some(description.into());
384        self
385    }
386
387    /// Set the requester name (required).
388    pub fn requester(mut self, requester: impl Into<String>) -> Self {
389        self.requester = Some(requester.into());
390        self
391    }
392
393    /// Set the priority. Default: "Low".
394    pub fn priority(mut self, priority: impl Into<String>) -> Self {
395        self.priority = priority.into();
396        self
397    }
398
399    /// Set the account name.
400    pub fn account(mut self, account: impl Into<String>) -> Self {
401        self.account = Some(account.into());
402        self
403    }
404
405    /// Set the template name.
406    pub fn template(mut self, template: impl Into<String>) -> Self {
407        self.template = Some(template.into());
408        self
409    }
410
411    /// Set custom UDF fields.
412    pub fn udf_fields(mut self, fields: Value) -> Self {
413        self.udf_fields = Some(fields);
414        self
415    }
416
417    /// Create the ticket.
418    pub async fn send(self) -> Result<TicketData, Error> {
419        let subject = self
420            .subject
421            .ok_or_else(|| Error::Other("subject is required".to_string()))?;
422        let requester = self
423            .requester
424            .ok_or_else(|| Error::Other("requester is required".to_string()))?;
425
426        let data = CreateTicketData {
427            subject,
428            description: self.description.unwrap_or_default(),
429            requester,
430            priority: self.priority,
431            account: self.account.unwrap_or_default(),
432            template: self.template.unwrap_or_default(),
433            udf_fields: self.udf_fields.unwrap_or(serde_json::json!({})),
434        };
435
436        self.client.create_ticket(&data).await
437    }
438}
439
440/// Builder for adding notes with custom settings.
441///
442/// All boolean options default to `false`.
443pub struct NoteBuilder {
444    description: String,
445    mark_first_response: bool,
446    add_to_linked_requests: bool,
447    notify_technician: bool,
448    show_to_requester: bool,
449}
450
451impl NoteBuilder {
452    /// Set the note content.
453    pub fn description(mut self, description: impl Into<String>) -> Self {
454        self.description = description.into();
455        self
456    }
457
458    /// Mark as first response.
459    pub fn mark_first_response(mut self) -> Self {
460        self.mark_first_response = true;
461        self
462    }
463
464    /// Add to linked requests.
465    pub fn add_to_linked_requests(mut self) -> Self {
466        self.add_to_linked_requests = true;
467        self
468    }
469
470    /// Notify the assigned technician.
471    pub fn notify_technician(mut self) -> Self {
472        self.notify_technician = true;
473        self
474    }
475
476    /// Make visible to the requester.
477    pub fn show_to_requester(mut self) -> Self {
478        self.show_to_requester = true;
479        self
480    }
481
482    pub fn build(self) -> NoteData {
483        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}
492
493#[derive(Debug, Serialize, Deserialize)]
494pub struct WorklogData {
495    owner: UserInfo,
496    description: String,
497    #[serde(serialize_with = "serialize_sdp_time")]
498    start_time: DateTime<Local>,
499    #[serde(serialize_with = "serialize_sdp_time")]
500    end_time: DateTime<Local>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    exchange_rate: Option<f64>,
503    mark_first_response: bool,
504    include_nonoperational_hours: bool,
505}
506
507fn serialize_sdp_time<S>(dt: &DateTime<Local>, serializer: S) -> Result<S::Ok, S::Error>
508where
509    S: serde::Serializer,
510{
511    use serde::ser::SerializeStruct;
512    let mut s = serializer.serialize_struct("SdpTime", 1)?;
513    s.serialize_field("value", &dt.timestamp_millis())?;
514    s.end()
515}
516
517pub struct WorklogBuilder {
518    owner: Option<UserInfo>,
519    description: Option<String>,
520    start_time: Option<DateTime<Local>>,
521    end_time: Option<DateTime<Local>>,
522    exchange_rate: Option<f64>,
523    mark_first_response: Option<bool>,
524    include_nonoperational_hours: Option<bool>,
525}
526
527impl Default for WorklogBuilder {
528    fn default() -> Self {
529        Self::new()
530    }
531}
532
533impl WorklogBuilder {
534    pub fn new() -> Self {
535        Self {
536            owner: None,
537            description: None,
538            start_time: None,
539            end_time: None,
540            exchange_rate: None,
541            mark_first_response: None,
542            include_nonoperational_hours: None,
543        }
544    }
545
546    pub fn owner(mut self, owner: UserInfo) -> Self {
547        self.owner = Some(owner);
548        self
549    }
550
551    /// Set the worklog description.
552    pub fn description(mut self, description: impl Into<String>) -> Self {
553        self.description = Some(description.into());
554        self
555    }
556
557    /// Set the worklog start time.
558    pub fn start_time(mut self, start_time: DateTime<Local>) -> Self {
559        self.start_time = Some(start_time);
560        self
561    }
562
563    /// Set the worklog end time.
564    pub fn end_time(mut self, end_time: DateTime<Local>) -> Self {
565        self.end_time = Some(end_time);
566        self
567    }
568
569    /// Set the exchange rate for cost calculation.
570    pub fn exchange_rate(mut self, exchange_rate: f64) -> Self {
571        self.exchange_rate = Some(exchange_rate);
572        self
573    }
574
575    /// Mark as first response.
576    pub fn mark_first_response(mut self) -> Self {
577        self.mark_first_response = Some(true);
578        self
579    }
580
581    /// Include non-operational hours in time calculation.
582    pub fn include_nonoperational_hours(mut self) -> Self {
583        self.include_nonoperational_hours = Some(true);
584        self
585    }
586
587    pub fn build(self) -> Result<WorklogData, Error> {
588        Ok(WorklogData {
589            owner: self
590                .owner
591                .ok_or_else(|| Error::FieldRequired("owner".to_string()))?,
592            description: self.description.unwrap_or_default(),
593            start_time: self.start_time.unwrap_or_else(Local::now),
594            end_time: self.end_time.unwrap_or_else(Local::now),
595            exchange_rate: self.exchange_rate,
596            mark_first_response: self.mark_first_response.unwrap_or(false),
597            include_nonoperational_hours: self.include_nonoperational_hours.unwrap_or(false),
598        })
599    }
600}
601
602impl ServiceDesk {
603    /// Get a client for ticket collection operations.
604    pub fn tickets(&self) -> TicketsClient<'_> {
605        TicketsClient { client: self }
606    }
607
608    /// Get a client for single ticket operations.
609    pub fn ticket(&self, id: impl Into<TicketID>) -> TicketClient<'_> {
610        TicketClient {
611            client: self,
612            id: id.into(),
613        }
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn ticket_status_display() {
623        assert_eq!(TicketStatus::Open.to_string(), "Open");
624        assert_eq!(TicketStatus::Closed.to_string(), "Closed");
625        assert_eq!(TicketStatus::Cancelled.to_string(), "Cancelled");
626        assert_eq!(TicketStatus::OnHold.to_string(), "On Hold");
627    }
628}