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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 15:15:00 +07:00
co-authored by Claude Sonnet 4.6
parent 05a5b39195
commit 11442c6285
119 changed files with 4385 additions and 11 deletions
+16
View File
@@ -32,3 +32,19 @@ SSLMODE=require
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
RETRY_DELAY=1 RETRY_DELAY=1
GOOGLE_REDIRECT_URL=http://localhost:8000/api/v1/auth/google/callback 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
View File
@@ -1604,6 +1604,7 @@ dependencies = [
"imphnen-dimentorin", "imphnen-dimentorin",
"imphnen-entities", "imphnen-entities",
"imphnen-gacha", "imphnen-gacha",
"imphnen-hackathon",
"imphnen-iam", "imphnen-iam",
"imphnen-libs", "imphnen-libs",
"imphnen-middleware", "imphnen-middleware",
@@ -1619,6 +1620,31 @@ dependencies = [
"utoipa-swagger-ui", "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]] [[package]]
name = "imphnen-iam" name = "imphnen-iam"
version = "0.2.0" version = "0.2.0"
@@ -4215,6 +4241,7 @@ dependencies = [
"quote", "quote",
"regex", "regex",
"syn 2.0.111", "syn 2.0.111",
"uuid",
] ]
[[package]] [[package]]
+4 -1
View File
@@ -10,6 +10,7 @@ members = [
"imphnen-cms", # Content management, depends on core services "imphnen-cms", # Content management, depends on core services
"imphnen-gacha", # Game mechanics, depends on core services "imphnen-gacha", # Game mechanics, depends on core services
"imphnen-dimentorin",# Learning platform, 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-gateway", # API gateway, depends on all services
"imphnen-backend", # Main application, 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"] } argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1" jsonwebtoken = "9.3.1"
chrono = "0.4.41" 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"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.18", features = ["tokio1-native-tls"] } lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
thiserror = "2.0.14" thiserror = "2.0.14"
@@ -63,6 +64,7 @@ hyper = "1.6.0"
hyper-util = "0.1.16" hyper-util = "0.1.16"
minio = "0.3.0" minio = "0.3.0"
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "with-chrono", "uuid"] } 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" num_cpus = "1.16.0"
@@ -86,6 +88,7 @@ imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" } imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" } imphnen-middleware = { path = "./imphnen-middleware" }
imphnen-macros = { path = "./imphnen-macros" } imphnen-macros = { path = "./imphnen-macros" }
imphnen-hackathon = { path = "./imphnen-hackathon" }
[profile.release] [profile.release]
lto = "fat" lto = "fat"
+1
View File
@@ -4,6 +4,7 @@ version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-hackathon.workspace = true
imphnen-iam.workspace = true imphnen-iam.workspace = true
imphnen-libs.workspace = true imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
+3
View File
@@ -16,6 +16,7 @@ use imphnen_dimentorin::{
sessions_public_routes, sessions_protected_routes, sessions_public_routes, sessions_protected_routes,
}; };
use imphnen_gacha::gacha_router; use imphnen_gacha::gacha_router;
use imphnen_hackathon::{hackathon_router, HackathonConfig};
use imphnen_iam::{ use imphnen_iam::{
auth_public_routes, auth_public_routes,
permissions_protected_routes, permissions_protected_routes,
@@ -43,6 +44,7 @@ pub async fn gateway_service(
let db = state.postgres_connection.conn.clone(); let db = state.postgres_connection.conn.clone();
let state_arc = Arc::new(state.clone()); let state_arc = Arc::new(state.clone());
let hackathon_config = Arc::new(HackathonConfig::from_env());
let public_routes = Router::new() let public_routes = Router::new()
.merge(auth_public_routes(db.clone(), Arc::clone(&state_arc)).layer(from_fn(rate_limiting_middleware))) .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() Router::new()
.route("/", get(Redirect::to("/docs"))) .route("/", get(Redirect::to("/docs")))
.nest("/v1", public_routes.merge(protected_routes)) .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())) .merge(SwaggerUi::new("/docs").url("/openapi.json", docs_router()))
.layer(cors_middleware()) .layer(cors_middleware())
.layer(from_fn(security_headers_middleware)) .layer(from_fn(security_headers_middleware))
+25
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
pub mod routes;
pub use routes::hackathon_admin_routes;
+191
View File
@@ -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;
+1
View File
@@ -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;
+5
View File
@@ -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;
File diff suppressed because one or more lines are too long
@@ -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,
}
+3
View File
@@ -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()))
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
pub use infrastructure::http::routes::build_chat_routes;
+120
View File
@@ -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()))
}
}
+3
View File
@@ -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))
}
}
+45
View File
@@ -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;

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