From 9e3cd46b3bde8b6e11b713c9653da0f55bcfdd13 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Mon, 8 Dec 2025 17:36:21 +0100 Subject: [PATCH 01/11] add projects to the backend --- .../application/commands/create_project.rs | 51 ++++ .../application/commands/delete_project.rs | 44 +++ backend/src/application/commands/mod.rs | 3 + .../application/commands/update_project.rs | 61 ++++ backend/src/application/dtos/project_dtos.rs | 32 +++ backend/src/application/projects/services.rs | 144 ++++++++++ .../application/queries/get_all_projects.rs | 56 ++++ .../src/application/queries/get_project.rs | 35 +++ .../queries/get_projects_by_creator.rs | 35 +++ backend/src/application/queries/mod.rs | 4 + backend/src/domain/entities/mod.rs | 2 + backend/src/domain/entities/projects.rs | 144 ++++++++++ backend/src/domain/repositories/mod.rs | 2 + .../domain/repositories/project_repository.rs | 48 ++++ .../src/infrastructure/repositories/mod.rs | 2 + .../postgres_project_repository.rs | 268 ++++++++++++++++++ backend/src/presentation/handlers.rs | 130 ++++++++- frontend/package-lock.json | 56 +--- 18 files changed, 1071 insertions(+), 46 deletions(-) create mode 100644 backend/src/application/commands/create_project.rs create mode 100644 backend/src/application/commands/delete_project.rs create mode 100644 backend/src/application/commands/update_project.rs create mode 100644 backend/src/application/dtos/project_dtos.rs create mode 100644 backend/src/application/projects/services.rs create mode 100644 backend/src/application/queries/get_all_projects.rs create mode 100644 backend/src/application/queries/get_project.rs create mode 100644 backend/src/application/queries/get_projects_by_creator.rs create mode 100644 backend/src/domain/entities/projects.rs create mode 100644 backend/src/domain/repositories/project_repository.rs create mode 100644 backend/src/infrastructure/repositories/postgres_project_repository.rs diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs new file mode 100644 index 0000000..6a672e2 --- /dev/null +++ b/backend/src/application/commands/create_project.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use crate::{ + application::projects::dtos::{CreateProjectRequest, ProjectResponse}, + domain::{ + entities::projects::Project, repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, + }, +}; + +pub async fn create_project( + repository: Arc, + creator_address: String, + request: CreateProjectRequest, +) -> Result { + // Validate and create WalletAddress + let creator = WalletAddress::new(creator_address) + .map_err(|e| format!("Invalid wallet address: {}", e))?; + + // Verify creator has a profile + if !repository + .profile_exists(&creator) + .await + .map_err(|e| e.to_string())? + { + return Err("Only addresses with profiles can create projects".to_string()); + } + + // Create project entity + let mut project = Project::new(request.name, request.description, request.status, creator); + + // Validate project + project.validate()?; + + // Save to repository + repository + .create(&project) + .await + .map_err(|e| e.to_string())?; + + // Return response + Ok(ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + creator: project.creator.to_string(), + created_at: project.created_at, + updated_at: project.updated_at, + }) +} diff --git a/backend/src/application/commands/delete_project.rs b/backend/src/application/commands/delete_project.rs new file mode 100644 index 0000000..b4fd9f7 --- /dev/null +++ b/backend/src/application/commands/delete_project.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + domain::{ + entities::projects::ProjectId, repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, + }, +}; + +pub async fn delete_project( + repository: Arc, + requester_address: String, + project_id: String, +) -> Result<(), String> { + // Parse project ID + let id = Uuid::parse_str(&project_id) + .map_err(|_| "Invalid project ID".to_string())?; + let project_id = ProjectId::from_uuid(id); + + // Validate requester address + let requester = WalletAddress::new(requester_address) + .map_err(|e| format!("Invalid wallet address: {}", e))?; + + // Get existing project + let project = repository + .find_by_id(&project_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Project not found".to_string())?; + + // Verify requester is the creator + if requester != project.creator { + return Err("Only the creator can delete this project".to_string()); + } + + // Delete from repository + repository + .delete(&project_id) + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index 2da8f3f..6bd514a 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -1,3 +1,6 @@ pub mod create_profile; pub mod login; pub mod update_profile; +pub mod create_project; +pub mod update_project; +pub mod delete_project; \ No newline at end of file diff --git a/backend/src/application/commands/update_project.rs b/backend/src/application/commands/update_project.rs new file mode 100644 index 0000000..697267f --- /dev/null +++ b/backend/src/application/commands/update_project.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + application::projects::dtos::{ProjectResponse, UpdateProjectRequest}, + domain::{ + entities::projects::ProjectId, repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, + }, +}; + +pub async fn update_project( + repository: Arc, + requester_address: String, + project_id: String, + request: UpdateProjectRequest, +) -> Result { + // Parse project ID + let id = Uuid::parse_str(&project_id) + .map_err(|_| "Invalid project ID".to_string())?; + let project_id = ProjectId::from_uuid(id); + + // Validate requester address + let requester = WalletAddress::new(requester_address) + .map_err(|e| format!("Invalid wallet address: {}", e))?; + + // Get existing project + let mut project = repository + .find_by_id(&project_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Project not found".to_string())?; + + // Verify requester is the creator + if requester != project.creator { + return Err("Only the creator can update this project".to_string()); + } + + // Update project + project.update_info(request.name, request.description, request.status); + + // Validate updated project + project.validate()?; + + // Save to repository + repository + .update(&project) + .await + .map_err(|e| e.to_string())?; + + // Return response + Ok(ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + creator: project.creator.to_string(), + created_at: project.created_at, + updated_at: project.updated_at, + }) +} \ No newline at end of file diff --git a/backend/src/application/dtos/project_dtos.rs b/backend/src/application/dtos/project_dtos.rs new file mode 100644 index 0000000..d8af93a --- /dev/null +++ b/backend/src/application/dtos/project_dtos.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::entities::projects::ProjectStatus; + +/// Request DTO for creating a project +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateProjectRequest { + pub name: String, + pub description: String, + pub status: ProjectStatus, +} + +/// Request DTO for updating a project +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateProjectRequest { + pub name: Option, + pub description: Option, + pub status: Option, +} + +/// Response DTO for a project +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectResponse { + pub id: String, + pub name: String, + pub description: String, + pub status: ProjectStatus, + pub creator: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/backend/src/application/projects/services.rs b/backend/src/application/projects/services.rs new file mode 100644 index 0000000..0826ca5 --- /dev/null +++ b/backend/src/application/projects/services.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; + +use crate::domain::{ + entities::projects::{Project, ProjectId, ProjectStatus}, + repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, +}; + +use super::dtos::{CreateProjectDto, ProjectDto, UpdateProjectDto}; + +/// Project service - handles business logic +pub struct ProjectService { + repository: Arc, +} + +impl ProjectService { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + /// Create a new project + pub async fn create_project( + &self, + dto: CreateProjectDto, + creator: WalletAddress, + ) -> Result> { + // Validate that creator has a profile + if !self.repository.profile_exists(&creator).await? { + return Err("Only addresses with profiles can create projects".into()); + } + + // Create project entity + let mut project = Project::new(dto.name, dto.description, dto.status, creator); + + // Validate project + project.validate()?; + + // Save to repository + self.repository.create(&project).await?; + + Ok(ProjectDto::from(project)) + } + + /// Get a project by ID + pub async fn get_project( + &self, + id: ProjectId, + ) -> Result, Box> { + let project = self.repository.find_by_id(&id).await?; + Ok(project.map(ProjectDto::from)) + } + + /// List all projects with optional filters + pub async fn list_projects( + &self, + status: Option, + creator: Option, + limit: Option, + offset: Option, + ) -> Result, Box> { + // Validate and limit pagination + let limit = limit.map(|l| l.max(1).min(100)); + let offset = offset.map(|o| o.max(0)); + + let projects = self + .repository + .find_all(status, creator.as_ref(), limit, offset) + .await?; + + Ok(projects.into_iter().map(ProjectDto::from).collect()) + } + + /// Get projects by creator + pub async fn get_projects_by_creator( + &self, + creator: WalletAddress, + ) -> Result, Box> { + let projects = self.repository.find_by_creator(&creator).await?; + Ok(projects.into_iter().map(ProjectDto::from).collect()) + } + + /// Update a project + pub async fn update_project( + &self, + id: ProjectId, + dto: UpdateProjectDto, + _requester: WalletAddress, // TODO: Verify requester is creator when auth is implemented + ) -> Result> { + // Check if project exists + let mut project = self + .repository + .find_by_id(&id) + .await? + .ok_or("Project not found")?; + + // TODO: Verify that requester is the creator + // if requester != project.creator { + // return Err("Only the creator can update this project".into()); + // } + + // Update project + project.update_info(dto.name, dto.description, dto.status); + + // Validate updated project + project.validate()?; + + // Save to repository + self.repository.update(&project).await?; + + Ok(ProjectDto::from(project)) + } + + /// Delete a project + pub async fn delete_project( + &self, + id: ProjectId, + _requester: WalletAddress, // TODO: Verify requester is creator when auth is implemented + ) -> Result<(), Box> { + // Check if project exists + let project = self + .repository + .find_by_id(&id) + .await? + .ok_or("Project not found")?; + + // TODO: Verify that requester is the creator + // if requester != project.creator { + // return Err("Only the creator can delete this project".into()); + // } + + // Delete from repository + self.repository.delete(&project.id).await?; + + Ok(()) + } + + /// Check if a project exists + pub async fn project_exists( + &self, + id: ProjectId, + ) -> Result> { + self.repository.exists(&id).await + } +} \ No newline at end of file diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs new file mode 100644 index 0000000..892f388 --- /dev/null +++ b/backend/src/application/queries/get_all_projects.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use crate::{ + application::projects::dtos::ProjectResponse, + domain::{ + entities::projects::ProjectStatus, repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, + }, +}; + +pub async fn get_all_projects( + repository: Arc, + status: Option, + creator: Option, + limit: Option, + offset: Option, +) -> Result, String> { + + let status_filter = if let Some(status_str) = status { + Some( + status_str + .parse::() + .map_err(|e| format!("Invalid status: {}", e))?, + ) + } else { + None + }; + + + let creator_filter = if let Some(creator_str) = creator { + Some(WalletAddress::new(creator_str).map_err(|e| format!("Invalid creator address: {}", e))?) + } else { + None + }; + + let limit = limit.map(|l| l.max(1).min(100)); + let offset = offset.map(|o| o.max(0)); + + let projects = repository + .find_all(status_filter, creator_filter.as_ref(), limit, offset) + .await + .map_err(|e| e.to_string())?; + + Ok(projects + .into_iter() + .map(|project| ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + creator: project.creator.to_string(), + created_at: project.created_at, + updated_at: project.updated_at, + }) + .collect()) +} \ No newline at end of file diff --git a/backend/src/application/queries/get_project.rs b/backend/src/application/queries/get_project.rs new file mode 100644 index 0000000..7b2cf45 --- /dev/null +++ b/backend/src/application/queries/get_project.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + application::projects::dtos::ProjectResponse, + domain::{entities::projects::ProjectId, repositories::project_repository::ProjectRepository}, +}; + +pub async fn get_project( + repository: Arc, + project_id: String, +) -> Result { + // Parse project ID + let id = Uuid::parse_str(&project_id) + .map_err(|_| "Invalid project ID".to_string())?; + let project_id = ProjectId::from_uuid(id); + + + let project = repository + .find_by_id(&project_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Project not found".to_string())?; + + // Return response + Ok(ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + creator: project.creator.to_string(), + created_at: project.created_at, + updated_at: project.updated_at, + }) +} \ No newline at end of file diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs new file mode 100644 index 0000000..1e6042a --- /dev/null +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use crate::{ + application::projects::dtos::ProjectResponse, + domain::{repositories::project_repository::ProjectRepository, value_objects::WalletAddress}, +}; + +pub async fn get_projects_by_creator( + repository: Arc, + creator_address: String, +) -> Result, String> { + // Validate creator address + let creator = WalletAddress::new(creator_address) + .map_err(|e| format!("Invalid wallet address: {}", e))?; + + // Get projects + let projects = repository + .find_by_creator(&creator) + .await + .map_err(|e| e.to_string())?; + + // Convert to responses + Ok(projects + .into_iter() + .map(|project| ProjectResponse { + id: project.id.value().to_string(), + name: project.name, + description: project.description, + status: project.status, + creator: project.creator.to_string(), + created_at: project.created_at, + updated_at: project.updated_at, + }) + .collect()) +} \ No newline at end of file diff --git a/backend/src/application/queries/mod.rs b/backend/src/application/queries/mod.rs index 298cba8..9a6c12e 100644 --- a/backend/src/application/queries/mod.rs +++ b/backend/src/application/queries/mod.rs @@ -1,3 +1,7 @@ pub mod get_all_profiles; pub mod get_login_nonce; pub mod get_profile; +pub mod get_projects_by_creator; +pub mod get_all_projects, +pub mod get_project; + diff --git a/backend/src/domain/entities/mod.rs b/backend/src/domain/entities/mod.rs index 1c6f56c..703c49d 100644 --- a/backend/src/domain/entities/mod.rs +++ b/backend/src/domain/entities/mod.rs @@ -1,3 +1,5 @@ pub mod profile; +pub mod projects; pub use profile::Profile; +pub use projects::{Project, ProjectId, ProjectStatus}; diff --git a/backend/src/domain/entities/projects.rs b/backend/src/domain/entities/projects.rs new file mode 100644 index 0000000..e6b20f3 --- /dev/null +++ b/backend/src/domain/entities/projects.rs @@ -0,0 +1,144 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::value_objects::WalletAddress; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ProjectId(pub Uuid); + +impl ProjectId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + pub fn value(&self) -> Uuid { + self.0 + } +} + +impl Default for ProjectId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for ProjectId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProjectStatus { + #[serde(rename = "proposal")] + Proposal, + #[serde(rename = "ongoing")] + Ongoing, + #[serde(rename = "rejected")] + Rejected, +} + +impl ProjectStatus { + pub fn as_str(&self) -> &'static str { + match self { + ProjectStatus::Proposal => "proposal", + ProjectStatus::Ongoing => "ongoing", + ProjectStatus::Rejected => "rejected", + } + } +} + +impl std::fmt::Display for ProjectStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for ProjectStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "proposal" => Ok(ProjectStatus::Proposal), + "ongoing" => Ok(ProjectStatus::Ongoing), + "rejected" => Ok(ProjectStatus::Rejected), + _ => Err(format!("Invalid project status: {}", s)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub id: ProjectId, + pub name: String, + pub description: String, + pub status: ProjectStatus, + pub creator: WalletAddress, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Project { + pub fn new( + name: String, + description: String, + status: ProjectStatus, + creator: WalletAddress, + ) -> Self { + let now = Utc::now(); + Self { + id: ProjectId::new(), + name, + description, + status, + creator, + created_at: now, + updated_at: now, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("Project name cannot be empty".to_string()); + } + + if self.name.len() > 255 { + return Err("Project name cannot exceed 255 characters".to_string()); + } + + if self.description.trim().is_empty() { + return Err("Project description cannot be empty".to_string()); + } + + Ok(()) + } + + pub fn update_info( + &mut self, + name: Option, + description: Option, + status: Option, + ) { + if let Some(n) = name { + self.name = n; + } + if let Some(d) = description { + self.description = d; + } + if let Some(s) = status { + self.status = s; + } + self.updated_at = Utc::now(); + } + + pub fn change_status(&mut self, new_status: ProjectStatus) { + self.status = new_status; + self.updated_at = Utc::now(); + } +} \ No newline at end of file diff --git a/backend/src/domain/repositories/mod.rs b/backend/src/domain/repositories/mod.rs index b330a49..fe48bd4 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 project_repository; pub use profile_repository::ProfileRepository; +pub use project_repository::ProjectRepository; diff --git a/backend/src/domain/repositories/project_repository.rs b/backend/src/domain/repositories/project_repository.rs new file mode 100644 index 0000000..96eefd7 --- /dev/null +++ b/backend/src/domain/repositories/project_repository.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; + +use crate::domain::{ + entities::projects::{Project, ProjectId, ProjectStatus}, + value_objects::WalletAddress, +}; + +#[async_trait] +pub trait ProjectRepository: Send + Sync { + /// Create a new project + async fn create(&self, project: &Project) -> Result<(), Box>; + + /// Find a project by ID + async fn find_by_id( + &self, + id: &ProjectId, + ) -> Result, Box>; + + /// Find all projects with optional filters + async fn find_all( + &self, + status: Option, + creator: Option<&WalletAddress>, + limit: Option, + offset: Option, + ) -> Result, Box>; + + /// Find projects by creator + async fn find_by_creator( + &self, + creator: &WalletAddress, + ) -> Result, Box>; + + /// Update a project + async fn update(&self, project: &Project) -> Result<(), Box>; + + /// Delete a project + async fn delete(&self, id: &ProjectId) -> Result<(), Box>; + + /// Check if a project exists + async fn exists(&self, id: &ProjectId) -> Result>; + + /// Check if a profile exists (for creator validation) + async fn profile_exists( + &self, + address: &WalletAddress, + ) -> Result>; +} diff --git a/backend/src/infrastructure/repositories/mod.rs b/backend/src/infrastructure/repositories/mod.rs index 1955904..ac8a7e3 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_project_repository; pub use postgres_profile_repository::PostgresProfileRepository; +pub use postgres_project_repository::PostgresProjectRepository; diff --git a/backend/src/infrastructure/repositories/postgres_project_repository.rs b/backend/src/infrastructure/repositories/postgres_project_repository.rs new file mode 100644 index 0000000..1638c68 --- /dev/null +++ b/backend/src/infrastructure/repositories/postgres_project_repository.rs @@ -0,0 +1,268 @@ +use async_trait::async_trait; +use sqlx::PgPool; + +use crate::domain::{ + entities::projects::{Project, ProjectId, ProjectStatus}, + repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, +}; + +#[derive(Clone)] +pub struct PostgresProjectRepository { + pool: PgPool, +} + +impl PostgresProjectRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ProjectRepository for PostgresProjectRepository { + async fn create(&self, project: &Project) -> Result<(), Box> { + sqlx::query!( + r#" + INSERT INTO projects (id, name, description, status, creator, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + project.id.value(), + project.name, + project.description, + project.status.as_str(), + project.creator.as_str(), + project.created_at, + project.updated_at + ) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(()) + } + + async fn find_by_id( + &self, + id: &ProjectId, + ) -> Result, Box> { + let row = sqlx::query!( + r#" + SELECT id, name, description, status, creator, created_at, updated_at + FROM projects + WHERE id = $1 + "#, + id.value() + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(row + .map(|r| { + let status = r.status.parse().unwrap_or(ProjectStatus::Proposal); + Project { + id: ProjectId::from_uuid(r.id), + name: r.name, + description: r.description, + status, + creator: WalletAddress(r.creator), + created_at: r.created_at.unwrap(), + updated_at: r.updated_at.unwrap(), + } + })) + } + + async fn find_all( + &self, + status: Option, + creator: Option<&WalletAddress>, + limit: Option, + offset: Option, + ) -> Result, Box> { + // Build query dynamically based on filters + let mut query = String::from( + "SELECT id, name, description, status, creator, created_at, updated_at FROM projects WHERE 1=1" + ); + + if status.is_some() { + query.push_str(" AND status = $1"); + } + + if creator.is_some() { + if status.is_some() { + query.push_str(" AND creator = $2"); + } else { + query.push_str(" AND creator = $1"); + } + } + + query.push_str(" ORDER BY created_at DESC"); + + if limit.is_some() { + let param_num = match (status.is_some(), creator.is_some()) { + (true, true) => 3, + (true, false) | (false, true) => 2, + (false, false) => 1, + }; + query.push_str(&format!(" LIMIT ${}", param_num)); + } + + if offset.is_some() { + let param_num = match (status.is_some(), creator.is_some(), limit.is_some()) { + (true, true, true) => 4, + (true, true, false) => 3, + (true, false, true) | (false, true, true) => 3, + (true, false, false) | (false, true, false) | (false, false, true) => 2, + (false, false, false) => 1, + }; + query.push_str(&format!(" OFFSET ${}", param_num)); + } + + let mut query_builder = sqlx::query_as::<_, ( + sqlx::types::Uuid, + String, + String, + String, + String, + Option>, + Option>, + )>(&query); + + if let Some(s) = status { + query_builder = query_builder.bind(s.as_str()); + } + + if let Some(c) = creator { + query_builder = query_builder.bind(c.as_str()); + } + + if let Some(l) = limit { + query_builder = query_builder.bind(l); + } + + if let Some(o) = offset { + query_builder = query_builder.bind(o); + } + + let rows = query_builder + .fetch_all(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(rows + .into_iter() + .map(|(id, name, description, status, creator, created_at, updated_at)| { + let status = status.parse().unwrap_or(ProjectStatus::Proposal); + Project { + id: ProjectId::from_uuid(id), + name, + description, + status, + creator: WalletAddress(creator), + created_at: created_at.unwrap(), + updated_at: updated_at.unwrap(), + } + }) + .collect()) + } + + async fn find_by_creator( + &self, + creator: &WalletAddress, + ) -> Result, Box> { + let rows = sqlx::query!( + r#" + SELECT id, name, description, status, creator, created_at, updated_at + FROM projects + WHERE creator = $1 + ORDER BY created_at DESC + "#, + creator.as_str() + ) + .fetch_all(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(rows + .into_iter() + .map(|r| { + let status = r.status.parse().unwrap_or(ProjectStatus::Proposal); + Project { + id: ProjectId::from_uuid(r.id), + name: r.name, + description: r.description, + status, + creator: WalletAddress(r.creator), + created_at: r.created_at.unwrap(), + updated_at: r.updated_at.unwrap(), + } + }) + .collect()) + } + + async fn update(&self, project: &Project) -> Result<(), Box> { + sqlx::query!( + r#" + UPDATE projects + SET name = $2, description = $3, status = $4, updated_at = $5 + WHERE id = $1 + "#, + project.id.value(), + project.name, + project.description, + project.status.as_str(), + project.updated_at + ) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(()) + } + + async fn delete(&self, id: &ProjectId) -> Result<(), Box> { + sqlx::query!( + r#" + DELETE FROM projects + WHERE id = $1 + "#, + id.value() + ) + .execute(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(()) + } + + async fn exists(&self, id: &ProjectId) -> Result> { + let row = sqlx::query!( + r#" + SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1) as "exists!" + "#, + id.value() + ) + .fetch_one(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(row.exists) + } + + async fn profile_exists( + &self, + address: &WalletAddress, + ) -> Result> { + let row = sqlx::query!( + r#" + SELECT EXISTS(SELECT 1 FROM profiles WHERE address = $1) as "exists!" + "#, + address.as_str() + ) + .fetch_one(&self.pool) + .await + .map_err(|e| Box::new(e) as Box)?; + + Ok(row.exists) + } +} diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 18239ad..9c8b20a 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -1,10 +1,12 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Extension, Json, }; +use serde::Deserialize; +// Profile imports use crate::{ application::{ commands::{create_profile::create_profile, login::login, update_profile::update_profile}, @@ -20,8 +22,34 @@ use crate::{ domain::value_objects::WalletAddress, }; +// Project imports +use crate::{ + application::projects::{ + commands::{ + create_project::create_project, + delete_project::delete_project, + update_project::update_project, + }, + dtos::{CreateProjectRequest, ProjectResponse, UpdateProjectRequest}, + queries::{ + get_all_projects::get_all_projects, + get_project::get_project, + get_projects_by_creator::get_projects_by_creator, + }, + }, +}; + use super::{api::AppState, middlewares::VerifiedWallet}; +/// Query parameters for listing projects +#[derive(Debug, Deserialize)] +pub struct ListProjectsQuery { + pub status: Option, + pub creator: Option, + pub limit: Option, + pub offset: Option, +} + pub async fn create_profile_handler( State(state): State, Extension(VerifiedWallet(wallet)): Extension, @@ -103,3 +131,103 @@ pub async fn login_handler( .into_response(), } } +pub async fn create_project_handler( + State(state): State, + Extension(VerifiedWallet(wallet)): Extension, + Json(payload): Json, +) -> impl IntoResponse { + match create_project(state.project_repository, wallet, payload).await { + Ok(project) => (StatusCode::CREATED, Json(project)).into_response(), + Err(e) => { + let status = if e.contains("profiles") { + StatusCode::FORBIDDEN + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } + } +} + +pub async fn get_project_handler( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match get_project(state.project_repository, id).await { + Ok(project) => Json(project).into_response(), + Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))).into_response(), + } +} + +pub async fn get_all_projects_handler( + State(state): State, + Query(query): Query, +) -> impl IntoResponse { + match get_all_projects( + state.project_repository, + query.status, + query.creator, + query.limit, + query.offset, + ) + .await + { + Ok(projects) => Json(projects).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + + +pub async fn get_projects_by_creator_handler( + State(state): State, + Path(address): Path, +) -> impl IntoResponse { + match get_projects_by_creator(state.project_repository, address).await { + Ok(projects) => Json(projects).into_response(), + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ) + .into_response(), + } +} + +pub async fn update_project_handler( + State(state): State, + Extension(VerifiedWallet(wallet)): Extension, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + match update_project(state.project_repository, wallet, id, payload).await { + Ok(project) => (StatusCode::OK, Json(project)).into_response(), + Err(e) => { + let status = if e.contains("not found") { + StatusCode::NOT_FOUND + } else if e.contains("Only the creator") { + StatusCode::FORBIDDEN + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } + } +} + +pub async fn delete_project_handler( + State(state): State, + Extension(VerifiedWallet(wallet)): Extension, + Path(id): Path, +) -> impl IntoResponse { + match delete_project(state.project_repository, wallet, id).await { + Ok(_) => StatusCode::ACCEPTED.into_response(), + Err(e) => { + let status = if e.contains("not found") { + StatusCode::NOT_FOUND + } else if e.contains("Only the creator") { + StatusCode::FORBIDDEN + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c6a83f0..2078c1e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -90,8 +90,7 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.12.2.tgz", "integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { "version": "0.7.2", @@ -300,7 +299,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -833,7 +831,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -857,7 +854,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3073,7 +3069,6 @@ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", - "peer": true, "dependencies": { "node-fetch": "^2.7.0" } @@ -3127,7 +3122,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4650,7 +4644,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5006,7 +4999,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5314,7 +5306,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6270,7 +6261,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.87.4.tgz", "integrity": "sha512-T5GT/1ZaNsUXf5I3RhcYuT17I4CPlbZgyLxc/ZGv7ciS6esytlbjb3DgUFO6c8JWYMDpdjSWInyGZUErgzqhcA==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.87.4" }, @@ -6452,7 +6442,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -6602,7 +6593,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -6621,7 +6611,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6631,7 +6620,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -6714,7 +6702,6 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -6925,7 +6912,6 @@ "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.17.3.tgz", "integrity": "sha512-jHivr1UPoJTX5Uel4AZSOwrCf4mO42LcdmnhJtUxZaRWhW4FviFbIfs0moAWWld7GOT+2XnuVZjjA/K32uUnMQ==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.8", @@ -7134,7 +7120,6 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-2.20.3.tgz", "integrity": "sha512-gsbuHnWxf0AYZISvR8LvF/vUCIq6/ZwT5f5/FKd6wLA7Wq05NihCvmQpIgrcVbpSJPL67wb6S8fXm3eJGJA1vQ==", "license": "MIT", - "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -7708,7 +7693,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7782,7 +7766,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7947,6 +7930,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8042,7 +8026,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.14.1.tgz", "integrity": "sha512-gPa8NY7/lP8j8g81iy8UwANF3+aukKRWS68IlthZQNgykpg80ne6lbHOp6FErYycxQ1TUhgEfkXVDQZAoJx8Bg==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.12.2", "@astrojs/internal-helpers": "0.7.3", @@ -8521,7 +8504,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -8615,7 +8597,6 @@ "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -9554,7 +9535,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "17.2.2", @@ -9608,7 +9590,6 @@ "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.15.tgz", "integrity": "sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==", "license": "MIT", - "peer": true, "dependencies": { "@ecies/ciphers": "^0.2.3", "@noble/ciphers": "^1.3.0", @@ -9923,7 +9904,6 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10625,8 +10605,7 @@ "version": "6.4.9", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eventemitter3": { "version": "5.0.1", @@ -12546,7 +12525,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -12587,7 +12565,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12769,7 +12746,6 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -13150,6 +13126,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15224,6 +15201,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15440,7 +15418,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15450,7 +15427,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15463,7 +15439,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -15489,7 +15464,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -15612,7 +15588,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -16200,7 +16175,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -16440,7 +16414,6 @@ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "license": "MIT", - "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", @@ -17742,7 +17715,6 @@ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -17783,7 +17755,6 @@ "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.13.2.tgz", "integrity": "sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A==", "license": "MIT", - "peer": true, "dependencies": { "derive-valtio": "0.1.0", "proxy-compare": "2.6.0", @@ -17867,7 +17838,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -17892,7 +17862,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -18095,7 +18064,6 @@ "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.16.9.tgz", "integrity": "sha512-5NbjvuNNhT0t0lQsDD5otQqZ5RZBM1UhInHoBq/Lpnr6xLLa8AWxYqHg5oZtGCdiUNltys11iBOS6z4mLepIqw==", "license": "MIT", - "peer": true, "dependencies": { "@wagmi/connectors": "5.9.9", "@wagmi/core": "2.20.3", @@ -18328,7 +18296,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -18629,7 +18596,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From eb007b82b848eb8698deb4791dbf6f62d36b7bc4 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Tue, 9 Dec 2025 09:08:07 +0100 Subject: [PATCH 02/11] fix updates --- .../migrations/004_create_projects_table.sql | 30 ++ backend/src/application/dtos/project_dtos.rs | 2 +- backend/src/application/mod.rs | 1 + backend/src/application/queries/mod.rs | 3 +- backend/src/presentation/handlers.rs | 4 +- backend/tests/project_tests.rs | 314 ++++++++++++++++++ 6 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/004_create_projects_table.sql create mode 100644 backend/tests/project_tests.rs diff --git a/backend/migrations/004_create_projects_table.sql b/backend/migrations/004_create_projects_table.sql new file mode 100644 index 0000000..9fc2608 --- /dev/null +++ b/backend/migrations/004_create_projects_table.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + status VARCHAR(50) NOT NULL CHECK (status IN ('proposal', 'ongoing', 'rejected')), + creator VARCHAR(42) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_creator FOREIGN KEY (creator) REFERENCES profiles(address) ON DELETE CASCADE +); + + +CREATE INDEX IF NOT EXISTS idx_projects_creator ON projects(creator); +CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); +CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects(created_at DESC); + + +CREATE OR REPLACE FUNCTION update_projects_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER trigger_update_projects_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION update_projects_updated_at(); \ No newline at end of file diff --git a/backend/src/application/dtos/project_dtos.rs b/backend/src/application/dtos/project_dtos.rs index d8af93a..14b8eb7 100644 --- a/backend/src/application/dtos/project_dtos.rs +++ b/backend/src/application/dtos/project_dtos.rs @@ -29,4 +29,4 @@ pub struct ProjectResponse { pub creator: String, pub created_at: DateTime, pub updated_at: DateTime, -} +} \ No newline at end of file diff --git a/backend/src/application/mod.rs b/backend/src/application/mod.rs index fd62725..2f91dec 100644 --- a/backend/src/application/mod.rs +++ b/backend/src/application/mod.rs @@ -1,3 +1,4 @@ pub mod commands; pub mod dtos; pub mod queries; + diff --git a/backend/src/application/queries/mod.rs b/backend/src/application/queries/mod.rs index 9a6c12e..320ad3e 100644 --- a/backend/src/application/queries/mod.rs +++ b/backend/src/application/queries/mod.rs @@ -2,6 +2,7 @@ pub mod get_all_profiles; pub mod get_login_nonce; pub mod get_profile; pub mod get_projects_by_creator; -pub mod get_all_projects, +pub mod get_all_projects; + pub mod get_project; diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 9c8b20a..a9f5c9b 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -24,13 +24,13 @@ use crate::{ // Project imports use crate::{ - application::projects::{ + application::{ commands::{ create_project::create_project, delete_project::delete_project, update_project::update_project, }, - dtos::{CreateProjectRequest, ProjectResponse, UpdateProjectRequest}, + dtos::project_dtos::{CreateProjectRequest, ProjectResponse, UpdateProjectRequest}, queries::{ get_all_projects::get_all_projects, get_project::get_project, diff --git a/backend/tests/project_tests.rs b/backend/tests/project_tests.rs new file mode 100644 index 0000000..4f5ec84 --- /dev/null +++ b/backend/tests/project_tests.rs @@ -0,0 +1,314 @@ + +#[cfg(test)] +mod project_tests { + use guild_backend::application::commands::create_project::create_project; + use guild_backend::application::commands::update_project::update_project; + use guild_backend::application::commands::delete_project::delete_project; + use guild_backend::application::dtos::project_dtos::{ + CreateProjectRequest, UpdateProjectRequest, + }; + use guild_backend::application::queries::get_project::get_project; + use guild_backend::domain::entities::projects::{Project, ProjectId, ProjectStatus}; + use guild_backend::domain::repositories::project_repository::ProjectRepository; + use guild_backend::domain::value_objects::WalletAddress; + use std::sync::Arc; + + // A fake in-memory repository for testing + struct FakeProjectRepo { + projects: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl ProjectRepository for FakeProjectRepo { + async fn create(&self, project: &Project) -> Result<(), Box> { + let mut list = self.projects.lock().unwrap(); + list.push(project.clone()); + Ok(()) + } + + async fn find_by_id( + &self, + id: &ProjectId, + ) -> Result, Box> { + let list = self.projects.lock().unwrap(); + Ok(list.iter().find(|p| p.id == *id).cloned()) + } + + async fn find_all( + &self, + status: Option, + creator: Option<&WalletAddress>, + _limit: Option, + _offset: Option, + ) -> Result, Box> { + let list = self.projects.lock().unwrap(); + let filtered: Vec = list + .iter() + .filter(|p| { + let status_match = status.map(|s| p.status == s).unwrap_or(true); + let creator_match = creator.map(|c| &p.creator == c).unwrap_or(true); + status_match && creator_match + }) + .cloned() + .collect(); + Ok(filtered) + } + + async fn find_by_creator( + &self, + creator: &WalletAddress, + ) -> Result, Box> { + let list = self.projects.lock().unwrap(); + Ok(list + .iter() + .filter(|p| &p.creator == creator) + .cloned() + .collect()) + } + + async fn update(&self, project: &Project) -> Result<(), Box> { + let mut list = self.projects.lock().unwrap(); + if let Some(slot) = list.iter_mut().find(|p| p.id == project.id) { + *slot = project.clone(); + Ok(()) + } else { + Err("Project not found".into()) + } + } + + async fn delete(&self, id: &ProjectId) -> Result<(), Box> { + let mut list = self.projects.lock().unwrap(); + list.retain(|p| &p.id != id); + Ok(()) + } + + async fn exists(&self, id: &ProjectId) -> Result> { + let list = self.projects.lock().unwrap(); + Ok(list.iter().any(|p| &p.id == id)) + } + + async fn profile_exists( + &self, + _address: &WalletAddress, + ) -> Result> { + // For testing, assume profile always exists + Ok(true) + } + } + + #[tokio::test] + async fn create_project_succeeds() { + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![]), + }); + + let creator_address = "0x1234567890123456789012345678901234567890".to_string(); + + let req = CreateProjectRequest { + name: "Test Project".into(), + description: "A test project".into(), + status: ProjectStatus::Proposal, + }; + + let result = create_project(repo.clone(), creator_address.clone(), req).await; + assert!(result.is_ok()); + + let response = result.unwrap(); + assert_eq!(response.name, "Test Project"); + assert_eq!(response.creator, creator_address); + } + + #[tokio::test] + async fn create_project_validates_name() { + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![]), + }); + + let creator_address = "0x1234567890123456789012345678901234567890".to_string(); + + // Empty name should fail + let req = CreateProjectRequest { + name: "".into(), + description: "Description".into(), + status: ProjectStatus::Proposal, + }; + + let result = create_project(repo.clone(), creator_address, req).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err(); + assert!(err_msg.contains("empty")); + } + + #[tokio::test] + async fn update_project_by_creator_succeeds() { + let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) + .unwrap(); + + let project = Project::new( + "Original Name".into(), + "Original Description".into(), + ProjectStatus::Proposal, + creator.clone(), + ); + let project_id = project.id; + + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![project]), + }); + + let req = UpdateProjectRequest { + name: Some("Updated Name".into()), + description: None, + status: Some(ProjectStatus::Ongoing), + }; + + let result = update_project( + repo.clone(), + creator.to_string(), + project_id.value().to_string(), + req, + ) + .await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.name, "Updated Name"); + assert_eq!(response.status, ProjectStatus::Ongoing); + } + + #[tokio::test] + async fn update_project_by_non_creator_fails() { + let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) + .unwrap(); + let other_user = + WalletAddress::new("0x0987654321098765432109876543210987654321".to_string()).unwrap(); + + let project = Project::new( + "Project".into(), + "Description".into(), + ProjectStatus::Proposal, + creator, + ); + let project_id = project.id; + + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![project]), + }); + + let req = UpdateProjectRequest { + name: Some("Hacked Name".into()), + description: None, + status: None, + }; + + let result = update_project( + repo.clone(), + other_user.to_string(), + project_id.value().to_string(), + req, + ) + .await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err(); + assert!(err_msg.contains("Only the creator")); + } + + #[tokio::test] + async fn delete_project_by_creator_succeeds() { + let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) + .unwrap(); + + let project = Project::new( + "To Delete".into(), + "Description".into(), + ProjectStatus::Proposal, + creator.clone(), + ); + let project_id = project.id; + + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![project]), + }); + + let result = + delete_project(repo.clone(), creator.to_string(), project_id.value().to_string()) + .await; + + assert!(result.is_ok()); + + // Verify it's deleted + let exists = repo.exists(&project_id).await.unwrap(); + assert!(!exists); + } + + #[tokio::test] + async fn delete_project_by_non_creator_fails() { + let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) + .unwrap(); + let other_user = + WalletAddress::new("0x0987654321098765432109876543210987654321".to_string()).unwrap(); + + let project = Project::new( + "Protected".into(), + "Description".into(), + ProjectStatus::Proposal, + creator, + ); + let project_id = project.id; + + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![project]), + }); + + let result = delete_project( + repo.clone(), + other_user.to_string(), + project_id.value().to_string(), + ) + .await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err(); + assert!(err_msg.contains("Only the creator")); + } + + #[tokio::test] + async fn get_project_by_id_succeeds() { + let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) + .unwrap(); + + let project = Project::new( + "Findable Project".into(), + "Description".into(), + ProjectStatus::Ongoing, + creator, + ); + let project_id = project.id; + + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![project.clone()]), + }); + + let result = get_project(repo.clone(), project_id.value().to_string()).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + assert_eq!(response.name, "Findable Project"); + assert_eq!(response.status, ProjectStatus::Ongoing); + } + + #[tokio::test] + async fn get_nonexistent_project_fails() { + let repo = Arc::new(FakeProjectRepo { + projects: std::sync::Mutex::new(vec![]), + }); + + let fake_id = uuid::Uuid::new_v4().to_string(); + let result = get_project(repo.clone(), fake_id).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err(); + assert!(err_msg.contains("not found")); + } +} \ No newline at end of file From cfdaf0956b86ca9c4cf0648ec05f190a45ded0c1 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Tue, 9 Dec 2025 14:01:53 +0100 Subject: [PATCH 03/11] update --- backend/src/application/commands/create_project.rs | 4 ++-- backend/src/application/commands/update_project.rs | 2 +- backend/src/application/dtos/mod.rs | 4 ++++ backend/src/application/queries/get_all_projects.rs | 9 ++++++--- backend/src/application/queries/get_project.rs | 4 ++-- .../src/application/queries/get_projects_by_creator.rs | 2 +- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index 6a672e2..b64fbc5 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::{ - application::projects::dtos::{CreateProjectRequest, ProjectResponse}, + application::dtos::project_dtos::{CreateProjectRequest, ProjectResponse}, domain::{ entities::projects::Project, repositories::project_repository::ProjectRepository, value_objects::WalletAddress, @@ -48,4 +48,4 @@ pub async fn create_project( created_at: project.created_at, updated_at: project.updated_at, }) -} +} \ No newline at end of file diff --git a/backend/src/application/commands/update_project.rs b/backend/src/application/commands/update_project.rs index 697267f..bd62830 100644 --- a/backend/src/application/commands/update_project.rs +++ b/backend/src/application/commands/update_project.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::{ - application::projects::dtos::{ProjectResponse, UpdateProjectRequest}, + application::dtos::project_dtos::{ProjectResponse, UpdateProjectRequest}, domain::{ entities::projects::ProjectId, repositories::project_repository::ProjectRepository, value_objects::WalletAddress, diff --git a/backend/src/application/dtos/mod.rs b/backend/src/application/dtos/mod.rs index 6eb6b75..eef5394 100644 --- a/backend/src/application/dtos/mod.rs +++ b/backend/src/application/dtos/mod.rs @@ -1,5 +1,9 @@ pub mod auth_dtos; pub mod profile_dtos; +pub mod project_dtos; pub use auth_dtos::*; pub use profile_dtos::*; +pub use project_dtos::*; + + diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index 892f388..b1c93c7 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::{ - application::projects::dtos::ProjectResponse, + application::dtos::project_dtos::ProjectResponse, domain::{ entities::projects::ProjectStatus, repositories::project_repository::ProjectRepository, value_objects::WalletAddress, @@ -15,7 +15,7 @@ pub async fn get_all_projects( limit: Option, offset: Option, ) -> Result, String> { - + // Parse status if provided let status_filter = if let Some(status_str) = status { Some( status_str @@ -26,21 +26,24 @@ pub async fn get_all_projects( None }; - + // Parse creator if provided let creator_filter = if let Some(creator_str) = creator { Some(WalletAddress::new(creator_str).map_err(|e| format!("Invalid creator address: {}", e))?) } else { None }; + // Validate and limit pagination let limit = limit.map(|l| l.max(1).min(100)); let offset = offset.map(|o| o.max(0)); + // Get projects let projects = repository .find_all(status_filter, creator_filter.as_ref(), limit, offset) .await .map_err(|e| e.to_string())?; + // Convert to responses Ok(projects .into_iter() .map(|project| ProjectResponse { diff --git a/backend/src/application/queries/get_project.rs b/backend/src/application/queries/get_project.rs index 7b2cf45..0c17b7a 100644 --- a/backend/src/application/queries/get_project.rs +++ b/backend/src/application/queries/get_project.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::{ - application::projects::dtos::ProjectResponse, + application::dtos::project_dtos::ProjectResponse, domain::{entities::projects::ProjectId, repositories::project_repository::ProjectRepository}, }; @@ -15,7 +15,7 @@ pub async fn get_project( .map_err(|_| "Invalid project ID".to_string())?; let project_id = ProjectId::from_uuid(id); - + // Get project let project = repository .find_by_id(&project_id) .await diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs index 1e6042a..f4f77b6 100644 --- a/backend/src/application/queries/get_projects_by_creator.rs +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::{ - application::projects::dtos::ProjectResponse, + application::dtos::project_dtos::ProjectResponse, domain::{repositories::project_repository::ProjectRepository, value_objects::WalletAddress}, }; From ce31a45008f6f05596aa69d5f874f0612693486d Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Tue, 9 Dec 2025 16:21:59 +0100 Subject: [PATCH 04/11] fix: backend CI issues --- ...4c439207adc547a1546e2365c09f05871fece.json | 22 +++++++ ...e753f21139a6c7824d8383d4ed3a469c42397.json | 22 +++++++ ...dd70d85233a3532c4a59cbd69f23f243be76c.json | 14 +++++ ...716a2126eeaf37594e4a847ab37dc6af14965.json | 58 +++++++++++++++++++ ...914ce8cfd5ecbc7eab71e1b4682a12a4bf38c.json | 20 +++++++ ...607fdaa1164923c5ddbb01b2a39197fa960da.json | 18 ++++++ ...ce3bbe4076bcf3cfd7b23d87d37a91a601c18.json | 58 +++++++++++++++++++ .../application/commands/create_project.rs | 2 +- .../postgres_project_repository.rs | 8 +-- backend/src/presentation/handlers.rs | 18 +----- 10 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 backend/.sqlx/query-12848607eb786f23ef75604ab424c439207adc547a1546e2365c09f05871fece.json create mode 100644 backend/.sqlx/query-18d603471474a708eb1f6497282e753f21139a6c7824d8383d4ed3a469c42397.json create mode 100644 backend/.sqlx/query-391650eeb50de2f320313870763dd70d85233a3532c4a59cbd69f23f243be76c.json create mode 100644 backend/.sqlx/query-51d5f8e6492ec06e07b8bd33fdb716a2126eeaf37594e4a847ab37dc6af14965.json create mode 100644 backend/.sqlx/query-9e418893765f6cdb43c039b3328914ce8cfd5ecbc7eab71e1b4682a12a4bf38c.json create mode 100644 backend/.sqlx/query-9f5cc87adffa6c94c7833987d32607fdaa1164923c5ddbb01b2a39197fa960da.json create mode 100644 backend/.sqlx/query-a6333b6f736730ea64a1b2d69f3ce3bbe4076bcf3cfd7b23d87d37a91a601c18.json diff --git a/backend/.sqlx/query-12848607eb786f23ef75604ab424c439207adc547a1546e2365c09f05871fece.json b/backend/.sqlx/query-12848607eb786f23ef75604ab424c439207adc547a1546e2365c09f05871fece.json new file mode 100644 index 0000000..af1a3c1 --- /dev/null +++ b/backend/.sqlx/query-12848607eb786f23ef75604ab424c439207adc547a1546e2365c09f05871fece.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM profiles WHERE address = $1) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "12848607eb786f23ef75604ab424c439207adc547a1546e2365c09f05871fece" +} diff --git a/backend/.sqlx/query-18d603471474a708eb1f6497282e753f21139a6c7824d8383d4ed3a469c42397.json b/backend/.sqlx/query-18d603471474a708eb1f6497282e753f21139a6c7824d8383d4ed3a469c42397.json new file mode 100644 index 0000000..0dc5f83 --- /dev/null +++ b/backend/.sqlx/query-18d603471474a708eb1f6497282e753f21139a6c7824d8383d4ed3a469c42397.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "18d603471474a708eb1f6497282e753f21139a6c7824d8383d4ed3a469c42397" +} diff --git a/backend/.sqlx/query-391650eeb50de2f320313870763dd70d85233a3532c4a59cbd69f23f243be76c.json b/backend/.sqlx/query-391650eeb50de2f320313870763dd70d85233a3532c4a59cbd69f23f243be76c.json new file mode 100644 index 0000000..4bf464b --- /dev/null +++ b/backend/.sqlx/query-391650eeb50de2f320313870763dd70d85233a3532c4a59cbd69f23f243be76c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM projects\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "391650eeb50de2f320313870763dd70d85233a3532c4a59cbd69f23f243be76c" +} diff --git a/backend/.sqlx/query-51d5f8e6492ec06e07b8bd33fdb716a2126eeaf37594e4a847ab37dc6af14965.json b/backend/.sqlx/query-51d5f8e6492ec06e07b8bd33fdb716a2126eeaf37594e4a847ab37dc6af14965.json new file mode 100644 index 0000000..f08a413 --- /dev/null +++ b/backend/.sqlx/query-51d5f8e6492ec06e07b8bd33fdb716a2126eeaf37594e4a847ab37dc6af14965.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, name, description, status, creator, created_at, updated_at\n FROM projects\n WHERE creator = $1\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "creator", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "51d5f8e6492ec06e07b8bd33fdb716a2126eeaf37594e4a847ab37dc6af14965" +} diff --git a/backend/.sqlx/query-9e418893765f6cdb43c039b3328914ce8cfd5ecbc7eab71e1b4682a12a4bf38c.json b/backend/.sqlx/query-9e418893765f6cdb43c039b3328914ce8cfd5ecbc7eab71e1b4682a12a4bf38c.json new file mode 100644 index 0000000..9794f04 --- /dev/null +++ b/backend/.sqlx/query-9e418893765f6cdb43c039b3328914ce8cfd5ecbc7eab71e1b4682a12a4bf38c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO projects (id, name, description, status, creator, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Varchar", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "9e418893765f6cdb43c039b3328914ce8cfd5ecbc7eab71e1b4682a12a4bf38c" +} diff --git a/backend/.sqlx/query-9f5cc87adffa6c94c7833987d32607fdaa1164923c5ddbb01b2a39197fa960da.json b/backend/.sqlx/query-9f5cc87adffa6c94c7833987d32607fdaa1164923c5ddbb01b2a39197fa960da.json new file mode 100644 index 0000000..4b6b561 --- /dev/null +++ b/backend/.sqlx/query-9f5cc87adffa6c94c7833987d32607fdaa1164923c5ddbb01b2a39197fa960da.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE projects\n SET name = $2, description = $3, status = $4, updated_at = $5\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "9f5cc87adffa6c94c7833987d32607fdaa1164923c5ddbb01b2a39197fa960da" +} diff --git a/backend/.sqlx/query-a6333b6f736730ea64a1b2d69f3ce3bbe4076bcf3cfd7b23d87d37a91a601c18.json b/backend/.sqlx/query-a6333b6f736730ea64a1b2d69f3ce3bbe4076bcf3cfd7b23d87d37a91a601c18.json new file mode 100644 index 0000000..0b62692 --- /dev/null +++ b/backend/.sqlx/query-a6333b6f736730ea64a1b2d69f3ce3bbe4076bcf3cfd7b23d87d37a91a601c18.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, name, description, status, creator, created_at, updated_at\n FROM projects\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "creator", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a6333b6f736730ea64a1b2d69f3ce3bbe4076bcf3cfd7b23d87d37a91a601c18" +} diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index b64fbc5..b941b64 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -27,7 +27,7 @@ pub async fn create_project( } // Create project entity - let mut project = Project::new(request.name, request.description, request.status, creator); + let project = Project::new(request.name, request.description, request.status, creator); // Validate project project.validate()?; diff --git a/backend/src/infrastructure/repositories/postgres_project_repository.rs b/backend/src/infrastructure/repositories/postgres_project_repository.rs index 1638c68..effe6b9 100644 --- a/backend/src/infrastructure/repositories/postgres_project_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_project_repository.rs @@ -66,8 +66,8 @@ impl ProjectRepository for PostgresProjectRepository { description: r.description, status, creator: WalletAddress(r.creator), - created_at: r.created_at.unwrap(), - updated_at: r.updated_at.unwrap(), + created_at: r.created_at, + updated_at: r.updated_at, } })) } @@ -193,8 +193,8 @@ impl ProjectRepository for PostgresProjectRepository { description: r.description, status, creator: WalletAddress(r.creator), - created_at: r.created_at.unwrap(), - updated_at: r.updated_at.unwrap(), + created_at: r.created_at, + updated_at: r.updated_at, } }) .collect()) diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index a9f5c9b..04cfb03 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, Query, State}, + extract::{Path, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -22,22 +22,6 @@ use crate::{ domain::value_objects::WalletAddress, }; -// Project imports -use crate::{ - application::{ - commands::{ - create_project::create_project, - delete_project::delete_project, - update_project::update_project, - }, - dtos::project_dtos::{CreateProjectRequest, ProjectResponse, UpdateProjectRequest}, - queries::{ - get_all_projects::get_all_projects, - get_project::get_project, - get_projects_by_creator::get_projects_by_creator, - }, - }, -}; use super::{api::AppState, middlewares::VerifiedWallet}; From f86a5143cf3bef3bd642d584f10fdef2407dd541 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Tue, 9 Dec 2025 16:31:37 +0100 Subject: [PATCH 05/11] fix: backend CI issues --- backend/src/application/queries/get_all_projects.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index b1c93c7..b012e10 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -34,7 +34,7 @@ pub async fn get_all_projects( }; // Validate and limit pagination - let limit = limit.map(|l| l.max(1).min(100)); + let limit = limit.map(|l| l.clamp(1, 100)); let offset = offset.map(|o| o.max(0)); // Get projects From a1c6b01cafff74cceae68868d24cbcc510d68e8b Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Tue, 9 Dec 2025 20:14:53 +0100 Subject: [PATCH 06/11] fix issues --- .../application/commands/create_project.rs | 2 +- .../application/commands/delete_project.rs | 12 ++-- backend/src/application/commands/mod.rs | 4 +- .../application/commands/update_project.rs | 5 +- backend/src/application/dtos/mod.rs | 3 - backend/src/application/dtos/project_dtos.rs | 2 +- backend/src/application/mod.rs | 1 - .../application/queries/get_all_projects.rs | 7 +- .../src/application/queries/get_project.rs | 5 +- .../queries/get_projects_by_creator.rs | 2 +- backend/src/application/queries/mod.rs | 3 +- backend/src/domain/entities/projects.rs | 2 +- .../postgres_project_repository.rs | 72 ++++++++++--------- backend/src/presentation/handlers.rs | 1 - backend/tests/project_tests.rs | 34 ++++----- 15 files changed, 76 insertions(+), 79 deletions(-) diff --git a/backend/src/application/commands/create_project.rs b/backend/src/application/commands/create_project.rs index b941b64..a157f7d 100644 --- a/backend/src/application/commands/create_project.rs +++ b/backend/src/application/commands/create_project.rs @@ -48,4 +48,4 @@ pub async fn create_project( created_at: project.created_at, updated_at: project.updated_at, }) -} \ No newline at end of file +} diff --git a/backend/src/application/commands/delete_project.rs b/backend/src/application/commands/delete_project.rs index b4fd9f7..eccfd77 100644 --- a/backend/src/application/commands/delete_project.rs +++ b/backend/src/application/commands/delete_project.rs @@ -1,21 +1,17 @@ use std::sync::Arc; use uuid::Uuid; -use crate::{ - domain::{ - entities::projects::ProjectId, repositories::project_repository::ProjectRepository, - value_objects::WalletAddress, - }, +use crate::domain::{ + entities::projects::ProjectId, repositories::project_repository::ProjectRepository, + value_objects::WalletAddress, }; - pub async fn delete_project( repository: Arc, requester_address: String, project_id: String, ) -> Result<(), String> { // Parse project ID - let id = Uuid::parse_str(&project_id) - .map_err(|_| "Invalid project ID".to_string())?; + let id = Uuid::parse_str(&project_id).map_err(|_| "Invalid project ID".to_string())?; let project_id = ProjectId::from_uuid(id); // Validate requester address diff --git a/backend/src/application/commands/mod.rs b/backend/src/application/commands/mod.rs index 6bd514a..2b63c7a 100644 --- a/backend/src/application/commands/mod.rs +++ b/backend/src/application/commands/mod.rs @@ -1,6 +1,6 @@ pub mod create_profile; +pub mod create_project; +pub mod delete_project; pub mod login; pub mod update_profile; -pub mod create_project; pub mod update_project; -pub mod delete_project; \ No newline at end of file diff --git a/backend/src/application/commands/update_project.rs b/backend/src/application/commands/update_project.rs index bd62830..ebc2e90 100644 --- a/backend/src/application/commands/update_project.rs +++ b/backend/src/application/commands/update_project.rs @@ -16,8 +16,7 @@ pub async fn update_project( request: UpdateProjectRequest, ) -> Result { // Parse project ID - let id = Uuid::parse_str(&project_id) - .map_err(|_| "Invalid project ID".to_string())?; + let id = Uuid::parse_str(&project_id).map_err(|_| "Invalid project ID".to_string())?; let project_id = ProjectId::from_uuid(id); // Validate requester address @@ -58,4 +57,4 @@ pub async fn update_project( created_at: project.created_at, updated_at: project.updated_at, }) -} \ No newline at end of file +} diff --git a/backend/src/application/dtos/mod.rs b/backend/src/application/dtos/mod.rs index eef5394..1e7059a 100644 --- a/backend/src/application/dtos/mod.rs +++ b/backend/src/application/dtos/mod.rs @@ -1,9 +1,6 @@ pub mod auth_dtos; pub mod profile_dtos; pub mod project_dtos; - pub use auth_dtos::*; pub use profile_dtos::*; pub use project_dtos::*; - - diff --git a/backend/src/application/dtos/project_dtos.rs b/backend/src/application/dtos/project_dtos.rs index 14b8eb7..d8af93a 100644 --- a/backend/src/application/dtos/project_dtos.rs +++ b/backend/src/application/dtos/project_dtos.rs @@ -29,4 +29,4 @@ pub struct ProjectResponse { pub creator: String, pub created_at: DateTime, pub updated_at: DateTime, -} \ No newline at end of file +} diff --git a/backend/src/application/mod.rs b/backend/src/application/mod.rs index 2f91dec..fd62725 100644 --- a/backend/src/application/mod.rs +++ b/backend/src/application/mod.rs @@ -1,4 +1,3 @@ pub mod commands; pub mod dtos; pub mod queries; - diff --git a/backend/src/application/queries/get_all_projects.rs b/backend/src/application/queries/get_all_projects.rs index b012e10..73fd935 100644 --- a/backend/src/application/queries/get_all_projects.rs +++ b/backend/src/application/queries/get_all_projects.rs @@ -28,7 +28,10 @@ pub async fn get_all_projects( // Parse creator if provided let creator_filter = if let Some(creator_str) = creator { - Some(WalletAddress::new(creator_str).map_err(|e| format!("Invalid creator address: {}", e))?) + Some( + WalletAddress::new(creator_str) + .map_err(|e| format!("Invalid creator address: {}", e))?, + ) } else { None }; @@ -56,4 +59,4 @@ pub async fn get_all_projects( updated_at: project.updated_at, }) .collect()) -} \ No newline at end of file +} diff --git a/backend/src/application/queries/get_project.rs b/backend/src/application/queries/get_project.rs index 0c17b7a..60e7c1a 100644 --- a/backend/src/application/queries/get_project.rs +++ b/backend/src/application/queries/get_project.rs @@ -11,8 +11,7 @@ pub async fn get_project( project_id: String, ) -> Result { // Parse project ID - let id = Uuid::parse_str(&project_id) - .map_err(|_| "Invalid project ID".to_string())?; + let id = Uuid::parse_str(&project_id).map_err(|_| "Invalid project ID".to_string())?; let project_id = ProjectId::from_uuid(id); // Get project @@ -32,4 +31,4 @@ pub async fn get_project( created_at: project.created_at, updated_at: project.updated_at, }) -} \ No newline at end of file +} diff --git a/backend/src/application/queries/get_projects_by_creator.rs b/backend/src/application/queries/get_projects_by_creator.rs index f4f77b6..c6dc605 100644 --- a/backend/src/application/queries/get_projects_by_creator.rs +++ b/backend/src/application/queries/get_projects_by_creator.rs @@ -32,4 +32,4 @@ pub async fn get_projects_by_creator( updated_at: project.updated_at, }) .collect()) -} \ No newline at end of file +} diff --git a/backend/src/application/queries/mod.rs b/backend/src/application/queries/mod.rs index 320ad3e..7874c61 100644 --- a/backend/src/application/queries/mod.rs +++ b/backend/src/application/queries/mod.rs @@ -1,8 +1,7 @@ pub mod get_all_profiles; +pub mod get_all_projects; pub mod get_login_nonce; pub mod get_profile; pub mod get_projects_by_creator; -pub mod get_all_projects; pub mod get_project; - diff --git a/backend/src/domain/entities/projects.rs b/backend/src/domain/entities/projects.rs index e6b20f3..af8f157 100644 --- a/backend/src/domain/entities/projects.rs +++ b/backend/src/domain/entities/projects.rs @@ -141,4 +141,4 @@ impl Project { self.status = new_status; self.updated_at = Utc::now(); } -} \ No newline at end of file +} diff --git a/backend/src/infrastructure/repositories/postgres_project_repository.rs b/backend/src/infrastructure/repositories/postgres_project_repository.rs index effe6b9..acd6a21 100644 --- a/backend/src/infrastructure/repositories/postgres_project_repository.rs +++ b/backend/src/infrastructure/repositories/postgres_project_repository.rs @@ -57,19 +57,18 @@ impl ProjectRepository for PostgresProjectRepository { .await .map_err(|e| Box::new(e) as Box)?; - Ok(row - .map(|r| { - let status = r.status.parse().unwrap_or(ProjectStatus::Proposal); - Project { - id: ProjectId::from_uuid(r.id), - name: r.name, - description: r.description, - status, - creator: WalletAddress(r.creator), - created_at: r.created_at, - updated_at: r.updated_at, - } - })) + Ok(row.map(|r| { + let status = r.status.parse().unwrap_or(ProjectStatus::Proposal); + Project { + id: ProjectId::from_uuid(r.id), + name: r.name, + description: r.description, + status, + creator: WalletAddress(r.creator), + created_at: r.created_at, + updated_at: r.updated_at, + } + })) } async fn find_all( @@ -118,15 +117,18 @@ impl ProjectRepository for PostgresProjectRepository { query.push_str(&format!(" OFFSET ${}", param_num)); } - let mut query_builder = sqlx::query_as::<_, ( - sqlx::types::Uuid, - String, - String, - String, - String, - Option>, - Option>, - )>(&query); + let mut query_builder = sqlx::query_as::< + _, + ( + sqlx::types::Uuid, + String, + String, + String, + String, + Option>, + Option>, + ), + >(&query); if let Some(s) = status { query_builder = query_builder.bind(s.as_str()); @@ -151,18 +153,20 @@ impl ProjectRepository for PostgresProjectRepository { Ok(rows .into_iter() - .map(|(id, name, description, status, creator, created_at, updated_at)| { - let status = status.parse().unwrap_or(ProjectStatus::Proposal); - Project { - id: ProjectId::from_uuid(id), - name, - description, - status, - creator: WalletAddress(creator), - created_at: created_at.unwrap(), - updated_at: updated_at.unwrap(), - } - }) + .map( + |(id, name, description, status, creator, created_at, updated_at)| { + let status = status.parse().unwrap_or(ProjectStatus::Proposal); + Project { + id: ProjectId::from_uuid(id), + name, + description, + status, + creator: WalletAddress(creator), + created_at: created_at.unwrap(), + updated_at: updated_at.unwrap(), + } + }, + ) .collect()) } diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 04cfb03..f7fc498 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -22,7 +22,6 @@ use crate::{ domain::value_objects::WalletAddress, }; - use super::{api::AppState, middlewares::VerifiedWallet}; /// Query parameters for listing projects diff --git a/backend/tests/project_tests.rs b/backend/tests/project_tests.rs index 4f5ec84..89dcae4 100644 --- a/backend/tests/project_tests.rs +++ b/backend/tests/project_tests.rs @@ -1,9 +1,8 @@ - #[cfg(test)] mod project_tests { use guild_backend::application::commands::create_project::create_project; - use guild_backend::application::commands::update_project::update_project; use guild_backend::application::commands::delete_project::delete_project; + use guild_backend::application::commands::update_project::update_project; use guild_backend::application::dtos::project_dtos::{ CreateProjectRequest, UpdateProjectRequest, }; @@ -141,8 +140,8 @@ mod project_tests { #[tokio::test] async fn update_project_by_creator_succeeds() { - let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) - .unwrap(); + let creator = + WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()).unwrap(); let project = Project::new( "Original Name".into(), @@ -178,8 +177,8 @@ mod project_tests { #[tokio::test] async fn update_project_by_non_creator_fails() { - let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) - .unwrap(); + let creator = + WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()).unwrap(); let other_user = WalletAddress::new("0x0987654321098765432109876543210987654321".to_string()).unwrap(); @@ -216,8 +215,8 @@ mod project_tests { #[tokio::test] async fn delete_project_by_creator_succeeds() { - let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) - .unwrap(); + let creator = + WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()).unwrap(); let project = Project::new( "To Delete".into(), @@ -231,9 +230,12 @@ mod project_tests { projects: std::sync::Mutex::new(vec![project]), }); - let result = - delete_project(repo.clone(), creator.to_string(), project_id.value().to_string()) - .await; + let result = delete_project( + repo.clone(), + creator.to_string(), + project_id.value().to_string(), + ) + .await; assert!(result.is_ok()); @@ -244,8 +246,8 @@ mod project_tests { #[tokio::test] async fn delete_project_by_non_creator_fails() { - let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) - .unwrap(); + let creator = + WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()).unwrap(); let other_user = WalletAddress::new("0x0987654321098765432109876543210987654321".to_string()).unwrap(); @@ -275,8 +277,8 @@ mod project_tests { #[tokio::test] async fn get_project_by_id_succeeds() { - let creator = WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()) - .unwrap(); + let creator = + WalletAddress::new("0x1234567890123456789012345678901234567890".to_string()).unwrap(); let project = Project::new( "Findable Project".into(), @@ -311,4 +313,4 @@ mod project_tests { let err_msg = result.unwrap_err(); assert!(err_msg.contains("not found")); } -} \ No newline at end of file +} From 8f80ce1586c6b5d1090f5329d3f17a89611974ee Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Wed, 10 Dec 2025 18:13:17 +0100 Subject: [PATCH 07/11] updating --- backend/README.md | 294 +++++++++++++++++++++++---- backend/src/presentation/api.rs | 70 ++++++- backend/src/presentation/handlers.rs | 104 ++++++---- 3 files changed, 389 insertions(+), 79 deletions(-) diff --git a/backend/README.md b/backend/README.md index dbb243c..2aec9a3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Guild Backend (Rust + Axum + SQLx) -This service exposes a REST API for managing profiles, backed by PostgreSQL. +This service exposes a REST API for managing profiles and projects, backed by PostgreSQL. - HTTP: 0.0.0.0:3001 - DB: PostgreSQL (SQLx) @@ -66,25 +66,131 @@ SKIP_MIGRATIONS=1 cargo run --bin guild-backend ### Production (Automatic Migrations) In production (Heroku, etc.), migrations run automatically on server startup. No additional setup needed. -### Disable SQLx Compile-time Validation (Development Only) -To avoid SQLx compile-time query validation issues during development: +## 5) SQLx Offline Mode & Query Validation + +### Overview +SQLx uses compile-time verification of SQL queries. This requires either: +- A live database connection during compilation, OR +- Pre-generated `.sqlx/*.json` metadata files for offline compilation + +### Generate SQLx Metadata Files +After adding new database queries or modifying the schema: + ```bash -export SQLX_OFFLINE=true +cd backend + +# Ensure database is running and migrations are applied +sqlx migrate run + +# Generate .sqlx metadata for offline compilation +cargo sqlx prepare -- --bin guild-backend + +# Or if you have multiple targets: +cargo sqlx prepare -- --all-targets ``` -This allows compilation without a database connection, but you lose compile-time query validation. -## 5) Launch the API +This creates `.sqlx/*.json` files containing validated query metadata. + +### Building Without Database Access +Once `.sqlx` files are generated, you can build without a database connection: + +```bash +SQLX_OFFLINE=true cargo build +``` + +### When to Regenerate `.sqlx` Files +Regenerate whenever you: +- ✅ Add new migrations +- ✅ Add new SQL queries +- ✅ Modify existing queries +- ✅ Change database schema + +### Regeneration Workflow +```bash +cd backend + +# 1. Apply any new migrations +sqlx migrate run + +# 2. Regenerate metadata +cargo sqlx prepare -- --bin guild-backend + +# 3. Commit the updated files +git add .sqlx/ +git commit -m "Update SQLx offline data" +``` + +### Troubleshooting SQLx Issues + +#### "Query data not found for query" +**Cause:** `.sqlx` files don't contain metadata for your query. + +**Solution:** +```bash +cargo sqlx prepare -- --bin guild-backend +``` + +#### "Password authentication failed" +**Cause:** Wrong `DATABASE_URL` in `.env`. + +**Solution:** Update `.env` with correct port and credentials: +```bash +# Check which port PostgreSQL is running on +sudo ss -tlnp | grep postgres + +# Update .env +DATABASE_URL=postgresql://guild_user:guild_password@localhost:5432/guild_genesis +``` + +#### "Connection refused" +**Cause:** PostgreSQL isn't running. + +**Solution:** +```bash +# Start PostgreSQL +sudo systemctl start postgresql + +# Or with Homebrew +brew services start postgresql + +# Or with Docker +docker start +``` + +### CI/CD Integration +For CI/CD pipelines where database isn't available: + +```yaml +# .github/workflows/rust.yml +env: + SQLX_OFFLINE: true + +steps: + - name: Build + run: cargo build --release +``` + +**Note:** Make sure `.sqlx/` files are committed to your repository! + +## 6) Launch the API ``` cd backend cargo run ``` The server listens on `http://0.0.0.0:3001`. -## 6) API quickstart -All endpoints require Ethereum header-based auth. +## 7) API Documentation -Create profile: -``` +### Authentication +All protected endpoints require Ethereum signature-based authentication with these headers: +- `x-eth-address`: Your Ethereum wallet address +- `x-eth-signature`: Signature of the payload +- `x-siwe-message`: Login nonce + +### Profile Endpoints + +#### Create Profile (Protected) +```bash curl -X POST \ -H 'Content-Type: application/json' \ -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ @@ -97,15 +203,14 @@ curl -X POST \ }' \ http://0.0.0.0:3001/profiles ``` -Get profile: -``` -curl -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ - -H 'x-eth-signature: 0x00000000000000' \ - -H 'x-siwe-message: LOGIN_NONCE' \ - http://0.0.0.0:3001/profiles/0x2581aAa94299787a8A588B2Fceb161A302939E28 -``` -Update profile: + +#### Get Profile (Public) +```bash +curl http://0.0.0.0:3001/profiles/0x2581aAa94299787a8A588B2Fceb161A302939E28 ``` + +#### Update Profile (Protected) +```bash curl -X PUT \ -H 'Content-Type: application/json' \ -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ @@ -115,17 +220,14 @@ curl -X PUT \ http://0.0.0.0:3001/profiles/0x2581aAa94299787a8A588B2Fceb161A302939E28 ``` -### GitHub handle support - -Profiles can now include an optional GitHub username stored as `github_login`. - -- The value is stored with its original casing, but uniqueness is enforced case-insensitively ("Alice" conflicts with "alice"). -- `github_login` must match the pattern `^[a-zA-Z0-9-]{1,39}$`; otherwise the API returns **400 Bad Request**. -- When the normalized value is already claimed by another profile, the API returns **409 Conflict**. -- Successful creates return **201 Created** and updates return **200 OK**. -- Include the field when creating or updating a profile: +#### GitHub Handle Support +Profiles can include an optional GitHub username stored as `github_login`: +- Stored with original casing, uniqueness enforced case-insensitively +- Must match pattern `^[a-zA-Z0-9-]{1,39}$` +- Returns **400 Bad Request** for invalid format +- Returns **409 Conflict** if already taken -``` +```bash curl -X PUT \ -H 'Content-Type: application/json' \ -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ @@ -135,9 +237,127 @@ curl -X PUT \ http://0.0.0.0:3001/profiles/0x2581aAa94299787a8A588B2Fceb161A302939E28 ``` -Integration and automated tests run under `TEST_MODE=1`, which swaps in a test-only auth layer so GitHub handle flows can be exercised without Ethereum signature verification. +### Project Endpoints + +#### List All Projects (Public) +```bash +# Get all projects +curl http://0.0.0.0:3001/projects + +# Filter by status +curl http://0.0.0.0:3001/projects?status=ongoing + +# Filter by creator +curl http://0.0.0.0:3001/projects?creator=0x2581aAa94299787a8A588B2Fceb161A302939E28 + +# Pagination +curl http://0.0.0.0:3001/projects?limit=10&offset=0 +``` + +**Query Parameters:** +- `status` - Filter by status (proposal, ongoing, rejected) +- `creator` - Filter by creator address +- `limit` - Max results (default: all, max: 100) +- `offset` - Skip N results + +#### Get Project by ID (Public) +```bash +curl http://0.0.0.0:3001/projects/123e4567-e89b-12d3-a456-426614174000 +``` + +#### Get User's Projects (Public) +```bash +curl http://0.0.0.0:3001/users/0x2581aAa94299787a8A588B2Fceb161A302939E28/projects +``` + +#### Create Project (Protected) +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ + -H 'x-eth-signature: 0x00000000000000' \ + -H 'x-siwe-message: LOGIN_NONCE' \ + -d '{ + "name": "Guild Treasury Management", + "description": "A system for managing guild funds", + "status": "proposal" + }' \ + http://0.0.0.0:3001/projects +``` + +**Valid statuses:** `proposal`, `ongoing`, `rejected` + +#### Update Project (Protected, Creator Only) +```bash +curl -X PATCH \ + -H 'Content-Type: application/json' \ + -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ + -H 'x-eth-signature: 0x00000000000000' \ + -H 'x-siwe-message: LOGIN_NONCE' \ + -d '{ + "status": "ongoing", + "description": "Updated description" + }' \ + http://0.0.0.0:3001/projects/123e4567-e89b-12d3-a456-426614174000 +``` + +**Note:** Only the project creator can update projects. + +#### Delete Project (Protected, Creator Only) +```bash +curl -X DELETE \ + -H 'x-eth-address: 0x2581aAa94299787a8A588B2Fceb161A302939E28' \ + -H 'x-eth-signature: 0x00000000000000' \ + -H 'x-siwe-message: LOGIN_NONCE' \ + http://0.0.0.0:3001/projects/123e4567-e89b-12d3-a456-426614174000 +``` + +**Note:** Only the project creator can delete projects. + +### API Response Codes +- **200 OK** - Successful GET/PUT/PATCH +- **201 Created** - Resource created +- **204 No Content** - Successful DELETE +- **400 Bad Request** - Invalid input +- **401 Unauthorized** - Missing/invalid authentication +- **403 Forbidden** - Not authorized (e.g., not project creator) +- **404 Not Found** - Resource doesn't exist +- **409 Conflict** - Duplicate resource (e.g., GitHub handle taken) + +## 8) Testing + +### Automated Test Scripts +Test scripts are available in the `scripts/` directory: + +```bash +# Test authentication and profile endpoints +./scripts/test_auth_login.sh + +# Test project endpoints (requires keys.json) +./scripts/test_projects_api.sh +``` + +**Note:** Test scripts require `keys.json` in the project root: +```json +{ + "publicKey": "0x...", + "privateKey": "0x..." +} +``` + +### Running Unit Tests +```bash +cd backend +cargo test +``` + +### Integration Tests (Test Mode) +Integration tests run under `TEST_MODE=1`, which uses a test-only auth layer: +```bash +TEST_MODE=1 cargo test +``` -## 7) Deployment +## 9) Deployment ### Heroku 1. Set environment variables: @@ -159,10 +379,11 @@ docker build -t guild-backend . docker run -e DATABASE_URL=postgresql://... guild-backend ``` -## 8) Troubleshooting +## 10) Troubleshooting ### Database Issues - **Port already in use**: Check what's running on port 5432: `lsof -i :5432` +- **Port mismatch**: Ensure `.env` uses correct port (5432, not 5433) - **Permission denied**: Ensure `guild_user` has proper permissions: ```bash psql -h localhost -p 5432 -U $(whoami) -d guild_genesis -c "GRANT ALL PRIVILEGES ON SCHEMA public TO guild_user;" @@ -177,15 +398,16 @@ docker run -e DATABASE_URL=postgresql://... guild-backend - **Port 3001 already in use**: Use a different port: `PORT=3002 cargo run --bin guild-backend` - **SQLx compile errors**: - For development: Set `SQLX_OFFLINE=true` and run migrations manually - - For production: Ensure database is accessible during compilation + - For production: Run `cargo sqlx prepare` to generate metadata - **Migration conflicts**: Use `SKIP_MIGRATIONS=1` to disable automatic migrations - **Rust edition 2024 error**: Repo pins `base64ct = 1.7.3`. If still present, `rustup update` or `rustup override set nightly` in `backend/`. -## 9) Structure +## 11) Structure - `src/main.rs`: boot server (automatic migrations in production, manual in dev) - `src/bin/migrate.rs`: standalone migrator - `src/presentation`: routes, handlers, middlewares -- `src/infrastructure`: Postgres repository, Ethereum verification -- `src/domain`: entities, repository traits, services -- `src/application`: commands and DTOs +- `src/infrastructure`: Postgres repositories, Ethereum verification +- `src/domain`: entities, repository traits, services, value objects +- `src/application`: commands, queries, and DTOs - `migrations/`: SQLx migration files +- `.sqlx/`: SQLx offline query metadata (committed to repo) diff --git a/backend/src/presentation/api.rs b/backend/src/presentation/api.rs index 093bfaf..3148b99 100644 --- a/backend/src/presentation/api.rs +++ b/backend/src/presentation/api.rs @@ -1,16 +1,18 @@ use std::sync::Arc; -use crate::domain::repositories::ProfileRepository; +use crate::domain::repositories::{ProfileRepository, ProjectRepository}; use crate::domain::services::auth_service::AuthService; use crate::infrastructure::{ - repositories::PostgresProfileRepository, + repositories::{ + postgres_project_repository::PostgresProjectRepository, PostgresProfileRepository, + }, services::ethereum_address_verification_service::EthereumAddressVerificationService, }; use axum::middleware::{from_fn, from_fn_with_state}; use axum::{ extract::DefaultBodyLimit, http::Method, - routing::{delete, get, post, put}, + routing::{delete, get, patch, post, put}, Router, }; use tower::ServiceBuilder; @@ -20,27 +22,48 @@ use tower_http::{ }; use super::handlers::{ - create_profile_handler, delete_profile_handler, get_all_profiles_handler, get_nonce_handler, - get_profile_handler, login_handler, update_profile_handler, + // Profile handlers + create_profile_handler, + // Project handlers + create_project_handler, + delete_profile_handler, + delete_project_handler, + get_all_profiles_handler, + get_nonce_handler, + get_profile_handler, + get_project_handler, + get_user_projects_handler, + list_projects_handler, + login_handler, + update_profile_handler, + update_project_handler, }; use super::middlewares::{eth_auth_layer, test_auth_layer}; pub async fn create_app(pool: sqlx::PgPool) -> Router { - let profile_repository = Arc::from(PostgresProfileRepository::new(pool)); + let profile_repository = Arc::from(PostgresProfileRepository::new(pool.clone())); + let project_repository = Arc::from(PostgresProjectRepository::new(pool)); let auth_service = EthereumAddressVerificationService::new(profile_repository.clone()); let state: AppState = AppState { profile_repository, + project_repository, auth_service: Arc::from(auth_service), }; + // Protected routes (require authentication) let protected_routes = Router::new() + // Profile protected routes .route("/profiles", post(create_profile_handler)) .route("/profiles/", post(create_profile_handler)) .route("/profiles/:address", put(update_profile_handler)) .route("/profiles/:address", delete(delete_profile_handler)) .route("/auth/login", post(login_handler)) + // Project protected routes + .route("/projects", post(create_project_handler)) + .route("/projects/:id", patch(update_project_handler)) + .route("/projects/:id", delete(delete_project_handler)) .with_state(state.clone()); let protected_with_auth = if std::env::var("TEST_MODE").is_ok() { @@ -49,10 +72,16 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { protected_routes.layer(from_fn_with_state(state.clone(), eth_auth_layer)) }; + // Public routes (no authentication) let public_routes = Router::new() + // Profile public routes .route("/profiles/:address", get(get_profile_handler)) .route("/profiles", get(get_all_profiles_handler)) .route("/auth/nonce/:address", get(get_nonce_handler)) + // Project public routes + .route("/projects", get(list_projects_handler)) + .route("/projects/:id", get(get_project_handler)) + .route("/users/:address/projects", get(get_user_projects_handler)) .with_state(state.clone()); Router::new() @@ -65,7 +94,13 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { .layer( CorsLayer::new() .allow_origin(Any) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) .allow_headers(Any), ) .layer(DefaultBodyLimit::max(1024 * 1024)), @@ -75,22 +110,35 @@ pub async fn create_app(pool: sqlx::PgPool) -> Router { #[derive(Clone)] pub struct AppState { pub profile_repository: Arc, + pub project_repository: Arc, pub auth_service: Arc, } pub fn test_api(state: AppState) -> Router { + // Protected routes (require authentication) let protected_routes = Router::new() + // Profile protected routes .route("/profiles", post(create_profile_handler)) .route("/profiles/:address", put(update_profile_handler)) .route("/profiles/:address", delete(delete_profile_handler)) .route("/auth/login", post(login_handler)) + // Project protected routes + .route("/projects", post(create_project_handler)) + .route("/projects/:id", patch(update_project_handler)) + .route("/projects/:id", delete(delete_project_handler)) .with_state(state.clone()) .layer(from_fn(test_auth_layer)); + // Public routes (no authentication) let public_routes = Router::new() + // Profile public routes .route("/profiles/:address", get(get_profile_handler)) .route("/profiles", get(get_all_profiles_handler)) .route("/auth/nonce/:address", get(get_nonce_handler)) + // Project public routes + .route("/projects", get(list_projects_handler)) + .route("/projects/:id", get(get_project_handler)) + .route("/users/:address/projects", get(get_user_projects_handler)) .with_state(state.clone()); Router::new() @@ -103,7 +151,13 @@ pub fn test_api(state: AppState) -> Router { .layer( CorsLayer::new() .allow_origin(Any) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) .allow_headers(Any), ) .layer(DefaultBodyLimit::max(1024 * 1024)), diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index f7fc498..95fcd92 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -22,6 +22,19 @@ use crate::{ domain::value_objects::WalletAddress, }; +// Project imports +use crate::application::{ + commands::{ + create_project::create_project, delete_project::delete_project, + update_project::update_project, + }, + dtos::project_dtos::{CreateProjectRequest, UpdateProjectRequest}, + queries::{ + get_all_projects::get_all_projects, get_project::get_project, + get_projects_by_creator::get_projects_by_creator, + }, +}; + use super::{api::AppState, middlewares::VerifiedWallet}; /// Query parameters for listing projects @@ -114,18 +127,26 @@ pub async fn login_handler( .into_response(), } } -pub async fn create_project_handler( + +pub async fn list_projects_handler( State(state): State, - Extension(VerifiedWallet(wallet)): Extension, - Json(payload): Json, + Query(params): Query, ) -> impl IntoResponse { - match create_project(state.project_repository, wallet, payload).await { - Ok(project) => (StatusCode::CREATED, Json(project)).into_response(), + match get_all_projects( + state.project_repository.clone(), + params.status, + params.creator, + params.limit, + params.offset, + ) + .await + { + Ok(projects) => (StatusCode::OK, Json(projects)).into_response(), Err(e) => { - let status = if e.contains("profiles") { - StatusCode::FORBIDDEN - } else { + let status = if e.contains("Invalid status") { StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR }; (status, Json(serde_json::json!({"error": e}))).into_response() } @@ -136,36 +157,40 @@ pub async fn get_project_handler( State(state): State, Path(id): Path, ) -> impl IntoResponse { - match get_project(state.project_repository, id).await { - Ok(project) => Json(project).into_response(), - Err(e) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e}))).into_response(), + match get_project(state.project_repository.clone(), id).await { + Ok(project) => (StatusCode::OK, Json(project)).into_response(), + Err(e) => { + let status = if e.contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::BAD_REQUEST + }; + (status, Json(serde_json::json!({"error": e}))).into_response() + } } } -pub async fn get_all_projects_handler( +pub async fn get_user_projects_handler( State(state): State, - Query(query): Query, + Path(address): Path, ) -> impl IntoResponse { - match get_all_projects( - state.project_repository, - query.status, - query.creator, - query.limit, - query.offset, - ) - .await - { - Ok(projects) => Json(projects).into_response(), + match get_projects_by_creator(state.project_repository.clone(), address).await { + Ok(projects) => (StatusCode::OK, Json(projects)).into_response(), Err(e) => ( StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e})), + ) + .into_response(), + } +} - -pub async fn get_projects_by_creator_handler( +pub async fn create_project_handler( State(state): State, - Path(address): Path, + Extension(VerifiedWallet(verified_wallet)): Extension, + Json(request): Json, ) -> impl IntoResponse { - match get_projects_by_creator(state.project_repository, address).await { - Ok(projects) => Json(projects).into_response(), + match create_project(state.project_repository.clone(), verified_wallet, request).await { + Ok(project) => (StatusCode::CREATED, Json(project)).into_response(), Err(e) => ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": e})), @@ -174,13 +199,21 @@ pub async fn get_projects_by_creator_handler( } } +/// PATCH /projects/:id - Update a project (Protected, creator only) pub async fn update_project_handler( State(state): State, - Extension(VerifiedWallet(wallet)): Extension, + Extension(VerifiedWallet(verified_wallet)): Extension, Path(id): Path, - Json(payload): Json, + Json(request): Json, ) -> impl IntoResponse { - match update_project(state.project_repository, wallet, id, payload).await { + match update_project( + state.project_repository.clone(), + verified_wallet, + id, + request, + ) + .await + { Ok(project) => (StatusCode::OK, Json(project)).into_response(), Err(e) => { let status = if e.contains("not found") { @@ -195,13 +228,14 @@ pub async fn update_project_handler( } } +/// DELETE /projects/:id - Delete a project (Protected, creator only) pub async fn delete_project_handler( State(state): State, - Extension(VerifiedWallet(wallet)): Extension, + Extension(VerifiedWallet(verified_wallet)): Extension, Path(id): Path, ) -> impl IntoResponse { - match delete_project(state.project_repository, wallet, id).await { - Ok(_) => StatusCode::ACCEPTED.into_response(), + match delete_project(state.project_repository.clone(), verified_wallet, id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), Err(e) => { let status = if e.contains("not found") { StatusCode::NOT_FOUND From 4544556f802131113616540ba85a51537bfdb67e Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Thu, 11 Dec 2025 07:28:32 +0100 Subject: [PATCH 08/11] update and fix CI issues --- backend/tests/integration_github_handle.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index fa2723c..f957b7b 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -1,6 +1,8 @@ use guild_backend::application::dtos::profile_dtos::ProfileResponse; +use guild_backend::infrastructure::repositories::postgres_project_repository::PostgresProjectRepository; use guild_backend::presentation::api::{test_api, AppState}; use serde_json::json; +use std::sync::Arc; use tokio::net::TcpListener; #[tokio::test] @@ -17,9 +19,12 @@ async fn valid_github_handle_works() { 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 state = AppState { profile_repository, - auth_service: std::sync::Arc::new(auth_service), + project_repository, + auth_service: std::sync::Arc::new(auth_service), // ← WRAP IN Arc }; let app = test_api(state); @@ -85,9 +90,12 @@ async fn invalid_format_rejected() { 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 state = AppState { profile_repository, - auth_service: std::sync::Arc::new(auth_service), + project_repository, + auth_service: std::sync::Arc::new(auth_service), // ← WRAP IN Arc }; let app = test_api(state); @@ -160,9 +168,12 @@ async fn conflict_case_insensitive() { 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 state = AppState { profile_repository, - auth_service: std::sync::Arc::new(auth_service), + project_repository, + auth_service: std::sync::Arc::new(auth_service), // ← WRAP IN Arc }; let app = test_api(state); @@ -247,4 +258,4 @@ async fn conflict_case_insensitive() { let msg = err_json["error"].as_str().unwrap_or(""); assert!(msg.contains("already taken")); } -} +} \ No newline at end of file From e734d01c1d8ac7ff564a7aa0b16f488ed2b39332 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Thu, 11 Dec 2025 07:32:58 +0100 Subject: [PATCH 09/11] update and fix CI issues --- backend/tests/integration_github_handle.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index f957b7b..1fb113a 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -9,7 +9,7 @@ use tokio::net::TcpListener; async fn valid_github_handle_works() { std::env::set_var("TEST_MODE", "1"); let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgres://guild_user:guild_password@localhost:5433/guild_genesis".to_string() + "postgres://guild_user:guild_password@localhost:5432/guild_genesis".to_string() }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -80,7 +80,7 @@ async fn valid_github_handle_works() { async fn invalid_format_rejected() { std::env::set_var("TEST_MODE", "1"); let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgres://guild_user:guild_password@localhost:5433/guild_genesis".to_string() + "postgres://guild_user:guild_password@localhost:5432/guild_genesis".to_string() }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -158,7 +158,7 @@ async fn invalid_format_rejected() { async fn conflict_case_insensitive() { std::env::set_var("TEST_MODE", "1"); let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { - "postgres://guild_user:guild_password@localhost:5433/guild_genesis".to_string() + "postgres://guild_user:guild_password@localhost:5432/guild_genesis".to_string() }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); From d3da6b5a0dd9d8f6014a7de83c1b8444e8a11984 Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Thu, 11 Dec 2025 07:48:10 +0100 Subject: [PATCH 10/11] fix CI issues --- backend/tests/integration_github_handle.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/backend/tests/integration_github_handle.rs b/backend/tests/integration_github_handle.rs index 1fb113a..cdb7bfa 100644 --- a/backend/tests/integration_github_handle.rs +++ b/backend/tests/integration_github_handle.rs @@ -2,7 +2,7 @@ use guild_backend::application::dtos::profile_dtos::ProfileResponse; use guild_backend::infrastructure::repositories::postgres_project_repository::PostgresProjectRepository; use guild_backend::presentation::api::{test_api, AppState}; use serde_json::json; -use std::sync::Arc; +use std::sync::Arc; use tokio::net::TcpListener; #[tokio::test] @@ -24,7 +24,7 @@ async fn valid_github_handle_works() { let state = AppState { profile_repository, project_repository, - auth_service: std::sync::Arc::new(auth_service), // ← WRAP IN Arc + auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -54,7 +54,6 @@ async fn valid_github_handle_works() { .await .unwrap(); - // Accept either 200 or 201 assert_eq!(create_resp.status(), reqwest::StatusCode::CREATED); let body = create_resp.json::().await.unwrap(); @@ -95,7 +94,7 @@ async fn invalid_format_rejected() { let state = AppState { profile_repository, project_repository, - auth_service: std::sync::Arc::new(auth_service), // ← WRAP IN Arc + auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -124,7 +123,6 @@ async fn invalid_format_rejected() { .send() .await .unwrap(); - // Similar acceptance for create assert_eq!( create_resp.status(), reqwest::StatusCode::CREATED, @@ -147,7 +145,6 @@ async fn invalid_format_rejected() { assert_eq!(update_resp.status(), reqwest::StatusCode::BAD_REQUEST); - // Optionally, try parse message if provided if let Ok(err_json) = update_resp.json::().await { let msg = err_json["error"].as_str().unwrap_or(""); assert!(msg.contains("Invalid GitHub handle")); @@ -173,7 +170,7 @@ async fn conflict_case_insensitive() { let state = AppState { profile_repository, project_repository, - auth_service: std::sync::Arc::new(auth_service), // ← WRAP IN Arc + auth_service: std::sync::Arc::new(auth_service), }; let app = test_api(state); @@ -258,4 +255,4 @@ async fn conflict_case_insensitive() { let msg = err_json["error"].as_str().unwrap_or(""); assert!(msg.contains("already taken")); } -} \ No newline at end of file +} From 7ffcb7f68523a70d4f1a25185c5b1ddf6c89602f Mon Sep 17 00:00:00 2001 From: Ifeoluwa Date: Fri, 9 Jan 2026 18:00:29 +0100 Subject: [PATCH 11/11] fix merge conflicts --- backend/src/presentation/handlers.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/presentation/handlers.rs b/backend/src/presentation/handlers.rs index 95fcd92..505415e 100644 --- a/backend/src/presentation/handlers.rs +++ b/backend/src/presentation/handlers.rs @@ -128,6 +128,7 @@ pub async fn login_handler( } } +/// GET /projects - List all projects with optional filters pub async fn list_projects_handler( State(state): State, Query(params): Query, @@ -153,6 +154,7 @@ pub async fn list_projects_handler( } } +// GET /projects/:id - Get a single project by ID pub async fn get_project_handler( State(state): State, Path(id): Path, @@ -170,6 +172,7 @@ pub async fn get_project_handler( } } +/// GET /users/:address/projects - Get all projects by a creator pub async fn get_user_projects_handler( State(state): State, Path(address): Path, @@ -184,6 +187,7 @@ pub async fn get_user_projects_handler( } } +/// POST /projects - Create a new project (Protected) pub async fn create_project_handler( State(state): State, Extension(VerifiedWallet(verified_wallet)): Extension,