primero
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Build outputs
|
||||
/target/
|
||||
|
||||
# IDE/editor junk
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
**/*~
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Local env/config
|
||||
.env
|
||||
|
||||
# Rust fmt/backup
|
||||
**/*.rs.bk
|
||||
3012
Cargo.lock
generated
Normal file
3012
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "pos-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono", "uuid"] }
|
||||
|
||||
dotenvy = "0.15"
|
||||
thiserror = "1"
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
argon2 = "0.5"
|
||||
password-hash = "0.5"
|
||||
|
||||
jsonwebtoken = "9"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
61
src/config.rs
Normal file
61
src/config.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub app_host: String,
|
||||
pub app_port: u16,
|
||||
pub database_url: Option<String>,
|
||||
pub db_host: String,
|
||||
pub db_port: u16,
|
||||
pub db_name: String,
|
||||
pub db_user: String,
|
||||
pub db_password: String,
|
||||
pub db_schema: String,
|
||||
pub jwt_secret: String,
|
||||
pub jwt_access_ttl_minutes: i64,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let app_host = env::var("APP_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
|
||||
let app_port = env::var("APP_PORT")
|
||||
.unwrap_or_else(|_| "8080".to_string())
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "APP_PORT must be a valid u16".to_string())?;
|
||||
|
||||
let database_url = env::var("DATABASE_URL").ok().filter(|s| !s.is_empty());
|
||||
|
||||
// Permite conexión sin URL, usando partes sueltas
|
||||
let db_host = env::var("DB_HOST").unwrap_or_else(|_| "localhost".to_string());
|
||||
let db_port = env::var("DB_PORT")
|
||||
.unwrap_or_else(|_| "5432".to_string())
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "DB_PORT must be a valid u16".to_string())?;
|
||||
let db_name = env::var("DB_NAME").unwrap_or_else(|_| "pos".to_string());
|
||||
let db_user = env::var("DB_USER").unwrap_or_else(|_| "postgres".to_string());
|
||||
let db_password = env::var("DB_PASSWORD").unwrap_or_else(|_| "postgres".to_string());
|
||||
let db_schema = env::var("DB_SCHEMA").unwrap_or_else(|_| "pos".to_string());
|
||||
|
||||
let jwt_secret = env::var("JWT_SECRET").map_err(|_| "JWT_SECRET is required".to_string())?;
|
||||
|
||||
let jwt_access_ttl_minutes = env::var("JWT_ACCESS_TTL_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse::<i64>()
|
||||
.map_err(|_| "JWT_ACCESS_TTL_MINUTES must be a valid i64".to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
app_host,
|
||||
app_port,
|
||||
database_url,
|
||||
db_host,
|
||||
db_port,
|
||||
db_name,
|
||||
db_user,
|
||||
db_password,
|
||||
db_schema,
|
||||
jwt_secret,
|
||||
jwt_access_ttl_minutes,
|
||||
})
|
||||
}
|
||||
}
|
||||
72
src/db.rs
Normal file
72
src/db.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use sqlx::{
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
PgPool,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
pub async fn new_pool(cfg: &AppConfig) -> Result<PgPool, sqlx::Error> {
|
||||
let connect_options = if let Some(url) = &cfg.database_url {
|
||||
PgConnectOptions::from_str(url)?
|
||||
} else {
|
||||
|
||||
let schema = if cfg.db_schema.is_empty() {
|
||||
"public"
|
||||
} else {
|
||||
cfg.db_schema.as_str()
|
||||
};
|
||||
|
||||
let options = format!(
|
||||
"-c lc_messages=C -c client_encoding=UTF8 -c TimeZone=America/Caracas -c search_path={}",
|
||||
schema
|
||||
);
|
||||
|
||||
// Percent-encode básico para la query string (?options=...)
|
||||
let encoded_options = options
|
||||
.replace(' ', "%20")
|
||||
.replace('=', "%3D")
|
||||
.replace('/', "%2F");
|
||||
|
||||
let url = format!(
|
||||
"postgres://{}:{}@{}:{}/{}?options={}",
|
||||
cfg.db_user, cfg.db_password, cfg.db_host, cfg.db_port, cfg.db_name, encoded_options
|
||||
);
|
||||
|
||||
PgConnectOptions::from_str(&url)?
|
||||
};
|
||||
|
||||
let db_schema = cfg.db_schema.clone();
|
||||
|
||||
PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.min_connections(1)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.after_connect(move |conn, _meta| {
|
||||
let db_schema = db_schema.clone();
|
||||
Box::pin(async move {
|
||||
let conn = conn;
|
||||
// Mensajes en ASCII para evitar errores de decodificación
|
||||
sqlx::query("SET lc_messages = 'C';")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
// Force UTF-8 on the client to avoid decoding issues
|
||||
sqlx::query("SET client_encoding = 'UTF8';")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query("SET TIME ZONE 'America/Caracas';")
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
// Search_path configurable (por defecto "pos")
|
||||
if !db_schema.is_empty() {
|
||||
let query = format!("SET search_path = {};", db_schema);
|
||||
sqlx::query(&query).execute(&mut *conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
}
|
||||
44
src/main.rs
Normal file
44
src/main.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
mod config;
|
||||
mod db;
|
||||
|
||||
mod middleware;
|
||||
mod modules;
|
||||
|
||||
|
||||
use actix_web::{middleware::Logger, web, App, HttpServer};
|
||||
use config::AppConfig;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = AppConfig::from_env().expect("Failed to load config");
|
||||
let pool = db::new_pool(&cfg).await.expect("DB pool error");
|
||||
let bind_host = cfg.app_host.clone();
|
||||
let bind_port = cfg.app_port;
|
||||
|
||||
tracing::info!(
|
||||
host = %cfg.app_host,
|
||||
port = cfg.app_port,
|
||||
"Starting server"
|
||||
);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.app_data(web::Data::new(cfg.clone()))
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.configure(modules::auth::routes::configure)
|
||||
.configure(modules::users::routes::configure)
|
||||
})
|
||||
.bind((bind_host.as_str(), bind_port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
9
src/middleware/authz.rs
Normal file
9
src/middleware/authz.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use super::{errors::ApiAuthError, jwt::AuthenticatedUser};
|
||||
|
||||
pub fn require_roles(user: &AuthenticatedUser, allowed: &[&str]) -> Result<(), ApiAuthError> {
|
||||
if allowed.iter().any(|r| *r == user.role_code) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiAuthError::Forbidden)
|
||||
}
|
||||
}
|
||||
97
src/middleware/errors.rs
Normal file
97
src/middleware/errors.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
/// Mensaje para mostrar en la UI
|
||||
error: String,
|
||||
/// Código estable para que el frontend lo maneje
|
||||
code: &'static str,
|
||||
/// Detalle opcional
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiAuthError {
|
||||
// 401
|
||||
#[error("No autenticado")]
|
||||
MissingAuthorization,
|
||||
|
||||
// 401
|
||||
#[error("No autenticado")]
|
||||
InvalidAuthorizationHeader,
|
||||
|
||||
// 401
|
||||
#[error("No autenticado")]
|
||||
InvalidBearerToken,
|
||||
|
||||
// 401
|
||||
#[error("No autenticado")]
|
||||
InvalidToken,
|
||||
|
||||
// 403
|
||||
#[error("No autorizado")]
|
||||
Forbidden,
|
||||
|
||||
// 500 (misconfig interna)
|
||||
#[error("Ocurrió un error inesperado")]
|
||||
MissingAppConfig,
|
||||
}
|
||||
|
||||
impl ApiAuthError {
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
ApiAuthError::MissingAuthorization => "missing_authorization",
|
||||
ApiAuthError::InvalidAuthorizationHeader => "invalid_authorization_header",
|
||||
ApiAuthError::InvalidBearerToken => "invalid_bearer_token",
|
||||
ApiAuthError::InvalidToken => "invalid_token",
|
||||
ApiAuthError::Forbidden => "forbidden",
|
||||
ApiAuthError::MissingAppConfig => "internal_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(&self) -> Option<String> {
|
||||
match self {
|
||||
ApiAuthError::MissingAuthorization => {
|
||||
Some("Falta el header Authorization: Bearer <token>.".to_string())
|
||||
}
|
||||
ApiAuthError::InvalidAuthorizationHeader => {
|
||||
Some("El header Authorization no tiene un formato válido.".to_string())
|
||||
}
|
||||
ApiAuthError::InvalidBearerToken => {
|
||||
Some("Se esperaba 'Bearer <token>'.".to_string())
|
||||
}
|
||||
ApiAuthError::InvalidToken => Some("Token inválido o expirado.".to_string()),
|
||||
ApiAuthError::Forbidden => Some("No tienes permisos para esta acción.".to_string()),
|
||||
ApiAuthError::MissingAppConfig => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiAuthError::MissingAuthorization
|
||||
| ApiAuthError::InvalidAuthorizationHeader
|
||||
| ApiAuthError::InvalidBearerToken
|
||||
| ApiAuthError::InvalidToken => StatusCode::UNAUTHORIZED,
|
||||
ApiAuthError::Forbidden => StatusCode::FORBIDDEN,
|
||||
ApiAuthError::MissingAppConfig => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for ApiAuthError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.status()
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let body = ErrorBody {
|
||||
error: self.to_string(),
|
||||
code: self.code(),
|
||||
detail: self.detail(),
|
||||
};
|
||||
HttpResponse::build(self.status_code()).json(body)
|
||||
}
|
||||
}
|
||||
143
src/middleware/jwt.rs
Normal file
143
src/middleware/jwt.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
// use actix_web::{
|
||||
// dev::Payload, error::ErrorUnauthorized, http::header, FromRequest, HttpRequest,
|
||||
// };
|
||||
// use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
// use serde::{Deserialize, Serialize};
|
||||
// use std::future::{ready, Ready};
|
||||
|
||||
// use crate::config::AppConfig;
|
||||
|
||||
// #[derive(Debug, Serialize, Deserialize)]
|
||||
// pub struct JwtClaims {
|
||||
// pub sub: i64,
|
||||
// pub company_id: i64,
|
||||
// pub role_code: String,
|
||||
// pub exp: usize,
|
||||
// }
|
||||
|
||||
// #[derive(Debug, Clone)]
|
||||
// pub struct AuthenticatedUser {
|
||||
// pub user_id: i64,
|
||||
// pub company_id: i64,
|
||||
// pub role_code: String,
|
||||
// }
|
||||
|
||||
// impl FromRequest for AuthenticatedUser {
|
||||
// type Error = actix_web::Error;
|
||||
// type Future = Ready<Result<Self, Self::Error>>;
|
||||
|
||||
// fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||
// let cfg = match req.app_data::<actix_web::web::Data<AppConfig>>() {
|
||||
// Some(c) => c.clone(),
|
||||
// None => return ready(Err(ErrorUnauthorized("Missing app config"))),
|
||||
// };
|
||||
|
||||
// let auth_header = match req.headers().get(header::AUTHORIZATION) {
|
||||
// Some(h) => h,
|
||||
// None => return ready(Err(ErrorUnauthorized("Missing Authorization header"))),
|
||||
// };
|
||||
|
||||
// let auth_str = match auth_header.to_str() {
|
||||
// Ok(s) => s,
|
||||
// Err(_) => return ready(Err(ErrorUnauthorized("Invalid Authorization header"))),
|
||||
// };
|
||||
|
||||
// let token = auth_str
|
||||
// .strip_prefix("Bearer ")
|
||||
// .or_else(|| auth_str.strip_prefix("bearer "))
|
||||
// .map(|s| s.trim());
|
||||
|
||||
// let token = match token {
|
||||
// Some(t) if !t.is_empty() => t,
|
||||
// _ => return ready(Err(ErrorUnauthorized("Invalid Bearer token"))),
|
||||
// };
|
||||
|
||||
// let decoded = decode::<JwtClaims>(
|
||||
// token,
|
||||
// &DecodingKey::from_secret(cfg.jwt_secret.as_bytes()),
|
||||
// &Validation::default(),
|
||||
// )
|
||||
// .map_err(|_| ErrorUnauthorized("Invalid token"));
|
||||
|
||||
// match decoded {
|
||||
// Ok(data) => ready(Ok(AuthenticatedUser {
|
||||
// user_id: data.claims.sub,
|
||||
// company_id: data.claims.company_id,
|
||||
// role_code: data.claims.role_code,
|
||||
// })),
|
||||
// Err(e) => ready(Err(e)),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
use actix_web::{dev::Payload, FromRequest, HttpRequest};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::future::{ready, Ready};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
use crate::middleware::errors::ApiAuthError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: i64,
|
||||
pub company_id: i64,
|
||||
pub role_code: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: i64,
|
||||
pub company_id: i64,
|
||||
pub role_code: String,
|
||||
}
|
||||
|
||||
impl FromRequest for AuthenticatedUser {
|
||||
type Error = ApiAuthError;
|
||||
type Future = Ready<Result<Self, Self::Error>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||
let cfg = match req.app_data::<actix_web::web::Data<AppConfig>>() {
|
||||
Some(c) => c.clone(),
|
||||
None => return ready(Err(ApiAuthError::MissingAppConfig)),
|
||||
};
|
||||
|
||||
let auth_header = match req.headers().get(actix_web::http::header::AUTHORIZATION) {
|
||||
Some(h) => h,
|
||||
None => return ready(Err(ApiAuthError::MissingAuthorization)),
|
||||
};
|
||||
|
||||
let auth_str = match auth_header.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return ready(Err(ApiAuthError::InvalidAuthorizationHeader)),
|
||||
};
|
||||
|
||||
let token = auth_str
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_str.strip_prefix("bearer "))
|
||||
.map(|s| s.trim());
|
||||
|
||||
let token = match token {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return ready(Err(ApiAuthError::InvalidBearerToken)),
|
||||
};
|
||||
|
||||
let decoded = decode::<JwtClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(cfg.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| ApiAuthError::InvalidToken);
|
||||
|
||||
match decoded {
|
||||
Ok(data) => ready(Ok(AuthenticatedUser {
|
||||
user_id: data.claims.sub,
|
||||
company_id: data.claims.company_id,
|
||||
role_code: data.claims.role_code,
|
||||
})),
|
||||
Err(e) => ready(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/middleware/mod.rs
Normal file
3
src/middleware/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod jwt;
|
||||
pub mod errors;
|
||||
pub mod authz;
|
||||
85
src/modules/auth/errors.rs
Normal file
85
src/modules/auth/errors.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
/// Mensaje para mostrar en la UI
|
||||
error: String,
|
||||
/// Código estable para que el frontend lo maneje (i18n, mensajes custom, etc.)
|
||||
code: &'static str,
|
||||
/// Detalle opcional (ideal para soporte; NO técnico para el usuario)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
// 400
|
||||
#[error("Datos inválidos")]
|
||||
BadRequest,
|
||||
|
||||
// 401
|
||||
#[error("Usuario o contraseña incorrectos")]
|
||||
Unauthorized,
|
||||
|
||||
// 403 (opcional pero recomendado, mejor UX)
|
||||
#[error("Usuario desactivado")]
|
||||
UserInactive,
|
||||
|
||||
// 500 (configuración/semilla mal hecha)
|
||||
#[error("No se puede iniciar sesión por un problema de configuración")]
|
||||
InvalidPasswordHash,
|
||||
|
||||
// 500
|
||||
#[error("Ocurrió un error inesperado")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl AuthError {
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
AuthError::BadRequest => "bad_request",
|
||||
AuthError::Unauthorized => "invalid_credentials",
|
||||
AuthError::UserInactive => "user_inactive",
|
||||
AuthError::InvalidPasswordHash => "user_misconfigured",
|
||||
AuthError::Internal => "internal_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(&self) -> Option<String> {
|
||||
match self {
|
||||
AuthError::BadRequest => Some("Revisa los campos requeridos e inténtalo nuevamente.".to_string()),
|
||||
AuthError::Unauthorized => Some("Verifica la empresa, el usuario y la contraseña.".to_string()),
|
||||
AuthError::UserInactive => Some("Contacta al administrador para reactivar tu cuenta.".to_string()),
|
||||
// OJO: este detalle es para SOPORTE, no para el usuario final.
|
||||
AuthError::InvalidPasswordHash => Some("Soporte: la contraseña almacenada no tiene un formato válido.".to_string()),
|
||||
AuthError::Internal => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
AuthError::BadRequest => StatusCode::BAD_REQUEST,
|
||||
AuthError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
AuthError::UserInactive => StatusCode::FORBIDDEN,
|
||||
AuthError::InvalidPasswordHash => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for AuthError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.status()
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let body = ErrorBody {
|
||||
error: self.to_string(),
|
||||
code: self.code(),
|
||||
detail: self.detail(),
|
||||
};
|
||||
HttpResponse::build(self.status_code()).json(body)
|
||||
}
|
||||
}
|
||||
54
src/modules/auth/handlers.rs
Normal file
54
src/modules/auth/handlers.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::middleware::{
|
||||
authz::require_roles,
|
||||
errors::ApiAuthError,
|
||||
jwt::AuthenticatedUser,
|
||||
};
|
||||
|
||||
use super::{
|
||||
errors::AuthError,
|
||||
models::{LoginRequest, LoginResponse},
|
||||
service,
|
||||
};
|
||||
|
||||
pub async fn login(
|
||||
pool: web::Data<PgPool>,
|
||||
cfg: web::Data<AppConfig>,
|
||||
body: web::Json<LoginRequest>,
|
||||
) -> Result<HttpResponse, AuthError> {
|
||||
let req = body.into_inner();
|
||||
|
||||
// Validaciones (400)
|
||||
let company_id = req.company_id.ok_or(AuthError::BadRequest)?;
|
||||
let username = req
|
||||
.username
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(AuthError::BadRequest)?;
|
||||
let password = req
|
||||
.password
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(AuthError::BadRequest)?;
|
||||
|
||||
let resp: LoginResponse =
|
||||
service::login(&pool, &cfg, company_id, &username, &password).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(resp))
|
||||
}
|
||||
|
||||
pub async fn me(user: AuthenticatedUser) -> HttpResponse {
|
||||
HttpResponse::Ok().json(user)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct OkResp {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub async fn admin_ping(user: AuthenticatedUser) -> Result<HttpResponse, ApiAuthError> {
|
||||
require_roles(&user, &["admin"])?;
|
||||
Ok(HttpResponse::Ok().json(OkResp { ok: true }))
|
||||
}
|
||||
5
src/modules/auth/mod.rs
Normal file
5
src/modules/auth/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod routes;
|
||||
mod handlers;
|
||||
mod service;
|
||||
mod models;
|
||||
mod errors;
|
||||
24
src/modules/auth/models.rs
Normal file
24
src/modules/auth/models.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub company_id: Option<i64>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub user: UserPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserPayload {
|
||||
pub id: i64,
|
||||
pub full_name: String,
|
||||
pub username: String,
|
||||
pub role_code: String,
|
||||
pub company_id: i64,
|
||||
}
|
||||
12
src/modules/auth/routes.rs
Normal file
12
src/modules/auth/routes.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use actix_web::web;
|
||||
|
||||
use super::handlers;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/auth")
|
||||
.route("/login", web::post().to(handlers::login))
|
||||
.route("/me", web::get().to(handlers::me))
|
||||
.route("/admin/ping", web::get().to(handlers::admin_ping)),
|
||||
);
|
||||
}
|
||||
199
src/modules/auth/service.rs
Normal file
199
src/modules/auth/service.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use password_hash::{PasswordHash, PasswordVerifier};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
use super::{
|
||||
errors::AuthError,
|
||||
models::{LoginResponse, UserPayload},
|
||||
};
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DbUserRow {
|
||||
id: i64,
|
||||
company_id: i64,
|
||||
username: String,
|
||||
full_name: String,
|
||||
password_hash: String,
|
||||
is_active: bool,
|
||||
role_code: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JwtClaims {
|
||||
sub: i64,
|
||||
company_id: i64,
|
||||
role_code: String,
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
pool: &PgPool,
|
||||
cfg: &AppConfig,
|
||||
company_id: i64,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<LoginResponse, AuthError> {
|
||||
let mut tx: Transaction<Postgres> = pool.begin().await.map_err(|e| {
|
||||
error!(error = %e, "Failed to begin transaction");
|
||||
AuthError::Internal
|
||||
})?;
|
||||
|
||||
// -------------------------
|
||||
// SQL exacto (1): Buscar user + role_code
|
||||
// -------------------------
|
||||
// Requisito: buscar por (company_id, username) y traer role_code desde pos.role
|
||||
//
|
||||
// (SQL exacto)
|
||||
// SELECT u.id, u.company_id, u.username, u.full_name, u.password_hash, u.is_active, r.code AS role_code
|
||||
// FROM pos.app_user u
|
||||
// JOIN pos.role r ON r.id = u.role_id
|
||||
// WHERE u.company_id = $1 AND u.username = $2
|
||||
// LIMIT 1;
|
||||
//
|
||||
let row: Option<DbUserRow> = sqlx::query_as::<_, DbUserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
u.id,
|
||||
u.company_id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.password_hash,
|
||||
u.is_active,
|
||||
r.code AS role_code
|
||||
FROM pos.app_user u
|
||||
JOIN pos.role r ON r.id = u.role_id
|
||||
WHERE u.company_id = $1 AND u.username = $2
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "DB query failed (login select)");
|
||||
AuthError::Internal
|
||||
})?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => DbUserRow {
|
||||
id: r.id,
|
||||
company_id: r.company_id,
|
||||
username: r.username,
|
||||
full_name: r.full_name,
|
||||
password_hash: r.password_hash,
|
||||
is_active: r.is_active,
|
||||
role_code: r.role_code,
|
||||
},
|
||||
None => {
|
||||
// rollback explícito antes de responder 401
|
||||
let _ = tx.rollback().await;
|
||||
info!(
|
||||
company_id = company_id,
|
||||
username = username,
|
||||
"Login failed: user not found"
|
||||
);
|
||||
return Err(AuthError::Unauthorized);
|
||||
}
|
||||
};
|
||||
|
||||
// Requisito: 401 si is_active=false
|
||||
if !row.is_active {
|
||||
let _ = tx.rollback().await;
|
||||
info!(user_id = row.id, "Login blocked: user inactive");
|
||||
return Err(AuthError::UserInactive);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Verificar password_hash (Argon2)
|
||||
// -------------------------
|
||||
// El hash en DB debe ser string compatible PHC (ej: $argon2id$v=19$m=...,t=...,p=...$salt$hash)
|
||||
let parsed_hash = match PasswordHash::new(&row.password_hash) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = tx.rollback().await;
|
||||
warn!(user_id = row.id, error = %e, "Invalid password_hash format in DB");
|
||||
return Err(AuthError::InvalidPasswordHash);
|
||||
}
|
||||
};
|
||||
|
||||
let verifier = argon2::Argon2::default();
|
||||
let ok = verifier
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
|
||||
if !ok {
|
||||
let _ = tx.rollback().await;
|
||||
info!(user_id = row.id, "Login failed: wrong password");
|
||||
return Err(AuthError::Unauthorized);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// SQL exacto (2): actualizar last_login_at = now()
|
||||
// -------------------------
|
||||
// (SQL exacto)
|
||||
// UPDATE pos.app_user
|
||||
// SET last_login_at = now(), updated_at = now()
|
||||
// WHERE id = $1;
|
||||
//
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE pos.app_user
|
||||
SET last_login_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
"#
|
||||
)
|
||||
.bind(row.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "DB update failed (last_login_at)");
|
||||
AuthError::Internal
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to commit transaction");
|
||||
AuthError::Internal
|
||||
})?;
|
||||
|
||||
// -------------------------
|
||||
// JWT access token
|
||||
// -------------------------
|
||||
let now = Utc::now();
|
||||
let exp = (now + Duration::minutes(cfg.jwt_access_ttl_minutes)).timestamp() as usize;
|
||||
|
||||
let claims = JwtClaims {
|
||||
sub: row.id,
|
||||
company_id: row.company_id,
|
||||
role_code: row.role_code.clone(),
|
||||
exp,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(cfg.jwt_secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "JWT encode failed");
|
||||
AuthError::Internal
|
||||
})?;
|
||||
|
||||
Ok(LoginResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
user: UserPayload {
|
||||
id: row.id,
|
||||
full_name: row.full_name,
|
||||
username: row.username,
|
||||
role_code: row.role_code,
|
||||
company_id: row.company_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
2
src/modules/mod.rs
Normal file
2
src/modules/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod users;
|
||||
91
src/modules/users/errors.rs
Normal file
91
src/modules/users/errors.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
code: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UsersError {
|
||||
#[error("Datos inválidos")]
|
||||
BadRequest,
|
||||
|
||||
#[error("Usuario no encontrado")]
|
||||
NotFound,
|
||||
|
||||
#[error("El usuario ya existe")]
|
||||
UsernameTaken,
|
||||
|
||||
#[error("Rol no válido")]
|
||||
RoleNotFound,
|
||||
|
||||
#[error("No autorizado")]
|
||||
Forbidden,
|
||||
|
||||
#[error("Ocurrió un error inesperado")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl UsersError {
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
UsersError::BadRequest => "bad_request",
|
||||
UsersError::NotFound => "user_not_found",
|
||||
UsersError::UsernameTaken => "username_taken",
|
||||
UsersError::RoleNotFound => "role_not_found",
|
||||
UsersError::Forbidden => "forbidden",
|
||||
UsersError::Internal => "internal_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(&self) -> Option<String> {
|
||||
match self {
|
||||
UsersError::BadRequest => Some("Revisa los campos requeridos e inténtalo nuevamente.".to_string()),
|
||||
UsersError::NotFound => Some("No existe un usuario con ese id en tu empresa.".to_string()),
|
||||
UsersError::UsernameTaken => Some("Ya existe un usuario con ese username en esta empresa.".to_string()),
|
||||
UsersError::RoleNotFound => Some("El role_id indicado no existe.".to_string()),
|
||||
UsersError::Forbidden => Some("No tienes permisos para esta acción.".to_string()),
|
||||
UsersError::Internal => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
UsersError::BadRequest | UsersError::RoleNotFound => StatusCode::BAD_REQUEST,
|
||||
UsersError::NotFound => StatusCode::NOT_FOUND,
|
||||
UsersError::UsernameTaken => StatusCode::CONFLICT,
|
||||
UsersError::Forbidden => StatusCode::FORBIDDEN,
|
||||
UsersError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseError for UsersError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.status()
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let body = ErrorBody {
|
||||
error: self.to_string(),
|
||||
code: self.code(),
|
||||
detail: self.detail(),
|
||||
};
|
||||
HttpResponse::build(self.status_code()).json(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Para poder usar require_roles() dentro de handlers y retornar UsersError
|
||||
impl From<crate::middleware::errors::ApiAuthError> for UsersError {
|
||||
fn from(e: crate::middleware::errors::ApiAuthError) -> Self {
|
||||
match e {
|
||||
crate::middleware::errors::ApiAuthError::Forbidden => UsersError::Forbidden,
|
||||
_ => UsersError::Internal,
|
||||
}
|
||||
}
|
||||
}
|
||||
141
src/modules/users/handlers.rs
Normal file
141
src/modules/users/handlers.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::middleware::{authz::require_roles, jwt::AuthenticatedUser};
|
||||
|
||||
use super::{
|
||||
errors::UsersError,
|
||||
models::{CreateUserRequest, ResetPasswordRequest, UpdateUserRequest},
|
||||
service,
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct OkResp {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<HttpResponse, UsersError> {
|
||||
let users = service::list_users(&pool, user.company_id).await?;
|
||||
Ok(HttpResponse::Ok().json(users))
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
path: web::Path<i64>,
|
||||
) -> Result<HttpResponse, UsersError> {
|
||||
let user_id = path.into_inner();
|
||||
let u = service::get_user(&pool, user.company_id, user_id).await?;
|
||||
Ok(HttpResponse::Ok().json(u))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
body: web::Json<CreateUserRequest>,
|
||||
) -> Result<HttpResponse, UsersError> {
|
||||
// RBAC - opción B (MAYÚSCULA)
|
||||
require_roles(&user, &["ADMIN"])?;
|
||||
|
||||
let req = body.into_inner();
|
||||
|
||||
let username = req
|
||||
.username
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(UsersError::BadRequest)?;
|
||||
|
||||
let full_name = req
|
||||
.full_name
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(UsersError::BadRequest)?;
|
||||
|
||||
let email = req
|
||||
.email
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let password = req
|
||||
.password
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(UsersError::BadRequest)?;
|
||||
|
||||
let role_id = req.role_id.ok_or(UsersError::BadRequest)?;
|
||||
let is_active = req.is_active.unwrap_or(true);
|
||||
|
||||
let created = service::create_user(
|
||||
&pool,
|
||||
user.company_id,
|
||||
&username,
|
||||
&full_name,
|
||||
email.as_deref(),
|
||||
&password,
|
||||
role_id,
|
||||
is_active,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Created().json(created))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<UpdateUserRequest>,
|
||||
) -> Result<HttpResponse, UsersError> {
|
||||
require_roles(&user, &["ADMIN"])?;
|
||||
|
||||
let user_id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
|
||||
let full_name = req
|
||||
.full_name
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let email = req
|
||||
.email
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let updated = service::update_user(
|
||||
&pool,
|
||||
user.company_id,
|
||||
user_id,
|
||||
full_name,
|
||||
email,
|
||||
req.role_id,
|
||||
req.is_active,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(updated))
|
||||
}
|
||||
|
||||
pub async fn reset_password(
|
||||
pool: web::Data<PgPool>,
|
||||
user: AuthenticatedUser,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<ResetPasswordRequest>,
|
||||
) -> Result<HttpResponse, UsersError> {
|
||||
require_roles(&user, &["ADMIN"])?;
|
||||
|
||||
let user_id = path.into_inner();
|
||||
let req = body.into_inner();
|
||||
|
||||
let new_password = req
|
||||
.new_password
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(UsersError::BadRequest)?;
|
||||
|
||||
service::reset_password(&pool, user.company_id, user_id, &new_password).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(OkResp { ok: true }))
|
||||
}
|
||||
5
src/modules/users/mod.rs
Normal file
5
src/modules/users/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod routes;
|
||||
mod handlers;
|
||||
mod service;
|
||||
mod models;
|
||||
mod errors;
|
||||
35
src/modules/users/models.rs
Normal file
35
src/modules/users/models.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserDto {
|
||||
pub id: i64,
|
||||
pub company_id: i64,
|
||||
pub username: String,
|
||||
pub full_name: String,
|
||||
pub email: Option<String>,
|
||||
pub role_code: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: Option<String>,
|
||||
pub full_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub role_id: Option<i64>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub full_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub role_id: Option<i64>,
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub new_password: Option<String>,
|
||||
}
|
||||
14
src/modules/users/routes.rs
Normal file
14
src/modules/users/routes.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use actix_web::web;
|
||||
|
||||
use super::handlers;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/users")
|
||||
.route("", web::get().to(handlers::list))
|
||||
.route("", web::post().to(handlers::create))
|
||||
.route("/{id}", web::get().to(handlers::get_by_id))
|
||||
.route("/{id}", web::put().to(handlers::update))
|
||||
.route("/{id}/password", web::put().to(handlers::reset_password)),
|
||||
);
|
||||
}
|
||||
259
src/modules/users/service.rs
Normal file
259
src/modules/users/service.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use argon2::{Argon2, PasswordHasher};
|
||||
use password_hash::{rand_core::OsRng, SaltString};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use crate::modules::users::{errors::UsersError, models::UserDto};
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DbUserRow {
|
||||
id: i64,
|
||||
company_id: i64,
|
||||
username: String,
|
||||
full_name: String,
|
||||
email: Option<String>,
|
||||
is_active: bool,
|
||||
role_code: String,
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, UsersError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
let hashed = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|_| UsersError::Internal)?;
|
||||
|
||||
Ok(hashed.to_string()) // PHC string
|
||||
}
|
||||
|
||||
fn map_db_error(e: sqlx::Error) -> UsersError {
|
||||
// 23505 = unique_violation
|
||||
if let sqlx::Error::Database(db_err) = &e {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return UsersError::UsernameTaken;
|
||||
}
|
||||
}
|
||||
UsersError::Internal
|
||||
}
|
||||
|
||||
pub async fn list_users(pool: &PgPool, company_id: i64) -> Result<Vec<UserDto>, UsersError> {
|
||||
let rows: Vec<DbUserRow> = sqlx::query_as::<_, DbUserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
u.id,
|
||||
u.company_id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.email,
|
||||
u.is_active,
|
||||
r.code AS role_code
|
||||
FROM pos.app_user u
|
||||
JOIN pos.role r ON r.id = u.role_id
|
||||
WHERE u.company_id = $1
|
||||
ORDER BY u.id
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| UserDto {
|
||||
id: r.id,
|
||||
company_id: r.company_id,
|
||||
username: r.username,
|
||||
full_name: r.full_name,
|
||||
email: r.email,
|
||||
role_code: r.role_code,
|
||||
is_active: r.is_active,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_user(pool: &PgPool, company_id: i64, user_id: i64) -> Result<UserDto, UsersError> {
|
||||
let row: Option<DbUserRow> = sqlx::query_as::<_, DbUserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
u.id,
|
||||
u.company_id,
|
||||
u.username,
|
||||
u.full_name,
|
||||
u.email,
|
||||
u.is_active,
|
||||
r.code AS role_code
|
||||
FROM pos.app_user u
|
||||
JOIN pos.role r ON r.id = u.role_id
|
||||
WHERE u.company_id = $1 AND u.id = $2
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(UserDto {
|
||||
id: r.id,
|
||||
company_id: r.company_id,
|
||||
username: r.username,
|
||||
full_name: r.full_name,
|
||||
email: r.email,
|
||||
role_code: r.role_code,
|
||||
is_active: r.is_active,
|
||||
}),
|
||||
None => Err(UsersError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
pool: &PgPool,
|
||||
company_id: i64,
|
||||
username: &str,
|
||||
full_name: &str,
|
||||
email: Option<&str>,
|
||||
password: &str,
|
||||
role_id: i64,
|
||||
is_active: bool,
|
||||
) -> Result<UserDto, UsersError> {
|
||||
let mut tx: Transaction<Postgres> = pool.begin().await.map_err(map_db_error)?;
|
||||
|
||||
// Validar rol existe
|
||||
let role_exists: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM pos.role
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(role_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if role_exists.is_none() {
|
||||
let _ = tx.rollback().await;
|
||||
return Err(UsersError::RoleNotFound);
|
||||
}
|
||||
|
||||
let password_hash = hash_password(password)?;
|
||||
|
||||
let new_id: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO pos.app_user (company_id, username, full_name, email, password_hash, role_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(username)
|
||||
.bind(full_name)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.bind(role_id)
|
||||
.bind(is_active)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
tx.commit().await.map_err(map_db_error)?;
|
||||
|
||||
get_user(pool, company_id, new_id).await
|
||||
}
|
||||
|
||||
pub async fn update_user(
|
||||
pool: &PgPool,
|
||||
company_id: i64,
|
||||
user_id: i64,
|
||||
full_name: Option<&str>,
|
||||
email: Option<&str>,
|
||||
role_id: Option<i64>,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<UserDto, UsersError> {
|
||||
let mut tx: Transaction<Postgres> = pool.begin().await.map_err(map_db_error)?;
|
||||
|
||||
if let Some(rid) = role_id {
|
||||
let role_exists: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM pos.role
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(rid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if role_exists.is_none() {
|
||||
let _ = tx.rollback().await;
|
||||
return Err(UsersError::RoleNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE pos.app_user
|
||||
SET
|
||||
full_name = COALESCE($3, full_name),
|
||||
email = COALESCE($4, email),
|
||||
role_id = COALESCE($5, role_id),
|
||||
is_active = COALESCE($6, is_active),
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(user_id)
|
||||
.bind(full_name)
|
||||
.bind(email)
|
||||
.bind(role_id)
|
||||
.bind(is_active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let _ = tx.rollback().await;
|
||||
return Err(UsersError::NotFound);
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(map_db_error)?;
|
||||
|
||||
get_user(pool, company_id, user_id).await
|
||||
}
|
||||
|
||||
pub async fn reset_password(
|
||||
pool: &PgPool,
|
||||
company_id: i64,
|
||||
user_id: i64,
|
||||
new_password: &str,
|
||||
) -> Result<(), UsersError> {
|
||||
let password_hash = hash_password(new_password)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE pos.app_user
|
||||
SET password_hash = $3,
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(company_id)
|
||||
.bind(user_id)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_db_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(UsersError::NotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user