feat: add neighborhood detail workspace
This commit is contained in:
@@ -10,8 +10,9 @@ use crate::models::{
|
||||
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
|
||||
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
|
||||
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
|
||||
MonthQuery, Neighborhood, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery,
|
||||
RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
|
||||
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
|
||||
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
|
||||
WatchlistItem,
|
||||
};
|
||||
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
|
||||
use crate::state::AppState;
|
||||
@@ -454,7 +455,118 @@ pub async fn get_neighborhood(
|
||||
State(state): State<AppState>,
|
||||
Path(neighborhood_id): Path<String>,
|
||||
) -> ApiResult<Json<Neighborhood>> {
|
||||
let neighborhood = sqlx::query_as::<_, Neighborhood>(
|
||||
let neighborhood = fetch_neighborhood_profile(&state, &neighborhood_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
|
||||
|
||||
Ok(Json(neighborhood))
|
||||
}
|
||||
|
||||
pub async fn get_neighborhood_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(neighborhood_id): Path<String>,
|
||||
Query(query): Query<MonthQuery>,
|
||||
) -> ApiResult<Json<NeighborhoodDetail>> {
|
||||
validate_month(&query.month)?;
|
||||
|
||||
let profile = fetch_neighborhood_profile(&state, &neighborhood_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
|
||||
|
||||
let current_score = 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 neighborhood_id = $1
|
||||
AND month = $2
|
||||
"#,
|
||||
)
|
||||
.bind(&neighborhood_id)
|
||||
.bind(&query.month)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let monthly_metrics = sqlx::query_as::<_, NeighborhoodMonthlyMetric>(
|
||||
r#"
|
||||
SELECT
|
||||
neighborhood_id,
|
||||
month,
|
||||
transaction_count,
|
||||
transaction_price_psm,
|
||||
listing_count,
|
||||
listing_price_psm,
|
||||
rent_price_psm,
|
||||
median_days_on_market,
|
||||
available_units,
|
||||
source,
|
||||
ingestion_run_id,
|
||||
raw_artifact_id
|
||||
FROM silver.neighborhood_monthly_metrics
|
||||
WHERE neighborhood_id = $1
|
||||
AND month <= $2
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
"#,
|
||||
)
|
||||
.bind(&neighborhood_id)
|
||||
.bind(&query.month)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let watchlist_items = sqlx::query_as::<_, WatchlistItem>(
|
||||
r#"
|
||||
SELECT
|
||||
watchlist_item_id,
|
||||
neighborhood_id,
|
||||
area_id,
|
||||
target_price_psm,
|
||||
status,
|
||||
notes
|
||||
FROM app.watchlist_items
|
||||
WHERE neighborhood_id = $1
|
||||
AND status <> 'archived'
|
||||
ORDER BY updated_at DESC, watchlist_item_id DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&neighborhood_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(NeighborhoodDetail {
|
||||
profile,
|
||||
current_score,
|
||||
monthly_metrics,
|
||||
watchlist_items,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_neighborhood_profile(
|
||||
state: &AppState,
|
||||
neighborhood_id: &str,
|
||||
) -> Result<Option<Neighborhood>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Neighborhood>(
|
||||
r#"
|
||||
SELECT
|
||||
n.neighborhood_id,
|
||||
@@ -474,10 +586,7 @@ pub async fn get_neighborhood(
|
||||
)
|
||||
.bind(neighborhood_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
|
||||
|
||||
Ok(Json(neighborhood))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_neighborhood_scores(
|
||||
|
||||
@@ -245,6 +245,22 @@ pub struct Neighborhood {
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct NeighborhoodMonthlyMetric {
|
||||
pub neighborhood_id: String,
|
||||
pub month: String,
|
||||
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 available_units: i32,
|
||||
pub source: String,
|
||||
pub ingestion_run_id: Option<i64>,
|
||||
pub raw_artifact_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct NeighborhoodScore {
|
||||
pub neighborhood_id: String,
|
||||
@@ -271,6 +287,14 @@ pub struct NeighborhoodScore {
|
||||
pub available_units: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NeighborhoodDetail {
|
||||
pub profile: Neighborhood,
|
||||
pub current_score: Option<NeighborhoodScore>,
|
||||
pub monthly_metrics: Vec<NeighborhoodMonthlyMetric>,
|
||||
pub watchlist_items: Vec<WatchlistItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct DataSource {
|
||||
pub source_id: i64,
|
||||
|
||||
@@ -6,10 +6,10 @@ use tower_http::trace::TraceLayer;
|
||||
use crate::handlers::{
|
||||
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
|
||||
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
|
||||
get_area_detail, get_area_score_lineage, get_neighborhood, health, list_area_scores,
|
||||
list_data_sources, list_ingestion_runs, list_neighborhood_scores, list_neighborhoods,
|
||||
list_raw_artifacts, list_watchlist_items, market_overview, ready, update_watchlist_item,
|
||||
validate_import,
|
||||
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
|
||||
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
|
||||
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
|
||||
update_watchlist_item, validate_import,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -51,6 +51,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/areas/{area_id}/detail", get(get_area_detail))
|
||||
.route("/neighborhoods", get(list_neighborhoods))
|
||||
.route("/neighborhoods/scores", get(list_neighborhood_scores))
|
||||
.route(
|
||||
"/neighborhoods/{neighborhood_id}/detail",
|
||||
get(get_neighborhood_detail),
|
||||
)
|
||||
.route("/neighborhoods/{neighborhood_id}", get(get_neighborhood))
|
||||
.route(
|
||||
"/watchlist",
|
||||
|
||||
Reference in New Issue
Block a user