feat: implement SEO improvements, listing profiles, service images, and towns serviced

This commit is contained in:
2026-03-08 15:12:53 -04:00
parent 6c95a7d201
commit da22c4f19a
31 changed files with 1921 additions and 42 deletions

90
Cargo.lock generated
View File

@@ -89,6 +89,7 @@ dependencies = [
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
] ]
[[package]] [[package]]
@@ -148,6 +149,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@@ -447,6 +449,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 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]] [[package]]
name = "event-listener" name = "event-listener"
version = "2.5.3" version = "2.5.3"
@@ -461,9 +472,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.1" version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [ dependencies = [
"percent-encoding", "percent-encoding",
] ]
@@ -804,9 +815,9 @@ dependencies = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.0.3" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [ dependencies = [
"idna_adapter", "idna_adapter",
"smallvec", "smallvec",
@@ -953,6 +964,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 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]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -979,6 +1000,24 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1123,9 +1162,9 @@ dependencies = [
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
@@ -1286,7 +1325,7 @@ dependencies = [
"cc", "cc",
"libc", "libc",
"once_cell", "once_cell",
"spin", "spin 0.5.2",
"untrusted 0.7.1", "untrusted 0.7.1",
"web-sys", "web-sys",
"winapi", "winapi",
@@ -1495,6 +1534,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "sqlformat" name = "sqlformat"
version = "0.2.6" version = "0.2.6"
@@ -1817,6 +1862,19 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tower" name = "tower"
version = "0.4.13" version = "0.4.13"
@@ -1846,9 +1904,16 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"http-range-header", "http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1937,6 +2002,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -1990,13 +2061,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde",
] ]
[[package]] [[package]]

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
axum = { version = "0.6.20", features = ["headers"] } axum = { version = "0.6.20", features = ["headers", "multipart"] }
tokio = { version = "1.35.1", features = ["full"] } tokio = { version = "1.35.1", features = ["full"] }
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "chrono"] } sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "chrono"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -12,10 +12,11 @@ serde_json = "1.0"
jsonwebtoken = "8.1" jsonwebtoken = "8.1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
dotenv = "0.15" dotenv = "0.15"
tower-http = { version = "0.4", features = ["cors"] } tower-http = { version = "0.4", features = ["cors", "fs"] }
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
hyper = "0.14" hyper = "0.14"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum-extra = { version = "0.7", features = ["cookie"] } axum-extra = { version = "0.7", features = ["cookie"] }
time = "0.3" time = "0.3"
url = "2.5.8"

View File

@@ -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 FROM rust:latest
RUN cargo install cargo-watch
WORKDIR /usr/src/app 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 EXPOSE 9552
CMD ["cargo-watch", "-x", "run"] CMD ["cargo-watch", "-x", "run"]

View File

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

View File

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

269
init_fuelprices_prod.sql Normal file
View File

@@ -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;

10
migrate_banner_url.sql Normal file
View File

@@ -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);

10
migrate_logo_url.sql Normal file
View File

@@ -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);

View File

@@ -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 <user> -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
);

View File

@@ -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)
);

View File

@@ -54,6 +54,11 @@ CREATE TABLE listings (
county_id INTEGER NOT NULL, county_id INTEGER NOT NULL,
town VARCHAR(100), town VARCHAR(100),
url VARCHAR(255), 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, user_id INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -90,3 +95,65 @@ CREATE TABLE stats_prices (
price DOUBLE PRECISION NOT NULL, price DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 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)
);

85
src/banner/data.rs Normal file
View File

@@ -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<AppState>,
) -> Result<Json<Option<Banner>>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Json(payload): Json<CreateBannerRequest>,
) -> Result<Json<Banner>, (StatusCode, Json<serde_json::Value>)> {
// 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<AppState>,
Path(banner_id): Path<i32>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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()}))
))
}
}
}

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

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

17
src/banner/structs.rs Normal file
View File

@@ -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<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateBannerRequest {
pub message: String,
}

View File

@@ -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<Listing>,
pub service_listings: Vec<ServiceListing>,
}
pub async fn get_company_profile(
State(app_state): State<AppState>,
Path(company_id): Path<i32>,
) -> 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<AppState>,
Path(user_id): Path<i32>,
) -> 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()
}

93
src/data/api_directory.rs Normal file
View File

@@ -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<Vec<String>>,
}
#[derive(Serialize)]
pub struct ApiDirectoryResponse {
pub title: String,
pub description: String,
pub version: String,
pub base_url: String,
pub endpoints: Vec<ApiEndpoint>,
}
pub async fn get_api_directory() -> Result<Json<ApiDirectoryResponse>, (StatusCode, Json<serde_json::Value>)> {
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))
}

View File

@@ -1 +1,2 @@
pub mod data; pub mod data;
pub mod api_directory;

View File

@@ -13,7 +13,7 @@ pub async fn get_listings(
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(user_id = user.id, "Fetching listings for user"); tracing::info!(user_id = user.id, "Fetching listings for user");
match sqlx::query_as::<_, Listing>( 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) .bind(user.id)
.fetch_all(&*app_state.db) .fetch_all(&*app_state.db)
@@ -40,7 +40,7 @@ pub async fn get_listing_by_id(
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(user_id = user.id, listing_id = listing_id, "Fetching listing by ID"); tracing::info!(user_id = user.id, listing_id = listing_id, "Fetching listing by ID");
match sqlx::query_as::<_, Listing>( 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(listing_id)
.bind(user.id) .bind(user.id)
@@ -85,7 +85,7 @@ pub async fn create_listing(
// Create the listing directly without company validation // Create the listing directly without company validation
match sqlx::query_as::<_, Listing>( 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.company_name)
.bind(payload.is_active) .bind(payload.is_active)
@@ -100,11 +100,26 @@ pub async fn create_listing(
.bind(payload.county_id) .bind(payload.county_id)
.bind(&payload.town) .bind(&payload.town)
.bind(&payload.url) .bind(&payload.url)
.bind(&payload.facebook_url)
.bind(&payload.instagram_url)
.bind(&payload.google_business_url)
.bind(user.id) .bind(user.id)
.fetch_one(&*app_state.db) .fetch_one(&*app_state.db)
.await .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); tracing::debug!("Successfully created listing: {:?}", listing);
Ok(Json(listing)) Ok(Json(listing))
}, },
@@ -188,6 +203,18 @@ pub async fn update_listing(
separated.push("url = "); separated.push("url = ");
separated.push_bind_unseparated(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"); separated.push("last_edited = CURRENT_TIMESTAMP");
@@ -195,12 +222,28 @@ pub async fn update_listing(
query_builder.push_bind(listing_id); query_builder.push_bind(listing_id);
query_builder.push(" AND user_id = "); query_builder.push(" AND user_id = ");
query_builder.push_bind(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::<Listing>(); let query = query_builder.build_query_as::<Listing>();
match query.fetch_optional(&*app_state.db).await { 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"); tracing::info!(user_id = user.id, listing_id = listing_id, "Listing updated successfully");
Ok(Json(listing)) Ok(Json(listing))
}, },
@@ -227,7 +270,7 @@ pub async fn get_listings_by_county(
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(county_id = county_id, "Fetching listings by county"); tracing::info!(county_id = county_id, "Fetching listings by county");
match sqlx::query_as::<_, Listing>( 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) .bind(county_id)
.fetch_all(&*app_state.db) .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<AppState>,
Path(listing_id): Path<i32>,
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
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( pub async fn delete_listing(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Path(listing_id): Path<i32>, Path(listing_id): Path<i32>,

View File

@@ -1,2 +1,3 @@
pub mod structs; pub mod structs;
pub mod data; pub mod data;
pub mod sitemap;

32
src/listing/sitemap.rs Normal file
View File

@@ -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<AppState>,
) -> Result<Json<Vec<ListingId>>, (StatusCode, Json<serde_json::Value>)> {
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)}))
)),
}
}

View File

@@ -21,6 +21,12 @@ pub struct Listing {
pub user_id: i32, pub user_id: i32,
pub last_edited: DateTime<Utc>, pub last_edited: DateTime<Utc>,
pub url: Option<String>, pub url: Option<String>,
pub logo_url: Option<String>,
pub banner_url: Option<String>,
pub facebook_url: Option<String>,
pub instagram_url: Option<String>,
pub google_business_url: Option<String>,
pub towns_serviced: Option<Vec<String>>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -38,6 +44,10 @@ pub struct CreateListingRequest {
pub county_id: i32, pub county_id: i32,
pub town: Option<String>, pub town: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub facebook_url: Option<String>,
pub instagram_url: Option<String>,
pub google_business_url: Option<String>,
pub towns_serviced: Option<Vec<String>>,
} }
impl CreateListingRequest { impl CreateListingRequest {
@@ -77,6 +87,10 @@ pub struct UpdateListingRequest {
pub county_id: Option<i32>, pub county_id: Option<i32>,
pub town: Option<String>, pub town: Option<String>,
pub url: Option<String>, pub url: Option<String>,
pub facebook_url: Option<String>,
pub instagram_url: Option<String>,
pub google_business_url: Option<String>,
pub towns_serviced: Option<Vec<String>>,
} }
impl UpdateListingRequest { impl UpdateListingRequest {

View File

@@ -4,11 +4,14 @@ use axum::{
}; };
use std::env; use std::env;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use crate::auth::structs::AppState; use crate::auth::structs::AppState;
use crate::auth::auth::{auth_middleware, login, register, logout}; use crate::auth::auth::{auth_middleware, login, register, logout};
use crate::data::data::get_user; use crate::data::data::get_user;
use crate::state::data::{get_counties_by_state, get_county_by_id}; 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::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 crate::oil_prices::data::get_oil_prices_by_county;
use axum::middleware; use axum::middleware;
use sqlx::PgPool; use sqlx::PgPool;
@@ -19,9 +22,14 @@ mod data;
mod state; mod state;
mod company; mod company;
mod listing; mod listing;
mod service_listing;
mod upload;
mod oil_prices; mod oil_prices;
mod stats; mod stats;
mod admin; mod admin;
mod subscription;
mod banner;
use crate::data::api_directory::get_api_directory;
async fn health_check() -> &'static str { async fn health_check() -> &'static str {
"NewEnglandBio API is running" "NewEnglandBio API is running"
@@ -38,22 +46,47 @@ async fn main() {
) )
.init(); .init();
tracing::info!("Starting NewEnglandBio API server..."); tracing::info!("🚀 Starting NewEnglandBio API server...");
// Load environment variables // Load environment variables
dotenv::dotenv().ok(); 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 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::<url::Url>() {
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 // Connect to PostgreSQL
tracing::info!("Connecting to PostgreSQL database..."); tracing::info!("Connecting to PostgreSQL database... ({})", db_details);
let db_pool = PgPool::connect(&database_url) let db_pool = match PgPool::connect(&database_url).await {
.await Ok(pool) => {
.expect("Failed to connect to database"); tracing::info!("✅ Database connection established successfully");
pool
tracing::info!("Database connection established"); }
Err(e) => {
tracing::error!("💀 Failed to connect to database: {}", e);
panic!("Database connection failed");
}
};
let db = Arc::new(db_pool); 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::get(get_listing_by_id))
.route("/listing/:listing_id", axum::routing::put(update_listing)) .route("/listing/:listing_id", axum::routing::put(update_listing))
.route("/listing/:listing_id", axum::routing::delete(delete_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()) .merge(crate::admin::admin_routes())
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); .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", axum::routing::get(get_counties_by_state))
.route("/state/:state_abbr/:county_id", axum::routing::get(get_county_by_id)) .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/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("/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 let app = public_routes
.merge(protected_routes) .merge(protected_routes)
.nest_service("/uploads", ServeDir::new("/uploads"))
.with_state(state) .with_state(state)
.layer(cors); .layer(cors);

314
src/service_listing/data.rs Normal file
View File

@@ -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<AppState>,
Path(county_id): Path<i32>,
) -> Result<Json<Vec<ServiceListing>>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Path(listing_id): Path<i32>,
) -> Result<Json<ServiceListing>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<ServiceListing>>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<ServiceListing>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<CreateServiceListingRequest>,
) -> Result<Json<ServiceListing>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
Json(payload): Json<UpdateServiceListingRequest>,
) -> Result<Json<ServiceListing>, (StatusCode, Json<serde_json::Value>)> {
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::<ServiceListing>();
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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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)}))
))
},
}
}

View File

@@ -0,0 +1,3 @@
pub mod structs;
pub mod data;
pub mod sitemap;

View File

@@ -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<AppState>,
) -> Result<Json<Vec<ServiceListingId>>, (StatusCode, Json<serde_json::Value>)> {
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)}))
)),
}
}

View File

@@ -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<String>,
pub county_id: i32,
pub phone: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub description: Option<String>,
pub licensed_insured: bool,
pub service_area: Option<String>,
pub years_experience: Option<i32>,
pub user_id: i32,
pub last_edited: DateTime<Utc>,
pub logo_url: Option<String>,
pub banner_url: Option<String>,
pub facebook_url: Option<String>,
pub instagram_url: Option<String>,
pub google_business_url: Option<String>,
pub towns_serviced: Option<Vec<String>>,
}
#[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<String>,
pub county_id: i32,
pub phone: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub description: Option<String>,
pub licensed_insured: bool,
pub service_area: Option<String>,
pub years_experience: Option<i32>,
pub facebook_url: Option<String>,
pub instagram_url: Option<String>,
pub google_business_url: Option<String>,
pub towns_serviced: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateServiceListingRequest {
pub company_name: Option<String>,
pub is_active: Option<bool>,
pub twenty_four_hour: Option<bool>,
pub emergency_service: Option<bool>,
pub town: Option<String>,
pub county_id: Option<i32>,
pub phone: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub description: Option<String>,
pub licensed_insured: Option<bool>,
pub service_area: Option<String>,
pub years_experience: Option<i32>,
pub facebook_url: Option<String>,
pub instagram_url: Option<String>,
pub google_business_url: Option<String>,
pub towns_serviced: Option<Vec<String>>,
}

83
src/subscription/data.rs Normal file
View File

@@ -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<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Subscription>, (StatusCode, Json<serde_json::Value>)> {
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()}))
))
}
}
}

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

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

View File

@@ -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<String>,
pub created_at: DateTime<Utc>,
}

397
src/upload/data.rs Normal file
View File

@@ -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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
// Verify ownership
let exists: Option<i32> = 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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let exists: Option<i32> = 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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let exists: Option<i32> = 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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let exists: Option<i32> = 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<AppState>,
Path(listing_id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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})))
}

1
src/upload/mod.rs Normal file
View File

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