feat: migrate imphnen-backend-hackathon into workspace as imphnen-hackathon crate

Consolidates the standalone hackathon backend (16 crates) into a single
imphnen-hackathon crate following the existing clean architecture patterns.
All endpoints are exposed under /v1/hackathon/ via the gateway.

Features migrated:
- Auth: Supabase-based signup/login/GitHub OAuth/password reset (own JWT)
- Users: profile management with team listing
- Teams: CRUD with city validation, deadline enforcement, invite system
- Invitations: team member invitations with accept/reject flow
- Join Requests: team join request workflow
- Chat: team messaging with author/leader delete permissions
- Submissions: project submission lifecycle (draft→pending→submitted)
- Storage: Supabase Storage file upload endpoints
- Certificates: public user certificate data endpoint
- Winners: public winners listing
- Admin: admin-only CRUD for all entities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 15:15:00 +07:00
co-authored by Claude Sonnet 4.6
parent 05a5b39195
commit 11442c6285
119 changed files with 4385 additions and 11 deletions
@@ -0,0 +1,129 @@
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;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
}
pub struct InvitationServiceImpl {
repo: Arc<dyn InvitationRepository>,
}
impl InvitationServiceImpl {
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_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_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 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(())
}
}
@@ -0,0 +1 @@
pub mod invitation_service;
@@ -0,0 +1,29 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct InvitationEntity {
pub id: Uuid,
pub team_id: Uuid,
pub inviter_id: Uuid,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct InvitationWithDetails {
pub id: Uuid,
pub team_id: Uuid,
pub team_name: String,
pub inviter_id: Uuid,
pub inviter_fullname: String,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct CreateInvitationInput {
pub invitee_email: String,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,41 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait InvitationRepository: Send + Sync {
async fn create(
&self,
invitation_id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: &str,
) -> Result<InvitationEntity, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<InvitationEntity>, AppError>;
async fn find_pending_by_email(&self, email: &str) -> Result<Vec<InvitationWithDetails>, AppError>;
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>;
async fn reject_pending_for_email_except(&self, email: &str, except_id: Uuid) -> Result<(), AppError>;
async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn get_team_leader_id(&self, team_id: Uuid) -> Result<Option<Uuid>, AppError>;
async fn get_team_name(&self, team_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_inviter_name(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError>;
async fn user_active_team_name(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
}
@@ -0,0 +1,30 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait InvitationService: Send + Sync {
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError>;
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError>;
async fn get_my_invitations(&self, user_id: Uuid) -> Result<Vec<InvitationWithDetails>, AppError>;
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError>;
}
@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::invitations::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct InvitationResponse {
pub id: Uuid,
pub team_id: Uuid,
pub team_name: String,
pub inviter_id: Uuid,
pub inviter_fullname: String,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
impl From<InvitationWithDetails> for InvitationResponse {
fn from(e: InvitationWithDetails) -> Self {
Self {
id: e.id,
team_id: e.team_id,
team_name: e.team_name,
inviter_id: e.inviter_id,
inviter_fullname: e.inviter_fullname,
invitee_email: e.invitee_email,
status: e.status,
created_at: e.created_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RespondToInvitationRequest {
pub accept: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateInvitationRequest {
pub invitee_email: String,
}
impl From<CreateInvitationRequest> for CreateInvitationInput {
fn from(r: CreateInvitationRequest) -> Self {
Self {
invitee_email: r.invitee_email,
}
}
}
@@ -0,0 +1,37 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use std::sync::Arc;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}};
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::invitations::domain::service::InvitationService;
use super::dto::*;
pub async fn get_my_invitations_handler(
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
) -> Result<axum::response::Response, AppError> {
let list = service.get_my_invitations(auth.user_id).await?;
let response: Vec<InvitationResponse> = list.into_iter().map(InvitationResponse::from).collect();
Ok(ApiSuccess(response).into_response())
}
pub async fn respond_to_invitation_handler(
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(invitation_id): Path<Uuid>,
Json(body): Json<RespondToInvitationRequest>,
) -> Result<axum::response::Response, AppError> {
service.respond_to_invitation(invitation_id, auth.user_id, body.accept).await?;
let msg = if body.accept { "Invitation accepted" } else { "Invitation declined" };
Ok(ApiMessage::ok(msg).into_response())
}
pub async fn invite_team_member_handler(
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<CreateInvitationRequest>,
) -> Result<axum::response::Response, AppError> {
let invitation = service.invite_member(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(InvitationResponse::from(invitation)).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,23 @@
use axum::{middleware::from_fn, routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::invitations::application::invitation_service::InvitationServiceImpl;
use crate::invitations::domain::service::InvitationService;
use crate::invitations::infrastructure::persistence::PostgresInvitationRepository;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
pub fn build_invitation_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>) -> Router {
let service: Arc<dyn InvitationService> = Arc::new(InvitationServiceImpl::new(
Arc::new(PostgresInvitationRepository::new(pool.clone())),
));
Router::new()
.route("/invitations/my", get(get_my_invitations_handler))
.route("/invitations/:invitation_id/respond", post(respond_to_invitation_handler))
.route("/invitations/teams/:team_id/invite", post(invite_team_member_handler))
.layer(Extension(service))
.layer(Extension(jwt.clone()))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,2 @@
pub mod postgres_invitation_repository;
pub use postgres_invitation_repository::PostgresInvitationRepository;
@@ -0,0 +1,159 @@
use std::sync::Arc;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use async_trait::async_trait;
use sqlx::{PgPool, FromRow};
use imphnen_utils::errors::AppError;
use crate::invitations::domain::entity::*;
use crate::invitations::domain::repository::InvitationRepository;
#[derive(FromRow)]
struct InvitationRow {
id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
}
impl From<InvitationRow> for InvitationEntity {
fn from(r: InvitationRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
inviter_id: r.inviter_id,
invitee_email: r.invitee_email,
status: r.status,
created_at: r.created_at,
}
}
}
#[derive(FromRow)]
struct InvitationDetailsRow {
id: Uuid,
team_id: Uuid,
team_name: String,
inviter_id: Uuid,
inviter_fullname: String,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
}
impl From<InvitationDetailsRow> for InvitationWithDetails {
fn from(r: InvitationDetailsRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
team_name: r.team_name,
inviter_id: r.inviter_id,
inviter_fullname: r.inviter_fullname,
invitee_email: r.invitee_email,
status: r.status,
created_at: r.created_at,
}
}
}
pub struct PostgresInvitationRepository {
pool: Arc<PgPool>,
}
impl PostgresInvitationRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl InvitationRepository for PostgresInvitationRepository {
async fn create(&self, invitation_id: Uuid, team_id: Uuid, inviter_id: Uuid, invitee_email: &str) -> Result<InvitationEntity, AppError> {
let row: InvitationRow = sqlx::query_as(
"INSERT INTO hackathon_team_invitations (id, team_id, inviter_id, invitee_email, status, created_at) VALUES ($1, $2, $3, $4, 'pending', NOW()) RETURNING id, team_id, inviter_id, invitee_email, status, created_at"
)
.bind(invitation_id).bind(team_id).bind(inviter_id).bind(invitee_email)
.fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.into())
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<InvitationEntity>, AppError> {
let row: Option<InvitationRow> = sqlx::query_as(
"SELECT id, team_id, inviter_id, invitee_email, status, created_at FROM hackathon_team_invitations WHERE id = $1"
)
.bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
async fn find_pending_by_email(&self, email: &str) -> Result<Vec<InvitationWithDetails>, AppError> {
let rows: Vec<InvitationDetailsRow> = sqlx::query_as(
"SELECT i.id, i.team_id, t.name AS team_name, i.inviter_id, u.fullname AS inviter_fullname, i.invitee_email, i.status, i.created_at FROM hackathon_team_invitations i JOIN hackathon_teams t ON t.id = i.team_id JOIN hackathon_users u ON u.id = i.inviter_id WHERE i.invitee_email = $1 AND i.status = 'pending'"
)
.bind(email).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_invitations SET status = $1 WHERE id = $2")
.bind(status).bind(id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn reject_pending_for_email_except(&self, email: &str, except_id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending' AND id != $2")
.bind(email).bind(except_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())")
.bind(Uuid::new_v4()).bind(team_id).bind(user_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'")
.bind(user_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn get_team_leader_id(&self, team_id: Uuid) -> Result<Option<Uuid>, AppError> {
sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1")
.bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_team_name(&self, team_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT name FROM hackathon_teams WHERE id = $1")
.bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_inviter_name(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT fullname FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError> {
sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'")
.bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)")
.bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn user_active_team_name(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::build_invitation_routes;