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:
co-authored by
Claude Sonnet 4.6
parent
2ae43b3bcc
commit
331a4a4e88
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
+67
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+114
-80
@@ -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,5 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::build_chat_routes;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+154
-102
@@ -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,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))
|
||||
}
|
||||
|
||||
+171
-114
@@ -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,5 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
|
||||
pub use infrastructure::http::routes::build_join_request_routes;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+160
-68
@@ -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,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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+257
-118
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user