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:
maulanasdqn
2026-04-02 16:03:39 +07:00
co-authored by Claude Sonnet 4.6
parent 5715e75593
commit 4bba182ea3
51 changed files with 1942 additions and 0 deletions
@@ -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))
}