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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqnandClaude Sonnet 4.6 committed 2026-04-02 22:29:08 +07:00
1 parent 2ae43b3bcc
commit 331a4a4e88
442 files changed
+22226 -18700

No files matched your search

@@ -0,0 +1,84 @@
use crate::admin::domain::entity::*;
use crate::admin::domain::repository::AdminRepository;
use crate::admin::domain::service::AdminService;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct AdminServiceImpl {
repo: Arc<dyn AdminRepository>,
}
impl AdminServiceImpl {
pub fn new(repo: Arc<dyn AdminRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl AdminService for AdminServiceImpl {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError> {
self.repo.list_users(page, limit, search).await
}
async fn get_user(&self, user_id: Uuid) -> Result<AdminUserRow, AppError> {
self
.repo
.get_user(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError> {
self.repo.set_admin(user_id, is_admin).await
}
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError> {
self.repo.delete_user(user_id).await
}
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError> {
self.repo.list_teams(page, limit, search).await
}
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError> {
self.repo.delete_team(team_id).await
}
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError> {
self.repo.list_submissions(page, limit, status).await
}
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError> {
self.repo.set_winner(team_id, rank, prize).await
}
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError> {
self.repo.remove_winner(team_id).await
}
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError> {
self.repo.list_winners().await
}
}
@@ -0,0 +1 @@
pub mod admin_service;
@@ -0,0 +1,44 @@
use serde::Serialize;
use sqlx::FromRow;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct AdminUserRow {
pub id: Uuid,
pub email: String,
pub fullname: String,
pub avatar: Option<String>,
pub is_active: Option<bool>,
pub is_admin: Option<bool>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct AdminTeamRow {
pub id: Uuid,
pub name: String,
pub city: String,
pub visibility: String,
pub leader_id: Uuid,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct AdminSubmissionRow {
pub id: Uuid,
pub team_id: Uuid,
pub project_name: String,
pub status: String,
pub submitted_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct WinnerRow {
pub id: Uuid,
pub team_id: Uuid,
pub rank: i32,
pub prize: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,38 @@
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait AdminRepository: Send + Sync {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError>;
async fn get_user(&self, user_id: Uuid) -> Result<Option<AdminUserRow>, AppError>;
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError>;
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError>;
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError>;
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError>;
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError>;
}
@@ -0,0 +1,38 @@
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait AdminService: Send + Sync {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError>;
async fn get_user(&self, user_id: Uuid) -> Result<AdminUserRow, AppError>;
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError>;
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError>;
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError>;
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError>;
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError>;
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError>;
}
@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct PageQuery {
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_limit")]
pub limit: i64,
pub search: Option<String>,
pub status: Option<String>,
}
fn default_page() -> i64 {
1
}
fn default_limit() -> i64 {
20
}
#[derive(Debug, Serialize, ToSchema)]
pub struct PagedResponse<T> {
pub data: Vec<T>,
pub total: i64,
pub page: i64,
pub limit: i64,
}
#[derive(Deserialize, ToSchema)]
pub struct SetAdminRequest {
pub is_admin: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct SetWinnerRequest {
pub team_id: Uuid,
pub rank: i32,
pub prize: Option<String>,
}
@@ -0,0 +1,119 @@
use super::dto::*;
use crate::admin::domain::service::AdminService;
use axum::{
Extension, Json,
extract::{Path, Query},
response::IntoResponse,
};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
use std::sync::Arc;
use uuid::Uuid;
pub async fn admin_list_users(
Extension(service): Extension<Arc<dyn AdminService>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let (users, total) = service.list_users(q.page, q.limit, q.search).await?;
Ok(
ApiSuccess(PagedResponse {
data: users,
total,
page: q.page,
limit: q.limit,
})
.into_response(),
)
}
pub async fn admin_get_user(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let user = service.get_user(user_id).await?;
Ok(ApiSuccess(user).into_response())
}
pub async fn admin_set_admin(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(user_id): Path<Uuid>,
Json(body): Json<SetAdminRequest>,
) -> Result<ApiMessage, AppError> {
service.set_admin(user_id, body.is_admin).await?;
Ok(ApiMessage::ok("User admin status updated"))
}
pub async fn admin_delete_user(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(user_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
service.delete_user(user_id).await?;
Ok(ApiMessage::ok("User deleted"))
}
pub async fn admin_list_teams(
Extension(service): Extension<Arc<dyn AdminService>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let (teams, total) = service.list_teams(q.page, q.limit, q.search).await?;
Ok(
ApiSuccess(PagedResponse {
data: teams,
total,
page: q.page,
limit: q.limit,
})
.into_response(),
)
}
pub async fn admin_delete_team(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
service.delete_team(team_id).await?;
Ok(ApiMessage::ok("Team deleted"))
}
pub async fn admin_list_submissions(
Extension(service): Extension<Arc<dyn AdminService>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let (subs, total) = service.list_submissions(q.page, q.limit, q.status).await?;
Ok(
ApiSuccess(PagedResponse {
data: subs,
total,
page: q.page,
limit: q.limit,
})
.into_response(),
)
}
pub async fn admin_set_winner(
Extension(service): Extension<Arc<dyn AdminService>>,
Json(body): Json<SetWinnerRequest>,
) -> Result<ApiMessage, AppError> {
service
.set_winner(body.team_id, body.rank, body.prize)
.await?;
Ok(ApiMessage::ok("Winner set"))
}
pub async fn admin_remove_winner(
Extension(service): Extension<Arc<dyn AdminService>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
service.remove_winner(team_id).await?;
Ok(ApiMessage::ok("Winner removed"))
}
pub async fn admin_list_winners(
Extension(service): Extension<Arc<dyn AdminService>>,
) -> Result<axum::response::Response, AppError> {
let rows = service.list_winners().await?;
Ok(ApiSuccess(rows).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,40 @@
use super::handlers::*;
use crate::admin::application::admin_service::AdminServiceImpl;
use crate::admin::domain::service::AdminService;
use crate::admin::infrastructure::persistence::PostgresAdminRepository;
use crate::middleware::{
admin_only::admin_only, hackathon_auth::hackathon_auth_middleware,
};
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, get, post},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn hackathon_admin_routes(pool: Arc<PgPool>) -> Router {
let service: Arc<dyn AdminService> = Arc::new(AdminServiceImpl::new(Arc::new(
PostgresAdminRepository::new(pool.clone()),
)));
Router::new()
.route("/admin/users", get(admin_list_users))
.route(
"/admin/users/:user_id",
get(admin_get_user).delete(admin_delete_user),
)
.route("/admin/users/:user_id/set-admin", post(admin_set_admin))
.route("/admin/teams", get(admin_list_teams))
.route("/admin/teams/:team_id", delete(admin_delete_team))
.route("/admin/submissions", get(admin_list_submissions))
.route(
"/admin/winners",
get(admin_list_winners).post(admin_set_winner),
)
.route("/admin/winners/:team_id", delete(admin_remove_winner))
.layer(Extension(service))
.layer(Extension(pool.clone()))
.layer(from_fn(admin_only))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,2 @@
pub mod postgres_admin_repository;
pub use postgres_admin_repository::PostgresAdminRepository;
@@ -0,0 +1,131 @@
use crate::admin::domain::entity::*;
use crate::admin::domain::repository::AdminRepository;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
pub struct PostgresAdminRepository {
pool: Arc<PgPool>,
}
impl PostgresAdminRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl AdminRepository for PostgresAdminRepository {
async fn list_users(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminUserRow>, i64), AppError> {
let offset = (page - 1) * limit;
let pattern = search.as_deref().map(|s| format!("%{}%", s));
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1)")
.bind(&pattern).fetch_one(self.pool.as_ref()).await.unwrap_or(0);
let users: Vec<AdminUserRow> = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&pattern).bind(limit).bind(offset)
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((users, total))
}
async fn get_user(&self, user_id: Uuid) -> Result<Option<AdminUserRow>, AppError> {
sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn set_admin(&self, user_id: Uuid, is_admin: bool) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_users SET is_admin = $1 WHERE id = $2")
.bind(is_admin)
.bind(user_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn delete_user(&self, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_users WHERE id = $1")
.bind(user_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn list_teams(
&self,
page: i64,
limit: i64,
search: Option<String>,
) -> Result<(Vec<AdminTeamRow>, i64), AppError> {
let offset = (page - 1) * limit;
let pattern = search.as_deref().map(|s| format!("%{}%", s));
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1)",
)
.bind(&pattern)
.fetch_one(self.pool.as_ref())
.await
.unwrap_or(0);
let teams: Vec<AdminTeamRow> = sqlx::query_as("SELECT id, name, city, visibility, leader_id, created_at FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&pattern).bind(limit).bind(offset)
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((teams, total))
}
async fn delete_team(&self, team_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_teams WHERE id = $1")
.bind(team_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn list_submissions(
&self,
page: i64,
limit: i64,
status: Option<String>,
) -> Result<(Vec<AdminSubmissionRow>, i64), AppError> {
let offset = (page - 1) * limit;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1)")
.bind(&status).fetch_one(self.pool.as_ref()).await.unwrap_or(0);
let subs: Vec<AdminSubmissionRow> = sqlx::query_as("SELECT id, team_id, project_name, status, submitted_at, created_at FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&status).bind(limit).bind(offset)
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok((subs, total))
}
async fn set_winner(
&self,
team_id: Uuid,
rank: i32,
prize: Option<String>,
) -> Result<(), AppError> {
sqlx::query("INSERT INTO hackathon_winners (id, team_id, rank, prize, announced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) ON CONFLICT (team_id) DO UPDATE SET rank = $3, prize = $4, updated_at = NOW()")
.bind(Uuid::new_v4()).bind(team_id).bind(rank).bind(prize)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn remove_winner(&self, team_id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM hackathon_winners WHERE team_id = $1")
.bind(team_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn list_winners(&self) -> Result<Vec<WinnerRow>, AppError> {
sqlx::query_as("SELECT id, team_id, rank, prize, created_at FROM hackathon_winners ORDER BY rank ASC")
.fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
+5 -2
View File
@@ -1,2 +1,5 @@
pub mod routes;
pub use routes::hackathon_admin_routes;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_admin_routes;
-189
View File
@@ -1,189 +0,0 @@
use axum::{
extract::{Path, Query},
middleware::from_fn,
response::IntoResponse,
routing::{delete, get, post},
Extension, Json, Router,
};
use sqlx::{PgPool, FromRow};
use std::sync::Arc;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}};
use crate::middleware::{admin_only::admin_only, hackathon_auth::hackathon_auth_middleware};
#[derive(Deserialize)]
struct PageQuery {
#[serde(default = "default_page")]
page: i64,
#[serde(default = "default_limit")]
limit: i64,
search: Option<String>,
status: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_limit() -> i64 { 20 }
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct AdminUserRow {
id: Uuid,
email: String,
fullname: String,
avatar: Option<String>,
is_active: Option<bool>,
is_admin: Option<bool>,
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize, ToSchema)]
struct PagedResponse<T> {
data: Vec<T>,
total: i64,
page: i64,
limit: i64,
}
#[derive(Deserialize, ToSchema)]
struct SetAdminRequest { is_admin: bool }
async fn admin_list_users(
Extension(pool): Extension<Arc<PgPool>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let offset = (q.page - 1) * q.limit;
let pattern = q.search.as_deref().map(|s| format!("%{}%", s));
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1)")
.bind(&pattern).fetch_one(pool.as_ref()).await.unwrap_or(0);
let users: Vec<AdminUserRow> = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE ($1::text IS NULL OR email ILIKE $1 OR fullname ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&pattern).bind(q.limit).bind(offset)
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(PagedResponse { data: users, total, page: q.page, limit: q.limit }).into_response())
}
async fn admin_get_user(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let user: AdminUserRow = sqlx::query_as("SELECT id, email, fullname, avatar, is_active, is_admin, created_at FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
Ok(ApiSuccess(user).into_response())
}
async fn admin_set_admin(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
Json(body): Json<SetAdminRequest>,
) -> Result<ApiMessage, AppError> {
sqlx::query("UPDATE hackathon_users SET is_admin = $1 WHERE id = $2")
.bind(body.is_admin).bind(user_id)
.execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("User admin status updated"))
}
async fn admin_delete_user(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
sqlx::query("DELETE FROM hackathon_users WHERE id = $1")
.bind(user_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("User deleted"))
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct AdminTeamRow {
id: Uuid, name: String, city: String, visibility: String,
leader_id: Uuid, created_at: chrono::DateTime<chrono::Utc>,
}
async fn admin_list_teams(
Extension(pool): Extension<Arc<PgPool>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let offset = (q.page - 1) * q.limit;
let pattern = q.search.as_deref().map(|s| format!("%{}%", s));
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1)")
.bind(&pattern).fetch_one(pool.as_ref()).await.unwrap_or(0);
let teams: Vec<AdminTeamRow> = sqlx::query_as("SELECT id, name, city, visibility, leader_id, created_at FROM hackathon_teams WHERE ($1::text IS NULL OR name ILIKE $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&pattern).bind(q.limit).bind(offset)
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(PagedResponse { data: teams, total, page: q.page, limit: q.limit }).into_response())
}
async fn admin_delete_team(
Extension(pool): Extension<Arc<PgPool>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
sqlx::query("DELETE FROM hackathon_teams WHERE id = $1")
.bind(team_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("Team deleted"))
}
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct AdminSubmissionRow {
id: Uuid, team_id: Uuid, project_name: String, status: String,
submitted_at: Option<chrono::DateTime<chrono::Utc>>, created_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn admin_list_submissions(
Extension(pool): Extension<Arc<PgPool>>,
Query(q): Query<PageQuery>,
) -> Result<axum::response::Response, AppError> {
let offset = (q.page - 1) * q.limit;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1)")
.bind(&q.status).fetch_one(pool.as_ref()).await.unwrap_or(0);
let subs: Vec<AdminSubmissionRow> = sqlx::query_as("SELECT id, team_id, project_name, status, submitted_at, created_at FROM hackathon_project_submissions WHERE ($1::text IS NULL OR status = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3")
.bind(&q.status).bind(q.limit).bind(offset)
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(PagedResponse { data: subs, total, page: q.page, limit: q.limit }).into_response())
}
#[derive(Debug, Deserialize, ToSchema)]
struct SetWinnerRequest { team_id: Uuid, rank: i32, prize: Option<String> }
#[derive(Debug, Serialize, ToSchema, FromRow)]
struct WinnerRow { id: Uuid, team_id: Uuid, rank: i32, prize: Option<String>, created_at: Option<chrono::DateTime<chrono::Utc>> }
async fn admin_set_winner(
Extension(pool): Extension<Arc<PgPool>>,
Json(body): Json<SetWinnerRequest>,
) -> Result<ApiMessage, AppError> {
sqlx::query("INSERT INTO hackathon_winners (id, team_id, rank, prize, announced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW()) ON CONFLICT (team_id) DO UPDATE SET rank = $3, prize = $4, updated_at = NOW()")
.bind(Uuid::new_v4()).bind(body.team_id).bind(body.rank).bind(body.prize)
.execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("Winner set"))
}
async fn admin_remove_winner(
Extension(pool): Extension<Arc<PgPool>>,
Path(team_id): Path<Uuid>,
) -> Result<ApiMessage, AppError> {
sqlx::query("DELETE FROM hackathon_winners WHERE team_id = $1")
.bind(team_id).execute(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiMessage::ok("Winner removed"))
}
async fn admin_list_winners(
Extension(pool): Extension<Arc<PgPool>>,
) -> Result<axum::response::Response, AppError> {
let rows: Vec<WinnerRow> = sqlx::query_as("SELECT id, team_id, rank, prize, created_at FROM hackathon_winners ORDER BY rank ASC")
.fetch_all(pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(ApiSuccess(rows).into_response())
}
pub fn hackathon_admin_routes(pool: Arc<PgPool>) -> Router {
Router::new()
.route("/admin/users", get(admin_list_users))
.route("/admin/users/:user_id", get(admin_get_user).delete(admin_delete_user))
.route("/admin/users/:user_id/set-admin", post(admin_set_admin))
.route("/admin/teams", get(admin_list_teams))
.route("/admin/teams/:team_id", delete(admin_delete_team))
.route("/admin/submissions", get(admin_list_submissions))
.route("/admin/winners", get(admin_list_winners).post(admin_set_winner))
.route("/admin/winners/:team_id", delete(admin_remove_winner))
.layer(Extension(pool.clone()))
.layer(from_fn(admin_only))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -0,0 +1,31 @@
use crate::certificates::domain::entity::CertificateData;
use crate::certificates::domain::repository::CertificateRepository;
use crate::certificates::domain::service::CertificateService;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct CertificateServiceImpl {
repo: Arc<dyn CertificateRepository>,
}
impl CertificateServiceImpl {
pub fn new(repo: Arc<dyn CertificateRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl CertificateService for CertificateServiceImpl {
async fn get_certificate(
&self,
user_id: Uuid,
) -> Result<CertificateData, AppError> {
self
.repo
.find_by_user_id(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
}
@@ -0,0 +1 @@
pub mod certificate_service;
@@ -0,0 +1,16 @@
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct CertificateData {
pub user_id: Uuid,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub team_id: Option<Uuid>,
pub team_name: Option<String>,
pub is_leader: Option<bool>,
pub project_name: Option<String>,
pub submission_status: Option<String>,
pub winner_rank: Option<i32>,
pub winner_prize: Option<String>,
}
@@ -0,0 +1,3 @@
pub mod entity;
pub mod repository;
pub mod service;
@@ -0,0 +1,12 @@
use super::entity::CertificateData;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait CertificateRepository: Send + Sync {
async fn find_by_user_id(
&self,
user_id: Uuid,
) -> Result<Option<CertificateData>, AppError>;
}
@@ -0,0 +1,12 @@
use super::entity::CertificateData;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait CertificateService: Send + Sync {
async fn get_certificate(
&self,
user_id: Uuid,
) -> Result<CertificateData, AppError>;
}
@@ -0,0 +1,37 @@
use crate::certificates::domain::entity::CertificateData;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, ToSchema)]
pub struct CertificateResponse {
pub user_id: Uuid,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub team_id: Option<Uuid>,
pub team_name: Option<String>,
pub is_leader: Option<bool>,
pub project_name: Option<String>,
pub submission_status: Option<String>,
pub winner_rank: Option<i32>,
pub winner_prize: Option<String>,
}
impl From<CertificateData> for CertificateResponse {
fn from(d: CertificateData) -> Self {
Self {
user_id: d.user_id,
fullname: d.fullname,
email: d.email,
avatar: d.avatar,
team_id: d.team_id,
team_name: d.team_name,
is_leader: d.is_leader,
project_name: d.project_name,
submission_status: d.submission_status,
winner_rank: d.winner_rank,
winner_prize: d.winner_prize,
}
}
}
@@ -0,0 +1,14 @@
use super::dto::CertificateResponse;
use crate::certificates::domain::service::CertificateService;
use axum::{Extension, extract::Path, response::IntoResponse};
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
use std::sync::Arc;
use uuid::Uuid;
pub async fn get_certificate_handler(
Extension(service): Extension<Arc<dyn CertificateService>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let cert = service.get_certificate(user_id).await?;
Ok(ApiSuccess(CertificateResponse::from(cert)).into_response())
}
@@ -0,0 +1,3 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -0,0 +1,17 @@
use super::handlers::get_certificate_handler;
use crate::certificates::application::certificate_service::CertificateServiceImpl;
use crate::certificates::domain::service::CertificateService;
use crate::certificates::infrastructure::persistence::PostgresCertificateRepository;
use axum::{Extension, Router, routing::get};
use sqlx::PgPool;
use std::sync::Arc;
pub fn hackathon_certificates_routes(pool: Arc<PgPool>) -> Router {
let service: Arc<dyn CertificateService> = Arc::new(CertificateServiceImpl::new(
Arc::new(PostgresCertificateRepository::new(pool.clone())),
));
Router::new()
.route("/certificates/:user_id", get(get_certificate_handler))
.layer(Extension(service))
.layer(Extension(pool))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,2 @@
pub mod postgres_certificate_repository;
pub use postgres_certificate_repository::PostgresCertificateRepository;
@@ -0,0 +1,67 @@
use crate::certificates::domain::entity::CertificateData;
use crate::certificates::domain::repository::CertificateRepository;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct CertificateRow {
user_id: Uuid,
fullname: String,
email: String,
avatar: Option<String>,
team_id: Option<Uuid>,
team_name: Option<String>,
is_leader: Option<bool>,
project_name: Option<String>,
submission_status: Option<String>,
winner_rank: Option<i32>,
winner_prize: Option<String>,
}
impl From<CertificateRow> for CertificateData {
fn from(r: CertificateRow) -> Self {
Self {
user_id: r.user_id,
fullname: r.fullname,
email: r.email,
avatar: r.avatar,
team_id: r.team_id,
team_name: r.team_name,
is_leader: r.is_leader,
project_name: r.project_name,
submission_status: r.submission_status,
winner_rank: r.winner_rank,
winner_prize: r.winner_prize,
}
}
}
pub struct PostgresCertificateRepository {
pool: Arc<PgPool>,
}
impl PostgresCertificateRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl CertificateRepository for PostgresCertificateRepository {
async fn find_by_user_id(
&self,
user_id: Uuid,
) -> Result<Option<CertificateData>, AppError> {
let row: Option<CertificateRow> = sqlx::query_as(
"SELECT u.id as user_id, u.fullname, u.email, u.avatar, t.id as team_id, t.name as team_name, (t.leader_id = u.id) as is_leader, ps.project_name, ps.status as submission_status, w.rank as winner_rank, w.prize as winner_prize FROM hackathon_users u LEFT JOIN hackathon_team_members tm ON tm.user_id = u.id AND tm.status = 'active' LEFT JOIN hackathon_teams t ON t.id = tm.team_id LEFT JOIN hackathon_project_submissions ps ON ps.team_id = t.id LEFT JOIN hackathon_winners w ON w.team_id =Line truncated
)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
}
+5 -2
View File
@@ -1,2 +1,5 @@
pub mod routes;
pub use routes::hackathon_certificates_routes;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::hackathon_certificates_routes;
@@ -1,44 +0,0 @@
use axum::{extract::Path, response::IntoResponse, routing::get, Extension, Router};
use sqlx::{PgPool, FromRow};
use std::sync::Arc;
use uuid::Uuid;
use serde::Serialize;
use utoipa::ToSchema;
use imphnen_utils::{errors::AppError, response_format::ApiSuccess};
#[derive(Debug, Serialize, ToSchema, FromRow)]
pub struct CertificateResponse {
pub user_id: Uuid,
pub fullname: String,
pub email: String,
pub avatar: Option<String>,
pub team_id: Option<Uuid>,
pub team_name: Option<String>,
pub is_leader: Option<bool>,
pub project_name: Option<String>,
pub submission_status: Option<String>,
pub winner_rank: Option<i32>,
pub winner_prize: Option<String>,
}
async fn get_certificate_handler(
Extension(pool): Extension<Arc<PgPool>>,
Path(user_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let row: Option<CertificateResponse> = sqlx::query_as(
"SELECT u.id as user_id, u.fullname, u.email, u.avatar, t.id as team_id, t.name as team_name, (t.leader_id = u.id) as is_leader, ps.project_name, ps.status as submission_status, w.rank as winner_rank, w.prize as winner_prize FROM hackathon_users u LEFT JOIN hackathon_team_members tm ON tm.user_id = u.id AND tm.status = 'active' LEFT JOIN hackathon_teams t ON t.id = tm.team_id LEFT JOIN hackathon_project_submissions ps ON ps.team_id = t.id LEFT JOIN hackathon_winners w ON w.team_id = t.iLine truncated
)
.bind(user_id)
.fetch_optional(pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let cert = row.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
Ok(ApiSuccess(cert).into_response())
}
pub fn hackathon_certificates_routes(pool: Arc<PgPool>) -> Router {
Router::new()
.route("/certificates/:user_id", get(get_certificate_handler))
.layer(Extension(pool))
}
@@ -1,70 +1,96 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use crate::chat::domain::entity::*;
use crate::chat::domain::repository::ChatRepository;
use crate::chat::domain::service::ChatService;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
pub struct ChatServiceImpl {
repo: Arc<dyn ChatRepository>,
repo: Arc<dyn ChatRepository>,
}
impl ChatServiceImpl {
pub fn new(repo: Arc<dyn ChatRepository>) -> Self {
Self { repo }
}
pub fn new(repo: Arc<dyn ChatRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl ChatService for ChatServiceImpl {
async fn get_team_messages(&self, team_id: Uuid, user_id: Uuid) -> Result<Vec<MessageWithUser>, AppError> {
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team members can view messages".to_string()));
}
self.repo.find_team_messages(team_id).await
}
async fn get_team_messages(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<Vec<MessageWithUser>, AppError> {
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError(
"Only team members can view messages".to_string(),
));
}
self.repo.find_team_messages(team_id).await
}
async fn send_message(
&self,
team_id: Uuid,
user_id: Uuid,
input: SendMessageInput,
) -> Result<MessageWithUser, AppError> {
if input.message.trim().is_empty() {
return Err(AppError::BadRequestError("Message cannot be empty".to_string()));
}
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError("Only team members can send messages".to_string()));
}
let user_info = self.repo.get_user_info(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
let id = Uuid::new_v4();
let entity = self.repo.create_message(id, team_id, user_id, &input.message).await?;
Ok(MessageWithUser {
id: entity.id,
team_id: entity.team_id,
user_id: entity.user_id,
user_fullname: user_info.0,
user_avatar: user_info.1,
message: entity.message,
created_at: entity.created_at,
updated_at: entity.updated_at,
})
}
async fn send_message(
&self,
team_id: Uuid,
user_id: Uuid,
input: SendMessageInput,
) -> Result<MessageWithUser, AppError> {
if input.message.trim().is_empty() {
return Err(AppError::BadRequestError(
"Message cannot be empty".to_string(),
));
}
if !self.repo.is_team_member(team_id, user_id).await? {
return Err(AppError::ForbiddenError(
"Only team members can send messages".to_string(),
));
}
let user_info = self
.repo
.get_user_info(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
let id = Uuid::new_v4();
let entity = self
.repo
.create_message(id, team_id, user_id, &input.message)
.await?;
Ok(MessageWithUser {
id: entity.id,
team_id: entity.team_id,
user_id: entity.user_id,
user_fullname: user_info.0,
user_avatar: user_info.1,
message: entity.message,
created_at: entity.created_at,
updated_at: entity.updated_at,
})
}
async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
let message = self.repo.find_message_by_id(message_id).await?
.ok_or_else(|| AppError::NotFoundError("Message not found".to_string()))?;
let is_author = message.user_id == user_id;
let is_leader = self.repo.is_team_leader(message.team_id, user_id).await?;
if !is_author && !is_leader {
return Err(AppError::ForbiddenError("You can only delete your own messages or messages as team leader".to_string()));
}
let deleted = self.repo.delete_message(message_id).await?;
if !deleted {
return Err(AppError::NotFoundError("Message not found".to_string()));
}
Ok(())
}
async fn delete_message(
&self,
message_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
let message = self
.repo
.find_message_by_id(message_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Message not found".to_string()))?;
let is_author = message.user_id == user_id;
let is_leader = self.repo.is_team_leader(message.team_id, user_id).await?;
if !is_author && !is_leader {
return Err(AppError::ForbiddenError(
"You can only delete your own messages or messages as team leader"
.to_string(),
));
}
let deleted = self.repo.delete_message(message_id).await?;
if !deleted {
return Err(AppError::NotFoundError("Message not found".to_string()));
}
Ok(())
}
}
+16 -16
View File
@@ -1,29 +1,29 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct MessageEntity {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct MessageWithUser {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_avatar: Option<String>,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_avatar: Option<String>,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct SendMessageInput {
pub message: String,
pub message: String,
}
+33 -16
View File
@@ -1,27 +1,44 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait ChatRepository: Send + Sync {
async fn find_team_messages(&self, team_id: Uuid) -> Result<Vec<MessageWithUser>, AppError>;
async fn find_team_messages(
&self,
team_id: Uuid,
) -> Result<Vec<MessageWithUser>, AppError>;
async fn create_message(
&self,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: &str,
) -> Result<MessageEntity, AppError>;
async fn create_message(
&self,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: &str,
) -> Result<MessageEntity, AppError>;
async fn find_message_by_id(&self, id: Uuid) -> Result<Option<MessageEntity>, AppError>;
async fn find_message_by_id(
&self,
id: Uuid,
) -> Result<Option<MessageEntity>, AppError>;
async fn delete_message(&self, id: Uuid) -> Result<bool, AppError>;
async fn delete_message(&self, id: Uuid) -> Result<bool, AppError>;
async fn get_user_info(&self, user_id: Uuid) -> Result<Option<(String, Option<String>)>, AppError>;
async fn get_user_info(
&self,
user_id: Uuid,
) -> Result<Option<(String, Option<String>)>, AppError>;
async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn is_team_member(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<bool, AppError>;
async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError>;
async fn is_team_leader(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<bool, AppError>;
}
+19 -11
View File
@@ -1,18 +1,26 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait ChatService: Send + Sync {
async fn get_team_messages(&self, team_id: Uuid, user_id: Uuid) -> Result<Vec<MessageWithUser>, AppError>;
async fn get_team_messages(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<Vec<MessageWithUser>, AppError>;
async fn send_message(
&self,
team_id: Uuid,
user_id: Uuid,
input: SendMessageInput,
) -> Result<MessageWithUser, AppError>;
async fn send_message(
&self,
team_id: Uuid,
user_id: Uuid,
input: SendMessageInput,
) -> Result<MessageWithUser, AppError>;
async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
async fn delete_message(
&self,
message_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError>;
}
@@ -1,43 +1,43 @@
use crate::chat::domain::entity::*;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::chat::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MessageResponse {
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_avatar: Option<String>,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub id: Uuid,
pub team_id: Uuid,
pub user_id: Uuid,
pub user_fullname: String,
pub user_avatar: Option<String>,
pub message: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
impl From<MessageWithUser> for MessageResponse {
fn from(e: MessageWithUser) -> Self {
Self {
id: e.id,
team_id: e.team_id,
user_id: e.user_id,
user_fullname: e.user_fullname,
user_avatar: e.user_avatar,
message: e.message,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
fn from(e: MessageWithUser) -> Self {
Self {
id: e.id,
team_id: e.team_id,
user_id: e.user_id,
user_fullname: e.user_fullname,
user_avatar: e.user_avatar,
message: e.message,
created_at: e.created_at,
updated_at: e.updated_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SendMessageRequest {
pub message: String,
pub message: String,
}
impl From<SendMessageRequest> for SendMessageInput {
fn from(r: SendMessageRequest) -> Self {
Self { message: r.message }
}
fn from(r: SendMessageRequest) -> Self {
Self { message: r.message }
}
}
@@ -1,36 +1,42 @@
use super::dto::*;
use crate::chat::domain::service::ChatService;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
use std::sync::Arc;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}};
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::chat::domain::service::ChatService;
use super::dto::*;
pub async fn get_team_messages_handler(
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
let messages = service.get_team_messages(team_id, auth.user_id).await?;
let response: Vec<MessageResponse> = messages.into_iter().map(MessageResponse::from).collect();
Ok(ApiSuccess(response).into_response())
let messages = service.get_team_messages(team_id, auth.user_id).await?;
let response: Vec<MessageResponse> =
messages.into_iter().map(MessageResponse::from).collect();
Ok(ApiSuccess(response).into_response())
}
pub async fn send_message_handler(
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<SendMessageRequest>,
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<SendMessageRequest>,
) -> Result<axum::response::Response, AppError> {
let message = service.send_message(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(MessageResponse::from(message)).into_response())
let message = service
.send_message(team_id, auth.user_id, body.into())
.await?;
Ok(ApiSuccess(MessageResponse::from(message)).into_response())
}
pub async fn delete_message_handler(
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(message_id): Path<Uuid>,
Extension(service): Extension<Arc<dyn ChatService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(message_id): Path<Uuid>,
) -> Result<axum::response::Response, AppError> {
service.delete_message(message_id, auth.user_id).await?;
Ok(ApiMessage::ok("Message deleted").into_response())
service.delete_message(message_id, auth.user_id).await?;
Ok(ApiMessage::ok("Message deleted").into_response())
}
@@ -1,20 +1,27 @@
use axum::{middleware::from_fn, routing::{delete, get}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
use crate::chat::application::chat_service::ChatServiceImpl;
use crate::chat::domain::service::ChatService;
use crate::chat::infrastructure::persistence::PostgresChatRepository;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{delete, get},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn build_chat_routes(pool: Arc<PgPool>) -> Router {
let service: Arc<dyn ChatService> = Arc::new(ChatServiceImpl::new(
Arc::new(PostgresChatRepository::new(pool.clone())),
));
Router::new()
.route("/chat/teams/:team_id", get(get_team_messages_handler).post(send_message_handler))
.route("/chat/messages/:message_id", delete(delete_message_handler))
.layer(Extension(service))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
let service: Arc<dyn ChatService> = Arc::new(ChatServiceImpl::new(Arc::new(
PostgresChatRepository::new(pool.clone()),
)));
Router::new()
.route(
"/chat/teams/:team_id",
get(get_team_messages_handler).post(send_message_handler),
)
.route("/chat/messages/:message_id", delete(delete_message_handler))
.layer(Extension(service))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -1,127 +1,161 @@
use std::sync::Arc;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use async_trait::async_trait;
use sqlx::{PgPool, FromRow};
use imphnen_utils::errors::AppError;
use crate::chat::domain::entity::*;
use crate::chat::domain::repository::ChatRepository;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct MessageRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
}
impl From<MessageRow> for MessageEntity {
fn from(r: MessageRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
message: r.message,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
fn from(r: MessageRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
message: r.message,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
#[derive(FromRow)]
struct MessageWithUserRow {
id: Uuid,
team_id: Uuid,
user_id: Uuid,
user_fullname: String,
user_avatar: Option<String>,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
user_fullname: String,
user_avatar: Option<String>,
message: String,
created_at: Option<DateTime<Utc>>,
updated_at: Option<DateTime<Utc>>,
}
impl From<MessageWithUserRow> for MessageWithUser {
fn from(r: MessageWithUserRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
user_fullname: r.user_fullname,
user_avatar: r.user_avatar,
message: r.message,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
fn from(r: MessageWithUserRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
user_id: r.user_id,
user_fullname: r.user_fullname,
user_avatar: r.user_avatar,
message: r.message,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
#[derive(FromRow)]
struct UserInfoRow {
fullname: String,
avatar: Option<String>,
fullname: String,
avatar: Option<String>,
}
pub struct PostgresChatRepository {
pool: Arc<PgPool>,
pool: Arc<PgPool>,
}
impl PostgresChatRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl ChatRepository for PostgresChatRepository {
async fn find_team_messages(&self, team_id: Uuid) -> Result<Vec<MessageWithUser>, AppError> {
let rows: Vec<MessageWithUserRow> = sqlx::query_as(
async fn find_team_messages(
&self,
team_id: Uuid,
) -> Result<Vec<MessageWithUser>, AppError> {
let rows: Vec<MessageWithUserRow> = sqlx::query_as(
"SELECT m.id, m.team_id, m.user_id, u.fullname AS user_fullname, u.avatar AS user_avatar, m.message, m.created_at, m.updated_at FROM hackathon_team_messages m JOIN hackathon_users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY m.created_at ASC"
)
.bind(team_id).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(rows.into_iter().map(Into::into).collect())
}
Ok(rows.into_iter().map(Into::into).collect())
}
async fn create_message(&self, id: Uuid, team_id: Uuid, user_id: Uuid, message: &str) -> Result<MessageEntity, AppError> {
let now = Utc::now();
let row: MessageRow = sqlx::query_as(
async fn create_message(
&self,
id: Uuid,
team_id: Uuid,
user_id: Uuid,
message: &str,
) -> Result<MessageEntity, AppError> {
let now = Utc::now();
let row: MessageRow = sqlx::query_as(
"INSERT INTO hackathon_team_messages (id, team_id, user_id, message, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, team_id, user_id, message, created_at, updated_at"
)
.bind(id).bind(team_id).bind(user_id).bind(message).bind(now).bind(now)
.fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.into())
}
Ok(row.into())
}
async fn find_message_by_id(&self, id: Uuid) -> Result<Option<MessageEntity>, AppError> {
let row: Option<MessageRow> = sqlx::query_as(
async fn find_message_by_id(
&self,
id: Uuid,
) -> Result<Option<MessageEntity>, AppError> {
let row: Option<MessageRow> = sqlx::query_as(
"SELECT id, team_id, user_id, message, created_at, updated_at FROM hackathon_team_messages WHERE id = $1"
)
.bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
Ok(row.map(Into::into))
}
async fn delete_message(&self, id: Uuid) -> Result<bool, AppError> {
let result = sqlx::query("DELETE FROM hackathon_team_messages WHERE id = $1")
.bind(id).execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
async fn delete_message(&self, id: Uuid) -> Result<bool, AppError> {
let result = sqlx::query("DELETE FROM hackathon_team_messages WHERE id = $1")
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
async fn get_user_info(&self, user_id: Uuid) -> Result<Option<(String, Option<String>)>, AppError> {
let row: Option<UserInfoRow> = sqlx::query_as(
"SELECT fullname, avatar FROM hackathon_users WHERE id = $1"
)
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(|r| (r.fullname, r.avatar)))
}
async fn get_user_info(
&self,
user_id: Uuid,
) -> Result<Option<(String, Option<String>)>, AppError> {
let row: Option<UserInfoRow> =
sqlx::query_as("SELECT fullname, avatar FROM hackathon_users WHERE id = $1")
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(|r| (r.fullname, r.avatar)))
}
async fn is_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')")
async fn is_team_member(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_team_members WHERE team_id = $1 AND user_id = $2 AND status = 'active')")
.bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
async fn is_team_leader(&self, team_id: Uuid, user_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)")
.bind(team_id).bind(user_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn is_team_leader(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<bool, AppError> {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM hackathon_teams WHERE id = $1 AND leader_id = $2)",
)
.bind(team_id)
.bind(user_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::build_chat_routes;
+504 -115
View File
@@ -1,120 +1,509 @@
pub static INDONESIAN_CITIES: &[&str] = &[
"Aceh", "Banda Aceh", "Sabang", "Langsa", "Lhokseumawe", "Subulussalam",
"Bireuen", "Aceh Besar", "Aceh Timur", "Aceh Utara", "Aceh Barat", "Nagan Raya",
"Aceh Selatan", "Aceh Tenggara", "Gayo Lues", "Aceh Tengah", "Bener Meriah",
"Pidie", "Pidie Jaya", "Aceh Jaya", "Aceh Barat Daya", "Aceh Singkil", "Simeulue",
"Medan", "Binjai", "Tebing Tinggi", "Pematangsiantar", "Tanjungbalai", "Sibolga",
"Padangsidimpuan", "Gunungsitoli",
"Deli Serdang", "Asahan", "Langkat", "Serdang Bedagai", "Batubara", "Labuhanbatu",
"Labuhanbatu Utara", "Labuhanbatu Selatan", "Karo", "Dairi", "Pakpak Bharat",
"Humbang Hasundutan", "Toba", "Samosir", "Tapanuli Utara", "Tapanuli Tengah",
"Tapanuli Selatan", "Padang Lawas", "Padang Lawas Utara", "Mandailing Natal",
"Nias", "Nias Utara", "Nias Barat", "Nias Selatan",
"Padang", "Solok", "Sawah Lunto", "Padangpanjang", "Bukittinggi", "Payakumbuh", "Pariaman",
"Agam", "Limapuluh Kota", "Tanah Datar", "Padang Pariaman", "Pesisir Selatan",
"Solok Selatan", "Sijunjung", "Dharmasraya", "Pasaman", "Pasaman Barat",
"Kepulauan Mentawai",
"Pekanbaru", "Dumai",
"Kampar", "Pelalawan", "Siak", "Bengkalis", "Rokan Hilir", "Rokan Hulu",
"Kuantan Singingi", "Indragiri Hulu", "Indragiri Hilir", "Kepulauan Meranti",
"Jambi", "Sungai Penuh",
"Batanghari", "Muaro Jambi", "Tanjung Jabung Timur", "Tanjung Jabung Barat",
"Sarolangun", "Merangin", "Bungo", "Tebo", "Kerinci",
"Palembang", "Pagar Alam", "Lubuklinggau", "Prabumulih",
"Ogan Komering Ulu", "Ogan Komering Ulu Timur", "Ogan Komering Ulu Selatan",
"Ogan Komering Ilir", "Ogan Ilir", "Muara Enim", "Lahat", "Empat Lawang",
"Musi Banyuasin", "Banyuasin", "Musi Rawas", "Musi Rawas Utara", "Penukal Abab Lematang Ilir",
"Bengkulu", "Bengkulu Utara", "Bengkulu Selatan", "Bengkulu Tengah",
"Rejang Lebong", "Kepahiang", "Lebong", "Seluma", "Kaur", "Mukomuko",
"Bandar Lampung", "Metro",
"Lampung Utara", "Lampung Selatan", "Lampung Tengah", "Lampung Barat", "Lampung Timur",
"Tulang Bawang", "Tulang Bawang Barat", "Mesuji", "Pringsewu", "Pesawaran",
"Tanggamus", "Way Kanan", "Pesisir Barat",
"Pangkalpinang",
"Bangka", "Bangka Tengah", "Bangka Selatan", "Bangka Barat", "Belitung", "Belitung Timur",
"Tanjungpinang", "Batam",
"Bintan", "Karimun", "Natuna", "Anambas", "Lingga",
"Jakarta", "Jakarta Selatan", "Jakarta Timur", "Jakarta Pusat", "Jakarta Barat", "Jakarta Utara",
"Kepulauan Seribu",
"Bogor", "Sukabumi", "Bandung", "Cirebon", "Bekasi", "Depok", "Cimahi", "Tasikmalaya", "Banjar",
"Cianjur", "Garut", "Tasikmalaya", "Ciamis", "Kuningan", "Majalengka", "Sumedang",
"Indramayu", "Subang", "Purwakarta", "Karawang", "Bekasi", "Bandung Barat", "Pangandaran",
"Semarang", "Surakarta", "Magelang", "Salatiga", "Pekalongan", "Tegal",
"Cilacap", "Banyumas", "Purbalingga", "Banjarnegara", "Kebumen", "Purworejo",
"Wonosobo", "Magelang", "Boyolali", "Klaten", "Sukoharjo", "Wonogiri", "Karanganyar",
"Sragen", "Grobogan", "Blora", "Rembang", "Pati", "Kudus", "Jepara", "Demak",
"Semarang", "Temanggung", "Kendal", "Batang", "Pekalongan", "Pemalang", "Tegal", "Brebes",
"Yogyakarta",
"Sleman", "Bantul", "Kulon Progo", "Gunungkidul",
"Surabaya", "Malang", "Kediri", "Blitar", "Madiun", "Mojokerto", "Pasuruan", "Probolinggo",
"Batu",
"Pacitan", "Ponorogo", "Trenggalek", "Tulungagung", "Blitar", "Kediri", "Malang",
"Lumajang", "Jember", "Banyuwangi", "Bondowoso", "Situbondo", "Probolinggo", "Pasuruan",
"Sidoarjo", "Mojokerto", "Jombang", "Nganjuk", "Madiun", "Magetan", "Ngawi",
"Bojonegoro", "Tuban", "Lamongan", "Gresik", "Bangkalan", "Sampang", "Pamekasan", "Sumenep",
"Serang", "Cilegon", "Tangerang", "Tangerang Selatan",
"Pandeglang", "Lebak", "Tangerang",
"Denpasar",
"Badung", "Gianyar", "Tabanan", "Bangli", "Klungkung", "Buleleng", "Jembrana", "Karangasem",
"Mataram", "Bima",
"Lombok Barat", "Lombok Tengah", "Lombok Timur", "Lombok Utara", "Sumbawa", "Sumbawa Barat",
"Dompu", "Bima",
"Kupang",
"Sumba Barat", "Sumba Timur", "Sumba Tengah", "Sumba Barat Daya",
"Flores Timur", "Sikka", "Ende", "Ngada", "Nagekeo", "Manggarai", "Manggarai Timur",
"Manggarai Barat", "Rote Ndao", "Kupang", "Timor Tengah Selatan", "Timor Tengah Utara",
"Belu", "Malaka", "Alor", "Lembata",
"Pontianak", "Singkawang",
"Sambas", "Bengkayang", "Landak", "Mempawah", "Sanggau", "Sekadau", "Melawi", "Sintang",
"Kapuas Hulu", "Kubu Raya", "Kayong Utara", "Ketapang",
"Palangkaraya",
"Kotawaringin Barat", "Kotawaringin Timur", "Kapuas", "Barito Selatan", "Barito Utara",
"Katingan", "Seruyan", "Sukamara", "Lamandau", "Gunung Mas", "Pulang Pisau",
"Murung Raya", "Barito Timur",
"Banjarmasin", "Banjarbaru",
"Tanah Laut", "Kotabaru", "Banjar", "Barito Kuala", "Tapin", "Hulu Sungai Selatan",
"Hulu Sungai Tengah", "Hulu Sungai Utara", "Tabalong", "Tanah Bumbu", "Balangan",
"Samarinda", "Balikpapan", "Bontang",
"Paser", "Kutai Barat", "Kutai Kartanegara", "Kutai Timur", "Berau",
"Penajam Paser Utara", "Mahakam Ulu",
"Tarakan",
"Bulungan", "Tana Tidung", "Malinau", "Nunukan",
"Manado", "Bitung", "Tomohon", "Kotamobagu",
"Minahasa", "Minahasa Utara", "Minahasa Selatan", "Minahasa Tenggara",
"Bolaang Mongondow", "Bolaang Mongondow Utara", "Bolaang Mongondow Selatan",
"Bolaang Mongondow Timur", "Kepulauan Sangihe", "Kepulauan Sitaro", "Kepulauan Talaud",
"Palu",
"Donggala", "Sigi", "Parigi Moutong", "Tojo Una-Una", "Banggai", "Banggai Kepulauan",
"Banggai Laut", "Morowali", "Morowali Utara", "Poso", "Toli-Toli", "Buol",
"Gorontalo",
"Gorontalo", "Bone Bolango", "Pohuwato", "Boalemo", "Gorontalo Utara",
"Makassar", "Parepare", "Palopo",
"Gowa", "Takalar", "Jeneponto", "Bantaeng", "Bulukumba", "Selayar", "Sinjai",
"Bone", "Soppeng", "Wajo", "Sidrap", "Pinrang", "Enrekang", "Tana Toraja",
"Toraja Utara", "Luwu", "Luwu Timur", "Luwu Utara", "Barru", "Pangkep", "Maros",
"Mamuju", "Mamuju Tengah", "Mamuju Utara",
"Mamasa", "Polewali Mandar", "Majene",
"Kendari", "Baubau",
"Konawe", "Konawe Selatan", "Konawe Utara", "Konawe Kepulauan", "Kolaka", "Kolaka Timur",
"Kolaka Utara", "Bombana", "Buton", "Buton Selatan", "Buton Tengah", "Buton Utara",
"Muna", "Muna Barat", "Wakatobi",
"Ambon", "Tual",
"Buru", "Buru Selatan", "Seram Bagian Barat", "Seram Bagian Timur", "Maluku Tengah",
"Maluku Tenggara", "Maluku Barat Daya", "Kepulauan Aru",
"Ternate", "Tidore Kepulauan",
"Halmahera Barat", "Halmahera Utara", "Halmahera Timur", "Halmahera Selatan",
"Halmahera Tengah", "Kepulauan Sula", "Pulau Morotai", "Pulau Taliabu",
"Jayapura",
"Merauke", "Jayawijaya", "Mimika", "Boven Digoel", "Mappi", "Asmat", "Yahukimo",
"Pegunungan Bintang", "Tolikara", "Sarmi", "Keerom", "Waropen", "Supiori",
"Mamberamo Raya", "Nduga", "Lanny Jaya", "Mamberamo Tengah", "Yalimo", "Puncak",
"Dogiyai", "Intan Jaya", "Deiyai", "Puncak Jaya",
"Sorong", "Sorong Selatan", "Raja Ampat", "Teluk Bintuni", "Teluk Wondama",
"Manokwari", "Manokwari Selatan", "Pegunungan Arfak", "Fakfak", "Kaimana",
"Maybrat", "Tambrauw",
"Aceh",
"Banda Aceh",
"Sabang",
"Langsa",
"Lhokseumawe",
"Subulussalam",
"Bireuen",
"Aceh Besar",
"Aceh Timur",
"Aceh Utara",
"Aceh Barat",
"Nagan Raya",
"Aceh Selatan",
"Aceh Tenggara",
"Gayo Lues",
"Aceh Tengah",
"Bener Meriah",
"Pidie",
"Pidie Jaya",
"Aceh Jaya",
"Aceh Barat Daya",
"Aceh Singkil",
"Simeulue",
"Medan",
"Binjai",
"Tebing Tinggi",
"Pematangsiantar",
"Tanjungbalai",
"Sibolga",
"Padangsidimpuan",
"Gunungsitoli",
"Deli Serdang",
"Asahan",
"Langkat",
"Serdang Bedagai",
"Batubara",
"Labuhanbatu",
"Labuhanbatu Utara",
"Labuhanbatu Selatan",
"Karo",
"Dairi",
"Pakpak Bharat",
"Humbang Hasundutan",
"Toba",
"Samosir",
"Tapanuli Utara",
"Tapanuli Tengah",
"Tapanuli Selatan",
"Padang Lawas",
"Padang Lawas Utara",
"Mandailing Natal",
"Nias",
"Nias Utara",
"Nias Barat",
"Nias Selatan",
"Padang",
"Solok",
"Sawah Lunto",
"Padangpanjang",
"Bukittinggi",
"Payakumbuh",
"Pariaman",
"Agam",
"Limapuluh Kota",
"Tanah Datar",
"Padang Pariaman",
"Pesisir Selatan",
"Solok Selatan",
"Sijunjung",
"Dharmasraya",
"Pasaman",
"Pasaman Barat",
"Kepulauan Mentawai",
"Pekanbaru",
"Dumai",
"Kampar",
"Pelalawan",
"Siak",
"Bengkalis",
"Rokan Hilir",
"Rokan Hulu",
"Kuantan Singingi",
"Indragiri Hulu",
"Indragiri Hilir",
"Kepulauan Meranti",
"Jambi",
"Sungai Penuh",
"Batanghari",
"Muaro Jambi",
"Tanjung Jabung Timur",
"Tanjung Jabung Barat",
"Sarolangun",
"Merangin",
"Bungo",
"Tebo",
"Kerinci",
"Palembang",
"Pagar Alam",
"Lubuklinggau",
"Prabumulih",
"Ogan Komering Ulu",
"Ogan Komering Ulu Timur",
"Ogan Komering Ulu Selatan",
"Ogan Komering Ilir",
"Ogan Ilir",
"Muara Enim",
"Lahat",
"Empat Lawang",
"Musi Banyuasin",
"Banyuasin",
"Musi Rawas",
"Musi Rawas Utara",
"Penukal Abab Lematang Ilir",
"Bengkulu",
"Bengkulu Utara",
"Bengkulu Selatan",
"Bengkulu Tengah",
"Rejang Lebong",
"Kepahiang",
"Lebong",
"Seluma",
"Kaur",
"Mukomuko",
"Bandar Lampung",
"Metro",
"Lampung Utara",
"Lampung Selatan",
"Lampung Tengah",
"Lampung Barat",
"Lampung Timur",
"Tulang Bawang",
"Tulang Bawang Barat",
"Mesuji",
"Pringsewu",
"Pesawaran",
"Tanggamus",
"Way Kanan",
"Pesisir Barat",
"Pangkalpinang",
"Bangka",
"Bangka Tengah",
"Bangka Selatan",
"Bangka Barat",
"Belitung",
"Belitung Timur",
"Tanjungpinang",
"Batam",
"Bintan",
"Karimun",
"Natuna",
"Anambas",
"Lingga",
"Jakarta",
"Jakarta Selatan",
"Jakarta Timur",
"Jakarta Pusat",
"Jakarta Barat",
"Jakarta Utara",
"Kepulauan Seribu",
"Bogor",
"Sukabumi",
"Bandung",
"Cirebon",
"Bekasi",
"Depok",
"Cimahi",
"Tasikmalaya",
"Banjar",
"Cianjur",
"Garut",
"Tasikmalaya",
"Ciamis",
"Kuningan",
"Majalengka",
"Sumedang",
"Indramayu",
"Subang",
"Purwakarta",
"Karawang",
"Bekasi",
"Bandung Barat",
"Pangandaran",
"Semarang",
"Surakarta",
"Magelang",
"Salatiga",
"Pekalongan",
"Tegal",
"Cilacap",
"Banyumas",
"Purbalingga",
"Banjarnegara",
"Kebumen",
"Purworejo",
"Wonosobo",
"Magelang",
"Boyolali",
"Klaten",
"Sukoharjo",
"Wonogiri",
"Karanganyar",
"Sragen",
"Grobogan",
"Blora",
"Rembang",
"Pati",
"Kudus",
"Jepara",
"Demak",
"Semarang",
"Temanggung",
"Kendal",
"Batang",
"Pekalongan",
"Pemalang",
"Tegal",
"Brebes",
"Yogyakarta",
"Sleman",
"Bantul",
"Kulon Progo",
"Gunungkidul",
"Surabaya",
"Malang",
"Kediri",
"Blitar",
"Madiun",
"Mojokerto",
"Pasuruan",
"Probolinggo",
"Batu",
"Pacitan",
"Ponorogo",
"Trenggalek",
"Tulungagung",
"Blitar",
"Kediri",
"Malang",
"Lumajang",
"Jember",
"Banyuwangi",
"Bondowoso",
"Situbondo",
"Probolinggo",
"Pasuruan",
"Sidoarjo",
"Mojokerto",
"Jombang",
"Nganjuk",
"Madiun",
"Magetan",
"Ngawi",
"Bojonegoro",
"Tuban",
"Lamongan",
"Gresik",
"Bangkalan",
"Sampang",
"Pamekasan",
"Sumenep",
"Serang",
"Cilegon",
"Tangerang",
"Tangerang Selatan",
"Pandeglang",
"Lebak",
"Tangerang",
"Denpasar",
"Badung",
"Gianyar",
"Tabanan",
"Bangli",
"Klungkung",
"Buleleng",
"Jembrana",
"Karangasem",
"Mataram",
"Bima",
"Lombok Barat",
"Lombok Tengah",
"Lombok Timur",
"Lombok Utara",
"Sumbawa",
"Sumbawa Barat",
"Dompu",
"Bima",
"Kupang",
"Sumba Barat",
"Sumba Timur",
"Sumba Tengah",
"Sumba Barat Daya",
"Flores Timur",
"Sikka",
"Ende",
"Ngada",
"Nagekeo",
"Manggarai",
"Manggarai Timur",
"Manggarai Barat",
"Rote Ndao",
"Kupang",
"Timor Tengah Selatan",
"Timor Tengah Utara",
"Belu",
"Malaka",
"Alor",
"Lembata",
"Pontianak",
"Singkawang",
"Sambas",
"Bengkayang",
"Landak",
"Mempawah",
"Sanggau",
"Sekadau",
"Melawi",
"Sintang",
"Kapuas Hulu",
"Kubu Raya",
"Kayong Utara",
"Ketapang",
"Palangkaraya",
"Kotawaringin Barat",
"Kotawaringin Timur",
"Kapuas",
"Barito Selatan",
"Barito Utara",
"Katingan",
"Seruyan",
"Sukamara",
"Lamandau",
"Gunung Mas",
"Pulang Pisau",
"Murung Raya",
"Barito Timur",
"Banjarmasin",
"Banjarbaru",
"Tanah Laut",
"Kotabaru",
"Banjar",
"Barito Kuala",
"Tapin",
"Hulu Sungai Selatan",
"Hulu Sungai Tengah",
"Hulu Sungai Utara",
"Tabalong",
"Tanah Bumbu",
"Balangan",
"Samarinda",
"Balikpapan",
"Bontang",
"Paser",
"Kutai Barat",
"Kutai Kartanegara",
"Kutai Timur",
"Berau",
"Penajam Paser Utara",
"Mahakam Ulu",
"Tarakan",
"Bulungan",
"Tana Tidung",
"Malinau",
"Nunukan",
"Manado",
"Bitung",
"Tomohon",
"Kotamobagu",
"Minahasa",
"Minahasa Utara",
"Minahasa Selatan",
"Minahasa Tenggara",
"Bolaang Mongondow",
"Bolaang Mongondow Utara",
"Bolaang Mongondow Selatan",
"Bolaang Mongondow Timur",
"Kepulauan Sangihe",
"Kepulauan Sitaro",
"Kepulauan Talaud",
"Palu",
"Donggala",
"Sigi",
"Parigi Moutong",
"Tojo Una-Una",
"Banggai",
"Banggai Kepulauan",
"Banggai Laut",
"Morowali",
"Morowali Utara",
"Poso",
"Toli-Toli",
"Buol",
"Gorontalo",
"Gorontalo",
"Bone Bolango",
"Pohuwato",
"Boalemo",
"Gorontalo Utara",
"Makassar",
"Parepare",
"Palopo",
"Gowa",
"Takalar",
"Jeneponto",
"Bantaeng",
"Bulukumba",
"Selayar",
"Sinjai",
"Bone",
"Soppeng",
"Wajo",
"Sidrap",
"Pinrang",
"Enrekang",
"Tana Toraja",
"Toraja Utara",
"Luwu",
"Luwu Timur",
"Luwu Utara",
"Barru",
"Pangkep",
"Maros",
"Mamuju",
"Mamuju Tengah",
"Mamuju Utara",
"Mamasa",
"Polewali Mandar",
"Majene",
"Kendari",
"Baubau",
"Konawe",
"Konawe Selatan",
"Konawe Utara",
"Konawe Kepulauan",
"Kolaka",
"Kolaka Timur",
"Kolaka Utara",
"Bombana",
"Buton",
"Buton Selatan",
"Buton Tengah",
"Buton Utara",
"Muna",
"Muna Barat",
"Wakatobi",
"Ambon",
"Tual",
"Buru",
"Buru Selatan",
"Seram Bagian Barat",
"Seram Bagian Timur",
"Maluku Tengah",
"Maluku Tenggara",
"Maluku Barat Daya",
"Kepulauan Aru",
"Ternate",
"Tidore Kepulauan",
"Halmahera Barat",
"Halmahera Utara",
"Halmahera Timur",
"Halmahera Selatan",
"Halmahera Tengah",
"Kepulauan Sula",
"Pulau Morotai",
"Pulau Taliabu",
"Jayapura",
"Merauke",
"Jayawijaya",
"Mimika",
"Boven Digoel",
"Mappi",
"Asmat",
"Yahukimo",
"Pegunungan Bintang",
"Tolikara",
"Sarmi",
"Keerom",
"Waropen",
"Supiori",
"Mamberamo Raya",
"Nduga",
"Lanny Jaya",
"Mamberamo Tengah",
"Yalimo",
"Puncak",
"Dogiyai",
"Intan Jaya",
"Deiyai",
"Puncak Jaya",
"Sorong",
"Sorong Selatan",
"Raja Ampat",
"Teluk Bintuni",
"Teluk Wondama",
"Manokwari",
"Manokwari Selatan",
"Pegunungan Arfak",
"Fakfak",
"Kaimana",
"Maybrat",
"Tambrauw",
];
pub fn is_valid_indonesian_city(city: &str) -> bool {
let city_lower = city.to_lowercase();
INDONESIAN_CITIES.iter().any(|c| c.to_lowercase() == city_lower)
let city_lower = city.to_lowercase();
INDONESIAN_CITIES
.iter()
.any(|c| c.to_lowercase() == city_lower)
}
-23
View File
@@ -1,23 +0,0 @@
use std::env;
#[derive(Debug, Clone)]
pub struct HackathonConfig {
pub smtp_host: String,
pub smtp_user: String,
pub smtp_password: String,
pub from_email: String,
pub frontend_url: String,
}
impl HackathonConfig {
pub fn from_env() -> Self {
Self {
smtp_host: env::var("HACKATHON_SMTP_HOST").unwrap_or_default(),
smtp_user: env::var("HACKATHON_SMTP_USER").unwrap_or_default(),
smtp_password: env::var("HACKATHON_SMTP_PASSWORD").unwrap_or_default(),
from_email: env::var("HACKATHON_FROM_EMAIL").unwrap_or_default(),
frontend_url: env::var("HACKATHON_FRONTEND_URL")
.unwrap_or_else(|_| "https://hackathon.imphnen.dev".to_string()),
}
}
}
@@ -1,129 +1,184 @@
use std::sync::Arc;
use uuid::Uuid;
use async_trait::async_trait;
use chrono::{Utc, TimeZone};
use imphnen_utils::errors::AppError;
use crate::invitations::domain::entity::*;
use crate::invitations::domain::repository::InvitationRepository;
use crate::invitations::domain::service::InvitationService;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use imphnen_utils::errors::AppError;
use std::sync::Arc;
use uuid::Uuid;
fn is_team_features_closed() -> bool {
let deadline = Utc.with_ymd_and_hms(2025, 11, 30, 16, 59, 0).unwrap();
Utc::now() >= deadline
let deadline = Utc
.with_ymd_and_hms(2025, 11, 30, 16, 59, 0)
.single()
.expect("valid constant date");
Utc::now() >= deadline
}
pub struct InvitationServiceImpl {
repo: Arc<dyn InvitationRepository>,
repo: Arc<dyn InvitationRepository>,
}
impl InvitationServiceImpl {
pub fn new(repo: Arc<dyn InvitationRepository>) -> Self {
Self { repo }
}
pub fn new(repo: Arc<dyn InvitationRepository>) -> Self {
Self { repo }
}
async fn do_invite(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Team invitations are closed (deadline: November 30, 2025).".to_string(),
));
}
let leader_id = self.repo.get_team_leader_id(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != inviter_id {
return Err(AppError::ForbiddenError("Only the team leader can send invitations".to_string()));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError("Cannot invite after submitting a project".to_string()));
}
let count = self.repo.active_member_count(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team already has the maximum of 5 members".to_string()));
}
let team_name = self.repo.get_team_name(team_id).await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
let inviter_fullname = self.repo.get_inviter_name(inviter_id).await?
.unwrap_or_else(|| "Unknown".to_string());
let invitation_id = Uuid::new_v4();
let entity = self.repo.create(invitation_id, team_id, inviter_id, &input.invitee_email).await?;
tracing::warn!("Email sending is not available; invitation created for {}", input.invitee_email);
Ok(InvitationWithDetails {
id: entity.id,
team_id: entity.team_id,
team_name,
inviter_id: entity.inviter_id,
inviter_fullname,
invitee_email: entity.invitee_email,
status: entity.status,
created_at: entity.created_at,
})
}
async fn do_invite(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
if is_team_features_closed() {
return Err(AppError::BadRequestError(
"Team invitations are closed (deadline: November 30, 2025).".to_string(),
));
}
let leader_id = self
.repo
.get_team_leader_id(team_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
if leader_id != inviter_id {
return Err(AppError::ForbiddenError(
"Only the team leader can send invitations".to_string(),
));
}
if self.repo.team_has_submission(team_id).await? {
return Err(AppError::BadRequestError(
"Cannot invite after submitting a project".to_string(),
));
}
let count = self.repo.active_member_count(team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError(
"Team already has the maximum of 5 members".to_string(),
));
}
let team_name = self
.repo
.get_team_name(team_id)
.await?
.ok_or_else(|| AppError::NotFoundError("Team not found".to_string()))?;
let inviter_fullname = self
.repo
.get_inviter_name(inviter_id)
.await?
.unwrap_or_else(|| "Unknown".to_string());
let invitation_id = Uuid::new_v4();
let entity = self
.repo
.create(invitation_id, team_id, inviter_id, &input.invitee_email)
.await?;
tracing::warn!(
"Email sending is not available; invitation created for {}",
input.invitee_email
);
Ok(InvitationWithDetails {
id: entity.id,
team_id: entity.team_id,
team_name,
inviter_id: entity.inviter_id,
inviter_fullname,
invitee_email: entity.invitee_email,
status: entity.status,
created_at: entity.created_at,
})
}
}
#[async_trait]
impl InvitationService for InvitationServiceImpl {
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError> {
self.do_invite(team_id, inviter_id, input).await
}
async fn get_my_invitations(&self, user_id: Uuid) -> Result<Vec<InvitationWithDetails>, AppError> {
let email = self.repo.get_user_email(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
self.repo.find_pending_by_email(&email).await
}
async fn get_my_invitations(
&self,
user_id: Uuid,
) -> Result<Vec<InvitationWithDetails>, AppError> {
let email = self
.repo
.get_user_email(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
self.repo.find_pending_by_email(&email).await
}
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let invitation = self.repo.find_by_id(invitation_id).await?
.ok_or_else(|| AppError::NotFoundError("Invitation not found".to_string()))?;
let user_email = self.repo.get_user_email(user_id).await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
if invitation.invitee_email != user_email {
return Err(AppError::ForbiddenError("This invitation is not for you".to_string()));
}
if invitation.status != "pending" {
return Err(AppError::BadRequestError("Invitation is no longer pending".to_string()));
}
if accept {
if self.repo.team_has_submission(invitation.team_id).await? {
return Err(AppError::BadRequestError("Cannot join a team that has already submitted".to_string()));
}
if let Some(active_team) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!("You are already a member of team '{}'", active_team)));
}
let count = self.repo.active_member_count(invitation.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError("Team is already full".to_string()));
}
self.repo.update_status(invitation_id, "accepted").await?;
self.repo.add_team_member(invitation.team_id, user_id).await?;
self.repo.reject_pending_for_email_except(&user_email, invitation_id).await?;
self.repo.reject_pending_join_requests_for_user(user_id).await?;
} else {
self.repo.update_status(invitation_id, "rejected").await?;
}
Ok(())
}
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError> {
let invitation =
self.repo.find_by_id(invitation_id).await?.ok_or_else(|| {
AppError::NotFoundError("Invitation not found".to_string())
})?;
let user_email = self
.repo
.get_user_email(user_id)
.await?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))?;
if invitation.invitee_email != user_email {
return Err(AppError::ForbiddenError(
"This invitation is not for you".to_string(),
));
}
if invitation.status != "pending" {
return Err(AppError::BadRequestError(
"Invitation is no longer pending".to_string(),
));
}
if accept {
if self.repo.team_has_submission(invitation.team_id).await? {
return Err(AppError::BadRequestError(
"Cannot join a team that has already submitted".to_string(),
));
}
if let Some(active_team) = self.repo.user_active_team_name(user_id).await? {
return Err(AppError::ConflictError(format!(
"You are already a member of team '{}'",
active_team
)));
}
let count = self.repo.active_member_count(invitation.team_id).await?;
if count >= 5 {
return Err(AppError::BadRequestError(
"Team is already full".to_string(),
));
}
self.repo.update_status(invitation_id, "accepted").await?;
self
.repo
.add_team_member(invitation.team_id, user_id)
.await?;
self
.repo
.reject_pending_for_email_except(&user_email, invitation_id)
.await?;
self
.repo
.reject_pending_join_requests_for_user(user_id)
.await?;
} else {
self.repo.update_status(invitation_id, "rejected").await?;
}
Ok(())
}
}
@@ -1,29 +1,29 @@
use uuid::Uuid;
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct InvitationEntity {
pub id: Uuid,
pub team_id: Uuid,
pub inviter_id: Uuid,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
pub id: Uuid,
pub team_id: Uuid,
pub inviter_id: Uuid,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct InvitationWithDetails {
pub id: Uuid,
pub team_id: Uuid,
pub team_name: String,
pub inviter_id: Uuid,
pub inviter_fullname: String,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
pub id: Uuid,
pub team_id: Uuid,
pub team_name: String,
pub inviter_id: Uuid,
pub inviter_fullname: String,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Default)]
pub struct CreateInvitationInput {
pub invitee_email: String,
pub invitee_email: String,
}
@@ -1,41 +1,65 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait InvitationRepository: Send + Sync {
async fn create(
&self,
invitation_id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: &str,
) -> Result<InvitationEntity, AppError>;
async fn create(
&self,
invitation_id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: &str,
) -> Result<InvitationEntity, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<InvitationEntity>, AppError>;
async fn find_by_id(&self, id: Uuid)
-> Result<Option<InvitationEntity>, AppError>;
async fn find_pending_by_email(&self, email: &str) -> Result<Vec<InvitationWithDetails>, AppError>;
async fn find_pending_by_email(
&self,
email: &str,
) -> Result<Vec<InvitationWithDetails>, AppError>;
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>;
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError>;
async fn reject_pending_for_email_except(&self, email: &str, except_id: Uuid) -> Result<(), AppError>;
async fn reject_pending_for_email_except(
&self,
email: &str,
except_id: Uuid,
) -> Result<(), AppError>;
async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError>;
async fn add_team_member(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError>;
async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError>;
async fn reject_pending_join_requests_for_user(
&self,
user_id: Uuid,
) -> Result<(), AppError>;
async fn get_team_leader_id(&self, team_id: Uuid) -> Result<Option<Uuid>, AppError>;
async fn get_team_leader_id(
&self,
team_id: Uuid,
) -> Result<Option<Uuid>, AppError>;
async fn get_team_name(&self, team_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_team_name(&self, team_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_inviter_name(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn get_inviter_name(
&self,
user_id: Uuid,
) -> Result<Option<String>, AppError>;
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError>;
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError>;
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError>;
async fn user_active_team_name(&self, user_id: Uuid) -> Result<Option<String>, AppError>;
async fn user_active_team_name(
&self,
user_id: Uuid,
) -> Result<Option<String>, AppError>;
}
@@ -1,30 +1,33 @@
use async_trait::async_trait;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
use super::entity::*;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use uuid::Uuid;
#[async_trait]
pub trait InvitationService: Send + Sync {
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError>;
async fn invite_member(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError>;
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError>;
async fn invite_member_for_team(
&self,
team_id: Uuid,
inviter_id: Uuid,
input: CreateInvitationInput,
) -> Result<InvitationWithDetails, AppError>;
async fn get_my_invitations(&self, user_id: Uuid) -> Result<Vec<InvitationWithDetails>, AppError>;
async fn get_my_invitations(
&self,
user_id: Uuid,
) -> Result<Vec<InvitationWithDetails>, AppError>;
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError>;
async fn respond_to_invitation(
&self,
invitation_id: Uuid,
user_id: Uuid,
accept: bool,
) -> Result<(), AppError>;
}
@@ -1,50 +1,50 @@
use crate::invitations::domain::entity::*;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::invitations::domain::entity::*;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct InvitationResponse {
pub id: Uuid,
pub team_id: Uuid,
pub team_name: String,
pub inviter_id: Uuid,
pub inviter_fullname: String,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
pub id: Uuid,
pub team_id: Uuid,
pub team_name: String,
pub inviter_id: Uuid,
pub inviter_fullname: String,
pub invitee_email: String,
pub status: String,
pub created_at: Option<DateTime<Utc>>,
}
impl From<InvitationWithDetails> for InvitationResponse {
fn from(e: InvitationWithDetails) -> Self {
Self {
id: e.id,
team_id: e.team_id,
team_name: e.team_name,
inviter_id: e.inviter_id,
inviter_fullname: e.inviter_fullname,
invitee_email: e.invitee_email,
status: e.status,
created_at: e.created_at,
}
}
fn from(e: InvitationWithDetails) -> Self {
Self {
id: e.id,
team_id: e.team_id,
team_name: e.team_name,
inviter_id: e.inviter_id,
inviter_fullname: e.inviter_fullname,
invitee_email: e.invitee_email,
status: e.status,
created_at: e.created_at,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RespondToInvitationRequest {
pub accept: bool,
pub accept: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateInvitationRequest {
pub invitee_email: String,
pub invitee_email: String,
}
impl From<CreateInvitationRequest> for CreateInvitationInput {
fn from(r: CreateInvitationRequest) -> Self {
Self {
invitee_email: r.invitee_email,
}
}
fn from(r: CreateInvitationRequest) -> Self {
Self {
invitee_email: r.invitee_email,
}
}
}
@@ -1,37 +1,49 @@
use super::dto::*;
use crate::invitations::domain::service::InvitationService;
use crate::middleware::hackathon_auth::HackathonAuthUser;
use axum::{Extension, Json, extract::Path, response::IntoResponse};
use imphnen_utils::{
errors::AppError,
response_format::{ApiMessage, ApiSuccess},
};
use std::sync::Arc;
use uuid::Uuid;
use imphnen_utils::{errors::AppError, response_format::{ApiSuccess, ApiMessage}};
use crate::middleware::hackathon_auth::HackathonAuthUser;
use crate::invitations::domain::service::InvitationService;
use super::dto::*;
pub async fn get_my_invitations_handler(
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
) -> Result<axum::response::Response, AppError> {
let list = service.get_my_invitations(auth.user_id).await?;
let response: Vec<InvitationResponse> = list.into_iter().map(InvitationResponse::from).collect();
Ok(ApiSuccess(response).into_response())
let list = service.get_my_invitations(auth.user_id).await?;
let response: Vec<InvitationResponse> =
list.into_iter().map(InvitationResponse::from).collect();
Ok(ApiSuccess(response).into_response())
}
pub async fn respond_to_invitation_handler(
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(invitation_id): Path<Uuid>,
Json(body): Json<RespondToInvitationRequest>,
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(invitation_id): Path<Uuid>,
Json(body): Json<RespondToInvitationRequest>,
) -> Result<axum::response::Response, AppError> {
service.respond_to_invitation(invitation_id, auth.user_id, body.accept).await?;
let msg = if body.accept { "Invitation accepted" } else { "Invitation declined" };
Ok(ApiMessage::ok(msg).into_response())
service
.respond_to_invitation(invitation_id, auth.user_id, body.accept)
.await?;
let msg = if body.accept {
"Invitation accepted"
} else {
"Invitation declined"
};
Ok(ApiMessage::ok(msg).into_response())
}
pub async fn invite_team_member_handler(
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<CreateInvitationRequest>,
Extension(service): Extension<Arc<dyn InvitationService>>,
Extension(auth): Extension<HackathonAuthUser>,
Path(team_id): Path<Uuid>,
Json(body): Json<CreateInvitationRequest>,
) -> Result<axum::response::Response, AppError> {
let invitation = service.invite_member(team_id, auth.user_id, body.into()).await?;
Ok(ApiSuccess(InvitationResponse::from(invitation)).into_response())
let invitation = service
.invite_member(team_id, auth.user_id, body.into())
.await?;
Ok(ApiSuccess(InvitationResponse::from(invitation)).into_response())
}
@@ -1,21 +1,31 @@
use axum::{middleware::from_fn, routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use super::handlers::*;
use crate::invitations::application::invitation_service::InvitationServiceImpl;
use crate::invitations::domain::service::InvitationService;
use crate::invitations::infrastructure::persistence::PostgresInvitationRepository;
use crate::middleware::hackathon_auth::hackathon_auth_middleware;
use super::handlers::*;
use axum::{
Extension, Router,
middleware::from_fn,
routing::{get, post},
};
use sqlx::PgPool;
use std::sync::Arc;
pub fn build_invitation_routes(pool: Arc<PgPool>) -> Router {
let service: Arc<dyn InvitationService> = Arc::new(InvitationServiceImpl::new(
Arc::new(PostgresInvitationRepository::new(pool.clone())),
));
Router::new()
.route("/invitations/my", get(get_my_invitations_handler))
.route("/invitations/:invitation_id/respond", post(respond_to_invitation_handler))
.route("/invitations/teams/:team_id/invite", post(invite_team_member_handler))
.layer(Extension(service))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
let service: Arc<dyn InvitationService> = Arc::new(InvitationServiceImpl::new(
Arc::new(PostgresInvitationRepository::new(pool.clone())),
));
Router::new()
.route("/invitations/my", get(get_my_invitations_handler))
.route(
"/invitations/:invitation_id/respond",
post(respond_to_invitation_handler),
)
.route(
"/invitations/teams/:team_id/invite",
post(invite_team_member_handler),
)
.layer(Extension(service))
.layer(Extension(pool))
.layer(from_fn(hackathon_auth_middleware))
}
@@ -1,159 +1,211 @@
use std::sync::Arc;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use async_trait::async_trait;
use sqlx::{PgPool, FromRow};
use imphnen_utils::errors::AppError;
use crate::invitations::domain::entity::*;
use crate::invitations::domain::repository::InvitationRepository;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use imphnen_utils::errors::AppError;
use sqlx::{FromRow, PgPool};
use std::sync::Arc;
use uuid::Uuid;
#[derive(FromRow)]
struct InvitationRow {
id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
}
impl From<InvitationRow> for InvitationEntity {
fn from(r: InvitationRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
inviter_id: r.inviter_id,
invitee_email: r.invitee_email,
status: r.status,
created_at: r.created_at,
}
}
fn from(r: InvitationRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
inviter_id: r.inviter_id,
invitee_email: r.invitee_email,
status: r.status,
created_at: r.created_at,
}
}
}
#[derive(FromRow)]
struct InvitationDetailsRow {
id: Uuid,
team_id: Uuid,
team_name: String,
inviter_id: Uuid,
inviter_fullname: String,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
id: Uuid,
team_id: Uuid,
team_name: String,
inviter_id: Uuid,
inviter_fullname: String,
invitee_email: String,
status: String,
created_at: Option<DateTime<Utc>>,
}
impl From<InvitationDetailsRow> for InvitationWithDetails {
fn from(r: InvitationDetailsRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
team_name: r.team_name,
inviter_id: r.inviter_id,
inviter_fullname: r.inviter_fullname,
invitee_email: r.invitee_email,
status: r.status,
created_at: r.created_at,
}
}
fn from(r: InvitationDetailsRow) -> Self {
Self {
id: r.id,
team_id: r.team_id,
team_name: r.team_name,
inviter_id: r.inviter_id,
inviter_fullname: r.inviter_fullname,
invitee_email: r.invitee_email,
status: r.status,
created_at: r.created_at,
}
}
}
pub struct PostgresInvitationRepository {
pool: Arc<PgPool>,
pool: Arc<PgPool>,
}
impl PostgresInvitationRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl InvitationRepository for PostgresInvitationRepository {
async fn create(&self, invitation_id: Uuid, team_id: Uuid, inviter_id: Uuid, invitee_email: &str) -> Result<InvitationEntity, AppError> {
let row: InvitationRow = sqlx::query_as(
async fn create(
&self,
invitation_id: Uuid,
team_id: Uuid,
inviter_id: Uuid,
invitee_email: &str,
) -> Result<InvitationEntity, AppError> {
let row: InvitationRow = sqlx::query_as(
"INSERT INTO hackathon_team_invitations (id, team_id, inviter_id, invitee_email, status, created_at) VALUES ($1, $2, $3, $4, 'pending', NOW()) RETURNING id, team_id, inviter_id, invitee_email, status, created_at"
)
.bind(invitation_id).bind(team_id).bind(inviter_id).bind(invitee_email)
.fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.into())
}
Ok(row.into())
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<InvitationEntity>, AppError> {
let row: Option<InvitationRow> = sqlx::query_as(
async fn find_by_id(
&self,
id: Uuid,
) -> Result<Option<InvitationEntity>, AppError> {
let row: Option<InvitationRow> = sqlx::query_as(
"SELECT id, team_id, inviter_id, invitee_email, status, created_at FROM hackathon_team_invitations WHERE id = $1"
)
.bind(id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(row.map(Into::into))
}
Ok(row.map(Into::into))
}
async fn find_pending_by_email(&self, email: &str) -> Result<Vec<InvitationWithDetails>, AppError> {
let rows: Vec<InvitationDetailsRow> = sqlx::query_as(
async fn find_pending_by_email(
&self,
email: &str,
) -> Result<Vec<InvitationWithDetails>, AppError> {
let rows: Vec<InvitationDetailsRow> = sqlx::query_as(
"SELECT i.id, i.team_id, t.name AS team_name, i.inviter_id, u.fullname AS inviter_fullname, i.invitee_email, i.status, i.created_at FROM hackathon_team_invitations i JOIN hackathon_teams t ON t.id = i.team_id JOIN hackathon_users u ON u.id = i.inviter_id WHERE i.invitee_email = $1 AND i.status = 'pending'"
)
.bind(email).fetch_all(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(rows.into_iter().map(Into::into).collect())
}
Ok(rows.into_iter().map(Into::into).collect())
}
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_invitations SET status = $1 WHERE id = $2")
.bind(status).bind(id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn update_status(&self, id: Uuid, status: &str) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_invitations SET status = $1 WHERE id = $2")
.bind(status)
.bind(id)
.execute(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn reject_pending_for_email_except(&self, email: &str, except_id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending' AND id != $2")
async fn reject_pending_for_email_except(
&self,
email: &str,
except_id: Uuid,
) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_invitations SET status = 'rejected' WHERE invitee_email = $1 AND status = 'pending' AND id != $2")
.bind(email).bind(except_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
Ok(())
}
async fn add_team_member(&self, team_id: Uuid, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())")
async fn add_team_member(
&self,
team_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
sqlx::query("INSERT INTO hackathon_team_members (id, team_id, user_id, role, status, joined_at) VALUES ($1, $2, $3, 'member', 'active', NOW())")
.bind(Uuid::new_v4()).bind(team_id).bind(user_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
Ok(())
}
async fn reject_pending_join_requests_for_user(&self, user_id: Uuid) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'")
async fn reject_pending_join_requests_for_user(
&self,
user_id: Uuid,
) -> Result<(), AppError> {
sqlx::query("UPDATE hackathon_team_join_requests SET status = 'rejected' WHERE user_id = $1 AND status = 'pending'")
.bind(user_id)
.execute(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
Ok(())
}
async fn get_team_leader_id(&self, team_id: Uuid) -> Result<Option<Uuid>, AppError> {
sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1")
.bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_team_leader_id(
&self,
team_id: Uuid,
) -> Result<Option<Uuid>, AppError> {
sqlx::query_scalar("SELECT leader_id FROM hackathon_teams WHERE id = $1")
.bind(team_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_team_name(&self, team_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT name FROM hackathon_teams WHERE id = $1")
.bind(team_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_team_name(&self, team_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT name FROM hackathon_teams WHERE id = $1")
.bind(team_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_user_email(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT email FROM hackathon_users WHERE id = $1")
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_inviter_name(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT fullname FROM hackathon_users WHERE id = $1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn get_inviter_name(
&self,
user_id: Uuid,
) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT fullname FROM hackathon_users WHERE id = $1")
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError> {
sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'")
async fn active_member_count(&self, team_id: Uuid) -> Result<i64, AppError> {
sqlx::query_scalar("SELECT COUNT(*) FROM hackathon_team_members WHERE team_id = $1 AND status = 'active'")
.bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)")
.bind(team_id).fetch_one(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn team_has_submission(&self, team_id: Uuid) -> Result<bool, AppError> {
sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM hackathon_project_submissions WHERE team_id = $1)",
)
.bind(team_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
async fn user_active_team_name(&self, user_id: Uuid) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1")
async fn user_active_team_name(
&self,
user_id: Uuid,
) -> Result<Option<String>, AppError> {
sqlx::query_scalar("SELECT t.name FROM hackathon_teams t JOIN hackathon_team_members m ON m.team_id = t.id WHERE m.user_id = $1 AND m.status = 'active' LIMIT 1")
.bind(user_id).fetch_optional(self.pool.as_ref()).await.map_err(|e| AppError::InternalServerError(e.to_string()))
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
pub mod domain;
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::build_invitation_routes;
Loaded 50 of 442 files, more files were not shown because too many files have changed in this diff. Show more