feat: v0.3.0 — standardize codebase, centralize infra, merge QR into CMS

- Enforce axum best practices across all 13 workspace crates
  (max 200 LOC/file, no comments, no unwrap, clean architecture)
- Fix domain→infrastructure dependency inversions in imphnen-iam and imphnen-dimentorin
- Extract imphnen-storage (MinIO) and imphnen-email (Lettre) as standalone crates
- Centralize all config in ENV struct: CDN_URL, CORS_ALLOWED_ORIGINS
- Centralize SMTP through imphnen-email; remove dead HackathonConfig
- Centralize database: QR crate now shares main DB pool (single DATABASE_URL)
- Rename QR users table to qr_users to avoid collision with main users table
- Merge imphnen-qr into imphnen-cms/src/qr (13 crates, down from 14)
- Restructure imphnen-hackathon flat modules into clean architecture
- Remove all stale env vars from .env.example (SurrealDB, QR_JWT, Hackathon infra)
- Fix Dockerfile to include all current workspace crates
- Bump all crate versions 0.2.0 → 0.3.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 22:29:08 +07:00
co-authored by Claude Sonnet 4.6
parent 2ae43b3bcc
commit 331a4a4e88
442 changed files with 22226 additions and 18700 deletions
@@ -0,0 +1,84 @@
use crate::admin::domain::entity::*;
use crate::admin::domain::repository::AdminRepository;
use crate::admin::domain::service::AdminService;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct AdminServiceImpl {
repo: Arc<dyn AdminRepository>,
}
impl AdminServiceImpl {
pub fn new(repo: Arc<dyn AdminRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl AdminService for AdminServiceImpl {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError> {
self.repo.list_users(page, limit, search).await
}
async fn get_user(&self, user_id: Uuid) -> Result<AdminUserRow, AppError> {
self
.repo
.get_user(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError> {
self.repo.set_admin(user_id, is_admin).await
}
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError> {
self.repo.delete_user(user_id).await
}
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError> {
self.repo.list_teams(page, limit, search).await
}
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError> {
self.repo.delete_team(team_id).await
}
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError> {
self.repo.list_submissions(page, limit, status).await
}
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError> {
self.repo.set_winner(team_id, rank, prize).await
}
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError> {
self.repo.remove_winner(team_id).await
}
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError> {
self.repo.list_winners().await
}
}
@@ -0,0 +1 @@
pub mod admin_service;
@@ -0,0 +1,44 @@
use serde::Serialize;
use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct AdminUserRow {
pub id: Uuid,
pub email: String,
pub fullname: String,
pub avatar: Option<String>,
pub is_active: Option<bool>,
pub is_admin: Option<bool>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct AdminTeamRow {
pub id: Uuid,
pub name: String,
pub city: String,
pub visibility: String,
pub leader_id: Uuid,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct AdminSubmissionRow {
pub id: Uuid,
pub team_id: Uuid,
pub project_name: String,
pub status: String,
pub submitted_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct WinnerRow {
pub id: Uuid,
pub team_id: Uuid,
pub rank: i32,
pub prize: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,38 @@
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait AdminRepository: Send + Sync {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError>;
async fn get_user(&self, user_id: Uuid) -> Result<Option<AdminUserRow>, AppError>;
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError>;
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError>;
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError>;
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError>;
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError>;
}
@@ -0,0 +1,38 @@
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait AdminService: Send + Sync {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError>;
async fn get_user(&self, user_id: Uuid) -> Result<AdminUserRow, AppError>;
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError>;
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError>;
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError>;
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError>;
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError>;
}
@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct PageQuery {
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_limit")]
pub limit: i64,
pub search: Option<String>,
pub status: Option<String>,
}
fn default_page() -> i64 {
1
}
fn default_limit() -> i64 {
20
}
#[derive(Debug, Serialize, ToSchema)]
pub struct PagedResponse<T> {
pub data: Vec<T>,
pub total: i64,
pub page: i64,
pub limit: i64,
}
#[derive(Deserialize, ToSchema)]
pub struct SetAdminRequest {
pub is_admin: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct SetWinnerRequest {
pub team_id: Uuid,
pub rank: i32,
pub prize: Option<String>,
}
@@ -0,0 +1,119 @@
use super::dto::*;
use crate::admin::domain::service::AdminService;
use axum::{
Extension, Json,
extract::{Path, Query},
response::IntoResponse,
};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
use std::sync::Arc;
use uuid::Uuid;
pub async fn admin_list_users(
Extension(service): Extension<Arc<dyn AdminService>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let (users, total) = service.list_users(q.page, q.limit, q.search).await?;
Ok(
ApiSuccess(PagedResponse {
data: users,
total,
page: q.page,
limit: q.limit,
})
.into_response(),
)
}
pub async fn admin_get_user(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let user = service.get_user(user_id).await?;
Ok(ApiSuccess(user).into_response())
}
pub async fn admin_set_admin(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(user_id): Path<Uuid>,
Json(body): Json<SetAdminRequest>,
) -> Result<ApiMessage, AppError> {
service.set_admin(user_id, body.is_admin).await?;
Ok(ApiMessage::ok("User admin status updated"))
}
pub async fn admin_delete_user(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(user_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
service.delete_user(user_id).await?;
Ok(ApiMessage::ok("User deleted"))
}
pub async fn admin_list_teams(
Extension(service): Extension<Arc<dyn AdminService>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let (teams, total) = service.list_teams(q.page, q.limit, q.search).await?;
Ok(
ApiSuccess(PagedResponse {
data: teams,
total,
page: q.page,
limit: q.limit,
})
.into_response(),
)
}
pub async fn admin_delete_team(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
service.delete_team(team_id).await?;
Ok(ApiMessage::ok("Team deleted"))
}
pub async fn admin_list_submissions(
Extension(service): Extension<Arc<dyn AdminService>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let (subs, total) = service.list_submissions(q.page, q.limit, q.status).await?;
Ok(
ApiSuccess(PagedResponse {
data: subs,
total,
page: q.page,
limit: q.limit,
})
.into_response(),
)
}
pub async fn admin_set_winner(
Extension(service): Extension<Arc<dyn AdminService>>,
Json(body): Json<SetWinnerRequest>,
) -> Result<ApiMessage, AppError> {
service
.set_winner(body.team_id, body.rank, body.prize)
.await?;
Ok(ApiMessage::ok("Winner set"))
}
pub async fn admin_remove_winner(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
service.remove_winner(team_id).await?;
Ok(ApiMessage::ok("Winner removed"))
}
pub async fn admin_list_winners(
Extension(service): Extension<Arc<dyn AdminService>>,
) -> Result<axum::response::Response, AppError> {
let rows = service.list_winners().await?;
Ok(ApiSuccess(rows).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,40 @@
use super::handlers::*;
use crate::admin::application::admin_service::AdminServiceImpl;
use crate::admin::domain::service::AdminService;
use crate::admin::infrastructure::persistence::PostgresAdminRepository;
use crate::middleware::{
admin_only::admin_only, hackathon_auth::hackathon_auth_middleware,
};
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, get, post},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn hackathon_admin_routes(pool: Arc<PgPool>) -> Router {
let service: Arc<dyn AdminService> = Arc::new(AdminServiceImpl::new(Arc::new(
PostgresAdminRepository::new(pool.clone()),
)));
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(service))
.layer(Extension(pool.clone()))
.layer(from_fn(admin_only))
.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_admin_repository;
pub use postgres_admin_repository::PostgresAdminRepository;
@@ -0,0 +1,131 @@
use crate::admin::domain::entity::*;
use crate::admin::domain::repository::AdminRepository;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
pub struct PostgresAdminRepository {
pool: Arc<PgPool>,
}
impl PostgresAdminRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl AdminRepository for PostgresAdminRepository {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError> {
let offset = (page - 1) * limit;
let pattern = 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(self.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(limit).bind(offset)
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((users, total))
}
async fn get_user(&self, user_id: Uuid) -> Result<Option<AdminUserRow>, AppError> {
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(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_users SET is_admin = $1 WHERE id = $2")
.bind(is_admin)
.bind(user_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_users WHERE id = $1")
.bind(user_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError> {
let offset = (page - 1) * limit;
let pattern = 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(self.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(limit).bind(offset)
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((teams, total))
}
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_teams WHERE id = $1")
.bind(team_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError> {
let offset = (page - 1) * limit;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1)")
.bind(&status).fetch_one(self.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(&status).bind(limit).bind(offset)
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((subs, total))
}
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), 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(team_id).bind(rank).bind(prize)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_winners WHERE team_id = $1")
.bind(team_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError> {
sqlx::query_as("SELECT id, team_id, rank, prize, created_at FROM hackathon_winners ORDER BY rank ASC")
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
+5 -2
View File
@@ -1,2 +1,5 @@
pub mod routes;
pub use routes::hackathon_admin_routes;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_admin_routes;
-189
View File
@@ -1,189 +0,0 @@
use axum::{
extract::{Path, Query},
middleware::from_fn,
response::IntoResponse,
routing::{delete, get, post},
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};
#[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>) -> 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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -0,0 +1,31 @@
use crate::certificates::domain::entity::CertificateData;
use crate::certificates::domain::repository::CertificateRepository;
use crate::certificates::domain::service::CertificateService;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct CertificateServiceImpl {
repo: Arc<dyn CertificateRepository>,
}
impl CertificateServiceImpl {
pub fn new(repo: Arc<dyn CertificateRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl CertificateService for CertificateServiceImpl {
async fn get_certificate(
&self,
user_id: Uuid,
) -> Result<CertificateData, AppError> {
self
.repo
.find_by_user_id(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
}
@@ -0,0 +1 @@
pub mod certificate_service;
@@ -0,0 +1,16 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct CertificateData {
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>,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,12 @@
use super::entity::CertificateData;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait CertificateRepository: Send + Sync {
async fn find_by_user_id(
&self,
user_id: Uuid,
) -> Result<Option<CertificateData>, AppError>;
}
@@ -0,0 +1,12 @@
use super::entity::CertificateData;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait CertificateService: Send + Sync {
async fn get_certificate(
&self,
user_id: Uuid,
) -> Result<CertificateData, AppError>;
}
@@ -0,0 +1,37 @@
use crate::certificates::domain::entity::CertificateData;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, ToSchema)]
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>,
}
impl From<CertificateData> for CertificateResponse {
fn from(d: CertificateData) -> Self {
Self {
user_id: d.user_id,
fullname: d.fullname,
email: d.email,
avatar: d.avatar,
team_id: d.team_id,
team_name: d.team_name,
is_leader: d.is_leader,
project_name: d.project_name,
submission_status: d.submission_status,
winner_rank: d.winner_rank,
winner_prize: d.winner_prize,
}
}
}
@@ -0,0 +1,14 @@
use super::dto::CertificateResponse;
use crate::certificates::domain::service::CertificateService;
use axum::{Extension, extract::Path, response::IntoResponse};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
pub async fn get_certificate_handler(
Extension(service): Extension<Arc<dyn CertificateService>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let cert = service.get_certificate(user_id).await?;
Ok(ApiSuccess(CertificateResponse::from(cert)).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,17 @@
use super::handlers::get_certificate_handler;
use crate::certificates::application::certificate_service::CertificateServiceImpl;
use crate::certificates::domain::service::CertificateService;
use crate::certificates::infrastructure::persistence::PostgresCertificateRepository;
use axum::{Extension, Router, routing::get};
use sqlx::PgPool;
use std::sync::Arc;
pub fn hackathon_certificates_routes(pool: Arc<PgPool>) -> Router {
let service: Arc<dyn CertificateService> = Arc::new(CertificateServiceImpl::new(
Arc::new(PostgresCertificateRepository::new(pool.clone())),
));
Router::new()
.route("/certificates/:user_id", get(get_certificate_handler))
.layer(Extension(service))
.layer(Extension(pool))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,2 @@
pub mod postgres_certificate_repository;
pub use postgres_certificate_repository::PostgresCertificateRepository;
@@ -0,0 +1,67 @@
use crate::certificates::domain::entity::CertificateData;
use crate::certificates::domain::repository::CertificateRepository;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct CertificateRow {
user_id: Uuid,
fullname: String,
email: String,
avatar: Option<String>,
team_id: Option<Uuid>,
team_name: Option<String>,
is_leader: Option<bool>,
project_name: Option<String>,
submission_status: Option<String>,
winner_rank: Option<i32>,
winner_prize: Option<String>,
}
impl From<CertificateRow> for CertificateData {
fn from(r: CertificateRow) -> Self {
Self {
user_id: r.user_id,
fullname: r.fullname,
email: r.email,
avatar: r.avatar,
team_id: r.team_id,
team_name: r.team_name,
is_leader: r.is_leader,
project_name: r.project_name,
submission_status: r.submission_status,
winner_rank: r.winner_rank,
winner_prize: r.winner_prize,
}
}
}
pub struct PostgresCertificateRepository {
pool: Arc<PgPool>,
}
impl PostgresCertificateRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl CertificateRepository for PostgresCertificateRepository {
async fn find_by_user_id(
&self,
user_id: Uuid,
) -> Result<Option<CertificateData>, AppError> {
let row: Option<CertificateRow> = 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(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
}
+5 -2
View File
@@ -1,2 +1,5 @@
pub mod routes;
pub use routes::hackathon_certificates_routes;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_certificates_routes;
@@ -1,44 +0,0 @@
use axum::{extract::Path, response::IntoResponse, routing::get, Extension, Router};
use sqlx::{PgPool, FromRow};
use std::sync::Arc;
use uuid::Uuid;
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))
}
@@ -1,70 +1,96 @@
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;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct ChatServiceImpl {
repo: Arc<dyn ChatRepository>,
repo: Arc<dyn ChatRepository>,
}
impl ChatServiceImpl {
pub fn new(repo: Arc<dyn ChatRepository>) -> Self {
Self { repo }
}
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 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 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(())
}
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(())
}
}
+16 -16
View File
@@ -1,29 +1,29 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[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>>,
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>>,
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,
pub message: String,
}
+33 -16
View File
@@ -1,27 +1,44 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait ChatRepository: Send + Sync {
async fn find_team_messages(&self, team_id: Uuid) -> Result<Vec<MessageWithUser>, AppError>;
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 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 find_message_by_id(
&self,
id: Uuid,
) -> Result<Option<MessageEntity>, AppError>;
async fn delete_message(&self, id: Uuid) -> Result<bool, 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 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_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>;
async fn is_team_leader(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<bool, AppError>;
}
+19 -11
View File
@@ -1,18 +1,26 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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 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 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>;
async fn delete_message(
&self,
message_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError>;
}
@@ -1,43 +1,43 @@
use crate::chat::domain::entity::*;
use chrono::{DateTime, Utc};
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>>,
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,
}
}
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,
pub message: String,
}
impl From<SendMessageRequest> for SendMessageInput {
fn from(r: SendMessageRequest) -> Self {
Self { message: r.message }
}
fn from(r: SendMessageRequest) -> Self {
Self { message: r.message }
}
}
@@ -1,36 +1,42 @@
use super::dto::*;
use crate::chat::domain::service::ChatService;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
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>,
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())
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>,
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())
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>,
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())
service.delete_message(message_id, auth.user_id).await?;
Ok(ApiMessage::ok("Message deleted").into_response())
}
@@ -1,20 +1,27 @@
use axum::{middleware::from_fn, routing::{delete, get}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
use crate::chat::application::chat_service::ChatServiceImpl;
use crate::chat::domain::service::ChatService;
use crate::chat::infrastructure::persistence::PostgresChatRepository;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, get},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn build_chat_routes(pool: Arc<PgPool>) -> 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(pool))
.layer(from_fn(hackathon_auth_middleware))
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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -1,127 +1,161 @@
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;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct MessageRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
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,
}
}
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>>,
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,
}
}
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>,
fullname: String,
avatar: Option<String>,
}
pub struct PostgresChatRepository {
pool: Arc<PgPool>,
pool: Arc<PgPool>,
}
impl PostgresChatRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
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(
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())
}
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(
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())
}
Ok(row.into())
}
async fn find_message_by_id(&self, id: Uuid) -> Result<Option<MessageEntity>, AppError> {
let row: Option<MessageRow> = sqlx::query_as(
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))
}
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 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 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')")
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()))
}
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()))
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::build_chat_routes;
+504 -115
View File
@@ -1,120 +1,509 @@
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",
"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)
let city_lower = city.to_lowercase();
INDONESIAN_CITIES
.iter()
.any(|c| c.to_lowercase() == city_lower)
}
-23
View File
@@ -1,23 +0,0 @@
use std::env;
#[derive(Debug, Clone)]
pub struct HackathonConfig {
pub smtp_host: String,
pub smtp_user: String,
pub smtp_password: String,
pub from_email: String,
pub frontend_url: String,
}
impl HackathonConfig {
pub fn from_env() -> Self {
Self {
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(),
frontend_url: env::var("HACKATHON_FRONTEND_URL")
.unwrap_or_else(|_| "https://hackathon.imphnen.dev".to_string()),
}
}
}
@@ -1,129 +1,184 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::invitations::domain::entity::*;
use crate::invitations::domain::repository::InvitationRepository;
use crate::invitations::domain::service::InvitationService;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
let deadline = Utc
.with_ymd_and_hms(2025, 11, 30, 16, 59, 0)
.single()
.expect("valid constant date");
Utc::now() >= deadline
}
pub struct InvitationServiceImpl {
repo: Arc<dyn InvitationRepository>,
repo: Arc<dyn InvitationRepository>,
}
impl InvitationServiceImpl {
pub fn new(repo: Arc<dyn InvitationRepository>) -> Self {
Self { repo }
}
pub fn new(repo: Arc<dyn InvitationRepository>) -> Self {
Self { repo }
}
async fn do_invite(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Team invitations are closed (deadline: November 30, 2025).".to_string(),
));
}
let leader_id = self.repo.get_team_leader_id(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != inviter_id {
return Err(AppError::ForbiddenError("Only the team leader can send invitations".to_string()));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError("Cannot invite after submitting a project".to_string()));
}
let count = self.repo.active_member_count(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team already has the maximum of 5 members".to_string()));
}
let team_name = self.repo.get_team_name(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
let inviter_fullname = self.repo.get_inviter_name(inviter_id).await?
.unwrap_or_else(|| "Unknown".to_string());
let invitation_id = Uuid::new_v4();
let entity = self.repo.create(invitation_id, team_id, inviter_id, &input.invitee_email).await?;
tracing::warn!("Email sending is not available; invitation created for {}", input.invitee_email);
Ok(InvitationWithDetails {
id: entity.id,
team_id: entity.team_id,
team_name,
inviter_id: entity.inviter_id,
inviter_fullname,
invitee_email: entity.invitee_email,
status: entity.status,
created_at: entity.created_at,
})
}
async fn do_invite(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Team invitations are closed (deadline: November 30, 2025).".to_string(),
));
}
let leader_id = self
.repo
.get_team_leader_id(team_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != inviter_id {
return Err(AppError::ForbiddenError(
"Only the team leader can send invitations".to_string(),
));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError(
"Cannot invite after submitting a project".to_string(),
));
}
let count = self.repo.active_member_count(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError(
"Team already has the maximum of 5 members".to_string(),
));
}
let team_name = self
.repo
.get_team_name(team_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
let inviter_fullname = self
.repo
.get_inviter_name(inviter_id)
.await?
.unwrap_or_else(|| "Unknown".to_string());
let invitation_id = Uuid::new_v4();
let entity = self
.repo
.create(invitation_id, team_id, inviter_id, &input.invitee_email)
.await?;
tracing::warn!(
"Email sending is not available; invitation created for {}",
input.invitee_email
);
Ok(InvitationWithDetails {
id: entity.id,
team_id: entity.team_id,
team_name,
inviter_id: entity.inviter_id,
inviter_fullname,
invitee_email: entity.invitee_email,
status: entity.status,
created_at: entity.created_at,
})
}
}
#[async_trait]
impl InvitationService for InvitationServiceImpl {
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn get_my_invitations(&self, user_id: Uuid) -> Result<Vec<InvitationWithDetails>, AppError> {
let email = self.repo.get_user_email(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
self.repo.find_pending_by_email(&email).await
}
async fn get_my_invitations(
&self,
user_id: Uuid,
) -> Result<Vec<InvitationWithDetails>, AppError> {
let email = self
.repo
.get_user_email(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
self.repo.find_pending_by_email(&email).await
}
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let invitation = self.repo.find_by_id(invitation_id).await?
.ok_or_else(|| AppError::NotFoundError("Invitation not found".to_string()))?;
let user_email = self.repo.get_user_email(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
if invitation.invitee_email != user_email {
return Err(AppError::ForbiddenError("This invitation is not for you".to_string()));
}
if invitation.status != "pending" {
return Err(AppError::BadRequestError("Invitation is no longer pending".to_string()));
}
if accept {
if self.repo.team_has_submission(invitation.team_id).await? {
return Err(AppError::BadRequestError("Cannot join a team that has already submitted".to_string()));
}
if let Some(active_team) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!("You are already a member of team '{}'", active_team)));
}
let count = self.repo.active_member_count(invitation.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team is already full".to_string()));
}
self.repo.update_status(invitation_id, "accepted").await?;
self.repo.add_team_member(invitation.team_id, user_id).await?;
self.repo.reject_pending_for_email_except(&user_email, invitation_id).await?;
self.repo.reject_pending_join_requests_for_user(user_id).await?;
} else {
self.repo.update_status(invitation_id, "rejected").await?;
}
Ok(())
}
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let invitation =
self.repo.find_by_id(invitation_id).await?.ok_or_else(|| {
AppError::NotFoundError("Invitation not found".to_string())
})?;
let user_email = self
.repo
.get_user_email(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
if invitation.invitee_email != user_email {
return Err(AppError::ForbiddenError(
"This invitation is not for you".to_string(),
));
}
if invitation.status != "pending" {
return Err(AppError::BadRequestError(
"Invitation is no longer pending".to_string(),
));
}
if accept {
if self.repo.team_has_submission(invitation.team_id).await? {
return Err(AppError::BadRequestError(
"Cannot join a team that has already submitted".to_string(),
));
}
if let Some(active_team) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!(
"You are already a member of team '{}'",
active_team
)));
}
let count = self.repo.active_member_count(invitation.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError(
"Team is already full".to_string(),
));
}
self.repo.update_status(invitation_id, "accepted").await?;
self
.repo
.add_team_member(invitation.team_id, user_id)
.await?;
self
.repo
.reject_pending_for_email_except(&user_email, invitation_id)
.await?;
self
.repo
.reject_pending_join_requests_for_user(user_id)
.await?;
} else {
self.repo.update_status(invitation_id, "rejected").await?;
}
Ok(())
}
}
@@ -1,29 +1,29 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[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>>,
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>>,
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,
pub invitee_email: String,
}
@@ -1,41 +1,65 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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 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_by_id(&self, id: Uuid)
-> Result<Option<InvitationEntity>, AppError>;
async fn find_pending_by_email(&self, email: &str) -> Result<Vec<InvitationWithDetails>, 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 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 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 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 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_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_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_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 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 active_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
async fn team_has_submission(&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 user_active_team_name(
&self,
user_id: Uuid,
) -> Result<Option<String>, AppError>;
}
@@ -1,30 +1,33 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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(
&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 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 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>;
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError>;
}
@@ -1,50 +1,50 @@
use crate::invitations::domain::entity::*;
use chrono::{DateTime, Utc};
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>>,
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,
}
}
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,
pub accept: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateInvitationRequest {
pub invitee_email: String,
pub invitee_email: String,
}
impl From<CreateInvitationRequest> for CreateInvitationInput {
fn from(r: CreateInvitationRequest) -> Self {
Self {
invitee_email: r.invitee_email,
}
}
fn from(r: CreateInvitationRequest) -> Self {
Self {
invitee_email: r.invitee_email,
}
}
}
@@ -1,37 +1,49 @@
use super::dto::*;
use crate::invitations::domain::service::InvitationService;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
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>,
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())
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>,
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())
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>,
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())
let invitation = service
.invite_member(team_id, auth.user_id, body.into())
.await?;
Ok(ApiSuccess(InvitationResponse::from(invitation)).into_response())
}
@@ -1,21 +1,31 @@
use axum::{middleware::from_fn, routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
use crate::invitations::application::invitation_service::InvitationServiceImpl;
use crate::invitations::domain::service::InvitationService;
use crate::invitations::infrastructure::persistence::PostgresInvitationRepository;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{get, post},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn build_invitation_routes(pool: Arc<PgPool>) -> 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(pool))
.layer(from_fn(hackathon_auth_middleware))
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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -1,159 +1,211 @@
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;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct InvitationRow {
id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
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,
}
}
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>>,
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,
}
}
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>,
pool: Arc<PgPool>,
}
impl PostgresInvitationRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
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(
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())
}
Ok(row.into())
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<InvitationEntity>, AppError> {
let row: Option<InvitationRow> = sqlx::query_as(
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))
}
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(
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())
}
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 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")
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(())
}
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())")
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(())
}
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'")
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(())
}
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_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_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_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 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'")
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 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")
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()))
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::build_invitation_routes;
@@ -1,117 +1,175 @@
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;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
let deadline = Utc
.with_ymd_and_hms(2025, 11, 30, 16, 59, 0)
.single()
.expect("valid constant date");
Utc::now() >= deadline
}
pub struct JoinRequestServiceImpl {
repo: Arc<dyn JoinRequestRepository>,
repo: Arc<dyn JoinRequestRepository>,
}
impl JoinRequestServiceImpl {
pub fn new(repo: Arc<dyn JoinRequestRepository>) -> Self {
Self { repo }
}
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 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_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 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(())
}
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(())
}
}
@@ -1,30 +1,30 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[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>>,
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>>,
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,
pub message: String,
}
@@ -1,43 +1,73 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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 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_id(
&self,
id: Uuid,
) -> Result<Option<JoinRequestEntity>, AppError>;
async fn find_by_user(&self, user_id: Uuid) -> Result<Vec<JoinRequestWithDetails>, 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 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 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 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_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 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_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 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_exists(&self, team_id: Uuid) -> Result<bool, AppError>;
async fn team_has_submission(&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 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 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>;
async fn pending_request_exists(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<bool, AppError>;
}
@@ -1,29 +1,32 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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 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_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 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>;
async fn respond_to_join_request(
&self,
request_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError>;
}
@@ -1,50 +1,50 @@
use crate::join_requests::domain::entity::*;
use chrono::{DateTime, Utc};
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>>,
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,
}
}
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,
pub message: String,
}
impl From<CreateJoinRequestRequest> for CreateJoinRequestInput {
fn from(r: CreateJoinRequestRequest) -> Self {
Self { message: r.message }
}
fn from(r: CreateJoinRequestRequest) -> Self {
Self { message: r.message }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RespondToJoinRequestRequest {
pub accept: bool,
pub accept: bool,
}
@@ -1,47 +1,62 @@
use super::dto::*;
use crate::join_requests::domain::service::JoinRequestService;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
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>,
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())
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>,
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())
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>,
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())
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>,
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())
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())
}
@@ -1,22 +1,35 @@
use axum::{middleware::from_fn, routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
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::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{get, post},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn build_join_request_routes(pool: Arc<PgPool>) -> 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(pool))
.layer(from_fn(hackathon_auth_middleware))
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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -1,173 +1,230 @@
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;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct JoinRequestRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: String,
status: String,
created_at: Option<DateTime<Utc>>,
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,
}
}
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>>,
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,
}
}
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>,
pool: Arc<PgPool>,
}
impl PostgresJoinRequestRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
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(
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())
}
Ok(row.into())
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<JoinRequestEntity>, AppError> {
let row: Option<JoinRequestRow> = sqlx::query_as(
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))
}
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(
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())
}
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(
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())
}
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 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())")
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(())
}
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'")
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(())
}
}
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")
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(())
}
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_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")
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 team_exists(&self, team_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1)")
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()))
}
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')")
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()))
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::build_join_request_routes;
+23 -22
View File
@@ -1,46 +1,47 @@
pub mod config;
pub mod common;
pub mod middleware;
pub mod admin;
pub mod certificates;
pub mod chat;
pub mod common;
pub mod invitations;
pub mod join_requests;
pub mod middleware;
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 certificates::hackathon_certificates_routes;
pub use chat::build_chat_routes;
pub use invitations::build_invitation_routes;
pub use join_requests::build_join_request_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 imphnen_storage::MinioService;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use imphnen_libs::MinioService;
pub fn hackathon_router(db: DatabaseConnection, _config: Arc<HackathonConfig>, minio: Arc<MinioService>) -> Router {
let pool = Arc::new(db.get_postgres_connection_pool().clone());
pub fn hackathon_router(
db: DatabaseConnection,
minio: Arc<MinioService>,
) -> Router {
let pool = Arc::new(db.get_postgres_connection_pool().clone());
Router::new()
.merge(hackathon_users_routes(pool.clone()))
.merge(build_team_routes(pool.clone()))
.merge(build_invitation_routes(pool.clone()))
.merge(build_join_request_routes(pool.clone()))
.merge(build_chat_routes(pool.clone()))
.merge(hackathon_submissions_routes(pool.clone()))
.merge(hackathon_storage_routes(pool.clone(), minio))
.merge(hackathon_certificates_routes(pool.clone()))
.merge(hackathon_winners_routes(pool.clone()))
.merge(hackathon_admin_routes(pool))
Router::new()
.merge(hackathon_users_routes(pool.clone()))
.merge(build_team_routes(pool.clone()))
.merge(build_invitation_routes(pool.clone()))
.merge(build_join_request_routes(pool.clone()))
.merge(build_chat_routes(pool.clone()))
.merge(hackathon_submissions_routes(pool.clone()))
.merge(hackathon_storage_routes(pool.clone(), minio))
.merge(hackathon_certificates_routes(pool.clone()))
.merge(hackathon_winners_routes(pool.clone()))
.merge(hackathon_admin_routes(pool))
}
+18 -14
View File
@@ -1,21 +1,25 @@
use crate::middleware::hackathon_auth::HackathonAuthUser;
use axum::{
body::Body,
extract::Extension,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
Json,
body::Body,
extract::Extension,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
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,
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
if !auth_user.is_admin {
return (
StatusCode::FORBIDDEN,
Json(json!({ "message": "Forbidden - Admin access required" })),
)
.into_response();
}
next.run(req).await
}
@@ -1,49 +1,62 @@
use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}};
use axum::http::StatusCode;
use axum::{
body::Body,
extract::Request,
middleware::Next,
response::{IntoResponse, Response},
};
use imphnen_libs::decode_access_token;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use imphnen_libs::decode_access_token;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HackathonAuthUser {
pub user_id: Uuid,
pub is_admin: bool,
pub user_id: Uuid,
pub is_admin: bool,
}
pub async fn hackathon_auth_middleware(
axum::Extension(pool): axum::Extension<Arc<PgPool>>,
mut request: Request<Body>,
next: Next,
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 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 token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
"Invalid Authorization header format",
)
.into_response()
})?;
let token_data = decode_access_token(token).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response()
})?;
let token_data = decode_access_token(token).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response()
})?;
let user_id = Uuid::parse_str(&token_data.claims.user_id).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response()
})?;
let user_id = Uuid::parse_str(&token_data.claims.user_id).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);
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)
request
.extensions_mut()
.insert(HackathonAuthUser { user_id, is_admin });
Ok(next.run(request).await)
}
@@ -0,0 +1 @@
pub mod storage_service;
@@ -0,0 +1,38 @@
use crate::storage::domain::service::StorageService;
use async_trait::async_trait;
use chrono::Utc;
use imphnen_storage::MinioService;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct StorageServiceImpl {
minio: Arc<MinioService>,
}
impl StorageServiceImpl {
pub fn new(minio: Arc<MinioService>) -> Self {
Self { minio }
}
}
#[async_trait]
impl StorageService for StorageServiceImpl {
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 unique_name =
format!("{}-{}.{}", user_id, Utc::now().timestamp_millis(), ext);
self
.minio
.upload_base64_file(data_base64, content_type, folder, &unique_name)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
@@ -0,0 +1 @@
pub mod service;
@@ -0,0 +1,15 @@
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait StorageService: Send + Sync {
async fn upload(
&self,
folder: &str,
user_id: Uuid,
filename: &str,
content_type: &str,
data_base64: &str,
) -> Result<String, AppError>;
}
@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[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,
}
@@ -0,0 +1,74 @@
use super::dto::{UploadRequest, UploadResponse};
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::storage::domain::service::StorageService;
use axum::{Extension, Json, response::IntoResponse};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
pub async fn upload_file_handler(
Extension(service): Extension<Arc<dyn 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())
}
pub async fn upload_avatar_handler(
Extension(service): Extension<Arc<dyn 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())
}
pub async fn upload_team_handler(
Extension(service): Extension<Arc<dyn 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())
}
pub async fn upload_submission_handler(
Extension(service): Extension<Arc<dyn 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())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,23 @@
use super::handlers::*;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use crate::storage::application::storage_service::StorageServiceImpl;
use crate::storage::domain::service::StorageService;
use axum::{Extension, Router, middleware::from_fn, routing::post};
use imphnen_storage::MinioService;
use sqlx::PgPool;
use std::sync::Arc;
pub fn hackathon_storage_routes(
pool: Arc<PgPool>,
minio: Arc<MinioService>,
) -> Router {
let service: Arc<dyn StorageService> = Arc::new(StorageServiceImpl::new(minio));
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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -0,0 +1 @@
pub mod http;
+4 -3
View File
@@ -1,4 +1,5 @@
pub mod service;
pub mod routes;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use routes::hackathon_storage_routes;
pub use infrastructure::http::routes::hackathon_storage_routes;
-69
View File
@@ -1,69 +0,0 @@
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 imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use imphnen_libs::MinioService;
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>, minio: Arc<MinioService>) -> Router {
let service = Arc::new(StorageService::new(minio));
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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
-22
View File
@@ -1,22 +0,0 @@
use std::sync::Arc;
use uuid::Uuid;
use chrono::Utc;
use imphnen_utils::errors::AppError;
use imphnen_libs::MinioService;
pub struct StorageService {
minio: Arc<MinioService>,
}
impl StorageService {
pub fn new(minio: Arc<MinioService>) -> Self { Self { minio } }
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 unique_name = format!("{}-{}.{}", user_id, Utc::now().timestamp_millis(), ext);
self.minio
.upload_base64_file(data_base64, content_type, folder, &unique_name)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
@@ -1,98 +1,163 @@
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;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
fn is_submission_deadline_passed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 12, 7, 16, 59, 0).unwrap();
Utc::now() >= deadline
let deadline = Utc
.with_ymd_and_hms(2025, 12, 7, 16, 59, 0)
.single()
.expect("valid constant date");
Utc::now() >= deadline
}
pub struct SubmissionServiceImpl {
repo: Arc<dyn SubmissionRepository>,
repo: Arc<dyn SubmissionRepository>,
}
impl SubmissionServiceImpl {
pub fn new(repo: Arc<dyn SubmissionRepository>) -> Self { Self { repo } }
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 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 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 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 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 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
}
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
}
}
@@ -1,39 +1,39 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[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>>,
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>>,
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>>,
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>>,
}
@@ -1,16 +1,40 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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>;
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>;
}
@@ -1,14 +1,40 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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>;
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>;
}
@@ -1,71 +1,88 @@
use crate::submissions::domain::entity::*;
use chrono::{DateTime, Utc};
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>>,
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,
}
}
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>>,
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,
}
}
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>>,
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,
}
}
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,
}
}
}
@@ -1,63 +1,71 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use std::sync::Arc;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use super::dto::*;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::submissions::domain::service::SubmissionService;
use super::dto::*;
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
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>,
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())
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>,
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())
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>,
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())
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>,
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())
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>,
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())
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>,
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())
let sub = service
.cancel_submission(submission_id, auth.user_id)
.await?;
Ok(ApiSuccess(SubmissionResponse::from(sub)).into_response())
}
@@ -1,21 +1,42 @@
use axum::{middleware::from_fn, routing::{get, post, put}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use crate::submissions::application::submission_service::SubmissionServiceImpl;
use crate::submissions::domain::service::SubmissionService;
use crate::submissions::infrastructure::persistence::PostgresSubmissionRepository;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{get, post, put},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn hackathon_submissions_routes(pool: Arc<PgPool>) -> 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(pool))
.layer(from_fn(hackathon_auth_middleware))
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(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -1,106 +1,198 @@
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;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[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>>,
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,
}
}
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 } } }
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(
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())
}
Ok(row.into())
}
async fn find_by_team(&self, team_id: Uuid) -> Result<Option<SubmissionEntity>, AppError> {
let row: Option<SubmissionRow> = sqlx::query_as(
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))
}
Ok(row.map(Into::into))
}
async fn find_by_id(&self, id: Uuid) -> Result<SubmissionEntity, AppError> {
let row: SubmissionRow = sqlx::query_as(
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())
}
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(
&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(
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())
}
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)")
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 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'")
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()))
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_submissions_routes;
@@ -1,177 +1,322 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::common::cities::is_valid_indonesian_city;
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;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
let deadline = Utc
.with_ymd_and_hms(2025, 11, 30, 16, 59, 0)
.single()
.expect("valid constant date");
Utc::now() >= deadline
}
fn team_features_closed_err() -> AppError {
AppError::BadRequestError("Team features are closed. The deadline was November 30, 2025 at 23:59 WIB.".to_string())
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>,
repo: Arc<dyn TeamRepository>,
}
impl TeamServiceImpl {
pub fn new(repo: Arc<dyn TeamRepository>) -> Self { Self { repo } }
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 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 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 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?;
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 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 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();
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 })
}
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 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 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
&& !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 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 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(())
}
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(())
}
}
+66 -66
View File
@@ -1,98 +1,98 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[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>>,
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>>,
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>>,
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>>,
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>,
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>,
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 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,
pub teams: Vec<TeamWithDetails>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
@@ -1,28 +1,73 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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>;
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>;
}
+32 -11
View File
@@ -1,16 +1,37 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[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>;
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>;
}
@@ -1,140 +1,188 @@
use crate::teams::domain::entity::*;
use chrono::{DateTime, Utc};
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>,
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 }
}
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>>,
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 }
}
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>>,
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,
}
}
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>,
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 }
}
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>,
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 }
}
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,
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 }
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 }
}
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,
pub data: Vec<TeamResponse>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
@@ -1,82 +1,104 @@
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 super::dto::*;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::teams::domain::service::TeamService;
use super::dto::*;
use axum::{
Extension, Json,
extract::{Path, Query},
response::IntoResponse,
};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
use std::sync::Arc;
use uuid::Uuid;
pub async fn create_team_handler(
Extension(service): Extension<Arc<dyn TeamService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<CreateTeamRequest>,
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())
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>,
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())
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>,
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())
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>,
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())
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>,
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())
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>,
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())
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>,
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())
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)>,
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())
service
.remove_team_member(team_id, auth.user_id, member_id)
.await?;
Ok(ApiMessage::ok("Member removed successfully").into_response())
}
@@ -1,30 +1,40 @@
use axum::{middleware::from_fn, routing::{delete, get, post, put}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use crate::teams::application::team_service::TeamServiceImpl;
use crate::teams::domain::service::TeamService;
use crate::teams::infrastructure::persistence::PostgresTeamRepository;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, get, post, put},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn build_team_routes(pool: Arc<PgPool>) -> Router {
let repo = Arc::new(PostgresTeamRepository::new(pool.clone()));
let service: Arc<dyn TeamService> = Arc::new(TeamServiceImpl::new(repo));
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 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(from_fn(hackathon_auth_middleware));
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(from_fn(hackathon_auth_middleware));
Router::new().merge(public).merge(protected)
Router::new().merge(public).merge(protected)
}
@@ -1,4 +1,4 @@
pub mod postgres_team_repository;
mod postgres_team_queries;
pub mod postgres_team_repository;
pub use postgres_team_repository::PostgresTeamRepository;
@@ -1,111 +1,240 @@
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};
use crate::teams::domain::entity::{BrowseTeamsInput, TeamEntity, TeamUserInfo};
use imphnen_utils::errors::AppError;
use sqlx::FromRow;
use uuid::Uuid;
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;
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);
}
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 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 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))
}
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 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 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 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())
}
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())
}
}
@@ -1,182 +1,321 @@
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;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[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>>,
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 }
}
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>>,
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 }
}
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 } } }
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(
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())
}
Ok(row.into())
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<TeamEntity>, AppError> {
let row: Option<TeamRow> = sqlx::query_as(
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))
}
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(
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())
}
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(
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))
}
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(
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())
}
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")
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(())
}
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 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'")
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')")
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 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")
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 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'")
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(())
}
}
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'")
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(())
}
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_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_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 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 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 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
}
async fn browse(
&self,
input: BrowseTeamsInput,
) -> Result<(Vec<TeamEntity>, i64), AppError> {
self.browse_query(input).await
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::build_team_routes;
@@ -1,32 +1,39 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput};
use crate::users::domain::repository::HackathonUserRepository;
use crate::users::domain::service::HackathonUserService;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct HackathonUserServiceImpl {
repo: Arc<dyn HackathonUserRepository>,
repo: Arc<dyn HackathonUserRepository>,
}
impl HackathonUserServiceImpl {
pub fn new(repo: Arc<dyn HackathonUserRepository>) -> Self {
Self { repo }
}
pub fn new(repo: Arc<dyn HackathonUserRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl HackathonUserService for HackathonUserServiceImpl {
async fn get_user(&self, id: Uuid) -> Result<HackathonUserEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn get_user(&self, id: Uuid) -> Result<HackathonUserEntity, AppError> {
self.repo.find_by_id(id).await
}
async fn update_user(&self, id: Uuid, input: UpdateUserInput) -> Result<HackathonUserEntity, AppError> {
self.repo.update(id, input).await
}
async fn update_user(
&self,
id: Uuid,
input: UpdateUserInput,
) -> Result<HackathonUserEntity, AppError> {
self.repo.update(id, input).await
}
async fn get_user_teams(&self, user_id: Uuid) -> Result<Vec<serde_json::Value>, AppError> {
self.repo.get_user_teams(user_id).await
}
async fn get_user_teams(
&self,
user_id: Uuid,
) -> Result<Vec<serde_json::Value>, AppError> {
self.repo.get_user_teams(user_id).await
}
}
+18 -18
View File
@@ -1,27 +1,27 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct HackathonUserEntity {
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>>,
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, Default)]
pub struct UpdateUserInput {
pub fullname: Option<String>,
pub phone_number: Option<String>,
pub avatar: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
pub fullname: Option<String>,
pub phone_number: Option<String>,
pub avatar: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
}
@@ -1,11 +1,18 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::{HackathonUserEntity, UpdateUserInput};
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait HackathonUserRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid) -> Result<HackathonUserEntity, AppError>;
async fn update(&self, id: Uuid, input: UpdateUserInput) -> Result<HackathonUserEntity, AppError>;
async fn get_user_teams(&self, user_id: Uuid) -> Result<Vec<serde_json::Value>, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<HackathonUserEntity, AppError>;
async fn update(
&self,
id: Uuid,
input: UpdateUserInput,
) -> Result<HackathonUserEntity, AppError>;
async fn get_user_teams(
&self,
user_id: Uuid,
) -> Result<Vec<serde_json::Value>, AppError>;
}
+13 -6
View File
@@ -1,11 +1,18 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::{HackathonUserEntity, UpdateUserInput};
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait HackathonUserService: Send + Sync {
async fn get_user(&self, id: Uuid) -> Result<HackathonUserEntity, AppError>;
async fn update_user(&self, id: Uuid, input: UpdateUserInput) -> Result<HackathonUserEntity, AppError>;
async fn get_user_teams(&self, user_id: Uuid) -> Result<Vec<serde_json::Value>, AppError>;
async fn get_user(&self, id: Uuid) -> Result<HackathonUserEntity, AppError>;
async fn update_user(
&self,
id: Uuid,
input: UpdateUserInput,
) -> Result<HackathonUserEntity, AppError>;
async fn get_user_teams(
&self,
user_id: Uuid,
) -> Result<Vec<serde_json::Value>, AppError>;
}
@@ -1,50 +1,61 @@
use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::users::domain::entity::{HackathonUserEntity, UpdateUserInput};
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserResponse {
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>>,
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<HackathonUserEntity> for UserResponse {
fn from(e: HackathonUserEntity) -> Self {
Self {
id: e.id, email: e.email, fullname: e.fullname, avatar: e.avatar,
phone_number: e.phone_number, location: e.location, bio: e.bio,
skills: e.skills, is_active: e.is_active,
created_at: e.created_at, updated_at: e.updated_at,
}
}
fn from(e: HackathonUserEntity) -> Self {
Self {
id: e.id,
email: e.email,
fullname: e.fullname,
avatar: e.avatar,
phone_number: e.phone_number,
location: e.location,
bio: e.bio,
skills: e.skills,
is_active: e.is_active,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateUserRequest {
pub fullname: Option<String>,
pub phone_number: Option<String>,
pub avatar: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
pub fullname: Option<String>,
pub phone_number: Option<String>,
pub avatar: Option<String>,
pub location: Option<String>,
pub bio: Option<String>,
pub skills: Option<Vec<String>>,
}
impl From<UpdateUserRequest> for UpdateUserInput {
fn from(r: UpdateUserRequest) -> Self {
Self {
fullname: r.fullname, phone_number: r.phone_number, avatar: r.avatar,
location: r.location, bio: r.bio, skills: r.skills,
}
}
fn from(r: UpdateUserRequest) -> Self {
Self {
fullname: r.fullname,
phone_number: r.phone_number,
avatar: r.avatar,
location: r.location,
bio: r.bio,
skills: r.skills,
}
}
}
@@ -1,40 +1,40 @@
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use std::sync::Arc;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use super::dto::{UpdateUserRequest, UserResponse};
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::users::domain::service::HackathonUserService;
use super::dto::{UserResponse, UpdateUserRequest};
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
pub async fn get_me_handler(
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Extension(auth): Extension<HackathonAuthUser>,
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Extension(auth): Extension<HackathonAuthUser>,
) -> Result<axum::response::Response, AppError> {
let user = service.get_user(auth.user_id).await?;
Ok(ApiSuccess(UserResponse::from(user)).into_response())
let user = service.get_user(auth.user_id).await?;
Ok(ApiSuccess(UserResponse::from(user)).into_response())
}
pub async fn update_me_handler(
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<UpdateUserRequest>,
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Extension(auth): Extension<HackathonAuthUser>,
Json(body): Json<UpdateUserRequest>,
) -> Result<axum::response::Response, AppError> {
let user = service.update_user(auth.user_id, body.into()).await?;
Ok(ApiSuccess(UserResponse::from(user)).into_response())
let user = service.update_user(auth.user_id, body.into()).await?;
Ok(ApiSuccess(UserResponse::from(user)).into_response())
}
pub async fn get_user_handler(
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Path(user_id): Path<Uuid>,
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let user = service.get_user(user_id).await?;
Ok(ApiSuccess(UserResponse::from(user)).into_response())
let user = service.get_user(user_id).await?;
Ok(ApiSuccess(UserResponse::from(user)).into_response())
}
pub async fn get_user_teams_handler(
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Path(user_id): Path<Uuid>,
Extension(service): Extension<Arc<dyn HackathonUserService>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let teams = service.get_user_teams(user_id).await?;
Ok(ApiSuccess(teams).into_response())
let teams = service.get_user_teams(user_id).await?;
Ok(ApiSuccess(teams).into_response())
}

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