This commit is contained in:
2026-06-17 10:59:45 +02:00
parent 408e48c568
commit a2ccec4bb1
35 changed files with 2514 additions and 257 deletions
+356
View File
@@ -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 180 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 180 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 1200 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 1200 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())
}
+11 -2
View File
@@ -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)