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