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