3.9 KiB · text History 7a00c02
//! Who may do what in an area. Areas are members-only: to anyone who isn't a
//! member, an area and everything in it (restaurants, lunches, outings)
//! answers 404, as though it didn't exist. Members are held to their role
//! with 403. See `entities::area_members::Role` for what each role allows.
use std::collections::HashMap;

use axum::http::StatusCode;
use chrono::Utc;
use sea_orm::sea_query::OnConflict;
use sea_orm::*;
use tracing::error;
use uuid::Uuid;

use crate::entities::area_members::{self, Role};
use crate::entities::{areas, users};

pub async fn role_in(db: &impl ConnectionTrait, area_id: Uuid, user_id: Uuid) -> Result<Option<Role>, DbErr> {
    Ok(area_members::Entity::find_by_id((area_id, user_id)).one(db).await?.map(|m| m.role))
}

/// The user's role in the area, if it's at least `min`. Not a member: 404;
/// a member with too little: 403.
pub async fn require(db: &impl ConnectionTrait, area_id: Uuid, user_id: Uuid, min: Role) -> Result<Role, StatusCode> {
    match role_in(db, area_id, user_id).await {
        Ok(Some(role)) if role >= min => Ok(role),
        Ok(Some(_)) => Err(StatusCode::FORBIDDEN),
        Ok(None) => Err(StatusCode::NOT_FOUND),
        Err(e) => {
            error!(error = %e, "load membership failed");
            Err(StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}

/// Areas the user belongs to, by name, with their role in each.
pub async fn member_areas(db: &impl ConnectionTrait, user_id: Uuid) -> Result<Vec<(areas::Model, Role)>, DbErr> {
    let roles: HashMap<Uuid, Role> = area_members::Entity::find()
        .filter(area_members::Column::UserId.eq(user_id))
        .all(db)
        .await?
        .into_iter()
        .map(|m| (m.area_id, m.role))
        .collect();
    Ok(areas::Entity::find()
        .filter(areas::Column::Id.is_in(roles.keys().copied()))
        .order_by_asc(areas::Column::Name)
        .all(db)
        .await?
        .into_iter()
        .map(|a| {
            let role = roles[&a.id];
            (a, role)
        })
        .collect())
}

/// An area's active members, by username, with roles.
pub async fn members(db: &impl ConnectionTrait, area_id: Uuid) -> Result<Vec<(users::Model, Role)>, DbErr> {
    let roles: HashMap<Uuid, Role> = area_members::Entity::find()
        .filter(area_members::Column::AreaId.eq(area_id))
        .all(db)
        .await?
        .into_iter()
        .map(|m| (m.user_id, m.role))
        .collect();
    Ok(users::Entity::find()
        .filter(users::Column::Id.is_in(roles.keys().copied()))
        .filter(users::Column::IsActive.eq(true))
        .order_by_asc(users::Column::Username)
        .all(db)
        .await?
        .into_iter()
        .map(|u| {
            let role = roles[&u.id];
            (u, role)
        })
        .collect())
}

/// Make someone a member with at least `role`. An existing higher role is
/// kept: an invite never demotes.
pub async fn grant(db: &impl ConnectionTrait, area_id: Uuid, user_id: Uuid, role: Role) -> Result<(), DbErr> {
    let role = match role_in(db, area_id, user_id).await? {
        Some(current) => current.max(role),
        None => role,
    };
    area_members::Entity::insert(area_members::ActiveModel {
        area_id: Set(area_id),
        user_id: Set(user_id),
        role: Set(role),
        created_at: Set(Utc::now().into()),
    })
    .on_conflict(
        OnConflict::columns([area_members::Column::AreaId, area_members::Column::UserId])
            .update_column(area_members::Column::Role)
            .to_owned(),
    )
    .exec_without_returning(db)
    .await?;
    Ok(())
}

/// Admins in the area — an area must always keep at least one.
pub async fn admin_count(db: &impl ConnectionTrait, area_id: Uuid) -> Result<u64, DbErr> {
    area_members::Entity::find()
        .filter(area_members::Column::AreaId.eq(area_id))
        .filter(area_members::Column::Role.eq(Role::Admin))
        .count(db)
        .await
}