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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user