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
+26 -10
View File
@@ -21,14 +21,30 @@ MINIO_SECRET_KEY=minioadmin
MINIO_SECURE=false
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
POOL_SIZE=10
CONNECT_TIMEOUT=30
IDLE_TIMEOUT=60
MAX_LIFETIME=1800
STATEMENT_TIMEOUT=30000
IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000
SSLMODE=require
RETRY_ATTEMPTS=3
RETRY_DELAY=1
GOOGLE_CLIENT_SECRET="your_google_client_secret"
POOL_SIZE=10
CONNECT_TIMEOUT=30
IDLE_TIMEOUT=60
MAX_LIFETIME=1800
STATEMENT_TIMEOUT=30000
IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000
SSLMODE=require
RETRY_ATTEMPTS=3
RETRY_DELAY=1
GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback
# Hackathon feature
HACKATHON_JWT_SECRET=your-hackathon-jwt-secret-at-least-32-chars
HACKATHON_JWT_EXPIRY_HOURS=168
HACKATHON_SUPABASE_URL=https://your-project.supabase.co
HACKATHON_SUPABASE_ANON_KEY=your-supabase-anon-key
HACKATHON_SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
HACKATHON_STORAGE_BUCKET=hackathon-uploads
HACKATHON_GITHUB_CLIENT_ID=your-github-client-id
HACKATHON_GITHUB_CLIENT_SECRET=your-github-client-secret
HACKATHON_GITHUB_REDIRECT_URL=http://localhost:8080/v1/hackathon/auth/github/callback
HACKATHON_SMTP_HOST=smtp.gmail.com
HACKATHON_SMTP_USER=your-email@gmail.com
HACKATHON_SMTP_PASSWORD=your-smtp-password
HACKATHON_FROM_EMAIL=noreply@yourdomain.com
HACKATHON_FRONTEND_URL=https://hackathon.imphnen.dev
Generated
+27
View File
@@ -1604,6 +1604,7 @@ dependencies = [
"imphnen-dimentorin",
"imphnen-entities",
"imphnen-gacha",
"imphnen-hackathon",
"imphnen-iam",
"imphnen-libs",
"imphnen-middleware",
@@ -1619,6 +1620,31 @@ dependencies = [
"utoipa-swagger-ui",
]
[[package]]
name = "imphnen-hackathon"
version = "0.2.0"
dependencies = [
"async-trait",
"axum",
"axum-extra",
"base64",
"chrono",
"imphnen-libs",
"imphnen-utils",
"jsonwebtoken",
"lettre",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.17",
"tokio",
"tracing",
"utoipa",
"uuid",
]
[[package]]
name = "imphnen-iam"
version = "0.2.0"
@@ -4215,6 +4241,7 @@ dependencies = [
"quote",
"regex",
"syn 2.0.111",
"uuid",
]
[[package]]
+4 -1
View File
@@ -10,6 +10,7 @@ members = [
"imphnen-cms", # Content management, depends on core services
"imphnen-gacha", # Game mechanics, depends on core services
"imphnen-dimentorin",# Learning platform, depends on core services
"imphnen-hackathon", # Hackathon feature, standalone with Supabase auth
"imphnen-gateway", # API gateway, depends on all services
"imphnen-backend", # Main application, depends on all services
]
@@ -27,7 +28,7 @@ tokio = { version = "1.47.1", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1"
chrono = "0.4.41"
utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa = { version = "5.4.0", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
thiserror = "2.0.14"
@@ -63,6 +64,7 @@ hyper = "1.6.0"
hyper-util = "0.1.16"
minio = "0.3.0"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "with-chrono", "uuid"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono", "json", "macros"] }
num_cpus = "1.16.0"
@@ -86,6 +88,7 @@ imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" }
imphnen-macros = { path = "./imphnen-macros" }
imphnen-hackathon = { path = "./imphnen-hackathon" }
[profile.release]
lto = "fat"
+1
View File
@@ -4,6 +4,7 @@ version = "0.2.0"
edition = "2024"
[dependencies]
imphnen-hackathon.workspace = true
imphnen-iam.workspace = true
imphnen-libs.workspace = true
imphnen-utils.workspace = true
+3
View File
@@ -16,6 +16,7 @@ use imphnen_dimentorin::{
sessions_public_routes, sessions_protected_routes,
};
use imphnen_gacha::gacha_router;
use imphnen_hackathon::{hackathon_router, HackathonConfig};
use imphnen_iam::{
auth_public_routes,
permissions_protected_routes,
@@ -43,6 +44,7 @@ pub async fn gateway_service(
let db = state.postgres_connection.conn.clone();
let state_arc = Arc::new(state.clone());
let hackathon_config = Arc::new(HackathonConfig::from_env());
let public_routes = Router::new()
.merge(auth_public_routes(db.clone(), Arc::clone(&state_arc)).layer(from_fn(rate_limiting_middleware)))
@@ -65,6 +67,7 @@ pub async fn gateway_service(
Router::new()
.route("/", get(Redirect::to("/docs")))
.nest("/v1", public_routes.merge(protected_routes))
.nest("/v1/hackathon", hackathon_router(db.clone(), hackathon_config))
.merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router()))
.layer(cors_middleware())
.layer(from_fn(security_headers_middleware))
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "imphnen-hackathon"
version = "0.2.0"
edition = "2024"
[dependencies]
imphnen-utils.workspace = true
imphnen-libs.workspace = true
axum.workspace = true
axum-extra.workspace = true
sea-orm.workspace = true
sqlx.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
utoipa.workspace = true
chrono.workspace = true
uuid.workspace = true
tokio.workspace = true
reqwest.workspace = true
lettre.workspace = true
base64.workspace = true
jsonwebtoken.workspace = true
tracing.workspace = true
thiserror.workspace = true
+2
View File
@@ -0,0 +1,2 @@
pub mod routes;
pub use routes::hackathon_admin_routes;
+191
View File
@@ -0,0 +1,191 @@
use axum::{
extract::{Path, Query},
middleware::from_fn,
response::IntoResponse,
routing::{delete, get, post, put},
Extension, Json, Router,
};
use sqlx::{PgPool, FromRow};
use std::sync::Arc;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}};
use crate::middleware::{admin_only::admin_only, hackathon_auth::hackathon_auth_middleware};
use crate::common::hackathon_jwt::HackathonJwtService;
#[derive(Deserialize)]
struct PageQuery {
#[serde(default = "default_page")]
page: i64,
#[serde(default = "default_limit")]
limit: i64,
search: Option<String>,
status: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_limit() -> i64 { 20 }
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct AdminUserRow {
id: Uuid,
email: String,
fullname: String,
avatar: Option<String>,
is_active: Option<bool>,
is_admin: Option<bool>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, ToSchema)]
struct PagedResponse<T> {
data: Vec<T>,
total: i64,
page: i64,
limit: i64,
}
#[derive(Deserialize, ToSchema)]
struct SetAdminRequest { is_admin: bool }
async fn admin_list_users(
Extension(pool): Extension<Arc<PgPool>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let offset = (q.page - 1) * q.limit;
let pattern = q.search.as_deref().map(|s| format!("%{}%", s));
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1)")
.bind(&pattern).fetch_one(pool.as_ref()).await.unwrap_or(0);
let users: Vec<AdminUserRow> = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&pattern).bind(q.limit).bind(offset)
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(PagedResponse { data: users, total, page: q.page, limit: q.limit }).into_response())
}
async fn admin_get_user(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let user: AdminUserRow = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
Ok(ApiSuccess(user).into_response())
}
async fn admin_set_admin(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
Json(body): Json<SetAdminRequest>,
) -> Result<ApiMessage, AppError> {
sqlx::query("UPDATE hackathon_users SET is_admin = $1 WHERE id = $2")
.bind(body.is_admin).bind(user_id)
.execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("User admin status updated"))
}
async fn admin_delete_user(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
sqlx::query("DELETE FROM hackathon_users WHERE id = $1")
.bind(user_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("User deleted"))
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct AdminTeamRow {
id: Uuid, name: String, city: String, visibility: String,
leader_id: Uuid, created_at: chrono::DateTime<chrono::Utc>,
}
async fn admin_list_teams(
Extension(pool): Extension<Arc<PgPool>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let offset = (q.page - 1) * q.limit;
let pattern = q.search.as_deref().map(|s| format!("%{}%", s));
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1)")
.bind(&pattern).fetch_one(pool.as_ref()).await.unwrap_or(0);
let teams: Vec<AdminTeamRow> = sqlx::query_as("SELECT id, name, city, visibility, leader_id, created_at FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&pattern).bind(q.limit).bind(offset)
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(PagedResponse { data: teams, total, page: q.page, limit: q.limit }).into_response())
}
async fn admin_delete_team(
Extension(pool): Extension<Arc<PgPool>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
sqlx::query("DELETE FROM hackathon_teams WHERE id = $1")
.bind(team_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("Team deleted"))
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct AdminSubmissionRow {
id: Uuid, team_id: Uuid, project_name: String, status: String,
submitted_at: Option<chrono::DateTime<chrono::Utc>>, created_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn admin_list_submissions(
Extension(pool): Extension<Arc<PgPool>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let offset = (q.page - 1) * q.limit;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1)")
.bind(&q.status).fetch_one(pool.as_ref()).await.unwrap_or(0);
let subs: Vec<AdminSubmissionRow> = sqlx::query_as("SELECT id, team_id, project_name, status, submitted_at, created_at FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&q.status).bind(q.limit).bind(offset)
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(PagedResponse { data: subs, total, page: q.page, limit: q.limit }).into_response())
}
#[derive(Debug, Deserialize, ToSchema)]
struct SetWinnerRequest { team_id: Uuid, rank: i32, prize: Option<String> }
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct WinnerRow { id: Uuid, team_id: Uuid, rank: i32, prize: Option<String>, created_at: Option<chrono::DateTime<chrono::Utc>> }
async fn admin_set_winner(
Extension(pool): Extension<Arc<PgPool>>,
Json(body): Json<SetWinnerRequest>,
) -> Result<ApiMessage, AppError> {
sqlx::query("INSERT INTO hackathon_winners (id, team_id, rank, prize, announced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) ON CONFLICT (team_id) DO UPDATE SET rank = $3, prize = $4, updated_at = NOW()")
.bind(Uuid::new_v4()).bind(body.team_id).bind(body.rank).bind(body.prize)
.execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("Winner set"))
}
async fn admin_remove_winner(
Extension(pool): Extension<Arc<PgPool>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
sqlx::query("DELETE FROM hackathon_winners WHERE team_id = $1")
.bind(team_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("Winner removed"))
}
async fn admin_list_winners(
Extension(pool): Extension<Arc<PgPool>>,
) -> Result<axum::response::Response, AppError> {
let rows: Vec<WinnerRow> = sqlx::query_as("SELECT id, team_id, rank, prize, created_at FROM hackathon_winners ORDER BY rank ASC")
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(rows).into_response())
}
pub fn hackathon_admin_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>) -> Router {
Router::new()
.route("/admin/users", get(admin_list_users))
.route("/admin/users/:user_id", get(admin_get_user).delete(admin_delete_user))
.route("/admin/users/:user_id/set-admin", post(admin_set_admin))
.route("/admin/teams", get(admin_list_teams))
.route("/admin/teams/:team_id", delete(admin_delete_team))
.route("/admin/submissions", get(admin_list_submissions))
.route("/admin/winners", get(admin_list_winners).post(admin_set_winner))
.route("/admin/winners/:team_id", delete(admin_remove_winner))
.layer(Extension(pool.clone()))
.layer(from_fn(admin_only))
.layer(Extension(jwt.clone()))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -0,0 +1,200 @@
use std::sync::Arc;
use uuid::Uuid;
use chrono::{Utc, TimeZone};
use sqlx::PgPool;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::common::supabase_client::SupabaseClient;
use crate::config::HackathonConfig;
use super::super::domain::service::{HackathonAuthService, AuthTokens, HackathonUserData};
fn is_registration_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 29, 0).unwrap();
Utc::now() >= deadline
}
pub struct HackathonAuthServiceImpl {
pool: Arc<PgPool>,
jwt: Arc<HackathonJwtService>,
supabase: Arc<SupabaseClient>,
config: Arc<HackathonConfig>,
}
impl HackathonAuthServiceImpl {
pub fn new(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>, supabase: Arc<SupabaseClient>, config: Arc<HackathonConfig>) -> Self {
Self { pool, jwt, supabase, config }
}
async fn get_user_by_id(&self, user_id: Uuid) -> Result<HackathonUserData, AppError> {
sqlx::query_as::<_, HackathonUserData>(
"SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id = $1"
)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
async fn get_or_create_active_user(&self, user_id: Uuid, email: &str, fullname: &str) -> Result<HackathonUserData, AppError> {
let now = Utc::now();
sqlx::query_as::<_, HackathonUserData>(
"INSERT INTO hackathon_users (id, email, fullname, is_active, created_at, updated_at)
VALUES ($1, LOWER($2), $3, true, $4, $5)
ON CONFLICT (email) DO UPDATE SET is_active = true, updated_at = NOW()
RETURNING id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at"
)
.bind(user_id)
.bind(email)
.bind(fullname)
.bind(now)
.bind(now)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_or_create_github_user(&self, email: &str, fullname: &str, avatar: Option<&str>) -> Result<HackathonUserData, AppError> {
let existing: Option<HackathonUserData> = sqlx::query_as::<_, HackathonUserData>(
"SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE LOWER(email) = LOWER($1)"
)
.bind(email)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if let Some(user) = existing {
return Ok(user);
}
if is_registration_closed() {
return Err(AppError::BadRequestError("Registration is closed.".to_string()));
}
let now = Utc::now();
let user_id = Uuid::new_v4();
sqlx::query_as::<_, HackathonUserData>(
"INSERT INTO hackathon_users (id, email, fullname, avatar, is_active, created_at, updated_at)
VALUES ($1, LOWER($2), $3, $4, true, $5, $6)
ON CONFLICT (email) DO UPDATE SET avatar = COALESCE(hackathon_users.avatar, EXCLUDED.avatar), is_active = true, updated_at = NOW()
RETURNING id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at"
)
.bind(user_id)
.bind(email)
.bind(fullname)
.bind(avatar)
.bind(now)
.bind(now)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
#[async_trait]
impl HackathonAuthService for HackathonAuthServiceImpl {
async fn signup(&self, email: String, password: String, fullname: String) -> Result<(), AppError> {
if is_registration_closed() {
return Err(AppError::BadRequestError("Registration is closed.".to_string()));
}
let data = self.supabase.signup(&email, &password, &fullname, &self.config.frontend_url).await?;
let user_id_str = data["user"]["id"].as_str()
.or_else(|| data["id"].as_str())
.ok_or_else(|| AppError::InternalServerError("Missing user ID in signup response".to_string()))?;
let user_uuid = Uuid::parse_str(user_id_str)
.map_err(|_| AppError::InternalServerError("Invalid user ID format".to_string()))?;
let now = Utc::now();
sqlx::query(
"INSERT INTO hackathon_users (id, email, fullname, is_active, created_at, updated_at) VALUES ($1, LOWER($2), $3, false, $4, $5) ON CONFLICT (email) DO NOTHING"
)
.bind(user_uuid)
.bind(email)
.bind(fullname)
.bind(now)
.bind(now)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, HackathonUserData), AppError> {
let data = self.supabase.login(&email, &password).await?;
let email_confirmed = data["user"]["email_confirmed_at"].as_str().map(|s| !s.is_empty()).unwrap_or(false);
if !email_confirmed {
return Err(AppError::AuthenticationError("Please confirm your email before logging in.".to_string()));
}
let user_id_str = data["user"]["id"].as_str()
.ok_or_else(|| AppError::InternalServerError("Missing user ID".to_string()))?;
let user_id = Uuid::parse_str(user_id_str).map_err(|_| AppError::InternalServerError("Invalid user ID".to_string()))?;
let fullname = data["user"]["user_metadata"]["fullname"].as_str()
.or_else(|| data["user"]["user_metadata"]["full_name"].as_str())
.unwrap_or(&email).to_string();
let user = self.get_or_create_active_user(user_id, &email, &fullname).await?;
let tokens = AuthTokens {
access_token: self.jwt.generate_token(user.id)?,
refresh_token: self.jwt.generate_refresh_token(user.id)?,
};
Ok((tokens, user))
}
async fn github_auth(&self, code: String) -> Result<(AuthTokens, HackathonUserData), AppError> {
let http = reqwest::Client::new();
let token_data: serde_json::Value = http.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.form(&[("client_id", &self.config.github_client_id), ("client_secret", &self.config.github_client_secret), ("code", &code)])
.send().await.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json().await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if token_data.get("error").is_some() {
return Err(AppError::BadRequestError("GitHub OAuth error".to_string()));
}
let access_token = token_data["access_token"].as_str()
.ok_or_else(|| AppError::InternalServerError("Missing access token from GitHub".to_string()))?;
let github_user: serde_json::Value = http.get("https://api.github.com/user")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "imphnen-hackathon-api")
.send().await.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json().await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let github_id = github_user["id"].as_i64()
.ok_or_else(|| AppError::InternalServerError("Missing GitHub user ID".to_string()))?;
let username = github_user["login"].as_str().unwrap_or("user");
let email = match github_user["email"].as_str().filter(|e| !e.is_empty()) {
Some(e) => e.to_string(),
None => {
let emails: Vec<serde_json::Value> = http.get("https://api.github.com/user/emails")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "imphnen-hackathon-api")
.send().await.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json().await.unwrap_or_default();
emails.iter().find(|e| e["primary"].as_bool().unwrap_or(false))
.or_else(|| emails.iter().find(|e| e["verified"].as_bool().unwrap_or(false)))
.and_then(|e| e["email"].as_str()).map(|s| s.to_string())
.unwrap_or_else(|| format!("{}+{}@users.noreply.github.com", github_id, username))
}
};
let fullname = github_user["name"].as_str().or_else(|| github_user["login"].as_str()).unwrap_or("GitHub User").to_string();
let avatar = github_user["avatar_url"].as_str();
let user = self.get_or_create_github_user(&email, &fullname, avatar).await?;
let tokens = AuthTokens {
access_token: self.jwt.generate_token(user.id)?,
refresh_token: self.jwt.generate_refresh_token(user.id)?,
};
Ok((tokens, user))
}
async fn get_session(&self, user_id: Uuid) -> Result<HackathonUserData, AppError> {
self.get_user_by_id(user_id).await
}
async fn forgot_password(&self, email: String) -> Result<(), AppError> {
self.supabase.recover_password(&email, &self.config.frontend_url).await
}
async fn reset_password(&self, access_token: String, new_password: String) -> Result<(), AppError> {
if new_password.len() < 6 {
return Err(AppError::BadRequestError("Password must be at least 6 characters long".to_string()));
}
self.supabase.update_password(&access_token, &new_password).await
}
}
@@ -0,0 +1 @@
pub mod auth_service;
+1
View File
@@ -0,0 +1 @@
pub mod service;
@@ -0,0 +1,34 @@
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, utoipa::ToSchema, sqlx::FromRow)]
pub struct HackathonUserData {
pub id: Uuid,
pub email: String,
pub fullname: String,
pub avatar: Option<String>,
pub phone_number: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
pub is_active: Option<bool>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait]
pub trait HackathonAuthService: Send + Sync {
async fn signup(&self, email: String, password: String, fullname: String) -> Result<(), AppError>;
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, HackathonUserData), AppError>;
async fn github_auth(&self, code: String) -> Result<(AuthTokens, HackathonUserData), AppError>;
async fn get_session(&self, user_id: Uuid) -> Result<HackathonUserData, AppError>;
async fn forgot_password(&self, email: String) -> Result<(), AppError>;
async fn reset_password(&self, access_token: String, new_password: String) -> Result<(), AppError>;
}
@@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SignupRequest {
pub email: String,
pub password: String,
pub fullname: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct GitHubAuthRequest {
pub code: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ForgotPasswordRequest {
pub email: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ResetPasswordRequest {
pub access_token: String,
pub new_password: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
pub user: crate::auth::domain::service::HackathonUserData,
}
@@ -0,0 +1,62 @@
use axum::{Extension, Json, response::IntoResponse};
use std::sync::Arc;
use imphnen_utils::response_format::{ApiSuccess, ApiMessage};
use crate::auth::domain::service::HackathonAuthService;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use super::dto::*;
pub async fn signup_handler(
Extension(service): Extension<Arc<dyn HackathonAuthService>>,
Json(body): Json<SignupRequest>,
) -> Result<ApiMessage, imphnen_utils::errors::AppError> {
service.signup(body.email, body.password, body.fullname).await?;
Ok(ApiMessage::created("Registration successful! Please check your email to activate your account."))
}
pub async fn login_handler(
Extension(service): Extension<Arc<dyn HackathonAuthService>>,
Json(body): Json<LoginRequest>,
) -> Result<axum::response::Response, imphnen_utils::errors::AppError> {
let (tokens, user) = service.login(body.email, body.password).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn github_auth_handler(
Extension(service): Extension<Arc<dyn HackathonAuthService>>,
Json(body): Json<GitHubAuthRequest>,
) -> Result<axum::response::Response, imphnen_utils::errors::AppError> {
let (tokens, user) = service.github_auth(body.code).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn get_session_handler(
Extension(service): Extension<Arc<dyn HackathonAuthService>>,
Extension(auth_user): Extension<HackathonAuthUser>,
) -> Result<axum::response::Response, imphnen_utils::errors::AppError> {
let user = service.get_session(auth_user.user_id).await?;
Ok(ApiSuccess(user).into_response())
}
pub async fn forgot_password_handler(
Extension(service): Extension<Arc<dyn HackathonAuthService>>,
Json(body): Json<ForgotPasswordRequest>,
) -> Result<ApiMessage, imphnen_utils::errors::AppError> {
service.forgot_password(body.email).await?;
Ok(ApiMessage::ok("If an account with that email exists, a password reset link has been sent."))
}
pub async fn reset_password_handler(
Extension(service): Extension<Arc<dyn HackathonAuthService>>,
Json(body): Json<ResetPasswordRequest>,
) -> Result<ApiMessage, imphnen_utils::errors::AppError> {
service.reset_password(body.access_token, body.new_password).await?;
Ok(ApiMessage::ok("Password has been successfully reset."))
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,33 @@
use axum::{middleware::from_fn, routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::auth::application::auth_service::HackathonAuthServiceImpl;
use crate::auth::domain::service::HackathonAuthService;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::common::supabase_client::SupabaseClient;
use crate::config::HackathonConfig;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
pub fn hackathon_auth_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>, supabase: Arc<SupabaseClient>, config: Arc<HackathonConfig>) -> Router {
let service: Arc<dyn HackathonAuthService> = Arc::new(
HackathonAuthServiceImpl::new(pool.clone(), jwt.clone(), supabase, config)
);
let public = Router::new()
.route("/auth/signup", post(signup_handler))
.route("/auth/login", post(login_handler))
.route("/auth/github", post(github_auth_handler))
.route("/auth/forgot-password", post(forgot_password_handler))
.route("/auth/reset-password", post(reset_password_handler))
.layer(Extension(service.clone()));
let protected = Router::new()
.route("/auth/session", get(get_session_handler))
.layer(Extension(service))
.layer(Extension(jwt.clone()))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware));
public.merge(protected)
}
@@ -0,0 +1 @@
pub mod http;
+5
View File
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_auth_routes;
@@ -0,0 +1,2 @@
pub mod routes;
pub use routes::hackathon_certificates_routes;
@@ -0,0 +1,45 @@
use axum::{extract::Path, response::IntoResponse, routing::get, Extension, Router};
use sqlx::{PgPool, FromRow};
use std::sync::Arc;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use serde::Serialize;
use utoipa::ToSchema;
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct CertificateResponse {
pub user_id: Uuid,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub team_id: Option<Uuid>,
pub team_name: Option<String>,
pub is_leader: Option<bool>,
pub project_name: Option<String>,
pub submission_status: Option<String>,
pub winner_rank: Option<i32>,
pub winner_prize: Option<String>,
}
async fn get_certificate_handler(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let row: Option<CertificateResponse> = sqlx::query_as(
"SELECT u.id as user_id, u.fullname, u.email, u.avatar, t.id as team_id, t.name as team_name, (t.leader_id = u.id) as is_leader, ps.project_name, ps.status as submission_status, w.rank as winner_rank, w.prize as winner_prize FROM hackathon_users u LEFT JOIN hackathon_team_members tm ON tm.user_id = u.id AND tm.status = 'active' LEFT JOIN hackathon_teams t ON t.id = tm.team_id LEFT JOIN hackathon_project_submissions ps ON ps.team_id = t.id LEFT JOIN hackathon_winners w ON w.team_id = t.id WHERE u.id = $1 LIMIT 1"
)
.bind(user_id)
.fetch_optional(pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let cert = row.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
Ok(ApiSuccess(cert).into_response())
}
pub fn hackathon_certificates_routes(pool: Arc<PgPool>) -> Router {
Router::new()
.route("/certificates/:user_id", get(get_certificate_handler))
.layer(Extension(pool))
}
@@ -0,0 +1,70 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use crate::chat::domain::entity::*;
use crate::chat::domain::repository::ChatRepository;
use crate::chat::domain::service::ChatService;
pub struct ChatServiceImpl {
repo: Arc<dyn ChatRepository>,
}
impl ChatServiceImpl {
pub fn new(repo: Arc<dyn ChatRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl ChatService for ChatServiceImpl {
async fn get_team_messages(&self, team_id: Uuid, user_id: Uuid) -> Result<Vec<MessageWithUser>, AppError> {
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team members can view messages".to_string()));
}
self.repo.find_team_messages(team_id).await
}
async fn send_message(
&self,
team_id: Uuid,
user_id: Uuid,
input: SendMessageInput,
) -> Result<MessageWithUser, AppError> {
if input.message.trim().is_empty() {
return Err(AppError::BadRequestError("Message cannot be empty".to_string()));
}
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team members can send messages".to_string()));
}
let user_info = self.repo.get_user_info(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
let id = Uuid::new_v4();
let entity = self.repo.create_message(id, team_id, user_id, &input.message).await?;
Ok(MessageWithUser {
id: entity.id,
team_id: entity.team_id,
user_id: entity.user_id,
user_fullname: user_info.0,
user_avatar: user_info.1,
message: entity.message,
created_at: entity.created_at,
updated_at: entity.updated_at,
})
}
async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
let message = self.repo.find_message_by_id(message_id).await?
.ok_or_else(|| AppError::NotFoundError("Message not found".to_string()))?;
let is_author = message.user_id == user_id;
let is_leader = self.repo.is_team_leader(message.team_id, user_id).await?;
if !is_author && !is_leader {
return Err(AppError::ForbiddenError("You can only delete your own messages or messages as team leader".to_string()));
}
let deleted = self.repo.delete_message(message_id).await?;
if !deleted {
return Err(AppError::NotFoundError("Message not found".to_string()));
}
Ok(())
}
}
@@ -0,0 +1 @@
pub mod chat_service;
@@ -0,0 +1,29 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct MessageEntity {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct MessageWithUser {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_avatar: Option<String>,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct SendMessageInput {
pub message: String,
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,27 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait ChatRepository: Send + Sync {
async fn find_team_messages(&self, team_id: Uuid) -> Result<Vec<MessageWithUser>, AppError>;
async fn create_message(
&self,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: &str,
) -> Result<MessageEntity, AppError>;
async fn find_message_by_id(&self, id: Uuid) -> Result<Option<MessageEntity>, AppError>;
async fn delete_message(&self, id: Uuid) -> Result<bool, AppError>;
async fn get_user_info(&self, user_id: Uuid) -> Result<Option<(String, Option<String>)>, AppError>;
async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
}
@@ -0,0 +1,18 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait ChatService: Send + Sync {
async fn get_team_messages(&self, team_id: Uuid, user_id: Uuid) -> Result<Vec<MessageWithUser>, AppError>;
async fn send_message(
&self,
team_id: Uuid,
user_id: Uuid,
input: SendMessageInput,
) -> Result<MessageWithUser, AppError>;
async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::chat::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MessageResponse {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_avatar: Option<String>,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
impl From<MessageWithUser> for MessageResponse {
fn from(e: MessageWithUser) -> Self {
Self {
id: e.id,
team_id: e.team_id,
user_id: e.user_id,
user_fullname: e.user_fullname,
user_avatar: e.user_avatar,
message: e.message,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SendMessageRequest {
pub message: String,
}
impl From<SendMessageRequest> for SendMessageInput {
fn from(r: SendMessageRequest) -> Self {
Self { message: r.message }
}
}
@@ -0,0 +1,36 @@
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::chat::domain::service::ChatService;
use super::dto::*;
pub async fn get_team_messages_handler(
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let messages = service.get_team_messages(team_id, auth.user_id).await?;
let response: Vec<MessageResponse> = messages.into_iter().map(MessageResponse::from).collect();
Ok(ApiSuccess(response).into_response())
}
pub async fn send_message_handler(
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<SendMessageRequest>,
) -> Result<axum::response::Response, AppError> {
let message = service.send_message(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(MessageResponse::from(message)).into_response())
}
pub async fn delete_message_handler(
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(message_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
service.delete_message(message_id, auth.user_id).await?;
Ok(ApiMessage::ok("Message deleted").into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,22 @@
use axum::{middleware::from_fn, routing::{delete, get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::chat::application::chat_service::ChatServiceImpl;
use crate::chat::domain::service::ChatService;
use crate::chat::infrastructure::persistence::PostgresChatRepository;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
pub fn build_chat_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>) -> Router {
let service: Arc<dyn ChatService> = Arc::new(ChatServiceImpl::new(
Arc::new(PostgresChatRepository::new(pool.clone())),
));
Router::new()
.route("/chat/teams/:team_id", get(get_team_messages_handler).post(send_message_handler))
.route("/chat/messages/:message_id", delete(delete_message_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_chat_repository;
pub use postgres_chat_repository::PostgresChatRepository;
@@ -0,0 +1,127 @@
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::chat::domain::entity::*;
use crate::chat::domain::repository::ChatRepository;
#[derive(FromRow)]
struct MessageRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
}
impl From<MessageRow> for MessageEntity {
fn from(r: MessageRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
message: r.message,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
#[derive(FromRow)]
struct MessageWithUserRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
user_fullname: String,
user_avatar: Option<String>,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
}
impl From<MessageWithUserRow> for MessageWithUser {
fn from(r: MessageWithUserRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
user_fullname: r.user_fullname,
user_avatar: r.user_avatar,
message: r.message,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
#[derive(FromRow)]
struct UserInfoRow {
fullname: String,
avatar: Option<String>,
}
pub struct PostgresChatRepository {
pool: Arc<PgPool>,
}
impl PostgresChatRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl ChatRepository for PostgresChatRepository {
async fn find_team_messages(&self, team_id: Uuid) -> Result<Vec<MessageWithUser>, AppError> {
let rows: Vec<MessageWithUserRow> = sqlx::query_as(
"SELECT m.id, m.team_id, m.user_id, u.fullname AS user_fullname, u.avatar AS user_avatar, m.message, m.created_at, m.updated_at FROM hackathon_team_messages m JOIN hackathon_users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at ASC"
)
.bind(team_id).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 create_message(&self, id: Uuid, team_id: Uuid, user_id: Uuid, message: &str) -> Result<MessageEntity, AppError> {
let now = Utc::now();
let row: MessageRow = sqlx::query_as(
"INSERT INTO hackathon_team_messages (id, team_id, user_id, message, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, team_id, user_id, message, created_at, updated_at"
)
.bind(id).bind(team_id).bind(user_id).bind(message).bind(now).bind(now)
.fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.into())
}
async fn find_message_by_id(&self, id: Uuid) -> Result<Option<MessageEntity>, AppError> {
let row: Option<MessageRow> = sqlx::query_as(
"SELECT id, team_id, user_id, message, created_at, updated_at FROM hackathon_team_messages 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 delete_message(&self, id: Uuid) -> Result<bool, AppError> {
let result = sqlx::query("DELETE FROM hackathon_team_messages WHERE id = $1")
.bind(id).execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
async fn get_user_info(&self, user_id: Uuid) -> Result<Option<(String, Option<String>)>, AppError> {
let row: Option<UserInfoRow> = sqlx::query_as(
"SELECT fullname, avatar FROM hackathon_users WHERE id = $1"
)
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(|r| (r.fullname, r.avatar)))
}
async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')")
.bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)")
.bind(team_id).bind(user_id).fetch_one(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_chat_routes;
+120
View File
@@ -0,0 +1,120 @@
pub static INDONESIAN_CITIES: &[&str] = &[
"Aceh", "Banda Aceh", "Sabang", "Langsa", "Lhokseumawe", "Subulussalam",
"Bireuen", "Aceh Besar", "Aceh Timur", "Aceh Utara", "Aceh Barat", "Nagan Raya",
"Aceh Selatan", "Aceh Tenggara", "Gayo Lues", "Aceh Tengah", "Bener Meriah",
"Pidie", "Pidie Jaya", "Aceh Jaya", "Aceh Barat Daya", "Aceh Singkil", "Simeulue",
"Medan", "Binjai", "Tebing Tinggi", "Pematangsiantar", "Tanjungbalai", "Sibolga",
"Padangsidimpuan", "Gunungsitoli",
"Deli Serdang", "Asahan", "Langkat", "Serdang Bedagai", "Batubara", "Labuhanbatu",
"Labuhanbatu Utara", "Labuhanbatu Selatan", "Karo", "Dairi", "Pakpak Bharat",
"Humbang Hasundutan", "Toba", "Samosir", "Tapanuli Utara", "Tapanuli Tengah",
"Tapanuli Selatan", "Padang Lawas", "Padang Lawas Utara", "Mandailing Natal",
"Nias", "Nias Utara", "Nias Barat", "Nias Selatan",
"Padang", "Solok", "Sawah Lunto", "Padangpanjang", "Bukittinggi", "Payakumbuh", "Pariaman",
"Agam", "Limapuluh Kota", "Tanah Datar", "Padang Pariaman", "Pesisir Selatan",
"Solok Selatan", "Sijunjung", "Dharmasraya", "Pasaman", "Pasaman Barat",
"Kepulauan Mentawai",
"Pekanbaru", "Dumai",
"Kampar", "Pelalawan", "Siak", "Bengkalis", "Rokan Hilir", "Rokan Hulu",
"Kuantan Singingi", "Indragiri Hulu", "Indragiri Hilir", "Kepulauan Meranti",
"Jambi", "Sungai Penuh",
"Batanghari", "Muaro Jambi", "Tanjung Jabung Timur", "Tanjung Jabung Barat",
"Sarolangun", "Merangin", "Bungo", "Tebo", "Kerinci",
"Palembang", "Pagar Alam", "Lubuklinggau", "Prabumulih",
"Ogan Komering Ulu", "Ogan Komering Ulu Timur", "Ogan Komering Ulu Selatan",
"Ogan Komering Ilir", "Ogan Ilir", "Muara Enim", "Lahat", "Empat Lawang",
"Musi Banyuasin", "Banyuasin", "Musi Rawas", "Musi Rawas Utara", "Penukal Abab Lematang Ilir",
"Bengkulu", "Bengkulu Utara", "Bengkulu Selatan", "Bengkulu Tengah",
"Rejang Lebong", "Kepahiang", "Lebong", "Seluma", "Kaur", "Mukomuko",
"Bandar Lampung", "Metro",
"Lampung Utara", "Lampung Selatan", "Lampung Tengah", "Lampung Barat", "Lampung Timur",
"Tulang Bawang", "Tulang Bawang Barat", "Mesuji", "Pringsewu", "Pesawaran",
"Tanggamus", "Way Kanan", "Pesisir Barat",
"Pangkalpinang",
"Bangka", "Bangka Tengah", "Bangka Selatan", "Bangka Barat", "Belitung", "Belitung Timur",
"Tanjungpinang", "Batam",
"Bintan", "Karimun", "Natuna", "Anambas", "Lingga",
"Jakarta", "Jakarta Selatan", "Jakarta Timur", "Jakarta Pusat", "Jakarta Barat", "Jakarta Utara",
"Kepulauan Seribu",
"Bogor", "Sukabumi", "Bandung", "Cirebon", "Bekasi", "Depok", "Cimahi", "Tasikmalaya", "Banjar",
"Cianjur", "Garut", "Tasikmalaya", "Ciamis", "Kuningan", "Majalengka", "Sumedang",
"Indramayu", "Subang", "Purwakarta", "Karawang", "Bekasi", "Bandung Barat", "Pangandaran",
"Semarang", "Surakarta", "Magelang", "Salatiga", "Pekalongan", "Tegal",
"Cilacap", "Banyumas", "Purbalingga", "Banjarnegara", "Kebumen", "Purworejo",
"Wonosobo", "Magelang", "Boyolali", "Klaten", "Sukoharjo", "Wonogiri", "Karanganyar",
"Sragen", "Grobogan", "Blora", "Rembang", "Pati", "Kudus", "Jepara", "Demak",
"Semarang", "Temanggung", "Kendal", "Batang", "Pekalongan", "Pemalang", "Tegal", "Brebes",
"Yogyakarta",
"Sleman", "Bantul", "Kulon Progo", "Gunungkidul",
"Surabaya", "Malang", "Kediri", "Blitar", "Madiun", "Mojokerto", "Pasuruan", "Probolinggo",
"Batu",
"Pacitan", "Ponorogo", "Trenggalek", "Tulungagung", "Blitar", "Kediri", "Malang",
"Lumajang", "Jember", "Banyuwangi", "Bondowoso", "Situbondo", "Probolinggo", "Pasuruan",
"Sidoarjo", "Mojokerto", "Jombang", "Nganjuk", "Madiun", "Magetan", "Ngawi",
"Bojonegoro", "Tuban", "Lamongan", "Gresik", "Bangkalan", "Sampang", "Pamekasan", "Sumenep",
"Serang", "Cilegon", "Tangerang", "Tangerang Selatan",
"Pandeglang", "Lebak", "Tangerang",
"Denpasar",
"Badung", "Gianyar", "Tabanan", "Bangli", "Klungkung", "Buleleng", "Jembrana", "Karangasem",
"Mataram", "Bima",
"Lombok Barat", "Lombok Tengah", "Lombok Timur", "Lombok Utara", "Sumbawa", "Sumbawa Barat",
"Dompu", "Bima",
"Kupang",
"Sumba Barat", "Sumba Timur", "Sumba Tengah", "Sumba Barat Daya",
"Flores Timur", "Sikka", "Ende", "Ngada", "Nagekeo", "Manggarai", "Manggarai Timur",
"Manggarai Barat", "Rote Ndao", "Kupang", "Timor Tengah Selatan", "Timor Tengah Utara",
"Belu", "Malaka", "Alor", "Lembata",
"Pontianak", "Singkawang",
"Sambas", "Bengkayang", "Landak", "Mempawah", "Sanggau", "Sekadau", "Melawi", "Sintang",
"Kapuas Hulu", "Kubu Raya", "Kayong Utara", "Ketapang",
"Palangkaraya",
"Kotawaringin Barat", "Kotawaringin Timur", "Kapuas", "Barito Selatan", "Barito Utara",
"Katingan", "Seruyan", "Sukamara", "Lamandau", "Gunung Mas", "Pulang Pisau",
"Murung Raya", "Barito Timur",
"Banjarmasin", "Banjarbaru",
"Tanah Laut", "Kotabaru", "Banjar", "Barito Kuala", "Tapin", "Hulu Sungai Selatan",
"Hulu Sungai Tengah", "Hulu Sungai Utara", "Tabalong", "Tanah Bumbu", "Balangan",
"Samarinda", "Balikpapan", "Bontang",
"Paser", "Kutai Barat", "Kutai Kartanegara", "Kutai Timur", "Berau",
"Penajam Paser Utara", "Mahakam Ulu",
"Tarakan",
"Bulungan", "Tana Tidung", "Malinau", "Nunukan",
"Manado", "Bitung", "Tomohon", "Kotamobagu",
"Minahasa", "Minahasa Utara", "Minahasa Selatan", "Minahasa Tenggara",
"Bolaang Mongondow", "Bolaang Mongondow Utara", "Bolaang Mongondow Selatan",
"Bolaang Mongondow Timur", "Kepulauan Sangihe", "Kepulauan Sitaro", "Kepulauan Talaud",
"Palu",
"Donggala", "Sigi", "Parigi Moutong", "Tojo Una-Una", "Banggai", "Banggai Kepulauan",
"Banggai Laut", "Morowali", "Morowali Utara", "Poso", "Toli-Toli", "Buol",
"Gorontalo",
"Gorontalo", "Bone Bolango", "Pohuwato", "Boalemo", "Gorontalo Utara",
"Makassar", "Parepare", "Palopo",
"Gowa", "Takalar", "Jeneponto", "Bantaeng", "Bulukumba", "Selayar", "Sinjai",
"Bone", "Soppeng", "Wajo", "Sidrap", "Pinrang", "Enrekang", "Tana Toraja",
"Toraja Utara", "Luwu", "Luwu Timur", "Luwu Utara", "Barru", "Pangkep", "Maros",
"Mamuju", "Mamuju Tengah", "Mamuju Utara",
"Mamasa", "Polewali Mandar", "Majene",
"Kendari", "Baubau",
"Konawe", "Konawe Selatan", "Konawe Utara", "Konawe Kepulauan", "Kolaka", "Kolaka Timur",
"Kolaka Utara", "Bombana", "Buton", "Buton Selatan", "Buton Tengah", "Buton Utara",
"Muna", "Muna Barat", "Wakatobi",
"Ambon", "Tual",
"Buru", "Buru Selatan", "Seram Bagian Barat", "Seram Bagian Timur", "Maluku Tengah",
"Maluku Tenggara", "Maluku Barat Daya", "Kepulauan Aru",
"Ternate", "Tidore Kepulauan",
"Halmahera Barat", "Halmahera Utara", "Halmahera Timur", "Halmahera Selatan",
"Halmahera Tengah", "Kepulauan Sula", "Pulau Morotai", "Pulau Taliabu",
"Jayapura",
"Merauke", "Jayawijaya", "Mimika", "Boven Digoel", "Mappi", "Asmat", "Yahukimo",
"Pegunungan Bintang", "Tolikara", "Sarmi", "Keerom", "Waropen", "Supiori",
"Mamberamo Raya", "Nduga", "Lanny Jaya", "Mamberamo Tengah", "Yalimo", "Puncak",
"Dogiyai", "Intan Jaya", "Deiyai", "Puncak Jaya",
"Sorong", "Sorong Selatan", "Raja Ampat", "Teluk Bintuni", "Teluk Wondama",
"Manokwari", "Manokwari Selatan", "Pegunungan Arfak", "Fakfak", "Kaimana",
"Maybrat", "Tambrauw",
];
pub fn is_valid_indonesian_city(city: &str) -> bool {
let city_lower = city.to_lowercase();
INDONESIAN_CITIES.iter().any(|c| c.to_lowercase() == city_lower)
}
@@ -0,0 +1,59 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use imphnen_utils::errors::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HackathonClaims {
pub sub: String,
pub exp: i64,
pub iat: i64,
pub jti: String,
#[serde(default)]
pub token_type: String,
}
#[derive(Clone)]
pub struct HackathonJwtService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
expiry_hours: i64,
}
impl HackathonJwtService {
pub fn new(secret: &str, expiry_hours: i64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_hours,
}
}
pub fn generate_token(&self, user_id: Uuid) -> Result<String, AppError> {
self.generate_token_with_type(user_id, "access", self.expiry_hours)
}
pub fn generate_refresh_token(&self, user_id: Uuid) -> Result<String, AppError> {
self.generate_token_with_type(user_id, "refresh", self.expiry_hours * 7)
}
fn generate_token_with_type(&self, user_id: Uuid, token_type: &str, expiry_hours: i64) -> Result<String, AppError> {
let now = Utc::now();
let claims = HackathonClaims {
sub: user_id.to_string(),
exp: (now + Duration::hours(expiry_hours)).timestamp(),
iat: now.timestamp(),
jti: Uuid::new_v4().to_string(),
token_type: token_type.to_string(),
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub fn verify_token(&self, token: &str) -> Result<HackathonClaims, AppError> {
decode::<HackathonClaims>(token, &self.decoding_key, &Validation::default())
.map(|d| d.claims)
.map_err(|_| AppError::AuthenticationError("Invalid or expired token".to_string()))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod cities;
pub mod hackathon_jwt;
pub mod supabase_client;
@@ -0,0 +1,107 @@
use imphnen_utils::errors::AppError;
use serde_json::{json, Value};
pub struct SupabaseClient {
pub base_url: String,
pub anon_key: String,
pub service_role_key: String,
pub storage_bucket: String,
client: reqwest::Client,
}
impl SupabaseClient {
pub fn new(base_url: String, anon_key: String, service_role_key: String, storage_bucket: String) -> Self {
Self {
base_url,
anon_key,
service_role_key,
storage_bucket,
client: reqwest::Client::new(),
}
}
pub async fn signup(&self, email: &str, password: &str, fullname: &str, frontend_url: &str) -> Result<Value, AppError> {
let resp = self.client
.post(format!("{}/auth/v1/signup", self.base_url))
.header("apikey", &self.anon_key)
.json(&json!({
"email": email,
"password": password,
"options": {
"data": { "fullname": fullname },
"emailRedirectTo": format!("{}/auth/callback", frontend_url)
}
}))
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if !resp.status().is_success() {
return Err(AppError::BadRequestError("Signup failed. Email may already be registered.".to_string()));
}
resp.json::<Value>().await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub async fn login(&self, email: &str, password: &str) -> Result<Value, AppError> {
let resp = self.client
.post(format!("{}/auth/v1/token?grant_type=password", self.base_url))
.header("apikey", &self.anon_key)
.json(&json!({ "email": email, "password": password }))
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if !resp.status().is_success() {
return Err(AppError::AuthenticationError("Invalid email or password".to_string()));
}
resp.json::<Value>().await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub async fn recover_password(&self, email: &str, frontend_url: &str) -> Result<(), AppError> {
let _ = self.client
.post(format!("{}/auth/v1/recover", self.base_url))
.header("apikey", &self.anon_key)
.json(&json!({
"email": email,
"redirectTo": format!("{}/auth/callback", frontend_url)
}))
.send()
.await;
Ok(())
}
pub async fn update_password(&self, access_token: &str, new_password: &str) -> Result<(), AppError> {
let resp = self.client
.put(format!("{}/auth/v1/user", self.base_url))
.header("apikey", &self.anon_key)
.header("Authorization", format!("Bearer {}", access_token))
.json(&json!({ "password": new_password }))
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if !resp.status().is_success() {
return Err(AppError::BadRequestError("Password reset failed. Link may have expired.".to_string()));
}
Ok(())
}
pub async fn upload_file(&self, path: &str, content_type: &str, data: &[u8]) -> Result<String, AppError> {
let resp = self.client
.post(format!("{}/storage/v1/object/{}/{}", self.base_url, self.storage_bucket, path))
.header("apikey", &self.service_role_key)
.header("Authorization", format!("Bearer {}", self.service_role_key))
.header("Content-Type", content_type)
.body(data.to_vec())
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
return Err(AppError::InternalServerError(format!("Upload failed: {}", err)));
}
Ok(format!("{}/storage/v1/object/public/{}/{}", self.base_url, self.storage_bucket, path))
}
}
+45
View File
@@ -0,0 +1,45 @@
use std::env;
#[derive(Debug, Clone)]
pub struct HackathonConfig {
pub supabase_url: String,
pub supabase_anon_key: String,
pub supabase_service_role_key: String,
pub jwt_secret: String,
pub jwt_expiry_hours: i64,
pub github_client_id: String,
pub github_client_secret: String,
pub github_redirect_url: String,
pub smtp_host: String,
pub smtp_user: String,
pub smtp_password: String,
pub from_email: String,
pub storage_bucket: String,
pub frontend_url: String,
}
impl HackathonConfig {
pub fn from_env() -> Self {
Self {
supabase_url: env::var("HACKATHON_SUPABASE_URL").unwrap_or_default(),
supabase_anon_key: env::var("HACKATHON_SUPABASE_ANON_KEY").unwrap_or_default(),
supabase_service_role_key: env::var("HACKATHON_SUPABASE_SERVICE_ROLE_KEY").unwrap_or_default(),
jwt_secret: env::var("HACKATHON_JWT_SECRET").expect("HACKATHON_JWT_SECRET must be set"),
jwt_expiry_hours: env::var("HACKATHON_JWT_EXPIRY_HOURS")
.unwrap_or_else(|_| "168".to_string())
.parse()
.unwrap_or(168),
github_client_id: env::var("HACKATHON_GITHUB_CLIENT_ID").unwrap_or_default(),
github_client_secret: env::var("HACKATHON_GITHUB_CLIENT_SECRET").unwrap_or_default(),
github_redirect_url: env::var("HACKATHON_GITHUB_REDIRECT_URL").unwrap_or_default(),
smtp_host: env::var("HACKATHON_SMTP_HOST").unwrap_or_default(),
smtp_user: env::var("HACKATHON_SMTP_USER").unwrap_or_default(),
smtp_password: env::var("HACKATHON_SMTP_PASSWORD").unwrap_or_default(),
from_email: env::var("HACKATHON_FROM_EMAIL").unwrap_or_default(),
storage_bucket: env::var("HACKATHON_STORAGE_BUCKET")
.unwrap_or_else(|_| "hackathon-uploads".to_string()),
frontend_url: env::var("HACKATHON_FRONTEND_URL")
.unwrap_or_else(|_| "https://hackathon.imphnen.dev".to_string()),
}
}
}
@@ -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;
@@ -0,0 +1,117 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::join_requests::domain::entity::*;
use crate::join_requests::domain::repository::JoinRequestRepository;
use crate::join_requests::domain::service::JoinRequestService;
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 JoinRequestServiceImpl {
repo: Arc<dyn JoinRequestRepository>,
}
impl JoinRequestServiceImpl {
pub fn new(repo: Arc<dyn JoinRequestRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl JoinRequestService for JoinRequestServiceImpl {
async fn create_join_request(
&self,
team_id: Uuid,
user_id: Uuid,
input: CreateJoinRequestInput,
) -> Result<JoinRequestWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Join requests are closed (deadline: November 30, 2025).".to_string(),
));
}
if !self.repo.team_exists(team_id).await? {
return Err(AppError::NotFoundError("Team not found".to_string()));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError("Cannot request to 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(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team is already full (max 5 members)".to_string()));
}
if self.repo.pending_request_exists(team_id, user_id).await? {
return Err(AppError::ConflictError("You already have a pending request for this team".to_string()));
}
let id = Uuid::new_v4();
let entity = self.repo.create(id, team_id, user_id, &input.message).await?;
let details: Vec<JoinRequestWithDetails> = self.repo.find_by_user(user_id).await?;
details.into_iter().find(|r| r.id == entity.id)
.ok_or_else(|| AppError::InternalServerError("Failed to retrieve created join request".to_string()))
}
async fn get_my_join_requests(&self, user_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, AppError> {
self.repo.find_by_user(user_id).await
}
async fn get_team_join_requests(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<Vec<JoinRequestWithDetails>, AppError> {
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 != user_id {
return Err(AppError::ForbiddenError("Only the team leader can view join requests".to_string()));
}
self.repo.find_pending_by_team(team_id).await
}
async fn respond_to_join_request(
&self,
request_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let request = self.repo.find_by_id(request_id).await?
.ok_or_else(|| AppError::NotFoundError("Join request not found".to_string()))?;
let leader_id = self.repo.get_team_leader_id(request.team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != user_id {
return Err(AppError::ForbiddenError("Only the team leader can respond to join requests".to_string()));
}
if request.status != "pending" {
return Err(AppError::BadRequestError("Join request is no longer pending".to_string()));
}
if accept {
if is_team_features_closed() {
return Err(AppError::BadRequestError("Team features are now closed".to_string()));
}
if self.repo.team_has_submission(request.team_id).await? {
return Err(AppError::BadRequestError("Cannot accept join request after submitting a project".to_string()));
}
if let Some(active_team) = self.repo.user_active_team_name(request.user_id).await? {
return Err(AppError::ConflictError(format!("User is already a member of team '{}'", active_team)));
}
let count = self.repo.active_member_count(request.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team is already full".to_string()));
}
self.repo.update_status(request_id, "accepted").await?;
self.repo.add_team_member(request.team_id, request.user_id).await?;
self.repo.reject_pending_invitations_for_user(request.user_id).await?;
self.repo.reject_other_pending_for_user(request.user_id, request_id).await?;
} else {
self.repo.update_status(request_id, "rejected").await?;
}
Ok(())
}
}
@@ -0,0 +1 @@
pub mod join_request_service;
@@ -0,0 +1,30 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct JoinRequestEntity {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub message: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct JoinRequestWithDetails {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_email: String,
pub user_avatar: Option<String>,
pub message: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct CreateJoinRequestInput {
pub message: String,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,43 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait JoinRequestRepository: Send + Sync {
async fn create(
&self,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: &str,
) -> Result<JoinRequestEntity, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<JoinRequestEntity>, AppError>;
async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, AppError>;
async fn find_pending_by_team(&self, team_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, AppError>;
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>;
async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
async fn reject_pending_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn reject_other_pending_for_user(&self, user_id: Uuid, except_id: Uuid) -> Result<(), AppError>;
async fn get_team_leader_id(&self, team_id: Uuid) -> Result<Option<Uuid>, AppError>;
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn team_exists(&self, team_id: Uuid) -> Result<bool, 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>;
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
async fn pending_request_exists(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
}
@@ -0,0 +1,29 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait JoinRequestService: Send + Sync {
async fn create_join_request(
&self,
team_id: Uuid,
user_id: Uuid,
input: CreateJoinRequestInput,
) -> Result<JoinRequestWithDetails, AppError>;
async fn get_my_join_requests(&self, user_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, AppError>;
async fn get_team_join_requests(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<Vec<JoinRequestWithDetails>, AppError>;
async fn respond_to_join_request(
&self,
request_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::join_requests::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct JoinRequestResponse {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_email: String,
pub user_avatar: Option<String>,
pub message: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
impl From<JoinRequestWithDetails> for JoinRequestResponse {
fn from(e: JoinRequestWithDetails) -> Self {
Self {
id: e.id,
team_id: e.team_id,
user_id: e.user_id,
user_fullname: e.user_fullname,
user_email: e.user_email,
user_avatar: e.user_avatar,
message: e.message,
status: e.status,
created_at: e.created_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateJoinRequestRequest {
pub message: String,
}
impl From<CreateJoinRequestRequest> for CreateJoinRequestInput {
fn from(r: CreateJoinRequestRequest) -> Self {
Self { message: r.message }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RespondToJoinRequestRequest {
pub accept: bool,
}
@@ -0,0 +1,47 @@
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::join_requests::domain::service::JoinRequestService;
use super::dto::*;
pub async fn create_join_request_handler(
Extension(service): Extension<Arc<dyn JoinRequestService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<CreateJoinRequestRequest>,
) -> Result<axum::response::Response, AppError> {
let request = service.create_join_request(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(JoinRequestResponse::from(request)).into_response())
}
pub async fn get_my_join_requests_handler(
Extension(service): Extension<Arc<dyn JoinRequestService>>,
Extension(auth): Extension<HackathonAuthUser>,
) -> Result<axum::response::Response, AppError> {
let list = service.get_my_join_requests(auth.user_id).await?;
let response: Vec<JoinRequestResponse> = list.into_iter().map(JoinRequestResponse::from).collect();
Ok(ApiSuccess(response).into_response())
}
pub async fn get_team_join_requests_handler(
Extension(service): Extension<Arc<dyn JoinRequestService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let list = service.get_team_join_requests(team_id, auth.user_id).await?;
let response: Vec<JoinRequestResponse> = list.into_iter().map(JoinRequestResponse::from).collect();
Ok(ApiSuccess(response).into_response())
}
pub async fn respond_to_join_request_handler(
Extension(service): Extension<Arc<dyn JoinRequestService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(request_id): Path<Uuid>,
Json(body): Json<RespondToJoinRequestRequest>,
) -> Result<axum::response::Response, AppError> {
service.respond_to_join_request(request_id, auth.user_id, body.accept).await?;
let msg = if body.accept { "Join request accepted" } else { "Join request rejected" };
Ok(ApiMessage::ok(msg).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,24 @@
use axum::{middleware::from_fn, routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::join_requests::application::join_request_service::JoinRequestServiceImpl;
use crate::join_requests::domain::service::JoinRequestService;
use crate::join_requests::infrastructure::persistence::PostgresJoinRequestRepository;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
pub fn build_join_request_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>) -> Router {
let service: Arc<dyn JoinRequestService> = Arc::new(JoinRequestServiceImpl::new(
Arc::new(PostgresJoinRequestRepository::new(pool.clone())),
));
Router::new()
.route("/join-requests/teams/:team_id", post(create_join_request_handler))
.route("/join-requests/my", get(get_my_join_requests_handler))
.route("/join-requests/teams/:team_id/pending", get(get_team_join_requests_handler))
.route("/join-requests/:request_id/respond", post(respond_to_join_request_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_join_request_repository;
pub use postgres_join_request_repository::PostgresJoinRequestRepository;
@@ -0,0 +1,173 @@
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::join_requests::domain::entity::*;
use crate::join_requests::domain::repository::JoinRequestRepository;
#[derive(FromRow)]
struct JoinRequestRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: String,
status: String,
created_at: Option<DateTime<Utc>>,
}
impl From<JoinRequestRow> for JoinRequestEntity {
fn from(r: JoinRequestRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
message: r.message,
status: r.status,
created_at: r.created_at,
}
}
}
#[derive(FromRow)]
struct JoinRequestDetailsRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
user_fullname: String,
user_email: String,
user_avatar: Option<String>,
message: String,
status: String,
created_at: Option<DateTime<Utc>>,
}
impl From<JoinRequestDetailsRow> for JoinRequestWithDetails {
fn from(r: JoinRequestDetailsRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
user_fullname: r.user_fullname,
user_email: r.user_email,
user_avatar: r.user_avatar,
message: r.message,
status: r.status,
created_at: r.created_at,
}
}
}
pub struct PostgresJoinRequestRepository {
pool: Arc<PgPool>,
}
impl PostgresJoinRequestRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl JoinRequestRepository for PostgresJoinRequestRepository {
async fn create(&self, id: Uuid, team_id: Uuid, user_id: Uuid, message: &str) -> Result<JoinRequestEntity, AppError> {
let row: JoinRequestRow = sqlx::query_as(
"INSERT INTO hackathon_team_join_requests (id, team_id, user_id, message, status, created_at) VALUES ($1, $2, $3, $4, 'pending', NOW()) RETURNING id, team_id, user_id, message, status, created_at"
)
.bind(id).bind(team_id).bind(user_id).bind(message)
.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<JoinRequestEntity>, AppError> {
let row: Option<JoinRequestRow> = sqlx::query_as(
"SELECT id, team_id, user_id, message, status, created_at FROM hackathon_team_join_requests 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_by_user(&self, user_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, AppError> {
let rows: Vec<JoinRequestDetailsRow> = sqlx::query_as(
"SELECT r.id, r.team_id, r.user_id, u.fullname AS user_fullname, u.email AS user_email, u.avatar AS user_avatar, r.message, r.status, r.created_at FROM hackathon_team_join_requests r JOIN hackathon_users u ON u.id = r.user_id WHERE r.user_id = $1 ORDER BY r.created_at DESC"
)
.bind(user_id).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 find_pending_by_team(&self, team_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, AppError> {
let rows: Vec<JoinRequestDetailsRow> = sqlx::query_as(
"SELECT r.id, r.team_id, r.user_id, u.fullname AS user_fullname, u.email AS user_email, u.avatar AS user_avatar, r.message, r.status, r.created_at FROM hackathon_team_join_requests r JOIN hackathon_users u ON u.id = r.user_id WHERE r.team_id = $1 AND r.status = 'pending' ORDER BY r.created_at ASC"
)
.bind(team_id).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_join_requests 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 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_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError> {
let email: Option<String> = 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()))?;
if let Some(email) = email {
sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending'")
.bind(email)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
}
Ok(())
}
async fn reject_other_pending_for_user(&self, user_id: Uuid, except_id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending' AND id != $2")
.bind(user_id).bind(except_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_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 team_exists(&self, team_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1)")
.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()))
}
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 pending_request_exists(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_join_requests WHERE team_id = $1 AND user_id = $2 AND status = 'pending')")
.bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::build_join_request_routes;
+56
View File
@@ -0,0 +1,56 @@
pub mod config;
pub mod common;
pub mod middleware;
pub mod admin;
pub mod auth;
pub mod certificates;
pub mod chat;
pub mod storage;
pub mod submissions;
pub mod teams;
pub mod users;
pub mod invitations;
pub mod join_requests;
pub mod winners;
pub use admin::hackathon_admin_routes;
pub use auth::hackathon_auth_routes;
pub use certificates::hackathon_certificates_routes;
pub use chat::build_chat_routes;
pub use storage::hackathon_storage_routes;
pub use submissions::hackathon_submissions_routes;
pub use teams::build_team_routes;
pub use users::hackathon_users_routes;
pub use invitations::build_invitation_routes;
pub use join_requests::build_join_request_routes;
pub use winners::hackathon_winners_routes;
pub use config::HackathonConfig;
use axum::Router;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use common::{hackathon_jwt::HackathonJwtService, supabase_client::SupabaseClient};
pub fn hackathon_router(db: DatabaseConnection, config: Arc<HackathonConfig>) -> Router {
let pool = Arc::new(db.get_postgres_connection_pool().clone());
let jwt = Arc::new(HackathonJwtService::new(&config.jwt_secret, config.jwt_expiry_hours));
let supabase = Arc::new(SupabaseClient::new(
config.supabase_url.clone(),
config.supabase_anon_key.clone(),
config.supabase_service_role_key.clone(),
config.storage_bucket.clone(),
));
Router::new()
.merge(hackathon_auth_routes(pool.clone(), jwt.clone(), supabase.clone(), config.clone()))
.merge(hackathon_users_routes(pool.clone(), jwt.clone()))
.merge(build_team_routes(pool.clone(), jwt.clone()))
.merge(build_invitation_routes(pool.clone(), jwt.clone()))
.merge(build_join_request_routes(pool.clone(), jwt.clone()))
.merge(build_chat_routes(pool.clone(), jwt.clone()))
.merge(hackathon_submissions_routes(pool.clone(), jwt.clone()))
.merge(hackathon_storage_routes(pool.clone(), jwt.clone(), supabase))
.merge(hackathon_certificates_routes(pool.clone()))
.merge(hackathon_winners_routes(pool.clone()))
.merge(hackathon_admin_routes(pool, jwt))
}
@@ -0,0 +1,21 @@
use axum::{
body::Body,
extract::Extension,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::middleware::hackathon_auth::HackathonAuthUser;
pub async fn admin_only(
Extension(auth_user): Extension<HackathonAuthUser>,
req: Request<Body>,
next: Next,
) -> Response {
if !auth_user.is_admin {
return (StatusCode::FORBIDDEN, Json(json!({ "message": "Forbidden - Admin access required" }))).into_response();
}
next.run(req).await
}
@@ -0,0 +1,48 @@
use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}};
use axum::http::StatusCode;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use crate::common::hackathon_jwt::HackathonJwtService;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HackathonAuthUser {
pub user_id: Uuid,
pub is_admin: bool,
}
pub async fn hackathon_auth_middleware(
axum::Extension(jwt_service): axum::Extension<Arc<HackathonJwtService>>,
axum::Extension(pool): axum::Extension<Arc<PgPool>>,
mut request: Request<Body>,
next: Next,
) -> Result<Response, Response> {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response())?;
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
(StatusCode::UNAUTHORIZED, "Invalid Authorization header format").into_response()
})?;
let claims = jwt_service.verify_token(token).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response()
})?;
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response()
})?;
let is_admin: bool = sqlx::query_scalar("SELECT COALESCE(is_admin, false) FROM hackathon_users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool.as_ref())
.await
.unwrap_or(None)
.unwrap_or(false);
request.extensions_mut().insert(HackathonAuthUser { user_id, is_admin });
Ok(next.run(request).await)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod admin_only;
pub mod hackathon_auth;
+4
View File
@@ -0,0 +1,4 @@
pub mod service;
pub mod routes;
pub use routes::hackathon_storage_routes;
+72
View File
@@ -0,0 +1,72 @@
use axum::{middleware::from_fn, response::IntoResponse, routing::post, Extension, Json, Router};
use sqlx::PgPool;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::common::supabase_client::SupabaseClient;
use crate::middleware::hackathon_auth::{hackathon_auth_middleware, HackathonAuthUser};
use super::service::StorageService;
#[derive(Debug, Deserialize, ToSchema)]
pub struct UploadRequest {
pub filename: String,
pub content_type: String,
pub data: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct UploadResponse {
pub url: String,
}
async fn upload_file_handler(
Extension(service): Extension<Arc<StorageService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<UploadRequest>,
) -> Result<axum::response::Response, AppError> {
let url = service.upload("uploads", auth.user_id, &body.filename, &body.content_type, &body.data).await?;
Ok(ApiSuccess(UploadResponse { url }).into_response())
}
async fn upload_avatar_handler(
Extension(service): Extension<Arc<StorageService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<UploadRequest>,
) -> Result<axum::response::Response, AppError> {
let url = service.upload("avatars", auth.user_id, &body.filename, &body.content_type, &body.data).await?;
Ok(ApiSuccess(UploadResponse { url }).into_response())
}
async fn upload_team_handler(
Extension(service): Extension<Arc<StorageService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<UploadRequest>,
) -> Result<axum::response::Response, AppError> {
let url = service.upload("teams", auth.user_id, &body.filename, &body.content_type, &body.data).await?;
Ok(ApiSuccess(UploadResponse { url }).into_response())
}
async fn upload_submission_handler(
Extension(service): Extension<Arc<StorageService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<UploadRequest>,
) -> Result<axum::response::Response, AppError> {
let url = service.upload("submissions", auth.user_id, &body.filename, &body.content_type, &body.data).await?;
Ok(ApiSuccess(UploadResponse { url }).into_response())
}
pub fn hackathon_storage_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>, supabase: Arc<SupabaseClient>) -> Router {
let service = Arc::new(StorageService::new(supabase));
Router::new()
.route("/upload", post(upload_file_handler))
.route("/upload/avatar", post(upload_avatar_handler))
.route("/upload/team", post(upload_team_handler))
.route("/upload/submission", post(upload_submission_handler))
.layer(Extension(service))
.layer(Extension(jwt.clone()))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
+22
View File
@@ -0,0 +1,22 @@
use std::sync::Arc;
use base64::Engine;
use chrono::Utc;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use crate::common::supabase_client::SupabaseClient;
pub struct StorageService {
supabase: Arc<SupabaseClient>,
}
impl StorageService {
pub fn new(supabase: Arc<SupabaseClient>) -> Self { Self { supabase } }
pub async fn upload(&self, folder: &str, user_id: Uuid, filename: &str, content_type: &str, data_base64: &str) -> Result<String, AppError> {
let ext = filename.rsplit('.').next().unwrap_or("bin");
let path = format!("{}/{}-{}.{}", folder, user_id, Utc::now().timestamp_millis(), ext);
let data = base64::engine::general_purpose::STANDARD.decode(data_base64)
.map_err(|_| AppError::BadRequestError("Invalid base64 data".to_string()))?;
self.supabase.upload_file(&path, content_type, &data).await
}
}
@@ -0,0 +1 @@
pub mod submission_service;
@@ -0,0 +1,98 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::submissions::domain::entity::*;
use crate::submissions::domain::repository::SubmissionRepository;
use crate::submissions::domain::service::SubmissionService;
fn is_submission_deadline_passed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 12, 7, 16, 59, 0).unwrap();
Utc::now() >= deadline
}
pub struct SubmissionServiceImpl {
repo: Arc<dyn SubmissionRepository>,
}
impl SubmissionServiceImpl {
pub fn new(repo: Arc<dyn SubmissionRepository>) -> Self { Self { repo } }
}
#[async_trait]
impl SubmissionService for SubmissionServiceImpl {
async fn create_submission(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result<SubmissionEntity, AppError> {
if is_submission_deadline_passed() {
return Err(AppError::BadRequestError("Submission deadline has passed (December 7, 2025 23:59 WIB).".to_string()));
}
if !self.repo.is_team_leader(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can create submission".to_string()));
}
if self.repo.find_by_team(team_id).await?.is_some() {
return Err(AppError::ConflictError("Team already has a submission".to_string()));
}
self.repo.create(team_id, user_id, input).await
}
async fn get_team_submission(&self, team_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError> {
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team members can view submission".to_string()));
}
self.repo.find_by_team(team_id).await?.ok_or_else(|| AppError::NotFoundError("No submission found".to_string()))
}
async fn update_submission(&self, submission_id: Uuid, user_id: Uuid, input: UpdateSubmissionInput) -> Result<SubmissionEntity, AppError> {
if is_submission_deadline_passed() {
return Err(AppError::BadRequestError("Submission deadline has passed.".to_string()));
}
let sub = self.repo.find_by_id(submission_id).await?;
if !self.repo.is_team_leader(sub.team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can update submission".to_string()));
}
if sub.status != "draft" {
return Err(AppError::BadRequestError("Can only update draft submissions".to_string()));
}
self.repo.update(submission_id, input).await
}
async fn submit_project(&self, submission_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError> {
if is_submission_deadline_passed() {
return Err(AppError::BadRequestError("Submission deadline has passed.".to_string()));
}
let sub = self.repo.find_by_id(submission_id).await?;
if !self.repo.is_team_leader(sub.team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can submit".to_string()));
}
if sub.status != "draft" {
return Err(AppError::BadRequestError("Can only submit from draft status".to_string()));
}
let count = self.repo.team_member_count(sub.team_id).await?;
if count < 2 {
return Err(AppError::BadRequestError("Team must have at least 2 members to submit".to_string()));
}
self.repo.update_status(submission_id, "pending").await
}
async fn confirm_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError> {
let sub = self.repo.find_by_id(submission_id).await?;
if !self.repo.is_team_leader(sub.team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can confirm submission".to_string()));
}
if sub.status != "pending" {
return Err(AppError::BadRequestError("Can only confirm pending submissions".to_string()));
}
self.repo.update_status(submission_id, "submitted").await
}
async fn cancel_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError> {
let sub = self.repo.find_by_id(submission_id).await?;
if !self.repo.is_team_leader(sub.team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can cancel submission".to_string()));
}
if sub.status == "submitted" {
return Err(AppError::BadRequestError("Cannot cancel a confirmed submission".to_string()));
}
self.repo.update_status(submission_id, "draft").await
}
}
@@ -0,0 +1,39 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct SubmissionEntity {
pub id: Uuid,
pub team_id: Uuid,
pub project_name: String,
pub description: String,
pub repository_url: String,
pub demo_url: Option<String>,
pub presentation_url: Option<String>,
pub screenshots: Option<Vec<String>>,
pub status: String,
pub submitted_at: Option<DateTime<Utc>>,
pub submitted_by: Uuid,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct CreateSubmissionInput {
pub project_name: String,
pub description: String,
pub repository_url: String,
pub demo_url: Option<String>,
pub presentation_url: Option<String>,
pub screenshots: Option<Vec<String>>,
}
#[derive(Debug, Default)]
pub struct UpdateSubmissionInput {
pub project_name: Option<String>,
pub description: Option<String>,
pub repository_url: Option<String>,
pub demo_url: Option<String>,
pub presentation_url: Option<String>,
pub screenshots: Option<Vec<String>>,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,16 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait SubmissionRepository: Send + Sync {
async fn create(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result<SubmissionEntity, AppError>;
async fn find_by_team(&self, team_id: Uuid) -> Result<Option<SubmissionEntity>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<SubmissionEntity, AppError>;
async fn update(&self, id: Uuid, input: UpdateSubmissionInput) -> Result<SubmissionEntity, AppError>;
async fn update_status(&self, id: Uuid, status: &str) -> Result<SubmissionEntity, AppError>;
async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn team_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
}
@@ -0,0 +1,14 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait SubmissionService: Send + Sync {
async fn create_submission(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result<SubmissionEntity, AppError>;
async fn get_team_submission(&self, team_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError>;
async fn update_submission(&self, submission_id: Uuid, user_id: Uuid, input: UpdateSubmissionInput) -> Result<SubmissionEntity, AppError>;
async fn submit_project(&self, submission_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError>;
async fn confirm_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError>;
async fn cancel_submission(&self, submission_id: Uuid, user_id: Uuid) -> Result<SubmissionEntity, AppError>;
}
@@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::submissions::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SubmissionResponse {
pub id: Uuid,
pub team_id: Uuid,
pub project_name: String,
pub description: String,
pub repository_url: String,
pub demo_url: Option<String>,
pub presentation_url: Option<String>,
pub screenshots: Option<Vec<String>>,
pub status: String,
pub submitted_at: Option<DateTime<Utc>>,
pub submitted_by: Uuid,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
impl From<SubmissionEntity> for SubmissionResponse {
fn from(e: SubmissionEntity) -> Self {
Self {
id: e.id, team_id: e.team_id, project_name: e.project_name, description: e.description,
repository_url: e.repository_url, demo_url: e.demo_url, presentation_url: e.presentation_url,
screenshots: e.screenshots, status: e.status, submitted_at: e.submitted_at,
submitted_by: e.submitted_by, created_at: e.created_at, updated_at: e.updated_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateSubmissionRequest {
pub project_name: String,
pub description: String,
pub repository_url: String,
pub demo_url: Option<String>,
pub presentation_url: Option<String>,
pub screenshots: Option<Vec<String>>,
}
impl From<CreateSubmissionRequest> for CreateSubmissionInput {
fn from(r: CreateSubmissionRequest) -> Self {
Self {
project_name: r.project_name, description: r.description, repository_url: r.repository_url,
demo_url: r.demo_url, presentation_url: r.presentation_url, screenshots: r.screenshots,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateSubmissionRequest {
pub project_name: Option<String>,
pub description: Option<String>,
pub repository_url: Option<String>,
pub demo_url: Option<String>,
pub presentation_url: Option<String>,
pub screenshots: Option<Vec<String>>,
}
impl From<UpdateSubmissionRequest> for UpdateSubmissionInput {
fn from(r: UpdateSubmissionRequest) -> Self {
Self {
project_name: r.project_name, description: r.description, repository_url: r.repository_url,
demo_url: r.demo_url, presentation_url: r.presentation_url, screenshots: r.screenshots,
}
}
}
@@ -0,0 +1,63 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use std::sync::Arc;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::submissions::domain::service::SubmissionService;
use super::dto::*;
pub async fn create_submission_handler(
Extension(service): Extension<Arc<dyn SubmissionService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<CreateSubmissionRequest>,
) -> Result<axum::response::Response, AppError> {
let sub = service.create_submission(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response())
}
pub async fn get_team_submission_handler(
Extension(service): Extension<Arc<dyn SubmissionService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let sub = service.get_team_submission(team_id, auth.user_id).await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response())
}
pub async fn update_submission_handler(
Extension(service): Extension<Arc<dyn SubmissionService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(submission_id): Path<Uuid>,
Json(body): Json<UpdateSubmissionRequest>,
) -> Result<axum::response::Response, AppError> {
let sub = service.update_submission(submission_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response())
}
pub async fn submit_project_handler(
Extension(service): Extension<Arc<dyn SubmissionService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(submission_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let sub = service.submit_project(submission_id, auth.user_id).await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response())
}
pub async fn confirm_submission_handler(
Extension(service): Extension<Arc<dyn SubmissionService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(submission_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let sub = service.confirm_submission(submission_id, auth.user_id).await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response())
}
pub async fn cancel_submission_handler(
Extension(service): Extension<Arc<dyn SubmissionService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(submission_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let sub = service.cancel_submission(submission_id, auth.user_id).await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).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, put}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::submissions::application::submission_service::SubmissionServiceImpl;
use crate::submissions::domain::service::SubmissionService;
use crate::submissions::infrastructure::persistence::PostgresSubmissionRepository;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
pub fn hackathon_submissions_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>) -> Router {
let service: Arc<dyn SubmissionService> = Arc::new(SubmissionServiceImpl::new(Arc::new(PostgresSubmissionRepository::new(pool.clone()))));
Router::new()
.route("/submissions/teams/:team_id", get(get_team_submission_handler).post(create_submission_handler))
.route("/submissions/:submission_id", put(update_submission_handler))
.route("/submissions/:submission_id/submit", post(submit_project_handler))
.route("/submissions/:submission_id/confirm", post(confirm_submission_handler))
.route("/submissions/:submission_id/cancel", post(cancel_submission_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_submission_repository;
pub use postgres_submission_repository::PostgresSubmissionRepository;
@@ -0,0 +1,106 @@
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::submissions::domain::entity::*;
use crate::submissions::domain::repository::SubmissionRepository;
#[derive(FromRow)]
struct SubmissionRow {
id: Uuid, team_id: Uuid, project_name: String, description: String, repository_url: String,
demo_url: Option<String>, presentation_url: Option<String>, screenshots: Option<Vec<String>>,
status: String, submitted_at: Option<DateTime<Utc>>, submitted_by: Uuid,
created_at: Option<DateTime<Utc>>, updated_at: Option<DateTime<Utc>>,
}
impl From<SubmissionRow> for SubmissionEntity {
fn from(r: SubmissionRow) -> Self {
Self {
id: r.id, team_id: r.team_id, project_name: r.project_name, description: r.description,
repository_url: r.repository_url, demo_url: r.demo_url, presentation_url: r.presentation_url,
screenshots: r.screenshots, status: r.status, submitted_at: r.submitted_at,
submitted_by: r.submitted_by, created_at: r.created_at, updated_at: r.updated_at,
}
}
}
pub struct PostgresSubmissionRepository { pool: Arc<PgPool> }
impl PostgresSubmissionRepository { pub fn new(pool: Arc<PgPool>) -> Self { Self { pool } } }
#[async_trait]
impl SubmissionRepository for PostgresSubmissionRepository {
async fn create(&self, team_id: Uuid, user_id: Uuid, input: CreateSubmissionInput) -> Result<SubmissionEntity, AppError> {
let id = Uuid::new_v4();
let now = Utc::now();
let row: SubmissionRow = sqlx::query_as(
"INSERT INTO hackathon_project_submissions (id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, $11) RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at"
)
.bind(id).bind(team_id).bind(&input.project_name).bind(&input.description)
.bind(&input.repository_url).bind(&input.demo_url).bind(&input.presentation_url)
.bind(&input.screenshots).bind(user_id).bind(now).bind(now)
.fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.into())
}
async fn find_by_team(&self, team_id: Uuid) -> Result<Option<SubmissionEntity>, AppError> {
let row: Option<SubmissionRow> = sqlx::query_as(
"SELECT id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at FROM hackathon_project_submissions WHERE team_id = $1 LIMIT 1"
)
.bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
async fn find_by_id(&self, id: Uuid) -> Result<SubmissionEntity, AppError> {
let row: SubmissionRow = sqlx::query_as(
"SELECT id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at FROM hackathon_project_submissions WHERE id = $1"
)
.bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Submission not found".to_string()))?;
Ok(row.into())
}
async fn update(&self, id: Uuid, input: UpdateSubmissionInput) -> Result<SubmissionEntity, AppError> {
let mut sets = vec!["updated_at = $1".to_string()];
let mut idx = 2usize;
if input.project_name.is_some() { sets.push(format!("project_name = ${}", idx)); idx += 1; }
if input.description.is_some() { sets.push(format!("description = ${}", idx)); idx += 1; }
if input.repository_url.is_some() { sets.push(format!("repository_url = ${}", idx)); idx += 1; }
if input.demo_url.is_some() { sets.push(format!("demo_url = ${}", idx)); idx += 1; }
if input.presentation_url.is_some() { sets.push(format!("presentation_url = ${}", idx)); idx += 1; }
if input.screenshots.is_some() { sets.push(format!("screenshots = ${}", idx)); idx += 1; }
let sql = format!("UPDATE hackathon_project_submissions SET {} WHERE id = ${} RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at", sets.join(", "), idx);
let mut q = sqlx::query_as::<_, SubmissionRow>(&sql).bind(Utc::now());
if let Some(v) = input.project_name { q = q.bind(v); }
if let Some(v) = input.description { q = q.bind(v); }
if let Some(v) = input.repository_url { q = q.bind(v); }
if let Some(v) = input.demo_url { q = q.bind(v); }
if let Some(v) = input.presentation_url { q = q.bind(v); }
if let Some(v) = input.screenshots { q = q.bind(v); }
q.bind(id).fetch_one(self.pool.as_ref()).await.map(Into::into).map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn update_status(&self, id: Uuid, status: &str) -> Result<SubmissionEntity, AppError> {
let row: SubmissionRow = sqlx::query_as(
"UPDATE hackathon_project_submissions SET status = $1, submitted_at = CASE WHEN $1 = 'submitted' THEN NOW() ELSE submitted_at END, updated_at = NOW() WHERE id = $2 RETURNING id, team_id, project_name, description, repository_url, demo_url, presentation_url, screenshots, status, submitted_at, submitted_by, created_at, updated_at"
)
.bind(status).bind(id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.into())
}
async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)")
.bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')")
.bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn team_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()))
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_submissions_routes;
@@ -0,0 +1 @@
pub mod team_service;
@@ -0,0 +1,177 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::teams::domain::entity::*;
use crate::teams::domain::repository::TeamRepository;
use crate::teams::domain::service::TeamService;
use crate::common::cities::is_valid_indonesian_city;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
}
fn team_features_closed_err() -> AppError {
AppError::BadRequestError("Team features are closed. The deadline was November 30, 2025 at 23:59 WIB.".to_string())
}
pub struct TeamServiceImpl {
repo: Arc<dyn TeamRepository>,
}
impl TeamServiceImpl {
pub fn new(repo: Arc<dyn TeamRepository>) -> Self { Self { repo } }
async fn assemble_team_details(&self, entity: TeamEntity) -> Result<TeamWithDetails, AppError> {
let leader = self.repo.get_leader(entity.leader_id).await?;
let members = self.repo.get_members(entity.id).await?;
let member_count = members.len() as i64;
let has_submission = self.repo.team_has_submission(entity.id).await?;
Ok(TeamWithDetails {
id: entity.id,
name: entity.name,
description: entity.description,
city: entity.city,
visibility: entity.visibility,
logo: entity.logo,
banner: entity.banner,
leader_id: entity.leader_id,
leader,
members: Some(members),
member_count: Some(member_count),
has_submission: Some(has_submission),
created_at: entity.created_at,
updated_at: entity.updated_at,
})
}
}
#[async_trait]
impl TeamService for TeamServiceImpl {
async fn create_team(&self, user_id: Uuid, input: CreateTeamInput) -> Result<TeamWithDetails, AppError> {
if is_team_features_closed() { return Err(team_features_closed_err()); }
if !is_valid_indonesian_city(&input.city) {
return Err(AppError::BadRequestError(format!("Invalid city '{}'. Only Indonesian cities are allowed.", input.city)));
}
if let Some(name) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!("You are already a member of team '{}'. Leave your current team first.", name)));
}
let id = Uuid::new_v4();
let entity = self.repo.create(id, user_id, input).await?;
self.repo.add_member(entity.id, user_id, "leader").await?;
self.repo.reject_pending_invitations_for_user(user_id).await?;
self.repo.reject_pending_join_requests_for_user(user_id).await?;
self.assemble_team_details(entity).await
}
async fn get_team_by_id(&self, team_id: Uuid) -> Result<TeamWithDetails, AppError> {
let entity = self.repo.find_by_id(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
self.assemble_team_details(entity).await
}
async fn browse_teams(&self, input: BrowseTeamsInput) -> Result<BrowseTeamsResult, AppError> {
let page = if input.page < 1 { 1 } else { input.page };
let per_page = if input.per_page < 1 { 10 } else if input.per_page > 100 { 100 } else { input.per_page };
let normalized = BrowseTeamsInput { page, per_page, ..input };
let (teams, total) = self.repo.browse(normalized).await?;
let leader_ids: Vec<Uuid> = teams.iter().map(|t| t.leader_id).collect();
let team_ids: Vec<Uuid> = teams.iter().map(|t| t.id).collect();
let leaders = if !leader_ids.is_empty() { self.repo.get_leaders_batch(leader_ids).await? } else { vec![] };
let counts = if !team_ids.is_empty() { self.repo.get_member_counts_batch(team_ids.clone()).await? } else { vec![] };
let submitted_ids = if !team_ids.is_empty() { self.repo.get_submitted_team_ids(team_ids).await? } else { vec![] };
let result_teams: Vec<TeamWithDetails> = teams.into_iter().map(|t| {
let leader = leaders.iter().find(|l| l.id == t.leader_id).cloned();
let member_count = counts.iter().find(|(id, _)| *id == t.id).map(|(_, c)| *c);
let has_submission = submitted_ids.contains(&t.id);
TeamWithDetails {
id: t.id, name: t.name, description: t.description, city: t.city,
visibility: t.visibility, logo: t.logo, banner: t.banner, leader_id: t.leader_id,
leader, members: None, member_count, has_submission: Some(has_submission),
created_at: t.created_at, updated_at: t.updated_at,
}
}).collect();
Ok(BrowseTeamsResult { teams: result_teams, total, page, per_page })
}
async fn get_user_teams(&self, user_id: Uuid) -> Result<Vec<TeamWithDetails>, AppError> {
let teams = self.repo.find_by_user(user_id).await?;
let leader_ids: Vec<Uuid> = teams.iter().map(|t| t.leader_id).collect();
let team_ids: Vec<Uuid> = teams.iter().map(|t| t.id).collect();
let leaders = if !leader_ids.is_empty() { self.repo.get_leaders_batch(leader_ids).await? } else { vec![] };
let counts = if !team_ids.is_empty() { self.repo.get_member_counts_batch(team_ids).await? } else { vec![] };
Ok(teams.into_iter().map(|t| {
let leader = leaders.iter().find(|l| l.id == t.leader_id).cloned();
let member_count = counts.iter().find(|(id, _)| *id == t.id).map(|(_, c)| *c);
TeamWithDetails {
id: t.id, name: t.name, description: t.description, city: t.city,
visibility: t.visibility, logo: t.logo, banner: t.banner, leader_id: t.leader_id,
leader, members: None, member_count, has_submission: None,
created_at: t.created_at, updated_at: t.updated_at,
}
}).collect())
}
async fn update_team(&self, team_id: Uuid, user_id: Uuid, input: UpdateTeamInput) -> Result<TeamWithDetails, AppError> {
if is_team_features_closed() { return Err(team_features_closed_err()); }
if !self.repo.is_leader(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can perform this action".to_string()));
}
if let Some(ref city) = input.city {
if !is_valid_indonesian_city(city) {
return Err(AppError::BadRequestError(format!("Invalid city '{}'. Only Indonesian cities are allowed.", city)));
}
}
let entity = self.repo.update(team_id, input).await?;
self.assemble_team_details(entity).await
}
async fn remove_team_member(&self, team_id: Uuid, user_id: Uuid, member_id: Uuid) -> Result<(), AppError> {
if is_team_features_closed() { return Err(team_features_closed_err()); }
if !self.repo.is_leader(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can perform this action".to_string()));
}
if member_id == user_id {
return Err(AppError::BadRequestError("Team leader cannot remove themselves".to_string()));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::ConflictError("Cannot remove members after project submission".to_string()));
}
self.repo.remove_member(team_id, member_id).await
}
async fn leave_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
if is_team_features_closed() { return Err(team_features_closed_err()); }
if !self.repo.is_member(team_id, user_id).await? {
return Err(AppError::NotFoundError("You are not a member of this team".to_string()));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::ConflictError("Cannot leave team after project submission".to_string()));
}
if self.repo.is_leader(team_id, user_id).await? {
return Err(AppError::BadRequestError("Team leader cannot leave team. Transfer leadership or delete the team.".to_string()));
}
self.repo.remove_member(team_id, user_id).await
}
async fn delete_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
if !self.repo.is_leader(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team leader can perform this action".to_string()));
}
let count = self.repo.get_member_count(team_id).await?;
if count > 1 {
return Err(AppError::ConflictError("Cannot delete team with other members. Remove all members first.".to_string()));
}
let deleted = self.repo.delete(team_id).await?;
if !deleted {
return Err(AppError::NotFoundError("Team not found".to_string()));
}
Ok(())
}
}
@@ -0,0 +1,98 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct TeamEntity {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub city: String,
pub visibility: String,
pub logo: Option<String>,
pub banner: Option<String>,
pub leader_id: Uuid,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct TeamUserInfo {
pub id: Uuid,
pub email: String,
pub fullname: String,
pub avatar: Option<String>,
pub phone_number: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
pub is_active: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct TeamMemberEntity {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user: TeamUserInfo,
pub role: String,
pub status: String,
pub joined_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct TeamWithDetails {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub city: String,
pub visibility: String,
pub logo: Option<String>,
pub banner: Option<String>,
pub leader_id: Uuid,
pub leader: Option<TeamUserInfo>,
pub members: Option<Vec<TeamMemberEntity>>,
pub member_count: Option<i64>,
pub has_submission: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default)]
pub struct CreateTeamInput {
pub name: String,
pub description: Option<String>,
pub city: String,
pub visibility: String,
pub logo: Option<String>,
pub banner: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateTeamInput {
pub name: Option<String>,
pub description: Option<String>,
pub city: Option<String>,
pub visibility: Option<String>,
pub logo: Option<String>,
pub banner: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct BrowseTeamsInput {
pub search: Option<String>,
pub city: Option<String>,
pub min_members: Option<i64>,
pub max_members: Option<i64>,
pub has_submission: Option<bool>,
pub page: i64,
pub per_page: i64,
}
pub struct BrowseTeamsResult {
pub teams: Vec<TeamWithDetails>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,28 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait TeamRepository: Send + Sync {
async fn create(&self, id: Uuid, leader_id: Uuid, input: CreateTeamInput) -> Result<TeamEntity, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<TeamEntity>, AppError>;
async fn browse(&self, input: BrowseTeamsInput) -> Result<(Vec<TeamEntity>, i64), AppError>;
async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<TeamEntity>, AppError>;
async fn update(&self, id: Uuid, input: UpdateTeamInput) -> Result<TeamEntity, AppError>;
async fn delete(&self, id: Uuid) -> Result<bool, AppError>;
async fn get_members(&self, team_id: Uuid) -> Result<Vec<TeamMemberEntity>, AppError>;
async fn get_leader(&self, leader_id: Uuid) -> Result<Option<TeamUserInfo>, AppError>;
async fn add_member(&self, team_id: Uuid, user_id: Uuid, role: &str) -> Result<(), AppError>;
async fn remove_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
async fn get_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
async fn is_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn is_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn user_active_team_name(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError>;
async fn reject_pending_invitations_for_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn get_leaders_batch(&self, leader_ids: Vec<Uuid>) -> Result<Vec<TeamUserInfo>, AppError>;
async fn get_member_counts_batch(&self, team_ids: Vec<Uuid>) -> Result<Vec<(Uuid, i64)>, AppError>;
async fn get_submitted_team_ids(&self, team_ids: Vec<Uuid>) -> Result<Vec<Uuid>, AppError>;
}
@@ -0,0 +1,16 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
#[async_trait]
pub trait TeamService: Send + Sync {
async fn create_team(&self, user_id: Uuid, input: CreateTeamInput) -> Result<TeamWithDetails, AppError>;
async fn get_team_by_id(&self, team_id: Uuid) -> Result<TeamWithDetails, AppError>;
async fn browse_teams(&self, input: BrowseTeamsInput) -> Result<BrowseTeamsResult, AppError>;
async fn get_user_teams(&self, user_id: Uuid) -> Result<Vec<TeamWithDetails>, AppError>;
async fn update_team(&self, team_id: Uuid, user_id: Uuid, input: UpdateTeamInput) -> Result<TeamWithDetails, AppError>;
async fn remove_team_member(&self, team_id: Uuid, user_id: Uuid, member_id: Uuid) -> Result<(), AppError>;
async fn leave_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
async fn delete_team(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,140 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::teams::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserInfoResponse {
pub id: Uuid,
pub email: String,
pub fullname: String,
pub avatar: Option<String>,
pub phone_number: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
pub is_active: Option<bool>,
}
impl From<TeamUserInfo> for UserInfoResponse {
fn from(u: TeamUserInfo) -> Self {
Self { id: u.id, email: u.email, fullname: u.fullname, avatar: u.avatar,
phone_number: u.phone_number, location: u.location, bio: u.bio,
skills: u.skills, is_active: u.is_active }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamMemberResponse {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user: UserInfoResponse,
pub role: String,
pub status: String,
pub joined_at: Option<DateTime<Utc>>,
}
impl From<TeamMemberEntity> for TeamMemberResponse {
fn from(m: TeamMemberEntity) -> Self {
Self { id: m.id, team_id: m.team_id, user_id: m.user_id,
user: UserInfoResponse::from(m.user), role: m.role, status: m.status, joined_at: m.joined_at }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub city: String,
pub visibility: String,
pub logo: Option<String>,
pub banner: Option<String>,
pub leader_id: Uuid,
pub leader: Option<UserInfoResponse>,
pub members: Option<Vec<TeamMemberResponse>>,
pub member_count: Option<i64>,
pub has_submission: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
impl From<TeamWithDetails> for TeamResponse {
fn from(t: TeamWithDetails) -> Self {
Self {
id: t.id, name: t.name, description: t.description, city: t.city,
visibility: t.visibility, logo: t.logo, banner: t.banner, leader_id: t.leader_id,
leader: t.leader.map(UserInfoResponse::from),
members: t.members.map(|ms| ms.into_iter().map(TeamMemberResponse::from).collect()),
member_count: t.member_count, has_submission: t.has_submission,
created_at: t.created_at, updated_at: t.updated_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateTeamRequest {
pub name: String,
pub description: Option<String>,
pub city: String,
pub visibility: String,
pub logo: Option<String>,
pub banner: Option<String>,
}
impl From<CreateTeamRequest> for CreateTeamInput {
fn from(r: CreateTeamRequest) -> Self {
Self { name: r.name, description: r.description, city: r.city,
visibility: r.visibility, logo: r.logo, banner: r.banner }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateTeamRequest {
pub name: Option<String>,
pub description: Option<String>,
pub city: Option<String>,
pub visibility: Option<String>,
pub logo: Option<String>,
pub banner: Option<String>,
}
impl From<UpdateTeamRequest> for UpdateTeamInput {
fn from(r: UpdateTeamRequest) -> Self {
Self { name: r.name, description: r.description, city: r.city,
visibility: r.visibility, logo: r.logo, banner: r.banner }
}
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct BrowseTeamsQuery {
pub search: Option<String>,
pub city: Option<String>,
pub min_members: Option<i64>,
pub max_members: Option<i64>,
pub has_submission: Option<bool>,
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_per_page")]
pub per_page: i64,
}
fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 10 }
impl From<BrowseTeamsQuery> for BrowseTeamsInput {
fn from(q: BrowseTeamsQuery) -> Self {
Self { search: q.search, city: q.city, min_members: q.min_members, max_members: q.max_members,
has_submission: q.has_submission, page: q.page, per_page: q.per_page }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamListResponse {
pub data: Vec<TeamResponse>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
@@ -0,0 +1,82 @@
use axum::{Extension, Json, extract::{Path, Query}, 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::teams::domain::service::TeamService;
use super::dto::*;
pub async fn create_team_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<CreateTeamRequest>,
) -> Result<axum::response::Response, AppError> {
let team = service.create_team(auth.user_id, body.into()).await?;
Ok(ApiSuccess(TeamResponse::from(team)).into_response())
}
pub async fn get_team_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let team = service.get_team_by_id(team_id).await?;
Ok(ApiSuccess(TeamResponse::from(team)).into_response())
}
pub async fn browse_teams_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Query(query): Query<BrowseTeamsQuery>,
) -> Result<axum::response::Response, AppError> {
let result = service.browse_teams(query.into()).await?;
Ok(ApiSuccess(TeamListResponse {
data: result.teams.into_iter().map(TeamResponse::from).collect(),
total: result.total,
page: result.page,
per_page: result.per_page,
}).into_response())
}
pub async fn get_my_teams_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
) -> Result<axum::response::Response, AppError> {
let teams = service.get_user_teams(auth.user_id).await?;
Ok(ApiSuccess(teams.into_iter().map(TeamResponse::from).collect::<Vec<_>>()).into_response())
}
pub async fn update_team_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<UpdateTeamRequest>,
) -> Result<axum::response::Response, AppError> {
let team = service.update_team(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(TeamResponse::from(team)).into_response())
}
pub async fn delete_team_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
service.delete_team(team_id, auth.user_id).await?;
Ok(ApiMessage::ok("Team deleted successfully").into_response())
}
pub async fn leave_team_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
service.leave_team(team_id, auth.user_id).await?;
Ok(ApiMessage::ok("Left team successfully").into_response())
}
pub async fn remove_member_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path((team_id, member_id)): Path<(Uuid, Uuid)>,
) -> Result<axum::response::Response, AppError> {
service.remove_team_member(team_id, auth.user_id, member_id).await?;
Ok(ApiMessage::ok("Member removed successfully").into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,32 @@
use axum::{middleware::from_fn, routing::{delete, get, post, put}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::teams::application::team_service::TeamServiceImpl;
use crate::teams::domain::service::TeamService;
use crate::teams::infrastructure::persistence::PostgresTeamRepository;
use crate::common::hackathon_jwt::HackathonJwtService;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
pub fn build_team_routes(pool: Arc<PgPool>, jwt: Arc<HackathonJwtService>) -> Router {
let repo = Arc::new(PostgresTeamRepository::new(pool.clone()));
let service: Arc<dyn TeamService> = Arc::new(TeamServiceImpl::new(repo));
let public = Router::new()
.route("/teams/browse", get(browse_teams_handler))
.route("/teams/:team_id", get(get_team_handler))
.layer(Extension(service.clone()));
let protected = Router::new()
.route("/teams", post(create_team_handler))
.route("/teams/my", get(get_my_teams_handler))
.route("/teams/:team_id", put(update_team_handler).delete(delete_team_handler))
.route("/teams/:team_id/leave", post(leave_team_handler))
.route("/teams/:team_id/members/:member_id", delete(remove_member_handler))
.layer(Extension(service))
.layer(Extension(pool.clone()))
.layer(Extension(jwt))
.layer(from_fn(hackathon_auth_middleware));
Router::new().merge(public).merge(protected)
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,4 @@
pub mod postgres_team_repository;
mod postgres_team_queries;
pub use postgres_team_repository::PostgresTeamRepository;

Some files were not shown because too many files have changed in this diff Show More