feat: implement SEO improvements, listing profiles, service images, and towns serviced
This commit is contained in:
90
Cargo.lock
generated
90
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE listings ALTER COLUMN price_per_gallon TYPE DOUBLE PRECISION;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE listings DROP COLUMN company_id;
|
||||
269
init_fuelprices_prod.sql
Normal file
269
init_fuelprices_prod.sql
Normal 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
10
migrate_banner_url.sql
Normal 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
10
migrate_logo_url.sql
Normal 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);
|
||||
24
migrate_service_listings.sql
Normal file
24
migrate_service_listings.sql
Normal 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
|
||||
);
|
||||
13
migrate_towns_serviced.sql
Normal file
13
migrate_towns_serviced.sql
Normal 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)
|
||||
);
|
||||
67
schema.sql
67
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)
|
||||
);
|
||||
|
||||
85
src/banner/data.rs
Normal file
85
src/banner/data.rs
Normal 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
2
src/banner/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod data;
|
||||
pub mod structs;
|
||||
17
src/banner/structs.rs
Normal file
17
src/banner/structs.rs
Normal 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,
|
||||
}
|
||||
@@ -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
93
src/data/api_directory.rs
Normal 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))
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod data;
|
||||
pub mod api_directory;
|
||||
@@ -13,7 +13,7 @@ pub async fn get_listings(
|
||||
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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::<Listing>();
|
||||
|
||||
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<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<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(
|
||||
State(app_state): State<AppState>,
|
||||
Path(listing_id): Path<i32>,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod structs;
|
||||
pub mod data;
|
||||
pub mod sitemap;
|
||||
|
||||
32
src/listing/sitemap.rs
Normal file
32
src/listing/sitemap.rs
Normal 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)}))
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ pub struct Listing {
|
||||
pub user_id: i32,
|
||||
pub last_edited: DateTime<Utc>,
|
||||
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)]
|
||||
@@ -38,6 +44,10 @@ pub struct CreateListingRequest {
|
||||
pub county_id: i32,
|
||||
pub town: 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 {
|
||||
@@ -77,6 +87,10 @@ pub struct UpdateListingRequest {
|
||||
pub county_id: Option<i32>,
|
||||
pub town: 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 {
|
||||
|
||||
87
src/main.rs
87
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 frontend_origin = env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "http://localhost:9551".to_string());
|
||||
let mut database_url = env::var("DATABASE_URL")
|
||||
.expect("💀 DATABASE_URL must be set")
|
||||
.trim()
|
||||
.trim_matches(|c| c == '"' || c == '\'')
|
||||
.to_string();
|
||||
|
||||
tracing::info!(frontend_origin = %frontend_origin, "Configuration loaded");
|
||||
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!("🌍 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
|
||||
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);
|
||||
|
||||
|
||||
314
src/service_listing/data.rs
Normal file
314
src/service_listing/data.rs
Normal 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)}))
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
3
src/service_listing/mod.rs
Normal file
3
src/service_listing/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod structs;
|
||||
pub mod data;
|
||||
pub mod sitemap;
|
||||
32
src/service_listing/sitemap.rs
Normal file
32
src/service_listing/sitemap.rs
Normal 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)}))
|
||||
)),
|
||||
}
|
||||
}
|
||||
72
src/service_listing/structs.rs
Normal file
72
src/service_listing/structs.rs
Normal 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
83
src/subscription/data.rs
Normal 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
2
src/subscription/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod data;
|
||||
pub mod structs;
|
||||
16
src/subscription/structs.rs
Normal file
16
src/subscription/structs.rs
Normal 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
397
src/upload/data.rs
Normal 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
1
src/upload/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod data;
|
||||
Reference in New Issue
Block a user