use axum::{ Extension, Json, Router, extract::Path, http::{HeaderMap, StatusCode}, routing::{delete, get, post}, }; use sqlx::{PgPool, Pool, Postgres}; use uuid::Uuid; use crate::{MAX_ROOM_NAME_LENGTH, auth::verify_jwt, db::room_id_from_uuid, errors::APIError}; use crate::{ db::{id_from_username, room_name_from_uuid, user_id_from_uuid, username_from_id}, errors::ErrorResponse, routes::users::UserProfile, }; #[derive(sqlx::FromRow, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] pub struct Room { pub uuid: Uuid, pub name: String, pub global: bool, pub owner_name: String, pub owner_uuid: Uuid, pub unread_count: i64, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct NewRoomPayload { pub name: String, pub global: bool, } #[derive(sqlx::FromRow, serde::Serialize, utoipa::ToSchema)] pub struct RoomInvite { pub room_uuid: Uuid, pub room_name: String, pub sender_uuid: Uuid, pub sender_username: String, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct SendRoomInvitePayload { pub room_uuid: Uuid, pub receiver_username: String, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct AcceptRoomInvitePayload { pub room_uuid: Uuid, pub sender_uuid: Uuid, } #[derive(serde::Deserialize, utoipa::ToSchema)] pub struct TransferOwnershipPayload { pub room_uuid: Uuid, pub new_owner_uuid: Uuid, } pub fn routes() -> Router { Router::new() .route("/rooms", get(list_rooms)) .route("/rooms", post(create_room)) .route("/rooms/{room_uuid}", get(get_room)) .route("/rooms/{room_uuid}/members", get(list_members)) .route("/rooms/invites", get(list_invites)) .route("/rooms/invite", post(send_invite)) .route("/rooms/join", post(accept_request)) .route("/rooms/decline", post(decline_request)) .route("/rooms/{room_uuid}/leave", post(leave_room)) .route("/rooms/{room_uuid}/delete", delete(delete_room)) .route("/rooms/transfer-ownership", post(transfer_ownership)) } pub async fn is_member(user_id: i32, room_id: i32, db: &Pool) -> bool { sqlx::query_scalar( r#" SELECT r.global OR EXISTS ( SELECT 1 FROM membership_ m WHERE m.user_id = $1 AND m.room = r.id ) FROM room_ r WHERE r.id = $2 "#, ) .bind(user_id) .bind(room_id) .fetch_one(db) .await .unwrap_or(false) } #[utoipa::path( get, path = "/rooms", responses( (status = 200, description = "Successfully listed rooms", body = [Room]), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 404, description = "User not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn list_rooms( headers: HeaderMap, Extension(db): Extension, ) -> Result>, APIError> { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; let rooms = sqlx::query_as::<_, Room>( r#" SELECT r.uuid, u.username AS owner_name, u.uuid AS owner_uuid, r.name, r.global, ( SELECT COUNT(*) FROM message_ m WHERE m.room = r.id AND m.sent_at > mem.last_read_at AND m.sender != $1 ) AS unread_count FROM room_ r JOIN user_ u ON u.id = r.owner LEFT JOIN membership_ mem ON mem.room = r.id AND mem.user_id = $1 WHERE r.global OR mem.user_id IS NOT NULL "#, ) .bind(user_id) .fetch_all(&db) .await .unwrap_or_else(|e| { tracing::error!("Failed listing rooms: {e}"); Vec::new() }); Ok(Json(rooms)) } #[utoipa::path( post, path = "/rooms", request_body = NewRoomPayload, responses( (status = 201, description = "Room successfully created", body = Room), (status = 400, description = "Room name must be 0-35 characters long", body = ErrorResponse), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn create_room( Extension(db): Extension, headers: HeaderMap, Json(payload): Json, ) -> Result<(StatusCode, Json), APIError> { let claims = verify_jwt(headers)?; { let room_name_length = payload.name.len(); if room_name_length > MAX_ROOM_NAME_LENGTH || room_name_length < 1 { return Err(APIError::RoomNameLength); } } let user_id = user_id_from_uuid(&db, claims.sub).await?; let room_uuid = uuid::Uuid::now_v7(); sqlx::query( "INSERT INTO room_ (uuid, owner, name, global) VALUES ($1, $2, $3, $4)", ) .bind(room_uuid) .bind(user_id) .bind(&payload.name) // .bind(&payload.global) .bind(false) // We do not allow global rooms .execute(&db) .await?; let room_id = room_id_from_uuid(&db, room_uuid).await?; // We do this even for the owner sqlx::query("INSERT INTO membership_ (user_id, room) VALUES ($1, $2)") .bind(user_id) .bind(room_id) .execute(&db) .await?; let owner_name = username_from_id(&db, user_id).await?; Ok(( StatusCode::CREATED, Json(Room { uuid: room_uuid, owner_name, owner_uuid: claims.sub, name: payload.name, global: payload.global, unread_count: 0, }), )) } #[utoipa::path( get, path = "/rooms/{room_uuid}", params( ("room_uuid" = Uuid, Path, description = "UUID of the room to retrieve") ), responses( (status = 200, description = "Room successfully retrieved", body = Room), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 403, description = "You are not a member of this room", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn get_room( Path(room_uuid): Path, headers: HeaderMap, Extension(db): Extension, ) -> Result, APIError> { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; #[derive(sqlx::FromRow)] struct RoomRow { uuid: Uuid, owner_name: String, owner_uuid: Uuid, name: String, global: bool, unread_count: Option, is_member: Option, } let row: RoomRow = sqlx::query_as( r#" SELECT r.uuid, u.username AS owner_name, u.uuid AS owner_uuid, r.name, r.global, ( SELECT COUNT(*) FROM message_ m WHERE m.room = r.id AND m.sent_at > mem.last_read_at AND m.sender != $2 ) AS unread_count, (r.global OR mem.user_id IS NOT NULL) AS is_member FROM room_ r JOIN user_ u ON u.id = r.owner LEFT JOIN membership_ mem ON mem.room = r.id AND mem.user_id = $2 WHERE r.uuid = $1 "#, ) .bind(room_uuid) .bind(user_id) .fetch_one(&db) .await .map_err(|_| APIError::RoomNotFound)?; if !row.is_member.unwrap_or(false) { return Err(APIError::NotAMember); } Ok(Json(Room { uuid: row.uuid, owner_name: row.owner_name, owner_uuid: row.owner_uuid, name: row.name, global: row.global, unread_count: row.unread_count.unwrap_or(0), })) } #[utoipa::path( get, path = "/rooms/invites", responses( (status = 200, description = "List of room invites successfully retrieved", body = [RoomInvite]), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 404, description = "User not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn list_invites( headers: HeaderMap, Extension(db): Extension, ) -> Result>, APIError> { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; let requests = sqlx::query_as::<_, RoomInvite>( r#" SELECT r.uuid AS room_uuid, r.name AS room_name, u.uuid AS sender_uuid, u.username AS sender_username FROM room_invite_ AS i JOIN user_ u ON u.id = i.sender JOIN room_ r ON r.id = i.room WHERE i.receiver = $1 "#, ) .bind(user_id) .fetch_all(&db) .await?; Ok(Json(requests)) } #[utoipa::path( post, path = "/rooms/invite", request_body = SendRoomInvitePayload, responses( (status = 201, description = "Room invite successfully sent", body = RoomInvite), (status = 400, description = "Cannot invite yourself", body = ErrorResponse), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 409, description = "User is already a member or Invite already sent", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn send_invite( headers: HeaderMap, Extension(db): Extension, Json(payload): Json, ) -> Result<(StatusCode, Json), APIError> { let claims = verify_jwt(headers)?; let sender_id = user_id_from_uuid(&db, claims.sub).await?; let receiver_id = id_from_username(&db, payload.receiver_username).await?; let room_id = room_id_from_uuid(&db, payload.room_uuid).await?; if sender_id == receiver_id { return Err(APIError::InviteSelf); } let is_already_member = sqlx::query_scalar::<_, bool>( r#" SELECT EXISTS ( SELECT 1 FROM membership_ WHERE user_id = $1 AND room = $2 ) "#, ) .bind(receiver_id) .bind(room_id) .fetch_one(&db) .await?; if is_already_member { return Err(APIError::AlreadyMember); } sqlx::query("INSERT INTO room_invite_ (sender, receiver, room) VALUES ($1, $2, $3)") .bind(sender_id) .bind(receiver_id) .bind(room_id) .execute(&db) .await .map_err(|_| APIError::AlreadyInvited)?; let room_name = room_name_from_uuid(&db, payload.room_uuid).await?; Ok(( StatusCode::CREATED, Json(RoomInvite { room_uuid: payload.room_uuid, room_name, sender_uuid: claims.sub, sender_username: username_from_id(&db, receiver_id).await?, }), )) } #[utoipa::path( post, path = "/rooms/join", request_body = AcceptRoomInvitePayload, responses( (status = 201, description = "Room invite successfully accepted", body = Room), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 404, description = "User not found, Room not found, or Invite not found", body = ErrorResponse), (status = 409, description = "User is already a member", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn accept_request( headers: HeaderMap, Extension(db): Extension, Json(payload): Json, ) -> Result<(StatusCode, Json), APIError> { let claims = verify_jwt(headers)?; let receiver_id = user_id_from_uuid(&db, claims.sub).await?; let sender_id = user_id_from_uuid(&db, payload.sender_uuid).await?; let mut tx = db.begin().await?; let rows = sqlx::query( r#" DELETE FROM room_invite_ WHERE sender = $1 AND receiver = $2 "#, ) .bind(sender_id) .bind(receiver_id) .execute(&mut *tx) .await? .rows_affected(); if rows == 0 { return Err(APIError::InviteNotFound); } let room_id = room_id_from_uuid(&db, payload.room_uuid).await?; sqlx::query("INSERT INTO membership_ (user_id, room) VALUES ($1, $2)") .bind(receiver_id) .bind(room_id) .execute(&mut *tx) .await .map_err(|_| APIError::AlreadyMember)?; let room: Room = sqlx::query_as( r#" SELECT r.uuid, u.username AS owner_name, u.uuid AS owner_uuid, r.name, r.global, 0::bigint AS unread_count FROM room_ r JOIN user_ u ON u.id = r.owner WHERE r.id = $1 AND r.owner = $2 "#, ) .bind(room_id) .bind(sender_id) .fetch_one(&db) .await .map_err(|_| APIError::RoomNotFound)?; tx.commit().await?; Ok(( StatusCode::CREATED, Json(Room { uuid: payload.room_uuid, owner_name: room.owner_name, owner_uuid: room.owner_uuid, name: room.name, global: room.global, unread_count: room.unread_count, }), )) } #[utoipa::path( post, path = "/rooms/decline", request_body = AcceptRoomInvitePayload, responses( (status = 201, description = "Room invite successfully declined"), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 404, description = "User not found or Invite not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn decline_request( headers: HeaderMap, Extension(db): Extension, Json(payload): Json, ) -> Result { let claims = verify_jwt(headers)?; let receiver_id = user_id_from_uuid(&db, claims.sub).await?; let sender_id = user_id_from_uuid(&db, payload.sender_uuid).await?; let rows = sqlx::query( r#" DELETE FROM room_invite_ WHERE sender = $1 AND receiver = $2 "#, ) .bind(sender_id) .bind(receiver_id) .execute(&db) .await? .rows_affected(); if rows == 0 { return Err(APIError::InviteNotFound); } Ok(StatusCode::CREATED) } #[utoipa::path( post, path = "/rooms/{room_uuid}/leave", params( ("room_uuid" = Uuid, Path, description = "UUID of the room to leave") ), responses( (status = 200, description = "Successfully left the room"), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 403, description = "You are not a member of this room or Owner cannot leave the room without transferring ownership", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn leave_room( headers: HeaderMap, Path(room_uuid): Path, Extension(db): Extension, ) -> Result { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; let room_id = room_id_from_uuid(&db, room_uuid).await?; if !is_member(user_id, room_id, &db).await { return Err(APIError::NotAMember); } let mut tx = db.begin().await?; let owner: i32 = sqlx::query_scalar(r#"SELECT owner FROM room_ WHERE id = $1"#) .bind(room_id) .fetch_one(&mut *tx) .await?; if owner == user_id { return Err(APIError::RoomOwnerCannotLeave); } sqlx::query( r#" DELETE FROM membership_ WHERE user_id = $1 AND room = $2 "#, ) .bind(user_id) .bind(room_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(StatusCode::OK) } #[utoipa::path( post, path = "/rooms/transfer-ownership", request_body = TransferOwnershipPayload, responses( (status = 200, description = "Room ownership successfully transferred"), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 403, description = "You are not a member of this room", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn transfer_ownership( headers: HeaderMap, Extension(db): Extension, Json(payload): Json, ) -> Result { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; let room_id = room_id_from_uuid(&db, payload.room_uuid).await?; if !is_member(user_id, room_id, &db).await { return Err(APIError::NotAMember); } let owner: i32 = sqlx::query_scalar(r#"SELECT owner FROM room_ WHERE id = $1"#) .bind(room_id) .fetch_one(&db) .await?; if owner != user_id { return Err(APIError::NotAMember); } let exists: bool = sqlx::query_scalar(r#"SELECT EXISTS (SELECT 1 FROM user_ WHERE uuid = $1)"#) .bind(payload.new_owner_uuid) .fetch_one(&db) .await?; if !exists { tracing::debug!( "User {user_id} tried to leave room {room_id} without transfering ownership" ); return Err(APIError::UserNotFound); } let new_owner_id = user_id_from_uuid(&db, payload.new_owner_uuid).await?; sqlx::query("UPDATE room_ SET owner = $1 WHERE id = $2") .bind(new_owner_id) .bind(room_id) .execute(&db) .await?; Ok(StatusCode::OK) } #[utoipa::path( get, path = "/rooms/{room_uuid}/members", params( ("room_uuid" = Uuid, Path, description = "UUID of the room to list members of") ), responses( (status = 200, description = "Room members successfully retrieved", body = [UserProfile]), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 403, description = "You are not a member of this room or Cannot list members for global rooms", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn list_members( headers: HeaderMap, Path(room_uuid): Path, Extension(db): Extension, ) -> Result<(StatusCode, Json>), APIError> { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; let room_id = room_id_from_uuid(&db, room_uuid).await?; if !is_member(user_id, room_id, &db).await { return Err(APIError::NotAMember); } let is_global: bool = sqlx::query_scalar("SELECT global FROM room_ WHERE id = $1") .bind(room_id) .fetch_one(&db) .await?; if is_global { return Err(APIError::GlobalRoomMemberError); } let members = sqlx::query_as::<_, UserProfile>( r#" SELECT u.uuid, u.username FROM user_ u JOIN membership_ m ON m.room = $1 AND m.user_id = u.id "#, ) .bind(room_id) .fetch_all(&db) .await?; Ok((StatusCode::OK, Json(members))) } #[utoipa::path( delete, path = "/rooms/{room_uuid}/delete", params( ("room_uuid" = Uuid, Path, description = "UUID of the room to delete") ), responses( (status = 200, description = "Room successfully deleted"), (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), (status = 403, description = "You are not a member of this room", body = ErrorResponse), (status = 404, description = "User not found or Room not found", body = ErrorResponse), (status = 500, description = "Database error", body = ErrorResponse) ), security( ("bearer_auth" = []) ) )] async fn delete_room( headers: HeaderMap, Path(room_uuid): Path, Extension(db): Extension, ) -> Result { let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; let room_id = room_id_from_uuid(&db, room_uuid).await?; if !is_member(user_id, room_id, &db).await { return Err(APIError::NotAMember); } let owner: i32 = sqlx::query_scalar(r#"SELECT owner FROM room_ WHERE id = $1"#) .bind(room_id) .fetch_one(&db) .await?; if owner != user_id { return Err(APIError::NotAMember); } let mut tx = db.begin().await?; sqlx::query(r#"DELETE FROM message_ WHERE room = $1"#) .bind(room_id) .execute(&mut *tx) .await?; sqlx::query(r#"DELETE FROM membership_ WHERE room = $1"#) .bind(room_id) .execute(&mut *tx) .await?; sqlx::query(r#"DELETE FROM room_ WHERE id = $1"#) .bind(room_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(StatusCode::OK) }