feat: migrate imphnen-backend-qr into workspace as imphnen-qr crate
Ports the Go QR campaign overlay service to a self-contained Rust crate nested at /v1/qr/... in the gateway. Features: - Auth: register, login, Google OAuth, JWT refresh (bcrypt compat with Go DB) - Users: profile management + admin CRUD (list/role/delete) - Campaigns: create (auto-generates QR PNG via qrcode crate), list, activate, delete; process-image endpoint overlays active campaign QR onto uploaded images (bottom-right corner, image crate) - QR pool connects to imphnen_qr database via QR_DATABASE_URL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5715e75593
commit
4bba182ea3
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "imphnen-qr"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
imphnen-utils.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
bcrypt.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
sqlx.workspace = true
|
||||
reqwest.workspace = true
|
||||
oauth2.workspace = true
|
||||
tracing.workspace = true
|
||||
utoipa.workspace = true
|
||||
image.workspace = true
|
||||
qrcode.workspace = true
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use sqlx::PgPool;
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use crate::common::qr_jwt::QrJwtService;
|
||||
use crate::config::QrConfig;
|
||||
use super::super::domain::service::{QrAuthService, AuthTokens, QrUserData};
|
||||
|
||||
pub struct QrAuthServiceImpl {
|
||||
pool: Arc<PgPool>,
|
||||
jwt: Arc<QrJwtService>,
|
||||
config: Arc<QrConfig>,
|
||||
}
|
||||
|
||||
impl QrAuthServiceImpl {
|
||||
pub fn new(pool: Arc<PgPool>, jwt: Arc<QrJwtService>, config: Arc<QrConfig>) -> Self {
|
||||
Self { pool, jwt, config }
|
||||
}
|
||||
|
||||
async fn find_user_by_id(&self, id: Uuid) -> Result<QrUserData, AppError> {
|
||||
sqlx::query_as::<_, QrUserData>(
|
||||
"SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
|
||||
}
|
||||
|
||||
async fn find_user_by_email(&self, email: &str) -> Result<Option<serde_json::Value>, AppError> {
|
||||
sqlx::query_scalar::<_, serde_json::Value>(
|
||||
"SELECT row_to_json(u) FROM (SELECT id, email, name, role, provider, password FROM users WHERE email = $1) u"
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
fn make_tokens(&self, user_id: Uuid, role: &str) -> Result<AuthTokens, AppError> {
|
||||
Ok(AuthTokens {
|
||||
access_token: self.jwt.generate_token(user_id, role)?,
|
||||
refresh_token: self.jwt.generate_refresh_token(user_id, role)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QrAuthService for QrAuthServiceImpl {
|
||||
async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError> {
|
||||
let existing = self.find_user_by_email(&email).await?;
|
||||
if existing.is_some() {
|
||||
return Err(AppError::ConflictError("Email already registered".to_string()));
|
||||
}
|
||||
let hashed = bcrypt::hash(&password, 10)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let user = sqlx::query_as::<_, QrUserData>(
|
||||
"INSERT INTO users (email, password, name, role, provider) VALUES ($1, $2, $3, 'user', 'local') RETURNING id, email, name, role, provider, created_at, updated_at"
|
||||
)
|
||||
.bind(&email)
|
||||
.bind(&hashed)
|
||||
.bind(&name)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let tokens = self.make_tokens(user.id, &user.role)?;
|
||||
Ok((tokens, user))
|
||||
}
|
||||
|
||||
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError> {
|
||||
let row = self.find_user_by_email(&email).await?
|
||||
.ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?;
|
||||
let provider = row["provider"].as_str().unwrap_or("local");
|
||||
if provider != "local" {
|
||||
return Err(AppError::AuthenticationError("Account uses social login".to_string()));
|
||||
}
|
||||
let stored_hash = row["password"].as_str()
|
||||
.ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?;
|
||||
let valid = bcrypt::verify(&password, stored_hash)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
if !valid {
|
||||
return Err(AppError::AuthenticationError("Invalid credentials".to_string()));
|
||||
}
|
||||
let user_id: Uuid = row["id"].as_str()
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
.ok_or_else(|| AppError::InternalServerError("Invalid user ID".to_string()))?;
|
||||
let user = self.find_user_by_id(user_id).await?;
|
||||
let tokens = self.make_tokens(user.id, &user.role)?;
|
||||
Ok((tokens, user))
|
||||
}
|
||||
|
||||
async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError> {
|
||||
let http = reqwest::Client::new();
|
||||
let token_res: serde_json::Value = http
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.form(&[
|
||||
("code", code.as_str()),
|
||||
("client_id", self.config.google_client_id.as_str()),
|
||||
("client_secret", self.config.google_client_secret.as_str()),
|
||||
("redirect_uri", self.config.google_redirect_url.as_str()),
|
||||
("grant_type", "authorization_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
if token_res.get("error").is_some() {
|
||||
return Err(AppError::BadRequestError("Google OAuth error".to_string()));
|
||||
}
|
||||
let access_token = token_res["access_token"].as_str()
|
||||
.ok_or_else(|| AppError::InternalServerError("Missing access token from Google".to_string()))?;
|
||||
let google_user: serde_json::Value = http
|
||||
.get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let email = google_user["email"].as_str()
|
||||
.ok_or_else(|| AppError::InternalServerError("Missing email from Google".to_string()))?;
|
||||
let name = google_user["name"].as_str().unwrap_or(email);
|
||||
let provider_id = google_user["id"].as_str().unwrap_or("");
|
||||
let user = sqlx::query_as::<_, QrUserData>(
|
||||
"INSERT INTO users (email, name, role, provider, provider_id) VALUES ($1, $2, 'user', 'google', $3)
|
||||
ON CONFLICT (email) DO UPDATE SET provider_id = EXCLUDED.provider_id, updated_at = NOW()
|
||||
RETURNING id, email, name, role, provider, created_at, updated_at"
|
||||
)
|
||||
.bind(email)
|
||||
.bind(name)
|
||||
.bind(provider_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let tokens = self.make_tokens(user.id, &user.role)?;
|
||||
Ok((tokens, user))
|
||||
}
|
||||
|
||||
async fn refresh_token(&self, refresh_token: String) -> Result<AuthTokens, AppError> {
|
||||
let claims = self.jwt.verify_token(&refresh_token)?;
|
||||
let user_id = Uuid::parse_str(&claims.sub)
|
||||
.map_err(|_| AppError::AuthenticationError("Invalid token subject".to_string()))?;
|
||||
let user = self.find_user_by_id(user_id).await?;
|
||||
Ok(AuthTokens {
|
||||
access_token: self.jwt.generate_token(user.id, &user.role)?,
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod auth_service;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod service;
|
||||
@@ -0,0 +1,30 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use imphnen_utils::errors::AppError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthTokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
|
||||
pub struct QrUserData {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub provider: String,
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait QrAuthService: Send + Sync {
|
||||
async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError>;
|
||||
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError>;
|
||||
async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError>;
|
||||
async fn refresh_token(&self, refresh_token: String) -> Result<AuthTokens, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use crate::auth::domain::service::QrUserData;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AuthResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub user: QrUserData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TokensResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use axum::{Extension, Json, response::IntoResponse};
|
||||
use axum::extract::Query;
|
||||
use std::sync::Arc;
|
||||
use serde::Deserialize;
|
||||
use imphnen_utils::response_format::ApiSuccess;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use crate::auth::domain::service::QrAuthService;
|
||||
use crate::config::QrConfig;
|
||||
use super::dto::{RegisterRequest, LoginRequest, RefreshRequest, AuthResponse, TokensResponse};
|
||||
|
||||
pub async fn register_handler(
|
||||
Extension(service): Extension<Arc<dyn QrAuthService>>,
|
||||
Json(body): Json<RegisterRequest>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let (tokens, user) = service.register(body.email, body.password, body.name).await?;
|
||||
Ok(ApiSuccess(AuthResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
user,
|
||||
}).into_response())
|
||||
}
|
||||
|
||||
pub async fn login_handler(
|
||||
Extension(service): Extension<Arc<dyn QrAuthService>>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let (tokens, user) = service.login(body.email, body.password).await?;
|
||||
Ok(ApiSuccess(AuthResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
user,
|
||||
}).into_response())
|
||||
}
|
||||
|
||||
pub async fn google_redirect_handler(
|
||||
Extension(config): Extension<Arc<QrConfig>>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let url = format!(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=email+profile",
|
||||
config.google_client_id,
|
||||
config.google_redirect_url,
|
||||
);
|
||||
Ok(axum::response::Redirect::temporary(&url).into_response())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GoogleCallbackQuery {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn google_callback_handler(
|
||||
Extension(service): Extension<Arc<dyn QrAuthService>>,
|
||||
Query(params): Query<GoogleCallbackQuery>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let (tokens, user) = service.google_callback(params.code).await?;
|
||||
Ok(ApiSuccess(AuthResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
user,
|
||||
}).into_response())
|
||||
}
|
||||
|
||||
pub async fn refresh_handler(
|
||||
Extension(service): Extension<Arc<dyn QrAuthService>>,
|
||||
Json(body): Json<RefreshRequest>,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let tokens = service.refresh_token(body.refresh_token).await?;
|
||||
Ok(ApiSuccess(TokensResponse {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
}).into_response())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,29 @@
|
||||
use axum::{routing::{get, post}, Extension, Router};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use crate::auth::application::auth_service::QrAuthServiceImpl;
|
||||
use crate::auth::domain::service::QrAuthService;
|
||||
use crate::common::qr_jwt::QrJwtService;
|
||||
use crate::config::QrConfig;
|
||||
use super::handlers::{
|
||||
register_handler,
|
||||
login_handler,
|
||||
google_redirect_handler,
|
||||
google_callback_handler,
|
||||
refresh_handler,
|
||||
};
|
||||
|
||||
pub fn qr_auth_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>, config: Arc<QrConfig>) -> Router {
|
||||
let service: Arc<dyn QrAuthService> = Arc::new(
|
||||
QrAuthServiceImpl::new(pool, jwt, config.clone())
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.route("/auth/register", post(register_handler))
|
||||
.route("/auth/login", post(login_handler))
|
||||
.route("/auth/google", get(google_redirect_handler))
|
||||
.route("/auth/google/callback", get(google_callback_handler))
|
||||
.route("/auth/refresh", post(refresh_handler))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension(config))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
@@ -0,0 +1,88 @@
|
||||
use async_trait::async_trait;
|
||||
use image::{DynamicImage, GenericImageView, ImageFormat, imageops};
|
||||
use imphnen_utils::errors::AppError;
|
||||
use qrcode::QrCode;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::campaigns::domain::{
|
||||
entity::{CampaignEntity, CreateCampaignInput},
|
||||
repository::CampaignRepository,
|
||||
service::QrCampaignService,
|
||||
};
|
||||
|
||||
pub struct QrCampaignServiceImpl {
|
||||
repo: Arc<dyn CampaignRepository>,
|
||||
}
|
||||
|
||||
impl QrCampaignServiceImpl {
|
||||
pub fn new(repo: Arc<dyn CampaignRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QrCampaignService for QrCampaignServiceImpl {
|
||||
async fn create(&self, name: String, url: String, created_by: Uuid) -> Result<CampaignEntity, AppError> {
|
||||
let qr = QrCode::new(url.as_bytes())
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
let qr_img = qr.render::<image::Luma<u8>>().min_dimensions(256, 256).build();
|
||||
let mut qr_bytes = Vec::new();
|
||||
DynamicImage::ImageLuma8(qr_img)
|
||||
.write_to(&mut Cursor::new(&mut qr_bytes), ImageFormat::Png)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let input = CreateCampaignInput {
|
||||
name,
|
||||
url,
|
||||
created_by,
|
||||
qr_code_data: qr_bytes,
|
||||
};
|
||||
self.repo.create(input).await
|
||||
}
|
||||
|
||||
async fn list_all(&self) -> Result<Vec<CampaignEntity>, AppError> {
|
||||
self.repo.find_all().await
|
||||
}
|
||||
|
||||
async fn get_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError> {
|
||||
self.repo.find_active_qr_data().await
|
||||
}
|
||||
|
||||
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError> {
|
||||
self.repo.set_active(id).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
|
||||
self.repo.delete(id).await
|
||||
}
|
||||
|
||||
async fn process_image(&self, image_bytes: Vec<u8>) -> Result<Vec<u8>, AppError> {
|
||||
let qr_data = self.repo.find_active_qr_data().await?
|
||||
.ok_or_else(|| AppError::NotFoundError("No active campaign".to_string()))?;
|
||||
|
||||
let img = image::load_from_memory(&image_bytes)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid image format".to_string()))?;
|
||||
|
||||
let qr_img = image::load_from_memory(&qr_data)
|
||||
.map_err(|_| AppError::InternalServerError("Failed to load QR data".to_string()))?;
|
||||
|
||||
let (w, h) = img.dimensions();
|
||||
let qr_size = (std::cmp::min(w, h) / 5).max(100);
|
||||
|
||||
let qr_resized = qr_img.resize_exact(qr_size, qr_size, imageops::FilterType::Nearest);
|
||||
|
||||
let mut output = img.to_rgba8();
|
||||
let x = (w - qr_size - 10) as i64;
|
||||
let y = (h - qr_size - 10) as i64;
|
||||
imageops::overlay(&mut output, &qr_resized.to_rgba8(), x, y);
|
||||
|
||||
let mut out_bytes = Vec::new();
|
||||
DynamicImage::ImageRgba8(output)
|
||||
.write_to(&mut Cursor::new(&mut out_bytes), ImageFormat::Png)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
Ok(out_bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod campaign_service;
|
||||
@@ -0,0 +1,24 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)]
|
||||
pub struct CampaignEntity {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub is_active: bool,
|
||||
pub created_by: Uuid,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct CreateCampaignInput {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub created_by: Uuid,
|
||||
pub qr_code_data: Vec<u8>,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod entity;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::entity::{CampaignEntity, CreateCampaignInput};
|
||||
|
||||
#[async_trait]
|
||||
pub trait CampaignRepository: Send + Sync {
|
||||
async fn create(&self, input: CreateCampaignInput) -> Result<CampaignEntity, AppError>;
|
||||
async fn find_all(&self) -> Result<Vec<CampaignEntity>, AppError>;
|
||||
async fn find_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError>;
|
||||
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::entity::CampaignEntity;
|
||||
|
||||
#[async_trait]
|
||||
pub trait QrCampaignService: Send + Sync {
|
||||
async fn create(&self, name: String, url: String, created_by: Uuid) -> Result<CampaignEntity, AppError>;
|
||||
async fn list_all(&self) -> Result<Vec<CampaignEntity>, AppError>;
|
||||
async fn get_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError>;
|
||||
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
async fn process_image(&self, image_bytes: Vec<u8>) -> Result<Vec<u8>, AppError>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateCampaignRequest {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct CampaignResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub is_active: bool,
|
||||
pub created_by: Uuid,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Path},
|
||||
response::{IntoResponse, Response},
|
||||
Extension, Json,
|
||||
};
|
||||
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
campaigns::{
|
||||
domain::service::QrCampaignService,
|
||||
infrastructure::http::dto::CreateCampaignRequest,
|
||||
},
|
||||
middleware::qr_auth::QrAuthUser,
|
||||
};
|
||||
|
||||
pub async fn create_campaign_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
Json(body): Json<CreateCampaignRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
let campaign = service.create(body.name, body.url, auth_user.user_id).await?;
|
||||
Ok(imphnen_utils::response_format::ApiCreated(campaign).into_response())
|
||||
}
|
||||
|
||||
pub async fn list_campaigns_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
let campaigns = service.list_all().await?;
|
||||
Ok(ApiSuccess(campaigns).into_response())
|
||||
}
|
||||
|
||||
pub async fn activate_campaign_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
let campaign = service.set_active(id).await?;
|
||||
Ok(ApiSuccess(campaign).into_response())
|
||||
}
|
||||
|
||||
pub async fn delete_campaign_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
service.delete(id).await?;
|
||||
Ok(imphnen_utils::response_format::ApiMessage::ok("Campaign deleted successfully").into_response())
|
||||
}
|
||||
|
||||
pub async fn process_image_handler(
|
||||
Extension(service): Extension<Arc<dyn QrCampaignService>>,
|
||||
Extension(_auth_user): Extension<QrAuthUser>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Response, AppError> {
|
||||
let mut image_bytes = Vec::new();
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| AppError::BadRequestError(e.to_string()))? {
|
||||
if field.name() == Some("file") {
|
||||
image_bytes = field.bytes().await.map_err(|e| AppError::BadRequestError(e.to_string()))?.to_vec();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if image_bytes.is_empty() {
|
||||
return Err(AppError::BadRequestError("No file provided".to_string()));
|
||||
}
|
||||
let png_bytes = service.process_image(image_bytes).await?;
|
||||
Ok((
|
||||
[(axum::http::header::CONTENT_TYPE, "image/png")],
|
||||
png_bytes,
|
||||
).into_response())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,38 @@
|
||||
use axum::{
|
||||
middleware::from_fn,
|
||||
routing::{delete, post, put},
|
||||
Extension, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
campaigns::{
|
||||
application::campaign_service::QrCampaignServiceImpl,
|
||||
domain::{repository::CampaignRepository, service::QrCampaignService},
|
||||
infrastructure::{
|
||||
http::handlers::{
|
||||
activate_campaign_handler, create_campaign_handler, delete_campaign_handler,
|
||||
list_campaigns_handler, process_image_handler,
|
||||
},
|
||||
persistence::postgres_campaign_repository::PostgresCampaignRepository,
|
||||
},
|
||||
},
|
||||
common::qr_jwt::QrJwtService,
|
||||
middleware::qr_auth::qr_auth_middleware,
|
||||
};
|
||||
|
||||
pub fn qr_campaigns_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> Router {
|
||||
let repo: Arc<dyn CampaignRepository> = Arc::new(PostgresCampaignRepository::new(pool.clone()));
|
||||
let service: Arc<dyn QrCampaignService> = Arc::new(QrCampaignServiceImpl::new(repo));
|
||||
|
||||
Router::new()
|
||||
.route("/campaigns", post(create_campaign_handler).get(list_campaigns_handler))
|
||||
.route("/campaigns/:id/activate", put(activate_campaign_handler))
|
||||
.route("/campaigns/:id", delete(delete_campaign_handler))
|
||||
.route("/campaigns/process-image", post(process_image_handler))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension(jwt.clone()))
|
||||
.layer(Extension(pool))
|
||||
.layer(from_fn(qr_auth_middleware))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod postgres_campaign_repository;
|
||||
@@ -0,0 +1,107 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::campaigns::domain::{
|
||||
entity::{CampaignEntity, CreateCampaignInput},
|
||||
repository::CampaignRepository,
|
||||
};
|
||||
|
||||
pub struct PostgresCampaignRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl PostgresCampaignRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CampaignRepository for PostgresCampaignRepository {
|
||||
async fn create(&self, input: CreateCampaignInput) -> Result<CampaignEntity, AppError> {
|
||||
let mut tx = self.pool.begin().await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let campaign = sqlx::query_as::<_, CampaignEntity>(
|
||||
"INSERT INTO qr_campaigns (id, name, url, qr_code_data, is_active, created_by, expires_at) \
|
||||
VALUES ($1, $2, $3, $4, true, $5, NOW() + INTERVAL '30 days') \
|
||||
RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.url)
|
||||
.bind(&input.qr_code_data)
|
||||
.bind(input.created_by)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
Ok(campaign)
|
||||
}
|
||||
|
||||
async fn find_all(&self) -> Result<Vec<CampaignEntity>, AppError> {
|
||||
sqlx::query_as::<_, CampaignEntity>(
|
||||
"SELECT id, name, url, is_active, created_by, expires_at, created_at, updated_at \
|
||||
FROM qr_campaigns ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn find_active_qr_data(&self) -> Result<Option<Vec<u8>>, AppError> {
|
||||
let row = sqlx::query_as::<_, (Vec<u8>,)>(
|
||||
"SELECT qr_code_data FROM qr_campaigns WHERE is_active = true LIMIT 1",
|
||||
)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
Ok(row.map(|r| r.0))
|
||||
}
|
||||
|
||||
async fn set_active(&self, id: Uuid) -> Result<CampaignEntity, AppError> {
|
||||
let mut tx = self.pool.begin().await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
sqlx::query("UPDATE qr_campaigns SET is_active = false, updated_at = NOW()")
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let campaign = sqlx::query_as::<_, CampaignEntity>(
|
||||
"UPDATE qr_campaigns SET is_active = true, updated_at = NOW() WHERE id = $1 \
|
||||
RETURNING id, name, url, is_active, created_by, expires_at, created_at, updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
Ok(campaign)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM qr_campaigns WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
pub use infrastructure::http::routes::qr_campaigns_routes;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod qr_jwt;
|
||||
@@ -0,0 +1,59 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use imphnen_utils::errors::AppError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct QrClaims {
|
||||
pub sub: String,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct QrJwtService {
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
expiry_minutes: i64,
|
||||
refresh_expiry_days: i64,
|
||||
}
|
||||
|
||||
impl QrJwtService {
|
||||
pub fn new(secret: &str, expiry_minutes: i64, refresh_expiry_days: i64) -> Self {
|
||||
Self {
|
||||
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
expiry_minutes,
|
||||
refresh_expiry_days,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_token(&self, user_id: Uuid, role: &str) -> Result<String, AppError> {
|
||||
let exp = (Utc::now() + Duration::minutes(self.expiry_minutes)).timestamp() as usize;
|
||||
let claims = QrClaims {
|
||||
sub: user_id.to_string(),
|
||||
role: role.to_string(),
|
||||
exp,
|
||||
};
|
||||
encode(&Header::default(), &claims, &self.encoding_key)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn generate_refresh_token(&self, user_id: Uuid, role: &str) -> Result<String, AppError> {
|
||||
let exp = (Utc::now() + Duration::days(self.refresh_expiry_days)).timestamp() as usize;
|
||||
let claims = QrClaims {
|
||||
sub: user_id.to_string(),
|
||||
role: role.to_string(),
|
||||
exp,
|
||||
};
|
||||
encode(&Header::default(), &claims, &self.encoding_key)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn verify_token(&self, token: &str) -> Result<QrClaims, AppError> {
|
||||
decode::<QrClaims>(token, &self.decoding_key, &Validation::default())
|
||||
.map(|d| d.claims)
|
||||
.map_err(|_| AppError::AuthenticationError("Invalid or expired token".to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QrConfig {
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiry_minutes: i64,
|
||||
pub refresh_expiry_days: i64,
|
||||
pub google_client_id: String,
|
||||
pub google_client_secret: String,
|
||||
pub google_redirect_url: String,
|
||||
}
|
||||
|
||||
impl QrConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
jwt_secret: env::var("QR_JWT_SECRET").expect("QR_JWT_SECRET must be set"),
|
||||
jwt_expiry_minutes: env::var("QR_JWT_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "15".to_string())
|
||||
.parse()
|
||||
.unwrap_or(15),
|
||||
refresh_expiry_days: env::var("QR_JWT_REFRESH_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "7".to_string())
|
||||
.parse()
|
||||
.unwrap_or(7),
|
||||
google_client_id: env::var("QR_GOOGLE_CLIENT_ID").unwrap_or_default(),
|
||||
google_client_secret: env::var("QR_GOOGLE_CLIENT_SECRET").unwrap_or_default(),
|
||||
google_redirect_url: env::var("QR_GOOGLE_REDIRECT_URL").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
pub mod config;
|
||||
pub mod common;
|
||||
pub mod middleware;
|
||||
pub mod auth;
|
||||
pub mod users;
|
||||
pub mod campaigns;
|
||||
|
||||
pub use config::QrConfig;
|
||||
|
||||
use axum::Router;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use common::qr_jwt::QrJwtService;
|
||||
|
||||
pub fn qr_router(pool: Arc<PgPool>, config: Arc<QrConfig>) -> Router {
|
||||
let jwt = Arc::new(QrJwtService::new(
|
||||
&config.jwt_secret,
|
||||
config.jwt_expiry_minutes,
|
||||
config.refresh_expiry_days,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.merge(auth::infrastructure::http::routes::qr_auth_routes(pool.clone(), jwt.clone(), config.clone()))
|
||||
.merge(users::infrastructure::http::routes::qr_users_routes(pool.clone(), jwt.clone()))
|
||||
.merge(campaigns::infrastructure::http::routes::qr_campaigns_routes(pool.clone(), jwt.clone()))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod qr_auth;
|
||||
@@ -0,0 +1,39 @@
|
||||
use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}};
|
||||
use axum::http::StatusCode;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::common::qr_jwt::QrJwtService;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QrAuthUser {
|
||||
pub user_id: Uuid,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
pub async fn qr_auth_middleware(
|
||||
axum::Extension(jwt_service): axum::Extension<Arc<QrJwtService>>,
|
||||
mut request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, Response> {
|
||||
let auth_header = request
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response())?;
|
||||
|
||||
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, "Invalid Authorization header format").into_response()
|
||||
})?;
|
||||
|
||||
let claims = jwt_service.verify_token(token).map_err(|_| {
|
||||
(StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response()
|
||||
})?;
|
||||
|
||||
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||
(StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response()
|
||||
})?;
|
||||
|
||||
request.extensions_mut().insert(QrAuthUser { user_id, role: claims.role });
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod user_service;
|
||||
@@ -0,0 +1,51 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::domain::{
|
||||
entity::{UpdateUserInput, UserEntity},
|
||||
repository::UserRepository,
|
||||
service::QrUserService,
|
||||
};
|
||||
|
||||
pub struct QrUserServiceImpl {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl QrUserServiceImpl {
|
||||
pub fn new(repo: Arc<dyn UserRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QrUserService for QrUserServiceImpl {
|
||||
async fn get_profile(&self, user_id: Uuid) -> Result<UserEntity, AppError> {
|
||||
self.repo
|
||||
.find_by_id(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
|
||||
}
|
||||
|
||||
async fn update_profile(&self, user_id: Uuid, input: UpdateUserInput) -> Result<UserEntity, AppError> {
|
||||
if let Some(ref email) = input.email {
|
||||
if email.trim().is_empty() {
|
||||
return Err(AppError::ValidationError("Email cannot be empty".to_string()));
|
||||
}
|
||||
}
|
||||
self.repo.update(user_id, input).await
|
||||
}
|
||||
|
||||
async fn list_all(&self) -> Result<Vec<UserEntity>, AppError> {
|
||||
self.repo.find_all().await
|
||||
}
|
||||
|
||||
async fn update_role(&self, id: Uuid, role: String) -> Result<UserEntity, AppError> {
|
||||
self.repo.update_role(id, role).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
|
||||
self.repo.delete(id).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, FromRow, Clone)]
|
||||
pub struct UserEntity {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub provider: String,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub struct UpdateUserInput {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod entity;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::entity::{UpdateUserInput, UserEntity};
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<UserEntity>, AppError>;
|
||||
async fn find_all(&self) -> Result<Vec<UserEntity>, AppError>;
|
||||
async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result<UserEntity, AppError>;
|
||||
async fn update_role(&self, id: Uuid, role: String) -> Result<UserEntity, AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::entity::{UpdateUserInput, UserEntity};
|
||||
|
||||
#[async_trait]
|
||||
pub trait QrUserService: Send + Sync {
|
||||
async fn get_profile(&self, user_id: Uuid) -> Result<UserEntity, AppError>;
|
||||
async fn update_profile(&self, user_id: Uuid, input: UpdateUserInput) -> Result<UserEntity, AppError>;
|
||||
async fn list_all(&self) -> Result<Vec<UserEntity>, AppError>;
|
||||
async fn update_role(&self, id: Uuid, role: String) -> Result<UserEntity, AppError>;
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateRoleRequest {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub provider: String,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
response::{IntoResponse, Response},
|
||||
Extension, Json,
|
||||
};
|
||||
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
middleware::qr_auth::QrAuthUser,
|
||||
users::{
|
||||
domain::{entity::UpdateUserInput, service::QrUserService},
|
||||
infrastructure::http::dto::{UpdateProfileRequest, UpdateRoleRequest},
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_me_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
let user = service.get_profile(auth_user.user_id).await?;
|
||||
Ok(ApiSuccess(user).into_response())
|
||||
}
|
||||
|
||||
pub async fn update_me_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
Json(body): Json<UpdateProfileRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
let input = UpdateUserInput {
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
};
|
||||
let user = service.update_profile(auth_user.user_id, input).await?;
|
||||
Ok(ApiSuccess(user).into_response())
|
||||
}
|
||||
|
||||
pub async fn list_users_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
let users = service.list_all().await?;
|
||||
Ok(ApiSuccess(users).into_response())
|
||||
}
|
||||
|
||||
pub async fn update_role_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<UpdateRoleRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
let user = service.update_role(id, body.role).await?;
|
||||
Ok(ApiSuccess(user).into_response())
|
||||
}
|
||||
|
||||
pub async fn delete_user_handler(
|
||||
Extension(service): Extension<Arc<dyn QrUserService>>,
|
||||
Extension(auth_user): Extension<QrAuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, AppError> {
|
||||
if auth_user.role != "admin" {
|
||||
return Err(AppError::ForbiddenError("Admin access required".to_string()));
|
||||
}
|
||||
service.delete(id).await?;
|
||||
Ok(imphnen_utils::response_format::ApiMessage::ok("User deleted successfully").into_response())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,38 @@
|
||||
use axum::{
|
||||
middleware::from_fn,
|
||||
routing::{delete, get, put},
|
||||
Extension, Router,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
common::qr_jwt::QrJwtService,
|
||||
middleware::qr_auth::qr_auth_middleware,
|
||||
users::{
|
||||
application::user_service::QrUserServiceImpl,
|
||||
domain::{repository::UserRepository, service::QrUserService},
|
||||
infrastructure::{
|
||||
http::handlers::{
|
||||
delete_user_handler, get_me_handler, list_users_handler, update_me_handler,
|
||||
update_role_handler,
|
||||
},
|
||||
persistence::postgres_user_repository::PostgresUserRepository,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn qr_users_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> Router {
|
||||
let repo: Arc<dyn UserRepository> = Arc::new(PostgresUserRepository::new(pool.clone()));
|
||||
let service: Arc<dyn QrUserService> = Arc::new(QrUserServiceImpl::new(repo));
|
||||
|
||||
Router::new()
|
||||
.route("/users/me", get(get_me_handler).put(update_me_handler))
|
||||
.route("/users", get(list_users_handler))
|
||||
.route("/users/:id/role", put(update_role_handler))
|
||||
.route("/users/:id", delete(delete_user_handler))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension(jwt.clone()))
|
||||
.layer(Extension(pool))
|
||||
.layer(from_fn(qr_auth_middleware))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod postgres_user_repository;
|
||||
@@ -0,0 +1,74 @@
|
||||
use async_trait::async_trait;
|
||||
use imphnen_utils::errors::AppError;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::domain::{
|
||||
entity::{UpdateUserInput, UserEntity},
|
||||
repository::UserRepository,
|
||||
};
|
||||
|
||||
pub struct PostgresUserRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl PostgresUserRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for PostgresUserRepository {
|
||||
async fn find_by_id(&self, id: Uuid) -> Result<Option<UserEntity>, AppError> {
|
||||
sqlx::query_as::<_, UserEntity>(
|
||||
"SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn find_all(&self) -> Result<Vec<UserEntity>, AppError> {
|
||||
sqlx::query_as::<_, UserEntity>(
|
||||
"SELECT id, email, name, role, provider, created_at, updated_at FROM users ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result<UserEntity, AppError> {
|
||||
sqlx::query_as::<_, UserEntity>(
|
||||
"UPDATE users SET name = COALESCE($1, name), email = COALESCE($2, email), updated_at = NOW() WHERE id = $3 RETURNING id, email, name, role, provider, created_at, updated_at",
|
||||
)
|
||||
.bind(input.name)
|
||||
.bind(input.email)
|
||||
.bind(id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn update_role(&self, id: Uuid, role: String) -> Result<UserEntity, AppError> {
|
||||
sqlx::query_as::<_, UserEntity>(
|
||||
"UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, role, provider, created_at, updated_at",
|
||||
)
|
||||
.bind(role)
|
||||
.bind(id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
pub use infrastructure::http::routes::qr_users_routes;
|
||||
Reference in New Issue
Block a user