use axum::{ extract::{Path, State}, http::{StatusCode, HeaderMap}, Json, }; use axum_login::AuthSession; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use tower_sessions::Session; use utoipa::ToSchema; use crate::auth::AuthBackend; use crate::models::invite_link::Model as InviteLinkModel; use crate::services::InviteLinkService; #[derive(Debug, Deserialize, ToSchema)] #[schema(example = json!({"expires_in_hours": 24, "max_uses": 5}))] pub struct CreateInviteLinkRequest { pub expires_in_hours: Option, pub max_uses: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct InviteLinkResponse { pub id: i32, pub family_id: i32, pub token: String, pub invite_url: String, pub expires_at: Option, pub max_uses: Option, pub uses_count: i32, } #[derive(Debug, Serialize, ToSchema)] pub struct ValidateInviteResponse { pub valid: bool, pub family_id: Option, pub family_name: Option, } #[derive(Debug, Serialize, ToSchema)] pub struct JoinFamilyResponse { pub success: bool, pub family_id: i32, pub message: String, } fn model_to_response(model: InviteLinkModel, base_url: &str) -> InviteLinkResponse { InviteLinkResponse { id: model.id, family_id: model.family_id, token: model.token.clone(), invite_url: format!("{}/invite/{}", base_url, model.token), expires_at: model.expires_at.map(|dt| dt.to_string()), max_uses: model.max_uses, uses_count: model.uses_count, } } #[utoipa::path( post, path = "/my-family/invite-links", tag = "invite-links", request_body = CreateInviteLinkRequest, responses( (status = 200, description = "Invite link created", body = InviteLinkResponse), (status = 401, description = "Not authenticated"), (status = 403, description = "User has no family"), (status = 500, description = "Internal server error") ) )] pub async fn create_invite_link( auth_session: AuthSession, headers: HeaderMap, State(db): State, Json(payload): Json, ) -> Result, StatusCode> { let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; let family_id = user.family_id.ok_or(StatusCode::FORBIDDEN)?; let expires_at = payload.expires_in_hours.map(|hours| { chrono::Utc::now().naive_utc() + chrono::Duration::hours(hours) }); let invite = InviteLinkService::create(&db, family_id, user.id, expires_at, payload.max_uses) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let base_url = headers .get("origin") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) .unwrap_or_else(|| std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:5173".to_string())); Ok(Json(model_to_response(invite, &base_url))) } #[utoipa::path( get, path = "/my-family/invite-links", tag = "invite-links", responses( (status = 200, description = "List of invite links", body = Vec), (status = 401, description = "Not authenticated"), (status = 403, description = "User has no family"), (status = 500, description = "Internal server error") ) )] pub async fn get_my_invite_links( auth_session: AuthSession, headers: HeaderMap, State(db): State, ) -> Result>, StatusCode> { let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; let family_id = user.family_id.ok_or(StatusCode::FORBIDDEN)?; let invites = InviteLinkService::find_by_family(&db, family_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let base_url = headers .get("origin") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) .unwrap_or_else(|| std::env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:5173".to_string())); let responses: Vec = invites .into_iter() .map(|i| model_to_response(i, &base_url)) .collect(); Ok(Json(responses)) } #[utoipa::path( delete, path = "/my-family/invite-links/{token}", tag = "invite-links", params( ("token" = String, Path, description = "Invite token") ), responses( (status = 204, description = "Invite link deleted"), (status = 401, description = "Not authenticated"), (status = 403, description = "User has no family or not authorized"), (status = 404, description = "Invite link not found"), (status = 500, description = "Internal server error") ) )] pub async fn delete_invite_link( auth_session: AuthSession, State(db): State, Path(token): Path, ) -> Result { let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; let family_id = user.family_id.ok_or(StatusCode::FORBIDDEN)?; let invite = InviteLinkService::find_by_token(&db, &token) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if invite.family_id != family_id { return Err(StatusCode::FORBIDDEN); } InviteLinkService::delete_by_token(&db, &token) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( get, path = "/invite/{token}", tag = "invite-links", params( ("token" = String, Path, description = "Invite token") ), responses( (status = 200, description = "Invite link is valid", body = ValidateInviteResponse), (status = 404, description = "Invite link not found or invalid"), (status = 500, description = "Internal server error") ) )] pub async fn validate_invite_link( State(db): State, Path(token): Path, ) -> Result, StatusCode> { use crate::models::Family; use sea_orm::EntityTrait; let invite = InviteLinkService::find_by_token(&db, &token) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if let Some(expires_at) = invite.expires_at { let now = chrono::Utc::now().naive_utc(); if now > expires_at { return Ok(Json(ValidateInviteResponse { valid: false, family_id: None, family_name: None, })); } } if let Some(max_uses) = invite.max_uses { if invite.uses_count >= max_uses { return Ok(Json(ValidateInviteResponse { valid: false, family_id: None, family_name: None, })); } } let family = Family::find_by_id(invite.family_id) .one(&db) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(ValidateInviteResponse { valid: true, family_id: Some(invite.family_id), family_name: family.map(|f| f.name), })) } #[utoipa::path( post, path = "/invite/{token}/join", tag = "invite-links", params( ("token" = String, Path, description = "Invite token") ), responses( (status = 200, description = "Successfully joined family", body = JoinFamilyResponse), (status = 401, description = "Not authenticated"), (status = 400, description = "User already in a family or invite invalid"), (status = 404, description = "Invite link not found"), (status = 500, description = "Internal server error") ) )] pub async fn join_family_via_invite( auth_session: AuthSession, session: Session, State(db): State, Path(token): Path, ) -> Result, StatusCode> { let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; if user.family_id.is_some() { return Ok(Json(JoinFamilyResponse { success: false, family_id: 0, message: "You already belong to a family".to_string(), })); } let invite = InviteLinkService::validate_and_use(&db, &token, user.id) .await .map_err(|e| match e { sea_orm::DbErr::RecordNotFound(_) => StatusCode::NOT_FOUND, sea_orm::DbErr::Custom(msg) if msg.contains("expired") => StatusCode::BAD_REQUEST, sea_orm::DbErr::Custom(msg) if msg.contains("max uses") => StatusCode::BAD_REQUEST, sea_orm::DbErr::Custom(msg) if msg.contains("already belongs") => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, })?; let mut authorized_families: Vec = session .get("authorized_families") .await .unwrap_or(None) .unwrap_or_default(); if !authorized_families.contains(&invite.family_id) { authorized_families.push(invite.family_id); session .insert("authorized_families", authorized_families) .await .ok(); } Ok(Json(JoinFamilyResponse { success: true, family_id: invite.family_id, message: "Successfully joined family".to_string(), })) }