feat: add neighborhood asset and scoring api

This commit is contained in:
2026-06-23 16:39:07 +08:00
parent c2604c65c1
commit b73a89e576
5 changed files with 327 additions and 4 deletions

View File

@@ -4,8 +4,9 @@ use serde::Serialize;
use crate::error::{ApiError, ApiResult};
use crate::models::{
AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery,
UpdateWatchlistItem, WatchlistItem,
AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery, Neighborhood,
NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, UpdateWatchlistItem,
WatchlistItem,
};
use crate::state::AppState;
@@ -70,6 +71,111 @@ pub async fn market_overview(
}))
}
pub async fn list_neighborhoods(
State(state): State<AppState>,
Query(query): Query<NeighborhoodQuery>,
) -> ApiResult<Json<Vec<Neighborhood>>> {
let neighborhoods = sqlx::query_as::<_, Neighborhood>(
r#"
SELECT
n.neighborhood_id,
n.area_id,
a.name AS area_name,
a.district,
n.name,
n.built_year,
n.property_type,
n.metro_distance_m,
n.school_quality,
n.notes
FROM silver.neighborhoods n
JOIN silver.areas a ON a.area_id = n.area_id
WHERE ($1::text IS NULL OR n.area_id = $1)
ORDER BY a.district, a.name, n.name
"#,
)
.bind(query.area_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(neighborhoods))
}
pub async fn get_neighborhood(
State(state): State<AppState>,
Path(neighborhood_id): Path<String>,
) -> ApiResult<Json<Neighborhood>> {
let neighborhood = sqlx::query_as::<_, Neighborhood>(
r#"
SELECT
n.neighborhood_id,
n.area_id,
a.name AS area_name,
a.district,
n.name,
n.built_year,
n.property_type,
n.metro_distance_m,
n.school_quality,
n.notes
FROM silver.neighborhoods n
JOIN silver.areas a ON a.area_id = n.area_id
WHERE n.neighborhood_id = $1
"#,
)
.bind(neighborhood_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
Ok(Json(neighborhood))
}
pub async fn list_neighborhood_scores(
State(state): State<AppState>,
Query(query): Query<NeighborhoodScoreQuery>,
) -> ApiResult<Json<Vec<NeighborhoodScore>>> {
validate_month(&query.month)?;
let scores = sqlx::query_as::<_, NeighborhoodScore>(
r#"
SELECT
neighborhood_id,
area_id,
name,
area_name,
district,
month,
investment_score,
recommendation,
liquidity_score,
rent_support_score,
price_safety_score,
location_score,
building_age_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
discount_pct,
available_units
FROM gold.neighborhood_scores
WHERE month = $1
AND ($2::text IS NULL OR area_id = $2)
ORDER BY investment_score DESC, neighborhood_id ASC
"#,
)
.bind(query.month)
.bind(query.area_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(scores))
}
pub async fn list_watchlist_items(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<WatchlistItem>>> {

View File

@@ -6,6 +6,17 @@ pub struct MonthQuery {
pub month: String,
}
#[derive(Debug, Deserialize)]
pub struct NeighborhoodQuery {
pub area_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NeighborhoodScoreQuery {
pub month: String,
pub area_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct AreaScore {
pub area_id: String,
@@ -67,6 +78,46 @@ pub struct MarketOverview {
pub highest_supply_risk_area: Option<AreaScoreSummary>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct Neighborhood {
pub neighborhood_id: String,
pub area_id: String,
pub area_name: String,
pub district: String,
pub name: String,
pub built_year: Option<i32>,
pub property_type: String,
pub metro_distance_m: Option<i32>,
pub school_quality: Option<String>,
pub notes: String,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NeighborhoodScore {
pub neighborhood_id: String,
pub area_id: String,
pub name: String,
pub area_name: String,
pub district: String,
pub month: String,
pub investment_score: f64,
pub recommendation: String,
pub liquidity_score: f64,
pub rent_support_score: f64,
pub price_safety_score: f64,
pub location_score: f64,
pub building_age_score: f64,
pub transaction_count: i32,
pub transaction_price_psm: f64,
pub listing_count: i32,
pub listing_price_psm: f64,
pub rent_price_psm: f64,
pub median_days_on_market: f64,
pub annual_rent_yield_pct: f64,
pub discount_pct: f64,
pub available_units: i32,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct WatchlistItem {
pub watchlist_item_id: i64,

View File

@@ -4,8 +4,9 @@ use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::handlers::{
archive_watchlist_item, create_watchlist_item, health, list_area_scores, list_watchlist_items,
market_overview, ready, update_watchlist_item,
archive_watchlist_item, create_watchlist_item, get_neighborhood, health, list_area_scores,
list_neighborhood_scores, list_neighborhoods, list_watchlist_items, market_overview, ready,
update_watchlist_item,
};
use crate::state::AppState;
@@ -18,6 +19,9 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/areas/scores", get(list_area_scores))
.route("/market/overview", get(market_overview))
.route("/neighborhoods", get(list_neighborhoods))
.route("/neighborhoods/scores", get(list_neighborhood_scores))
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
.route(
"/watchlist",
get(list_watchlist_items).post(create_watchlist_item),