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
@@ -1,129 +1,184 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::invitations::domain::entity::*;
use crate::invitations::domain::repository::InvitationRepository;
use crate::invitations::domain::service::InvitationService;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
let deadline = Utc
.with_ymd_and_hms(2025, 11, 30, 16, 59, 0)
.single()
.expect("valid constant date");
Utc::now() >= deadline
}
pub struct InvitationServiceImpl {
repo: Arc<dyn InvitationRepository>,
repo: Arc<dyn InvitationRepository>,
}
impl InvitationServiceImpl {
pub fn new(repo: Arc<dyn InvitationRepository>) -> Self {
Self { repo }
}
pub fn new(repo: Arc<dyn InvitationRepository>) -> Self {
Self { repo }
}
async fn do_invite(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Team invitations are closed (deadline: November 30, 2025).".to_string(),
));
}
let leader_id = self.repo.get_team_leader_id(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != inviter_id {
return Err(AppError::ForbiddenError("Only the team leader can send invitations".to_string()));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError("Cannot invite after submitting a project".to_string()));
}
let count = self.repo.active_member_count(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team already has the maximum of 5 members".to_string()));
}
let team_name = self.repo.get_team_name(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
let inviter_fullname = self.repo.get_inviter_name(inviter_id).await?
.unwrap_or_else(|| "Unknown".to_string());
let invitation_id = Uuid::new_v4();
let entity = self.repo.create(invitation_id, team_id, inviter_id, &input.invitee_email).await?;
tracing::warn!("Email sending is not available; invitation created for {}", input.invitee_email);
Ok(InvitationWithDetails {
id: entity.id,
team_id: entity.team_id,
team_name,
inviter_id: entity.inviter_id,
inviter_fullname,
invitee_email: entity.invitee_email,
status: entity.status,
created_at: entity.created_at,
})
}
async fn do_invite(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Team invitations are closed (deadline: November 30, 2025).".to_string(),
));
}
let leader_id = self
.repo
.get_team_leader_id(team_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != inviter_id {
return Err(AppError::ForbiddenError(
"Only the team leader can send invitations".to_string(),
));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError(
"Cannot invite after submitting a project".to_string(),
));
}
let count = self.repo.active_member_count(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError(
"Team already has the maximum of 5 members".to_string(),
));
}
let team_name = self
.repo
.get_team_name(team_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
let inviter_fullname = self
.repo
.get_inviter_name(inviter_id)
.await?
.unwrap_or_else(|| "Unknown".to_string());
let invitation_id = Uuid::new_v4();
let entity = self
.repo
.create(invitation_id, team_id, inviter_id, &input.invitee_email)
.await?;
tracing::warn!(
"Email sending is not available; invitation created for {}",
input.invitee_email
);
Ok(InvitationWithDetails {
id: entity.id,
team_id: entity.team_id,
team_name,
inviter_id: entity.inviter_id,
inviter_fullname,
invitee_email: entity.invitee_email,
status: entity.status,
created_at: entity.created_at,
})
}
}
#[async_trait]
impl InvitationService for InvitationServiceImpl {
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn get_my_invitations(&self, user_id: Uuid) -> Result<Vec<InvitationWithDetails>, AppError> {
let email = self.repo.get_user_email(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
self.repo.find_pending_by_email(&email).await
}
async fn get_my_invitations(
&self,
user_id: Uuid,
) -> Result<Vec<InvitationWithDetails>, AppError> {
let email = self
.repo
.get_user_email(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
self.repo.find_pending_by_email(&email).await
}
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let invitation = self.repo.find_by_id(invitation_id).await?
.ok_or_else(|| AppError::NotFoundError("Invitation not found".to_string()))?;
let user_email = self.repo.get_user_email(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
if invitation.invitee_email != user_email {
return Err(AppError::ForbiddenError("This invitation is not for you".to_string()));
}
if invitation.status != "pending" {
return Err(AppError::BadRequestError("Invitation is no longer pending".to_string()));
}
if accept {
if self.repo.team_has_submission(invitation.team_id).await? {
return Err(AppError::BadRequestError("Cannot join a team that has already submitted".to_string()));
}
if let Some(active_team) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!("You are already a member of team '{}'", active_team)));
}
let count = self.repo.active_member_count(invitation.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team is already full".to_string()));
}
self.repo.update_status(invitation_id, "accepted").await?;
self.repo.add_team_member(invitation.team_id, user_id).await?;
self.repo.reject_pending_for_email_except(&user_email, invitation_id).await?;
self.repo.reject_pending_join_requests_for_user(user_id).await?;
} else {
self.repo.update_status(invitation_id, "rejected").await?;
}
Ok(())
}
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let invitation =
self.repo.find_by_id(invitation_id).await?.ok_or_else(|| {
AppError::NotFoundError("Invitation not found".to_string())
})?;
let user_email = self
.repo
.get_user_email(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
if invitation.invitee_email != user_email {
return Err(AppError::ForbiddenError(
"This invitation is not for you".to_string(),
));
}
if invitation.status != "pending" {
return Err(AppError::BadRequestError(
"Invitation is no longer pending".to_string(),
));
}
if accept {
if self.repo.team_has_submission(invitation.team_id).await? {
return Err(AppError::BadRequestError(
"Cannot join a team that has already submitted".to_string(),
));
}
if let Some(active_team) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!(
"You are already a member of team '{}'",
active_team
)));
}
let count = self.repo.active_member_count(invitation.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError(
"Team is already full".to_string(),
));
}
self.repo.update_status(invitation_id, "accepted").await?;
self
.repo
.add_team_member(invitation.team_id, user_id)
.await?;
self
.repo
.reject_pending_for_email_except(&user_email, invitation_id)
.await?;
self
.repo
.reject_pending_join_requests_for_user(user_id)
.await?;
} else {
self.repo.update_status(invitation_id, "rejected").await?;
}
Ok(())
}
}