use std::sync::atomic::Ordering;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::response::{IntoResponse as _, Response};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::CurrentUser;
use crate::error::Error;
use crate::groups::{self, GroupMemberView, GroupRole, GroupView};
use crate::library;
use crate::rate_limit::{self, RateCategory};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CreateGroupRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct RenameGroupRequest {
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct GroupResponse {
pub group: GroupView,
}
#[derive(Debug, Serialize)]
pub struct ListGroupsResponse {
pub groups: Vec<GroupView>,
}
#[derive(Debug, Serialize)]
pub struct ListMembersResponse {
pub members: Vec<GroupMemberView>,
}
#[derive(Debug, Deserialize)]
pub struct SetRoleRequest {
pub role: String,
}
pub async fn list_mine(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<ListGroupsResponse>, Error> {
let groups = groups::list_for_user(&state.db, user.user_id).await?;
Ok(Json(ListGroupsResponse { groups }))
}
pub async fn create(
user: CurrentUser,
State(state): State<AppState>,
Json(req): Json<CreateGroupRequest>,
) -> Result<(ReqwestStatusCode, Json<GroupResponse>), Error> {
rate_limit::try_acquire(&state.db, RateCategory::GroupCreate, user.user_id).await?;
let name = library::sanitise_display_name(&req.name, "group name")?;
let group_id = groups::create_group(&state.db, &name, user.user_id).await?;
let group = groups::get_for_user(&state.db, group_id, user.user_id).await?;
Ok((ReqwestStatusCode::CREATED, Json(GroupResponse { group })))
}
pub async fn get(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
) -> Result<Json<GroupResponse>, Error> {
let group = groups::get_for_user(&state.db, group_id, user.user_id).await?;
Ok(Json(GroupResponse { group }))
}
pub async fn rename(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
Json(req): Json<RenameGroupRequest>,
) -> Result<Json<GroupResponse>, Error> {
require_owner(&state, group_id, user.user_id).await?;
let name = library::sanitise_display_name(&req.name, "group name")?;
groups::rename_group(&state.db, group_id, &name).await?;
let group = groups::get_for_user(&state.db, group_id, user.user_id).await?;
Ok(Json(GroupResponse { group }))
}
pub async fn delete(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
) -> Result<Response, Error> {
require_owner(&state, group_id, user.user_id).await?;
groups::delete_group(&state.db, group_id).await?;
state.library_cleanup_dirty.store(true, Ordering::Release);
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
pub async fn list_members(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
) -> Result<Json<ListMembersResponse>, Error> {
require_member(&state, group_id, user.user_id).await?;
let members = groups::list_members(&state.db, group_id).await?;
Ok(Json(ListMembersResponse { members }))
}
pub async fn set_member_role(
user: CurrentUser,
State(state): State<AppState>,
Path((group_id, target_user_id)): Path<(Uuid, Uuid)>,
Json(req): Json<SetRoleRequest>,
) -> Result<Response, Error> {
require_owner(&state, group_id, user.user_id).await?;
let role = GroupRole::parse(req.role.trim())?;
let current = groups::lookup_role(&state.db, group_id, target_user_id)
.await?
.ok_or_else(|| {
Error::NotFound(format!(
"user {target_user_id} is not a member of group {group_id}"
))
})?;
match (current, role) {
(GroupRole::Owner, GroupRole::Owner) | (GroupRole::Member, GroupRole::Member) => {}
(GroupRole::Member, GroupRole::Owner) => {
let promoted =
groups::try_promote_member_to_owner(&state.db, group_id, target_user_id).await?;
if !promoted {
return Err(Error::NotFound(format!(
"user {target_user_id} is not a member of group {group_id}"
)));
}
}
(GroupRole::Owner, GroupRole::Member) => {
if target_user_id != user.user_id {
return Err(Error::Forbidden(
"owners cannot demote other owners; ask them to demote themselves or leave"
.to_owned(),
));
}
let demoted = groups::try_self_demote_owner(&state.db, group_id, user.user_id).await?;
if !demoted {
return Err(Error::BadRequest(
"cannot demote the last owner; promote another member first or delete the group"
.to_owned(),
));
}
}
}
Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
}
pub async fn remove_member(
user: CurrentUser,
State(state): State<AppState>,
Path((group_id, target_user_id)): Path<(Uuid, Uuid)>,
) -> Result<Response, Error> {
require_owner(&state, group_id, user.user_id).await?;
let removed = groups::try_remove_non_owner(&state.db, group_id, target_user_id).await?;
if removed {
return Ok((ReqwestStatusCode::NO_CONTENT, "").into_response());
}
match groups::lookup_role(&state.db, group_id, target_user_id).await? {
None => Err(Error::NotFound(format!(
"user {target_user_id} is not a member of group {group_id}"
))),
Some(_) => Err(Error::Forbidden(
"owners cannot be removed by other owners; the owner must demote themselves or leave"
.to_owned(),
)),
}
}
pub async fn leave(
user: CurrentUser,
State(state): State<AppState>,
Path(group_id): Path<Uuid>,
) -> Result<Response, Error> {
let left = groups::try_leave(&state.db, group_id, user.user_id).await?;
if left {
return Ok((ReqwestStatusCode::NO_CONTENT, "").into_response());
}
match groups::lookup_role(&state.db, group_id, user.user_id).await? {
None => Err(Error::NotFound(format!(
"you are not a member of group {group_id}"
))),
Some(_) => Err(Error::BadRequest(
"you are the last owner of this group; promote another member to owner or \
delete the group instead of leaving"
.to_owned(),
)),
}
}
async fn require_owner(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
if groups::lookup_role(&state.db, group_id, user_id).await? == Some(GroupRole::Owner) {
Ok(())
} else {
Err(Error::NotFound(format!("group {group_id}")))
}
}
async fn require_member(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
if groups::lookup_role(&state.db, group_id, user_id)
.await?
.is_some()
{
Ok(())
} else {
Err(Error::NotFound(format!("group {group_id}")))
}
}