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,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,82 @@
use axum::{
Extension, Json,
extract::Path,
response::{IntoResponse, Response},
};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
use crate::qr::{
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,37 @@
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, get, put},
};
use sqlx::PgPool;
use std::sync::Arc;
use crate::qr::{
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>) -> 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(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,112 @@
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::users::domain::{
entity::{UpdateUserInput, UserEntity},
repository::UserRepository,
};
#[derive(FromRow)]
struct UserRow {
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>>,
}
impl From<UserRow> for UserEntity {
fn from(row: UserRow) -> Self {
UserEntity {
id: row.id,
email: row.email,
name: row.name,
role: row.role,
provider: row.provider,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
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::<_, UserRow>(
"SELECT id, email, name, role, provider, created_at, updated_at FROM qr_users WHERE id = $1",
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
.map(|opt| opt.map(Into::into))
}
async fn find_all(&self) -> Result<Vec<UserEntity>, AppError> {
sqlx::query_as::<_, UserRow>(
"SELECT id, email, name, role, provider, created_at, updated_at FROM qr_users 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 update(
&self,
id: Uuid,
input: UpdateUserInput,
) -> Result<UserEntity, AppError> {
sqlx::query_as::<_, UserRow>(
"UPDATE qr_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()))
.map(Into::into)
}
async fn update_role(
&self,
id: Uuid,
role: String,
) -> Result<UserEntity, AppError> {
sqlx::query_as::<_, UserRow>(
"UPDATE qr_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()))
.map(Into::into)
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM qr_users WHERE id = $1")
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}