raisfast 0.2.23

The last backend you'll ever need. Rust-powered headless CMS with built-in blog, ecommerce, wallet, payment and 4 plugin engines.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(feature = "export-types")]
use ts_rs::TS;
use utoipa::ToSchema;
use validator::Validate;

use crate::errors::app_error::AppResult;
use crate::models::user::{RegisteredVia, User, UserRole, UserStatus};
use crate::models::user_credential::AuthType;
use crate::utils::tz::Timestamp;

use super::validate_password;

pub type SocialLinks = HashMap<String, String>;
pub type UserMetadata = serde_json::Value;

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct RegisterRequest {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 2, max = 50))]
    pub username: String,
    #[validate(length(min = 8, max = 128), custom(function = "validate_password"))]
    pub password: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct LoginRequest {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 1, max = 128))]
    pub password: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct RefreshRequest {
    #[validate(length(min = 1))]
    pub refresh_token: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateUserRequest {
    #[validate(length(min = 2, max = 50))]
    pub username: Option<String>,
    pub bio: Option<String>,
    pub website: Option<String>,
    pub avatar: Option<String>,
    #[cfg_attr(feature = "export-types", ts(type = "Record<string, string>"))]
    pub social_links: Option<SocialLinks>,
    #[cfg_attr(feature = "export-types", ts(type = "unknown"))]
    pub metadata: Option<UserMetadata>,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdatePasswordRequest {
    #[validate(length(min = 1, max = 128))]
    pub old_password: String,
    #[validate(length(min = 8, max = 128), custom(function = "validate_password"))]
    pub new_password: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleRequest {
    pub role: UserRole,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct ForgotPasswordRequest {
    #[validate(email)]
    pub email: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct ResetPasswordRequest {
    #[validate(length(min = 1))]
    pub token: String,
    #[validate(length(min = 8, max = 128), custom(function = "validate_password"))]
    pub new_password: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct SetPasswordRequest {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8, max = 128), custom(function = "validate_password"))]
    pub new_password: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct SendSmsCodeRequest {
    #[validate(length(min = 5, max = 20))]
    pub phone: String,
    #[validate(length(min = 1, max = 30))]
    pub purpose: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct VerifySmsRequest {
    #[validate(length(min = 5, max = 20))]
    pub phone: String,
    #[validate(length(min = 4, max = 8))]
    pub code: String,
    #[validate(length(min = 1, max = 30))]
    pub purpose: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct BindPhoneRequest {
    #[validate(length(min = 5, max = 20))]
    pub phone: String,
    #[validate(length(min = 4, max = 8))]
    pub code: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct BindEmailRequest {
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 8, max = 128), custom(function = "validate_password"))]
    pub password: String,
}

/// Credential information response
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Serialize, ToSchema)]
pub struct CredentialResponse {
    pub id: String,
    pub auth_type: AuthType,
    pub identifier: String,
    pub verified: bool,
    #[schema(value_type = String)]
    pub created_at: Timestamp,
    #[schema(value_type = String)]
    pub updated_at: Timestamp,
}

impl CredentialResponse {
    pub fn from_credential(c: crate::models::user_credential::UserCredential) -> AppResult<Self> {
        Ok(Self {
            id: c.id.to_string(),
            auth_type: c.auth_type,
            identifier: c.identifier,
            verified: c.verified == 1,
            created_at: c.created_at,
            updated_at: c.updated_at,
        })
    }
}

/// Authentication configuration response
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Serialize, ToSchema)]
pub struct AuthConfigResponse {
    pub registration_email_enabled: bool,
    pub registration_sms_enabled: bool,
    pub oauth_providers: Vec<String>,
    pub require_email_verification: bool,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct VerifyEmailRequest {
    #[validate(length(min = 1))]
    pub token: String,
}

#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct ResendVerificationRequest {
    #[validate(email)]
    pub email: String,
}

/// User public profile response
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[non_exhaustive]
pub struct UserResponse {
    pub id: String,
    pub username: String,
    pub role: UserRole,
    pub status: UserStatus,
    pub registered_via: RegisteredVia,
    pub avatar: Option<String>,
    pub bio: Option<String>,
    pub website: Option<String>,
    pub display_name: Option<String>,
    pub slug: Option<String>,
    pub locale: Option<String>,
    #[cfg_attr(feature = "export-types", ts(type = "Record<string, string>"))]
    pub social_links: Option<SocialLinks>,
    #[cfg_attr(feature = "export-types", ts(type = "unknown"))]
    pub metadata: Option<UserMetadata>,
    #[schema(value_type = String)]
    pub created_at: Timestamp,
    #[schema(value_type = String)]
    pub updated_at: Timestamp,
}

impl UserResponse {
    pub fn from_user(user: User) -> AppResult<Self> {
        let role = user.role;
        let status = user.status;
        let registered_via = user.registered_via;
        Ok(Self {
            id: user.id.to_string(),
            username: user.username,
            role,
            status,
            registered_via,
            avatar: user.avatar,
            bio: user.bio,
            website: user.website,
            display_name: user.display_name,
            slug: user.slug,
            locale: user.locale,
            social_links: crate::models::user::parse_social_links(&user.social_links),
            metadata: crate::models::user::parse_metadata(&user.metadata),
            created_at: user.created_at,
            updated_at: user.updated_at,
        })
    }
}

/// Login success response
#[cfg_attr(feature = "export-types", derive(TS))]
#[derive(Debug, Serialize, ToSchema)]
#[non_exhaustive]
pub struct LoginResponse {
    pub access_token: String,
    pub refresh_token: String,
    pub expires_in: u64,
    pub user: UserResponse,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn register_request_valid() {
        let req = RegisterRequest {
            email: "test@example.com".to_string(),
            username: "testuser".to_string(),
            password: "Password1".to_string(),
        };
        assert!(req.validate().is_ok());
    }

    #[test]
    fn register_request_bad_email() {
        let req = RegisterRequest {
            email: "not-an-email".to_string(),
            username: "testuser".to_string(),
            password: "Password1".to_string(),
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn register_request_short_username() {
        let req = RegisterRequest {
            email: "test@example.com".to_string(),
            username: "a".to_string(),
            password: "Password1".to_string(),
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn register_request_short_password() {
        let req = RegisterRequest {
            email: "test@example.com".to_string(),
            username: "testuser".to_string(),
            password: "short".to_string(),
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn register_request_password_no_digit() {
        let req = RegisterRequest {
            email: "test@example.com".to_string(),
            username: "testuser".to_string(),
            password: "passwordonly".to_string(),
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn login_request_valid() {
        let req = LoginRequest {
            email: "test@example.com".to_string(),
            password: "anypassword".to_string(),
        };
        assert!(req.validate().is_ok());
    }

    #[test]
    fn login_request_empty_password() {
        let req = LoginRequest {
            email: "test@example.com".to_string(),
            password: "".to_string(),
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn update_user_request_valid() {
        let req = UpdateUserRequest {
            username: Some("newname".to_string()),
            bio: None,
            website: None,
            avatar: None,
            social_links: None,
            metadata: None,
        };
        assert!(req.validate().is_ok());
    }

    #[test]
    fn update_password_request_valid() {
        let req = UpdatePasswordRequest {
            old_password: "OldPass1".to_string(),
            new_password: "NewPass2".to_string(),
        };
        assert!(req.validate().is_ok());
    }

    #[test]
    fn update_password_request_weak_new_password() {
        let req = UpdatePasswordRequest {
            old_password: "OldPass1".to_string(),
            new_password: "abcdefgh".to_string(),
        };
        assert!(req.validate().is_err());
    }

    #[test]
    fn user_response_from_user_serializes() {
        let resp = UserResponse {
            id: "doc-123".to_string(),
            username: "test".to_string(),
            role: UserRole::Reader,
            status: UserStatus::Active,
            registered_via: RegisteredVia::Email,
            avatar: None,
            bio: None,
            website: None,
            display_name: None,
            slug: None,
            locale: None,
            social_links: None,
            metadata: None,
            created_at: "2025-01-01T00:00:00Z".parse().unwrap(),
            updated_at: "2025-01-01T00:00:00Z".parse().unwrap(),
        };
        let json = serde_json::to_string(&resp).unwrap();
        assert!(json.contains("\"id\":\"doc-123\""));
    }
}