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:
co-authored by
Claude Sonnet 4.6
parent
05a5b39195
commit
11442c6285
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::build_team_routes;
|
||||
Reference in New Issue
Block a user