405 lines
14 KiB
Rust

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<Arc<AppState>>,
auth: AuthUser,
Json(body): Json<CreateRoomRequest>,
) -> Result<Json<RoomResponse>, (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<Arc<AppState>>,
auth: AuthUser,
) -> Result<Json<Vec<RoomResponse>>, (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<String>, Option<String>)>(
"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<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<String>,
) -> Result<Json<RoomResponse>, (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<String>, Option<String>)>(
"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<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<String>,
Query(params): Query<PaginationParams>,
) -> Result<Json<Vec<MessagePayload>>, (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) = &params.before {
sqlx::query_as::<_, (String, String, String, String, String, String, bool, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"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<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(
"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<MessagePayload> = 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::<crate::models::AiMeta>(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<Arc<AppState>>,
auth: AuthUser,
Path(hash): Path<String>,
) -> Result<Json<serde_json::Value>, (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<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
// 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<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
// 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<Arc<AppState>>,
auth: AuthUser,
Path(room_id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
// 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)
}