init
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::auth::session::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Item, List, PricePoint};
|
||||
use crate::state::AppState;
|
||||
use crate::worker;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/lists", get(list_lists).post(create_list))
|
||||
.route(
|
||||
"/lists/{id}",
|
||||
axum::routing::patch(update_list).delete(delete_list),
|
||||
)
|
||||
.route("/lists/{id}/items", get(list_items).post(create_item))
|
||||
.route(
|
||||
"/items/{id}",
|
||||
axum::routing::patch(update_item).delete(delete_item),
|
||||
)
|
||||
.route("/items/{id}/refetch", post(refetch_item))
|
||||
.route("/items/{id}/history", get(item_history))
|
||||
}
|
||||
|
||||
pub const ITEM_COLS: &str = "id, list_id, title, url, note, status::text AS status, target_price, \
|
||||
position, title_fetched, current_price, currency, image_url, in_stock, source, fetched_at, \
|
||||
track_enabled, last_error, checked_at, created_at, updated_at";
|
||||
|
||||
// Same columns, qualified with the `i` alias for use in UPDATE … FROM lists,
|
||||
// where bare `id`/`position`/`created_at` would be ambiguous across both tables.
|
||||
const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::text AS status, \
|
||||
i.target_price, i.position, i.title_fetched, i.current_price, i.currency, i.image_url, \
|
||||
i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \
|
||||
i.created_at, i.updated_at";
|
||||
|
||||
const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"];
|
||||
|
||||
// ---- Lists ----------------------------------------------------------------
|
||||
|
||||
async fn list_lists(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<Json<Vec<List>>> {
|
||||
let lists = sqlx::query_as::<_, List>(
|
||||
"SELECT id, user_id, name, emoji, description, position, created_at, updated_at
|
||||
FROM lists WHERE user_id = $1 ORDER BY position, created_at",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(lists))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct CreateListReq {
|
||||
#[validate(length(min = 1, max = 80, message = "name must be 1–80 chars"))]
|
||||
name: String,
|
||||
#[validate(length(max = 16))]
|
||||
emoji: Option<String>,
|
||||
#[validate(length(max = 500))]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
async fn create_list(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<CreateListReq>,
|
||||
) -> AppResult<Json<List>> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let list = sqlx::query_as::<_, List>(
|
||||
"INSERT INTO lists (user_id, name, emoji, description, position)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0))
|
||||
RETURNING id, user_id, name, emoji, description, position, created_at, updated_at",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(req.name.trim())
|
||||
.bind(opt_trim(req.emoji))
|
||||
.bind(opt_trim(req.description))
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct UpdateListReq {
|
||||
#[validate(length(min = 1, max = 80, message = "name must be 1–80 chars"))]
|
||||
name: Option<String>,
|
||||
#[validate(length(max = 16))]
|
||||
emoji: Option<String>,
|
||||
#[validate(length(max = 500))]
|
||||
description: Option<String>,
|
||||
position: Option<i32>,
|
||||
}
|
||||
|
||||
async fn update_list(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateListReq>,
|
||||
) -> AppResult<Json<List>> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let list = sqlx::query_as::<_, List>(
|
||||
"UPDATE lists SET
|
||||
name = COALESCE($3, name),
|
||||
emoji = COALESCE($4, emoji),
|
||||
description = COALESCE($5, description),
|
||||
position = COALESCE($6, position)
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, user_id, name, emoji, description, position, created_at, updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user.id)
|
||||
.bind(req.name.map(|s| s.trim().to_string()))
|
||||
.bind(opt_trim(req.emoji))
|
||||
.bind(opt_trim(req.description))
|
||||
.bind(req.position)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
async fn delete_list(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let res = sqlx::query("DELETE FROM lists WHERE id = $1 AND user_id = $2")
|
||||
.bind(id)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "deleted": id })))
|
||||
}
|
||||
|
||||
// ---- Items ----------------------------------------------------------------
|
||||
|
||||
/// Confirm the list exists and belongs to the user. Returns NotFound otherwise.
|
||||
async fn assert_list_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
||||
let owns = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(list_id)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
if owns {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_items(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(list_id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<Item>>> {
|
||||
assert_list_owner(&state, list_id, user.id).await?;
|
||||
let items = sqlx::query_as::<_, Item>(&format!(
|
||||
"SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at"
|
||||
))
|
||||
.bind(list_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct CreateItemReq {
|
||||
#[validate(length(min = 1, max = 200, message = "title must be 1–200 chars"))]
|
||||
title: String,
|
||||
#[validate(url(message = "url must be a valid URL"))]
|
||||
url: Option<String>,
|
||||
#[validate(length(max = 1000))]
|
||||
note: Option<String>,
|
||||
target_price: Option<Decimal>,
|
||||
}
|
||||
|
||||
async fn create_item(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(list_id): Path<Uuid>,
|
||||
Json(req): Json<CreateItemReq>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
assert_list_owner(&state, list_id, user.id).await?;
|
||||
|
||||
let item = sqlx::query_as::<_, Item>(&format!(
|
||||
"INSERT INTO items (list_id, title, url, note, target_price, position)
|
||||
VALUES ($1, $2, $3, $4, $5,
|
||||
COALESCE((SELECT MAX(position) + 1 FROM items WHERE list_id = $1), 0))
|
||||
RETURNING {ITEM_COLS}"
|
||||
))
|
||||
.bind(list_id)
|
||||
.bind(req.title.trim())
|
||||
.bind(opt_trim(req.url))
|
||||
.bind(opt_trim(req.note))
|
||||
.bind(req.target_price)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct UpdateItemReq {
|
||||
#[validate(length(min = 1, max = 200, message = "title must be 1–200 chars"))]
|
||||
title: Option<String>,
|
||||
#[validate(url(message = "url must be a valid URL"))]
|
||||
url: Option<String>,
|
||||
#[validate(length(max = 1000))]
|
||||
note: Option<String>,
|
||||
status: Option<String>,
|
||||
target_price: Option<Decimal>,
|
||||
position: Option<i32>,
|
||||
}
|
||||
|
||||
async fn update_item(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateItemReq>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
if let Some(s) = &req.status {
|
||||
if !ALLOWED_STATUS.contains(&s.as_str()) {
|
||||
return Err(AppError::Validation(format!("unknown status: {s}")));
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership enforced via the join to lists.user_id.
|
||||
let item = sqlx::query_as::<_, Item>(&format!(
|
||||
"UPDATE items i SET
|
||||
title = COALESCE($3, i.title),
|
||||
url = COALESCE($4, i.url),
|
||||
note = COALESCE($5, i.note),
|
||||
status = COALESCE($6::item_status, i.status),
|
||||
target_price = COALESCE($7, i.target_price),
|
||||
position = COALESCE($8, i.position)
|
||||
FROM lists l
|
||||
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2
|
||||
RETURNING {ITEM_COLS_I}"
|
||||
))
|
||||
.bind(id)
|
||||
.bind(user.id)
|
||||
.bind(req.title.map(|s| s.trim().to_string()))
|
||||
.bind(opt_trim(req.url))
|
||||
.bind(opt_trim(req.note))
|
||||
.bind(req.status)
|
||||
.bind(req.target_price)
|
||||
.bind(req.position)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
async fn delete_item(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM items i USING lists l
|
||||
WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "deleted": id })))
|
||||
}
|
||||
|
||||
// ---- Tracking -------------------------------------------------------------
|
||||
|
||||
/// Owned item's URL, or NotFound. Inner Option is the (nullable) url.
|
||||
async fn owned_item_url(
|
||||
state: &AppState,
|
||||
item_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<Option<String>> {
|
||||
let row = sqlx::query_as::<_, (Option<String>,)>(
|
||||
"SELECT i.url FROM items i JOIN lists l ON l.id = i.list_id
|
||||
WHERE i.id = $1 AND l.user_id = $2",
|
||||
)
|
||||
.bind(item_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// Refetch a single item's price on demand. Surfaces fetch errors to the user.
|
||||
async fn refetch_item(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
let url = owned_item_url(&state, id, user.id).await?.ok_or_else(|| {
|
||||
AppError::BadRequest("this temptation has no URL to keep vigil over".into())
|
||||
})?;
|
||||
|
||||
worker::refetch(&state, id, &url)
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
|
||||
let item = sqlx::query_as::<_, Item>(&format!("SELECT {ITEM_COLS} FROM items WHERE id = $1"))
|
||||
.bind(id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
/// Price observations for an item, newest first.
|
||||
async fn item_history(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<PricePoint>>> {
|
||||
// Ownership: NotFound if the item isn't the user's.
|
||||
owned_item_url(&state, id, user.id).await?;
|
||||
|
||||
let history = sqlx::query_as::<_, PricePoint>(
|
||||
"SELECT price, currency, in_stock, fetched_at
|
||||
FROM price_history WHERE item_id = $1
|
||||
ORDER BY fetched_at DESC LIMIT 200",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(history))
|
||||
}
|
||||
|
||||
fn opt_trim(s: Option<String>) -> Option<String> {
|
||||
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
@@ -10,11 +10,14 @@ use crate::error::{AppError, AppResult};
|
||||
use crate::models::UserSettings;
|
||||
use crate::state::AppState;
|
||||
|
||||
mod lists;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/settings", patch(update_settings))
|
||||
.route("/profile", patch(update_profile))
|
||||
.merge(lists::router())
|
||||
}
|
||||
|
||||
async fn health() -> Json<Value> {
|
||||
@@ -50,7 +53,9 @@ async fn update_settings(
|
||||
}
|
||||
if let Some(cur) = &req.currency {
|
||||
if cur.len() != 3 {
|
||||
return Err(AppError::Validation("currency must be a 3-letter code".into()));
|
||||
return Err(AppError::Validation(
|
||||
"currency must be a 3-letter code".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +93,11 @@ async fn update_profile(
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let display = req
|
||||
.display_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1")
|
||||
.bind(user.id)
|
||||
.bind(display)
|
||||
|
||||
Reference in New Issue
Block a user