This commit is contained in:
2026-01-23 21:50:03 -04:00
commit f3c93a78f0
23 changed files with 4409 additions and 0 deletions

16
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

26
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
pub mod jwt;
pub mod errors;
pub mod authz;

View 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)
}
}

View 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
View File

@@ -0,0 +1,5 @@
pub mod routes;
mod handlers;
mod service;
mod models;
mod errors;

View 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,
}

View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod auth;
pub mod users;

View 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,
}
}
}

View 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
View File

@@ -0,0 +1,5 @@
pub mod routes;
mod handlers;
mod service;
mod models;
mod errors;

View 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>,
}

View 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)),
);
}

View 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(())
}