1use 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
48pub struct TicketsClient<'a> {
50 pub(crate) client: &'a ServiceDesk,
51}
52
53impl<'a> TicketsClient<'a> {
54 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 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
79pub struct TicketClient<'a> {
81 pub(crate) client: &'a ServiceDesk,
82 pub(crate) id: TicketID,
83}
84
85impl<'a> TicketClient<'a> {
86 pub async fn get(&self) -> Result<DetailedTicket, Error> {
88 self.client.ticket_details(self.id).await
89 }
90
91 pub async fn close(&self, comment: &str) -> Result<(), Error> {
93 self.client.close_ticket(self.id, comment).await
94 }
95
96 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 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 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 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 pub async fn merge(&self, ticket_ids: &[TicketID]) -> Result<(), Error> {
160 self.client.merge(self.id, ticket_ids).await
161 }
162
163 pub async fn merged_ticket_ids(&self) -> Result<Vec<TicketID>, Error> {
165 self.client.merged_ticket_ids(self.id).await
166 }
167
168 pub async fn edit(&self, data: &EditTicketData) -> Result<(), Error> {
170 self.client.edit(self.id, data).await
171 }
172
173 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
188pub struct TicketSearchBuilder<'a> {
192 client: &'a ServiceDesk,
193 root_criteria: Option<Criteria>,
194 children: Vec<Criteria>,
195 row_count: u32,
196}
197
198#[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 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 pub fn filter(self, filter: &TicketStatus) -> Self {
234 self.status(&filter.to_string())
235 }
236
237 pub fn open(self) -> Self {
239 self.status("Open")
240 }
241
242 pub fn closed(self) -> Self {
244 self.status("Closed")
245 }
246
247 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 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 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 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 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 pub fn limit(mut self, count: u32) -> Self {
309 self.row_count = count;
310 self
311 }
312
313 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 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 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
359pub 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 pub fn subject(mut self, subject: impl Into<String>) -> Self {
377 self.subject = Some(subject.into());
378 self
379 }
380
381 pub fn description(mut self, description: impl Into<String>) -> Self {
383 self.description = Some(description.into());
384 self
385 }
386
387 pub fn requester(mut self, requester: impl Into<String>) -> Self {
389 self.requester = Some(requester.into());
390 self
391 }
392
393 pub fn priority(mut self, priority: impl Into<String>) -> Self {
395 self.priority = priority.into();
396 self
397 }
398
399 pub fn account(mut self, account: impl Into<String>) -> Self {
401 self.account = Some(account.into());
402 self
403 }
404
405 pub fn template(mut self, template: impl Into<String>) -> Self {
407 self.template = Some(template.into());
408 self
409 }
410
411 pub fn udf_fields(mut self, fields: Value) -> Self {
413 self.udf_fields = Some(fields);
414 self
415 }
416
417 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
440pub 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 pub fn description(mut self, description: impl Into<String>) -> Self {
454 self.description = description.into();
455 self
456 }
457
458 pub fn mark_first_response(mut self) -> Self {
460 self.mark_first_response = true;
461 self
462 }
463
464 pub fn add_to_linked_requests(mut self) -> Self {
466 self.add_to_linked_requests = true;
467 self
468 }
469
470 pub fn notify_technician(mut self) -> Self {
472 self.notify_technician = true;
473 self
474 }
475
476 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 pub fn description(mut self, description: impl Into<String>) -> Self {
553 self.description = Some(description.into());
554 self
555 }
556
557 pub fn start_time(mut self, start_time: DateTime<Local>) -> Self {
559 self.start_time = Some(start_time);
560 self
561 }
562
563 pub fn end_time(mut self, end_time: DateTime<Local>) -> Self {
565 self.end_time = Some(end_time);
566 self
567 }
568
569 pub fn exchange_rate(mut self, exchange_rate: f64) -> Self {
571 self.exchange_rate = Some(exchange_rate);
572 self
573 }
574
575 pub fn mark_first_response(mut self) -> Self {
577 self.mark_first_response = Some(true);
578 self
579 }
580
581 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 pub fn tickets(&self) -> TicketsClient<'_> {
605 TicketsClient { client: self }
606 }
607
608 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}