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::turso::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.turso.get_user_conversations(&claims.sub).await?;
96
97    let summaries: Vec<ConversationSummary> = conversations
98        .into_iter()
99        .map(ConversationSummary::from)
100        .collect();
101
102    Ok(Json(summaries))
103}
104
105/// Get a specific conversation with all messages.
106#[utoipa::path(
107    get,
108    path = "/api/conversations/{id}",
109    params(
110        ("id" = String, Path, description = "Conversation ID")
111    ),
112    responses(
113        (status = 200, description = "Conversation details", body = ConversationDetails),
114        (status = 404, description = "Conversation not found"),
115        (status = 401, description = "Unauthorized")
116    ),
117    tag = "conversations",
118    security(("bearer" = []))
119)]
120pub async fn get_conversation(
121    State(state): State<AppState>,
122    AuthUser(claims): AuthUser,
123    Path(id): Path<String>,
124) -> Result<Json<ConversationDetails>> {
125    // Verify conversation belongs to user
126    let conversation = state.turso.get_conversation(&id).await?;
127
128    if conversation.user_id != claims.sub {
129        return Err(AppError::Auth(
130            "Not authorized to access this conversation".to_string(),
131        ));
132    }
133
134    let messages = state.turso.get_conversation_history(&id).await?;
135
136    let message_details: Vec<ConversationMessage> = messages
137        .into_iter()
138        .enumerate()
139        .map(|(idx, msg)| ConversationMessage {
140            id: format!("{}-{}", id, idx), // Generate a pseudo-ID from conversation_id and index
141            role: format!("{:?}", msg.role).to_lowercase(),
142            content: msg.content,
143            created_at: msg.timestamp.to_rfc3339(),
144        })
145        .collect();
146
147    Ok(Json(ConversationDetails {
148        id: conversation.id,
149        title: conversation.title,
150        messages: message_details,
151        created_at: conversation.created_at,
152        updated_at: conversation.updated_at,
153    }))
154}
155
156/// Update a conversation (e.g., change title).
157#[utoipa::path(
158    put,
159    path = "/api/conversations/{id}",
160    params(
161        ("id" = String, Path, description = "Conversation ID")
162    ),
163    request_body = UpdateConversationRequest,
164    responses(
165        (status = 200, description = "Conversation updated"),
166        (status = 404, description = "Conversation not found"),
167        (status = 401, description = "Unauthorized")
168    ),
169    tag = "conversations",
170    security(("bearer" = []))
171)]
172pub async fn update_conversation(
173    State(state): State<AppState>,
174    AuthUser(claims): AuthUser,
175    Path(id): Path<String>,
176    Json(payload): Json<UpdateConversationRequest>,
177) -> Result<Json<serde_json::Value>> {
178    // Verify conversation belongs to user
179    let conversation = state.turso.get_conversation(&id).await?;
180
181    if conversation.user_id != claims.sub {
182        return Err(AppError::Auth(
183            "Not authorized to modify this conversation".to_string(),
184        ));
185    }
186
187    state
188        .turso
189        .update_conversation_title(&id, payload.title.as_deref())
190        .await?;
191
192    Ok(Json(serde_json::json!({"success": true})))
193}
194
195/// Delete a conversation and all its messages.
196#[utoipa::path(
197    delete,
198    path = "/api/conversations/{id}",
199    params(
200        ("id" = String, Path, description = "Conversation ID")
201    ),
202    responses(
203        (status = 204, description = "Conversation deleted"),
204        (status = 404, description = "Conversation not found"),
205        (status = 401, description = "Unauthorized")
206    ),
207    tag = "conversations",
208    security(("bearer" = []))
209)]
210pub async fn delete_conversation(
211    State(state): State<AppState>,
212    AuthUser(claims): AuthUser,
213    Path(id): Path<String>,
214) -> Result<axum::http::StatusCode> {
215    // Verify conversation belongs to user
216    let conversation = state.turso.get_conversation(&id).await?;
217
218    if conversation.user_id != claims.sub {
219        return Err(AppError::Auth(
220            "Not authorized to delete this conversation".to_string(),
221        ));
222    }
223
224    state.turso.delete_conversation(&id).await?;
225
226    Ok(axum::http::StatusCode::NO_CONTENT)
227}