feat: add watchlist api
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{AreaScore, AreaScoreSummary, MarketOverview, MonthQuery};
|
||||
use crate::models::{
|
||||
AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery,
|
||||
UpdateWatchlistItem, WatchlistItem,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -67,6 +70,129 @@ pub async fn market_overview(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_watchlist_items(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<WatchlistItem>>> {
|
||||
let items = sqlx::query_as::<_, WatchlistItem>(
|
||||
r#"
|
||||
SELECT
|
||||
watchlist_item_id,
|
||||
neighborhood_id,
|
||||
area_id,
|
||||
target_price_psm,
|
||||
status,
|
||||
notes
|
||||
FROM app.watchlist_items
|
||||
WHERE status <> 'archived'
|
||||
ORDER BY updated_at DESC, watchlist_item_id DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
pub async fn create_watchlist_item(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateWatchlistItem>,
|
||||
) -> ApiResult<Json<WatchlistItem>> {
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||
|
||||
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||
r#"
|
||||
INSERT INTO app.watchlist_items (
|
||||
neighborhood_id,
|
||||
area_id,
|
||||
target_price_psm,
|
||||
notes
|
||||
)
|
||||
VALUES ($1, $2, $3, COALESCE($4, ''))
|
||||
RETURNING
|
||||
watchlist_item_id,
|
||||
neighborhood_id,
|
||||
area_id,
|
||||
target_price_psm,
|
||||
status,
|
||||
notes
|
||||
"#,
|
||||
)
|
||||
.bind(payload.neighborhood_id)
|
||||
.bind(payload.area_id)
|
||||
.bind(payload.target_price_psm)
|
||||
.bind(payload.notes)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
pub async fn update_watchlist_item(
|
||||
State(state): State<AppState>,
|
||||
Path(watchlist_item_id): Path<i64>,
|
||||
Json(payload): Json<UpdateWatchlistItem>,
|
||||
) -> ApiResult<Json<WatchlistItem>> {
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||
|
||||
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||
r#"
|
||||
UPDATE app.watchlist_items
|
||||
SET target_price_psm = COALESCE($2, target_price_psm),
|
||||
status = COALESCE($3, status),
|
||||
notes = COALESCE($4, notes),
|
||||
updated_at = now()
|
||||
WHERE watchlist_item_id = $1
|
||||
RETURNING
|
||||
watchlist_item_id,
|
||||
neighborhood_id,
|
||||
area_id,
|
||||
target_price_psm,
|
||||
status,
|
||||
notes
|
||||
"#,
|
||||
)
|
||||
.bind(watchlist_item_id)
|
||||
.bind(payload.target_price_psm)
|
||||
.bind(payload.status)
|
||||
.bind(payload.notes)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
|
||||
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
pub async fn archive_watchlist_item(
|
||||
State(state): State<AppState>,
|
||||
Path(watchlist_item_id): Path<i64>,
|
||||
) -> ApiResult<Json<WatchlistItem>> {
|
||||
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||
r#"
|
||||
UPDATE app.watchlist_items
|
||||
SET status = 'archived',
|
||||
updated_at = now()
|
||||
WHERE watchlist_item_id = $1
|
||||
RETURNING
|
||||
watchlist_item_id,
|
||||
neighborhood_id,
|
||||
area_id,
|
||||
target_price_psm,
|
||||
status,
|
||||
notes
|
||||
"#,
|
||||
)
|
||||
.bind(watchlist_item_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
|
||||
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
|
||||
sqlx::query_as::<_, AreaScore>(
|
||||
r#"
|
||||
@@ -135,6 +261,8 @@ fn validate_month(month: &str) -> ApiResult<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::{CreateWatchlistItem, UpdateWatchlistItem};
|
||||
|
||||
use super::validate_month;
|
||||
|
||||
#[test]
|
||||
@@ -148,4 +276,52 @@ mod tests {
|
||||
assert!(validate_month("2026-13").is_err());
|
||||
assert!(validate_month("abcd-05").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_watchlist_create_payload() {
|
||||
assert!(CreateWatchlistItem {
|
||||
neighborhood_id: None,
|
||||
area_id: None,
|
||||
target_price_psm: None,
|
||||
notes: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
|
||||
assert!(CreateWatchlistItem {
|
||||
neighborhood_id: None,
|
||||
area_id: Some("zhangjiang".to_string()),
|
||||
target_price_psm: Some(80_000.0),
|
||||
notes: None,
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_watchlist_update_payload() {
|
||||
assert!(UpdateWatchlistItem {
|
||||
target_price_psm: Some(-1.0),
|
||||
status: None,
|
||||
notes: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
|
||||
assert!(UpdateWatchlistItem {
|
||||
target_price_psm: None,
|
||||
status: Some("unknown".to_string()),
|
||||
notes: None,
|
||||
}
|
||||
.validate()
|
||||
.is_err());
|
||||
|
||||
assert!(UpdateWatchlistItem {
|
||||
target_price_psm: Some(80_000.0),
|
||||
status: Some("active".to_string()),
|
||||
notes: Some("跟踪产业兑现和挂牌变化".to_string()),
|
||||
}
|
||||
.validate()
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user