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
6 changes: 3 additions & 3 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ edition = "2021"

[dependencies]
tokio = { version = "1.0", features = ["full"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
axum = { version = "0.8", features = ["json", "multipart", "macros", "ws"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "util", "set-header"] }
tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
Expand All @@ -29,7 +28,8 @@ jsonwebtoken = "9.0"
base64 = "0.21"
stellar-strkey = "0.0.8"
ed25519-dalek = { version = "2.1", features = ["pkcs8", "rand_core"] }

printpdf = "0.7"
redis = { version = "0.27", features = ["tokio-comp"] }
dashmap = "6"
prometheus = { version = "0.13", features = ["process"] }
once_cell = "1.19"
1 change: 1 addition & 0 deletions backend/migrations/20260627000000_add_ping_logs.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ping_logs;
9 changes: 9 additions & 0 deletions backend/migrations/20260627000000_add_ping_logs.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE ping_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES plans (id) ON DELETE CASCADE,
pinged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
accrued_yield_snapshot NUMERIC(78, 4) NOT NULL DEFAULT 0
);

CREATE INDEX ping_logs_plan_id_idx ON ping_logs (plan_id);
CREATE INDEX ping_logs_pinged_at_idx ON ping_logs (pinged_at);
165 changes: 160 additions & 5 deletions backend/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ use crate::middleware::{
};
use axum::http::{HeaderValue, Method};
use axum::{
extract::{Query, State},
http::header::HeaderName,
http::StatusCode,
body::Body,
extract::{Path, Query, State},
http::{header, header::HeaderName, StatusCode},
middleware::from_fn,
response::IntoResponse,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
Expand All @@ -20,7 +20,7 @@ use tower_http::cors::CorsLayer;
use tracing::error;
use uuid::Uuid;

use crate::auth::signature_auth_middleware;
use crate::auth::{jwt_auth_middleware, signature_auth_middleware};
use crate::cache::PlanCache;
use crate::kyc_webhook::kyc_webhook_handler;
use crate::metrics::{latency_middleware, metrics_handler};
Expand Down Expand Up @@ -140,6 +140,11 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/api/plans/payout", post(trigger_payout))
.route_layer(from_fn(signature_auth_middleware));

// Admin routes requiring JWT authentication
let admin_routes = Router::new()
.route("/api/plans/:id/report", get(get_plan_report))
.route_layer(from_fn(jwt_auth_middleware));

// Public or admin routes
let public_routes = Router::new()
.route("/api/plans", get(get_plans))
Expand All @@ -154,6 +159,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {

Router::new()
.merge(user_routes)
.merge(admin_routes)
.merge(public_routes)
.layer(axum::middleware::from_fn(move |req, next| {
rate_limit_middleware(req, next, store.clone(), config.clone())
Expand Down Expand Up @@ -195,6 +201,12 @@ pub struct BeneficiaryRow {
pub fiat_anchor_info: String,
}

#[derive(Debug, Clone, sqlx::FromRow)]
pub struct PingLogRow {
pub pinged_at: chrono::DateTime<chrono::Utc>,
pub accrued_yield_snapshot: rust_decimal::Decimal,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanResponse {
pub id: uuid::Uuid,
Expand Down Expand Up @@ -1375,3 +1387,146 @@ async fn get_kyc_requirements() -> impl IntoResponse {

(StatusCode::OK, Json(response))
}

/// Handler: GET /api/plans/:id/report
/// Generates a PDF audit report for the given plan.
/// Requires a valid JWT (role = admin) via Bearer token.
pub async fn get_plan_report(
State(state): State<Arc<AppState>>,
Path(plan_id): Path<uuid::Uuid>,
) -> Response {
// 1. Fetch the plan
let plan = match sqlx::query_as::<_, PlanRow>(
r#"
SELECT id, owner_address, token_address, amount, grace_period,
grace_period_seconds, earn_yield, last_ping, is_active,
status, yield_rate_bps, accrued_yield, created_at
FROM plans
WHERE id = $1
"#,
)
.bind(plan_id)
.fetch_optional(&state.db_pool)
.await
{
Ok(Some(p)) => p,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Plan not found" })),
)
.into_response()
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Database error: {}", e) })),
)
.into_response()
}
};

// 2. Fetch beneficiaries
let beneficiary_rows = match sqlx::query_as::<_, BeneficiaryRow>(
"SELECT id, plan_id, wallet_address, allocation_bps, fiat_anchor_info \
FROM beneficiaries WHERE plan_id = $1 ORDER BY allocation_bps DESC",
)
.bind(plan_id)
.fetch_all(&state.db_pool)
.await
{
Ok(rows) => rows,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to load beneficiaries: {}", e) })),
)
.into_response()
}
};

// 3. Fetch ping logs
let ping_rows = match sqlx::query_as::<_, PingLogRow>(
"SELECT pinged_at, accrued_yield_snapshot FROM ping_logs \
WHERE plan_id = $1 ORDER BY pinged_at ASC",
)
.bind(plan_id)
.fetch_all(&state.db_pool)
.await
{
Ok(rows) => rows,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to load ping logs: {}", e) })),
)
.into_response()
}
};

// 4. Build PDF data structs (owned, so they can cross the thread boundary)
let report_data = crate::pdf::PlanReportData {
plan_id: plan.id.to_string(),
owner_address: plan.owner_address.clone(),
token_address: plan.token_address.clone(),
amount: plan.amount.to_string(),
status: plan.status.clone(),
earn_yield: plan.earn_yield,
yield_rate_bps: plan.yield_rate_bps,
accrued_yield: plan.accrued_yield.to_string(),
created_at: plan.created_at.to_rfc3339(),
grace_period_seconds: plan.grace_period_seconds,
beneficiaries: beneficiary_rows
.into_iter()
.map(|b| crate::pdf::BeneficiaryData {
wallet_address: b.wallet_address,
allocation_bps: b.allocation_bps,
fiat_anchor_info: b.fiat_anchor_info,
})
.collect(),
ping_logs: ping_rows
.into_iter()
.map(|p| crate::pdf::PingLogData {
pinged_at: p.pinged_at.to_rfc3339(),
accrued_yield_snapshot: p.accrued_yield_snapshot.to_string(),
})
.collect(),
};

// 5. Generate PDF in a blocking thread (printpdf is CPU-bound / not async)
let pdf_bytes = match tokio::task::spawn_blocking(move || crate::pdf::generate(report_data))
.await
{
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("PDF generation failed: {}", e) })),
)
.into_response()
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("PDF task panicked: {}", e) })),
)
.into_response()
}
};

// 6. Return the PDF as a downloadable attachment
let filename = format!("plan-{}-report.pdf", plan_id);
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/pdf".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from(pdf_bytes),
)
.into_response()
}

6 changes: 1 addition & 5 deletions backend/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,8 @@ pub enum AuthError {

impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let status = match self {
AuthError::TokenExpired => StatusCode::UNAUTHORIZED,
_ => StatusCode::UNAUTHORIZED,
};
let body = serde_json::json!({ "error": self.to_string() });
(status, Json(body)).into_response()
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
}
}

Expand Down
1 change: 1 addition & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod inactivity_watchdog;
pub mod kyc_webhook;
pub mod metrics;
pub mod middleware;
pub mod pdf;
pub mod stellar_anchor;
pub mod telemetry;
pub mod ws;
Expand Down
Loading
Loading