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",
"chrono",
"dotenv",
"hyper",
"jsonwebtoken",
"serde",
"serde_json",
@@ -126,6 +127,7 @@ dependencies = [
"bitflags 1.3.2",
"bytes",
"futures-util",
"headers",
"http",
"http-body",
"hyper",
@@ -524,6 +526,30 @@ dependencies = [
"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]]
name = "heck"
version = "0.4.1"

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.6.20"
axum = { version = "0.6.20", features = ["headers"] }
tokio = { version = "1.35.1", features = ["full"] }
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "chrono"] }
serde = { version = "1.0", features = ["derive"] }
@@ -14,3 +14,4 @@ chrono = { version = "0.4", features = ["serde"] }
dotenv = "0.15"
tower-http = { version = "0.4", features = ["cors"] }
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,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
created TIMESTAMPTZ NOT NULL,
created TIMESTAMPTZ,
email VARCHAR(255),
last_login TIMESTAMPTZ,
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::{
extract::{State, Json},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
routing::post,
Router,
body::Body,
http::Request as HttpRequest,
};
use crate::auth::structs::{AppState, User, RegisterRequest, LoginRequest, Claims};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use jsonwebtoken::{encode, Header, EncodingKey};
// Create auth router
pub fn router() -> Router<AppState> {
Router::new()
.route("/auth/register", post(register))
.route("/auth/login", post(login))
}
use jsonwebtoken::{decode, encode, Header, EncodingKey, DecodingKey, Validation};
// A helper function to convert any error into a 500 Internal Server Error response.
fn internal_error<E>(err: E) -> Response
@@ -38,7 +30,7 @@ where
}
async fn register(
pub async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> impl IntoResponse {
@@ -94,8 +86,8 @@ pub async fn login(
Json(payload): Json<LoginRequest>,
) -> impl IntoResponse {
// 1. Fetch user from the database
let user = match sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
.bind(&payload.username)
let user = match sqlx::query_as::<_, User>("SELECT * FROM users WHERE TRIM(username) = $1")
.bind(&payload.username.trim())
.fetch_optional(&*state.db)
.await
{
@@ -157,3 +149,55 @@ pub async fn login(
(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)]
pub struct AppState {
pub db_url: String,
pub db: Arc<PgPool>,
pub jwt_secret: String,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
#[derive(Clone, Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: i32,
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::{
http::StatusCode,
http::{StatusCode, Method},
response::{IntoResponse, Response},
Json, Router, extract::State,
routing::post,
Json, extract::{State, Extension},
body::Body,
http::Request,
};
use crate::auth::structs::AppState;
use sqlx::query_as;
use crate::auth::structs::{AppState, User};
use crate::company::structs::Company;
use serde::{Deserialize, Serialize};
use serde_json::json;
pub async fn company_routes() -> Router<AppState> {
let router = Router::new().route("/company", post(update_or_create_company));
router
#[derive(Debug, Serialize, Deserialize)]
pub struct CompanyRequest {
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>,
Json(company): Json<Company>,
Extension(user): Extension<User>,
) -> Response {
let result = query_as::<_, Company>(
"SELECT * FROM company WHERE id = $1",
match sqlx::query_as::<_, Company>(
"SELECT * FROM company WHERE user_id = $1 AND active = true"
)
.bind(company.id)
.bind(user.id)
.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 {
Ok(existing_company) => {
match existing_company {
Some(_) => {
// Update existing company
let result = sqlx::query(
"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"
pub async fn create_company(
State(state): State<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<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::text, $5, $6, $7, $8, $9, true, CURRENT_DATE) RETURNING *"
)
.bind(company.id)
.bind(company.active)
.bind(company.name)
.bind(company.address)
.bind(company.town)
.bind(company.state)
.bind(company.phone)
.bind(company.owner_name)
.bind(company.owner_phone_number)
.bind(company.email)
.bind(company.user_id)
.execute(&*state.db)
.await;
.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(),
}
}
match result {
Ok(_) => (StatusCode::OK, "Company updated successfully".to_string()).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error updating company: {}", e)).into_response(),
}
}
None => {
// Create new company
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)"
pub async fn update_company(
State(state): State<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<CompanyRequest>,
) -> Response {
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(company.active)
.bind(chrono::Utc::now().naive_utc().date())
.bind(company.name)
.bind(company.address)
.bind(company.town)
.bind(company.state)
.bind(company.phone)
.bind(company.owner_name)
.bind(company.owner_phone_number)
.bind(company.email)
.bind(company.user_id)
.execute(&*state.db)
.await;
.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)) => (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 {
Ok(_) => (StatusCode::CREATED, "Company created successfully".to_string()).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error creating company: {}", e)).into_response(),
pub async fn delete_company(
State(state): State<AppState>,
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 category;
pub mod structs;

View File

@@ -4,6 +4,7 @@ use sqlx::FromRow;
use chrono::NaiveDate;
#[derive(Debug, Serialize, Deserialize, FromRow)]
#[allow(dead_code)]
pub struct Company {
pub id: i32,
pub active: bool,
@@ -12,10 +13,19 @@ pub struct Company {
pub address: Option<String>,
pub town: Option<String>,
pub state: Option<String>,
pub phone: Option<i64>,
pub phone: Option<String>,
pub owner_name: Option<String>,
pub owner_phone_number: Option<i64>,
pub owner_phone_number: Option<String>,
pub email: Option<String>,
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,
http::StatusCode,
response::IntoResponse,
routing::get,
Router,
};
use crate::auth::structs::AppState;
// 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
// In a real application, you would query the database using state.db
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(),
}
}
// 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 tower_http::cors::{CorsLayer, Any};
use crate::auth::structs::AppState;
use crate::auth::auth::router as auth_router;
use crate::data::data::router as data_router;
use crate::state::data::router as state_router;
use crate::company::company::company_routes;
use crate::auth::auth::{auth_middleware, login, register};
use crate::data::data::get_user;
use crate::state::data::{get_counties_by_state, get_county_by_id};
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 std::sync::Arc;
@@ -17,6 +18,7 @@ mod auth;
mod data;
mod state;
mod company;
mod listing;
#[tokio::main]
async fn main() {
@@ -35,7 +37,6 @@ async fn main() {
// Create app state
let state = AppState {
db_url: database_url.clone(),
db: db.clone(),
jwt_secret: env::var("JWT_SECRET").expect("JWT_SECRET must be set"),
};
@@ -43,18 +44,29 @@ async fn main() {
// Configure CORS
let cors = CorsLayer::new()
.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);
// Build router
let app = Router::new()
.route("/", axum::routing::get(|| async { "API is running" }))
.merge(auth_router())
.merge(data_router())
.merge(state_router())
.merge(company_routes().await)
.with_state(state.clone())
.layer(cors);
// Build router with separated public and protected routes
let protected_routes = Router::new()
.route("/user", axum::routing::get(get_user))
.route("/company", axum::routing::any(crate::company::company::company_handler))
.route("/listing", axum::routing::get(get_listings))
.route("/listing", axum::routing::post(create_listing))
.route("/listing/:listing_id", axum::routing::get(get_listing_by_id))
.route("/listing/:listing_id", axum::routing::put(update_listing))
.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
println!("Server is running on http://0.0.0.0:9552");

View File

@@ -2,19 +2,11 @@ use axum::{
extract::{Path, State},
response::Json,
http::StatusCode,
Router,
routing::get,
};
use crate::auth::structs::AppState;
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(
@@ -84,4 +76,3 @@ pub async fn get_county_by_id(
}
}
}