diff --git a/anon/backend/.sqlx/query-0cc145bb3fb0d46711d52aa7eff1407375da88dbf55a0ba7f2982c70e40ad7c9.json b/anon/backend/.sqlx/query-0cc145bb3fb0d46711d52aa7eff1407375da88dbf55a0ba7f2982c70e40ad7c9.json new file mode 100644 index 00000000..94302302 --- /dev/null +++ b/anon/backend/.sqlx/query-0cc145bb3fb0d46711d52aa7eff1407375da88dbf55a0ba7f2982c70e40ad7c9.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n id as appeal_id, actor, review_id, reason, status, resolution_note, created_at::text\n FROM appeals\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "appeal_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "actor", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "review_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "reason", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "resolution_note", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + null + ] + }, + "hash": "0cc145bb3fb0d46711d52aa7eff1407375da88dbf55a0ba7f2982c70e40ad7c9" +} diff --git a/anon/backend/.sqlx/query-92172cfcb47356c57914ec94b2a509d337b751ce408a13735adc53c37deeff5c.json b/anon/backend/.sqlx/query-92172cfcb47356c57914ec94b2a509d337b751ce408a13735adc53c37deeff5c.json new file mode 100644 index 00000000..465feefb --- /dev/null +++ b/anon/backend/.sqlx/query-92172cfcb47356c57914ec94b2a509d337b751ce408a13735adc53c37deeff5c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE appeals SET reason = $1, status = $2 , resolution_note = $3 WHERE id = $4 ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "92172cfcb47356c57914ec94b2a509d337b751ce408a13735adc53c37deeff5c" +} diff --git a/anon/backend/.sqlx/query-b2fb4c61d3d5fd1f431652603f762da9e39cd9f4a24bd823fdbc9849801cccdd.json b/anon/backend/.sqlx/query-b2fb4c61d3d5fd1f431652603f762da9e39cd9f4a24bd823fdbc9849801cccdd.json new file mode 100644 index 00000000..a2906eef --- /dev/null +++ b/anon/backend/.sqlx/query-b2fb4c61d3d5fd1f431652603f762da9e39cd9f4a24bd823fdbc9849801cccdd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wallet\n FROM users\n WHERE wallet = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "wallet", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b2fb4c61d3d5fd1f431652603f762da9e39cd9f4a24bd823fdbc9849801cccdd" +} diff --git a/anon/backend/.sqlx/query-d73d4b90678c6f0f77df4732322c1648b6fef8a4c21cc83de707695ef307b969.json b/anon/backend/.sqlx/query-d73d4b90678c6f0f77df4732322c1648b6fef8a4c21cc83de707695ef307b969.json new file mode 100644 index 00000000..6e496c30 --- /dev/null +++ b/anon/backend/.sqlx/query-d73d4b90678c6f0f77df4732322c1648b6fef8a4c21cc83de707695ef307b969.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO appeals (actor, review_id, reason, status, resolution_note)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d73d4b90678c6f0f77df4732322c1648b6fef8a4c21cc83de707695ef307b969" +} diff --git a/anon/backend/migrations/0005__appeals.sql b/anon/backend/migrations/0005__appeals.sql new file mode 100644 index 00000000..adbbcc2c --- /dev/null +++ b/anon/backend/migrations/0005__appeals.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS appeals ( + id BIGSERIAL PRIMARY KEY, + + actor TEXT NOT NULL UNIQUE, + + review_id BIGSERIAL NOT NULL REFERENCES reviews(id) ON DELETE CASCADE, + + reason TEXT NOT NULL, + + status VARCHAR(20) NOT NULL CHECK (status IN ('open', 'accepted', 'rejected')) DEFAULT 'open', + + resolution_note TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_appeals_review_id ON appeals (review_id); + +CREATE INDEX idx_appeals_actor ON appeals (actor); + +CREATE INDEX idx_appeals_status ON appeals (status); + +CREATE INDEX idx_appeals_review_status ON appeals (review_id, status); diff --git a/anon/backend/src/lib.rs b/anon/backend/src/lib.rs index 1a748d53..818e1048 100644 --- a/anon/backend/src/lib.rs +++ b/anon/backend/src/lib.rs @@ -15,6 +15,7 @@ pub mod middlewares { } pub mod routes { + pub mod appeals; pub mod generate; pub mod health; pub mod register; diff --git a/anon/backend/src/libs/apispec.rs b/anon/backend/src/libs/apispec.rs index 1f7ce183..10821361 100644 --- a/anon/backend/src/libs/apispec.rs +++ b/anon/backend/src/libs/apispec.rs @@ -28,7 +28,11 @@ impl Modify for SecurityAddon { crate::routes::health::healthz, crate::routes::generate::generate_contract, crate::routes::generate::list_generated_contracts, - crate::routes::reviews::list_reviews + crate::routes::reviews::list_reviews, + crate::routes::appeals::create_appeal, + crate::routes::appeals::get_appeal, + crate::routes::appeals::get_admin_appeal, + crate::routes::appeals::update_admin_appeal, ), components( schemas( @@ -45,7 +49,13 @@ impl Modify for SecurityAddon { crate::routes::generate::GeneratedContractsListRes, // Reviews crate::routes::reviews::ReviewItem, - crate::routes::reviews::ReviewsListRes + crate::routes::reviews::ReviewsListRes, + // Apeals + crate::routes::appeals::CreateAppealRequest, + crate::routes::appeals::UpdateAppealRequest, + crate::routes::appeals::AdminAppealRequest, + crate::routes::appeals::GetAppealItems, + crate::routes::appeals::GetAppealRes, ) ), modifiers(&SecurityAddon), @@ -53,7 +63,8 @@ impl Modify for SecurityAddon { (name = "health", description = "Health check endpoints"), (name = "auth", description = "Authentication & registration endpoints"), (name = "contracts", description = "Generated contracts endpoints"), - (name = "reviews", description = "Reviews listing endpoints") + (name = "reviews", description = "Reviews listing endpoints"), + (name = "appeals", description = "Adding an appeal endpoint") ) )] pub struct ApiDoc; diff --git a/anon/backend/src/main.rs b/anon/backend/src/main.rs index 64df9e8f..2bd7b5b4 100644 --- a/anon/backend/src/main.rs +++ b/anon/backend/src/main.rs @@ -7,7 +7,7 @@ use axum::{ header::{AUTHORIZATION, CONTENT_TYPE, LOCATION}, }, response::IntoResponse, - routing::{get, post}, + routing::{get, post, put}, }; use tokio::net::TcpListener; use tower_http::{ @@ -56,6 +56,13 @@ async fn main() { get(routes::generate::list_generated_contracts), ) .route("/reviews", get(routes::reviews::list_reviews)) + .route("/appeals", post(routes::appeals::create_appeal)) + .route("/appeals/{id}", get(routes::appeals::get_appeal)) + .route("/admin/appeals", get(routes::appeals::get_admin_appeal)) + .route( + "/admin/appeals/{id}", + put(routes::appeals::update_admin_appeal), + ) // Swagger UI at /docs and OpenAPI JSON at /api-docs/openapi.json .merge(SwaggerUi::new("/docs").url( "/api-docs/openapi.json", diff --git a/anon/backend/src/routes/appeals.rs b/anon/backend/src/routes/appeals.rs new file mode 100644 index 00000000..1c113159 --- /dev/null +++ b/anon/backend/src/routes/appeals.rs @@ -0,0 +1,320 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::{Deserialize, Serialize}; +use sqlx::Arguments; +use utoipa::ToSchema; + +use crate::{ + libs::{db::AppState, error::ApiError, wallet}, + middlewares::auth::AuthUser, +}; + +#[derive(Deserialize, ToSchema)] +pub struct CreateAppealRequest { + pub actor: String, + pub review_id: i64, + pub reason: String, + pub status: String, + pub resolution_note: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateAppealRequest { + pub reason: Option, + pub status: Option, + pub resolution_note: Option, +} + +#[derive(Deserialize, ToSchema, Serialize)] +pub struct GetAppealItems { + pub appeal_id: i64, + pub actor: String, + pub review_id: i64, + pub reason: String, + pub status: String, + pub resolution_note: Option, + pub created_at: Option, +} +#[derive(Deserialize, ToSchema, Serialize, utoipa::IntoParams)] +pub struct AdminAppealRequest { + pub status: Option, + pub reason: Option, + pub cursor: Option, + pub limit: Option, +} + +type AdminAppeal = ( + i64, + String, + i64, + String, + String, + Option, + Option, +); + +#[derive(Deserialize, ToSchema, Serialize)] +pub struct GetAppealRes { + items: Vec, + next_cursor: i64, +} + +#[utoipa::path( + post, + path = "/appeals", + tag = "appeals", + request_body = CreateAppealRequest, + responses( + (status = 201, description = "Appeal created successfully", body = CreateAppealRequest), + (status = 400, description = "Invalid request", body = crate::libs::error::ErrorBody), + (status = 404, description = "User not found", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn create_appeal( + State(AppState { pool }): State, + AuthUser { wallet }: AuthUser, + Json(req): Json, +) -> Result { + tracing::info!( + "Generating appeal for review_id: {}, reason: {}", + req.review_id, + req.reason + ); + let rec = sqlx::query!( + r#"SELECT u.id, u.wallet, u.created_at, p.referral_code + FROM users u + LEFT JOIN profiles p ON p.user_id = u.id + WHERE u.wallet = $1"#, + wallet + ) + .fetch_optional(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))?; + + rec.ok_or(ApiError::NotFound("user not found"))?; + + let actor = req.actor; + + let normalized_wallet = wallet::normalize_and_validate(&actor)?; + + sqlx::query!( + r#"INSERT INTO appeals (actor, review_id, reason, status, resolution_note) + VALUES ($1, $2, $3, $4, $5) + RETURNING id"#, + normalized_wallet, + req.review_id, + req.reason, + req.status, + req.resolution_note + ) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("❌ Failed to insert appeal: {:?}", e); + crate::libs::error::map_sqlx_error(&e) + })?; + + Ok((StatusCode::CREATED, Json("Appeal created successfully"))) +} + +#[utoipa::path( + get, + path = "/appeals/{id}", + tag = "appeals", + responses( + (status = 201, body = GetAppealItems), + (status = 400, description = "Invalid request", body = crate::libs::error::ErrorBody), + (status = 404, description = "User not found", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn get_appeal( + State(AppState { pool }): State, + AuthUser { wallet }: AuthUser, + Path(appeal_id): Path, +) -> Result { + sqlx::query!( + r#"SELECT wallet + FROM users + WHERE wallet = $1"#, + wallet + ) + .fetch_optional(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))? + .ok_or(ApiError::NotFound("user not found"))?; + + let appeal: GetAppealItems = sqlx::query_as!( + GetAppealItems, + r#"SELECT + id as appeal_id, actor, review_id, reason, status, resolution_note, created_at::text + FROM appeals + WHERE id = $1"#, + appeal_id + ) + .fetch_optional(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))? + .ok_or(ApiError::NotFound("appeal not found"))?; + + if wallet == appeal.actor { + Ok(Json(appeal)) + } else { + Err(ApiError::Unauthorized("not allowed to view this appeal")) + } +} + +#[utoipa::path( + get, + path = "/admin/appeals", + tag = "appeals", + params(AdminAppealRequest), + responses( + (status = 201, description = "Successfully gets appeals based on params", body = GetAppealRes), + (status = 400, description = "Invalid request", body = crate::libs::error::ErrorBody), + (status = 404, description = "User not found", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn get_admin_appeal( + State(AppState { pool }): State, + Query(params): Query, +) -> Result { + let limit = params.limit.unwrap_or(20).clamp(1, 50); + + let mut dynamic_sql = String::from( + r#"SELECT id as appeal_id, actor, review_id, reason, status, resolution_note, created_at::text FROM appeals WHERE 1 = 1"#, + ); + + let mut args: sqlx::postgres::PgArguments = sqlx::postgres::PgArguments::default(); + let mut i: i32 = 1; + + println!("i before {i}"); + + if let Some(status) = ¶ms.status { + dynamic_sql.push_str(&format!(" AND status = ${i}")); + args.add(status) + .map_err(|_| ApiError::Internal("Failed to add status args"))?; + i += 1; + } + + if let Some(reason) = ¶ms.reason { + dynamic_sql.push_str(&format!(" AND reason = ${i}")); + args.add(reason) + .map_err(|_| ApiError::Internal("Failed to add reason args"))?; + i += 1; + } + + if let Some(cursor) = ¶ms.cursor { + dynamic_sql.push_str(&format!(" AND id > ${i}")); + args.add(cursor) + .map_err(|_| ApiError::Internal("Failed to add cursor args"))?; + i += 1; + } + + dynamic_sql.push_str(" ORDER BY created_at DESC, id DESC"); + + dynamic_sql.push_str(&format!(" LIMIT ${}", i)); + args.add(&limit) + .map_err(|_| crate::libs::error::ApiError::Internal("Failed to add limit arg"))?; + + let rows: Vec = sqlx::query_as_with(&dynamic_sql, args) + .fetch_all(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))?; + + let items: Vec = rows + .into_iter() + .map( + |(appeal_id, actor, review_id, reason, status, resolution_note, created_at)| { + GetAppealItems { + appeal_id, + actor, + review_id, + reason, + status, + resolution_note, + created_at, + } + }, + ) + .collect(); + + let next_cursor = items.last().unwrap().review_id; + + Ok(Json(GetAppealRes { items, next_cursor })) +} + +#[utoipa::path( + put, + path = "/admin/appeals/{id}", + tag = "appeals", + request_body = UpdateAppealRequest, + responses( + (status = 201, description = "Successfully updates an admin appeal"), + (status = 400, description = "Invalid request", body = crate::libs::error::ErrorBody), + (status = 404, description = "User not found", body = crate::libs::error::ErrorBody), + (status = 500, description = "Internal error", body = crate::libs::error::ErrorBody) + ) +)] +pub async fn update_admin_appeal( + State(AppState { pool }): State, + AuthUser { wallet }: AuthUser, + Path(appeal_id): Path, + Json(req): Json, +) -> Result { + tracing::info!("Updating appeal: {}", appeal_id); + + let rec = sqlx::query!( + r#"SELECT u.id, u.wallet, u.created_at, p.referral_code + FROM users u + LEFT JOIN profiles p ON p.user_id = u.id + WHERE u.wallet = $1"#, + wallet + ) + .fetch_optional(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))?; + + rec.ok_or(ApiError::NotFound("user not found"))?; + + let appeal = sqlx::query_as!( + GetAppealItems, + r#"SELECT + id as appeal_id, actor, review_id, reason, status, resolution_note, created_at::text + FROM appeals + WHERE id = $1"#, + appeal_id + ) + .fetch_optional(&pool) + .await + .map_err(|e| crate::libs::error::map_sqlx_error(&e))? + .ok_or(ApiError::NotFound("appeal not found"))?; + + let reason = req.reason.unwrap_or(appeal.reason); + let status = req.status.unwrap_or(appeal.status); + let resolution_note = req + .resolution_note + .unwrap_or(appeal.resolution_note.unwrap_or_default()); + println!("details {reason} {status} {resolution_note} {appeal_id}"); + sqlx::query!( + r#"UPDATE appeals SET reason = $1, status = $2 , resolution_note = $3 WHERE id = $4 "#, + reason, + status, + resolution_note, + appeal_id + ) + .execute(&pool) + .await + .map_err(|e| { + tracing::error!("❌ Failed to update appeal: {:?}", e); + crate::libs::error::map_sqlx_error(&e) + })?; + + Ok(Json("APPEAL UPDATED")) +}