Skip to main content

ares/api/handlers/
conversations.rs

1//! Conversation management handlers.
2//!
3//! This module provides CRUD operations for user conversations.
4
5use crate::{
6    auth::middleware::AuthUser,
7    db::postgres::Conversation,
8    types::{AppError, Result},
9    AppState,
10};
11use axum::{
12    extract::{Path, State},
13    Json,
14};
15use serde::{Deserialize, Serialize};
16use utoipa::ToSchema;
17
18/// Conversation summary returned in list endpoints.
19#[derive(Debug, Serialize, ToSchema)]
20pub struct ConversationSummary {
21    /// Unique conversation identifier
22    pub id: String,
23    /// Optional conversation title
24    pub title: Option<String>,
25    /// Number of messages in the conversation
26    pub message_count: i32,
27    /// RFC3339 formatted creation timestamp
28    pub created_at: String,
29    /// RFC3339 formatted last update timestamp
30    pub updated_at: String,
31}
32
33impl From<Conversation> for ConversationSummary {
34    fn from(c: Conversation) -> Self {
35        Self {
36            id: c.id,
37            title: c.title,
38            message_count: c.message_count,
39            created_at: c.created_at,
40            updated_at: c.updated_at,
41        }
42    }
43}
44
45/// Full conversation with messages.
46#[derive(Debug, Serialize, ToSchema)]
47pub struct ConversationDetails {
48    /// Unique conversation identifier
49    pub id: String,
50    /// Optional conversation title
51    pub title: Option<String>,
52    /// Messages in the conversation, ordered by time
53    pub messages: Vec<ConversationMessage>,
54    /// RFC3339 formatted creation timestamp
55    pub created_at: String,
56    /// RFC3339 formatted last update timestamp
57    pub updated_at: String,
58}
59
60/// A message in a conversation.
61#[derive(Debug, Serialize, ToSchema)]
62pub struct ConversationMessage {
63    /// Unique message identifier
64    pub id: String,
65    /// Message role: "user", "assistant", or "system"
66    pub role: String,
67    /// Message content
68    pub content: String,
69    /// RFC3339 formatted timestamp
70    pub created_at: String,
71}
72
73/// Request to update a conversation.
74#[derive(Debug, Deserialize, ToSchema)]
75pub struct UpdateConversationRequest {
76    /// New title for the conversation (None to clear)
77    pub title: Option<String>,
78}
79
80/// List all conversations for the authenticated user.
81#[utoipa::path(
82    get,
83    path = "/api/conversations",
84    responses(
85        (status = 200, description = "List of conversations", body = Vec<ConversationSummary>),
86        (status = 401, description = "Unauthorized")
87    ),
88    tag = "conversations",
89    security(("bearer" = []))
90)]
91pub async fn list_conversations(
92    State(state): State<AppState>,
93    AuthUser(claims): AuthUser,
94) -> Result<Json<Vec<ConversationSummary>>> {
95    let conversations = state.db.get_user_conversations(&claims.sub).await?;
96
97    let summaries: Vec<ConversationSummary> = conversations
98        .into_iter()
99        .map(|c| ConversationSummary {
100            id: c.id,
101            title: Some(c.title),
102            message_count: c.message_count,
103            created_at: c.created_at,
104            updated_at: c.updated_at,
105        })
106        .collect();
107
108    Ok(Json(summaries))
109}
110
111/// Get a specific conversation with all messages.
112#[utoipa::path(
113    get,
114    path = "/api/conversations/{id}",
115    params(
116        ("id" = String, Path, description = "Conversation ID")
117    ),
118    responses(
119        (status = 200, description = "Conversation details", body = ConversationDetails),
120        (status = 404, description = "Conversation not found"),
121        (status = 401, description = "Unauthorized")
122    ),
123    tag = "conversations",
124    security(("bearer" = []))
125)]
126pub async fn get_conversation(
127    State(state): State<AppState>,
128    AuthUser(claims): AuthUser,
129    Path(id): Path<String>,
130) -> Result<Json<ConversationDetails>> {
131    // Verify conversation belongs to user
132    let conversation = state.db.get_conversation(&id).await?;
133
134    if conversation.user_id != claims.sub {
135        return Err(AppError::Auth(
136            "Not authorized to access this conversation".to_string(),
137        ));
138    }
139
140    let messages = state.db.get_conversation_history(&id).await?;
141
142    let message_details: Vec<ConversationMessage> = messages
143        .into_iter()
144        .enumerate()
145        .map(|(idx, msg)| ConversationMessage {
146            id: format!("{}-{}", id, idx), // Generate a pseudo-ID from conversation_id and index
147            role: format!("{:?}", msg.role).to_lowercase(),
148            content: msg.content,
149            created_at: msg.timestamp.to_rfc3339(),
150        })
151        .collect();
152
153    Ok(Json(ConversationDetails {
154        id: conversation.id,
155        title: conversation.title,
156        messages: message_details,
157        created_at: conversation.created_at,
158        updated_at: conversation.updated_at,
159    }))
160}
161
162/// Update a conversation (e.g., change title).
163#[utoipa::path(
164    put,
165    path = "/api/conversations/{id}",
166    params(
167        ("id" = String, Path, description = "Conversation ID")
168    ),
169    request_body = UpdateConversationRequest,
170    responses(
171        (status = 200, description = "Conversation updated"),
172        (status = 404, description = "Conversation not found"),
173        (status = 401, description = "Unauthorized")
174    ),
175    tag = "conversations",
176    security(("bearer" = []))
177)]
178pub async fn update_conversation(
179    State(state): State<AppState>,
180    AuthUser(claims): AuthUser,
181    Path(id): Path<String>,
182    Json(payload): Json<UpdateConversationRequest>,
183) -> Result<Json<serde_json::Value>> {
184    // Verify conversation belongs to user
185    let conversation = state.db.get_conversation(&id).await?;
186
187    if conversation.user_id != claims.sub {
188        return Err(AppError::Auth(
189            "Not authorized to modify this conversation".to_string(),
190        ));
191    }
192
193    state
194        .db
195        .update_conversation_title(&id, payload.title.as_deref())
196        .await?;
197
198    Ok(Json(serde_json::json!({"success": true})))
199}
200
201/// Delete a conversation and all its messages.
202#[utoipa::path(
203    delete,
204    path = "/api/conversations/{id}",
205    params(
206        ("id" = String, Path, description = "Conversation ID")
207    ),
208    responses(
209        (status = 204, description = "Conversation deleted"),
210        (status = 404, description = "Conversation not found"),
211        (status = 401, description = "Unauthorized")
212    ),
213    tag = "conversations",
214    security(("bearer" = []))
215)]
216pub async fn delete_conversation(
217    State(state): State<AppState>,
218    AuthUser(claims): AuthUser,
219    Path(id): Path<String>,
220) -> Result<axum::http::StatusCode> {
221    // Verify conversation belongs to user
222    let conversation = state.db.get_conversation(&id).await?;
223
224    if conversation.user_id != claims.sub {
225        return Err(AppError::Auth(
226            "Not authorized to delete this conversation".to_string(),
227        ));
228    }
229
230    state.db.delete_conversation(&id).await?;
231
232    Ok(axum::http::StatusCode::NO_CONTENT)
233}