405 lines
14 KiB
Rust
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) = ¶ms.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)
|
|
}
|