1use 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
50pub struct TicketsClient<'a> {
52 pub(crate) client: &'a ServiceDesk,
53}
54
55impl<'a> TicketsClient<'a> {
56 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 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
81pub struct TicketClient<'a> {
83 pub(crate) client: &'a ServiceDesk,
84 pub(crate) id: TicketID,
85}
86
87impl<'a> TicketClient<'a> {
88 pub async fn get(&self) -> Result<DetailedTicket, Error> {
90 self.client.ticket_details(self.id).await
91 }
92
93 pub async fn close(&self, comment: &str) -> Result<(), Error> {
95 self.client.close_ticket(self.id, comment).await
96 }
97
98 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 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 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 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 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 pub async fn merge(&self, ticket_ids: &[TicketID]) -> Result<(), Error> {
183 self.client.merge(self.id, ticket_ids).await
184 }
185
186 pub async fn merged_ticket_ids(&self) -> Result<Vec<TicketID>, Error> {
188 self.client.merged_ticket_ids(self.id).await
189 }
190
191 pub async fn edit(&self, data: &EditTicketData) -> Result<(), Error> {
193 self.client.edit(self.id, data).await
194 }
195
196 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
211pub struct TicketSearchBuilder<'a> {
215 client: &'a ServiceDesk,
216 root_criteria: Option<Criteria>,
217 children: Vec<Criteria>,
218 row_count: u32,
219}
220
221#[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 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 pub fn filter(self, filter: &TicketStatus) -> Self {
257 self.status(&filter.to_string())
258 }
259
260 pub fn open(self) -> Self {
262 self.status("Open")
263 }
264
265 pub fn closed(self) -> Self {
267 self.status("Closed")
268 }
269
270 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 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 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 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 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 pub fn limit(mut self, count: u32) -> Self {
332 self.row_count = count;
333 self
334 }
335
336 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 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 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
382pub 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 pub fn subject(mut self, subject: impl Into<String>) -> Self {
400 self.subject = Some(subject.into());
401 self
402 }
403
404 pub fn description(mut self, description: impl Into<String>) -> Self {
406 self.description = Some(description.into());
407 self
408 }
409
410 pub fn requester(mut self, requester: impl Into<String>) -> Self {
412 self.requester = Some(requester.into());
413 self
414 }
415
416 pub fn priority(mut self, priority: Priority) -> Self {
418 self.priority = priority;
419 self
420 }
421
422 pub fn account(mut self, account: impl Into<String>) -> Self {
424 self.account = Some(account.into());
425 self
426 }
427
428 pub fn template(mut self, template: impl Into<String>) -> Self {
430 self.template = Some(template.into());
431 self
432 }
433
434 pub fn udf_fields(mut self, fields: Value) -> Self {
436 self.udf_fields = Some(fields);
437 self
438 }
439
440 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
463pub 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 pub fn description(mut self, description: impl Into<String>) -> Self {
479 self.description = description.into();
480 self
481 }
482
483 pub fn mark_first_response(mut self) -> Self {
485 self.mark_first_response = true;
486 self
487 }
488
489 pub fn add_to_linked_requests(mut self) -> Self {
491 self.add_to_linked_requests = true;
492 self
493 }
494
495 pub fn notify_technician(mut self) -> Self {
497 self.notify_technician = true;
498 self
499 }
500
501 pub fn show_to_requester(mut self) -> Self {
503 self.show_to_requester = true;
504 self
505 }
506
507 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 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, ¬e).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 pub fn description(mut self, description: impl Into<String>) -> Self {
571 self.description = Some(description.into());
572 self
573 }
574
575 pub fn start_time(mut self, start_time: DateTime<Local>) -> Self {
577 self.start_time = Some(start_time);
578 self
579 }
580
581 pub fn end_time(mut self, end_time: DateTime<Local>) -> Self {
583 self.end_time = Some(end_time);
584 self
585 }
586
587 pub fn exchange_rate(mut self, exchange_rate: f64) -> Self {
589 self.exchange_rate = Some(exchange_rate);
590 self
591 }
592
593 pub fn mark_first_response(mut self) -> Self {
595 self.mark_first_response = Some(true);
596 self
597 }
598
599 pub fn include_nonoperational_hours(mut self) -> Self {
601 self.include_nonoperational_hours = Some(true);
602 self
603 }
604
605 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 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 pub fn tickets(&self) -> TicketsClient<'_> {
632 TicketsClient { client: self }
633 }
634
635 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}