first commit
This commit is contained in:
28
main-api/Cargo.toml
Normal file
28
main-api/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "main-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "main-api"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
shared = { path = "../shared" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
validator = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
15
main-api/Dockerfile
Normal file
15
main-api/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM rust:1.94-slim as builder
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ ./crates/
|
||||
COPY db/ ./db/
|
||||
COPY .sqlx/ ./.sqlx/
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cargo build --release --bin main-api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/main-api /usr/local/bin/main-api
|
||||
EXPOSE 3000
|
||||
CMD ["main-api"]
|
||||
71
main-api/src/main.rs
Normal file
71
main-api/src/main.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
mod middleware;
|
||||
mod models;
|
||||
mod routes;
|
||||
|
||||
use axum::{
|
||||
middleware as axum_middleware,
|
||||
routing::{get, patch, post},
|
||||
Router,
|
||||
};
|
||||
use middleware::JwtConfig;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "main_api=debug,tower_http=debug".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
|
||||
let db = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
|
||||
let jwt_config = JwtConfig { secret: jwt_secret };
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_headers(Any)
|
||||
.allow_methods(Any);
|
||||
|
||||
// Public routes (no JWT required)
|
||||
let public_routes = Router::new()
|
||||
.route("/reports/near", get(routes::reports::get_reports_near))
|
||||
.route("/feed", get(routes::feed::get_feed))
|
||||
.route("/poi/fuel", get(routes::poi::get_fuel_poi))
|
||||
.route("/poi/repair", get(routes::poi::get_repair_poi))
|
||||
.with_state(db.clone());
|
||||
|
||||
// Protected routes (JWT required)
|
||||
let protected_routes = Router::new()
|
||||
.route("/reports", post(routes::reports::create_report))
|
||||
.route("/reports/{id}/upvote", post(routes::reports::upvote_report))
|
||||
.route("/reports/{id}/flag", post(routes::reports::flag_report))
|
||||
.route("/service-requests", post(routes::service_requests::create_service_request))
|
||||
.route("/service-requests/open", get(routes::service_requests::get_open_service_requests))
|
||||
.route("/service-requests/{id}/claim", patch(routes::service_requests::claim_service_request))
|
||||
.with_state(db)
|
||||
.layer(axum_middleware::from_fn_with_state(jwt_config, middleware::jwt_auth));
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let addr = "0.0.0.0:3000";
|
||||
tracing::info!("main-api listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
39
main-api/src/middleware.rs
Normal file
39
main-api/src/middleware.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use shared::{errors::AppError, models::Claims};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
pub async fn jwt_auth(
|
||||
State(config): State<JwtConfig>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid Authorization format".to_string()))?;
|
||||
|
||||
let claims = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|e| AppError::Unauthorized(format!("Invalid token: {e}")))?
|
||||
.claims;
|
||||
|
||||
req.extensions_mut().insert(claims);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
29
main-api/src/models.rs
Normal file
29
main-api/src/models.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::models::{ReportType, ServiceStatus};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub handle: String,
|
||||
pub report_type: ReportType,
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
pub text: String,
|
||||
pub upvotes: i32,
|
||||
pub flags: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ServiceRequest {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub description: String,
|
||||
pub truck_details: serde_json::Value,
|
||||
pub status: ServiceStatus,
|
||||
pub claimed_by: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
74
main-api/src/routes/feed.rs
Normal file
74
main-api/src/routes/feed.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{errors::AppError, models::ReportType};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeedQuery {
|
||||
#[serde(default = "default_page")]
|
||||
pub page: i64,
|
||||
#[serde(default = "default_per_page")]
|
||||
pub per_page: i64,
|
||||
pub report_type: Option<ReportType>,
|
||||
pub hours: Option<i32>,
|
||||
}
|
||||
|
||||
fn default_page() -> i64 { 1 }
|
||||
fn default_per_page() -> i64 { 20 }
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FeedItem {
|
||||
pub id: Uuid,
|
||||
pub handle: String,
|
||||
pub report_type: ReportType,
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
pub text: String,
|
||||
pub upvotes: i32,
|
||||
pub flags: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub async fn get_feed(
|
||||
State(db): State<PgPool>,
|
||||
Query(q): Query<FeedQuery>,
|
||||
) -> Result<Json<Vec<FeedItem>>, AppError> {
|
||||
let hours = q.hours.unwrap_or(24);
|
||||
let offset = (q.page - 1) * q.per_page;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT r.id, u.handle,
|
||||
r.report_type as "report_type: ReportType",
|
||||
ST_Y(r.geom::geometry) as lat,
|
||||
ST_X(r.geom::geometry) as lng,
|
||||
r.text, r.upvotes, r.flags, r.created_at
|
||||
FROM reports r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
WHERE r.created_at > NOW() - ($1 || ' hours')::interval
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $2 OFFSET $3"#,
|
||||
hours.to_string(),
|
||||
q.per_page,
|
||||
offset
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
let items = rows.into_iter().map(|r| FeedItem {
|
||||
id: r.id,
|
||||
handle: r.handle,
|
||||
report_type: r.report_type,
|
||||
lat: r.lat.unwrap_or(0.0),
|
||||
lng: r.lng.unwrap_or(0.0),
|
||||
text: r.text,
|
||||
upvotes: r.upvotes,
|
||||
flags: r.flags,
|
||||
created_at: r.created_at,
|
||||
}).collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
4
main-api/src/routes/mod.rs
Normal file
4
main-api/src/routes/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod feed;
|
||||
pub mod poi;
|
||||
pub mod reports;
|
||||
pub mod service_requests;
|
||||
21
main-api/src/routes/poi.rs
Normal file
21
main-api/src/routes/poi.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use shared::errors::AppError;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PoiItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
pub async fn get_fuel_poi() -> Result<Json<Vec<PoiItem>>, AppError> {
|
||||
// Stub: return empty for now, populate from OSM data later
|
||||
Ok(Json(vec![]))
|
||||
}
|
||||
|
||||
pub async fn get_repair_poi() -> Result<Json<Vec<PoiItem>>, AppError> {
|
||||
Ok(Json(vec![]))
|
||||
}
|
||||
138
main-api/src/routes/reports.rs
Normal file
138
main-api/src/routes/reports.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{errors::AppError, models::{Claims, ReportType}};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct CreateReportRequest {
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
pub report_type: ReportType,
|
||||
#[validate(length(min = 1, max = 500))]
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NearQuery {
|
||||
pub min_lat: f64,
|
||||
pub min_lng: f64,
|
||||
pub max_lat: f64,
|
||||
pub max_lng: f64,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 { 100 }
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReportResponse {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub handle: String,
|
||||
pub report_type: ReportType,
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
pub text: String,
|
||||
pub upvotes: i32,
|
||||
pub flags: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub async fn create_report(
|
||||
State(db): State<PgPool>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<CreateReportRequest>,
|
||||
) -> Result<Json<ReportResponse>, AppError> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let user_id: Uuid = claims.sub.parse().map_err(|_| AppError::Internal("bad uid".into()))?;
|
||||
|
||||
let report_type = req.report_type.clone();
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO reports (id, user_id, report_type, geom, text)
|
||||
VALUES ($1, $2, $3::report_type, ST_SetSRID(ST_MakePoint($5, $4), 4326), $6)"#,
|
||||
id, user_id, report_type as ReportType, req.lat, req.lng, req.text
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ReportResponse {
|
||||
id,
|
||||
user_id,
|
||||
handle: claims.handle,
|
||||
report_type: req.report_type,
|
||||
lat: req.lat,
|
||||
lng: req.lng,
|
||||
text: req.text,
|
||||
upvotes: 0,
|
||||
flags: 0,
|
||||
created_at: Utc::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_reports_near(
|
||||
State(db): State<PgPool>,
|
||||
Query(q): Query<NearQuery>,
|
||||
) -> Result<Json<Vec<ReportResponse>>, AppError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT r.id, r.user_id, u.handle,
|
||||
r.report_type as "report_type: ReportType",
|
||||
ST_Y(r.geom::geometry) as lat,
|
||||
ST_X(r.geom::geometry) as lng,
|
||||
r.text, r.upvotes, r.flags, r.created_at
|
||||
FROM reports r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
WHERE r.geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
|
||||
AND r.created_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $5"#,
|
||||
q.min_lng, q.min_lat, q.max_lng, q.max_lat, q.limit
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
let reports = rows.into_iter().map(|r| ReportResponse {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
handle: r.handle,
|
||||
report_type: r.report_type,
|
||||
lat: r.lat.unwrap_or(0.0),
|
||||
lng: r.lng.unwrap_or(0.0),
|
||||
text: r.text,
|
||||
upvotes: r.upvotes,
|
||||
flags: r.flags,
|
||||
created_at: r.created_at,
|
||||
}).collect();
|
||||
|
||||
Ok(Json(reports))
|
||||
}
|
||||
|
||||
pub async fn upvote_report(
|
||||
State(db): State<PgPool>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
axum::extract::Path(id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query!("UPDATE reports SET upvotes = upvotes + 1 WHERE id = $1", id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub async fn flag_report(
|
||||
State(db): State<PgPool>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
axum::extract::Path(id): axum::extract::Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query!("UPDATE reports SET flags = flags + 1 WHERE id = $1", id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
116
main-api/src/routes/service_requests.rs
Normal file
116
main-api/src/routes/service_requests.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use axum::{
|
||||
extract::{Extension, Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{errors::AppError, models::{Claims, ServiceStatus, UserRole}};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct CreateServiceRequest {
|
||||
#[validate(length(min = 10, max = 1000))]
|
||||
pub description: String,
|
||||
pub truck_details: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ServiceRequestResponse {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub description: String,
|
||||
pub truck_details: serde_json::Value,
|
||||
pub status: ServiceStatus,
|
||||
pub claimed_by: Option<Uuid>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub async fn create_service_request(
|
||||
State(db): State<PgPool>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<CreateServiceRequest>,
|
||||
) -> Result<Json<ServiceRequestResponse>, AppError> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
if claims.role != UserRole::Driver {
|
||||
return Err(AppError::Unauthorized("Only drivers can create service requests".into()));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let user_id: Uuid = claims.sub.parse().map_err(|_| AppError::Internal("bad uid".into()))?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO service_requests (id, user_id, description, truck_details) VALUES ($1, $2, $3, $4)",
|
||||
id, user_id, req.description, req.truck_details
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ServiceRequestResponse {
|
||||
id,
|
||||
user_id,
|
||||
description: req.description,
|
||||
truck_details: req.truck_details,
|
||||
status: ServiceStatus::Open,
|
||||
claimed_by: None,
|
||||
created_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_open_service_requests(
|
||||
State(db): State<PgPool>,
|
||||
Extension(_claims): Extension<Claims>,
|
||||
) -> Result<Json<Vec<ServiceRequestResponse>>, AppError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT id, user_id, description, truck_details,
|
||||
status as "status: ServiceStatus", claimed_by, created_at
|
||||
FROM service_requests
|
||||
WHERE status = 'open'::service_status
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50"#
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
let items = rows.into_iter().map(|r| ServiceRequestResponse {
|
||||
id: r.id,
|
||||
user_id: r.user_id,
|
||||
description: r.description,
|
||||
truck_details: r.truck_details,
|
||||
status: r.status,
|
||||
claimed_by: r.claimed_by,
|
||||
created_at: r.created_at,
|
||||
}).collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
pub async fn claim_service_request(
|
||||
State(db): State<PgPool>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if claims.role != UserRole::MechanicShop && claims.role != UserRole::MobileMechanic {
|
||||
return Err(AppError::Unauthorized("Only mechanics can claim service requests".into()));
|
||||
}
|
||||
|
||||
let mechanic_id: Uuid = claims.sub.parse().map_err(|_| AppError::Internal("bad uid".into()))?;
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE service_requests
|
||||
SET status = 'claimed'::service_status, claimed_by = $1
|
||||
WHERE id = $2 AND status = 'open'::service_status"#,
|
||||
mechanic_id, id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Service request not found or already claimed".into()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
Reference in New Issue
Block a user