🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Creating and listing registration invites.
//!
//! A user can generate invite links for others to register with. Each invite
//! is single-use and expires after a configurable period (default 48h). There
//! is a per-user per-day quota (default 5).

use std::sync::Arc;

use actix_web::{HttpResponse, get, post, web};
use chrono::{DateTime, FixedOffset, Utc};
use tokio::sync::RwLock;
use tracing::error;

use crate::auth::CurrentUser;
use crate::views::{self, InviteSummary};
use crate::webui::repositories::acting_user;
use crate::{AppState, state_read};

#[get("/~{user_or_team}/invites")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn get_invites(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    user_or_team: web::Path<String>,
) -> HttpResponse {
    let user = match acting_user(&data, &current, user_or_team.as_str()).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    let state = state_read(&data).await;
    let db = &state.db;

    let invites = match crate::registration::list_invites(db, user.id).await {
        Ok(invites) => invites,
        Err(error) => {
            error!(%error, owner = %user.name, "listing invites failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Compute remaining quota
    let now = Utc::now();
    let day_ago = now - chrono::Duration::hours(24);
    let day_ago_fixed: DateTime<FixedOffset> = day_ago.into();
    let recent_count = invites
        .iter()
        .filter(|i| i.created_at >= day_ago_fixed)
        .count() as u64;
    let remaining = crate::registration::invites_allowed_per_day().saturating_sub(recent_count);

    // Build summaries
    let summaries: Vec<InviteSummary> = invites
        .into_iter()
        .map(|i| {
            let link = format!("/register/?invite={}", i.uuid);
            let created_at = i.created_at.format("%Y-%m-%d %H:%M UTC").to_string();
            let expires_at = i.expires_at.format("%Y-%m-%d %H:%M UTC").to_string();
            InviteSummary {
                uuid: i.uuid,
                link,
                created_at,
                expires_at,
                used: None, // TODO: look up used_by user name if needed
            }
        })
        .collect();

    let mode = crate::registration::registration_mode();
    let mode_note = if mode == crate::registration::RegistrationMode::Open {
        Some(
            "Registration is currently open; invites are not required but may still be useful for tracking.",
        )
    } else {
        None
    };

    drop(state);

    views::invites(
        Some(&current.name),
        &user.name,
        &summaries,
        remaining,
        None,
        mode_note,
    )
}

#[post("/~{user_or_team}/invites")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn post_invite(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    user_or_team: web::Path<String>,
) -> HttpResponse {
    let user = match acting_user(&data, &current, user_or_team.as_str()).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    let state = state_read(&data).await;
    let db = &state.db;

    match crate::registration::create_invite(db, user.id).await {
        Ok(_invite) => {
            drop(state);
            HttpResponse::SeeOther()
                .append_header(("Location", format!("/~{}/invites", user.name)))
                .finish()
        }
        Err(crate::registration::InviteError::QuotaExceeded) => {
            // Re-render the page with the error
            let invites = match crate::registration::list_invites(db, user.id).await {
                Ok(invites) => invites,
                Err(error) => {
                    error!(%error, owner = %user.name, "listing invites failed");
                    return HttpResponse::InternalServerError().finish();
                }
            };

            let now = Utc::now();
            let day_ago = now - chrono::Duration::hours(24);
            let day_ago_fixed: DateTime<FixedOffset> = day_ago.into();
            let recent_count = invites
                .iter()
                .filter(|i| i.created_at >= day_ago_fixed)
                .count() as u64;
            let remaining =
                crate::registration::invites_allowed_per_day().saturating_sub(recent_count);

            let summaries: Vec<InviteSummary> = invites
                .into_iter()
                .map(|i| {
                    let link = format!("/register/?invite={}", i.uuid);
                    let created_at = i.created_at.format("%Y-%m-%d %H:%M UTC").to_string();
                    let expires_at = i.expires_at.format("%Y-%m-%d %H:%M UTC").to_string();
                    InviteSummary {
                        uuid: i.uuid,
                        link,
                        created_at,
                        expires_at,
                        used: None,
                    }
                })
                .collect();

            let mode = crate::registration::registration_mode();
            let mode_note = if mode == crate::registration::RegistrationMode::Open {
                Some(
                    "Registration is currently open; invites are not required but may still be useful for tracking.",
                )
            } else {
                None
            };

            drop(state);

            views::invites(
                Some(&current.name),
                &user.name,
                &summaries,
                remaining,
                Some("You have already created the maximum number of invites for today."),
                mode_note,
            )
        }
        Err(crate::registration::InviteError::Database(msg)) => {
            error!(%msg, owner = %user.name, "creating invite failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}