use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::callback::AuthCallback;
use crate::errors::AppError;
use crate::handlers::admin::validate_system_admin;
use crate::repositories::AccessCodeEntity;
use crate::services::EmailService;
use crate::utils::authenticate;
use crate::AppState;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessCodeResponse {
pub id: String,
pub code: String,
pub code_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_uses: Option<i32>,
pub current_uses: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
pub created_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccessCodeListResponse {
pub items: Vec<AccessCodeResponse>,
pub total: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignupStatsResponse {
pub count: u64,
pub limit: Option<u64>,
pub period: String,
pub period_start: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListCodesQuery {
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
pub code_type: Option<String>,
}
fn default_limit() -> u32 {
20
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccessCodeRequest {
pub code: String,
pub max_uses: Option<i32>,
pub expires_at: Option<String>,
}
fn entity_to_response(e: AccessCodeEntity) -> AccessCodeResponse {
AccessCodeResponse {
id: e.id.to_string(),
code: e.code,
code_type: e.code_type,
max_uses: e.max_uses,
current_uses: e.current_uses,
created_by: e.created_by.map(|id| id.to_string()),
expires_at: e.expires_at.map(|dt| dt.to_rfc3339()),
created_at: e.created_at.to_rfc3339(),
}
}
pub async fn generate_user_code<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
) -> Result<Json<AccessCodeResponse>, AppError> {
let auth_user = authenticate(&state, &headers).await?;
let entity = state
.signup_gating_service
.generate_user_code(auth_user.user_id)
.await?;
tracing::info!(
user_id = %auth_user.user_id,
code_id = %entity.id,
"User generated invite code"
);
Ok(Json(entity_to_response(entity)))
}
pub async fn list_my_codes<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Query(query): Query<ListCodesQuery>,
) -> Result<Json<AccessCodeListResponse>, AppError> {
let auth_user = authenticate(&state, &headers).await?;
let limit = query.limit.min(100);
let items = state
.storage
.access_code_repo
.list_by_creator(auth_user.user_id, limit, query.offset)
.await?;
let total = state
.storage
.access_code_repo
.count_by_creator_since(
auth_user.user_id,
DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
)
.await?;
Ok(Json(AccessCodeListResponse {
items: items.into_iter().map(entity_to_response).collect(),
total,
}))
}
pub async fn admin_list_codes<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Query(query): Query<ListCodesQuery>,
) -> Result<Json<AccessCodeListResponse>, AppError> {
validate_system_admin(&state, &headers).await?;
let limit = query.limit.min(100);
let code_type = query.code_type.as_deref();
let items = state
.storage
.access_code_repo
.list_all(limit, query.offset, code_type)
.await?;
let total = state.storage.access_code_repo.count_all(code_type).await?;
Ok(Json(AccessCodeListResponse {
items: items.into_iter().map(entity_to_response).collect(),
total,
}))
}
pub async fn admin_create_code<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Json(req): Json<CreateAccessCodeRequest>,
) -> Result<Json<AccessCodeResponse>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
if req.code.trim().is_empty() {
return Err(AppError::Validation("code must not be empty".into()));
}
let expires_at: Option<DateTime<Utc>> = match req.expires_at.as_deref() {
None | Some("") => None,
Some(s) => {
let dt = DateTime::parse_from_rfc3339(s)
.map_err(|_| AppError::Validation(format!("Invalid expires_at: '{}'", s)))?
.with_timezone(&Utc);
Some(dt)
}
};
let now = Utc::now();
let entity = AccessCodeEntity {
id: Uuid::new_v4(),
code: req.code.trim().to_string(),
code_type: "limited".to_string(),
max_uses: req.max_uses,
current_uses: 0,
created_by: None,
expires_at,
created_at: now,
};
let created = state.storage.access_code_repo.create(entity).await?;
tracing::info!(
admin_id = %admin_id,
code_id = %created.id,
code = %created.code,
"Admin created access code"
);
Ok(Json(entity_to_response(created)))
}
pub async fn admin_delete_code<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, AppError> {
let admin_id = validate_system_admin(&state, &headers).await?;
state.storage.access_code_repo.delete(id).await?;
tracing::info!(admin_id = %admin_id, code_id = %id, "Admin deleted access code");
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn admin_signup_stats<C: AuthCallback, E: EmailService>(
State(state): State<Arc<AppState<C, E>>>,
headers: HeaderMap,
) -> Result<Json<SignupStatsResponse>, AppError> {
validate_system_admin(&state, &headers).await?;
let period = state
.settings_service
.get("signup_limit_period")
.await?
.unwrap_or_else(|| "day".to_string());
let start = crate::services::period_start(&period);
let count = state.user_repo.count_created_since(start).await?;
let limit_enabled = state
.settings_service
.get_bool("signup_limit_enabled")
.await?
.unwrap_or(false);
let limit = if limit_enabled {
state
.settings_service
.get("signup_limit_max")
.await?
.and_then(|s| s.parse::<u64>().ok())
.or(Some(100))
} else {
None
};
Ok(Json(SignupStatsResponse {
count,
limit,
period,
period_start: start.to_rfc3339(),
}))
}