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