//! 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, ¤t, 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(¤t.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, ¤t, 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(¤t.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()
}
}
}