use axum::{ Extension, Json, extract::{Query, State}, http::StatusCode, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::core::db::schema; use crate::api::middleware::UserAuth; use crate::api::types::AppState; // ─── Trace Profile ─── #[derive(Deserialize)] pub struct TraceProfileQuery { pub file_uuid: String, pub trace_id: i64, } #[derive(Debug, Serialize, Deserialize)] pub struct TraceProfile { pub file_uuid: String, pub trace_id: i64, pub name: String, pub key_frame: Option, pub key_face: Option, pub aliases: Option, pub bbox: Option, pub properties: serde_json::Value, } #[derive(Deserialize)] pub struct UpdateTraceProfileRequest { pub file_uuid: String, pub trace_id: i64, pub name: Option, pub key_frame: Option, pub key_face: Option, pub aliases: Option, pub properties: Option, } #[derive(Deserialize)] pub struct UpdateTraceProfileGroupRequest { pub file_uuid: String, pub trace_ids: Vec, pub name: String, } pub async fn get_trace_profile_handler( State(state): State, Extension(_auth): Extension, Query(params): Query, ) -> Result, StatusCode> { let tkg_table = schema::table_name("tkg_nodes"); let external_id = format!("face_track_{}", params.trace_id); let row: Option<(String, String, serde_json::Value)> = sqlx::query_as(&format!( "SELECT label, external_id, properties FROM {} \ WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2", tkg_table )) .bind(¶ms.file_uuid) .bind(&external_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { tracing::error!("[TraceProfile] DB error: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let (label, _ext_id, properties) = row.ok_or(StatusCode::NOT_FOUND)?; let key_frame = properties.get("key_frame").and_then(|v| v.as_i64()); let key_face = properties .get("key_face") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let aliases = properties.get("aliases").cloned(); let bbox = properties.get("avg_bbox").cloned(); Ok(Json(TraceProfile { file_uuid: params.file_uuid, trace_id: params.trace_id, name: label, key_frame, key_face, aliases, bbox, properties, })) } pub async fn update_trace_profile_handler( State(state): State, Extension(_auth): Extension, Json(req): Json, ) -> Result, StatusCode> { let tkg_table = schema::table_name("tkg_nodes"); let external_id = format!("face_track_{}", req.trace_id); // Get current node let current: Option<(String, serde_json::Value)> = sqlx::query_as(&format!( "SELECT label, properties FROM {} \ WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2", tkg_table )) .bind(&req.file_uuid) .bind(&external_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { tracing::error!("[TraceProfile] DB error: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let (current_label, mut current_props) = current.ok_or(StatusCode::NOT_FOUND)?; // Build updates let mut updates: Vec = Vec::new(); if let Some(ref new_name) = req.name { if new_name != ¤t_label { updates.push(format!("label = $3")); } } // Merge properties if let Some(ref new_props) = req.properties { if let Some(obj) = new_props.as_object() { for (k, v) in obj { current_props[k] = v.clone(); } } } if let Some(ref key_frame) = req.key_frame { current_props["key_frame"] = serde_json::json!(key_frame); } if let Some(ref key_face) = req.key_face { current_props["key_face"] = serde_json::json!(key_face); } if let Some(ref aliases) = req.aliases { current_props["aliases"] = aliases.clone(); } if updates.is_empty() && req.properties.is_none() && req.key_frame.is_none() && req.key_face.is_none() && req.aliases.is_none() { return Ok(Json(serde_json::json!({ "success": true, "message": "No changes" }))); } let mut query = format!( "UPDATE {} SET properties = $4", tkg_table ); if !updates.is_empty() { query.push_str(", "); query.push_str(&updates.join(", ")); } query.push_str(" WHERE file_uuid = $1 AND node_type = 'face_track' AND external_id = $2"); let param_idx = if updates.is_empty() { 3 } else { 4 }; query.push_str(&format!(" RETURNING id")); let result = if !updates.is_empty() { sqlx::query(&query) .bind(&req.file_uuid) .bind(&external_id) .bind(req.name.as_ref().unwrap_or(¤t_label)) .bind(¤t_props) .execute(state.db.pool()) .await } else { sqlx::query(&query) .bind(&req.file_uuid) .bind(&external_id) .bind(¤t_props) .execute(state.db.pool()) .await }; match result { Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({ "success": true, "message": "Trace profile updated", "file_uuid": req.file_uuid, "trace_id": req.trace_id }))), Ok(_) => Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("[TraceProfile] Update failed: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } } pub async fn update_trace_profile_group_handler( State(state): State, Extension(_auth): Extension, Json(req): Json, ) -> Result, StatusCode> { let tkg_table = schema::table_name("tkg_nodes"); let mut updated = 0; for trace_id in &req.trace_ids { let external_id = format!("face_track_{}", trace_id); let result = sqlx::query(&format!( "UPDATE {} SET label = $1 \ WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3", tkg_table )) .bind(&req.name) .bind(&req.file_uuid) .bind(&external_id) .execute(state.db.pool()) .await; if let Ok(res) = result { updated += res.rows_affected(); } } Ok(Json(serde_json::json!({ "success": true, "message": format!("Updated {} traces in group", updated), "file_uuid": req.file_uuid, "updated_count": updated }))) } // ─── File Profile ─── #[derive(Deserialize)] pub struct FileProfileQuery { pub file_uuid: String, } #[derive(Debug, Serialize, Deserialize)] pub struct FileProfile { pub file_uuid: String, pub file_name: String, pub file_path: String, pub status: String, pub duration: f64, pub width: i32, pub height: i32, pub fps: f64, pub total_frames: i64, } #[derive(Deserialize)] pub struct UpdateFileProfileRequest { pub file_uuid: String, pub file_path: Option, pub file_name: Option, } pub async fn get_file_profile_handler( State(state): State, Extension(_auth): Extension, Query(params): Query, ) -> Result, StatusCode> { let videos_table = schema::table_name("videos"); let row: Option<(String, String, String, String, f64, i32, i32, f64, i64)> = sqlx::query_as(&format!( "SELECT file_uuid, file_name, file_path, status, duration, width, height, fps, \ COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1", videos_table )) .bind(¶ms.file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|e| { tracing::error!("[FileProfile] DB error: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let (file_uuid, file_name, file_path, status, duration, width, height, fps, total_frames) = row.ok_or(StatusCode::NOT_FOUND)?; Ok(Json(FileProfile { file_uuid, file_name, file_path, status, duration, width, height, fps, total_frames, })) } pub async fn update_file_profile_handler( State(state): State, Extension(_auth): Extension, Json(req): Json, ) -> Result, StatusCode> { let videos_table = schema::table_name("videos"); let mut updates: Vec = Vec::new(); let mut param_idx: i32 = 1; if req.file_path.is_some() { updates.push(format!("file_path = ${}", param_idx)); param_idx += 1; } if req.file_name.is_some() { updates.push(format!("file_name = ${}", param_idx)); param_idx += 1; } if updates.is_empty() { return Ok(Json(serde_json::json!({ "success": true, "message": "No changes" }))); } updates.push("updated_at = CURRENT_TIMESTAMP".to_string()); let sql = format!( "UPDATE {} SET {} WHERE file_uuid = ${}", videos_table, updates.join(", "), param_idx ); // Build the SQL and execute with correct bind order let result = match (&req.file_path, &req.file_name) { (Some(fp), Some(fn_)) => { sqlx::query(&sql) .bind(fp) .bind(fn_) .bind(&req.file_uuid) .execute(state.db.pool()) .await } (Some(fp), None) => { sqlx::query(&sql) .bind(fp) .bind(&req.file_uuid) .execute(state.db.pool()) .await } (None, Some(fn_)) => { sqlx::query(&sql) .bind(fn_) .bind(&req.file_uuid) .execute(state.db.pool()) .await } (None, None) => unreachable!(), }; match result { Ok(res) if res.rows_affected() > 0 => Ok(Json(serde_json::json!({ "success": true, "message": "File profile updated", "file_uuid": req.file_uuid }))), Ok(_) => Err(StatusCode::NOT_FOUND), Err(e) => { tracing::error!("[FileProfile] Update failed: {}", e); Err(StatusCode::INTERNAL_SERVER_ERROR) } } } // ─── Routes ─── pub fn profile_routes() -> axum::Router { use axum::routing::{get, put}; axum::Router::new() .route("/api/v1/trace-profile", get(get_trace_profile_handler)) .route("/api/v1/trace-profile", put(update_trace_profile_handler)) .route( "/api/v1/trace-profile/group", put(update_trace_profile_group_handler), ) .route("/api/v1/file-profile", get(get_file_profile_handler)) .route("/api/v1/file-profile", put(update_file_profile_handler)) }