10.8 KiB · text History 7a00c02
//! An area's members and invitations.
//!
//! Admins invite existing users by username with a role; the invitation
//! waits until the invitee accepts or declines it from their home page.
//! (People without an account are invited by sign-up link, from /invites,
//! which can carry area grants.) Admins change roles and remove members;
//! anyone can leave. An area always keeps at least one admin.
use std::collections::{HashMap, HashSet};

use axum::extract::{Form, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use chrono::Utc;
use minijinja::context;
use sea_orm::sea_query::OnConflict;
use sea_orm::*;
use serde::Deserialize;
use uuid::Uuid;

use super::areas::authorize;
use super::{internal, non_empty, render};
use crate::entities::area_invitations;
use crate::entities::area_members::{self, Role};
use crate::entities::users;
use crate::friends::{self, Link};
use crate::middleware::UserContext;
use crate::{access, AppState};

#[derive(Debug, Deserialize)]
pub struct Notice {
    pub notice: Option<String>,
}

pub async fn show_members(
    State(state): State<AppState>,
    user: UserContext,
    Path(area_id): Path<Uuid>,
    Query(q): Query<Notice>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let (area, role) = authorize(db, area_id, user.user.id, Role::Voter).await?;
    let members = access::members(db, area_id).await.map_err(internal("list members failed"))?;
    let links: HashMap<Uuid, Link> =
        friends::links(db, user.user.id).await.map_err(internal("load friends failed"))?.into_iter().collect();
    let member_ids: HashSet<Uuid> = members.iter().map(|(u, _)| u.id).collect();
    let admins = members.iter().filter(|(_, r)| *r == Role::Admin).count();

    let rows: Vec<minijinja::Value> = members
        .iter()
        .map(|(u, r)| {
            context! {
                id => u.id,
                username => u.username,
                role => r,
                me => u.id == user.user.id,
                link => links.get(&u.id).copied().unwrap_or(Link::None),
                last_admin => *r == Role::Admin && admins == 1,
            }
        })
        .collect();

    // Pending invitations are admin business; so are suggestions of whom to
    // invite (your friends who aren't here yet).
    let (invitations, suggestions) = if role == Role::Admin {
        let pending = area_invitations::Entity::find()
            .filter(area_invitations::Column::AreaId.eq(area_id))
            .all(db)
            .await
            .map_err(internal("load invitations failed"))?;
        let invited: HashSet<Uuid> = pending.iter().map(|i| i.user_id).collect();
        let friend_ids: Vec<Uuid> = links
            .iter()
            .filter(|(id, l)| **l == Link::Friends && !member_ids.contains(id) && !invited.contains(id))
            .map(|(id, _)| *id)
            .collect();
        let wanted: Vec<Uuid> = invited.iter().copied().chain(friend_ids.iter().copied()).collect();
        let names: HashMap<Uuid, String> = users::Entity::find()
            .filter(users::Column::Id.is_in(wanted))
            .all(db)
            .await
            .map_err(internal("load users failed"))?
            .into_iter()
            .map(|u| (u.id, u.username))
            .collect();
        let invitations: Vec<minijinja::Value> = pending
            .iter()
            .filter_map(|i| Some(context! { user_id => i.user_id, username => names.get(&i.user_id)?, role => i.role }))
            .collect();
        let mut suggestions: Vec<(Uuid, String)> =
            friend_ids.iter().filter_map(|id| Some((*id, names.get(id)?.clone()))).collect();
        suggestions.sort_by(|a, b| a.1.cmp(&b.1));
        let suggestions: Vec<minijinja::Value> =
            suggestions.into_iter().map(|(id, username)| context! { id => id, username => username }).collect();
        (invitations, suggestions)
    } else {
        (vec![], vec![])
    };

    render(
        &state,
        "areas/members.html",
        context! {
            title => format!("Members · {}", area.name),
            user => user.user,
            area => area,
            role => role,
            members => rows,
            invitations => invitations,
            suggestions => suggestions,
            notice => q.notice,
            roles => Role::ALL,
        },
    )
}

fn back(area_id: Uuid, notice: &str) -> Redirect {
    Redirect::to(&format!("/areas/{area_id}/members?notice={notice}"))
}

#[derive(Debug, Deserialize)]
pub struct InviteForm {
    /// Username, or a user id from a suggestion button.
    pub username: Option<String>,
    pub user_id: Option<Uuid>,
    pub role: String,
}

pub async fn invite(
    State(state): State<AppState>,
    user: UserContext,
    Path(area_id): Path<Uuid>,
    Form(form): Form<InviteForm>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    authorize(db, area_id, user.user.id, Role::Admin).await?;
    let role = Role::parse(&form.role).ok_or(StatusCode::BAD_REQUEST)?;
    let mut q = users::Entity::find().filter(users::Column::IsActive.eq(true));
    q = match (form.user_id, non_empty(form.username)) {
        (Some(id), _) => q.filter(users::Column::Id.eq(id)),
        (None, Some(name)) => q.filter(users::Column::Username.eq(name)),
        (None, None) => return Err(StatusCode::BAD_REQUEST),
    };
    let Some(invitee) = q.one(db).await.map_err(internal("load user failed"))? else {
        return Ok(back(area_id, "no-such-user"));
    };
    if access::role_in(db, area_id, invitee.id).await.map_err(internal("load membership failed"))?.is_some() {
        return Ok(back(area_id, "already-member"));
    }
    area_invitations::Entity::insert(area_invitations::ActiveModel {
        area_id: Set(area_id),
        user_id: Set(invitee.id),
        role: Set(role),
        invited_by: Set(Some(user.user.id)),
        created_at: Set(Utc::now().into()),
    })
    // Re-inviting updates the role offered.
    .on_conflict(
        OnConflict::columns([area_invitations::Column::AreaId, area_invitations::Column::UserId])
            .update_columns([area_invitations::Column::Role, area_invitations::Column::InvitedBy])
            .to_owned(),
    )
    .exec_without_returning(db)
    .await
    .map_err(internal("invite failed"))?;
    Ok(back(area_id, "invited"))
}

pub async fn cancel_invitation(
    State(state): State<AppState>,
    user: UserContext,
    Path((area_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    authorize(db, area_id, user.user.id, Role::Admin).await?;
    area_invitations::Entity::delete_by_id((area_id, user_id))
        .exec(db)
        .await
        .map_err(internal("cancel invitation failed"))?;
    Ok(back(area_id, "cancelled"))
}

#[derive(Debug, Deserialize)]
pub struct RoleForm {
    pub role: String,
}

pub async fn set_role(
    State(state): State<AppState>,
    user: UserContext,
    Path((area_id, user_id)): Path<(Uuid, Uuid)>,
    Form(form): Form<RoleForm>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    authorize(db, area_id, user.user.id, Role::Admin).await?;
    let role = Role::parse(&form.role).ok_or(StatusCode::BAD_REQUEST)?;
    let current = access::role_in(db, area_id, user_id)
        .await
        .map_err(internal("load membership failed"))?
        .ok_or(StatusCode::NOT_FOUND)?;
    if current == Role::Admin
        && role != Role::Admin
        && access::admin_count(db, area_id).await.map_err(internal("count admins failed"))? <= 1
    {
        return Ok(back(area_id, "last-admin"));
    }
    let mut active: area_members::ActiveModel = area_members::Entity::find_by_id((area_id, user_id))
        .one(db)
        .await
        .map_err(internal("load membership failed"))?
        .ok_or(StatusCode::NOT_FOUND)?
        .into();
    active.role = Set(role);
    active.update(db).await.map_err(internal("set role failed"))?;
    Ok(back(area_id, "updated"))
}

/// Admins remove anyone; everyone can remove themselves (leave). Never the
/// last admin.
pub async fn remove_member(
    State(state): State<AppState>,
    user: UserContext,
    Path((area_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let leaving = user_id == user.user.id;
    authorize(db, area_id, user.user.id, if leaving { Role::Voter } else { Role::Admin }).await?;
    let current = access::role_in(db, area_id, user_id)
        .await
        .map_err(internal("load membership failed"))?
        .ok_or(StatusCode::NOT_FOUND)?;
    if current == Role::Admin && access::admin_count(db, area_id).await.map_err(internal("count admins failed"))? <= 1 {
        return Ok(back(area_id, "last-admin"));
    }
    area_members::Entity::delete_by_id((area_id, user_id))
        .exec(db)
        .await
        .map_err(internal("remove member failed"))?;
    Ok(if leaving { Redirect::to("/areas") } else { back(area_id, "removed") })
}

/// Invitations waiting on `user_id`, for their home page.
pub async fn pending_for(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<minijinja::Value>, StatusCode> {
    let pending = area_invitations::Entity::find()
        .filter(area_invitations::Column::UserId.eq(user_id))
        .all(db)
        .await
        .map_err(internal("load invitations failed"))?;
    let mut out = Vec::with_capacity(pending.len());
    for i in pending {
        let area = super::areas::load_area(db, i.area_id).await?;
        let by = match i.invited_by {
            Some(id) => users::Entity::find_by_id(id)
                .one(db)
                .await
                .map_err(internal("load user failed"))?
                .map(|u| u.username),
            None => None,
        };
        out.push(context! { area => area, role => i.role, invited_by => by });
    }
    Ok(out)
}

/// The invitee's answer. Accepting grants the offered role (never demoting
/// an existing one) and opens the area.
pub async fn answer_invitation(
    State(state): State<AppState>,
    user: UserContext,
    Path((area_id, answer)): Path<(Uuid, String)>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let invitation = area_invitations::Entity::find_by_id((area_id, user.user.id))
        .one(db)
        .await
        .map_err(internal("load invitation failed"))?
        .ok_or(StatusCode::NOT_FOUND)?;
    let txn = db.begin().await.map_err(internal("begin failed"))?;
    if answer == "accept" {
        access::grant(&txn, area_id, user.user.id, invitation.role)
            .await
            .map_err(internal("grant failed"))?;
    } else if answer != "decline" {
        return Err(StatusCode::NOT_FOUND);
    }
    area_invitations::Entity::delete_by_id((area_id, user.user.id))
        .exec(&txn)
        .await
        .map_err(internal("clear invitation failed"))?;
    txn.commit().await.map_err(internal("commit failed"))?;
    Ok(Redirect::to(&if answer == "accept" { format!("/areas/{area_id}") } else { "/".to_string() }))
}