From bf191e3f4e66efa8df0bc7fa97ebb39d6bdd4e22 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Mon, 2 Mar 2026 16:14:50 +0000 Subject: [PATCH 1/3] feat: add distribution backend --- .../008_create_distributions_table.sql | 5 + backend/src/application/commands/mod.rs | 1 + .../commands/register_distribution.rs | 30 ++++++ .../src/application/dtos/distribution_dtos.rs | 15 +++ backend/src/application/dtos/mod.rs | 1 + .../repositories/distribution_repository.rs | 16 +++ backend/src/domain/repositories/mod.rs | 2 + .../src/infrastructure/repositories/mod.rs | 2 + .../postgres_distribution_repository.rs | 44 ++++++++ backend/src/presentation/api.rs | 11 +- backend/src/presentation/handlers.rs | 17 +++ backend/tests/distribution_tests.rs | 101 ++++++++++++++++++ backend/tests/integration_github_handle.rs | 7 ++ 13 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/008_create_distributions_table.sql create mode 100644 backend/src/application/commands/register_distribution.rs create mode 100644 backend/src/application/dtos/distribution_dtos.rs create mode 100644 backend/src/domain/repositories/distribution_repository.rs create mode 100644 backend/src/infrastructure/repositories/postgres_distribution_repository.rs create mode 100644 backend/tests/distribution_tests.rs diff --git a/backend/migrations/008_create_distributions_table.sql b/backend/migrations/008_create_distributions_table.sql new file mode 100644 index 0000000..d968ca2 --- /dev/null +++ b/backend/migrations/008_create_distributions_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS distributions ( + address TEXT NOT NULL, + badge_name TEXT NOT NULL, + distribution_id TEXT NOT NULL +); diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index af4e93e..315845e 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -2,6 +2,7 @@ pub mod create_profile; pub mod create_project; pub mod delete_project; pub mod login; +pub mod register_distribution; pub mod sync_github_issues; pub mod update_profile; pub mod update_project; diff --git a/backend/src/application/commands/register_distribution.rs b/backend/src/application/commands/register_distribution.rs new file mode 100644 index 0000000..4bda223 --- /dev/null +++ b/backend/src/application/commands/register_distribution.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use crate::{ + application::dtos::distribution_dtos::RegisterDistributionRequest, + domain::repositories::distribution_repository::{DistributionRecord, DistributionRepository}, +}; + +pub async fn register_distribution( + repository: Arc, + request: RegisterDistributionRequest, +) -> Result<(), String> { + if request.distributions.is_empty() { + return Err("distributions must not be empty".to_string()); + } + + let records: Vec = request + .distributions + .into_iter() + .map(|item| DistributionRecord { + address: item.address, + badge_name: item.badge_name, + distribution_id: item.distribution_id, + }) + .collect(); + + repository + .create_many(&records) + .await + .map_err(|e| e.to_string()) +} diff --git a/backend/src/application/dtos/distribution_dtos.rs b/backend/src/application/dtos/distribution_dtos.rs new file mode 100644 index 0000000..7aaef4f --- /dev/null +++ b/backend/src/application/dtos/distribution_dtos.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RegisterDistributionItem { + pub address: String, + #[serde(rename = "badgeName")] + pub badge_name: String, + #[serde(rename = "distributionId")] + pub distribution_id: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RegisterDistributionRequest { + pub distributions: Vec, +} diff --git a/backend/src/application/dtos/mod.rs b/backend/src/application/dtos/mod.rs index bb5526b..6e2deee 100644 --- a/backend/src/application/dtos/mod.rs +++ b/backend/src/application/dtos/mod.rs @@ -1,4 +1,5 @@ pub mod auth_dtos; +pub mod distribution_dtos; pub mod github_dtos; pub mod profile_dtos; pub mod project_dtos; diff --git a/backend/src/domain/repositories/distribution_repository.rs b/backend/src/domain/repositories/distribution_repository.rs new file mode 100644 index 0000000..bdb167c --- /dev/null +++ b/backend/src/domain/repositories/distribution_repository.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; + +#[derive(Clone, Debug)] +pub struct DistributionRecord { + pub address: String, + pub badge_name: String, + pub distribution_id: String, +} + +#[async_trait] +pub trait DistributionRepository: Send + Sync { + async fn create_many( + &self, + records: &[DistributionRecord], + ) -> Result<(), Box>; +} diff --git a/backend/src/domain/repositories/mod.rs b/backend/src/domain/repositories/mod.rs index 0bd393e..520f7c7 100644 --- a/backend/src/domain/repositories/mod.rs +++ b/backend/src/domain/repositories/mod.rs @@ -1,7 +1,9 @@ +pub mod distribution_repository; pub mod github_issue_repository; pub mod profile_repository; pub mod project_repository; +pub use distribution_repository::DistributionRepository; pub use github_issue_repository::GithubIssueRepository; pub use profile_repository::ProfileRepository; pub use project_repository::ProjectRepository; diff --git a/backend/src/infrastructure/repositories/mod.rs b/backend/src/infrastructure/repositories/mod.rs index cb7e757..eb23f99 100644 --- a/backend/src/infrastructure/repositories/mod.rs +++ b/backend/src/infrastructure/repositories/mod.rs @@ -1,7 +1,9 @@ +pub mod postgres_distribution_repository; pub mod postgres_github_issue_repository; pub mod postgres_profile_repository; pub mod postgres_project_repository; +pub use postgres_distribution_repository::PostgresDistributionRepository; pub use postgres_github_issue_repository::PostgresGithubIssueRepository; pub use postgres_profile_repository::PostgresProfileRepository; pub use postgres_project_repository::PostgresProjectRepository; diff --git a/backend/src/infrastructure/repositories/postgres_distribution_repository.rs b/backend/src/infrastructure/repositories/postgres_distribution_repository.rs new file mode 100644 index 0000000..364edb1 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgres_distribution_repository.rs @@ -0,0 +1,44 @@ +use async_trait::async_trait; +use sqlx::PgPool; + +use crate::domain::repositories::distribution_repository::{ + DistributionRecord, DistributionRepository, +}; + +#[derive(Clone)] +pub struct PostgresDistributionRepository { + pool: PgPool, +} + +impl PostgresDistributionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl DistributionRepository for PostgresDistributionRepository { + async fn create_many( + &self, + records: &[DistributionRecord], + ) -> Result<(), Box> { + let mut tx = self.pool.begin().await?; + + for record in records { + sqlx::query( + r#" + INSERT INTO distributions (address, badge_name, distribution_id) + VALUES ($1, $2, $3) + "#, + ) + .bind(&record.address) + .bind(&record.badge_name) + .bind(&record.distribution_id) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } +} diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index c4cb791..d88e4b6 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -1,10 +1,13 @@ use std::sync::Arc; -use crate::domain::repositories::{GithubIssueRepository, ProfileRepository, ProjectRepository}; +use crate::domain::repositories::{ + DistributionRepository, GithubIssueRepository, ProfileRepository, ProjectRepository, +}; use crate::domain::services::auth_service::AuthService; use crate::domain::services::github_service::GithubService; use crate::infrastructure::{ repositories::{ + postgres_distribution_repository::PostgresDistributionRepository, postgres_github_issue_repository::PostgresGithubIssueRepository, postgres_project_repository::PostgresProjectRepository, PostgresProfileRepository, }, @@ -43,6 +46,7 @@ use super::handlers::{ list_github_issues_handler, list_projects_handler, login_handler, + register_distribution_handler, update_profile_handler, update_project_handler, }; @@ -52,6 +56,7 @@ use super::middlewares::{admin_auth_layer, eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { let profile_repository = Arc::from(PostgresProfileRepository::new(pool.clone())); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let distribution_repository = Arc::from(PostgresDistributionRepository::new(pool.clone())); let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool)); let auth_service = EthereumAddressVerificationService::new(profile_repository.clone()); let github_service: Arc = Arc::from(RestGithubService::new()); @@ -59,6 +64,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { let state: AppState = AppState { profile_repository, project_repository, + distribution_repository, auth_service: Arc::from(auth_service), github_issue_repository, github_service, @@ -76,6 +82,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { .route("/projects", post(create_project_handler)) .route("/projects/:id", patch(update_project_handler)) .route("/projects/:id", delete(delete_project_handler)) + .route("/distributions", post(register_distribution_handler)) .with_state(state.clone()); let protected_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -142,6 +149,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { pub struct AppState { pub profile_repository: Arc, pub project_repository: Arc, + pub distribution_repository: Arc, pub auth_service: Arc, pub github_issue_repository: Arc, pub github_service: Arc, @@ -159,6 +167,7 @@ pub fn test_api(state: AppState) -> Router { .route("/projects", post(create_project_handler)) .route("/projects/:id", patch(update_project_handler)) .route("/projects/:id", delete(delete_project_handler)) + .route("/distributions", post(register_distribution_handler)) .with_state(state.clone()) .layer(from_fn(test_auth_layer)); diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 6095c76..7689bcd 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -35,6 +35,12 @@ use crate::application::{ }, }; +// Distribution imports +use crate::application::{ + commands::register_distribution::register_distribution, + dtos::distribution_dtos::RegisterDistributionRequest, +}; + // GitHub sync imports use crate::application::{ commands::sync_github_issues::sync_github_issues, @@ -375,3 +381,14 @@ pub async fn list_github_issues_handler( .into_response(), } } + +/// POST /distributions - Register distributions in batch (Protected) +pub async fn register_distribution_handler( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + match register_distribution(state.distribution_repository.clone(), request).await { + Ok(_) => StatusCode::CREATED.into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e}))).into_response(), + } +} diff --git a/backend/tests/distribution_tests.rs b/backend/tests/distribution_tests.rs new file mode 100644 index 0000000..3cc9bef --- /dev/null +++ b/backend/tests/distribution_tests.rs @@ -0,0 +1,101 @@ +use guild_backend::infrastructure::repositories::postgres_distribution_repository::PostgresDistributionRepository; +use guild_backend::infrastructure::repositories::postgres_github_issue_repository::PostgresGithubIssueRepository; +use guild_backend::infrastructure::repositories::postgres_project_repository::PostgresProjectRepository; +use guild_backend::infrastructure::services::rest_github_service::RestGithubService; +use guild_backend::presentation::api::{test_api, AppState}; +use serde_json::json; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::test] +async fn register_distribution_list_succeeds() { + std::env::set_var("TEST_MODE", "1"); + let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { + "postgres://guild_user:guild_password@localhost:5432/guild_genesis".to_string() + }); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let pool = sqlx::PgPool::connect(&database_url).await.unwrap(); + + let profile_repository = std::sync::Arc::new( + guild_backend::infrastructure::repositories::PostgresProfileRepository::new(pool.clone()), + ); + let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); + let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let distribution_repository = Arc::from(PostgresDistributionRepository::new(pool.clone())); + let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); + let github_service: Arc = + Arc::from(RestGithubService::new()); + + let state = AppState { + profile_repository, + project_repository, + distribution_repository, + auth_service: std::sync::Arc::new(auth_service), + github_issue_repository, + github_service, + }; + let app = test_api(state); + + let server = axum::serve(listener, app); + tokio::spawn(async move { server.await.unwrap() }); + + let base = format!("http://{}", addr); + let client = reqwest::Client::new(); + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS distributions ( + address TEXT NOT NULL, + badge_name TEXT NOT NULL, + distribution_id TEXT NOT NULL + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM distributions WHERE distribution_id = $1") + .bind("dist-test-001") + .execute(&pool) + .await + .unwrap(); + + let response = client + .post(format!("{}/distributions", base)) + .header( + "x-eth-address", + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + ) + .json(&json!({ + "distributions": [ + { + "address": "0x1234567890123456789012345678901234567890", + "badgeName": "Contributor", + "distributionId": "dist-test-001" + }, + { + "address": "0x1234567890123456789012345678901234567891", + "badgeName": "Reviewer", + "distributionId": "dist-test-001" + } + ] + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + + let inserted = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM distributions WHERE distribution_id = $1", + ) + .bind("dist-test-001") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(inserted, 2); +} diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index 5980468..77551e7 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -1,4 +1,5 @@ use guild_backend::application::dtos::profile_dtos::ProfileResponse; +use guild_backend::infrastructure::repositories::postgres_distribution_repository::PostgresDistributionRepository; use guild_backend::infrastructure::repositories::postgres_github_issue_repository::PostgresGithubIssueRepository; use guild_backend::infrastructure::repositories::postgres_project_repository::PostgresProjectRepository; use guild_backend::infrastructure::services::rest_github_service::RestGithubService; @@ -22,6 +23,7 @@ async fn valid_github_handle_works() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let distribution_repository = Arc::from(PostgresDistributionRepository::new(pool.clone())); let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); let github_service: Arc = @@ -30,6 +32,7 @@ async fn valid_github_handle_works() { let state = AppState { profile_repository, project_repository, + distribution_repository, auth_service: std::sync::Arc::new(auth_service), github_issue_repository, github_service, @@ -98,6 +101,7 @@ async fn invalid_format_rejected() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let distribution_repository = Arc::from(PostgresDistributionRepository::new(pool.clone())); let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); let github_service: Arc = Arc::from(RestGithubService::new()); @@ -105,6 +109,7 @@ async fn invalid_format_rejected() { let state = AppState { profile_repository, project_repository, + distribution_repository, auth_service: std::sync::Arc::new(auth_service), github_issue_repository, github_service, @@ -179,6 +184,7 @@ async fn conflict_case_insensitive() { ); let auth_service = guild_backend::infrastructure::services::ethereum_address_verification_service::EthereumAddressVerificationService::new(profile_repository.clone()); let project_repository = Arc::from(PostgresProjectRepository::new(pool.clone())); + let distribution_repository = Arc::from(PostgresDistributionRepository::new(pool.clone())); let github_issue_repository = Arc::from(PostgresGithubIssueRepository::new(pool.clone())); let github_service: Arc = Arc::from(RestGithubService::new()); @@ -186,6 +192,7 @@ async fn conflict_case_insensitive() { let state = AppState { profile_repository, project_repository, + distribution_repository, auth_service: std::sync::Arc::new(auth_service), github_issue_repository, github_service, From a6e96c7b05a39f47dba90c4362ace153a4a045ef Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Mon, 2 Mar 2026 17:00:19 +0000 Subject: [PATCH 2/3] fix: run rustfmt for handlers --- backend/src/presentation/handlers.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 7689bcd..8632158 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -389,6 +389,10 @@ pub async fn register_distribution_handler( ) -> impl IntoResponse { match register_distribution(state.distribution_repository.clone(), request).await { Ok(_) => StatusCode::CREATED.into_response(), - Err(e) => (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e}))).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ) + .into_response(), } } From 6a5163be6f9ac6fbc47da732829089d6dfeb7692 Mon Sep 17 00:00:00 2001 From: tusharshah21 Date: Fri, 6 Mar 2026 12:14:27 +0000 Subject: [PATCH 3/3] fix: make distributions admin-only --- backend/README.md | 41 +++++ .../src/application/dtos/distribution_dtos.rs | 9 + .../application/queries/list_distributions.rs | 26 +++ backend/src/application/queries/mod.rs | 1 + .../repositories/distribution_repository.rs | 5 + .../postgres_distribution_repository.rs | 40 +++++ backend/src/presentation/api.rs | 7 +- backend/src/presentation/handlers.rs | 29 +++- backend/tests/distribution_tests.rs | 25 ++- scripts/test_admin_distributions.sh | 159 ++++++++++++++++++ 10 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 backend/src/application/queries/list_distributions.rs create mode 100755 scripts/test_admin_distributions.sh diff --git a/backend/README.md b/backend/README.md index f42f578..7fb68a6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -314,6 +314,44 @@ curl -X DELETE \ **Note:** Only the project creator can delete projects. +### Distribution Endpoints (Admin) + +`/admin/distributions` endpoints are admin-only and require admin authentication. +Set `ADMIN_ADDRESSES` in `backend/.env` as a comma-separated list of admin wallet addresses. + +#### Register Distributions (Admin) +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'x-eth-address: ' \ + -H 'x-eth-signature: ' \ + -d '{ + "distributions": [ + { + "address": "0x1234567890123456789012345678901234567890", + "badgeName": "Contributor", + "distributionId": "dist-001" + } + ] + }' \ + http://0.0.0.0:3001/admin/distributions +``` + +#### List Distributions (Admin) +```bash +# List all distributions +curl \ + -H 'x-eth-address: ' \ + -H 'x-eth-signature: ' \ + 'http://0.0.0.0:3001/admin/distributions' + +# Filter by distributionId +curl \ + -H 'x-eth-address: ' \ + -H 'x-eth-signature: ' \ + 'http://0.0.0.0:3001/admin/distributions?distributionId=dist-001' +``` + ### API Response Codes - **200 OK** - Successful GET/PUT/PATCH - **201 Created** - Resource created @@ -335,6 +373,9 @@ Test scripts are available in the `scripts/` directory: # Test project endpoints (requires keys.json) ./scripts/test_projects_api.sh + +# Test admin distribution flow (nonce -> signature -> post -> list) +./scripts/test_admin_distributions.sh ``` **Note:** Test scripts require `keys.json` in the project root: diff --git a/backend/src/application/dtos/distribution_dtos.rs b/backend/src/application/dtos/distribution_dtos.rs index 7aaef4f..3aa7258 100644 --- a/backend/src/application/dtos/distribution_dtos.rs +++ b/backend/src/application/dtos/distribution_dtos.rs @@ -13,3 +13,12 @@ pub struct RegisterDistributionItem { pub struct RegisterDistributionRequest { pub distributions: Vec, } + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DistributionResponse { + pub address: String, + #[serde(rename = "badgeName")] + pub badge_name: String, + #[serde(rename = "distributionId")] + pub distribution_id: String, +} diff --git a/backend/src/application/queries/list_distributions.rs b/backend/src/application/queries/list_distributions.rs new file mode 100644 index 0000000..0cf59e7 --- /dev/null +++ b/backend/src/application/queries/list_distributions.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use crate::{ + application::dtos::distribution_dtos::DistributionResponse, + domain::repositories::distribution_repository::DistributionRepository, +}; + +pub async fn list_distributions( + repository: Arc, + distribution_id: Option, +) -> Result, String> { + repository + .list(distribution_id.as_deref()) + .await + .map(|records| { + records + .into_iter() + .map(|record| DistributionResponse { + address: record.address, + badge_name: record.badge_name, + distribution_id: record.distribution_id, + }) + .collect() + }) + .map_err(|e| e.to_string()) +} diff --git a/backend/src/application/queries/mod.rs b/backend/src/application/queries/mod.rs index 7874c61..ab15cad 100644 --- a/backend/src/application/queries/mod.rs +++ b/backend/src/application/queries/mod.rs @@ -3,5 +3,6 @@ pub mod get_all_projects; pub mod get_login_nonce; pub mod get_profile; pub mod get_projects_by_creator; +pub mod list_distributions; pub mod get_project; diff --git a/backend/src/domain/repositories/distribution_repository.rs b/backend/src/domain/repositories/distribution_repository.rs index bdb167c..282584c 100644 --- a/backend/src/domain/repositories/distribution_repository.rs +++ b/backend/src/domain/repositories/distribution_repository.rs @@ -13,4 +13,9 @@ pub trait DistributionRepository: Send + Sync { &self, records: &[DistributionRecord], ) -> Result<(), Box>; + + async fn list( + &self, + distribution_id: Option<&str>, + ) -> Result, Box>; } diff --git a/backend/src/infrastructure/repositories/postgres_distribution_repository.rs b/backend/src/infrastructure/repositories/postgres_distribution_repository.rs index 364edb1..2cedb76 100644 --- a/backend/src/infrastructure/repositories/postgres_distribution_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_distribution_repository.rs @@ -41,4 +41,44 @@ impl DistributionRepository for PostgresDistributionRepository { tx.commit().await?; Ok(()) } + + async fn list( + &self, + distribution_id: Option<&str>, + ) -> Result, Box> { + let rows: Vec<(String, String, String)> = if let Some(id) = distribution_id { + sqlx::query_as( + r#" + SELECT address, badge_name, distribution_id + FROM distributions + WHERE distribution_id = $1 + ORDER BY address ASC + "#, + ) + .bind(id) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query_as( + r#" + SELECT address, badge_name, distribution_id + FROM distributions + ORDER BY distribution_id DESC, address ASC + "#, + ) + .fetch_all(&self.pool) + .await? + }; + + Ok(rows + .into_iter() + .map( + |(address, badge_name, distribution_id)| DistributionRecord { + address, + badge_name, + distribution_id, + }, + ) + .collect()) + } } diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index d88e4b6..afc3942 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -43,6 +43,7 @@ use super::handlers::{ get_user_projects_handler, // GitHub sync handler github_sync_handler, + list_distributions_handler, list_github_issues_handler, list_projects_handler, login_handler, @@ -82,7 +83,6 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { .route("/projects", post(create_project_handler)) .route("/projects/:id", patch(update_project_handler)) .route("/projects/:id", delete(delete_project_handler)) - .route("/distributions", post(register_distribution_handler)) .with_state(state.clone()); let protected_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -98,6 +98,8 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { delete(admin_delete_profile_handler), ) .route("/admin/github/sync", post(github_sync_handler)) + .route("/admin/distributions", post(register_distribution_handler)) + .route("/admin/distributions", get(list_distributions_handler)) .with_state(state.clone()); let admin_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -167,7 +169,6 @@ pub fn test_api(state: AppState) -> Router { .route("/projects", post(create_project_handler)) .route("/projects/:id", patch(update_project_handler)) .route("/projects/:id", delete(delete_project_handler)) - .route("/distributions", post(register_distribution_handler)) .with_state(state.clone()) .layer(from_fn(test_auth_layer)); @@ -178,6 +179,8 @@ pub fn test_api(state: AppState) -> Router { delete(admin_delete_profile_handler), ) .route("/admin/github/sync", post(github_sync_handler)) + .route("/admin/distributions", post(register_distribution_handler)) + .route("/admin/distributions", get(list_distributions_handler)) .with_state(state.clone()) .layer(from_fn(test_auth_layer)); diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 8632158..a39b102 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -39,6 +39,7 @@ use crate::application::{ use crate::application::{ commands::register_distribution::register_distribution, dtos::distribution_dtos::RegisterDistributionRequest, + queries::list_distributions::list_distributions, }; // GitHub sync imports @@ -58,6 +59,12 @@ pub struct ListProjectsQuery { pub offset: Option, } +#[derive(Debug, Deserialize)] +pub struct ListDistributionsQuery { + #[serde(rename = "distributionId")] + pub distribution_id: Option, +} + pub async fn create_profile_handler( State(state): State, Extension(VerifiedWallet(wallet)): Extension, @@ -382,7 +389,7 @@ pub async fn list_github_issues_handler( } } -/// POST /distributions - Register distributions in batch (Protected) +/// POST /admin/distributions - Register distributions in batch (Admin only) pub async fn register_distribution_handler( State(state): State, Json(request): Json, @@ -396,3 +403,23 @@ pub async fn register_distribution_handler( .into_response(), } } + +/// GET /admin/distributions?distributionId= - List distributions (Admin only) +pub async fn list_distributions_handler( + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + match list_distributions( + state.distribution_repository.clone(), + params.distribution_id, + ) + .await + { + Ok(distributions) => (StatusCode::OK, Json(distributions)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e})), + ) + .into_response(), + } +} diff --git a/backend/tests/distribution_tests.rs b/backend/tests/distribution_tests.rs index 3cc9bef..6fd8711 100644 --- a/backend/tests/distribution_tests.rs +++ b/backend/tests/distribution_tests.rs @@ -64,7 +64,7 @@ async fn register_distribution_list_succeeds() { .unwrap(); let response = client - .post(format!("{}/distributions", base)) + .post(format!("{}/admin/distributions", base)) .header( "x-eth-address", "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", @@ -89,13 +89,20 @@ async fn register_distribution_list_succeeds() { assert_eq!(response.status(), reqwest::StatusCode::CREATED); - let inserted = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM distributions WHERE distribution_id = $1", - ) - .bind("dist-test-001") - .fetch_one(&pool) - .await - .unwrap(); + let list_resp = client + .get(format!( + "{}/admin/distributions?distributionId=dist-test-001", + base + )) + .header( + "x-eth-address", + "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + ) + .send() + .await + .unwrap(); - assert_eq!(inserted, 2); + assert_eq!(list_resp.status(), reqwest::StatusCode::OK); + let listed: serde_json::Value = list_resp.json().await.unwrap(); + assert_eq!(listed.as_array().unwrap().len(), 2); } diff --git a/scripts/test_admin_distributions.sh b/scripts/test_admin_distributions.sh new file mode 100755 index 0000000..77da330 --- /dev/null +++ b/scripts/test_admin_distributions.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test admin distribution endpoints: +# - POST /admin/distributions +# - GET /admin/distributions?distributionId=... +# +# Sequence: +# 1) get nonce +# 2) generate signature +# 3) post endpoint +# 4) list endpoint +# +# Requirements: curl, node, npm. Installs ethers locally into /tmp by default. +# +# Inputs (env): +# ADMIN_ADDRESS (required) - admin wallet address +# ADMIN_PRIVATE_KEY (required) - admin wallet private key (0x-prefixed) +# API_URL (optional) - defaults to http://localhost:3001 +# DISTRIBUTION_ID (optional) - defaults to dist- + +API_URL="${API_URL:-http://localhost:3001}" +ADMIN_ADDRESS="${ADMIN_ADDRESS:-}" +ADMIN_PRIVATE_KEY="${ADMIN_PRIVATE_KEY:-}" +DISTRIBUTION_ID="${DISTRIBUTION_ID:-dist-$(date +%s)}" + +if [[ -z "${ADMIN_ADDRESS}" ]]; then + read -r -p "Enter ADMIN_ADDRESS (0x...): " ADMIN_ADDRESS +fi +if [[ -z "${ADMIN_PRIVATE_KEY}" ]]; then + read -r -s -p "Enter ADMIN_PRIVATE_KEY (0x..., hidden): " ADMIN_PRIVATE_KEY + echo +fi + +if [[ -z "${ADMIN_ADDRESS}" || -z "${ADMIN_PRIVATE_KEY}" ]]; then + echo "ADMIN_ADDRESS and ADMIN_PRIVATE_KEY are required. Aborting." + exit 1 +fi + +TOOLS_DIR="${TOOLS_DIR:-/tmp/theguildgenesis-login}" +export NODE_PATH="${TOOLS_DIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" +export PATH="${TOOLS_DIR}/node_modules/.bin:${PATH}" +if ! node -e "require('ethers')" >/dev/null 2>&1; then + echo "Installing ethers@6 to ${TOOLS_DIR}..." + mkdir -p "${TOOLS_DIR}" + npm install --prefix "${TOOLS_DIR}" ethers@6 >/dev/null +fi + +echo "Fetching nonce for admin ${ADMIN_ADDRESS}..." +nonce_resp="$(curl -sS "${API_URL}/auth/nonce/${ADMIN_ADDRESS}")" +echo "Nonce response: ${nonce_resp}" + +nonce="$(RESP="${nonce_resp}" python3 - <<'PY' +import json, os +data = json.loads(os.environ["RESP"]) +print(data["nonce"]) +PY +)" + +if [[ -z "${nonce}" ]]; then + echo "Failed to parse nonce from response" + exit 1 +fi + +message=$'Sign this message to authenticate with The Guild.\n\nNonce: '"${nonce}" + +echo "Signing nonce..." +signature="$( + ADDRESS="${ADMIN_ADDRESS}" PRIVATE_KEY="${ADMIN_PRIVATE_KEY}" MESSAGE="${message}" \ + node - <<'NODE' +const { Wallet } = require('ethers'); + +const address = process.env.ADDRESS; +const pk = process.env.PRIVATE_KEY; +const message = process.env.MESSAGE; + +if (!address || !pk || !message) { + console.error("Missing ADDRESS, PRIVATE_KEY or MESSAGE"); + process.exit(1); +} + +const wallet = new Wallet(pk); +if (wallet.address.toLowerCase() !== address.toLowerCase()) { + console.error(`Private key does not match address. Wallet: ${wallet.address}, Provided: ${address}`); + process.exit(1); +} + +(async () => { + const sig = await wallet.signMessage(message); + console.log(sig); +})(); +NODE +)" + +echo "Signature: ${signature}" + +payload=$(cat <