diff --git a/backend/.sqlx/query-5b1288837b287c27c40cf4665b33fe43303e6b9cb0724ef339712e48890555ca.json b/backend/.sqlx/query-5b1288837b287c27c40cf4665b33fe43303e6b9cb0724ef339712e48890555ca.json new file mode 100644 index 0000000..387879e --- /dev/null +++ b/backend/.sqlx/query-5b1288837b287c27c40cf4665b33fe43303e6b9cb0724ef339712e48890555ca.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, distribution_id, address, badge_name, metadata, created_at\n FROM distributions\n WHERE address = $1\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "distribution_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "badge_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false + ] + }, + "hash": "5b1288837b287c27c40cf4665b33fe43303e6b9cb0724ef339712e48890555ca" +} diff --git a/backend/.sqlx/query-ad124efc283897c7caab312f8e6a76124c869d3e3186e51d29354931147aea76.json b/backend/.sqlx/query-ad124efc283897c7caab312f8e6a76124c869d3e3186e51d29354931147aea76.json new file mode 100644 index 0000000..5212145 --- /dev/null +++ b/backend/.sqlx/query-ad124efc283897c7caab312f8e6a76124c869d3e3186e51d29354931147aea76.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, distribution_id, address, badge_name, metadata, created_at\n FROM distributions\n WHERE distribution_id = $1\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "distribution_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "badge_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false + ] + }, + "hash": "ad124efc283897c7caab312f8e6a76124c869d3e3186e51d29354931147aea76" +} diff --git a/backend/.sqlx/query-da01519a1037e306529e5cea167551fa0204647387c52712367988d78598398b.json b/backend/.sqlx/query-da01519a1037e306529e5cea167551fa0204647387c52712367988d78598398b.json new file mode 100644 index 0000000..8edc8aa --- /dev/null +++ b/backend/.sqlx/query-da01519a1037e306529e5cea167551fa0204647387c52712367988d78598398b.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO distributions (id, distribution_id, address, badge_name, metadata, created_at)\n VALUES ($1, $2, $3, $4, $5::jsonb, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Jsonb", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "da01519a1037e306529e5cea167551fa0204647387c52712367988d78598398b" +} diff --git a/backend/migrations/004_add_distributions.sql b/backend/migrations/004_add_distributions.sql new file mode 100644 index 0000000..3dbb1ec --- /dev/null +++ b/backend/migrations/004_add_distributions.sql @@ -0,0 +1,15 @@ +-- Adds a distributions table which stores each distributed badge item. +-- Each row is one delivered badge and belongs to a batch (distribution_id). + +CREATE TABLE IF NOT EXISTS distributions ( + id UUID PRIMARY KEY, + distribution_id UUID NOT NULL, + address TEXT NOT NULL, + badge_name TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_distributions_distribution_id ON distributions(distribution_id); + +CREATE INDEX IF NOT EXISTS idx_distributions_address ON distributions(address); diff --git a/backend/src/application/commands/create_distribution.rs b/backend/src/application/commands/create_distribution.rs new file mode 100644 index 0000000..f2d5c30 --- /dev/null +++ b/backend/src/application/commands/create_distribution.rs @@ -0,0 +1,37 @@ +// src/application/commands/create_distribution.rs +use uuid::Uuid; +use anyhow::Result; +use crate::domain::repositories::distribution_repository::DistributionRepository; +use crate::domain::entities::distribution::Distribution; +use crate::domain::value_objects::WalletAddress; +use serde_json::Value; + +pub struct CreateDistribution<'a> { + pub repo: &'a dyn DistributionRepository, +} + +impl<'a> CreateDistribution<'a> { + pub fn new(repo: &'a dyn DistributionRepository) -> Self { + Self { repo } + } + + pub async fn execute( + &self, + items: Vec<(String, String, Option)>, + _batch_metadata: Option, + ) -> Result { + let distribution_id = Uuid::new_v4(); + let mut domain_items = Vec::with_capacity(items.len()); + + for (address_str, badge_name, metadata) in items { + let wallet_address = WalletAddress::new(address_str) + .map_err(|e| anyhow::anyhow!(e))?; + let item = Distribution::new(distribution_id, wallet_address, badge_name, metadata); + domain_items.push(item); + } + + self.repo.insert_batch(distribution_id, domain_items).await?; + + Ok(distribution_id) + } +} \ No newline at end of file diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index d5e1704..d5e14a4 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -1,2 +1,3 @@ pub mod create_profile; pub mod update_profile; +pub mod create_distribution; diff --git a/backend/src/application/dtos/distribution_dtos.rs b/backend/src/application/dtos/distribution_dtos.rs new file mode 100644 index 0000000..f247552 --- /dev/null +++ b/backend/src/application/dtos/distribution_dtos.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateDistributionRequest { + pub items: Vec, + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateDistributionDto { + pub address: String, + pub badge_name: String, + pub metadata: Option, +} + +#[derive(Debug, Serialize)] +pub struct DistributionResponse { + pub id: Uuid, + pub distribution_id: Uuid, + pub address: String, + pub badge_name: String, + pub metadata: serde_json::Value, + pub created_at: chrono::NaiveDateTime, +} \ No newline at end of file diff --git a/backend/src/application/dtos/mod.rs b/backend/src/application/dtos/mod.rs index 6eb6b75..c66346e 100644 --- a/backend/src/application/dtos/mod.rs +++ b/backend/src/application/dtos/mod.rs @@ -1,5 +1,7 @@ pub mod auth_dtos; pub mod profile_dtos; +pub mod distribution_dtos; pub use auth_dtos::*; pub use profile_dtos::*; +pub use distribution_dtos::*; diff --git a/backend/src/domain/entities/distribution.rs b/backend/src/domain/entities/distribution.rs new file mode 100644 index 0000000..4fd76cf --- /dev/null +++ b/backend/src/domain/entities/distribution.rs @@ -0,0 +1,28 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use serde_json::Value; +use crate::domain::value_objects::WalletAddress; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Distribution { + pub id: Uuid, + pub distribution_id: Uuid, + pub address: WalletAddress, + pub badge_name: String, + pub metadata: Option, + pub created_at: DateTime, +} + +impl Distribution { + pub fn new(distribution_id: Uuid, address: WalletAddress, badge_name: String, metadata: Option) -> Self { + Self { + id: Uuid::new_v4(), + distribution_id, + address, + badge_name, + metadata, + created_at: Utc::now(), + } + } +} \ No newline at end of file diff --git a/backend/src/domain/entities/mod.rs b/backend/src/domain/entities/mod.rs index 1c6f56c..deb4a03 100644 --- a/backend/src/domain/entities/mod.rs +++ b/backend/src/domain/entities/mod.rs @@ -1,3 +1,5 @@ pub mod profile; +pub mod distribution; pub use profile::Profile; +pub use distribution::Distribution; diff --git a/backend/src/domain/repositories/distribution_repository.rs b/backend/src/domain/repositories/distribution_repository.rs new file mode 100644 index 0000000..f65bf27 --- /dev/null +++ b/backend/src/domain/repositories/distribution_repository.rs @@ -0,0 +1,13 @@ +use async_trait::async_trait; +use anyhow::Result; +use uuid::Uuid; +use crate::domain::entities::distribution::Distribution; + +#[async_trait] +pub trait DistributionRepository: Send + Sync { + async fn insert_batch(&self, distribution_id: Uuid, items: Vec) -> Result<()>; + + async fn get_by_distribution_id(&self, distribution_id: Uuid) -> Result>; + + async fn get_by_address(&self, address: &str) -> Result>; +} \ No newline at end of file diff --git a/backend/src/domain/repositories/mod.rs b/backend/src/domain/repositories/mod.rs index b330a49..6fde8ac 100644 --- a/backend/src/domain/repositories/mod.rs +++ b/backend/src/domain/repositories/mod.rs @@ -1,3 +1,5 @@ pub mod profile_repository; +pub mod distribution_repository; pub use profile_repository::ProfileRepository; +pub use distribution_repository::DistributionRepository; diff --git a/backend/src/infrastructure/repositories/mod.rs b/backend/src/infrastructure/repositories/mod.rs index 1955904..fb96f19 100644 --- a/backend/src/infrastructure/repositories/mod.rs +++ b/backend/src/infrastructure/repositories/mod.rs @@ -1,3 +1,5 @@ pub mod postgres_profile_repository; +pub mod postgres_distribution_repository; pub use postgres_profile_repository::PostgresProfileRepository; +pub use postgres_distribution_repository::PostgresDistributionRepository; 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..fdf413f --- /dev/null +++ b/backend/src/infrastructure/repositories/postgres_distribution_repository.rs @@ -0,0 +1,108 @@ +use crate::domain::entities::distribution::Distribution; +use crate::domain::repositories::distribution_repository::DistributionRepository; +use async_trait::async_trait; +use anyhow::Result; +use sqlx::PgPool; +use uuid::Uuid; +use crate::domain::value_objects::WalletAddress; + +pub struct PostgresDistributionRepository { + pool: PgPool, +} + +impl PostgresDistributionRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl DistributionRepository for PostgresDistributionRepository { + async fn insert_batch( + &self, + distribution_id: Uuid, + items: Vec, + ) -> Result<(), anyhow::Error> { + for item in items { + let metadata_json = item.metadata.clone(); + + sqlx::query!( + r#" + INSERT INTO distributions (id, distribution_id, address, badge_name, metadata, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6) + "#, + item.id, + distribution_id, + item.address.as_str(), + item.badge_name, + metadata_json, + item.created_at + ) + .execute(&self.pool) + .await + .map_err(|e| anyhow::Error::new(e))?; + } + + Ok(()) + } + + async fn get_by_distribution_id(&self, distribution_id: Uuid) -> Result> { + let rows = sqlx::query!( + r#" + SELECT id, distribution_id, address, badge_name, metadata, created_at + FROM distributions + WHERE distribution_id = $1 + ORDER BY created_at ASC + "#, + distribution_id + ) + .fetch_all(&self.pool) + .await?; + + let items = rows + .into_iter() + .map(|r| { + Distribution { + id: r.id, + distribution_id: r.distribution_id, + address: WalletAddress::new(r.address).expect("Invalid wallet address"), + badge_name: r.badge_name, + metadata: r.metadata, + created_at: r.created_at.expect("created_at should not be null"), + } + }) + .collect(); + + Ok(items) + } + + async fn get_by_address(&self, address: &str) -> Result> { + let rows = sqlx::query!( + r#" + SELECT id, distribution_id, address, badge_name, metadata, created_at + FROM distributions + WHERE address = $1 + ORDER BY created_at DESC + "#, + address + ) + .fetch_all(&self.pool) + .await?; + + let items = rows + .into_iter() + .map(|r| { + Distribution { + id: r.id, + distribution_id: r.distribution_id, + address: WalletAddress::new(r.address).expect("Invalid wallet address"), + badge_name: r.badge_name, + metadata: r.metadata, + created_at: r.created_at.expect("created_at should not be null"), + } + }) + .collect(); + + Ok(items) + } +} \ No newline at end of file diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index 587a993..760b649 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -1,11 +1,13 @@ use std::sync::Arc; use crate::domain::repositories::ProfileRepository; +use crate::domain::repositories::distribution_repository::DistributionRepository; use crate::domain::services::auth_service::AuthService; use crate::infrastructure::{ repositories::PostgresProfileRepository, services::ethereum_address_verification_service::EthereumAddressVerificationService, }; +use crate::infrastructure::repositories::postgres_distribution_repository::PostgresDistributionRepository; use axum::middleware::{from_fn, from_fn_with_state}; use axum::{ extract::DefaultBodyLimit, @@ -21,18 +23,24 @@ use tower_http::{ use super::handlers::{ create_profile_handler, delete_profile_handler, get_all_profiles_handler, get_nonce_handler, - get_profile_handler, update_profile_handler, + get_profile_handler, update_profile_handler, get_distribution_by_id_handler, get_distributions_by_address_handler, }; +use super::handlers::{ create_distribution_handler }; + use super::middlewares::{eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { + let pool_distribution = pool.clone(); let profile_repository = Arc::from(PostgresProfileRepository::new(pool)); let auth_service = EthereumAddressVerificationService::new(profile_repository.clone()); + let distribution_repository: Arc = + Arc::new(PostgresDistributionRepository::new(pool_distribution)); let state: AppState = AppState { profile_repository, auth_service: Arc::from(auth_service), + distribution_repository, }; let protected_routes = Router::new() @@ -40,6 +48,9 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { .route("/profiles/", post(create_profile_handler)) .route("/profiles/:address", put(update_profile_handler)) .route("/profiles/:address", delete(delete_profile_handler)) + .route("/distributions", post(create_distribution_handler)) + .route("/distributions/:id", get(get_distribution_by_id_handler)) + .route("/distributions/address/:address", get(get_distributions_by_address_handler)) .with_state(state.clone()); let protected_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -75,6 +86,7 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { pub struct AppState { pub profile_repository: Arc, pub auth_service: Arc, + pub distribution_repository: Arc, } pub fn test_api(state: AppState) -> Router { @@ -82,6 +94,7 @@ pub fn test_api(state: AppState) -> Router { .route("/profiles", post(create_profile_handler)) .route("/profiles/:address", put(update_profile_handler)) .route("/profiles/:address", delete(delete_profile_handler)) + .route("/distributions", post(create_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 79ebd72..d1e4820 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -5,10 +5,13 @@ use axum::{ Extension, Json, }; +use uuid::Uuid; use crate::{ application::{ commands::{create_profile::create_profile, update_profile::update_profile}, + commands::{create_distribution::CreateDistribution}, dtos::{CreateProfileRequest, NonceResponse, ProfileResponse, UpdateProfileRequest}, + dtos::{CreateDistributionRequest, DistributionResponse}, queries::{ get_all_profiles::get_all_profiles, get_login_nonce::get_login_nonce, get_profile::get_profile, @@ -19,6 +22,7 @@ use crate::{ use super::{api::AppState, middlewares::VerifiedWallet}; + pub async fn create_profile_handler( State(state): State, Extension(VerifiedWallet(wallet)): Extension, @@ -87,3 +91,100 @@ pub async fn get_nonce_handler( Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))).into_response(), } } + +pub async fn create_distribution_handler( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + + let items_for_cmd = payload + .items + .into_iter() + .map(|it| (it.address, it.badge_name, it.metadata)) + .collect::>(); + + let distribution_repo = state.distribution_repository.clone(); + let cmd = CreateDistribution::new(distribution_repo.as_ref()); + + match cmd.execute(items_for_cmd, payload.metadata).await { + Ok(distribution_id) => { + let items = distribution_repo.get_by_distribution_id(distribution_id).await.unwrap(); + + let body = items.into_iter().map(|i| DistributionResponse { + id: i.id, + distribution_id: i.distribution_id, + address: i.address.as_str().to_string(), + badge_name: i.badge_name, + metadata: i.metadata.expect("Metadata missing"), + created_at: i.created_at.naive_utc(), + }).collect::>(); + + (StatusCode::CREATED, Json(body)).into_response() + } + Err(e) => { + tracing::error!("create_distribution failed: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Failed to create distribution"}))).into_response() + } + } +} + + +pub async fn get_distribution_by_id_handler( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let repo = state.distribution_repository.clone(); + + match repo.get_by_distribution_id(id).await { + Ok(items) => { + let response = items + .into_iter() + .map(|i| DistributionResponse { + id: i.id, + distribution_id: i.distribution_id, + address: i.address.as_str().to_string(), + badge_name: i.badge_name, + metadata: i.metadata.expect("Metadata missing"), + created_at: i.created_at.naive_utc(), + }) + .collect::>(); + + Json(response).into_response() + } + Err(err) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": err.to_string() })), + ) + .into_response(), + } +} + +pub async fn get_distributions_by_address_handler( + State(state): State, + Path(address): Path, +) -> impl IntoResponse { + let repo = state.distribution_repository.clone(); + + match repo.get_by_address(&address).await { + Ok(items) => { + let response = items + .into_iter() + .map(|i| DistributionResponse { + id: i.id, + distribution_id: i.distribution_id, + address: i.address.as_str().to_string(), + badge_name: i.badge_name, + metadata: i.metadata.expect("Metadata missing"), + created_at: i.created_at.naive_utc(), + }) + .collect::>(); + + Json(response).into_response() + } + Err(err) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": err.to_string() })), + ) + .into_response(), + } +} \ No newline at end of file diff --git a/backend/tests/distribution_tests.rs b/backend/tests/distribution_tests.rs new file mode 100644 index 0000000..a0cb8ec --- /dev/null +++ b/backend/tests/distribution_tests.rs @@ -0,0 +1,34 @@ +use uuid::Uuid; +use serde_json::json; +use crate::helpers::{spawn_app, get_pg_pool}; // adapt to your test helpers + +#[tokio::test] +async fn create_distribution_inserts_rows() { + // spawn_app should start server + return test client and DB pool + let app = spawn_app().await; // adapt to your test harness + let pool = &app.pg_pool; + + let payload = json!({ + "items": [ + { "address": "0x1111111111111111111111111111111111111111", "badge_name": "contributor", "metadata": { "reason": "helped" } }, + { "address": "0x2222222222222222222222222222222222222222", "badge_name": "builder", "metadata": null } + ], + "metadata": { "campaign": "nov-2025" } + }); + + let response = app.post("/distributions").json(&payload).send().await.unwrap(); + assert_eq!(response.status(), 201); + + let body: serde_json::Value = response.json().await.unwrap(); + let distribution_id = body["distribution_id"].as_str().unwrap(); + let uuid = Uuid::parse_str(distribution_id).unwrap(); + + // Query DB directly + let rows = sqlx::query!("SELECT count(*) FROM distributions WHERE distribution_id = $1", uuid) + .fetch_one(pool) + .await + .unwrap(); + + let count: i64 = rows.count.unwrap_or(0); + assert_eq!(count, 2); +}