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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/http/project/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,71 @@ pub fn validate_bounty_expiry_date(date: &Option<DateTime<Utc>>, _context: &())
}
Ok(())
}

#[derive(Debug, Deserialize, Validate)]
pub struct ListProjectsQuery {
#[garde(custom(validate_starknet_address_optional))]
pub owner_address: Option<String>,
#[garde(skip)]
pub active_only: Option<bool>, // Filter by closed_at being null
#[garde(skip)]
pub has_bounty: Option<bool>, // Filter by bounty_amount being not null
#[garde(custom(validate_sort_by))]
pub sort_by: Option<String>, // "created_at" (default), "bounty_amount", "name"
#[garde(custom(validate_sort_order))]
pub sort_order: Option<String>, // "desc" (default), "asc"
#[garde(range(min = 1, max = 20))]
pub limit: Option<i64>, // Max 20, default 10
#[garde(skip)]
pub offset: Option<i64>, // Default 0
}

#[derive(Debug, Serialize)]
pub struct ProjectListItem {
pub id: Uuid,
pub name: String,
pub owner_address: String,
pub contract_address: String,
pub description: String,
pub is_verified: bool,
pub verification_date: Option<DateTime<Utc>>,
pub repository_url: Option<String>,
pub bounty_amount: Option<BigDecimal>,
pub bounty_currency: Option<String>,
pub bounty_expiry_date: Option<DateTime<Utc>>,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub closed_at: Option<DateTime<Utc>>,
}

#[derive(Debug, Serialize)]
pub struct ListProjectsResponse {
pub projects: Vec<ProjectListItem>,
pub total_count: i64,
pub has_next: bool,
}

pub fn validate_starknet_address_optional(addr: &Option<String>, _context: &()) -> garde::Result {
if let Some(addr) = addr {
validate_starknet_address(addr, _context)?;
}
Ok(())
}

pub fn validate_sort_by(sort_by: &Option<String>, _context: &()) -> garde::Result {
if let Some(sort_by) = sort_by {
if !["created_at", "bounty_amount", "name"].contains(&sort_by.as_str()) {
return Err(garde::Error::new("Invalid sort_by field"));
}
}
Ok(())
}

pub fn validate_sort_order(sort_order: &Option<String>, _context: &()) -> garde::Result {
if let Some(sort_order) = sort_order {
if !["asc", "desc"].contains(&sort_order.as_str()) {
return Err(garde::Error::new("Invalid sort_order"));
}
}
Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great Job with the Domain Modelling and Validation.

185 changes: 185 additions & 0 deletions src/http/project/list_projects.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use crate::{AppState, Result};

Check warning on line 1 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 1 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
use crate::http::project::{ListProjectsQuery, ListProjectsResponse, ProjectListItem};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, But there's a CI complaint here, you should Lint and Format your code when you're done, it does you some good. 🪨

use axum::{Json, extract::{Query, State}};
use garde::Validate;
use sqlx::Row;

#[tracing::instrument(name = "list_projects", skip(state))]
pub async fn list_projects_handler(
State(state): State<AppState>,
Query(params): Query<ListProjectsQuery>,
) -> Result<Json<ListProjectsResponse>> {
params.validate()?;

let limit = params.limit.unwrap_or(10);
let offset = params.offset.unwrap_or(0);
let sort_by = params.sort_by.as_deref().unwrap_or("created_at");
let sort_order = params.sort_order.as_deref().unwrap_or("desc");

// Build the ORDER BY clause
let order_by = match sort_by {
"bounty_amount" => format!("p.bounty_amount {} NULLS LAST", sort_order.to_uppercase()),
"name" => format!("p.name {}", sort_order.to_uppercase()),
_ => format!("p.created_at {}", sort_order.to_uppercase()),
};

Check warning on line 25 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 25 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
// Build query based on filters
let (total_count_query, projects_query) = if let Some(_owner_address) = &params.owner_address {
// Filter by owner address
let base_conditions = "WHERE p.owner_address = $1";
let mut additional_conditions = Vec::new();

if params.active_only.unwrap_or(false) {
additional_conditions.push("p.closed_at IS NULL");
}

Check warning on line 35 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 35 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
if params.has_bounty.unwrap_or(false) {
additional_conditions.push("p.bounty_amount IS NOT NULL");
additional_conditions.push("p.closed_at IS NULL");
}

Check warning on line 39 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 39 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

let where_clause = if additional_conditions.is_empty() {
base_conditions.to_string()
} else {
format!("{} AND {}", base_conditions, additional_conditions.join(" AND "))

Check warning on line 44 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 44 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
};

let count_query = format!(

Check failure on line 47 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string

Check failure on line 47 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string
"SELECT COUNT(*) FROM projects p {}",
where_clause
);

let projects_query = format!(

Check failure on line 52 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string

Check failure on line 52 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string
r#"
SELECT
p.id, p.name, p.owner_address, p.contract_address, p.description,
p.is_verified, p.verification_date, p.repository_url, p.bounty_amount,
p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at,
COALESCE(
array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL),
ARRAY[]::text[]
) as tags
FROM projects p
LEFT JOIN project_tags pt ON p.id = pt.project_id
LEFT JOIN tags t ON pt.tag_id = t.id
{}
GROUP BY p.id, p.name, p.owner_address, p.contract_address, p.description,
p.is_verified, p.verification_date, p.repository_url, p.bounty_amount,
p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at
ORDER BY {}
LIMIT $2 OFFSET $3
"#,
where_clause, order_by
);

(count_query, projects_query)
} else {

Check warning on line 76 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 76 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
// No owner filter
let mut conditions = Vec::new();

if params.active_only.unwrap_or(false) {
conditions.push("p.closed_at IS NULL");
}

Check warning on line 83 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 83 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
if params.has_bounty.unwrap_or(false) {
conditions.push("p.bounty_amount IS NOT NULL");
conditions.push("p.closed_at IS NULL");
}

Check warning on line 87 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 87 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

let where_clause = if conditions.is_empty() {
String::new()
} else {
format!("WHERE {}", conditions.join(" AND "))

Check warning on line 92 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 92 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
};

let count_query = format!(

Check failure on line 95 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string

Check failure on line 95 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string
"SELECT COUNT(*) FROM projects p {}",
where_clause
);

let projects_query = format!(

Check failure on line 100 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string

Check failure on line 100 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Clippy

variables can be used directly in the `format!` string
r#"
SELECT
p.id, p.name, p.owner_address, p.contract_address, p.description,
p.is_verified, p.verification_date, p.repository_url, p.bounty_amount,
p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at,
COALESCE(
array_agg(t.name ORDER BY t.name) FILTER (WHERE t.name IS NOT NULL),
ARRAY[]::text[]
) as tags
FROM projects p
LEFT JOIN project_tags pt ON p.id = pt.project_id
LEFT JOIN tags t ON pt.tag_id = t.id
{}
GROUP BY p.id, p.name, p.owner_address, p.contract_address, p.description,
p.is_verified, p.verification_date, p.repository_url, p.bounty_amount,
p.bounty_currency, p.bounty_expiry_date, p.created_at, p.closed_at
ORDER BY {}
LIMIT $1 OFFSET $2
"#,
where_clause, order_by
);

(count_query, projects_query)
};

// Execute count query
let total_count: i64 = if let Some(owner_address) = &params.owner_address {
sqlx::query_scalar(&total_count_query)
.bind(owner_address)
.fetch_one(&state.db.pool)
.await?
} else {
sqlx::query_scalar(&total_count_query)
.fetch_one(&state.db.pool)
.await?
};

// Execute projects query
let rows = if let Some(owner_address) = &params.owner_address {
sqlx::query(&projects_query)
.bind(owner_address)
.bind(limit)
.bind(offset)
.fetch_all(&state.db.pool)
.await?
} else {
sqlx::query(&projects_query)
.bind(limit)
.bind(offset)
.fetch_all(&state.db.pool)
.await?
};

let projects: Vec<ProjectListItem> = rows
.into_iter()

Check warning on line 155 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs

Check warning on line 155 in src/http/project/list_projects.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/FortiChain-Server/FortiChain-Server/src/http/project/list_projects.rs
.map(|row| {
let tags: Vec<String> = row.get::<Vec<String>, _>("tags");

ProjectListItem {
id: row.get("id"),
name: row.get("name"),
owner_address: row.get("owner_address"),
contract_address: row.get("contract_address"),
description: row.get("description"),
is_verified: row.get("is_verified"),
verification_date: row.get("verification_date"),
repository_url: row.get("repository_url"),
bounty_amount: row.get("bounty_amount"),
bounty_currency: row.get("bounty_currency"),
bounty_expiry_date: row.get("bounty_expiry_date"),
tags,
created_at: row.get("created_at"),
closed_at: row.get("closed_at"),
}
})
.collect();

let has_next = (offset + limit) < total_count;

Ok(Json(ListProjectsResponse {
projects,
total_count,
has_next,
}))
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as this goes, a couple of things could be reused, but it's off to a great start. 🪨

5 changes: 5 additions & 0 deletions src/http/project/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod close_project;
mod create_project;
mod domain;
mod list_projects;
mod project_detail_view;
mod shared;
mod verify_project;
Expand Down Expand Up @@ -31,4 +32,8 @@ pub(crate) fn router() -> Router<AppState> {
"/projects/{project_id}",
get(project_detail_view::get_project_detail_view),
)
.route(
"/projects",
get(list_projects::list_projects_handler),
)
}
Loading
Loading