cal_core/rest/
conversation.rs

1// File: cal-core/src/rest/conversation.rs
2use crate::contact::Contact;
3use crate::conversation::{
4    Conversation, ConversationId, ConversationItem, ConversationNotification,
5    SessionVariable, VoiceEvent, VoiceItem, WhatsAppItem, WhatsappRequest
6    ,
7};
8use crate::rest::common::{
9    ApiError, ApiResponse, ListRequest, PaginatedResponse,
10    PaginationParams, SortOrder, SortParams,
11};
12use crate::RecordReference;
13use serde::{Deserialize, Serialize};
14use std::collections::HashSet;
15
16// ==================== Create Conversation Request ====================
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct CreateConversationRequest {
21    pub from: String, // E164 phone number
22    pub account: RecordReference,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub contact: Option<Contact>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub initial_item: Option<ConversationItem>,
27}
28
29// ==================== Update Conversation Request ====================
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct UpdateConversationRequest {
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub contact: Option<Contact>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub metadata: Option<serde_json::Value>,
38}
39
40// ==================== Add Conversation Item Request ====================
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct AddConversationItemRequest {
45    pub channel: String, // "voice" or "whatsapp"
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub voice: Option<VoiceItem>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub whatsapp: Option<WhatsAppItem>,
50    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
51    pub meta: HashSet<SessionVariable>,
52}
53
54// ==================== Send WhatsApp Message Request ====================
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct SendWhatsAppMessageRequest {
59    pub phone_number_id: String,
60    pub message: WhatsappRequest,
61}
62
63// ==================== Update Voice Events Request ====================
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct UpdateVoiceEventsRequest {
68    pub item_id: String,
69    pub events: Vec<VoiceEvent>,
70}
71
72// ==================== List Conversations Request ====================
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct ConversationListRequest {
77    #[serde(flatten)]
78    pub common: ListRequest,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub account_id: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub from: Option<String>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub channel: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub has_contact: Option<bool>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub has_parked_calls: Option<bool>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub within_whatsapp_period: Option<bool>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub created_after: Option<i64>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub created_before: Option<i64>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub updated_after: Option<i64>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub updated_before: Option<i64>,
99}
100
101// ==================== Conversation Events Request ====================
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct ConversationEventsRequest {
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub item_id: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub event_type: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub since_timestamp: Option<i64>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub until_timestamp: Option<i64>,
114    #[serde(default = "default_limit")]
115    pub limit: u32,
116}
117
118// ==================== Session Variables Request ====================
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct UpdateSessionVariablesRequest {
123    pub variables: Vec<SessionVariable>,
124    #[serde(default)]
125    pub merge: bool, // If true, merge with existing; if false, replace
126}
127
128// ==================== Conversation Statistics ====================
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct ConversationStats {
133    pub total_conversations: u64,
134    pub active_conversations: u64,
135    pub conversations_with_parked_calls: u64,
136    pub voice_conversations: u64,
137    pub whatsapp_conversations: u64,
138    pub average_items_per_conversation: f64,
139    pub conversations_by_channel: serde_json::Map<String, serde_json::Value>,
140}
141
142// ==================== Type Aliases ====================
143
144pub type ConversationResponse = ApiResponse<Conversation>;
145pub type ConversationListResponse = ApiResponse<PaginatedResponse<Conversation>>;
146pub type ConversationItemResponse = ApiResponse<ConversationItem>;
147pub type ConversationEventsResponse = ApiResponse<Vec<VoiceEvent>>;
148pub type ConversationStatsResponse = ApiResponse<ConversationStats>;
149pub type ConversationNotificationResponse = ApiResponse<ConversationNotification>;
150
151// ==================== Error Codes ====================
152
153#[derive(Debug, Clone)]
154pub enum ConversationErrorCode {
155    NotFound,
156    InvalidPhoneNumber,
157    InvalidChannel,
158    MissingChannelData,
159    InvalidAccount,
160    ItemNotFound,
161    InvalidTimeRange,
162    WhatsAppPeriodExpired,
163}
164
165impl ConversationErrorCode {
166    pub fn as_str(&self) -> &'static str {
167        match self {
168            Self::NotFound => "CONVERSATION_NOT_FOUND",
169            Self::InvalidPhoneNumber => "INVALID_PHONE_NUMBER",
170            Self::InvalidChannel => "INVALID_CHANNEL",
171            Self::MissingChannelData => "MISSING_CHANNEL_DATA",
172            Self::InvalidAccount => "INVALID_ACCOUNT",
173            Self::ItemNotFound => "CONVERSATION_ITEM_NOT_FOUND",
174            Self::InvalidTimeRange => "INVALID_TIME_RANGE",
175            Self::WhatsAppPeriodExpired => "WHATSAPP_PERIOD_EXPIRED",
176        }
177    }
178}
179
180// ==================== Helper Implementations ====================
181
182impl CreateConversationRequest {
183    pub fn into_conversation(self) -> Conversation {
184        let id = ConversationId {
185            from: self.from,
186            account: self.account,
187        };
188
189        let now = chrono::Utc::now().timestamp_millis();
190        let mut items = Vec::new();
191
192        if let Some(initial_item) = self.initial_item {
193            items.push(initial_item);
194        }
195
196        Conversation {
197            id,
198            contact: self.contact.map(|c| c.into()),
199            items,
200            created: now,
201            last_updated: now,
202        }
203    }
204
205    pub fn validate(&self) -> Result<(), ApiError> {
206        // Validate E164 phone number format
207        if !self.from.starts_with('+') || self.from.len() < 10 || self.from.len() > 15 {
208            return Err(ApiError::new(
209                ConversationErrorCode::InvalidPhoneNumber.as_str(),
210                "Phone number must be in E164 format",
211            )
212                .with_field("from"));
213        }
214
215        Ok(())
216    }
217}
218
219impl AddConversationItemRequest {
220    pub fn into_conversation_item(self, id: String) -> Result<ConversationItem, ApiError> {
221        // Validate channel data
222        match self.channel.as_str() {
223            "voice" => {
224                if self.voice.is_none() {
225                    return Err(ApiError::new(
226                        ConversationErrorCode::MissingChannelData.as_str(),
227                        "Voice data is required for voice channel",
228                    ));
229                }
230            }
231            "whatsapp" => {
232                if self.whatsapp.is_none() {
233                    return Err(ApiError::new(
234                        ConversationErrorCode::MissingChannelData.as_str(),
235                        "WhatsApp data is required for whatsapp channel",
236                    ));
237                }
238            }
239            _ => {
240                return Err(ApiError::new(
241                    ConversationErrorCode::InvalidChannel.as_str(),
242                    "Channel must be 'voice' or 'whatsapp'",
243                )
244                    .with_field("channel"));
245            }
246        }
247
248        Ok(ConversationItem {
249            id,
250            channel: self.channel,
251            timestamp: chrono::Utc::now().timestamp_millis(),
252            voice: self.voice,
253            whatsapp: self.whatsapp,
254            meta: self.meta,
255        })
256    }
257}
258
259impl UpdateConversationRequest {
260    pub fn apply_to(self, conversation: &mut Conversation) {
261        if let Some(contact) = self.contact {
262            conversation.contact = Some(contact.into());
263        }
264        conversation.last_updated = chrono::Utc::now().timestamp_millis();
265    }
266
267    pub fn is_empty(&self) -> bool {
268        self.contact.is_none() && self.metadata.is_none()
269    }
270}
271
272impl Default for ConversationListRequest {
273    fn default() -> Self {
274        Self {
275            common: ListRequest {
276                pagination: PaginationParams::default(),
277                sort: SortParams::default(),
278                search: None,
279                time_range: None,
280                filters: None,
281            },
282            account_id: None,
283            from: None,
284            channel: None,
285            has_contact: None,
286            has_parked_calls: None,
287            within_whatsapp_period: None,
288            created_after: None,
289            created_before: None,
290            updated_after: None,
291            updated_before: None,
292        }
293    }
294}
295
296impl ConversationListRequest {
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    pub fn with_pagination(mut self, page: u32, page_size: u32) -> Self {
302        self.common.pagination = PaginationParams::new(page, page_size);
303        self
304    }
305
306    pub fn with_sorting(mut self, sort_by: String, sort_order: SortOrder) -> Self {
307        self.common.sort = SortParams {
308            sort_by: Some(sort_by),
309            sort_order,
310        };
311        self
312    }
313
314    pub fn with_account_id(mut self, account_id: String) -> Self {
315        self.account_id = Some(account_id);
316        self
317    }
318
319    pub fn with_channel(mut self, channel: String) -> Self {
320        self.channel = Some(channel);
321        self
322    }
323
324    pub fn with_time_range(mut self, created_after: i64, created_before: i64) -> Self {
325        self.created_after = Some(created_after);
326        self.created_before = Some(created_before);
327        self
328    }
329
330    pub fn only_with_parked_calls(mut self) -> Self {
331        self.has_parked_calls = Some(true);
332        self
333    }
334
335    pub fn only_within_whatsapp_period(mut self) -> Self {
336        self.within_whatsapp_period = Some(true);
337        self
338    }
339}
340
341fn default_limit() -> u32 {
342    100
343}
344
345impl UpdateSessionVariablesRequest {
346    pub fn apply_to(self, conversation: &mut ConversationItem) {
347        if self.merge {
348            // Merge new variables with existing ones
349            for var in self.variables {
350                conversation.meta.replace(var);
351            }
352        } else {
353            // Replace all variables
354            conversation.meta = self.variables.into_iter().collect();
355        }
356    }
357}