added listings

This commit is contained in:
2025-12-26 20:01:08 -05:00
parent 5d0a9bb255
commit 56ebb8eba2
19 changed files with 843 additions and 130 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "Rust Dev Container",
"dockerComposeFile": [
"../../deploy/docker-compose.dev.yml"
],
"service": "api_rust", // Replace with your Rust service name in docker-compose.dev.yml
"workspaceFolder": "/mnt/code/newenglandoilbio/api_rust",
"customizations": {
"vscode": {
"extensions": ["rust-lang.rust-analyzer"],
"settings": {
"rust-analyzer.server.path": null,
"rust-analyzer.cargo.sysroot": null,
"rust-analyzer.enable": true
}
}
},
"remoteUser": "root", // Adjust to match your container's user
"shutdownAction": "stopCompose"
}

26
Cargo.lock generated
View File

@@ -69,6 +69,7 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"dotenv", "dotenv",
"hyper",
"jsonwebtoken", "jsonwebtoken",
"serde", "serde",
"serde_json", "serde_json",
@@ -126,6 +127,7 @@ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"bytes", "bytes",
"futures-util", "futures-util",
"headers",
"http", "http",
"http-body", "http-body",
"hyper", "hyper",
@@ -524,6 +526,30 @@ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.14.5",
] ]
[[package]]
name = "headers"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270"
dependencies = [
"base64 0.21.7",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429"
dependencies = [
"http",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
axum = "0.6.20" axum = { version = "0.6.20", features = ["headers"] }
tokio = { version = "1.35.1", features = ["full"] } tokio = { version = "1.35.1", features = ["full"] }
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "chrono"] } sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "chrono"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -14,3 +14,4 @@ chrono = { version = "0.4", features = ["serde"] }
dotenv = "0.15" dotenv = "0.15"
tower-http = { version = "0.4", features = ["cors"] } tower-http = { version = "0.4", features = ["cors"] }
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
hyper = "0.14"

1
alter_column.sql Normal file
View File

@@ -0,0 +1 @@
ALTER TABLE listings ALTER COLUMN price_per_gallon TYPE DOUBLE PRECISION;

1
drop_column.sql Normal file
View File

@@ -0,0 +1 @@
ALTER TABLE listings DROP COLUMN company_id;

View File

@@ -2,8 +2,58 @@ CREATE TABLE users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL, username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL, created TIMESTAMPTZ,
email VARCHAR(255), email VARCHAR(255),
last_login TIMESTAMPTZ, last_login TIMESTAMPTZ,
owner INTEGER owner INTEGER
); );
CREATE TABLE service_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
clicks_total INTEGER DEFAULT 0,
total_companies INTEGER DEFAULT 0
);
CREATE TABLE companies (
id SERIAL PRIMARY KEY,
active BOOLEAN DEFAULT true,
created DATE NOT NULL DEFAULT CURRENT_DATE,
name VARCHAR(255) NOT NULL,
address VARCHAR(255),
town VARCHAR(255),
state VARCHAR(2),
phone VARCHAR(20),
owner_name VARCHAR(255),
owner_phone_number VARCHAR(20),
email VARCHAR(255),
user_id INTEGER
);
CREATE TABLE listings (
id SERIAL PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
price_per_gallon DOUBLE PRECISION NOT NULL,
price_per_gallon_cash DOUBLE PRECISION,
note TEXT,
minimum_order INTEGER,
service BOOLEAN DEFAULT false,
bio_percent INTEGER NOT NULL,
phone VARCHAR(20),
online_ordering VARCHAR(20) NOT NULL DEFAULT 'none',
county_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- If the table already exists, add the new columns
-- ALTER TABLE listings ADD COLUMN price_per_gallon_cash DOUBLE PRECISION;
-- ALTER TABLE listings ADD COLUMN note TEXT;
-- ALTER TABLE listings ADD COLUMN minimum_order INTEGER;
-- ALTER TABLE listings ADD COLUMN last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
-- If the table already exists, drop the company_id column
-- ALTER TABLE listings DROP COLUMN company_id;

37
seed_categories.sql Normal file
View File

@@ -0,0 +1,37 @@
-- Seed data for service_categories table
INSERT INTO service_categories (name, description, clicks_total, total_companies) VALUES
('Landscaping - Lawn care, tree trimming, and garden design', 'Professional landscaping services including lawn maintenance, tree trimming, garden design, and outdoor space enhancement.', 0, 0),
('Snowplowing - Snow removal from driveways and walkways', 'Reliable snow removal services to clear driveways, walkways, and parking areas during winter storms.', 0, 0),
('Roofing - Roof repairs, replacements, and inspections', 'Comprehensive roofing services including repairs, complete replacements, and routine inspections to maintain your roof.', 0, 0),
('Plumbing - Fixing leaks, installing fixtures, and unclogging drains', 'Expert plumbing services for leak repairs, fixture installation, drain cleaning, and all plumbing needs.', 0, 0),
('HVAC - Heating, ventilation, and air conditioning maintenance and repair', 'Complete HVAC services including heating repair, air conditioning service, ventilation system maintenance, and energy efficiency upgrades.', 0, 0),
('Electrical - Wiring, lighting installation, and electrical repairs', 'Professional electrical services for wiring, lighting installation, outlet repairs, and electrical safety inspections.', 0, 0),
('Pest Control - Extermination of insects, rodents, and other pests', 'Effective pest control services for eliminating insects, rodents, and other unwanted pests from your property.', 0, 0),
('House Cleaning - Regular or deep cleaning services', 'Thorough house cleaning services including regular maintenance cleaning and deep cleaning for a fresh, organized home.', 0, 0),
('Window Cleaning - Exterior and interior window washing', 'Professional window cleaning for both exterior and interior surfaces, ensuring crystal clear views and streak-free glass.', 0, 0),
('Gutter Cleaning - Removing debris from gutters to prevent water damage', 'Gutter cleaning and maintenance to remove leaves, debris, and prevent water damage and foundation issues.', 0, 0),
('Painting - Interior and exterior painting for aesthetic and protection', 'Quality painting services for interior rooms and exterior surfaces, enhancing both appearance and protection.', 0, 0),
('Carpentry - Building or repairing decks, fences, and furniture', 'Skilled carpentry work including deck construction, fence repair, furniture building, and custom woodwork.', 0, 0),
('Masonry - Brickwork, stonework, and chimney repairs', 'Expert masonry services for brickwork, stone installations, chimney repairs, and stone structure maintenance.', 0, 0),
('Siding Installation/Repair - Maintaining or replacing exterior siding', 'Siding services including installation, repair, and replacement to protect and enhance your home exterior.', 0, 0),
('Pressure Washing - Cleaning driveways, decks, and home exteriors', 'High-pressure cleaning services for driveways, decks, siding, and other exterior surfaces to restore appearance.', 0, 0),
('Tree Services - Tree removal, pruning, and stump grinding', 'Professional tree care including removal of hazardous trees, pruning, trimming, and stump grinding services.', 0, 0),
('Septic System Services - Pumping, maintenance, and repairs for septic tanks', 'Septic system maintenance including pumping, inspections, repairs, and regular servicing to prevent system failure.', 0, 0),
('Well Water Services - Maintenance and testing for private wells', 'Well water services including maintenance, testing, filtration, and repair for private water well systems.', 0, 0),
('Home Security Installation - Alarm systems, cameras, and smart locks', 'Home security installation including alarm systems, surveillance cameras, smart locks, and security monitoring.', 0, 0),
('Locksmith Services - Lock repairs, replacements, and rekeying', 'Professional locksmith services for lock repair, replacement, rekeying, and emergency lockout assistance.', 0, 0),
('Appliance Repair - Fixing refrigerators, washers, dryers, and more', 'Appliance repair services for all major household appliances including refrigerators, washers, dryers, and ovens.', 0, 0),
('Garage Door Services - Installation, repair, and maintenance of garage doors', 'Garage door services including installation, repair, maintenance, and opener replacement for residential and commercial doors.', 0, 0),
('Foundation Repair - Addressing cracks or structural issues in foundations', 'Foundation repair services to address cracks, settling, and structural issues in home and building foundations.', 0, 0),
('Waterproofing - Basement or crawlspace waterproofing to prevent leaks', 'Waterproofing services for basements and crawlspaces to prevent water intrusion and moisture damage.', 0, 0),
('Mold Remediation - Removing mold and addressing moisture issues', 'Professional mold remediation services including identification, removal, and moisture control to protect your health.', 0, 0),
('Insulation Services - Installing or upgrading insulation for energy efficiency', 'Insulation installation and upgrading services to improve energy efficiency, comfort, and reduce heating/cooling costs.', 0, 0),
('Drywall Installation/Repair - Fixing holes or installing new drywall', 'Drywall services including installation, repair of holes and damage, and finishing for smooth, professional walls.', 0, 0),
('Flooring Services - Installing or repairing hardwood, tile, or carpet', 'Flooring installation and repair services for hardwood, tile, carpeting, laminate, and other flooring types.', 0, 0),
('Carpet Cleaning - Deep cleaning or stain removal for carpets', 'Professional carpet cleaning services including deep cleaning, stain removal, and maintenance to extend carpet life.', 0, 0),
('Chimney Sweep - Cleaning chimneys to ensure safe fireplace use', 'Chimney sweeping and inspection services to clean creosote buildup and ensure safe, efficient fireplace operation.', 0, 0),
('Pool Maintenance - Cleaning, repairs, and chemical balancing for pools', 'Complete pool maintenance services including cleaning, repairs, chemical balancing, and seasonal maintenance.', 0, 0),
('Fence Installation/Repair - Building or fixing fences for privacy or security', 'Fence installation and repair services including new fence construction and fixing damaged sections for privacy and security.', 0, 0),
('Home Inspection - Pre-purchase or routine home condition assessments', 'Comprehensive home inspection services for pre-purchase evaluations or routine condition assessments.', 0, 0),
('Window Replacement - Installing energy-efficient windows or repairing frames', 'Window replacement and repair services including energy-efficient window installation and frame repairs.', 0, 0),
('Junk Removal - Hauling away unwanted items or debris', 'Junk removal services for clearing unwanted items, debris, and clutter from homes, offices, and construction sites.', 0, 0);

View File

@@ -1,25 +1,17 @@
use axum::{ use axum::{
extract::{State, Json}, extract::{State, Json},
http::StatusCode, http::StatusCode,
middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::post, body::Body,
Router, http::Request as HttpRequest,
}; };
use crate::auth::structs::{AppState, User, RegisterRequest, LoginRequest, Claims}; use crate::auth::structs::{AppState, User, RegisterRequest, LoginRequest, Claims};
use argon2::{ use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Argon2,
}; };
use jsonwebtoken::{encode, Header, EncodingKey}; use jsonwebtoken::{decode, encode, Header, EncodingKey, DecodingKey, Validation};
// Create auth router
pub fn router() -> Router<AppState> {
Router::new()
.route("/auth/register", post(register))
.route("/auth/login", post(login))
}
// A helper function to convert any error into a 500 Internal Server Error response. // A helper function to convert any error into a 500 Internal Server Error response.
fn internal_error<E>(err: E) -> Response fn internal_error<E>(err: E) -> Response
@@ -38,7 +30,7 @@ where
} }
async fn register( pub async fn register(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<RegisterRequest>, Json(payload): Json<RegisterRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -94,8 +86,8 @@ pub async fn login(
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// 1. Fetch user from the database // 1. Fetch user from the database
let user = match sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") let user = match sqlx::query_as::<_, User>("SELECT * FROM users WHERE TRIM(username) = $1")
.bind(&payload.username) .bind(&payload.username.trim())
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await .await
{ {
@@ -157,3 +149,55 @@ pub async fn login(
(StatusCode::OK, Json(serde_json::json!({ "token": token, "user": user }))).into_response() (StatusCode::OK, Json(serde_json::json!({ "token": token, "user": user }))).into_response()
} }
pub async fn auth_middleware(
State(state): State<AppState>,
mut request: HttpRequest<Body>,
next: Next<Body>,
) -> Result<Response, Response> {
// Manually extract Authorization header
let auth_header = match request.headers().get(axum::http::header::AUTHORIZATION) {
Some(header) => header.to_str().ok(),
None => return Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Missing authorization header"}))).into_response()),
};
let token = match auth_header.and_then(|h| h.strip_prefix("Bearer ")) {
Some(t) => t,
None => return Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Invalid authorization header"}))).into_response()),
};
let claims = match decode::<Claims>(
token,
&DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&Validation::default(),
) {
Ok(token_data) => token_data.claims,
Err(_) => return Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "Invalid token"}))).into_response()),
};
// Fetch user from database using username from claims
// Note: Database might pad CHAR fields with spaces, so we trim the username
let trimmed_username = claims.sub.trim();
eprintln!("Looking up user: '{}' (trimmed: '{}')", &claims.sub, trimmed_username);
let user = match sqlx::query_as::<_, User>("SELECT * FROM users WHERE TRIM(username) = $1")
.bind(trimmed_username)
.fetch_one(&*state.db)
.await
{
Ok(user) => {
eprintln!("Found user: {}", user.username.trim());
user
},
Err(e) => {
eprintln!("Database error finding user '{}' : {:?}", trimmed_username, e);
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "User not found"}))).into_response());
}
};
// Insert the user and state into request extensions
request.extensions_mut().insert(user);
request.extensions_mut().insert(state.clone());
// Proceed to the next handler
Ok(next.run(request).await)
}

View File

@@ -7,13 +7,12 @@ use chrono::NaiveDateTime;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db_url: String,
pub db: Arc<PgPool>, pub db: Arc<PgPool>,
pub jwt_secret: String, pub jwt_secret: String,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub username: String, pub username: String,

33
src/company/category.rs Normal file
View File

@@ -0,0 +1,33 @@
use axum::{
extract::State,
response::Json,
http::StatusCode,
};
use crate::auth::structs::AppState;
use crate::company::structs::ServiceCategory;
use crate::state::structs::ErrorResponse;
pub async fn get_all_categories(
State(app_state): State<AppState>,
) -> Result<Json<Vec<ServiceCategory>>, (StatusCode, Json<ErrorResponse>)> {
println!("Querying all service categories");
match sqlx::query_as::<_, ServiceCategory>("SELECT id, name, description, clicks_total, total_companies
FROM service_categories ORDER BY name ASC")
.fetch_all(&*app_state.db)
.await
{
Ok(categories) => {
Ok(Json(categories))
}
Err(e) => {
eprintln!("Database error fetching service categories: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to retrieve service categories. Please try again later.".to_string(),
}),
))
}
}
}

View File

@@ -1,83 +1,284 @@
use axum::{ use axum::{
http::StatusCode, http::{StatusCode, Method},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Router, extract::State, Json, extract::{State, Extension},
routing::post, body::Body,
http::Request,
}; };
use crate::auth::structs::AppState; use crate::auth::structs::{AppState, User};
use sqlx::query_as;
use crate::company::structs::Company; use crate::company::structs::Company;
use serde::{Deserialize, Serialize};
use serde_json::json;
pub async fn company_routes() -> Router<AppState> { #[derive(Debug, Serialize, Deserialize)]
let router = Router::new().route("/company", post(update_or_create_company)); pub struct CompanyRequest {
router pub name: String,
pub address: Option<String>,
pub town: Option<String>,
pub state: Option<String>,
pub phone: Option<String>,
pub owner_name: Option<String>,
pub owner_phone_number: Option<String>,
pub email: Option<String>,
} }
// Define a simple error type. This is necessary for the Result pattern.
#[allow(dead_code)]
pub enum AppError {
Internal(String),
NotFound(String),
}
pub async fn update_or_create_company( // Teach Axum how to convert our error into a response.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
};
(status, Json(json!({"success": false, "error": error_message}))).into_response()
}
}
// ===============================================================
// COMPANY HANDLERS
// ===============================================================
pub async fn get_company(
State(state): State<AppState>, State(state): State<AppState>,
Json(company): Json<Company>, Extension(user): Extension<User>,
) -> Response { ) -> Response {
let result = query_as::<_, Company>( match sqlx::query_as::<_, Company>(
"SELECT * FROM company WHERE id = $1", "SELECT * FROM company WHERE user_id = $1 AND active = true"
) )
.bind(company.id) .bind(user.id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await; .await
{
Ok(Some(company)) => (StatusCode::OK, Json(company)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
match result { pub async fn create_company(
Ok(existing_company) => { State(state): State<AppState>,
match existing_company { Extension(user): Extension<User>,
Some(_) => { Json(payload): Json<CompanyRequest>,
// Update existing company ) -> Response {
let result = sqlx::query( // Check if company already exists
"UPDATE company SET active = $2, name = $3, address = $4, town = $5, state = $6, phone = $7, owner_name = $8, owner_phone_number = $9, email = $10, user_id = $11 WHERE id = $1" match sqlx::query("SELECT 1 FROM company WHERE user_id = $1 AND active = true")
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(_)) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Company already exists"}))).into_response(),
Ok(None) => {},
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
match sqlx::query_as::<_, Company>(
"INSERT INTO company (name, address, town, state, phone, owner_name, owner_phone_number, email, user_id, active, created) VALUES ($1, $2, $3, $4::text, $5, $6, $7, $8, $9, true, CURRENT_DATE) RETURNING *"
) )
.bind(company.id) .bind(&payload.name)
.bind(company.active) .bind(&payload.address)
.bind(company.name) .bind(&payload.town)
.bind(company.address) .bind(&payload.state)
.bind(company.town) .bind(&payload.phone)
.bind(company.state) .bind(&payload.owner_name)
.bind(company.phone) .bind(&payload.owner_phone_number)
.bind(company.owner_name) .bind(&payload.email)
.bind(company.owner_phone_number) .bind(user.id)
.bind(company.email) .fetch_one(&*state.db)
.bind(company.user_id) .await
.execute(&*state.db) {
.await; Ok(company) => (StatusCode::CREATED, Json(company)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
match result { pub async fn update_company(
Ok(_) => (StatusCode::OK, "Company updated successfully".to_string()).into_response(), State(state): State<AppState>,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error updating company: {}", e)).into_response(), Extension(user): Extension<User>,
} Json(payload): Json<CompanyRequest>,
} ) -> Response {
None => { match sqlx::query_as::<_, Company>(
// Create new company "UPDATE company SET name = $1, address = $2, town = $3, state = $4::text, phone = $5, owner_name = $6, owner_phone_number = $7, email = $8 WHERE user_id = $9 AND active = true RETURNING *"
let result = sqlx::query(
"INSERT INTO company (active, created, name, address, town, state, phone, owner_name, owner_phone_number, email, user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"
) )
.bind(company.active) .bind(&payload.name)
.bind(chrono::Utc::now().naive_utc().date()) .bind(&payload.address)
.bind(company.name) .bind(&payload.town)
.bind(company.address) .bind(&payload.state)
.bind(company.town) .bind(&payload.phone)
.bind(company.state) .bind(&payload.owner_name)
.bind(company.phone) .bind(&payload.owner_phone_number)
.bind(company.owner_name) .bind(&payload.email)
.bind(company.owner_phone_number) .bind(user.id)
.bind(company.email) .fetch_optional(&*state.db)
.bind(company.user_id) .await
.execute(&*state.db) {
.await; Ok(Some(company)) => (StatusCode::OK, Json(company)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
match result { pub async fn delete_company(
Ok(_) => (StatusCode::CREATED, "Company created successfully".to_string()).into_response(), State(state): State<AppState>,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error creating company: {}", e)).into_response(), Extension(user): Extension<User>,
) -> Response {
match sqlx::query("UPDATE company SET active = false WHERE user_id = $1 AND active = true")
.bind(user.id)
.execute(&*state.db)
.await
{
Ok(result) => {
if result.rows_affected() == 0 {
(StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response()
} else {
(StatusCode::OK, Json(json!({"success": true, "message": "Company deleted"}))).into_response()
} }
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
} }
} }
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error checking for existing company: {}", e)).into_response(),
pub async fn company_handler(
request: Request<Body>,
) -> impl IntoResponse {
// Extract user and state from extensions
let user = match request.extensions().get::<User>().cloned() {
Some(user) => user,
None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
};
let state = match request.extensions().get::<AppState>().cloned() {
Some(state) => state,
None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "State not found"}))).into_response(),
};
let method = request.method().clone();
match method {
Method::GET => get_company_logic(&state, &user).await,
Method::POST => {
let body = match hyper::body::to_bytes(request.into_body()).await {
Ok(bytes) => bytes,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid body"}))).into_response(),
};
let payload: CompanyRequest = match serde_json::from_slice(&body) {
Ok(data) => data,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid JSON"}))).into_response(),
};
create_company_logic(&state, &user, payload).await
}
Method::PUT => {
let body = match hyper::body::to_bytes(request.into_body()).await {
Ok(bytes) => bytes,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid body"}))).into_response(),
};
let payload: CompanyRequest = match serde_json::from_slice(&body) {
Ok(data) => data,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid JSON"}))).into_response(),
};
update_company_logic(&state, &user, payload).await
}
Method::DELETE => delete_company_logic(&state, &user).await,
_ => (StatusCode::METHOD_NOT_ALLOWED, Json(json!({"error": "Method not allowed"}))).into_response(),
}
}
async fn get_company_logic(state: &AppState, user: &User) -> Response {
match sqlx::query_as::<_, Company>(
"SELECT * FROM company WHERE user_id = $1 AND active = true"
)
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(company)) => (StatusCode::OK, Json(company)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
async fn create_company_logic(state: &AppState, user: &User, payload: CompanyRequest) -> Response {
// Check if company already exists
match sqlx::query("SELECT 1 FROM company WHERE user_id = $1 AND active = true")
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(_)) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Company already exists"}))).into_response(),
Ok(None) => {},
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
match sqlx::query_as::<_, Company>(
"INSERT INTO company (name, address, town, state, phone, owner_name, owner_phone_number, email, user_id, active, created) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, CURRENT_DATE) RETURNING *"
)
.bind(&payload.name)
.bind(&payload.address)
.bind(&payload.town)
.bind(&payload.state)
.bind(&payload.phone)
.bind(&payload.owner_name)
.bind(&payload.owner_phone_number)
.bind(&payload.email)
.bind(user.id)
.fetch_one(&*state.db)
.await
{
Ok(company) => (StatusCode::CREATED, Json(company)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
async fn update_company_logic(state: &AppState, user: &User, payload: CompanyRequest) -> Response {
eprintln!("Updating company for user {}: {:?}", user.id, payload);
match sqlx::query_as::<_, Company>(
"UPDATE company SET name = $1, address = $2, town = $3, state = $4::text, phone = $5, owner_name = $6, owner_phone_number = $7, email = $8 WHERE user_id = $9 AND active = true RETURNING *"
)
.bind(&payload.name)
.bind(&payload.address)
.bind(&payload.town)
.bind(&payload.state)
.bind(&payload.phone)
.bind(&payload.owner_name)
.bind(&payload.owner_phone_number)
.bind(&payload.email)
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(company)) => {
eprintln!("Updated company successfully");
(StatusCode::OK, Json(company)).into_response()
},
Ok(None) => {
eprintln!("No company found to update, creating new one");
create_company_logic(state, user, payload).await
},
Err(e) => {
eprintln!("Database error updating company: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
},
}
}
async fn delete_company_logic(state: &AppState, user: &User) -> Response {
match sqlx::query("UPDATE company SET active = false WHERE user_id = $1 AND active = true")
.bind(user.id)
.execute(&*state.db)
.await
{
Ok(result) => {
if result.rows_affected() == 0 {
(StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response()
} else {
(StatusCode::OK, Json(json!({"success": true, "message": "Company deleted"}))).into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
} }
} }

View File

@@ -1,2 +1,3 @@
pub mod company; pub mod company;
pub mod category;
pub mod structs; pub mod structs;

View File

@@ -4,6 +4,7 @@ use sqlx::FromRow;
use chrono::NaiveDate; use chrono::NaiveDate;
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
#[allow(dead_code)]
pub struct Company { pub struct Company {
pub id: i32, pub id: i32,
pub active: bool, pub active: bool,
@@ -12,10 +13,19 @@ pub struct Company {
pub address: Option<String>, pub address: Option<String>,
pub town: Option<String>, pub town: Option<String>,
pub state: Option<String>, pub state: Option<String>,
pub phone: Option<i64>, pub phone: Option<String>,
pub owner_name: Option<String>, pub owner_name: Option<String>,
pub owner_phone_number: Option<i64>, pub owner_phone_number: Option<String>,
pub email: Option<String>, pub email: Option<String>,
pub user_id: Option<i32>, pub user_id: Option<i32>,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)]
#[allow(dead_code)]
pub struct ServiceCategory {
pub id: i32,
pub name: String,
pub description: String,
pub clicks_total: i32,
pub total_companies: i32,
}

View File

@@ -2,13 +2,11 @@ use axum::{
extract::State, extract::State,
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::get,
Router,
}; };
use crate::auth::structs::AppState; use crate::auth::structs::AppState;
// Define the handler for the /user/ endpoint // Define the handler for the /user/ endpoint
async fn get_user(State(state): State<AppState>) -> impl IntoResponse { pub async fn get_user(State(state): State<AppState>) -> impl IntoResponse {
// Placeholder for user data retrieval logic // Placeholder for user data retrieval logic
// In a real application, you would query the database using state.db // In a real application, you would query the database using state.db
let users = sqlx::query("SELECT * FROM users").fetch_all(&*state.db).await; let users = sqlx::query("SELECT * FROM users").fetch_all(&*state.db).await;
@@ -17,9 +15,3 @@ async fn get_user(State(state): State<AppState>) -> impl IntoResponse {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to retrieve users: {}", e)).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to retrieve users: {}", e)).into_response(),
} }
} }
// Define the router for the data module
pub fn router() -> Router<AppState> {
Router::new()
.route("/user/", get(get_user))
}

240
src/listing/data.rs Normal file
View File

@@ -0,0 +1,240 @@
use axum::{
extract::{Path, State, Extension},
http::StatusCode,
Json,
};
use crate::auth::structs::{AppState, User};
use crate::company::structs::Company;
use crate::listing::structs::{Listing, CreateListingRequest, UpdateListingRequest};
use serde_json::json;
pub async fn get_listings(
State(app_state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
match sqlx::query_as::<_, Listing>(
"SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, user_id, last_edited FROM listings WHERE user_id = $1 ORDER BY id DESC"
)
.bind(user.id)
.fetch_all(&*app_state.db)
.await
{
Ok(listings) => Ok(Json(listings)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to fetch listings: {}", e)}))
)),
}
}
pub async fn get_listing_by_id(
State(app_state): State<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
match sqlx::query_as::<_, Listing>(
"SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, user_id, last_edited FROM listings WHERE id = $1 AND user_id = $2"
)
.bind(listing_id)
.bind(user.id)
.fetch_optional(&*app_state.db)
.await
{
Ok(Some(listing)) => Ok(Json(listing)),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(json!({"error": "Listing not found"}))
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to fetch listing: {}", e)}))
)),
}
}
pub async fn create_listing(
State(app_state): State<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<CreateListingRequest>,
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
eprintln!("DEBUG: Starting create_listing for user_id: {}", user.id);
eprintln!("DEBUG: Payload: {:?}", payload);
// Create the listing directly without company validation
match sqlx::query_as::<_, Listing>(
"INSERT INTO listings (company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, user_id, last_edited"
)
.bind(&payload.company_name)
.bind(payload.is_active)
.bind(payload.price_per_gallon)
.bind(payload.price_per_gallon_cash)
.bind(&payload.note)
.bind(payload.minimum_order)
.bind(payload.service)
.bind(payload.bio_percent)
.bind(&payload.phone)
.bind(&payload.online_ordering)
.bind(payload.county_id)
.bind(user.id)
.fetch_one(&*app_state.db)
.await
{
Ok(listing) => {
eprintln!("DEBUG: Successfully created listing: {:?}", listing);
Ok(Json(listing))
},
Err(e) => {
eprintln!("DEBUG: Error creating listing: {:?}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to create listing: {}", e)}))
))
},
}
}
pub async fn update_listing(
State(app_state): State<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
Json(payload): Json<UpdateListingRequest>,
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
// Build dynamic update query
let mut query = "UPDATE listings SET ".to_string();
let mut params: Vec<String> = Vec::new();
let mut param_count = 1;
if let Some(company_name) = &payload.company_name {
params.push(format!("company_name = ${}", param_count));
param_count += 1;
}
if let Some(is_active) = payload.is_active {
params.push(format!("is_active = ${}", param_count));
param_count += 1;
}
if let Some(price_per_gallon) = payload.price_per_gallon {
params.push(format!("price_per_gallon = ${}", param_count));
param_count += 1;
}
if let Some(price_per_gallon_cash) = payload.price_per_gallon_cash {
params.push(format!("price_per_gallon_cash = ${}", param_count));
param_count += 1;
}
if let Some(note) = &payload.note {
params.push(format!("note = ${}", param_count));
param_count += 1;
}
if let Some(minimum_order) = payload.minimum_order {
params.push(format!("minimum_order = ${}", param_count));
param_count += 1;
}
if let Some(service) = payload.service {
params.push(format!("service = ${}", param_count));
param_count += 1;
}
if let Some(bio_percent) = payload.bio_percent {
params.push(format!("bio_percent = ${}", param_count));
param_count += 1;
}
if let Some(phone) = &payload.phone {
params.push(format!("phone = ${}", param_count));
param_count += 1;
}
if let Some(online_ordering) = &payload.online_ordering {
params.push(format!("online_ordering = ${}", param_count));
param_count += 1;
}
if let Some(county_id) = payload.county_id {
params.push(format!("county_id = ${}", param_count));
param_count += 1;
}
if params.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({"error": "No fields to update"}))
));
}
query.push_str(&params.join(", "));
query.push_str(&format!(" WHERE id = ${} AND user_id = ${} RETURNING *", param_count, param_count + 1));
// This is a simplified version - in production, you'd want to build the query more safely
// For now, let's use a simpler approach
match sqlx::query_as::<_, Listing>(
"UPDATE listings SET company_name = COALESCE($1, company_name), is_active = COALESCE($2, is_active), price_per_gallon = COALESCE($3, price_per_gallon), price_per_gallon_cash = COALESCE($4, price_per_gallon_cash), note = COALESCE($5, note), minimum_order = COALESCE($6, minimum_order), service = COALESCE($7, service), bio_percent = COALESCE($8, bio_percent), phone = COALESCE($9, phone), online_ordering = COALESCE($10, online_ordering), county_id = COALESCE($11, county_id), last_edited = CURRENT_TIMESTAMP WHERE id = $12 AND user_id = $13 RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, user_id, last_edited"
)
.bind(&payload.company_name)
.bind(payload.is_active)
.bind(payload.price_per_gallon)
.bind(payload.price_per_gallon_cash)
.bind(&payload.note)
.bind(payload.minimum_order)
.bind(payload.service)
.bind(payload.bio_percent)
.bind(&payload.phone)
.bind(&payload.online_ordering)
.bind(payload.county_id)
.bind(listing_id)
.bind(user.id)
.fetch_optional(&*app_state.db)
.await
{
Ok(Some(listing)) => Ok(Json(listing)),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(json!({"error": "Listing not found"}))
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to update listing: {}", e)}))
)),
}
}
pub async fn get_listings_by_county(
State(app_state): State<AppState>,
Path(county_id): Path<i32>,
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
match sqlx::query_as::<_, Listing>(
"SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, user_id, last_edited FROM listings WHERE county_id = $1 AND is_active = true ORDER BY last_edited DESC"
)
.bind(county_id)
.fetch_all(&*app_state.db)
.await
{
Ok(listings) => Ok(Json(listings)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to fetch listings: {}", e)}))
)),
}
}
pub async fn delete_listing(
State(app_state): State<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
match sqlx::query("DELETE FROM listings WHERE id = $1 AND user_id = $2")
.bind(listing_id)
.bind(user.id)
.execute(&*app_state.db)
.await
{
Ok(result) => {
if result.rows_affected() == 0 {
Err((
StatusCode::NOT_FOUND,
Json(json!({"error": "Listing not found"}))
))
} else {
Ok(Json(json!({"success": true, "message": "Listing deleted"})))
}
}
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to delete listing: {}", e)}))
)),
}
}

2
src/listing/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod structs;
pub mod data;

52
src/listing/structs.rs Normal file
View File

@@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use chrono::{DateTime, Utc};
#[derive(Debug, Serialize, Deserialize, FromRow)]
#[allow(dead_code)]
pub struct Listing {
pub id: i32,
pub company_name: String,
pub is_active: bool,
pub price_per_gallon: f64,
pub price_per_gallon_cash: Option<f64>,
pub note: Option<String>,
pub minimum_order: Option<i32>,
pub service: bool,
pub bio_percent: i32,
pub phone: Option<String>,
pub online_ordering: String,
pub county_id: i32,
pub user_id: i32,
pub last_edited: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateListingRequest {
pub company_name: String,
pub is_active: bool,
pub price_per_gallon: f64,
pub price_per_gallon_cash: Option<f64>,
pub note: Option<String>,
pub minimum_order: Option<i32>,
pub service: bool,
pub bio_percent: i32,
pub phone: Option<String>,
pub online_ordering: String,
pub county_id: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateListingRequest {
pub company_name: Option<String>,
pub is_active: Option<bool>,
pub price_per_gallon: Option<f64>,
pub price_per_gallon_cash: Option<f64>,
pub note: Option<String>,
pub minimum_order: Option<i32>,
pub service: Option<bool>,
pub bio_percent: Option<i32>,
pub phone: Option<String>,
pub online_ordering: Option<String>,
pub county_id: Option<i32>,
}

View File

@@ -6,10 +6,11 @@ use axum::{
use std::env; use std::env;
use tower_http::cors::{CorsLayer, Any}; use tower_http::cors::{CorsLayer, Any};
use crate::auth::structs::AppState; use crate::auth::structs::AppState;
use crate::auth::auth::router as auth_router; use crate::auth::auth::{auth_middleware, login, register};
use crate::data::data::router as data_router; use crate::data::data::get_user;
use crate::state::data::router as state_router; use crate::state::data::{get_counties_by_state, get_county_by_id};
use crate::company::company::company_routes; use crate::listing::data::{get_listings, get_listing_by_id, get_listings_by_county, create_listing, update_listing, delete_listing};
use axum::middleware;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
@@ -17,6 +18,7 @@ mod auth;
mod data; mod data;
mod state; mod state;
mod company; mod company;
mod listing;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -35,7 +37,6 @@ async fn main() {
// Create app state // Create app state
let state = AppState { let state = AppState {
db_url: database_url.clone(),
db: db.clone(), db: db.clone(),
jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"), jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
}; };
@@ -43,18 +44,29 @@ async fn main() {
// Configure CORS // Configure CORS
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(tower_http::cors::AllowOrigin::exact(frontend_origin.parse::<header::HeaderValue>().unwrap())) .allow_origin(tower_http::cors::AllowOrigin::exact(frontend_origin.parse::<header::HeaderValue>().unwrap()))
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(Any); .allow_headers(Any);
// Build router // Build router with separated public and protected routes
let app = Router::new() let protected_routes = Router::new()
.route("/", axum::routing::get(|| async { "API is running" })) .route("/user", axum::routing::get(get_user))
.merge(auth_router()) .route("/company", axum::routing::any(crate::company::company::company_handler))
.merge(data_router()) .route("/listing", axum::routing::get(get_listings))
.merge(state_router()) .route("/listing", axum::routing::post(create_listing))
.merge(company_routes().await) .route("/listing/:listing_id", axum::routing::get(get_listing_by_id))
.with_state(state.clone()) .route("/listing/:listing_id", axum::routing::put(update_listing))
.layer(cors); .route("/listing/:listing_id", axum::routing::delete(delete_listing))
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
let public_routes = Router::new()
.route("/auth/register", axum::routing::post(register))
.route("/auth/login", axum::routing::post(login))
.route("/categories", axum::routing::get(crate::company::category::get_all_categories))
.route("/state/:state_abbr", axum::routing::get(get_counties_by_state))
.route("/state/:state_abbr/:county_id", axum::routing::get(get_county_by_id))
.route("/listings/county/:county_id", axum::routing::get(get_listings_by_county));
let app = public_routes.merge(protected_routes).with_state(state).layer(cors);
// Print server status // Print server status
println!("Server is running on http://0.0.0.0:9552"); println!("Server is running on http://0.0.0.0:9552");

View File

@@ -2,19 +2,11 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Json, response::Json,
http::StatusCode, http::StatusCode,
Router,
routing::get,
}; };
use crate::auth::structs::AppState; use crate::auth::structs::AppState;
use crate::state::structs::{County, ErrorResponse}; use crate::state::structs::{County, ErrorResponse};
pub fn router() -> Router<AppState> {
Router::new()
.route("/state/:state_abbr", get(get_counties_by_state))
.route("/state/:state_abbr/:county_id", get(get_county_by_id))
}
pub async fn get_counties_by_state( pub async fn get_counties_by_state(
@@ -84,4 +76,3 @@ pub async fn get_county_by_id(
} }
} }
} }