Skip to main content

sl_map_web/routes/
groups.rs

1//! HTTP handlers for groups.
2
3use std::sync::atomic::Ordering;
4
5use axum::Json;
6use axum::extract::{Path, State};
7use axum::http::StatusCode as ReqwestStatusCode;
8use axum::response::{IntoResponse as _, Response};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::auth::CurrentUser;
13use crate::error::Error;
14use crate::groups::{self, GroupMemberView, GroupRole, GroupView};
15use crate::library;
16use crate::rate_limit::{self, RateCategory};
17use crate::state::AppState;
18
19/// Body for `POST /api/groups`.
20#[derive(Debug, Deserialize)]
21pub struct CreateGroupRequest {
22    /// the human-readable group name.
23    pub name: String,
24}
25
26/// Body for `PATCH /api/groups/{id}` (rename).
27#[derive(Debug, Deserialize)]
28pub struct RenameGroupRequest {
29    /// the new group name.
30    pub name: String,
31}
32
33/// Response carrying a single group view.
34#[derive(Debug, Serialize)]
35pub struct GroupResponse {
36    /// the requested group, including the caller's role within it.
37    pub group: GroupView,
38}
39
40/// Response carrying the list of the current user's groups.
41#[derive(Debug, Serialize)]
42pub struct ListGroupsResponse {
43    /// the groups, sorted by name.
44    pub groups: Vec<GroupView>,
45}
46
47/// Response carrying the member list of a group.
48#[derive(Debug, Serialize)]
49pub struct ListMembersResponse {
50    /// the group's members, each with their role and username.
51    pub members: Vec<GroupMemberView>,
52}
53
54/// Body for `PATCH /api/groups/{id}/members/{user_id}` (promote/demote).
55#[derive(Debug, Deserialize)]
56pub struct SetRoleRequest {
57    /// the new role to assign.
58    pub role: String,
59}
60
61/// `GET /api/groups` — list the calling user's groups.
62///
63/// # Errors
64///
65/// Returns [`Error::Database`] on lookup failure.
66pub async fn list_mine(
67    user: CurrentUser,
68    State(state): State<AppState>,
69) -> Result<Json<ListGroupsResponse>, Error> {
70    let groups = groups::list_for_user(&state.db, user.user_id).await?;
71    Ok(Json(ListGroupsResponse { groups }))
72}
73
74/// `POST /api/groups` — create a new group. The caller becomes its first
75/// owner.
76///
77/// # Errors
78///
79/// Returns [`Error::BadRequest`] for an empty name, or [`Error::Database`]
80/// on insert failure.
81pub async fn create(
82    user: CurrentUser,
83    State(state): State<AppState>,
84    Json(req): Json<CreateGroupRequest>,
85) -> Result<(ReqwestStatusCode, Json<GroupResponse>), Error> {
86    rate_limit::try_acquire(&state.db, RateCategory::GroupCreate, user.user_id).await?;
87    let name = library::sanitise_display_name(&req.name, "group name")?;
88    let group_id = groups::create_group(&state.db, &name, user.user_id).await?;
89    let group = groups::get_for_user(&state.db, group_id, user.user_id).await?;
90    Ok((ReqwestStatusCode::CREATED, Json(GroupResponse { group })))
91}
92
93/// `GET /api/groups/{id}` — get a single group.
94///
95/// # Errors
96///
97/// Returns [`Error::NotFound`] if the group does not exist or the caller is
98/// not a member.
99pub async fn get(
100    user: CurrentUser,
101    State(state): State<AppState>,
102    Path(group_id): Path<Uuid>,
103) -> Result<Json<GroupResponse>, Error> {
104    let group = groups::get_for_user(&state.db, group_id, user.user_id).await?;
105    Ok(Json(GroupResponse { group }))
106}
107
108/// `PATCH /api/groups/{id}` — rename the group. Owners only.
109///
110/// # Errors
111///
112/// Returns [`Error::NotFound`] if the group does not exist or the caller
113/// is not an owner.
114pub async fn rename(
115    user: CurrentUser,
116    State(state): State<AppState>,
117    Path(group_id): Path<Uuid>,
118    Json(req): Json<RenameGroupRequest>,
119) -> Result<Json<GroupResponse>, Error> {
120    require_owner(&state, group_id, user.user_id).await?;
121    let name = library::sanitise_display_name(&req.name, "group name")?;
122    groups::rename_group(&state.db, group_id, &name).await?;
123    let group = groups::get_for_user(&state.db, group_id, user.user_id).await?;
124    Ok(Json(GroupResponse { group }))
125}
126
127/// `DELETE /api/groups/{id}` — delete the group. Owners only. Cascades nuke
128/// memberships, pending invitations, group-owned notecards, and group-owned
129/// renders; the orphan-sweeper dirty flag is raised so render files on disk
130/// are reaped on the next tick.
131///
132/// # Errors
133///
134/// Returns [`Error::NotFound`] if the group does not exist or the caller
135/// is not an owner.
136pub async fn delete(
137    user: CurrentUser,
138    State(state): State<AppState>,
139    Path(group_id): Path<Uuid>,
140) -> Result<Response, Error> {
141    require_owner(&state, group_id, user.user_id).await?;
142    groups::delete_group(&state.db, group_id).await?;
143    state.library_cleanup_dirty.store(true, Ordering::Release);
144    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
145}
146
147/// `GET /api/groups/{id}/members` — list members. Any group member.
148///
149/// # Errors
150///
151/// Returns [`Error::NotFound`] if the group does not exist or the caller is
152/// not a member.
153pub async fn list_members(
154    user: CurrentUser,
155    State(state): State<AppState>,
156    Path(group_id): Path<Uuid>,
157) -> Result<Json<ListMembersResponse>, Error> {
158    require_member(&state, group_id, user.user_id).await?;
159    let members = groups::list_members(&state.db, group_id).await?;
160    Ok(Json(ListMembersResponse { members }))
161}
162
163/// `PATCH /api/groups/{id}/members/{user_id}` — change a member's role.
164/// Owners only. An owner may promote any member to owner, but the only
165/// owner→member transition allowed is self-demotion, and only when at
166/// least one other owner remains.
167///
168/// # Errors
169///
170/// Returns [`Error::NotFound`] if the group does not exist, the caller is
171/// not an owner, or the target is not a member; [`Error::Forbidden`] if
172/// the caller attempts to demote a different owner; [`Error::BadRequest`]
173/// for a malformed role or a self-demotion that would orphan the group.
174pub async fn set_member_role(
175    user: CurrentUser,
176    State(state): State<AppState>,
177    Path((group_id, target_user_id)): Path<(Uuid, Uuid)>,
178    Json(req): Json<SetRoleRequest>,
179) -> Result<Response, Error> {
180    require_owner(&state, group_id, user.user_id).await?;
181    let role = GroupRole::parse(req.role.trim())?;
182    let current = groups::lookup_role(&state.db, group_id, target_user_id)
183        .await?
184        .ok_or_else(|| {
185            Error::NotFound(format!(
186                "user {target_user_id} is not a member of group {group_id}"
187            ))
188        })?;
189    match (current, role) {
190        (GroupRole::Owner, GroupRole::Owner) | (GroupRole::Member, GroupRole::Member) => {}
191        (GroupRole::Member, GroupRole::Owner) => {
192            let promoted =
193                groups::try_promote_member_to_owner(&state.db, group_id, target_user_id).await?;
194            if !promoted {
195                return Err(Error::NotFound(format!(
196                    "user {target_user_id} is not a member of group {group_id}"
197                )));
198            }
199        }
200        (GroupRole::Owner, GroupRole::Member) => {
201            if target_user_id != user.user_id {
202                return Err(Error::Forbidden(
203                    "owners cannot demote other owners; ask them to demote themselves or leave"
204                        .to_owned(),
205                ));
206            }
207            let demoted = groups::try_self_demote_owner(&state.db, group_id, user.user_id).await?;
208            if !demoted {
209                return Err(Error::BadRequest(
210                    "cannot demote the last owner; promote another member first or delete the group"
211                        .to_owned(),
212                ));
213            }
214        }
215    }
216    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
217}
218
219/// `DELETE /api/groups/{id}/members/{user_id}` — remove a non-owner
220/// member from the group. Owners only. Owners can never be removed by
221/// another owner; an owner who wants to step down must demote themselves
222/// or call `/leave`.
223///
224/// # Errors
225///
226/// Returns [`Error::NotFound`] if the group does not exist, the caller is
227/// not an owner, or the target is not a member; [`Error::Forbidden`] if
228/// the target is an owner.
229pub async fn remove_member(
230    user: CurrentUser,
231    State(state): State<AppState>,
232    Path((group_id, target_user_id)): Path<(Uuid, Uuid)>,
233) -> Result<Response, Error> {
234    require_owner(&state, group_id, user.user_id).await?;
235    let removed = groups::try_remove_non_owner(&state.db, group_id, target_user_id).await?;
236    if removed {
237        return Ok((ReqwestStatusCode::NO_CONTENT, "").into_response());
238    }
239    match groups::lookup_role(&state.db, group_id, target_user_id).await? {
240        None => Err(Error::NotFound(format!(
241            "user {target_user_id} is not a member of group {group_id}"
242        ))),
243        Some(_) => Err(Error::Forbidden(
244            "owners cannot be removed by other owners; the owner must demote themselves or leave"
245                .to_owned(),
246        )),
247    }
248}
249
250/// `POST /api/groups/{id}/leave` — the calling user leaves the group. The
251/// last owner cannot leave; they must delete the group instead.
252///
253/// # Errors
254///
255/// Returns [`Error::BadRequest`] if the caller is the sole owner, or
256/// [`Error::NotFound`] if the caller is not a member.
257pub async fn leave(
258    user: CurrentUser,
259    State(state): State<AppState>,
260    Path(group_id): Path<Uuid>,
261) -> Result<Response, Error> {
262    let left = groups::try_leave(&state.db, group_id, user.user_id).await?;
263    if left {
264        return Ok((ReqwestStatusCode::NO_CONTENT, "").into_response());
265    }
266    match groups::lookup_role(&state.db, group_id, user.user_id).await? {
267        None => Err(Error::NotFound(format!(
268            "you are not a member of group {group_id}"
269        ))),
270        Some(_) => Err(Error::BadRequest(
271            "you are the last owner of this group; promote another member to owner or \
272             delete the group instead of leaving"
273                .to_owned(),
274        )),
275    }
276}
277
278/// Require that the calling user is an owner of the given group. Returns
279/// [`Error::NotFound`] for both "group does not exist" and "caller is not
280/// an owner" so non-owners cannot probe for group existence.
281async fn require_owner(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
282    if groups::lookup_role(&state.db, group_id, user_id).await? == Some(GroupRole::Owner) {
283        Ok(())
284    } else {
285        Err(Error::NotFound(format!("group {group_id}")))
286    }
287}
288
289/// Require that the calling user is at least a member of the given group.
290/// Returns [`Error::NotFound`] for both "group does not exist" and "caller
291/// is not a member" so non-members cannot probe for group existence.
292async fn require_member(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
293    if groups::lookup_role(&state.db, group_id, user_id)
294        .await?
295        .is_some()
296    {
297        Ok(())
298    } else {
299        Err(Error::NotFound(format!("group {group_id}")))
300    }
301}