use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use std::sync::Arc; use uuid::Uuid; use crate::{ middleware::auth::AuthUser, models::{ self, CreateRoomRequest, MessagePayload, PaginationParams, Room, RoomResponse, UserPublic, }, AppState, }; /// Create a room, persist it, and add the creator as the first member. pub async fn create_room( State(state): State>, auth: AuthUser, Json(body): Json, ) -> Result, (StatusCode, String)> { let room_id = Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO rooms (id, name, model_id, created_by, ai_always_respond, system_prompt, ai_name) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(&room_id) .bind(&body.name) .bind(&body.model_id) .bind(&auth.user_id) .bind(body.ai_always_respond) .bind(&body.system_prompt) .bind(&body.ai_name) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Auto-join creator sqlx::query("INSERT INTO room_members (room_id, user_id) VALUES (?, ?)") .bind(&room_id) .bind(&auth.user_id) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(RoomResponse { id: room_id, name: body.name, model_id: body.model_id, created_by: auth.user_id.clone(), ai_always_respond: body.ai_always_respond, system_prompt: body.system_prompt, ai_name: body.ai_name, created_at: chrono::Utc::now().to_rfc3339(), members: vec![UserPublic { id: auth.user_id, email: models::public_email(&auth.email), display_name: auth.display_name, avatar_url: None, nostr_pubkey: None, }], })) } /// List all active rooms the caller belongs to, including current room members. pub async fn list_rooms( State(state): State>, auth: AuthUser, ) -> Result>, (StatusCode, String)> { let rooms = sqlx::query_as::<_, Room>( "SELECT r.* FROM rooms r JOIN room_members rm ON r.id = rm.room_id WHERE rm.user_id = ? AND r.deleted_at IS NULL ORDER BY r.created_at DESC", ) .bind(&auth.user_id) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut result = Vec::new(); for room in rooms { let members = sqlx::query_as::<_, (String, String, String, Option, Option)>( "SELECT u.id, u.email, u.display_name, u.avatar_url, u.nostr_pubkey FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?", ) .bind(&room.id) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; result.push(RoomResponse { id: room.id, name: room.name, model_id: room.model_id, created_by: room.created_by, ai_always_respond: room.ai_always_respond, system_prompt: room.system_prompt, ai_name: room.ai_name, created_at: room.created_at, members: members .into_iter() .map( |(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic { id, email: models::public_email(&email), display_name, avatar_url, nostr_pubkey, }, ) .collect(), }); } Ok(Json(result)) } /// Return details for a single room after verifying the caller is a member. pub async fn get_room( State(state): State>, auth: AuthUser, Path(room_id): Path, ) -> Result, (StatusCode, String)> { // Verify membership let is_member = sqlx::query_scalar::<_, String>( "SELECT user_id FROM room_members WHERE room_id = ? AND user_id = ?", ) .bind(&room_id) .bind(&auth.user_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if is_member.is_none() { return Err((StatusCode::FORBIDDEN, "Not a member of this room".into())); } let room = sqlx::query_as::<_, Room>("SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL") .bind(&room_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?; let members = sqlx::query_as::<_, (String, String, String, Option, Option)>( "SELECT u.id, u.email, u.display_name, u.avatar_url, u.nostr_pubkey FROM users u JOIN room_members rm ON u.id = rm.user_id WHERE rm.room_id = ?", ) .bind(&room_id) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(RoomResponse { id: room.id, name: room.name, model_id: room.model_id, created_by: room.created_by, ai_always_respond: room.ai_always_respond, system_prompt: room.system_prompt, ai_name: room.ai_name, created_at: room.created_at, members: members .into_iter() .map( |(id, email, display_name, avatar_url, nostr_pubkey)| UserPublic { id, email: models::public_email(&email), display_name, avatar_url, nostr_pubkey, }, ) .collect(), })) } /// Return paginated message history for a room the caller can access. pub async fn get_messages( State(state): State>, auth: AuthUser, Path(room_id): Path, Query(params): Query, ) -> Result>, (StatusCode, String)> { // Verify membership and room not deleted let is_member = sqlx::query_scalar::<_, String>( "SELECT rm.user_id FROM room_members rm JOIN rooms r ON r.id = rm.room_id WHERE rm.room_id = ? AND rm.user_id = ? AND r.deleted_at IS NULL", ) .bind(&room_id) .bind(&auth.user_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if is_member.is_none() { return Err((StatusCode::FORBIDDEN, "Not a member of this room".into())); } // Query messages with user email + avatar_url via LEFT JOIN let rows = if let Some(before) = ¶ms.before { sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option, Option, Option, Option, Option)>( "SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email, u.avatar_url, m.hash \ FROM messages m LEFT JOIN users u ON m.sender_id = u.id \ WHERE m.room_id = ? AND m.created_at < ? ORDER BY m.created_at DESC LIMIT ?", ) .bind(&room_id) .bind(before) .bind(params.limit) .fetch_all(&state.db) .await } else { sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option, Option, Option, Option, Option)>( "SELECT m.id, m.room_id, m.sender_id, m.sender_name, m.content, m.mentions, m.is_ai, m.created_at, m.ai_meta, m.image_url, u.email, u.avatar_url, m.hash \ FROM messages m LEFT JOIN users u ON m.sender_id = u.id \ WHERE m.room_id = ? ORDER BY m.created_at DESC LIMIT ?", ) .bind(&room_id) .bind(params.limit) .fetch_all(&state.db) .await } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // The SQL query reads newest-first for efficient pagination, but clients // render chat oldest-to-newest, so reverse the rows before serializing. let payloads: Vec = rows .into_iter() .rev() .map( |( id, room_id, sender_id, sender_name, content, mentions, is_ai, created_at, ai_meta_str, image_url, email, avatar_url, hash, )| { let ai_meta = ai_meta_str .as_deref() .and_then(|s| serde_json::from_str::(s).ok()); let avatar_hash = email .map(|e| crate::models::gravatar_hash(&e)) .unwrap_or_default(); MessagePayload { id, room_id, sender_id, sender_name, content, mentions: serde_json::from_str(&mentions).unwrap_or_default(), is_ai, created_at, ai_meta, avatar_hash, avatar_url, image_url, hash, } }, ) .collect(); Ok(Json(payloads)) } /// Resolve a stable message hash into the room that contains it. pub async fn resolve_message_hash( State(state): State>, auth: AuthUser, Path(hash): Path, ) -> Result, (StatusCode, String)> { // Find the message by hash let row = sqlx::query_as::<_, (String,)>( "SELECT m.room_id FROM messages m \ JOIN room_members rm ON rm.room_id = m.room_id AND rm.user_id = ? \ JOIN rooms r ON r.id = m.room_id AND r.deleted_at IS NULL \ WHERE m.hash = ? LIMIT 1", ) .bind(&auth.user_id) .bind(&hash) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match row { Some((room_id,)) => Ok(Json( serde_json::json!({ "room_id": room_id, "hash": hash }), )), None => Err(( StatusCode::NOT_FOUND, "Message not found or no access".into(), )), } } /// Add the caller to a room directly when they already know its ID. pub async fn join_room( State(state): State>, auth: AuthUser, Path(room_id): Path, ) -> Result { // Check room exists let room_exists = sqlx::query_scalar::<_, String>("SELECT id FROM rooms WHERE id = ? AND deleted_at IS NULL") .bind(&room_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if room_exists.is_none() { return Err((StatusCode::NOT_FOUND, "Room not found".into())); } sqlx::query("INSERT OR IGNORE INTO room_members (room_id, user_id) VALUES (?, ?)") .bind(&room_id) .bind(&auth.user_id) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::OK) } /// Soft-delete a room and broadcast the deletion event to connected members. pub async fn delete_room( State(state): State>, auth: AuthUser, Path(room_id): Path, ) -> Result { // Fetch room and verify ownership let room = sqlx::query_as::<_, Room>("SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL") .bind(&room_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?; if room.created_by != auth.user_id { return Err(( StatusCode::FORBIDDEN, "Only the room creator can delete this room".into(), )); } // Soft-delete sqlx::query("UPDATE rooms SET deleted_at = datetime('now') WHERE id = ?") .bind(&room_id) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tracing::info!("Room {} soft-deleted by {}", room_id, auth.user_id); // Broadcast to all subscribers let _ = state.tx.send(crate::models::BroadcastEvent { room_id: room_id.clone(), message: crate::models::WsServerMessage::RoomDeleted { room_id }, }); Ok(StatusCode::OK) } /// Permanently remove all messages from a room without deleting the room itself. pub async fn clear_room( State(state): State>, auth: AuthUser, Path(room_id): Path, ) -> Result { // Verify room exists and not deleted let room = sqlx::query_as::<_, Room>("SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL") .bind(&room_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Room not found".into()))?; if room.created_by != auth.user_id { return Err(( StatusCode::FORBIDDEN, "Only the room creator can clear messages".into(), )); } // Hard-delete all messages sqlx::query("DELETE FROM messages WHERE room_id = ?") .bind(&room_id) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tracing::info!("Room {} messages cleared by {}", room_id, auth.user_id); // Broadcast to all subscribers let _ = state.tx.send(crate::models::BroadcastEvent { room_id: room_id.clone(), message: crate::models::WsServerMessage::RoomCleared { room_id }, }); Ok(StatusCode::OK) }