feat: v0.3.0 — standardize codebase, centralize infra, merge QR into CMS

- Enforce axum best practices across all 13 workspace crates
  (max 200 LOC/file, no comments, no unwrap, clean architecture)
- Fix domain→infrastructure dependency inversions in imphnen-iam and imphnen-dimentorin
- Extract imphnen-storage (MinIO) and imphnen-email (Lettre) as standalone crates
- Centralize all config in ENV struct: CDN_URL, CORS_ALLOWED_ORIGINS
- Centralize SMTP through imphnen-email; remove dead HackathonConfig
- Centralize database: QR crate now shares main DB pool (single DATABASE_URL)
- Rename QR users table to qr_users to avoid collision with main users table
- Merge imphnen-qr into imphnen-cms/src/qr (13 crates, down from 14)
- Restructure imphnen-hackathon flat modules into clean architecture
- Remove all stale env vars from .env.example (SurrealDB, QR_JWT, Hackathon infra)
- Fix Dockerfile to include all current workspace crates
- Bump all crate versions 0.2.0 → 0.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 22:29:08 +07:00
co-authored by Claude Sonnet 4.6
parent 2ae43b3bcc
commit 331a4a4e88
442 changed files with 22226 additions and 18700 deletions
@@ -0,0 +1,101 @@
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::qr::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,22 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, 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,17 @@
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,20 @@
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,103 @@
use axum::{
Extension, Json,
extract::{Multipart, Path},
response::{IntoResponse, Response},
};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
use crate::qr::{
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,41 @@
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, post, put},
};
use sqlx::PgPool;
use std::sync::Arc;
use crate::qr::{
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,
},
},
middleware::qr_auth::qr_auth_middleware,
};
pub fn qr_campaigns_routes(pool: Arc<PgPool>) -> 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(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,147 @@
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::FromRow;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::qr::campaigns::domain::{
entity::{CampaignEntity, CreateCampaignInput},
repository::CampaignRepository,
};
#[derive(FromRow)]
struct CampaignRow {
pub id: Uuid,
pub name: String,
pub url: String,
pub is_active: bool,
pub created_by: Uuid,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<CampaignRow> for CampaignEntity {
fn from(row: CampaignRow) -> Self {
CampaignEntity {
id: row.id,
name: row.name,
url: row.url,
is_active: row.is_active,
created_by: row.created_by,
expires_at: row.expires_at,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
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::<_, CampaignRow>(
"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.into())
}
async fn find_all(&self) -> Result<Vec<CampaignEntity>, AppError> {
sqlx::query_as::<_, CampaignRow>(
"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()))
.map(|rows| rows.into_iter().map(Into::into).collect())
}
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::<_, CampaignRow>(
"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.into())
}
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(())
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::qr_campaigns_routes;