first commit
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -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
|
||||||
17
README.md
Normal file
17
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
32
auth-api/Cargo.toml
Normal file
32
auth-api/Cargo.toml
Normal file
@@ -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"] }
|
||||||
15
auth-api/Dockerfile
Normal file
15
auth-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 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"]
|
||||||
64
auth-api/src/main.rs
Normal file
64
auth-api/src/main.rs
Normal file
@@ -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<TokenService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
}
|
||||||
93
auth-api/src/routes/login.rs
Normal file
93
auth-api/src/routes/login.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
28
auth-api/src/routes/logout.rs
Normal file
28
auth-api/src/routes/logout.rs
Normal file
@@ -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<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
4
auth-api/src/routes/mod.rs
Normal file
4
auth-api/src/routes/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod refresh;
|
||||||
|
pub mod register;
|
||||||
86
auth-api/src/routes/refresh.rs
Normal file
86
auth-api/src/routes/refresh.rs
Normal file
@@ -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<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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 }),
|
||||||
|
))
|
||||||
|
}
|
||||||
62
auth-api/src/routes/register.rs
Normal file
62
auth-api/src/routes/register.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> Result<Json<RegisterResponse>, 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
59
auth-api/src/tokens.rs
Normal file
59
auth-api/src/tokens.rs
Normal file
@@ -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<String> {
|
||||||
|
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<Claims> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
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 })))
|
||||||
|
}
|
||||||
14
shared/Cargo.toml
Normal file
14
shared/Cargo.toml
Normal file
@@ -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 }
|
||||||
40
shared/src/errors.rs
Normal file
40
shared/src/errors.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
shared/src/lib.rs
Normal file
2
shared/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod errors;
|
||||||
|
pub mod models;
|
||||||
50
shared/src/models.rs
Normal file
50
shared/src/models.rs
Normal file
@@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user