Skip to main content

sl_map_web/routes/
invitations.rs

1//! HTTP handlers for group invitations.
2
3use axum::Json;
4use axum::extract::{Path, State};
5use axum::http::StatusCode as ReqwestStatusCode;
6use axum::response::{IntoResponse as _, Response};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::auth::{self, CurrentUser, uuid_from_bytes};
12use crate::error::Error;
13use crate::groups::{self, GroupRole, InvitationStatus};
14use crate::rate_limit::{self, RateCategory};
15use crate::state::AppState;
16
17/// Body for `POST /api/groups/{id}/invitations`.
18#[derive(Debug, Deserialize)]
19pub struct CreateInvitationRequest {
20    /// Avatar UUID, username, or legacy name to invite.
21    pub identifier: String,
22    /// Role the invitee will be granted on acceptance. Defaults to
23    /// `"member"`.
24    #[serde(default = "default_target_role")]
25    pub target_role: String,
26}
27
28/// Default target role when the field is omitted.
29fn default_target_role() -> String {
30    "member".to_owned()
31}
32
33/// View of one invitation as returned to either the inviter (the group's
34/// owner list) or the invitee (their pending list).
35#[derive(Debug, Clone, Serialize)]
36pub struct InvitationView {
37    /// the invitation id.
38    pub invitation_id: Uuid,
39    /// the target group.
40    pub group_id: Uuid,
41    /// the group's display name (for the invitee's UI).
42    pub group_name: String,
43    /// the inviter's user id.
44    pub inviter_id: Uuid,
45    /// the inviter's username.
46    pub inviter_username: String,
47    /// the inviter's legacy name.
48    pub inviter_legacy_name: String,
49    /// the invitee's user id.
50    pub invitee_id: Uuid,
51    /// the invitee's username.
52    pub invitee_username: String,
53    /// the invitee's legacy name.
54    pub invitee_legacy_name: String,
55    /// the role the invitee will receive on accept.
56    pub target_role: GroupRole,
57    /// the current invitation status.
58    pub status: InvitationStatus,
59    /// when the invitation was created.
60    pub created_at: DateTime<Utc>,
61    /// when the invitation reached a terminal status, if it has.
62    pub responded_at: Option<DateTime<Utc>>,
63}
64
65/// Response carrying a list of invitations.
66#[derive(Debug, Serialize)]
67pub struct ListInvitationsResponse {
68    /// the invitations the caller can see, newest first.
69    pub invitations: Vec<InvitationView>,
70}
71
72/// Response carrying a single invitation.
73#[derive(Debug, Serialize)]
74pub struct InvitationResponse {
75    /// the invitation, including its current status and timestamps.
76    pub invitation: InvitationView,
77}
78
79/// `POST /api/groups/{id}/invitations` — create an invitation. Owners only.
80///
81/// # Errors
82///
83/// Returns [`Error::NotFound`] if the group does not exist or the caller
84/// is not an owner; [`Error::BadRequest`] for an unknown identifier (same
85/// message as login to avoid user enumeration), an invalid target role, an
86/// attempt to invite the caller themselves, or if a pending invitation for
87/// the same target already exists.
88pub async fn create(
89    user: CurrentUser,
90    State(state): State<AppState>,
91    Path(group_id): Path<Uuid>,
92    Json(req): Json<CreateInvitationRequest>,
93) -> Result<(ReqwestStatusCode, Json<InvitationResponse>), Error> {
94    rate_limit::try_acquire(&state.db, RateCategory::InvitationCreate, user.user_id).await?;
95    require_owner(&state, group_id, user.user_id).await?;
96    let identifier = req.identifier.trim();
97    if identifier.is_empty() {
98        return Err(Error::BadRequest("identifier is required".to_owned()));
99    }
100    let target_role = GroupRole::parse(req.target_role.trim())?;
101
102    let invitee_row = auth::lookup_user_by_identifier(&state.db, identifier)
103        .await?
104        .ok_or_else(|| {
105            // identical to the login flow's "invalid identifier" to avoid
106            // letting an outsider enumerate the user table via this endpoint.
107            Error::BadRequest("no user matches that identifier".to_owned())
108        })?;
109    let (invitee_bytes, invitee_legacy, invitee_username, _password_hash) = invitee_row;
110    let invitee_id = uuid_from_bytes(&invitee_bytes).ok_or_else(|| {
111        tracing::error!("invitee user_id blob was the wrong length");
112        Error::Database
113    })?;
114
115    if invitee_id == user.user_id {
116        return Err(Error::BadRequest(
117            "cannot invite yourself to a group you are already in".to_owned(),
118        ));
119    }
120    if let Some(existing_role) = groups::lookup_role(&state.db, group_id, invitee_id).await? {
121        return Err(Error::BadRequest(format!(
122            "user is already a {} of this group",
123            existing_role.as_str()
124        )));
125    }
126
127    let invitation_id = Uuid::new_v4();
128    let now = Utc::now();
129    let insert = sqlx::query(
130        "INSERT INTO group_invitations \
131            (invitation_id, group_id, invitee_id, inviter_id, target_role, status, created_at) \
132         VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)",
133    )
134    .bind(invitation_id.as_bytes().to_vec())
135    .bind(group_id.as_bytes().to_vec())
136    .bind(invitee_id.as_bytes().to_vec())
137    .bind(user.user_id.as_bytes().to_vec())
138    .bind(target_role.as_str())
139    .bind(now)
140    .execute(&state.db)
141    .await;
142    if let Err(err) = insert {
143        // unique partial index on (group_id, invitee_id) WHERE status='pending'
144        if let sqlx::Error::Database(db_err) = &err
145            && db_err.is_unique_violation()
146        {
147            return Err(Error::BadRequest(
148                "there is already a pending invitation for this user".to_owned(),
149            ));
150        }
151        tracing::error!("invitation insert failed: {err}");
152        return Err(Error::Database);
153    }
154
155    let inviter_username = user.username.clone();
156    let inviter_legacy_name = user.legacy_name.clone();
157    let group_name = group_name(&state, group_id).await?;
158    let view = InvitationView {
159        invitation_id,
160        group_id,
161        group_name,
162        inviter_id: user.user_id,
163        inviter_username,
164        inviter_legacy_name,
165        invitee_id,
166        invitee_username,
167        invitee_legacy_name: invitee_legacy,
168        target_role,
169        status: InvitationStatus::Pending,
170        created_at: now,
171        responded_at: None,
172    };
173    Ok((
174        ReqwestStatusCode::CREATED,
175        Json(InvitationResponse { invitation: view }),
176    ))
177}
178
179/// `GET /api/groups/{id}/invitations` — list this group's invitations.
180/// Owners only.
181///
182/// # Errors
183///
184/// Returns [`Error::NotFound`] if the group does not exist or the caller
185/// is not an owner.
186pub async fn list_for_group(
187    user: CurrentUser,
188    State(state): State<AppState>,
189    Path(group_id): Path<Uuid>,
190) -> Result<Json<ListInvitationsResponse>, Error> {
191    require_owner(&state, group_id, user.user_id).await?;
192    let invitations = fetch_invitations(&state, InvitationsFilter::Group(group_id)).await?;
193    Ok(Json(ListInvitationsResponse { invitations }))
194}
195
196/// `GET /api/invitations` — list pending invitations addressed to the
197/// calling user.
198///
199/// # Errors
200///
201/// Returns [`Error::Database`] on lookup failure.
202pub async fn list_mine(
203    user: CurrentUser,
204    State(state): State<AppState>,
205) -> Result<Json<ListInvitationsResponse>, Error> {
206    let invitations =
207        fetch_invitations(&state, InvitationsFilter::PendingForInvitee(user.user_id)).await?;
208    Ok(Json(ListInvitationsResponse { invitations }))
209}
210
211/// `POST /api/invitations/{id}/accept` — accept an invitation. Only the
212/// invitee may accept. An accept never downgrades an existing membership:
213/// if the invitee is already a member at a higher role, the role is left
214/// unchanged but the invitation is still marked accepted.
215///
216/// # Errors
217///
218/// Returns [`Error::NotFound`] if the invitation does not exist or is not
219/// addressed to the caller, or [`Error::BadRequest`] if the invitation is
220/// no longer pending (e.g. concurrently accepted or rejected from another
221/// session).
222pub async fn accept(
223    user: CurrentUser,
224    State(state): State<AppState>,
225    Path(invitation_id): Path<Uuid>,
226) -> Result<Response, Error> {
227    let row = fetch_pending_for_invitee(&state, invitation_id, user.user_id).await?;
228    let (group_id_bytes, target_role) = row;
229    let now = Utc::now();
230    let mut tx = state.db.begin().await.map_err(|err| {
231        tracing::error!("begin accept tx failed: {err}");
232        Error::Database
233    })?;
234    sqlx::query(
235        "INSERT INTO group_memberships (group_id, user_id, role, created_at) \
236         VALUES (?1, ?2, ?3, ?4) \
237         ON CONFLICT(group_id, user_id) DO UPDATE \
238            SET role = excluded.role \
239            WHERE excluded.role = 'owner' AND group_memberships.role = 'member'",
240    )
241    .bind(&group_id_bytes)
242    .bind(user.user_id.as_bytes().to_vec())
243    .bind(target_role.as_str())
244    .bind(now)
245    .execute(&mut *tx)
246    .await
247    .map_err(|err| {
248        tracing::error!("insert membership on accept failed: {err}");
249        Error::Database
250    })?;
251    let result = sqlx::query(
252        "UPDATE group_invitations SET status = 'accepted', responded_at = ?1 \
253         WHERE invitation_id = ?2 AND status = 'pending'",
254    )
255    .bind(now)
256    .bind(invitation_id.as_bytes().to_vec())
257    .execute(&mut *tx)
258    .await
259    .map_err(|err| {
260        tracing::error!("update invitation on accept failed: {err}");
261        Error::Database
262    })?;
263    if result.rows_affected() != 1 {
264        return Err(Error::BadRequest(
265            "invitation is no longer pending".to_owned(),
266        ));
267    }
268    tx.commit().await.map_err(|err| {
269        tracing::error!("commit accept tx failed: {err}");
270        Error::Database
271    })?;
272    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
273}
274
275/// `POST /api/invitations/{id}/reject` — reject an invitation. Only the
276/// invitee may reject.
277///
278/// # Errors
279///
280/// Returns [`Error::NotFound`] if the invitation does not exist or is not
281/// addressed to the caller, or [`Error::BadRequest`] if the invitation is
282/// no longer pending.
283pub async fn reject(
284    user: CurrentUser,
285    State(state): State<AppState>,
286    Path(invitation_id): Path<Uuid>,
287) -> Result<Response, Error> {
288    let _row = fetch_pending_for_invitee(&state, invitation_id, user.user_id).await?;
289    let now = Utc::now();
290    let result = sqlx::query(
291        "UPDATE group_invitations SET status = 'rejected', responded_at = ?1 \
292         WHERE invitation_id = ?2 AND status = 'pending'",
293    )
294    .bind(now)
295    .bind(invitation_id.as_bytes().to_vec())
296    .execute(&state.db)
297    .await
298    .map_err(|err| {
299        tracing::error!("update invitation on reject failed: {err}");
300        Error::Database
301    })?;
302    if result.rows_affected() != 1 {
303        return Err(Error::BadRequest(
304            "invitation is no longer pending".to_owned(),
305        ));
306    }
307    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
308}
309
310/// Fetch the pending invitation row for the calling invitee. Returns
311/// `(group_id_bytes, target_role)`. Returns [`Error::NotFound`] for both
312/// "no such invitation" and "this invitation is addressed to someone else"
313/// so a caller cannot probe whether an invitation id belongs to another
314/// user. [`Error::BadRequest`] still fires if the invitation exists, is
315/// addressed to the caller, but is no longer pending.
316async fn fetch_pending_for_invitee(
317    state: &AppState,
318    invitation_id: Uuid,
319    invitee_id: Uuid,
320) -> Result<(Vec<u8>, GroupRole), Error> {
321    let row: Option<(Vec<u8>, Vec<u8>, String, String)> = sqlx::query_as(
322        "SELECT group_id, invitee_id, target_role, status FROM group_invitations \
323         WHERE invitation_id = ?1",
324    )
325    .bind(invitation_id.as_bytes().to_vec())
326    .fetch_optional(&state.db)
327    .await
328    .map_err(|err| {
329        tracing::error!("fetch invitation failed: {err}");
330        Error::Database
331    })?;
332    let (group_id_bytes, row_invitee_bytes, target_role, status) =
333        row.ok_or_else(|| Error::NotFound(format!("invitation {invitation_id}")))?;
334    let row_invitee = uuid_from_bytes(&row_invitee_bytes).ok_or_else(|| {
335        tracing::error!("bad invitee uuid in invitation");
336        Error::Database
337    })?;
338    if row_invitee != invitee_id {
339        return Err(Error::NotFound(format!("invitation {invitation_id}")));
340    }
341    if status != "pending" {
342        return Err(Error::BadRequest(format!("invitation is already {status}")));
343    }
344    let target_role = GroupRole::parse(&target_role)?;
345    Ok((group_id_bytes, target_role))
346}
347
348/// Selector for which slice of `group_invitations` a `fetch_invitations`
349/// call should return. Each variant maps to a single hard-coded SQL string
350/// inside [`fetch_invitations`] so no caller can inject SQL via a
351/// `where_clause` string parameter.
352enum InvitationsFilter {
353    /// All invitations addressed to a given group, newest first. Owners-
354    /// only endpoint.
355    Group(Uuid),
356    /// Pending invitations addressed to a given invitee, newest first.
357    PendingForInvitee(Uuid),
358}
359
360/// Run a parameterised lookup of invitations joined with group + user info.
361/// The SQL is selected by matching on [`InvitationsFilter`]; the variant's
362/// payload is the only data bound into the query.
363async fn fetch_invitations(
364    state: &AppState,
365    filter: InvitationsFilter,
366) -> Result<Vec<InvitationView>, Error> {
367    let (sql, blob): (&'static str, Vec<u8>) = match filter {
368        InvitationsFilter::Group(group_id) => (
369            "SELECT group_invitations.invitation_id, \
370                    group_invitations.group_id, \
371                    groups.name, \
372                    group_invitations.inviter_id, \
373                    inviter.username, inviter.legacy_name, \
374                    group_invitations.invitee_id, \
375                    invitee.username, invitee.legacy_name, \
376                    group_invitations.target_role, \
377                    group_invitations.status, \
378                    group_invitations.created_at, \
379                    group_invitations.responded_at \
380             FROM group_invitations \
381             JOIN groups ON groups.group_id = group_invitations.group_id \
382             JOIN users AS inviter ON inviter.user_id = group_invitations.inviter_id \
383             JOIN users AS invitee ON invitee.user_id = group_invitations.invitee_id \
384             WHERE group_invitations.group_id = ?1 \
385             ORDER BY group_invitations.created_at DESC",
386            group_id.as_bytes().to_vec(),
387        ),
388        InvitationsFilter::PendingForInvitee(user_id) => (
389            "SELECT group_invitations.invitation_id, \
390                    group_invitations.group_id, \
391                    groups.name, \
392                    group_invitations.inviter_id, \
393                    inviter.username, inviter.legacy_name, \
394                    group_invitations.invitee_id, \
395                    invitee.username, invitee.legacy_name, \
396                    group_invitations.target_role, \
397                    group_invitations.status, \
398                    group_invitations.created_at, \
399                    group_invitations.responded_at \
400             FROM group_invitations \
401             JOIN groups ON groups.group_id = group_invitations.group_id \
402             JOIN users AS inviter ON inviter.user_id = group_invitations.inviter_id \
403             JOIN users AS invitee ON invitee.user_id = group_invitations.invitee_id \
404             WHERE group_invitations.invitee_id = ?1 \
405                   AND group_invitations.status = 'pending' \
406             ORDER BY group_invitations.created_at DESC",
407            user_id.as_bytes().to_vec(),
408        ),
409    };
410    let rows = sqlx::query_as::<
411        _,
412        (
413            Vec<u8>,
414            Vec<u8>,
415            String,
416            Vec<u8>,
417            String,
418            String,
419            Vec<u8>,
420            String,
421            String,
422            String,
423            String,
424            DateTime<Utc>,
425            Option<DateTime<Utc>>,
426        ),
427    >(sql)
428    .bind(blob)
429    .fetch_all(&state.db)
430    .await
431    .map_err(|err| {
432        tracing::error!("fetch invitations failed: {err}");
433        Error::Database
434    })?;
435    let mut out = Vec::with_capacity(rows.len());
436    for (
437        invitation_bytes,
438        group_id_bytes,
439        group_name,
440        inviter_bytes,
441        inviter_username,
442        inviter_legacy_name,
443        invitee_bytes,
444        invitee_username,
445        invitee_legacy_name,
446        target_role,
447        status,
448        created_at,
449        responded_at,
450    ) in rows
451    {
452        let invitation_id = uuid_from_bytes(&invitation_bytes).ok_or_else(|| {
453            tracing::error!("bad invitation uuid");
454            Error::Database
455        })?;
456        let group_id = uuid_from_bytes(&group_id_bytes).ok_or_else(|| {
457            tracing::error!("bad group uuid");
458            Error::Database
459        })?;
460        let inviter_id = uuid_from_bytes(&inviter_bytes).ok_or_else(|| {
461            tracing::error!("bad inviter uuid");
462            Error::Database
463        })?;
464        let invitee_id = uuid_from_bytes(&invitee_bytes).ok_or_else(|| {
465            tracing::error!("bad invitee uuid");
466            Error::Database
467        })?;
468        let status = match status.as_str() {
469            "pending" => InvitationStatus::Pending,
470            "accepted" => InvitationStatus::Accepted,
471            "rejected" => InvitationStatus::Rejected,
472            other => {
473                return Err(Error::BadRequest(format!(
474                    "unknown invitation status `{other}`"
475                )));
476            }
477        };
478        out.push(InvitationView {
479            invitation_id,
480            group_id,
481            group_name,
482            inviter_id,
483            inviter_username,
484            inviter_legacy_name,
485            invitee_id,
486            invitee_username,
487            invitee_legacy_name,
488            target_role: GroupRole::parse(&target_role)?,
489            status,
490            created_at,
491            responded_at,
492        });
493    }
494    Ok(out)
495}
496
497/// Fetch a group's name (used in invitation views).
498async fn group_name(state: &AppState, group_id: Uuid) -> Result<String, Error> {
499    let row: Option<(String,)> = sqlx::query_as("SELECT name FROM groups WHERE group_id = ?1")
500        .bind(group_id.as_bytes().to_vec())
501        .fetch_optional(&state.db)
502        .await
503        .map_err(|err| {
504            tracing::error!("group name lookup failed: {err}");
505            Error::Database
506        })?;
507    row.map(|(n,)| n)
508        .ok_or_else(|| Error::NotFound(format!("group {group_id}")))
509}
510
511/// Ensure the calling user is an owner of the group. Returns
512/// [`Error::NotFound`] for both "group does not exist" and "caller is not
513/// an owner" so non-owners cannot probe for group existence.
514async fn require_owner(state: &AppState, group_id: Uuid, user_id: Uuid) -> Result<(), Error> {
515    if groups::lookup_role(&state.db, group_id, user_id).await? == Some(GroupRole::Owner) {
516        Ok(())
517    } else {
518        Err(Error::NotFound(format!("group {group_id}")))
519    }
520}