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