diff --git a/Cargo.lock b/Cargo.lock index 24b1b05..d17586b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -148,6 +149,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -447,6 +449,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -461,9 +472,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -804,9 +815,9 @@ dependencies = [ [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -953,6 +964,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -979,6 +1000,24 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -1123,9 +1162,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" @@ -1286,7 +1325,7 @@ dependencies = [ "cc", "libc", "once_cell", - "spin", + "spin 0.5.2", "untrusted 0.7.1", "web-sys", "winapi", @@ -1495,6 +1534,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "sqlformat" version = "0.2.6" @@ -1817,6 +1862,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.4.13" @@ -1846,9 +1904,16 @@ dependencies = [ "http", "http-body", "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1937,6 +2002,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -1990,13 +2061,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a8b5e15..48c763a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -axum = { version = "0.6.20", features = ["headers"] } +axum = { version = "0.6.20", features = ["headers", "multipart"] } tokio = { version = "1.35.1", features = ["full"] } sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "chrono"] } serde = { version = "1.0", features = ["derive"] } @@ -12,10 +12,11 @@ serde_json = "1.0" jsonwebtoken = "8.1" chrono = { version = "0.4", features = ["serde"] } dotenv = "0.15" -tower-http = { version = "0.4", features = ["cors"] } +tower-http = { version = "0.4", features = ["cors", "fs"] } argon2 = { version = "0.5.3", features = ["std"] } hyper = "0.14" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum-extra = { version = "0.7", features = ["cookie"] } time = "0.3" +url = "2.5.8" diff --git a/Dockerfile.dev b/Dockerfile.dev index 01fc454..c9535b3 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,15 +1,5 @@ -# Development stage for Rust API -FROM rust:latest AS builder -WORKDIR /usr/src/app -COPY . . -RUN cargo install cargo-watch -RUN cargo build --release - -# Runtime stage for development FROM rust:latest +RUN cargo install cargo-watch WORKDIR /usr/src/app -COPY --from=builder /usr/src/app/target/release/api_rust /usr/local/bin/api_rust -COPY --from=builder /usr/local/cargo/bin/cargo-watch /usr/local/bin/cargo-watch -COPY . . EXPOSE 9552 CMD ["cargo-watch", "-x", "run"] diff --git a/alter_column.sql b/alter_column.sql deleted file mode 100644 index 30c1dd2..0000000 --- a/alter_column.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE listings ALTER COLUMN price_per_gallon TYPE DOUBLE PRECISION; diff --git a/drop_column.sql b/drop_column.sql deleted file mode 100644 index ef0f2a0..0000000 --- a/drop_column.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE listings DROP COLUMN company_id; diff --git a/init_fuelprices_prod.sql b/init_fuelprices_prod.sql new file mode 100644 index 0000000..a2f97ab --- /dev/null +++ b/init_fuelprices_prod.sql @@ -0,0 +1,269 @@ +-- ============================================================ +-- Production database initialization for fuelprices_prod +-- +-- Run in two steps: +-- 1. psql -h 192.168.1.204 -U postgres -c "CREATE DATABASE fuelprices_prod;" +-- 2. psql -h 192.168.1.204 -U postgres -d fuelprices_prod -f init_fuelprices_prod.sql +-- ============================================================ + +-- ---- Tables ------------------------------------------------ + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password TEXT NOT NULL, + created TIMESTAMPTZ, + email VARCHAR(255), + last_login TIMESTAMPTZ, + owner INTEGER +); + +CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS company ( + 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 IF NOT EXISTS county ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + state VARCHAR(2) NOT NULL, + UNIQUE(name, state) +); + +CREATE TABLE IF NOT EXISTS 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, + town VARCHAR(100), + url VARCHAR(255), + logo_url VARCHAR(255), + banner_url VARCHAR(255), + facebook_url VARCHAR(255), + instagram_url VARCHAR(255), + google_business_url VARCHAR(255), + user_id INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS oil_prices ( + id SERIAL PRIMARY KEY, + state VARCHAR(100), + zone INTEGER, + name VARCHAR(255), + price DOUBLE PRECISION, + date VARCHAR(20), + scrapetimestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + company_id INTEGER, + county_id INTEGER, + phone VARCHAR(20), + url VARCHAR(500) +); + +CREATE TABLE IF NOT EXISTS stats_prices ( + id SERIAL PRIMARY KEY, + state VARCHAR(2) NOT NULL, + price DOUBLE PRECISION NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS service_listings ( + id SERIAL PRIMARY KEY, + company_name VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT true, + twenty_four_hour BOOLEAN DEFAULT false, + emergency_service BOOLEAN DEFAULT false, + town VARCHAR(100), + county_id INTEGER NOT NULL, + phone VARCHAR(20), + website VARCHAR(255), + email VARCHAR(255), + description TEXT, + licensed_insured BOOLEAN DEFAULT false, + service_area VARCHAR(255), + years_experience INTEGER, + logo_url VARCHAR(255), + banner_url VARCHAR(255), + facebook_url VARCHAR(255), + instagram_url VARCHAR(255), + google_business_url VARCHAR(255), + user_id INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + company_id INTEGER NOT NULL UNIQUE, + trial_start DATE NOT NULL DEFAULT CURRENT_DATE, + trial_end DATE NOT NULL DEFAULT (CURRENT_DATE + INTERVAL '1 year'), + status VARCHAR(20) NOT NULL DEFAULT 'trial', + plan VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS banners ( + id SERIAL PRIMARY KEY, + message TEXT NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS listing_towns ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + town VARCHAR(100) NOT NULL, + UNIQUE(listing_id, town) +); + +CREATE TABLE IF NOT EXISTS service_listing_towns ( + id SERIAL PRIMARY KEY, + service_listing_id INTEGER NOT NULL REFERENCES service_listings(id) ON DELETE CASCADE, + town VARCHAR(100) NOT NULL, + UNIQUE(service_listing_id, town) +); + +-- ---- County seed data (all 6 New England states) ----------- + +INSERT INTO county (name, state) VALUES + -- Connecticut + ('Fairfield', 'CT'), + ('Hartford', 'CT'), + ('Litchfield', 'CT'), + ('Middlesex', 'CT'), + ('New Haven', 'CT'), + ('New London', 'CT'), + ('Tolland', 'CT'), + ('Windham', 'CT'), + -- Maine + ('Androscoggin','ME'), + ('Aroostook', 'ME'), + ('Cumberland', 'ME'), + ('Franklin', 'ME'), + ('Hancock', 'ME'), + ('Kennebec', 'ME'), + ('Knox', 'ME'), + ('Lincoln', 'ME'), + ('Oxford', 'ME'), + ('Penobscot', 'ME'), + ('Piscataquis', 'ME'), + ('Sagadahoc', 'ME'), + ('Somerset', 'ME'), + ('Waldo', 'ME'), + ('Washington', 'ME'), + ('York', 'ME'), + -- Massachusetts + ('Barnstable', 'MA'), + ('Berkshire', 'MA'), + ('Bristol', 'MA'), + ('Dukes', 'MA'), + ('Essex', 'MA'), + ('Franklin', 'MA'), + ('Hampden', 'MA'), + ('Hampshire', 'MA'), + ('Middlesex', 'MA'), + ('Nantucket', 'MA'), + ('Norfolk', 'MA'), + ('Plymouth', 'MA'), + ('Suffolk', 'MA'), + ('Worcester', 'MA'), + -- New Hampshire + ('Belknap', 'NH'), + ('Carroll', 'NH'), + ('Cheshire', 'NH'), + ('Coos', 'NH'), + ('Grafton', 'NH'), + ('Hillsborough','NH'), + ('Merrimack', 'NH'), + ('Rockingham', 'NH'), + ('Strafford', 'NH'), + ('Sullivan', 'NH'), + -- Rhode Island + ('Bristol', 'RI'), + ('Kent', 'RI'), + ('Newport', 'RI'), + ('Providence', 'RI'), + ('Washington', 'RI'), + -- Vermont + ('Addison', 'VT'), + ('Bennington', 'VT'), + ('Caledonia', 'VT'), + ('Chittenden', 'VT'), + ('Essex', 'VT'), + ('Franklin', 'VT'), + ('Grand Isle', 'VT'), + ('Lamoille', 'VT'), + ('Orange', 'VT'), + ('Orleans', 'VT'), + ('Rutland', 'VT'), + ('Washington', 'VT'), + ('Windham', 'VT'), + ('Windsor', 'VT') +ON CONFLICT (name, state) DO NOTHING; + +-- ---- Service categories seed data -------------------------- + +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) +ON CONFLICT DO NOTHING; diff --git a/migrate_banner_url.sql b/migrate_banner_url.sql new file mode 100644 index 0000000..7348f93 --- /dev/null +++ b/migrate_banner_url.sql @@ -0,0 +1,10 @@ +-- Migration: Add banner_url column to listings and service_listings +-- Run against fuelprices database: +-- docker run --rm postgres:15 psql "postgres://postgres:password@192.168.1.204:5432/fuelprices" -f /path/to/this/file +-- Or inline: +-- docker run --rm postgres:15 psql "postgres://postgres:password@192.168.1.204:5432/fuelprices" \ +-- -c "ALTER TABLE listings ADD COLUMN IF NOT EXISTS banner_url VARCHAR(255);" \ +-- -c "ALTER TABLE service_listings ADD COLUMN IF NOT EXISTS banner_url VARCHAR(255);" + +ALTER TABLE listings ADD COLUMN IF NOT EXISTS banner_url VARCHAR(255); +ALTER TABLE service_listings ADD COLUMN IF NOT EXISTS banner_url VARCHAR(255); diff --git a/migrate_logo_url.sql b/migrate_logo_url.sql new file mode 100644 index 0000000..cc63299 --- /dev/null +++ b/migrate_logo_url.sql @@ -0,0 +1,10 @@ +-- Migration: Add logo_url column to listings and service_listings +-- Run against fuelprices database: +-- docker run --rm postgres:15 psql "postgres://postgres:password@192.168.1.204:5432/fuelprices" -f /path/to/this/file +-- Or inline: +-- docker run --rm postgres:15 psql "postgres://postgres:password@192.168.1.204:5432/fuelprices" \ +-- -c "ALTER TABLE listings ADD COLUMN IF NOT EXISTS logo_url VARCHAR(255);" \ +-- -c "ALTER TABLE service_listings ADD COLUMN IF NOT EXISTS logo_url VARCHAR(255);" + +ALTER TABLE listings ADD COLUMN IF NOT EXISTS logo_url VARCHAR(255); +ALTER TABLE service_listings ADD COLUMN IF NOT EXISTS logo_url VARCHAR(255); diff --git a/migrate_service_listings.sql b/migrate_service_listings.sql new file mode 100644 index 0000000..1f1a4b0 --- /dev/null +++ b/migrate_service_listings.sql @@ -0,0 +1,24 @@ +-- Migration: Add service_listings table +-- Run this against the fuelprices database to enable the service companies feature. +-- +-- psql -h 192.168.1.204 -U -d fuelprices -f migrate_service_listings.sql + +CREATE TABLE IF NOT EXISTS service_listings ( + id SERIAL PRIMARY KEY, + company_name VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT true, + twenty_four_hour BOOLEAN DEFAULT false, + emergency_service BOOLEAN DEFAULT false, + town VARCHAR(100), + county_id INTEGER NOT NULL, + phone VARCHAR(20), + website VARCHAR(255), + email VARCHAR(255), + description TEXT, + licensed_insured BOOLEAN DEFAULT false, + service_area VARCHAR(255), + years_experience INTEGER, + user_id INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrate_towns_serviced.sql b/migrate_towns_serviced.sql new file mode 100644 index 0000000..1c12658 --- /dev/null +++ b/migrate_towns_serviced.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS listing_towns ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + town VARCHAR(100) NOT NULL, + UNIQUE(listing_id, town) +); + +CREATE TABLE IF NOT EXISTS service_listing_towns ( + id SERIAL PRIMARY KEY, + service_listing_id INTEGER NOT NULL REFERENCES service_listings(id) ON DELETE CASCADE, + town VARCHAR(100) NOT NULL, + UNIQUE(service_listing_id, town) +); diff --git a/schema.sql b/schema.sql index 2068f05..bf40e02 100755 --- a/schema.sql +++ b/schema.sql @@ -54,6 +54,11 @@ CREATE TABLE listings ( county_id INTEGER NOT NULL, town VARCHAR(100), url VARCHAR(255), + logo_url VARCHAR(255), + banner_url VARCHAR(255), + facebook_url VARCHAR(255), + instagram_url VARCHAR(255), + google_business_url VARCHAR(255), user_id INTEGER NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP @@ -90,3 +95,65 @@ CREATE TABLE stats_prices ( price DOUBLE PRECISION NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); + +-- Boiler/HVAC service companies (separate from fuel price listings) +CREATE TABLE service_listings ( + id SERIAL PRIMARY KEY, + company_name VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT true, + twenty_four_hour BOOLEAN DEFAULT false, -- 24/7 emergency coverage + emergency_service BOOLEAN DEFAULT false, -- emergency same-day response + town VARCHAR(100), + county_id INTEGER NOT NULL, + phone VARCHAR(20), + website VARCHAR(255), + email VARCHAR(255), + description TEXT, -- short description of services offered + licensed_insured BOOLEAN DEFAULT false, -- licensed and insured + service_area VARCHAR(255), -- e.g. "All of Middlesex County", "Greater Boston" + years_experience INTEGER, -- years in business + logo_url VARCHAR(255), + banner_url VARCHAR(255), + facebook_url VARCHAR(255), + instagram_url VARCHAR(255), + google_business_url VARCHAR(255), + user_id INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Run this to create the table if not exists (after initial schema is deployed): +-- CREATE TABLE IF NOT EXISTS service_listings ( ... same as above ... ); + +-- Subscription tracking (one per company, auto-created with 1-year trial) +CREATE TABLE subscriptions ( + id SERIAL PRIMARY KEY, + company_id INTEGER NOT NULL UNIQUE, + trial_start DATE NOT NULL DEFAULT CURRENT_DATE, + trial_end DATE NOT NULL DEFAULT (CURRENT_DATE + INTERVAL '1 year'), + status VARCHAR(20) NOT NULL DEFAULT 'trial', + plan VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Admin-managed site-wide banners +CREATE TABLE banners ( + id SERIAL PRIMARY KEY, + message TEXT NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE listing_towns ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + town VARCHAR(100) NOT NULL, + UNIQUE(listing_id, town) +); + +CREATE TABLE service_listing_towns ( + id SERIAL PRIMARY KEY, + service_listing_id INTEGER NOT NULL REFERENCES service_listings(id) ON DELETE CASCADE, + town VARCHAR(100) NOT NULL, + UNIQUE(service_listing_id, town) +); diff --git a/src/banner/data.rs b/src/banner/data.rs new file mode 100644 index 0000000..94c7db0 --- /dev/null +++ b/src/banner/data.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use crate::auth::structs::AppState; +use crate::banner::structs::{Banner, CreateBannerRequest}; +use serde_json::json; + +/// Public: Get the currently active banner (if any) +pub async fn get_active_banner( + State(app_state): State, +) -> Result>, (StatusCode, Json)> { + match sqlx::query_as::<_, Banner>( + "SELECT * FROM banners WHERE is_active = true ORDER BY created_at DESC LIMIT 1" + ) + .fetch_optional(&*app_state.db) + .await + { + Ok(banner) => Ok(Json(banner)), + Err(e) => { + tracing::error!(error = %e, "Failed to fetch active banner"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})) + )) + } + } +} + +/// Admin: Create a new banner (deactivates all existing banners first) +pub async fn create_banner( + State(app_state): State, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + // Deactivate all existing banners + let _ = sqlx::query("UPDATE banners SET is_active = false") + .execute(&*app_state.db) + .await; + + // Create new active banner + match sqlx::query_as::<_, Banner>( + "INSERT INTO banners (message, is_active) VALUES ($1, true) RETURNING *" + ) + .bind(&payload.message) + .fetch_one(&*app_state.db) + .await + { + Ok(banner) => { + tracing::info!(banner_id = banner.id, "Banner created"); + Ok(Json(banner)) + }, + Err(e) => { + tracing::error!(error = %e, "Failed to create banner"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})) + )) + } + } +} + +/// Admin: Delete (deactivate) a banner +pub async fn delete_banner( + State(app_state): State, + Path(banner_id): Path, +) -> Result, (StatusCode, Json)> { + match sqlx::query("UPDATE banners SET is_active = false WHERE id = $1") + .bind(banner_id) + .execute(&*app_state.db) + .await + { + Ok(_) => { + tracing::info!(banner_id = banner_id, "Banner deactivated"); + Ok(Json(json!({"success": true, "message": "Banner deactivated"}))) + }, + Err(e) => { + tracing::error!(error = %e, "Failed to delete banner"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})) + )) + } + } +} diff --git a/src/banner/mod.rs b/src/banner/mod.rs new file mode 100644 index 0000000..62c697c --- /dev/null +++ b/src/banner/mod.rs @@ -0,0 +1,2 @@ +pub mod data; +pub mod structs; diff --git a/src/banner/structs.rs b/src/banner/structs.rs new file mode 100644 index 0000000..3ec575b --- /dev/null +++ b/src/banner/structs.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct Banner { + pub id: i32, + pub message: String, + pub is_active: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateBannerRequest { + pub message: String, +} diff --git a/src/company/company.rs b/src/company/company.rs index d1d1c42..542b3e3 100644 --- a/src/company/company.rs +++ b/src/company/company.rs @@ -238,3 +238,133 @@ async fn delete_company_logic(state: &AppState, user: &User) -> Response { }, } } + +// =============================================================== +// PUBLIC COMPANY PROFILE +// =============================================================== + +use axum::extract::{Path, State}; +use crate::listing::structs::Listing; +use crate::service_listing::structs::ServiceListing; + +#[derive(Serialize)] +pub struct CompanyProfile { + pub company: Company, + pub fuel_listings: Vec, + pub service_listings: Vec, +} + +pub async fn get_company_profile( + State(app_state): State, + Path(company_id): Path, +) -> impl IntoResponse { + tracing::info!(company_id = company_id, "Fetching public company profile"); + + // Fetch the company + let company = match sqlx::query_as::<_, Company>( + "SELECT * FROM company WHERE id = $1 AND active = true" + ) + .bind(company_id) + .fetch_optional(&*app_state.db) + .await + { + Ok(Some(c)) => c, + Ok(None) => { + return (StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response(); + }, + Err(e) => { + tracing::error!(company_id = company_id, error = %e, "DB error fetching company"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(); + } + }; + + let user_id = match company.user_id { + Some(uid) => uid, + None => { + // Company has no user_id, return just the company with empty listings + let profile = CompanyProfile { + company, + fuel_listings: vec![], + service_listings: vec![], + }; + return (StatusCode::OK, Json(profile)).into_response(); + } + }; + + // Fetch fuel listings for this company's user + let fuel_listings = 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, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited FROM listings WHERE user_id = $1 AND is_active = true ORDER BY last_edited DESC" + ) + .bind(user_id) + .fetch_all(&*app_state.db) + .await + .unwrap_or_default(); + + // Fetch service listings for this company's user + let service_listings = sqlx::query_as::<_, ServiceListing>( + "SELECT id, company_name, is_active, twenty_four_hour, emergency_service, town, county_id, phone, website, email, description, licensed_insured, service_area, years_experience, user_id, last_edited, logo_url, banner_url, facebook_url, instagram_url, google_business_url FROM service_listings WHERE user_id = $1 AND is_active = true ORDER BY last_edited DESC" + ) + .bind(user_id) + .fetch_all(&*app_state.db) + .await + .unwrap_or_default(); + + let profile = CompanyProfile { + company, + fuel_listings, + service_listings, + }; + + (StatusCode::OK, Json(profile)).into_response() +} + +pub async fn get_company_profile_by_user( + State(app_state): State, + Path(user_id): Path, +) -> impl IntoResponse { + tracing::info!(user_id = user_id, "Fetching public company profile by user_id"); + + // Fetch the company by user_id + let company = match sqlx::query_as::<_, Company>( + "SELECT * FROM company WHERE user_id = $1 AND active = true" + ) + .bind(user_id) + .fetch_optional(&*app_state.db) + .await + { + Ok(Some(c)) => c, + Ok(None) => { + return (StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response(); + }, + Err(e) => { + tracing::error!(user_id = user_id, error = %e, "DB error fetching company by user"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(); + } + }; + + // Fetch fuel listings for this user + let fuel_listings = 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, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited FROM listings WHERE user_id = $1 AND is_active = true ORDER BY last_edited DESC" + ) + .bind(user_id) + .fetch_all(&*app_state.db) + .await + .unwrap_or_default(); + + // Fetch service listings for this user + let service_listings = sqlx::query_as::<_, ServiceListing>( + "SELECT id, company_name, is_active, twenty_four_hour, emergency_service, town, county_id, phone, website, email, description, licensed_insured, service_area, years_experience, user_id, last_edited, logo_url, banner_url, facebook_url, instagram_url, google_business_url FROM service_listings WHERE user_id = $1 AND is_active = true ORDER BY last_edited DESC" + ) + .bind(user_id) + .fetch_all(&*app_state.db) + .await + .unwrap_or_default(); + + let profile = CompanyProfile { + company, + fuel_listings, + service_listings, + }; + + (StatusCode::OK, Json(profile)).into_response() +} diff --git a/src/data/api_directory.rs b/src/data/api_directory.rs new file mode 100644 index 0000000..4bb080b --- /dev/null +++ b/src/data/api_directory.rs @@ -0,0 +1,93 @@ +use axum::{ + response::Json, + http::StatusCode, +}; +use serde::Serialize; +use serde_json::json; + +#[derive(Serialize)] +pub struct ApiEndpoint { + pub path: String, + pub method: String, + pub description: String, + pub url_params: Option>, +} + +#[derive(Serialize)] +pub struct ApiDirectoryResponse { + pub title: String, + pub description: String, + pub version: String, + pub base_url: String, + pub endpoints: Vec, +} + +pub async fn get_api_directory() -> Result, (StatusCode, Json)> { + let directory = ApiDirectoryResponse { + title: "LocalOilPrices Public API".to_string(), + description: "Public data endpoints for scrapers and bots. All data returned is JSON.".to_string(), + version: "1.0.0".to_string(), + base_url: "https://localoilprices.com".to_string(), // In production this would be dynamic or matched to the domain + endpoints: vec![ + ApiEndpoint { + path: "/state/:state_abbr".to_string(), + method: "GET".to_string(), + description: "Get a list of all counties in a given state.".to_string(), + url_params: Some(vec!["state_abbr (e.g., 'MA', 'CT')".to_string()]), + }, + ApiEndpoint { + path: "/state/:state_abbr/:county_id".to_string(), + method: "GET".to_string(), + description: "Get specific details for a single county.".to_string(), + url_params: Some(vec![ + "state_abbr (e.g., 'MA')".to_string(), + "county_id (integer)".to_string(), + ]), + }, + ApiEndpoint { + path: "/listings/county/:county_id".to_string(), + method: "GET".to_string(), + description: "Get all active heating oil listings for a specific county.".to_string(), + url_params: Some(vec!["county_id (integer)".to_string()]), + }, + ApiEndpoint { + path: "/listings/:listing_id".to_string(), + method: "GET".to_string(), + description: "Get full details for a single heating oil listing.".to_string(), + url_params: Some(vec!["listing_id (integer)".to_string()]), + }, + ApiEndpoint { + path: "/service-listings/county/:county_id".to_string(), + method: "GET".to_string(), + description: "Get all active service company listings for a specific county.".to_string(), + url_params: Some(vec!["county_id (integer)".to_string()]), + }, + ApiEndpoint { + path: "/service-listings/:listing_id".to_string(), + method: "GET".to_string(), + description: "Get full details for a single service company listing.".to_string(), + url_params: Some(vec!["listing_id (integer)".to_string()]), + }, + ApiEndpoint { + path: "/categories".to_string(), + method: "GET".to_string(), + description: "Get a list of all service categories and their stats.".to_string(), + url_params: None, + }, + ApiEndpoint { + path: "/stats".to_string(), + method: "GET".to_string(), + description: "Get aggregate high/low/average fuel prices across all regions.".to_string(), + url_params: None, + }, + ApiEndpoint { + path: "/banner".to_string(), + method: "GET".to_string(), + description: "Get the currently active site-wide announcement banner, if any.".to_string(), + url_params: None, + }, + ], + }; + + Ok(Json(directory)) +} diff --git a/src/data/mod.rs b/src/data/mod.rs index 12e35bb..c9efb33 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1 +1,2 @@ -pub mod data; \ No newline at end of file +pub mod data; +pub mod api_directory; \ No newline at end of file diff --git a/src/listing/data.rs b/src/listing/data.rs index b86e993..9f3ceda 100644 --- a/src/listing/data.rs +++ b/src/listing/data.rs @@ -13,7 +13,7 @@ pub async fn get_listings( ) -> Result>, (StatusCode, Json)> { tracing::info!(user_id = user.id, "Fetching listings for user"); 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, town, url, user_id, last_edited FROM listings WHERE user_id = $1 ORDER BY id DESC" + "SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited, COALESCE((SELECT array_agg(town) FROM listing_towns WHERE listing_id = listings.id), ARRAY[]::VARCHAR[]) as towns_serviced FROM listings WHERE user_id = $1 ORDER BY id DESC" ) .bind(user.id) .fetch_all(&*app_state.db) @@ -40,7 +40,7 @@ pub async fn get_listing_by_id( ) -> Result, (StatusCode, Json)> { tracing::info!(user_id = user.id, listing_id = listing_id, "Fetching listing by ID"); 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, town, url, user_id, last_edited FROM listings WHERE id = $1 AND user_id = $2" + "SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited, COALESCE((SELECT array_agg(town) FROM listing_towns WHERE listing_id = listings.id), ARRAY[]::VARCHAR[]) as towns_serviced FROM listings WHERE id = $1 AND user_id = $2" ) .bind(listing_id) .bind(user.id) @@ -85,7 +85,7 @@ pub async fn create_listing( // 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, town, url, user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, user_id, last_edited" + "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, town, url, facebook_url, instagram_url, google_business_url, user_id, logo_url, banner_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NULL, NULL) RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited, ARRAY[]::VARCHAR[] as towns_serviced" ) .bind(&payload.company_name) .bind(payload.is_active) @@ -100,11 +100,26 @@ pub async fn create_listing( .bind(payload.county_id) .bind(&payload.town) .bind(&payload.url) + .bind(&payload.facebook_url) + .bind(&payload.instagram_url) + .bind(&payload.google_business_url) .bind(user.id) .fetch_one(&*app_state.db) .await { - Ok(listing) => { + Ok(mut listing) => { + // Include towns if provided + if let Some(towns) = payload.towns_serviced { + for town in &towns { + let _ = sqlx::query("INSERT INTO listing_towns (listing_id, town) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(listing.id) + .bind(town) + .execute(&*app_state.db) + .await; + } + listing.towns_serviced = Some(towns); + } + tracing::debug!("Successfully created listing: {:?}", listing); Ok(Json(listing)) }, @@ -188,6 +203,18 @@ pub async fn update_listing( separated.push("url = "); separated.push_bind_unseparated(url); } + if let Some(facebook_url) = &payload.facebook_url { + separated.push("facebook_url = "); + separated.push_bind_unseparated(facebook_url); + } + if let Some(instagram_url) = &payload.instagram_url { + separated.push("instagram_url = "); + separated.push_bind_unseparated(instagram_url); + } + if let Some(google_business_url) = &payload.google_business_url { + separated.push("google_business_url = "); + separated.push_bind_unseparated(google_business_url); + } separated.push("last_edited = CURRENT_TIMESTAMP"); @@ -195,12 +222,28 @@ pub async fn update_listing( query_builder.push_bind(listing_id); query_builder.push(" AND user_id = "); query_builder.push_bind(user.id); - query_builder.push(" RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, user_id, last_edited"); + query_builder.push(" RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited, COALESCE((SELECT array_agg(town) FROM listing_towns WHERE listing_id = listings.id), ARRAY[]::VARCHAR[]) as towns_serviced"); let query = query_builder.build_query_as::(); match query.fetch_optional(&*app_state.db).await { - Ok(Some(listing)) => { + Ok(Some(mut listing)) => { + if let Some(towns) = payload.towns_serviced { + let _ = sqlx::query("DELETE FROM listing_towns WHERE listing_id = $1") + .bind(listing.id) + .execute(&*app_state.db) + .await; + + for town in &towns { + let _ = sqlx::query("INSERT INTO listing_towns (listing_id, town) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(listing.id) + .bind(town) + .execute(&*app_state.db) + .await; + } + listing.towns_serviced = Some(towns); + } + tracing::info!(user_id = user.id, listing_id = listing_id, "Listing updated successfully"); Ok(Json(listing)) }, @@ -227,7 +270,7 @@ pub async fn get_listings_by_county( ) -> Result>, (StatusCode, Json)> { tracing::info!(county_id = county_id, "Fetching listings by county"); 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, town, url, user_id, last_edited FROM listings WHERE county_id = $1 AND is_active = true ORDER BY last_edited DESC" + "SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited, COALESCE((SELECT array_agg(town) FROM listing_towns WHERE listing_id = listings.id), ARRAY[]::VARCHAR[]) as towns_serviced FROM listings WHERE county_id = $1 AND is_active = true ORDER BY last_edited DESC" ) .bind(county_id) .fetch_all(&*app_state.db) @@ -241,6 +284,28 @@ pub async fn get_listings_by_county( } } +/// Public: get a single listing by ID (no auth) +pub async fn get_listing_public( + State(app_state): State, + Path(listing_id): Path, +) -> Result, (StatusCode, Json)> { + tracing::info!(listing_id = listing_id, "Fetching public listing by ID"); + 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, town, url, logo_url, banner_url, facebook_url, instagram_url, google_business_url, user_id, last_edited, COALESCE((SELECT array_agg(town) FROM listing_towns WHERE listing_id = listings.id), ARRAY[]::VARCHAR[]) as towns_serviced FROM listings WHERE id = $1 AND is_active = true" + ) + .bind(listing_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 delete_listing( State(app_state): State, Path(listing_id): Path, diff --git a/src/listing/mod.rs b/src/listing/mod.rs index 0499a0b..1d50816 100644 --- a/src/listing/mod.rs +++ b/src/listing/mod.rs @@ -1,2 +1,3 @@ pub mod structs; pub mod data; +pub mod sitemap; diff --git a/src/listing/sitemap.rs b/src/listing/sitemap.rs new file mode 100644 index 0000000..2390acc --- /dev/null +++ b/src/listing/sitemap.rs @@ -0,0 +1,32 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use crate::auth::structs::AppState; +use serde::Serialize; +use serde_json::json; + +#[derive(Serialize)] +pub struct ListingId { + pub id: i32, + pub company_name: String, +} + +pub async fn get_all_active_listing_ids( + State(app_state): State, +) -> Result>, (StatusCode, Json)> { + match sqlx::query_as!( + ListingId, + "SELECT id, company_name FROM listings WHERE is_active = true ORDER BY id DESC" + ) + .fetch_all(&*app_state.db) + .await + { + Ok(ids) => Ok(Json(ids)), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to fetch listing ids: {}", e)})) + )), + } +} diff --git a/src/listing/structs.rs b/src/listing/structs.rs index 3287293..5571897 100644 --- a/src/listing/structs.rs +++ b/src/listing/structs.rs @@ -21,6 +21,12 @@ pub struct Listing { pub user_id: i32, pub last_edited: DateTime, pub url: Option, + pub logo_url: Option, + pub banner_url: Option, + pub facebook_url: Option, + pub instagram_url: Option, + pub google_business_url: Option, + pub towns_serviced: Option>, } #[derive(Debug, Serialize, Deserialize)] @@ -38,6 +44,10 @@ pub struct CreateListingRequest { pub county_id: i32, pub town: Option, pub url: Option, + pub facebook_url: Option, + pub instagram_url: Option, + pub google_business_url: Option, + pub towns_serviced: Option>, } impl CreateListingRequest { @@ -77,6 +87,10 @@ pub struct UpdateListingRequest { pub county_id: Option, pub town: Option, pub url: Option, + pub facebook_url: Option, + pub instagram_url: Option, + pub google_business_url: Option, + pub towns_serviced: Option>, } impl UpdateListingRequest { diff --git a/src/main.rs b/src/main.rs index 33efb10..e83b9ca 100755 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,14 @@ use axum::{ }; use std::env; use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; use crate::auth::structs::AppState; use crate::auth::auth::{auth_middleware, login, register, logout}; 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 crate::service_listing::data::{get_service_listings, get_service_listing_by_id, get_service_listings_by_county, create_service_listing, update_service_listing, delete_service_listing}; +use crate::upload::data::{upload_listing_image, delete_listing_image, upload_listing_banner, delete_listing_banner, upload_service_listing_image, delete_service_listing_image, upload_service_listing_banner, delete_service_listing_banner}; use crate::oil_prices::data::get_oil_prices_by_county; use axum::middleware; use sqlx::PgPool; @@ -19,9 +22,14 @@ mod data; mod state; mod company; mod listing; +mod service_listing; +mod upload; mod oil_prices; mod stats; mod admin; +mod subscription; +mod banner; +use crate::data::api_directory::get_api_directory; async fn health_check() -> &'static str { "NewEnglandBio API is running" @@ -38,22 +46,47 @@ async fn main() { ) .init(); - tracing::info!("Starting NewEnglandBio API server..."); + tracing::info!("🚀 Starting NewEnglandBio API server..."); // Load environment variables dotenv::dotenv().ok(); - let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let mut database_url = env::var("DATABASE_URL") + .expect("💀 DATABASE_URL must be set") + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string(); + + if !database_url.contains("://") { + database_url = format!("postgres://{}", database_url); + tracing::warn!("Added missing 'postgres://' scheme to DATABASE_URL"); + } let frontend_origin = env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "http://localhost:9551".to_string()); + let environment_mode = env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()); - tracing::info!(frontend_origin = %frontend_origin, "Configuration loaded"); + tracing::info!("🌍 Environment Mode: {}", environment_mode); + tracing::info!("🔗 Frontend Origin: {}", frontend_origin); + + // Parse database URL to show details without password + let db_details = if let Ok(parsed) = database_url.parse::() { + let host = parsed.host_str().unwrap_or("unknown_host"); + let db_name = parsed.path().trim_start_matches('/'); + format!("Host: {}, Database: {}", host, db_name) + } else { + "Unknown DB details".to_string() + }; // Connect to PostgreSQL - tracing::info!("Connecting to PostgreSQL database..."); - let db_pool = PgPool::connect(&database_url) - .await - .expect("Failed to connect to database"); - - tracing::info!("Database connection established"); + tracing::info!("⏳ Connecting to PostgreSQL database... ({})", db_details); + let db_pool = match PgPool::connect(&database_url).await { + Ok(pool) => { + tracing::info!("✅ Database connection established successfully"); + pool + } + Err(e) => { + tracing::error!("💀 Failed to connect to database: {}", e); + panic!("Database connection failed"); + } + }; let db = Arc::new(db_pool); @@ -81,6 +114,22 @@ async fn main() { .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("/service-listing", axum::routing::get(get_service_listings)) + .route("/service-listing", axum::routing::post(create_service_listing)) + .route("/service-listing/:listing_id", axum::routing::get(get_service_listing_by_id)) + .route("/service-listing/:listing_id", axum::routing::put(update_service_listing)) + .route("/service-listing/:listing_id", axum::routing::delete(delete_service_listing)) + .route("/upload/listing/:listing_id/image", axum::routing::post(upload_listing_image)) + .route("/upload/listing/:listing_id/image", axum::routing::delete(delete_listing_image)) + .route("/upload/service-listing/:listing_id/image", axum::routing::post(upload_service_listing_image)) + .route("/upload/service-listing/:listing_id/image", axum::routing::delete(delete_service_listing_image)) + .route("/upload/listing/:listing_id/banner", axum::routing::post(upload_listing_banner)) + .route("/upload/listing/:listing_id/banner", axum::routing::delete(delete_listing_banner)) + .route("/upload/service-listing/:listing_id/banner", axum::routing::post(upload_service_listing_banner)) + .route("/upload/service-listing/:listing_id/banner", axum::routing::delete(delete_service_listing_banner)) + .route("/subscription", axum::routing::get(crate::subscription::data::get_subscription)) + .route("/admin/banner", axum::routing::post(crate::banner::data::create_banner)) + .route("/admin/banner/:banner_id", axum::routing::delete(crate::banner::data::delete_banner)) .merge(crate::admin::admin_routes()) .route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); @@ -94,11 +143,27 @@ async fn main() { .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)) + .route("/listings/:listing_id", axum::routing::get(crate::listing::data::get_listing_public)) + .route("/service-listings/county/:county_id", axum::routing::get(get_service_listings_by_county)) + .route("/service-listings/:listing_id", axum::routing::get(crate::service_listing::data::get_service_listing_public)) .route("/oil-prices/county/:county_id", axum::routing::get(get_oil_prices_by_county)) - .route("/stats", axum::routing::get(crate::stats::data::get_latest_stats)); + .route("/stats", axum::routing::get(crate::stats::data::get_latest_stats)) + .route("/company/:company_id", axum::routing::get(crate::company::company::get_company_profile)) + .route("/company/user/:user_id", axum::routing::get(crate::company::company::get_company_profile_by_user)) + .route("/banner", axum::routing::get(crate::banner::data::get_active_banner)) + .route("/api-directory", axum::routing::get(get_api_directory)) + .route("/listings/sitemap/all", axum::routing::get(crate::listing::sitemap::get_all_active_listing_ids)) + .route("/service-listings/sitemap/all", axum::routing::get(crate::service_listing::sitemap::get_all_active_service_listing_ids)); + + // Ensure upload directories exist + tokio::fs::create_dir_all("/uploads/listings").await.ok(); + tokio::fs::create_dir_all("/uploads/service").await.ok(); + tokio::fs::create_dir_all("/uploads/banners").await.ok(); + tokio::fs::create_dir_all("/uploads/service_banners").await.ok(); let app = public_routes .merge(protected_routes) + .nest_service("/uploads", ServeDir::new("/uploads")) .with_state(state) .layer(cors); diff --git a/src/service_listing/data.rs b/src/service_listing/data.rs new file mode 100644 index 0000000..a1638b7 --- /dev/null +++ b/src/service_listing/data.rs @@ -0,0 +1,314 @@ +use axum::{ + extract::{Path, State, Extension}, + http::StatusCode, + Json, +}; +use crate::auth::structs::{AppState, User}; +use crate::service_listing::structs::{ServiceListing, CreateServiceListingRequest, UpdateServiceListingRequest}; +use serde_json::json; + +const SELECT_FIELDS: &str = "id, company_name, is_active, twenty_four_hour, emergency_service, town, county_id, phone, website, email, description, licensed_insured, service_area, years_experience, user_id, last_edited, logo_url, banner_url, facebook_url, instagram_url, google_business_url, COALESCE((SELECT array_agg(town) FROM service_listing_towns WHERE service_listing_id = service_listings.id), ARRAY[]::VARCHAR[]) as towns_serviced"; + +pub async fn get_service_listings_by_county( + State(app_state): State, + Path(county_id): Path, +) -> Result>, (StatusCode, Json)> { + tracing::info!(county_id = county_id, "Fetching service listings by county"); + let query = format!( + "SELECT {} FROM service_listings WHERE county_id = $1 AND is_active = true ORDER BY company_name ASC", + SELECT_FIELDS + ); + match sqlx::query_as::<_, ServiceListing>(&query) + .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 service listings: {}", e)})) + )), + } +} + +/// Public: get a single service listing by ID (no auth) +pub async fn get_service_listing_public( + State(app_state): State, + Path(listing_id): Path, +) -> Result, (StatusCode, Json)> { + tracing::info!(listing_id = listing_id, "Fetching public service listing by ID"); + let query = format!( + "SELECT {} FROM service_listings WHERE id = $1 AND is_active = true", + SELECT_FIELDS + ); + match sqlx::query_as::<_, ServiceListing>(&query) + .bind(listing_id) + .fetch_optional(&*app_state.db) + .await + { + Ok(Some(listing)) => Ok(Json(listing)), + Ok(None) => Err((StatusCode::NOT_FOUND, Json(json!({"error": "Service listing not found"})))), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to fetch service listing: {}", e)})) + )), + } +} + +pub async fn get_service_listings( + State(app_state): State, + Extension(user): Extension, +) -> Result>, (StatusCode, Json)> { + tracing::info!(user_id = user.id, "Fetching service listings for user"); + let query = format!( + "SELECT {} FROM service_listings WHERE user_id = $1 ORDER BY id DESC", + SELECT_FIELDS + ); + match sqlx::query_as::<_, ServiceListing>(&query) + .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 service listings: {}", e)})) + )), + } +} + +pub async fn get_service_listing_by_id( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + tracing::info!(user_id = user.id, listing_id = listing_id, "Fetching service listing by ID"); + let query = format!( + "SELECT {} FROM service_listings WHERE id = $1 AND user_id = $2", + SELECT_FIELDS + ); + match sqlx::query_as::<_, ServiceListing>(&query) + .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": "Service listing not found"})) + )), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to fetch service listing: {}", e)})) + )), + } +} + +pub async fn create_service_listing( + State(app_state): State, + Extension(user): Extension, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + tracing::info!(user_id = user.id, "Creating service listing"); + let query = format!( + "INSERT INTO service_listings (company_name, is_active, twenty_four_hour, emergency_service, town, county_id, phone, website, email, description, licensed_insured, service_area, years_experience, facebook_url, instagram_url, google_business_url, user_id, logo_url, banner_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NULL, NULL) RETURNING {}", + SELECT_FIELDS.replace("COALESCE((SELECT array_agg(town) FROM service_listing_towns WHERE service_listing_id = service_listings.id), ARRAY[]::VARCHAR[]) as towns_serviced", "ARRAY[]::VARCHAR[] as towns_serviced") + ); + match sqlx::query_as::<_, ServiceListing>(&query) + .bind(&payload.company_name) + .bind(payload.is_active) + .bind(payload.twenty_four_hour) + .bind(payload.emergency_service) + .bind(&payload.town) + .bind(payload.county_id) + .bind(&payload.phone) + .bind(&payload.website) + .bind(&payload.email) + .bind(&payload.description) + .bind(payload.licensed_insured) + .bind(&payload.service_area) + .bind(payload.years_experience) + .bind(&payload.facebook_url) + .bind(&payload.instagram_url) + .bind(&payload.google_business_url) + .bind(user.id) + .fetch_one(&*app_state.db) + .await + { + Ok(mut listing) => { + if let Some(towns) = payload.towns_serviced { + for town in &towns { + let _ = sqlx::query("INSERT INTO service_listing_towns (service_listing_id, town) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(listing.id) + .bind(town) + .execute(&*app_state.db) + .await; + } + listing.towns_serviced = Some(towns); + } + tracing::info!(user_id = user.id, "Service listing created"); + Ok(Json(listing)) + }, + Err(e) => { + tracing::error!(user_id = user.id, error = %e, "Failed to create service listing"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to create service listing: {}", e)})) + )) + }, + } +} + +pub async fn update_service_listing( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + tracing::info!(user_id = user.id, listing_id = listing_id, "Updating service listing"); + + let mut query_builder = sqlx::QueryBuilder::new("UPDATE service_listings SET "); + let mut separated = query_builder.separated(", "); + + if let Some(company_name) = &payload.company_name { + separated.push("company_name = "); + separated.push_bind_unseparated(company_name); + } + if let Some(is_active) = payload.is_active { + separated.push("is_active = "); + separated.push_bind_unseparated(is_active); + } + if let Some(twenty_four_hour) = payload.twenty_four_hour { + separated.push("twenty_four_hour = "); + separated.push_bind_unseparated(twenty_four_hour); + } + if let Some(emergency_service) = payload.emergency_service { + separated.push("emergency_service = "); + separated.push_bind_unseparated(emergency_service); + } + if let Some(town) = &payload.town { + separated.push("town = "); + separated.push_bind_unseparated(town); + } + if let Some(county_id) = payload.county_id { + separated.push("county_id = "); + separated.push_bind_unseparated(county_id); + } + if let Some(phone) = &payload.phone { + separated.push("phone = "); + separated.push_bind_unseparated(phone); + } + if let Some(website) = &payload.website { + separated.push("website = "); + separated.push_bind_unseparated(website); + } + if let Some(email) = &payload.email { + separated.push("email = "); + separated.push_bind_unseparated(email); + } + if let Some(description) = &payload.description { + separated.push("description = "); + separated.push_bind_unseparated(description); + } + if let Some(licensed_insured) = payload.licensed_insured { + separated.push("licensed_insured = "); + separated.push_bind_unseparated(licensed_insured); + } + if let Some(service_area) = &payload.service_area { + separated.push("service_area = "); + separated.push_bind_unseparated(service_area); + } + if let Some(years_experience) = payload.years_experience { + separated.push("years_experience = "); + separated.push_bind_unseparated(years_experience); + } + if let Some(facebook_url) = &payload.facebook_url { + separated.push("facebook_url = "); + separated.push_bind_unseparated(facebook_url); + } + if let Some(instagram_url) = &payload.instagram_url { + separated.push("instagram_url = "); + separated.push_bind_unseparated(instagram_url); + } + if let Some(google_business_url) = &payload.google_business_url { + separated.push("google_business_url = "); + separated.push_bind_unseparated(google_business_url); + } + + separated.push("last_edited = CURRENT_TIMESTAMP"); + + query_builder.push(" WHERE id = "); + query_builder.push_bind(listing_id); + query_builder.push(" AND user_id = "); + query_builder.push_bind(user.id); + query_builder.push(&format!(" RETURNING {}", SELECT_FIELDS)); + + let query = query_builder.build_query_as::(); + + match query.fetch_optional(&*app_state.db).await { + Ok(Some(mut listing)) => { + if let Some(towns) = payload.towns_serviced { + let _ = sqlx::query("DELETE FROM service_listing_towns WHERE service_listing_id = $1") + .bind(listing.id) + .execute(&*app_state.db) + .await; + + for town in &towns { + let _ = sqlx::query("INSERT INTO service_listing_towns (service_listing_id, town) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(listing.id) + .bind(town) + .execute(&*app_state.db) + .await; + } + listing.towns_serviced = Some(towns); + } + tracing::info!(user_id = user.id, listing_id = listing_id, "Service listing updated"); + Ok(Json(listing)) + }, + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": "Service listing not found or access denied"})) + )), + Err(e) => { + tracing::error!(user_id = user.id, listing_id = listing_id, error = %e, "Failed to update service listing"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to update service listing: {}", e)})) + )) + }, + } +} + +pub async fn delete_service_listing( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + tracing::info!(user_id = user.id, listing_id = listing_id, "Deleting service listing"); + match sqlx::query("DELETE FROM service_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": "Service listing not found"})) + )) + } else { + tracing::info!(user_id = user.id, listing_id = listing_id, "Service listing deleted"); + Ok(Json(json!({"success": true, "message": "Service listing deleted"}))) + } + }, + Err(e) => { + tracing::error!(user_id = user.id, listing_id = listing_id, error = %e, "Failed to delete service listing"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to delete service listing: {}", e)})) + )) + }, + } +} diff --git a/src/service_listing/mod.rs b/src/service_listing/mod.rs new file mode 100644 index 0000000..1d50816 --- /dev/null +++ b/src/service_listing/mod.rs @@ -0,0 +1,3 @@ +pub mod structs; +pub mod data; +pub mod sitemap; diff --git a/src/service_listing/sitemap.rs b/src/service_listing/sitemap.rs new file mode 100644 index 0000000..b58c587 --- /dev/null +++ b/src/service_listing/sitemap.rs @@ -0,0 +1,32 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, +}; +use crate::auth::structs::AppState; +use serde::Serialize; +use serde_json::json; + +#[derive(Serialize)] +pub struct ServiceListingId { + pub id: i32, + pub company_name: String, +} + +pub async fn get_all_active_service_listing_ids( + State(app_state): State, +) -> Result>, (StatusCode, Json)> { + match sqlx::query_as!( + ServiceListingId, + "SELECT id, company_name FROM service_listings WHERE is_active = true ORDER BY id DESC" + ) + .fetch_all(&*app_state.db) + .await + { + Ok(ids) => Ok(Json(ids)), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to fetch service listing ids: {}", e)})) + )), + } +} diff --git a/src/service_listing/structs.rs b/src/service_listing/structs.rs new file mode 100644 index 0000000..8a22bae --- /dev/null +++ b/src/service_listing/structs.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct ServiceListing { + pub id: i32, + pub company_name: String, + pub is_active: bool, + pub twenty_four_hour: bool, + pub emergency_service: bool, + pub town: Option, + pub county_id: i32, + pub phone: Option, + pub website: Option, + pub email: Option, + pub description: Option, + pub licensed_insured: bool, + pub service_area: Option, + pub years_experience: Option, + pub user_id: i32, + pub last_edited: DateTime, + pub logo_url: Option, + pub banner_url: Option, + pub facebook_url: Option, + pub instagram_url: Option, + pub google_business_url: Option, + pub towns_serviced: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateServiceListingRequest { + pub company_name: String, + pub is_active: bool, + pub twenty_four_hour: bool, + pub emergency_service: bool, + pub town: Option, + pub county_id: i32, + pub phone: Option, + pub website: Option, + pub email: Option, + pub description: Option, + pub licensed_insured: bool, + pub service_area: Option, + pub years_experience: Option, + pub facebook_url: Option, + pub instagram_url: Option, + pub google_business_url: Option, + pub towns_serviced: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateServiceListingRequest { + pub company_name: Option, + pub is_active: Option, + pub twenty_four_hour: Option, + pub emergency_service: Option, + pub town: Option, + pub county_id: Option, + pub phone: Option, + pub website: Option, + pub email: Option, + pub description: Option, + pub licensed_insured: Option, + pub service_area: Option, + pub years_experience: Option, + pub facebook_url: Option, + pub instagram_url: Option, + pub google_business_url: Option, + pub towns_serviced: Option>, +} diff --git a/src/subscription/data.rs b/src/subscription/data.rs new file mode 100644 index 0000000..2d26d47 --- /dev/null +++ b/src/subscription/data.rs @@ -0,0 +1,83 @@ +use axum::{ + extract::State, + http::StatusCode, + Json, + Extension, +}; +use crate::auth::structs::{AppState, User}; +use crate::subscription::structs::Subscription; +use serde_json::json; + +/// Get the subscription for the currently authenticated user's company. +/// If a company exists but no subscription record, auto-create a trial subscription. +pub async fn get_subscription( + State(app_state): State, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + tracing::info!(user_id = user.id, "Fetching subscription for user"); + + // First, find the user's company + let company_id: i32 = match sqlx::query_scalar::<_, i32>( + "SELECT id FROM company WHERE user_id = $1 AND active = true" + ) + .bind(user.id) + .fetch_optional(&*app_state.db) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": "No company found. Create a company profile first."})) + )); + }, + Err(e) => { + tracing::error!(user_id = user.id, error = %e, "DB error fetching company for subscription"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})) + )); + } + }; + + // Check if subscription already exists + match sqlx::query_as::<_, Subscription>( + "SELECT * FROM subscriptions WHERE company_id = $1" + ) + .bind(company_id) + .fetch_optional(&*app_state.db) + .await + { + Ok(Some(sub)) => { + tracing::info!(user_id = user.id, company_id = company_id, "Subscription found"); + Ok(Json(sub)) + }, + Ok(None) => { + // Auto-create trial subscription + tracing::info!(user_id = user.id, company_id = company_id, "Creating trial subscription"); + match sqlx::query_as::<_, Subscription>( + "INSERT INTO subscriptions (company_id, trial_start, trial_end, status) VALUES ($1, CURRENT_DATE, CURRENT_DATE + INTERVAL '1 year', 'trial') RETURNING *" + ) + .bind(company_id) + .fetch_one(&*app_state.db) + .await + { + Ok(sub) => Ok(Json(sub)), + Err(e) => { + tracing::error!(user_id = user.id, error = %e, "Failed to create trial subscription"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Failed to create subscription: {}", e)})) + )) + } + } + }, + Err(e) => { + tracing::error!(user_id = user.id, error = %e, "DB error fetching subscription"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})) + )) + } + } +} diff --git a/src/subscription/mod.rs b/src/subscription/mod.rs new file mode 100644 index 0000000..62c697c --- /dev/null +++ b/src/subscription/mod.rs @@ -0,0 +1,2 @@ +pub mod data; +pub mod structs; diff --git a/src/subscription/structs.rs b/src/subscription/structs.rs new file mode 100644 index 0000000..4f38a93 --- /dev/null +++ b/src/subscription/structs.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use chrono::NaiveDate; +use chrono::{DateTime, Utc}; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct Subscription { + pub id: i32, + pub company_id: i32, + pub trial_start: NaiveDate, + pub trial_end: NaiveDate, + pub status: String, + pub plan: Option, + pub created_at: DateTime, +} diff --git a/src/upload/data.rs b/src/upload/data.rs new file mode 100644 index 0000000..35b8491 --- /dev/null +++ b/src/upload/data.rs @@ -0,0 +1,397 @@ +use axum::{ + extract::{Multipart, Path, State, Extension}, + http::StatusCode, + Json, +}; +use crate::auth::structs::{AppState, User}; +use serde_json::json; +use std::path::PathBuf; +use tokio::fs; + +const UPLOADS_BASE: &str = "/uploads"; +const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; // 5 MB + +fn ext_for_content_type(ct: &str) -> Option<&'static str> { + match ct { + "image/jpeg" | "image/jpg" => Some("jpg"), + "image/png" => Some("png"), + "image/webp" => Some("webp"), + _ => None, + } +} + +/// Remove all known image variants for a given stem (e.g. "listing_42") +async fn remove_existing(dir: &PathBuf, stem: &str) { + for ext in &["jpg", "png", "webp"] { + let _ = fs::remove_file(dir.join(format!("{}.{}", stem, ext))).await; + } +} + +// ── Fuel Listing Image ────────────────────────────────────────────────────── + +pub async fn upload_listing_image( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + // Verify ownership + let exists: Option = sqlx::query_scalar( + "SELECT id FROM listings WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .fetch_optional(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Listing not found"})))); + } + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))? + { + let ct = field.content_type().unwrap_or("").to_string(); + let ext = ext_for_content_type(&ct).ok_or_else(|| { + (StatusCode::BAD_REQUEST, Json(json!({"error": "Only JPEG, PNG, and WebP images are allowed"}))) + })?; + + let data = field + .bytes() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))?; + + if data.len() > MAX_FILE_SIZE { + return Err((StatusCode::BAD_REQUEST, Json(json!({"error": "File too large. Maximum 5 MB."})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("listings"); + fs::create_dir_all(&dir) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let stem = format!("listing_{}", listing_id); + remove_existing(&dir, &stem).await; + + let filename = format!("{}.{}", stem, ext); + fs::write(dir.join(&filename), &data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let logo_url = format!("/uploads/listings/{}", filename); + + sqlx::query("UPDATE listings SET logo_url = $1 WHERE id = $2") + .bind(&logo_url) + .bind(listing_id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + tracing::info!(listing_id = listing_id, logo_url = %logo_url, "Listing image uploaded"); + return Ok(Json(json!({"success": true, "logo_url": logo_url}))); + } + + Err((StatusCode::BAD_REQUEST, Json(json!({"error": "No image provided"})))) +} + +pub async fn delete_listing_image( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + let result = sqlx::query( + "UPDATE listings SET logo_url = NULL WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Listing not found"})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("listings"); + remove_existing(&dir, &format!("listing_{}", listing_id)).await; + + Ok(Json(json!({"success": true}))) +} + +pub async fn upload_listing_banner( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + let exists: Option = sqlx::query_scalar( + "SELECT id FROM listings WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .fetch_optional(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Listing not found"})))); + } + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))? + { + let ct = field.content_type().unwrap_or("").to_string(); + let ext = ext_for_content_type(&ct).ok_or_else(|| { + (StatusCode::BAD_REQUEST, Json(json!({"error": "Only JPEG, PNG, and WebP images are allowed"}))) + })?; + + let data = field + .bytes() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))?; + + if data.len() > MAX_FILE_SIZE { + return Err((StatusCode::BAD_REQUEST, Json(json!({"error": "File too large. Maximum 5 MB."})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("banners"); + fs::create_dir_all(&dir) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let stem = format!("listing_{}", listing_id); + remove_existing(&dir, &stem).await; + + let filename = format!("{}.{}", stem, ext); + fs::write(dir.join(&filename), &data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let banner_url = format!("/uploads/banners/{}", filename); + + sqlx::query("UPDATE listings SET banner_url = $1 WHERE id = $2") + .bind(&banner_url) + .bind(listing_id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + tracing::info!(listing_id = listing_id, banner_url = %banner_url, "Listing banner uploaded"); + return Ok(Json(json!({"success": true, "banner_url": banner_url}))); + } + + Err((StatusCode::BAD_REQUEST, Json(json!({"error": "No image provided"})))) +} + +pub async fn delete_listing_banner( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + let result = sqlx::query( + "UPDATE listings SET banner_url = NULL WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Listing not found"})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("banners"); + remove_existing(&dir, &format!("listing_{}", listing_id)).await; + + Ok(Json(json!({"success": true}))) +} + +// ── Service Listing Image ─────────────────────────────────────────────────── + +pub async fn upload_service_listing_image( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + let exists: Option = sqlx::query_scalar( + "SELECT id FROM service_listings WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .fetch_optional(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Service listing not found"})))); + } + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))? + { + let ct = field.content_type().unwrap_or("").to_string(); + let ext = ext_for_content_type(&ct).ok_or_else(|| { + (StatusCode::BAD_REQUEST, Json(json!({"error": "Only JPEG, PNG, and WebP images are allowed"}))) + })?; + + let data = field + .bytes() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))?; + + if data.len() > MAX_FILE_SIZE { + return Err((StatusCode::BAD_REQUEST, Json(json!({"error": "File too large. Maximum 5 MB."})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("service"); + fs::create_dir_all(&dir) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let stem = format!("service_{}", listing_id); + remove_existing(&dir, &stem).await; + + let filename = format!("{}.{}", stem, ext); + fs::write(dir.join(&filename), &data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let logo_url = format!("/uploads/service/{}", filename); + + sqlx::query("UPDATE service_listings SET logo_url = $1 WHERE id = $2") + .bind(&logo_url) + .bind(listing_id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + tracing::info!(listing_id = listing_id, logo_url = %logo_url, "Service listing image uploaded"); + return Ok(Json(json!({"success": true, "logo_url": logo_url}))); + } + + Err((StatusCode::BAD_REQUEST, Json(json!({"error": "No image provided"})))) +} + +pub async fn delete_service_listing_image( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + let result = sqlx::query( + "UPDATE service_listings SET logo_url = NULL WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Service listing not found"})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("service"); + remove_existing(&dir, &format!("service_{}", listing_id)).await; + + Ok(Json(json!({"success": true}))) +} + +pub async fn upload_service_listing_banner( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + let exists: Option = sqlx::query_scalar( + "SELECT id FROM service_listings WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .fetch_optional(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if exists.is_none() { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Service listing not found"})))); + } + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))? + { + let ct = field.content_type().unwrap_or("").to_string(); + let ext = ext_for_content_type(&ct).ok_or_else(|| { + (StatusCode::BAD_REQUEST, Json(json!({"error": "Only JPEG, PNG, and WebP images are allowed"}))) + })?; + + let data = field + .bytes() + .await + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))?; + + if data.len() > MAX_FILE_SIZE { + return Err((StatusCode::BAD_REQUEST, Json(json!({"error": "File too large. Maximum 5 MB."})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("service_banners"); + fs::create_dir_all(&dir) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let stem = format!("service_{}", listing_id); + remove_existing(&dir, &stem).await; + + let filename = format!("{}.{}", stem, ext); + fs::write(dir.join(&filename), &data) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + let banner_url = format!("/uploads/service_banners/{}", filename); + + sqlx::query("UPDATE service_listings SET banner_url = $1 WHERE id = $2") + .bind(&banner_url) + .bind(listing_id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + tracing::info!(listing_id = listing_id, banner_url = %banner_url, "Service listing banner uploaded"); + return Ok(Json(json!({"success": true, "banner_url": banner_url}))); + } + + Err((StatusCode::BAD_REQUEST, Json(json!({"error": "No image provided"})))) +} + +pub async fn delete_service_listing_banner( + State(app_state): State, + Path(listing_id): Path, + Extension(user): Extension, +) -> Result, (StatusCode, Json)> { + let result = sqlx::query( + "UPDATE service_listings SET banner_url = NULL WHERE id = $1 AND user_id = $2", + ) + .bind(listing_id) + .bind(user.id) + .execute(&*app_state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, Json(json!({"error": "Service listing not found"})))); + } + + let dir = PathBuf::from(UPLOADS_BASE).join("service_banners"); + remove_existing(&dir, &format!("service_{}", listing_id)).await; + + Ok(Json(json!({"success": true}))) +} diff --git a/src/upload/mod.rs b/src/upload/mod.rs new file mode 100644 index 0000000..7a345e4 --- /dev/null +++ b/src/upload/mod.rs @@ -0,0 +1 @@ +pub mod data;