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