Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ADMIN_ADDRESS>' \
-H 'x-eth-signature: <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: <ADMIN_ADDRESS>' \
-H 'x-eth-signature: <SIGNATURE>' \
'http://0.0.0.0:3001/admin/distributions'

# Filter by distributionId
curl \
-H 'x-eth-address: <ADMIN_ADDRESS>' \
-H 'x-eth-signature: <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
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions backend/migrations/008_create_distributions_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS distributions (
address TEXT NOT NULL,
badge_name TEXT NOT NULL,
distribution_id TEXT NOT NULL
);
1 change: 1 addition & 0 deletions backend/src/application/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
30 changes: 30 additions & 0 deletions backend/src/application/commands/register_distribution.rs
Original file line number Diff line number Diff line change
@@ -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<dyn DistributionRepository>,
request: RegisterDistributionRequest,
) -> Result<(), String> {
if request.distributions.is_empty() {
return Err("distributions must not be empty".to_string());
}

let records: Vec<DistributionRecord> = 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())
}
24 changes: 24 additions & 0 deletions backend/src/application/dtos/distribution_dtos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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<RegisterDistributionItem>,
}

#[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,
}
1 change: 1 addition & 0 deletions backend/src/application/dtos/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod auth_dtos;
pub mod distribution_dtos;
pub mod github_dtos;
pub mod profile_dtos;
pub mod project_dtos;
Expand Down
26 changes: 26 additions & 0 deletions backend/src/application/queries/list_distributions.rs
Original file line number Diff line number Diff line change
@@ -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<dyn DistributionRepository>,
distribution_id: Option<String>,
) -> Result<Vec<DistributionResponse>, 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())
}
1 change: 1 addition & 0 deletions backend/src/application/queries/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
21 changes: 21 additions & 0 deletions backend/src/domain/repositories/distribution_repository.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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<dyn std::error::Error>>;

async fn list(
&self,
distribution_id: Option<&str>,
) -> Result<Vec<DistributionRecord>, Box<dyn std::error::Error>>;
}
2 changes: 2 additions & 0 deletions backend/src/domain/repositories/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions backend/src/infrastructure/repositories/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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<dyn std::error::Error>> {
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(())
}

async fn list(
&self,
distribution_id: Option<&str>,
) -> Result<Vec<DistributionRecord>, Box<dyn std::error::Error>> {
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())
}
}
14 changes: 13 additions & 1 deletion backend/src/presentation/api.rs
Original file line number Diff line number Diff line change
@@ -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,
},
Expand Down Expand Up @@ -40,9 +43,11 @@ 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,
register_distribution_handler,
update_profile_handler,
update_project_handler,
};
Expand All @@ -52,13 +57,15 @@ 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<dyn GithubService> = Arc::from(RestGithubService::new());

let state: AppState = AppState {
profile_repository,
project_repository,
distribution_repository,
auth_service: Arc::from(auth_service),
github_issue_repository,
github_service,
Expand Down Expand Up @@ -91,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() {
Expand Down Expand Up @@ -142,6 +151,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router {
pub struct AppState {
pub profile_repository: Arc<dyn ProfileRepository>,
pub project_repository: Arc<dyn ProjectRepository>,
pub distribution_repository: Arc<dyn DistributionRepository>,
pub auth_service: Arc<dyn AuthService>,
pub github_issue_repository: Arc<dyn GithubIssueRepository>,
pub github_service: Arc<dyn GithubService>,
Expand Down Expand Up @@ -169,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));

Expand Down
Loading
Loading