use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; use serde::Deserialize; use serde_json::json; use uuid::Uuid; use validator::Validate; use plc_platform_core::util::response::ApiErr; use crate::{service::station as station_service, AppState}; const STATION_TYPES: &[&str] = &[ "load", "dry_in", "dry_step", "dry_out", "fire_in", "fire_step", "fire_out", "transfer", "unload", "return", ]; const SIGNAL_ROLES: &[&str] = &[ "presence", "vacancy", "arrived", "allow_in", "done", "fault", ]; fn validate_station_type(value: &str) -> Result<(), ApiErr> { if STATION_TYPES.contains(&value) { Ok(()) } else { Err(ApiErr::BadRequest( format!("invalid station_type: {}", value), Some(json!({ "allowed": STATION_TYPES })), )) } } fn validate_signal_role(value: &str) -> Result<(), ApiErr> { if SIGNAL_ROLES.contains(&value) { Ok(()) } else { Err(ApiErr::BadRequest( format!("invalid signal_role: {}", value), Some(json!({ "allowed": SIGNAL_ROLES })), )) } } #[derive(Debug, Deserialize, Validate)] pub struct ListStationQuery { #[validate(length(min = 1, max = 50))] pub line_code: Option, } pub async fn list_stations( State(state): State, Query(query): Query, ) -> Result { query.validate()?; let stations = station_service::list_stations(&state.platform.pool, query.line_code.as_deref()).await?; Ok(Json(stations)) } pub async fn get_station( State(state): State, Path(station_id): Path, ) -> Result { let station = station_service::get_station_by_id(&state.platform.pool, station_id) .await? .ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?; let signals = station_service::list_station_signals(&state.platform.pool, station_id).await?; Ok(Json(json!({ "station": station, "signals": signals, }))) } #[derive(Debug, Deserialize, Validate)] pub struct CreateStationReq { #[validate(length(min = 1, max = 100))] pub code: String, #[validate(length(min = 1, max = 100))] pub name: String, #[validate(length(min = 1, max = 50))] pub line_code: Option, #[validate(length(min = 1, max = 50))] pub segment_code: Option, #[validate(length(min = 1, max = 32))] pub station_type: String, pub enabled: Option, pub description: Option, } pub async fn create_station( State(state): State, Json(payload): Json, ) -> Result { payload.validate()?; validate_station_type(&payload.station_type)?; if station_service::get_station_by_code(&state.platform.pool, &payload.code) .await? .is_some() { return Err(ApiErr::BadRequest( "Station code already exists".to_string(), None, )); } let station_id = station_service::create_station( &state.platform.pool, station_service::CreateStationParams { code: &payload.code, name: &payload.name, line_code: payload.line_code.as_deref(), segment_code: payload.segment_code.as_deref(), station_type: &payload.station_type, enabled: payload.enabled.unwrap_or(true), description: payload.description.as_deref(), }, ) .await?; Ok(( StatusCode::CREATED, Json(json!({ "id": station_id, "ok_msg": "Station created" })), )) } #[derive(Debug, Deserialize, Validate)] pub struct UpdateStationReq { #[validate(length(min = 1, max = 100))] pub code: Option, #[validate(length(min = 1, max = 100))] pub name: Option, #[validate(length(min = 1, max = 50))] pub line_code: Option, #[validate(length(min = 1, max = 50))] pub segment_code: Option, #[validate(length(min = 1, max = 32))] pub station_type: Option, pub enabled: Option, pub description: Option, } pub async fn update_station( State(state): State, Path(station_id): Path, Json(payload): Json, ) -> Result { payload.validate()?; if let Some(t) = payload.station_type.as_deref() { validate_station_type(t)?; } let existing = station_service::get_station_by_id(&state.platform.pool, station_id) .await? .ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?; if let Some(code) = payload.code.as_deref() { if let Some(other) = station_service::get_station_by_code(&state.platform.pool, code).await? { if other.id != existing.id { return Err(ApiErr::BadRequest( "Station code already exists".to_string(), None, )); } } } station_service::update_station( &state.platform.pool, station_id, station_service::UpdateStationParams { code: payload.code.as_deref(), name: payload.name.as_deref(), line_code: payload.line_code.as_deref(), segment_code: payload.segment_code.as_deref(), station_type: payload.station_type.as_deref(), enabled: payload.enabled, description: payload.description.as_deref(), }, ) .await?; Ok(Json(json!({ "ok_msg": "Station updated" }))) } pub async fn delete_station( State(state): State, Path(station_id): Path, ) -> Result { let deleted = station_service::delete_station(&state.platform.pool, station_id).await?; if !deleted { return Err(ApiErr::NotFound("Station not found".to_string(), None)); } Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize, Validate)] pub struct UpsertStationSignalReq { #[validate(length(min = 1, max = 32))] pub signal_role: String, pub point_id: Option, #[validate(length(min = 1, max = 32))] pub derived_from_role: Option, pub invert_value: Option, } pub async fn upsert_station_signal( State(state): State, Path(station_id): Path, Json(payload): Json, ) -> Result { payload.validate()?; validate_signal_role(&payload.signal_role)?; if let Some(role) = payload.derived_from_role.as_deref() { validate_signal_role(role)?; } if payload.point_id.is_none() && payload.derived_from_role.is_none() { return Err(ApiErr::BadRequest( "either point_id or derived_from_role must be provided".to_string(), None, )); } station_service::get_station_by_id(&state.platform.pool, station_id) .await? .ok_or_else(|| ApiErr::NotFound("Station not found".to_string(), None))?; let signal = station_service::upsert_station_signal( &state.platform.pool, station_id, station_service::UpsertStationSignalParams { signal_role: payload.signal_role, point_id: payload.point_id, derived_from_role: payload.derived_from_role, invert_value: payload.invert_value.unwrap_or(false), }, ) .await?; Ok(Json(signal)) } pub async fn delete_station_signal( State(state): State, Path((station_id, role)): Path<(Uuid, String)>, ) -> Result { validate_signal_role(&role)?; let deleted = station_service::delete_station_signal(&state.platform.pool, station_id, &role).await?; if !deleted { return Err(ApiErr::NotFound( "Station signal binding not found".to_string(), None, )); } Ok(StatusCode::NO_CONTENT) } #[cfg(test)] mod tests { use super::*; use crate::model::StationSignalRole; #[test] fn create_station_req_rejects_blank_code() { let payload = CreateStationReq { code: "".to_string(), name: "Dry-1 In".to_string(), line_code: None, segment_code: None, station_type: "dry_in".to_string(), enabled: None, description: None, }; assert!(payload.validate().is_err()); } #[test] fn validate_station_type_rejects_unknown() { assert!(validate_station_type("nope").is_err()); assert!(validate_station_type("dry_in").is_ok()); } #[test] fn station_signal_role_enum_covers_handler_allowlist() { let known = [ StationSignalRole::Presence, StationSignalRole::Vacancy, StationSignalRole::Arrived, StationSignalRole::AllowIn, StationSignalRole::Done, StationSignalRole::Fault, ]; for role in known { assert!(SIGNAL_ROLES.contains(&role.as_str())); } } }