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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 15:15:00 +07:00
co-authored by Claude Sonnet 4.6
parent 05a5b39195
commit 11442c6285
119 changed files with 4385 additions and 11 deletions
@@ -0,0 +1 @@
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;
@@ -0,0 +1,111 @@
use uuid::Uuid;
use sqlx::FromRow;
use imphnen_utils::errors::AppError;
use crate::teams::domain::entity::{TeamEntity, TeamUserInfo, BrowseTeamsInput};
use super::postgres_team_repository::{PostgresTeamRepository, TeamRow, UserRow};
impl PostgresTeamRepository {
pub(super) async fn browse_query(&self, input: BrowseTeamsInput) -> Result<(Vec<TeamEntity>, i64), AppError> {
let offset = (input.page - 1) * input.per_page;
let mut where_clauses: Vec<String> = vec!["t.visibility = 'public'".to_string()];
let mut param_count = 1usize;
if input.city.is_some() { where_clauses.push(format!("t.city = ${}", param_count)); param_count += 1; }
if input.search.is_some() { where_clauses.push(format!("t.name ILIKE ${}", param_count)); param_count += 1; }
if input.min_members.is_some() { where_clauses.push(format!("mc.member_count >= ${}", param_count)); param_count += 1; }
if input.max_members.is_some() { where_clauses.push(format!("mc.member_count <= ${}", param_count)); param_count += 1; }
if let Some(has_sub) = input.has_submission {
let clause = if has_sub {
"EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = t.id)".to_string()
} else {
"NOT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = t.id)".to_string()
};
where_clauses.push(clause);
}
let where_sql = where_clauses.join(" AND ");
let base = format!(
"FROM hackathon_teams t LEFT JOIN (SELECT team_id, COUNT(*) as member_count FROM hackathon_team_members WHERE status = 'active' GROUP BY team_id) mc ON mc.team_id = t.id WHERE {}",
where_sql
);
let count_sql = format!("SELECT COUNT(*) {}", base);
let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
if let Some(ref v) = input.city { count_q = count_q.bind(v.clone()); }
if let Some(ref v) = input.search { count_q = count_q.bind(format!("%{}%", v)); }
if let Some(v) = input.min_members { count_q = count_q.bind(v); }
if let Some(v) = input.max_members { count_q = count_q.bind(v); }
let total: i64 = count_q.fetch_one(self.pool.as_ref()).await.unwrap_or(0);
let select_sql = format!(
"SELECT t.id, t.name, t.description, t.city, t.visibility, t.logo, t.banner, t.leader_id, t.created_at, t.updated_at {} ORDER BY t.created_at DESC LIMIT ${} OFFSET ${}",
base, param_count, param_count + 1
);
let mut q = sqlx::query_as::<_, TeamRow>(&select_sql);
if let Some(v) = input.city { q = q.bind(v); }
if let Some(v) = input.search { q = q.bind(format!("%{}%", v)); }
if let Some(v) = input.min_members { q = q.bind(v); }
if let Some(v) = input.max_members { q = q.bind(v); }
q = q.bind(input.per_page).bind(offset);
let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((rows.into_iter().map(Into::into).collect(), total))
}
pub(super) async fn update_query(&self, id: Uuid, input: crate::teams::domain::entity::UpdateTeamInput) -> Result<TeamEntity, AppError> {
use chrono::Utc;
let mut sets = vec!["updated_at = $1".to_string()];
let mut idx = 2usize;
if input.name.is_some() { sets.push(format!("name = ${}", idx)); idx += 1; }
if input.description.is_some() { sets.push(format!("description = ${}", idx)); idx += 1; }
if input.city.is_some() { sets.push(format!("city = ${}", idx)); idx += 1; }
if input.visibility.is_some() { sets.push(format!("visibility = ${}", idx)); idx += 1; }
if input.logo.is_some() { sets.push(format!("logo = ${}", idx)); idx += 1; }
if input.banner.is_some() { sets.push(format!("banner = ${}", idx)); idx += 1; }
let sql = format!(
"UPDATE hackathon_teams SET {} WHERE id = ${} RETURNING id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at",
sets.join(", "), idx
);
let mut q = sqlx::query_as::<_, TeamRow>(&sql).bind(Utc::now());
if let Some(v) = input.name { q = q.bind(v); }
if let Some(v) = input.description { q = q.bind(v); }
if let Some(v) = input.city { q = q.bind(v); }
if let Some(v) = input.visibility { q = q.bind(v); }
if let Some(v) = input.logo { q = q.bind(v); }
if let Some(v) = input.banner { 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()))
}
pub(super) async fn leaders_batch_query(&self, leader_ids: Vec<Uuid>) -> Result<Vec<TeamUserInfo>, AppError> {
if leader_ids.is_empty() { return Ok(vec![]); }
let placeholders = (1..=leader_ids.len()).map(|i| format!("${}", i)).collect::<Vec<_>>().join(", ");
let sql = format!("SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id IN ({})", placeholders);
let mut q = sqlx::query_as::<_, UserRow>(&sql);
for id in &leader_ids { q = q.bind(id); }
let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub(super) async fn member_counts_batch_query(&self, team_ids: Vec<Uuid>) -> Result<Vec<(Uuid, i64)>, AppError> {
if team_ids.is_empty() { return Ok(vec![]); }
let placeholders = (1..=team_ids.len()).map(|i| format!("${}", i)).collect::<Vec<_>>().join(", ");
let sql = format!("SELECT team_id, COUNT(*) as count FROM hackathon_team_members WHERE team_id IN ({}) AND status = 'active' GROUP BY team_id", placeholders);
#[derive(FromRow)]
struct CountRow { team_id: Uuid, count: i64 }
let mut q = sqlx::query_as::<_, CountRow>(&sql);
for id in &team_ids { q = q.bind(id); }
let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(rows.into_iter().map(|r| (r.team_id, r.count)).collect())
}
pub(super) async fn submitted_team_ids_query(&self, team_ids: Vec<Uuid>) -> Result<Vec<Uuid>, AppError> {
if team_ids.is_empty() { return Ok(vec![]); }
let placeholders = (1..=team_ids.len()).map(|i| format!("${}", i)).collect::<Vec<_>>().join(", ");
let sql = format!("SELECT DISTINCT team_id FROM hackathon_project_submissions WHERE team_id IN ({})", placeholders);
#[derive(FromRow)]
struct SubRow { team_id: Uuid }
let mut q = sqlx::query_as::<_, SubRow>(&sql);
for id in &team_ids { q = q.bind(id); }
let rows = q.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.team_id).collect())
}
}
@@ -0,0 +1,182 @@
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::teams::domain::entity::*;
use crate::teams::domain::repository::TeamRepository;
#[derive(FromRow)]
pub(crate) struct TeamRow {
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>>,
}
impl From<TeamRow> for TeamEntity {
fn from(r: TeamRow) -> Self {
Self { id: r.id, name: r.name, description: r.description, city: r.city, visibility: r.visibility,
logo: r.logo, banner: r.banner, leader_id: r.leader_id, created_at: r.created_at, updated_at: r.updated_at }
}
}
#[derive(FromRow)]
pub(crate) struct UserRow {
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>>,
}
impl From<UserRow> for TeamUserInfo {
fn from(r: UserRow) -> Self {
Self { id: r.id, email: r.email, fullname: r.fullname, avatar: r.avatar,
phone_number: r.phone_number, location: r.location, bio: r.bio,
skills: r.skills, is_active: r.is_active, created_at: r.created_at, updated_at: r.updated_at }
}
}
pub struct PostgresTeamRepository { pub(crate) pool: Arc<PgPool> }
impl PostgresTeamRepository { pub fn new(pool: Arc<PgPool>) -> Self { Self { pool } } }
#[async_trait]
impl TeamRepository for PostgresTeamRepository {
async fn create(&self, id: Uuid, leader_id: Uuid, input: CreateTeamInput) -> Result<TeamEntity, AppError> {
let now = Utc::now();
let row: TeamRow = sqlx::query_as(
"INSERT INTO hackathon_teams (id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at"
)
.bind(id).bind(&input.name).bind(&input.description).bind(&input.city)
.bind(&input.visibility).bind(&input.logo).bind(&input.banner).bind(leader_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_id(&self, id: Uuid) -> Result<Option<TeamEntity>, AppError> {
let row: Option<TeamRow> = sqlx::query_as(
"SELECT id, name, description, city, visibility, logo, banner, leader_id, created_at, updated_at FROM hackathon_teams 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<TeamEntity>, AppError> {
let rows: Vec<TeamRow> = sqlx::query_as(
"SELECT t.id, t.name, t.description, t.city, t.visibility, t.logo, t.banner, t.leader_id, t.created_at, t.updated_at FROM hackathon_teams t JOIN hackathon_team_members tm ON tm.team_id = t.id WHERE tm.user_id = $1 AND tm.status = 'active'"
)
.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 get_leader(&self, leader_id: Uuid) -> Result<Option<TeamUserInfo>, AppError> {
let row: Option<UserRow> = sqlx::query_as(
"SELECT id, email, fullname, avatar, phone_number, location, bio, skills, is_active, created_at, updated_at FROM hackathon_users WHERE id = $1"
)
.bind(leader_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
async fn get_members(&self, team_id: Uuid) -> Result<Vec<TeamMemberEntity>, AppError> {
#[derive(FromRow)]
struct MemberRow {
id: Uuid, team_id: Uuid, user_id: Uuid, role: String, status: String, joined_at: Option<DateTime<Utc>>,
user_email: String, user_fullname: String, user_avatar: Option<String>,
user_phone_number: Option<String>, user_location: Option<String>, user_bio: Option<String>,
user_skills: Option<Vec<String>>, user_is_active: Option<bool>,
user_created_at: Option<DateTime<Utc>>, user_updated_at: Option<DateTime<Utc>>,
}
let rows: Vec<MemberRow> = sqlx::query_as(
"SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.status, tm.joined_at, u.email as user_email, u.fullname as user_fullname, u.avatar as user_avatar, u.phone_number as user_phone_number, u.location as user_location, u.bio as user_bio, u.skills as user_skills, u.is_active as user_is_active, u.created_at as user_created_at, u.updated_at as user_updated_at FROM hackathon_team_members tm JOIN hackathon_users u ON tm.user_id = u.id WHERE tm.team_id = $1 AND tm.status = 'active' ORDER BY tm.role DESC, tm.joined_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(|r| TeamMemberEntity {
id: r.id, team_id: r.team_id, user_id: r.user_id, role: r.role, status: r.status, joined_at: r.joined_at,
user: TeamUserInfo { id: r.user_id, email: r.user_email, fullname: r.user_fullname, avatar: r.user_avatar,
phone_number: r.user_phone_number, location: r.user_location, bio: r.user_bio,
skills: r.user_skills, is_active: r.user_is_active, created_at: r.user_created_at, updated_at: r.user_updated_at },
}).collect())
}
async fn add_member(&self, team_id: Uuid, user_id: Uuid, role: &str) -> Result<(), AppError> {
let now = Utc::now();
sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, $4, 'active', $5) ON CONFLICT (team_id, user_id) DO NOTHING")
.bind(Uuid::new_v4()).bind(team_id).bind(user_id).bind(role).bind(now)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn remove_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2")
.bind(team_id).bind(user_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn get_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 is_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_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 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 tm ON tm.team_id = t.id WHERE tm.user_id = $1 AND tm.status = 'active' LIMIT 1")
.bind(user_id).fetch_optional(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 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_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_leaders_batch(&self, leader_ids: Vec<Uuid>) -> Result<Vec<TeamUserInfo>, AppError> {
self.leaders_batch_query(leader_ids).await
}
async fn get_member_counts_batch(&self, team_ids: Vec<Uuid>) -> Result<Vec<(Uuid, i64)>, AppError> {
self.member_counts_batch_query(team_ids).await
}
async fn get_submitted_team_ids(&self, team_ids: Vec<Uuid>) -> Result<Vec<Uuid>, AppError> {
self.submitted_team_ids_query(team_ids).await
}
async fn update(&self, id: Uuid, input: UpdateTeamInput) -> Result<TeamEntity, AppError> {
self.update_query(id, input).await
}
async fn delete(&self, id: Uuid) -> Result<bool, AppError> {
let result = sqlx::query("DELETE FROM hackathon_teams 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 browse(&self, input: BrowseTeamsInput) -> Result<(Vec<TeamEntity>, i64), AppError> {
self.browse_query(input).await
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::build_team_routes;