diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c885bec --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 200c322..93fa393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index cbaaa0c..4881aff 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/alter_column.sql b/alter_column.sql new file mode 100644 index 0000000..30c1dd2 --- /dev/null +++ b/alter_column.sql @@ -0,0 +1 @@ +ALTER TABLE listings ALTER COLUMN price_per_gallon TYPE DOUBLE PRECISION; diff --git a/drop_column.sql b/drop_column.sql new file mode 100644 index 0000000..ef0f2a0 --- /dev/null +++ b/drop_column.sql @@ -0,0 +1 @@ +ALTER TABLE listings DROP COLUMN company_id; diff --git a/schema.sql b/schema.sql index a8acdce..8a5452b 100755 --- a/schema.sql +++ b/schema.sql @@ -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; diff --git a/seed_categories.sql b/seed_categories.sql new file mode 100644 index 0000000..35e3d87 --- /dev/null +++ b/seed_categories.sql @@ -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); diff --git a/src/auth/auth.rs b/src/auth/auth.rs index f10556e..76a7592 100755 --- a/src/auth/auth.rs +++ b/src/auth/auth.rs @@ -1,25 +1,17 @@ use axum::{ extract::{State, Json}, http::StatusCode, - response::{IntoResponse,Response}, - routing::post, - Router, + middleware::Next, + response::{IntoResponse, Response}, + 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 { - 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(err: E) -> Response @@ -38,7 +30,7 @@ where } -async fn register( +pub async fn register( State(state): State, Json(payload): Json, ) -> impl IntoResponse { @@ -94,8 +86,8 @@ pub async fn login( Json(payload): Json, ) -> 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, + mut request: HttpRequest, + next: Next, +) -> Result { + // 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::( + 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) +} diff --git a/src/auth/structs.rs b/src/auth/structs.rs index 1c0b024..4fb772d 100755 --- a/src/auth/structs.rs +++ b/src/auth/structs.rs @@ -7,13 +7,12 @@ use chrono::NaiveDateTime; #[derive(Clone)] pub struct AppState { - pub db_url: String, pub db: Arc, pub jwt_secret: String, } -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Clone, Debug, Serialize, Deserialize, FromRow)] pub struct User { pub id: i32, pub username: String, diff --git a/src/company/category.rs b/src/company/category.rs new file mode 100644 index 0000000..f4a84a0 --- /dev/null +++ b/src/company/category.rs @@ -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, +) -> Result>, (StatusCode, Json)> { + 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(), + }), + )) + } + } +} diff --git a/src/company/company.rs b/src/company/company.rs index 744d6d1..f9560d8 100644 --- a/src/company/company.rs +++ b/src/company/company.rs @@ -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 { - 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, + pub town: Option, + pub state: Option, + pub phone: Option, + pub owner_name: Option, + pub owner_phone_number: Option, + pub email: Option, } +// 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( - State(state): State, - Json(company): Json, -) -> Response { - let result = query_as::<_, Company>( - "SELECT * FROM company WHERE id = $1", - ) - .bind(company.id) - .fetch_optional(&*state.db) - .await; - - 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" - ) - .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; - - 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)" - ) - .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; - - 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(), - } - } - } - } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error checking for existing company: {}", e)).into_response(), +// 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, + Extension(user): Extension, +) -> 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(), + } +} + +pub async fn create_company( + State(state): State, + Extension(user): Extension, + Json(payload): Json, +) -> 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(&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(), + } +} + +pub async fn update_company( + State(state): State, + Extension(user): Extension, + Json(payload): Json, +) -> 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(&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(), + } +} + +pub async fn delete_company( + State(state): State, + Extension(user): Extension, +) -> 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(), + } +} + +pub async fn company_handler( + request: Request, +) -> impl IntoResponse { + // Extract user and state from extensions + let user = match request.extensions().get::().cloned() { + Some(user) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let state = match request.extensions().get::().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(), } } diff --git a/src/company/mod.rs b/src/company/mod.rs index e721cc2..372d698 100644 --- a/src/company/mod.rs +++ b/src/company/mod.rs @@ -1,2 +1,3 @@ pub mod company; -pub mod structs; \ No newline at end of file +pub mod category; +pub mod structs; diff --git a/src/company/structs.rs b/src/company/structs.rs index d8cb256..3a826a5 100644 --- a/src/company/structs.rs +++ b/src/company/structs.rs @@ -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, pub town: Option, pub state: Option, - pub phone: Option, + pub phone: Option, pub owner_name: Option, - pub owner_phone_number: Option, + pub owner_phone_number: Option, pub email: Option, pub user_id: Option, } - \ No newline at end of file + +#[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, +} diff --git a/src/data/data.rs b/src/data/data.rs index bf2260f..424ddb1 100644 --- a/src/data/data.rs +++ b/src/data/data.rs @@ -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) -> impl IntoResponse { +pub async fn get_user(State(state): State) -> 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) -> 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 { - Router::new() - .route("/user/", get(get_user)) -} diff --git a/src/listing/data.rs b/src/listing/data.rs new file mode 100644 index 0000000..47ad729 --- /dev/null +++ b/src/listing/data.rs @@ -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, + Extension(user): Extension, +) -> Result>, (StatusCode, Json)> { + 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, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + 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, + Extension(user): Extension, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + 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, + Path(listing_id): Path, + Extension(user): Extension, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + // Build dynamic update query + let mut query = "UPDATE listings SET ".to_string(); + let mut params: Vec = 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(¶ms.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, + Path(county_id): Path, +) -> Result>, (StatusCode, Json)> { + 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, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + 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)})) + )), + } +} diff --git a/src/listing/mod.rs b/src/listing/mod.rs new file mode 100644 index 0000000..0499a0b --- /dev/null +++ b/src/listing/mod.rs @@ -0,0 +1,2 @@ +pub mod structs; +pub mod data; diff --git a/src/listing/structs.rs b/src/listing/structs.rs new file mode 100644 index 0000000..5ce936e --- /dev/null +++ b/src/listing/structs.rs @@ -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, + pub note: Option, + pub minimum_order: Option, + pub service: bool, + pub bio_percent: i32, + pub phone: Option, + pub online_ordering: String, + pub county_id: i32, + pub user_id: i32, + pub last_edited: DateTime, +} + +#[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, + pub note: Option, + pub minimum_order: Option, + pub service: bool, + pub bio_percent: i32, + pub phone: Option, + pub online_ordering: String, + pub county_id: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateListingRequest { + pub company_name: Option, + pub is_active: Option, + pub price_per_gallon: Option, + pub price_per_gallon_cash: Option, + pub note: Option, + pub minimum_order: Option, + pub service: Option, + pub bio_percent: Option, + pub phone: Option, + pub online_ordering: Option, + pub county_id: Option, +} diff --git a/src/main.rs b/src/main.rs index 0395ea6..8354fc8 100755 --- a/src/main.rs +++ b/src/main.rs @@ -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::().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"); diff --git a/src/state/data.rs b/src/state/data.rs index 270365a..d9070a1 100644 --- a/src/state/data.rs +++ b/src/state/data.rs @@ -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 { - 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( } } } -