commit 0c9af957fee85ba0d69ab87f1309fca11d2d23be Author: Edwin Eames Date: Sat Mar 14 20:50:05 2026 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4da3256 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Rust build output +target/ + +# Cargo lock is kept for workspace/binary crates +# Cargo.lock + +# Environment files +.env +.env.local +.env.*.local + +# Editor / OS +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +Thumbs.db + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e14a1ab --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# TruckNet Crates + +Rust backend crates for TruckNet. + +## Crates + +- `auth-api` — Authentication service +- `main-api` — Main API service +- `shared` — Shared types and utilities + +## Development + +```bash +cargo build +cargo run -p auth-api +cargo run -p main-api +``` diff --git a/auth-api/Cargo.toml b/auth-api/Cargo.toml new file mode 100644 index 0000000..7d36f9f --- /dev/null +++ b/auth-api/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "auth-api" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "auth-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 } +argon2 = { workspace = true } +rand = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +validator = { workspace = true } +dotenvy = { workspace = true } +tower = { workspace = true } +sha2 = "0.10" +time = { version = "0.3", features = ["serde-human-readable"] } diff --git a/auth-api/Dockerfile b/auth-api/Dockerfile new file mode 100644 index 0000000..29cf6c3 --- /dev/null +++ b/auth-api/Dockerfile @@ -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 auth-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/auth-api /usr/local/bin/auth-api +EXPOSE 3001 +CMD ["auth-api"] diff --git a/auth-api/src/main.rs b/auth-api/src/main.rs new file mode 100644 index 0000000..82d7131 --- /dev/null +++ b/auth-api/src/main.rs @@ -0,0 +1,64 @@ +mod routes; +mod tokens; + +use axum::{routing::post, Router}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokens::TokenService; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Clone)] +pub struct AppState { + pub db: sqlx::PgPool, + pub tokens: Arc, +} + +#[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(|_| "auth_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(5) + .connect(&database_url) + .await?; + + sqlx::migrate!("../../db/migrations") + .run(&db) + .await?; + + let state = AppState { + db, + tokens: Arc::new(TokenService::new(jwt_secret)), + }; + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_headers(Any) + .allow_methods(Any); + + let app = Router::new() + .route("/register", post(routes::register::register)) + .route("/login", post(routes::login::login)) + .route("/refresh", post(routes::refresh::refresh)) + .route("/logout", post(routes::logout::logout)) + .layer(cors) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let addr = "0.0.0.0:3001"; + tracing::info!("auth-api listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/auth-api/src/routes/login.rs b/auth-api/src/routes/login.rs new file mode 100644 index 0000000..bb220be --- /dev/null +++ b/auth-api/src/routes/login.rs @@ -0,0 +1,93 @@ +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{extract::State, http::header, response::IntoResponse, Json}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use shared::{errors::AppError, models::UserRole}; +use uuid::Uuid; + +use crate::AppState; + +#[derive(Deserialize)] +pub struct LoginRequest { + pub handle: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub user_id: String, + pub handle: String, + pub role: UserRole, +} + +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result { + let row = sqlx::query!( + r#"SELECT id, handle, role as "role: UserRole", password_hash FROM users WHERE handle = $1"#, + req.handle + ) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::Unauthorized("Invalid credentials".to_string()))?; + + let parsed_hash = PasswordHash::new(&row.password_hash) + .map_err(|e| AppError::Internal(e.to_string()))?; + Argon2::default() + .verify_password(req.password.as_bytes(), &parsed_hash) + .map_err(|_| AppError::Unauthorized("Invalid credentials".to_string()))?; + + let access_token = state + .tokens + .generate_access_token(row.id, &row.handle, row.role.clone()) + .map_err(|e| AppError::Internal(e.to_string()))?; + + let refresh_raw = crate::tokens::TokenService::generate_refresh_token(); + let refresh_hash = sha256_hex(&refresh_raw); + let expires_at = Utc::now() + chrono::Duration::days(14); + + sqlx::query!( + "INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)", + Uuid::new_v4(), + row.id, + refresh_hash, + expires_at + ) + .execute(&state.db) + .await?; + + let cookie = Cookie::build(("refresh_token", refresh_raw)) + .http_only(true) + .secure(true) + .same_site(SameSite::Strict) + .path("/auth") + .max_age(time::Duration::days(14)) + .build(); + + let resp = LoginResponse { + access_token, + user_id: row.id.to_string(), + handle: row.handle, + role: row.role, + }; + + Ok(( + [(header::SET_COOKIE, cookie.to_string())], + Json(resp), + )) +} + +pub fn sha256_hex(input: &str) -> String { + use sha2::Digest; + let mut hasher = sha2::Sha256::new(); + hasher.update(input.as_bytes()); + let result = hasher.finalize(); + result.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write; + write!(acc, "{:02x}", b).unwrap(); + acc + }) +} diff --git a/auth-api/src/routes/logout.rs b/auth-api/src/routes/logout.rs new file mode 100644 index 0000000..667ba33 --- /dev/null +++ b/auth-api/src/routes/logout.rs @@ -0,0 +1,28 @@ +use axum::{extract::State, http::header, response::IntoResponse, Json}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use serde_json::json; +use shared::errors::AppError; + +use crate::{routes::login::sha256_hex, AppState}; + +pub async fn logout( + State(state): State, + jar: CookieJar, +) -> Result { + if let Some(cookie) = jar.get("refresh_token") { + let hash = sha256_hex(cookie.value()); + sqlx::query!("UPDATE refresh_tokens SET revoked = true WHERE token_hash = $1", hash) + .execute(&state.db) + .await?; + } + + let removal = Cookie::build(("refresh_token", "")) + .path("/auth") + .max_age(time::Duration::seconds(0)) + .build(); + + Ok(( + [(header::SET_COOKIE, removal.to_string())], + Json(json!({ "message": "logged out" })), + )) +} diff --git a/auth-api/src/routes/mod.rs b/auth-api/src/routes/mod.rs new file mode 100644 index 0000000..5cc96ad --- /dev/null +++ b/auth-api/src/routes/mod.rs @@ -0,0 +1,4 @@ +pub mod login; +pub mod logout; +pub mod refresh; +pub mod register; diff --git a/auth-api/src/routes/refresh.rs b/auth-api/src/routes/refresh.rs new file mode 100644 index 0000000..28a6e87 --- /dev/null +++ b/auth-api/src/routes/refresh.rs @@ -0,0 +1,86 @@ +use axum::{extract::State, http::header, response::IntoResponse, Json}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use chrono::Utc; +use serde::Serialize; +use shared::errors::AppError; +use uuid::Uuid; + +use crate::{routes::login::sha256_hex, AppState}; + +#[derive(Serialize)] +pub struct RefreshResponse { + pub access_token: String, +} + +pub async fn refresh( + State(state): State, + jar: CookieJar, +) -> Result { + let raw_token = jar + .get("refresh_token") + .map(|c| c.value().to_owned()) + .ok_or_else(|| AppError::Unauthorized("No refresh token".to_string()))?; + + let token_hash = sha256_hex(&raw_token); + + let row = sqlx::query!( + r#"SELECT id, user_id, expires_at, revoked + FROM refresh_tokens + WHERE token_hash = $1"#, + token_hash + ) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::Unauthorized("Invalid refresh token".to_string()))?; + + if row.revoked { + return Err(AppError::Unauthorized("Token revoked".to_string())); + } + if row.expires_at < Utc::now() { + return Err(AppError::Unauthorized("Token expired".to_string())); + } + + // Rotate: revoke old, issue new + sqlx::query!("UPDATE refresh_tokens SET revoked = true WHERE id = $1", row.id) + .execute(&state.db) + .await?; + + let user = sqlx::query!( + r#"SELECT id, handle, role as "role: shared::models::UserRole" FROM users WHERE id = $1"#, + row.user_id + ) + .fetch_one(&state.db) + .await?; + + let access_token = state + .tokens + .generate_access_token(user.id, &user.handle, user.role.clone()) + .map_err(|e| AppError::Internal(e.to_string()))?; + + let new_refresh_raw = crate::tokens::TokenService::generate_refresh_token(); + let new_refresh_hash = sha256_hex(&new_refresh_raw); + let expires_at = Utc::now() + chrono::Duration::days(14); + + sqlx::query!( + "INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)", + Uuid::new_v4(), + row.user_id, + new_refresh_hash, + expires_at + ) + .execute(&state.db) + .await?; + + let cookie = Cookie::build(("refresh_token", new_refresh_raw)) + .http_only(true) + .secure(true) + .same_site(SameSite::Strict) + .path("/auth") + .max_age(time::Duration::days(14)) + .build(); + + Ok(( + [(header::SET_COOKIE, cookie.to_string())], + Json(RefreshResponse { access_token }), + )) +} diff --git a/auth-api/src/routes/register.rs b/auth-api/src/routes/register.rs new file mode 100644 index 0000000..d892d1b --- /dev/null +++ b/auth-api/src/routes/register.rs @@ -0,0 +1,62 @@ +use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use axum::{extract::State, Json}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use shared::{errors::AppError, models::UserRole}; +use uuid::Uuid; +use validator::Validate; + +use crate::AppState; + +#[derive(Debug, Deserialize, Validate)] +pub struct RegisterRequest { + #[validate(length(min = 3, max = 30, message = "Handle must be 3-30 chars"))] + pub handle: String, + #[validate(length(min = 8, message = "Password must be at least 8 chars"))] + pub password: String, + pub role: UserRole, +} + +#[derive(Serialize)] +pub struct RegisterResponse { + pub user_id: Uuid, + pub handle: String, +} + +pub async fn register( + State(state): State, + Json(req): Json, +) -> Result, AppError> { + req.validate() + .map_err(|e| AppError::BadRequest(e.to_string()))?; + + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(req.password.as_bytes(), &salt) + .map_err(|e| AppError::Internal(e.to_string()))? + .to_string(); + + let user_id = Uuid::new_v4(); + sqlx::query!( + r#"INSERT INTO users (id, handle, role, password_hash) VALUES ($1, $2, $3::user_role, $4)"#, + user_id, + req.handle, + req.role as UserRole, + hash + ) + .execute(&state.db) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.constraint() == Some("users_handle_key") { + return AppError::BadRequest("Handle already taken".to_string()); + } + } + AppError::Database(e) + })?; + + Ok(Json(RegisterResponse { + user_id, + handle: req.handle, + })) +} diff --git a/auth-api/src/tokens.rs b/auth-api/src/tokens.rs new file mode 100644 index 0000000..05a5aed --- /dev/null +++ b/auth-api/src/tokens.rs @@ -0,0 +1,59 @@ +use chrono::Utc; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use rand::Rng; +use shared::models::{Claims, UserRole}; +use uuid::Uuid; + +pub struct TokenService { + pub secret: String, + pub access_expiry_secs: i64, + pub refresh_expiry_secs: i64, +} + +impl TokenService { + pub fn new(secret: String) -> Self { + Self { + secret, + access_expiry_secs: 30 * 60, // 30 min + refresh_expiry_secs: 14 * 24 * 3600, // 14 days + } + } + + pub fn generate_access_token( + &self, + user_id: Uuid, + handle: &str, + role: UserRole, + ) -> anyhow::Result { + let now = Utc::now().timestamp() as usize; + let claims = Claims { + sub: user_id.to_string(), + handle: handle.to_string(), + role, + iat: now, + exp: now + self.access_expiry_secs as usize, + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(self.secret.as_bytes()), + )?; + Ok(token) + } + + pub fn validate_access_token(&self, token: &str) -> anyhow::Result { + let data = decode::( + token, + &DecodingKey::from_secret(self.secret.as_bytes()), + &Validation::default(), + )?; + Ok(data.claims) + } + + pub fn generate_refresh_token() -> String { + let mut rng = rand::thread_rng(); + (0..64) + .map(|_| rng.sample(rand::distributions::Alphanumeric) as char) + .collect() + } +} diff --git a/main-api/Cargo.toml b/main-api/Cargo.toml new file mode 100644 index 0000000..666527d --- /dev/null +++ b/main-api/Cargo.toml @@ -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 } diff --git a/main-api/Dockerfile b/main-api/Dockerfile new file mode 100644 index 0000000..43b906f --- /dev/null +++ b/main-api/Dockerfile @@ -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"] diff --git a/main-api/src/main.rs b/main-api/src/main.rs new file mode 100644 index 0000000..7fe46e6 --- /dev/null +++ b/main-api/src/main.rs @@ -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(()) +} diff --git a/main-api/src/middleware.rs b/main-api/src/middleware.rs new file mode 100644 index 0000000..fe03b5f --- /dev/null +++ b/main-api/src/middleware.rs @@ -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, + mut req: Request, + next: Next, +) -> Result { + 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::( + 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) +} diff --git a/main-api/src/models.rs b/main-api/src/models.rs new file mode 100644 index 0000000..f185fc2 --- /dev/null +++ b/main-api/src/models.rs @@ -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, +} + +#[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, + pub created_at: DateTime, +} diff --git a/main-api/src/routes/feed.rs b/main-api/src/routes/feed.rs new file mode 100644 index 0000000..7c0c27e --- /dev/null +++ b/main-api/src/routes/feed.rs @@ -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, + pub hours: Option, +} + +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, +} + +pub async fn get_feed( + State(db): State, + Query(q): Query, +) -> Result>, 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)) +} diff --git a/main-api/src/routes/mod.rs b/main-api/src/routes/mod.rs new file mode 100644 index 0000000..a59dd46 --- /dev/null +++ b/main-api/src/routes/mod.rs @@ -0,0 +1,4 @@ +pub mod feed; +pub mod poi; +pub mod reports; +pub mod service_requests; diff --git a/main-api/src/routes/poi.rs b/main-api/src/routes/poi.rs new file mode 100644 index 0000000..2cd76b4 --- /dev/null +++ b/main-api/src/routes/poi.rs @@ -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>, AppError> { + // Stub: return empty for now, populate from OSM data later + Ok(Json(vec![])) +} + +pub async fn get_repair_poi() -> Result>, AppError> { + Ok(Json(vec![])) +} diff --git a/main-api/src/routes/reports.rs b/main-api/src/routes/reports.rs new file mode 100644 index 0000000..ae0aa4e --- /dev/null +++ b/main-api/src/routes/reports.rs @@ -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, +} + +pub async fn create_report( + State(db): State, + Extension(claims): Extension, + Json(req): Json, +) -> Result, 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, + Query(q): Query, +) -> Result>, 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, + Extension(_claims): Extension, + axum::extract::Path(id): axum::extract::Path, +) -> Result, 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, + Extension(_claims): Extension, + axum::extract::Path(id): axum::extract::Path, +) -> Result, AppError> { + sqlx::query!("UPDATE reports SET flags = flags + 1 WHERE id = $1", id) + .execute(&db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/main-api/src/routes/service_requests.rs b/main-api/src/routes/service_requests.rs new file mode 100644 index 0000000..0b201de --- /dev/null +++ b/main-api/src/routes/service_requests.rs @@ -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, + pub created_at: chrono::DateTime, +} + +pub async fn create_service_request( + State(db): State, + Extension(claims): Extension, + Json(req): Json, +) -> Result, 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, + Extension(_claims): Extension, +) -> Result>, 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, + Extension(claims): Extension, + Path(id): Path, +) -> Result, 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 }))) +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..8cd4243 --- /dev/null +++ b/shared/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shared" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +axum = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } diff --git a/shared/src/errors.rs b/shared/src/errors.rs new file mode 100644 index 0000000..c886e49 --- /dev/null +++ b/shared/src/errors.rs @@ -0,0 +1,40 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + #[error("Unauthorized: {0}")] + Unauthorized(String), + #[error("Bad request: {0}")] + BadRequest(String), + #[error("Not found: {0}")] + NotFound(String), + #[error("Internal error: {0}")] + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::Database(e) => { + tracing::error!("DB error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string()) + } + AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + AppError::Internal(msg) => { + tracing::error!("Internal error: {msg}"); + (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..221cfd0 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,2 @@ +pub mod errors; +pub mod models; diff --git a/shared/src/models.rs b/shared/src/models.rs new file mode 100644 index 0000000..36b756e --- /dev/null +++ b/shared/src/models.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)] +#[sqlx(type_name = "user_role", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserRole { + Driver, + MechanicShop, + MobileMechanic, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)] +#[sqlx(type_name = "report_type", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ReportType { + Police, + Dot, + Hazard, + RoadCondition, + BreakdownRequest, + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, sqlx::Type)] +#[sqlx(type_name = "service_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ServiceStatus { + Open, + Claimed, + Resolved, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: Uuid, + pub handle: String, + pub role: UserRole, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, // user_id + pub handle: String, + pub role: UserRole, + pub exp: usize, + pub iat: usize, +}