use axum::{ extract::{Extension, Path, Query, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use serde::{Deserialize, Serialize}; use sqlx::Row; use crate::core::db::{Database, PostgresDb}; use crate::core::person_identity::{ BindIdentityRequest, BindIdentityTraceRequest, Identity, MergeIdentitiesRequest, UnbindIdentityRequest, }; #[derive(Debug, Clone, Serialize)] pub struct ApiResponse { pub success: bool, pub message: String, pub data: Option, } // ============================================================================ // API Handlers // ============================================================================ async fn get_db() -> Result)> { PostgresDb::init().await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": format!("DB init failed: {}", e) })), ) }) } /// 獲取 Identity (人物) 列表 pub async fn list_identities( Query(params): Query, ) -> Result>>, (StatusCode, Json)> { let db = get_db().await?; let limit = params.limit.unwrap_or(100); let offset = params.offset.unwrap_or(0); let search = params.search.unwrap_or_default(); let identities = db .list_identities(&search, limit, offset) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() })), ) })?; Ok(Json(ApiResponse { success: true, message: format!("Found {} identities", identities.len()), data: Some(identities), })) } /// V4.0 直接綁定:face_detections.identity_id = identities.id pub async fn bind_identity( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); let uuid_clean = identity_uuid.replace('-', ""); let identity_row: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let (identity_id, name) = identity_row.ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": format!("Identity not found: {}", identity_uuid)})), ) })?; // Capture old identity_id before bind let old_identity_id: Option = sqlx::query_scalar(&format!( "SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2", table )) .bind(&req.file_uuid) .bind(&req.face_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })? .flatten(); // Direct UPDATE face_detections.identity_id let result = sqlx::query(&format!( "UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3", table )) .bind(identity_id) .bind(&req.file_uuid) .bind(&req.face_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Clear bind redo stack let _ = sqlx::query(&format!( "DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')", history_table )) .bind(identity_id) .execute(state.db.pool()) .await; // Insert history record let uid = auth.user_id.to_string(); let usrc = match auth.source { crate::api::middleware::AuthSource::Jwt => "jwt", crate::api::middleware::AuthSource::Session => "session", crate::api::middleware::AuthSource::ApiKey => "api_key", }; let before = serde_json::json!({ "file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id }); let after = serde_json::json!({ "file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_after": identity_id }); let _ = sqlx::query(&format!( "INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'bind', $2, $3, false, $4, $5)", history_table )) .bind(identity_id) .bind(before) .bind(after) .bind(&uid) .bind(usrc) .execute(state.db.pool()) .await; // Sync identity JSON file if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean) .await { tracing::warn!( "[bind] Failed to sync identity file for {}: {}", uuid_clean, e ); } Ok(Json(ApiResponse { success: true, message: format!( "Bound face {} of {} to {}", req.face_id, req.file_uuid, name ), data: Some(serde_json::json!({"rows_affected": result.rows_affected()})), })) } /// V4.0 直接解綁:SET face_detections.identity_id = NULL pub async fn unbind_identity( State(state): State, Extension(auth): Extension, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); // Capture old identity_id before unbind let old_identity_id: Option = sqlx::query_scalar(&format!( "SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2", table )) .bind(&req.file_uuid) .bind(&req.face_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })? .flatten(); let result = sqlx::query(&format!( "UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2", table )) .bind(&req.file_uuid) .bind(&req.face_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Record history if there was a binding if let Some(identity_id) = old_identity_id { // Clear bind redo stack let _ = sqlx::query(&format!( "DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')", history_table )) .bind(identity_id) .execute(state.db.pool()) .await; // Insert history record let uid = auth.user_id.to_string(); let usrc = match auth.source { crate::api::middleware::AuthSource::Jwt => "jwt", crate::api::middleware::AuthSource::Session => "session", crate::api::middleware::AuthSource::ApiKey => "api_key", }; let before = serde_json::json!({ "file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id }); let after = serde_json::json!({ "file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_after": null }); let _ = sqlx::query(&format!( "INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'unbind', $2, $3, false, $4, $5)", history_table )) .bind(identity_id) .bind(before) .bind(after) .bind(&uid) .bind(usrc) .execute(state.db.pool()) .await; // Sync the identity JSON let uuid: Option = sqlx::query_scalar(&format!( "SELECT uuid::text FROM {} WHERE id = $1", id_table )) .bind(identity_id) .fetch_optional(state.db.pool()) .await .ok() .flatten(); if let Some(identity_uuid) = uuid { if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool( state.db.pool(), &identity_uuid, ) .await { tracing::warn!( "[unbind] Failed to sync identity file for {}: {}", identity_uuid, e ); } } } Ok(Json(ApiResponse { success: true, message: format!("Unbound face {} from {}", req.face_id, req.file_uuid), data: Some(serde_json::json!({"rows_affected": result.rows_affected()})), })) } fn deep_merge_metadata_fields( from: &serde_json::Map, into: &mut serde_json::Map, added: &mut Vec, prefix: &str, ) { for (key, value) in from.iter() { if key == "aliases" { continue; } let path = if prefix.is_empty() { key.clone() } else { format!("{}.{}", prefix, key) }; if !into.contains_key(key) { into.insert(key.clone(), value.clone()); added.push(path); } else if let (Some(from_obj), Some(into_obj)) = (value.as_object(), into[key].as_object()) { let mut merged = into_obj.clone(); deep_merge_metadata_fields(from_obj, &mut merged, added, &path); into.insert(key.clone(), serde_json::Value::Object(merged)); } } } fn remove_nested_meta_field(value: &mut serde_json::Value, parts: &[&str]) { if parts.is_empty() { return; } if parts.len() == 1 { if let Some(obj) = value.as_object_mut() { obj.remove(parts[0]); } return; } if let Some(child) = value.get_mut(parts[0]) { remove_nested_meta_field(child, &parts[1..]); if let Some(obj) = value.get(parts[0]).and_then(|v| v.as_object()) { if obj.is_empty() { if let Some(parent_obj) = value.as_object_mut() { parent_obj.remove(parts[0]); } } } } } /// V4.0 合併:將 identity A 合併入 identity B,A 被刪除或標記為 merged pub async fn merge_identities( Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let face_table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let uuid_clean = identity_uuid.replace('-', ""); let into_uuid_clean = req.into_uuid.replace('-', ""); // Get full source identity snapshot let source_row: Option<( i32, String, Option, Option, String, Option, Option, Option, Option, )> = sqlx::query_as(&format!( "SELECT id, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata, created_at FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let ( from_id, from_name, from_type, from_source, from_status, from_tmdb_id, from_tmdb_profile, from_metadata, from_created_at, ) = source_row.ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Source identity not found"})), ))?; // Get full target identity snapshot let target_row: Option<(i32, String, Option)> = sqlx::query_as(&format!( "SELECT id, name, metadata FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&into_uuid_clean) .fetch_optional(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let (into_id, into_name, into_metadata) = target_row.ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Target identity not found"})), ))?; // Get face_ids that will be transferred let face_ids: Vec<(Option, Option)> = sqlx::query_as(&format!( "SELECT face_id, trace_id FROM {} WHERE identity_id = $1", face_table )) .bind(from_id) .fetch_all(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let face_id_list: Vec = face_ids.iter().filter_map(|(f, _)| f.clone()).collect(); let trace_id_list: Vec = face_ids.iter().filter_map(|(_, t)| *t).collect(); let faces_count = face_id_list.len() as i64; // Get file_uuid for these faces let file_uuid: Option = sqlx::query_scalar(&format!( "SELECT file_uuid FROM {} WHERE identity_id = $1 LIMIT 1", face_table )) .bind(from_id) .fetch_optional(&db) .await .ok() .flatten(); // Capture target metadata BEFORE merge let into_meta_before = into_metadata.clone().unwrap_or(serde_json::json!({})); // Transfer all face bindings from source → target let updated = sqlx::query(&format!( "UPDATE {} SET identity_id = $1, stranger_id = NULL WHERE identity_id = $2", face_table )) .bind(into_id) .bind(from_id) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Merge text: source name → target aliases, source aliases → target aliases, source metadata → target metadata let from_meta = from_metadata.clone(); let into_meta = into_metadata.clone(); let mut merged_meta = into_meta.clone().unwrap_or(serde_json::json!({})); let from_meta_obj = from_meta .as_ref() .and_then(|m| m.as_object()) .cloned() .unwrap_or_default(); let into_meta_obj = into_meta .as_ref() .and_then(|m| m.as_object()) .cloned() .unwrap_or_default(); // Track aliases added for undo let mut aliases_added: Vec = Vec::new(); let mut metadata_fields_added: Vec = Vec::new(); // Merge aliases let mut aliases = into_meta_obj .get("aliases") .and_then(|a| a.as_array()) .cloned() .unwrap_or_default(); // Add source name as alias (if not already present) let from_name_str = from_name.clone(); if !aliases .iter() .any(|a| a.get("name").and_then(|n| n.as_str()) == Some(&from_name_str)) { aliases.push(serde_json::json!({"name": from_name_str, "locale": "en", "source": "merge"})); aliases_added.push(crate::core::db::AliasEntry { name: from_name_str, locale: "en".to_string(), source: Some("merge".to_string()), }); } // Add source aliases (if not already present) if let Some(from_aliases) = from_meta_obj.get("aliases").and_then(|a| a.as_array()) { for fa in from_aliases { let fa_name = fa.get("name").and_then(|n| n.as_str()).unwrap_or(""); let fa_locale = fa.get("locale").and_then(|l| l.as_str()).unwrap_or("en"); if !aliases .iter() .any(|a| a.get("name").and_then(|n| n.as_str()) == Some(fa_name)) { aliases.push(fa.clone()); aliases_added.push(crate::core::db::AliasEntry { name: fa_name.to_string(), locale: fa_locale.to_string(), source: None, }); } } } merged_meta["aliases"] = serde_json::Value::Array(aliases); // Deep-merge other metadata fields (recursive for nested objects) if let Some(ref mut merged_map) = merged_meta.as_object_mut() { deep_merge_metadata_fields(&from_meta_obj, merged_map, &mut metadata_fields_added, ""); } // Update target metadata sqlx::query(&format!( "UPDATE {} SET metadata = $1 WHERE id = $2", id_table )) .bind(&merged_meta) .bind(into_id) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let keep = req.keep_history.unwrap_or(true); if keep { // Mark as merged, keep record sqlx::query(&format!( "UPDATE {} SET status = 'merged', metadata = COALESCE(metadata, '{{}}'::jsonb) || jsonb_build_object('merged_into', $1) WHERE id = $2", id_table )) .bind(&req.into_uuid).bind(from_id) .execute(&db).await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?; } else { // Delete source identity sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table)) .bind(from_id) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; } // Sync target identity JSON if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &into_uuid_clean).await { tracing::warn!( "[merge] Failed to sync target identity file for {}: {}", into_uuid_clean, e ); } // Delete source identity JSON if not keeping history if !keep { let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean); } // Generate merge_id and store to MongoDB let merge_id = crate::core::db::IdentityMergeHistoryStore::generate_merge_id(); let now = chrono::Utc::now(); let undo_deadline = now + chrono::Duration::hours(24); // Create MongoDB history record let merge_history = crate::core::db::IdentityMergeHistory { id: None, merge_id: merge_id.clone(), source_identity: crate::core::db::IdentitySnapshot { id: from_id as i64, uuid: uuid_clean.clone(), name: from_name.clone(), identity_type: from_type, source: from_source, status: from_status, tmdb_id: from_tmdb_id.map(|id| id as i64), tmdb_profile: from_tmdb_profile, metadata: from_metadata.unwrap_or(serde_json::json!({})), created_at: from_created_at .map(|dt| chrono::DateTime::from_naive_utc_and_offset(dt, chrono::Utc)), face_count: faces_count, }, target_identity: crate::core::db::TargetIdentitySnapshot { id: into_id as i64, uuid: into_uuid_clean.clone(), name: into_name.clone(), metadata_before: into_meta_before, metadata_after: Some(merged_meta.clone()), }, aliases_added_to_target: aliases_added.clone(), metadata_fields_added: metadata_fields_added.clone(), faces_transferred: crate::core::db::FacesTransferred { file_uuid: file_uuid.unwrap_or_default(), face_ids: face_id_list, trace_ids: trace_id_list, count: faces_count, }, merge_params: crate::core::db::MergeParams { keep_history: keep, cleared_stranger_id: true, performed_by_user: Some(auth.user_id.to_string()), }, merged_at: now, undo_deadline, undone: false, undone_at: None, undone_by: None, undone_snapshot: None, undo_expired: false, }; // Store to MongoDB (non-blocking, warn on failure) let mongo_store = crate::core::db::IdentityMergeHistoryStore::init().await; if let Ok(store) = mongo_store { if let Err(e) = store.store_merge_history(&merge_history).await { tracing::warn!("[merge] Failed to store merge history in MongoDB: {}", e); } } else { tracing::warn!( "[merge] Failed to init MongoDB store: {:?}", mongo_store.err() ); } Ok(Json(ApiResponse { success: true, message: format!( "Merged '{}' into '{}' ({} faces transferred, {})", from_name, into_name, updated.rows_affected(), if keep { "history kept" } else { "source deleted" } ), data: Some(serde_json::json!({ "merge_id": merge_id, "faces_transferred": updated.rows_affected(), "aliases_added": aliases_added.len(), "metadata_fields_added": metadata_fields_added.len() })), })) } // ============================================================================ // Router Setup // ============================================================================ // Router Setup // ============================================================================ #[derive(Debug, Deserialize)] pub struct ListIdentitiesParams { pub search: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Serialize)] pub struct IdentityTraceInfo { pub file_uuid: String, pub trace_id: i32, pub frame_count: i64, pub first_frame: i32, pub last_frame: i32, pub first_sec: f64, pub last_sec: f64, pub avg_confidence: f64, } #[derive(Debug, Serialize)] pub struct IdentityTracesResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub total: usize, pub page: usize, pub page_size: usize, pub total_faces: i64, pub traces: Vec, } #[derive(Debug, Deserialize)] pub struct TracesQuery { pub page: Option, pub page_size: Option, } pub async fn bind_identity_trace( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let fd_table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); let uuid_clean = identity_uuid.replace('-', ""); let identity_row: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })?; let (identity_id, name) = identity_row.ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": format!("Identity not found: {}", identity_uuid)})), ) })?; // Capture old identity_id before bind trace (use first face in trace as reference) let old_identity_id: Option = sqlx::query_scalar(&format!( "SELECT identity_id FROM {} WHERE file_uuid = $1 AND trace_id = $2 LIMIT 1", fd_table )) .bind(&req.file_uuid) .bind(req.trace_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })? .flatten(); let result = sqlx::query(&format!( "UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = $3", fd_table )) .bind(identity_id) .bind(&req.file_uuid) .bind(req.trace_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Update failed: {}", e)})), ) })?; // Clear bind redo stack let _ = sqlx::query(&format!( "DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')", history_table )) .bind(identity_id) .execute(state.db.pool()) .await; // Insert history record let uid = auth.user_id.to_string(); let usrc = match auth.source { crate::api::middleware::AuthSource::Jwt => "jwt", crate::api::middleware::AuthSource::Session => "session", crate::api::middleware::AuthSource::ApiKey => "api_key", }; let before = serde_json::json!({ "file_uuid": req.file_uuid, "trace_id": req.trace_id, "identity_id_before": old_identity_id }); let after = serde_json::json!({ "file_uuid": req.file_uuid, "trace_id": req.trace_id, "identity_id_after": identity_id }); let _ = sqlx::query(&format!( "INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'bind_trace', $2, $3, false, $4, $5)", history_table )) .bind(identity_id) .bind(before) .bind(after) .bind(&uid) .bind(usrc) .execute(state.db.pool()) .await; if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean) .await { tracing::warn!( "[bind/trace] Failed to sync identity file for {}: {}", uuid_clean, e ); } Ok(Json(ApiResponse { success: true, message: format!( "Bound trace {} of {} to {}", req.trace_id, req.file_uuid, name ), data: Some(serde_json::json!({"rows_affected": result.rows_affected()})), })) } pub async fn get_identity_traces( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let id_table = crate::core::db::schema::table_name("identities"); let fd_table = crate::core::db::schema::table_name("face_detections"); let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let offset = ((page - 1) as i64) * (page_size as i64); // Get identity name let uuid_clean = identity_uuid.replace('-', ""); let identity: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let (identity_id, name) = identity.ok_or((StatusCode::NOT_FOUND, "Identity not found".to_string()))?; // Get paginated traces for this identity across all files let rows: Vec<(String, i32, i64, i32, i32, f64, f64, f64)> = sqlx::query_as(&format!( r#"SELECT fd.file_uuid::text, fd.trace_id, COUNT(*)::bigint AS frame_count, MIN(fd.frame_number)::int AS first_frame, MAX(fd.frame_number)::int AS last_frame, ROUND(MIN(fd.frame_number)::numeric / 25.0, 1)::float8 AS first_sec, ROUND(MAX(fd.frame_number)::numeric / 25.0, 1)::float8 AS last_sec, ROUND(AVG(fd.confidence)::numeric, 4)::float8 AS avg_confidence FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid, fd.trace_id ORDER BY fd.file_uuid, fd.trace_id LIMIT $2 OFFSET $3"#, fd_table )) .bind(identity_id) .bind(page_size as i64) .bind(offset) .fetch_all(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Get total count for pagination let total: (i64,) = sqlx::query_as(&format!( "SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid, fd.trace_id) sub", fd_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let total_traces = total.0 as usize; let total_faces: i64 = rows.iter().map(|r| r.2).sum(); let traces: Vec = rows .into_iter() .map( |( file_uuid, trace_id, frame_count, first_frame, last_frame, first_sec, last_sec, avg_confidence, )| IdentityTraceInfo { file_uuid, trace_id, frame_count, first_frame, last_frame, first_sec, last_sec, avg_confidence, }, ) .collect(); Ok(Json(IdentityTracesResponse { success: true, identity_uuid, name, total: total_traces, page, page_size, total_faces, traces, })) } /// Undo a merge operation within 24 hours pub async fn undo_merge( Extension(auth): Extension, Path(merge_id): Path, ) -> Result>, (StatusCode, Json)> { let face_table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); // Get merge history from MongoDB let mongo_store = crate::core::db::IdentityMergeHistoryStore::init() .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("MongoDB init failed: {}", e)})), ) })?; let history = mongo_store .get_merge_history(&merge_id) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("MongoDB query failed: {}", e)})), ) })?; let history = history.ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Merge record not found"})), ))?; // Check if already undone if history.undone { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Merge already undone"})), )); } // Check 24hr deadline let now = chrono::Utc::now(); let now_naive = now.naive_utc(); if now > history.undo_deadline { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Undo deadline expired", "merged_at": history.merged_at.to_rfc3339(), "undo_deadline": history.undo_deadline.to_rfc3339() })), )); } // Connect to PostgreSQL let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Restore source identity (or recreate if keep_history=false) let mut source_id = history.source_identity.id; let source_uuid = history.source_identity.uuid.clone(); if history.merge_params.keep_history { // Source identity still exists, just restore status sqlx::query(&format!( "UPDATE {} SET status = 'confirmed', metadata = $1 WHERE id = $2", id_table )) .bind(&history.source_identity.metadata) .bind(source_id as i32) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; } else { // Need to recreate source identity let source_uuid_parsed = uuid::Uuid::parse_str(&source_uuid).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Invalid UUID format: {}", e)})), ) })?; let new_id: i32 = sqlx::query_scalar(&format!( "INSERT INTO {} (uuid, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id", id_table )) .bind(source_uuid_parsed) .bind(&history.source_identity.name) .bind(&history.source_identity.identity_type) .bind(&history.source_identity.source) .bind("confirmed") .bind(history.source_identity.tmdb_id.map(|id| id as i32)) .bind(&history.source_identity.tmdb_profile) .bind(&history.source_identity.metadata) .bind(history.source_identity.created_at.map(|dt| dt.naive_utc()).unwrap_or(now_naive)) .fetch_one(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to recreate source identity: {}", e)})), ) })?; source_id = new_id as i64; } // Restore faces to source identity let faces_reverted = sqlx::query(&format!( "UPDATE {} SET identity_id = $1 WHERE face_id = ANY($2)", face_table )) .bind(source_id as i32) .bind(&history.faces_transferred.face_ids) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Restore target metadata (precise removal) let target_meta: Option = sqlx::query_scalar(&format!("SELECT metadata FROM {} WHERE id = $1", id_table)) .bind(history.target_identity.id as i32) .fetch_optional(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; let mut restored_meta = target_meta.unwrap_or(serde_json::json!({})); // Remove aliases that were added from source let aliases_to_remove = &history.aliases_added_to_target; if let Some(aliases) = restored_meta .get("aliases") .and_then(|a| a.as_array()) .cloned() { let new_aliases: Vec = aliases .into_iter() .filter(|alias| { let alias_name = alias.get("name").and_then(|n| n.as_str()).unwrap_or(""); let alias_source = alias.get("source").and_then(|s| s.as_str()); !aliases_to_remove.iter().any(|to_remove| { to_remove.name == alias_name && to_remove.source == Some("merge".to_string()) }) && alias_source != Some("merge") }) .collect(); if let Some(obj) = restored_meta.as_object_mut() { obj.insert("aliases".to_string(), serde_json::Value::Array(new_aliases)); } } // Remove metadata fields that were added from source (handles nested paths) for field in &history.metadata_fields_added { let parts: Vec<&str> = field.split('.').collect(); remove_nested_meta_field(&mut restored_meta, &parts); } // Update target metadata sqlx::query(&format!( "UPDATE {} SET metadata = $1 WHERE id = $2", id_table )) .bind(&restored_meta) .bind(history.target_identity.id as i32) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Sync identity JSON files let source_uuid_clean = source_uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &source_uuid_clean).await { tracing::warn!("[undo] Failed to sync source identity file: {}", e); } let target_uuid_clean = history.target_identity.uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &target_uuid_clean).await { tracing::warn!("[undo] Failed to sync target identity file: {}", e); } // Mark as undone in MongoDB with snapshot for potential redo let undone_snapshot = crate::core::db::UndoneSnapshot { source_identity_id: source_id, source_uuid: source_uuid.clone(), source_name: history.source_identity.name.clone(), target_metadata_at_undo: restored_meta.clone(), }; mongo_store .mark_as_undone(&merge_id, Some(&auth.user_id.to_string()), undone_snapshot) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to mark as undone: {}", e)})), ) })?; Ok(Json(ApiResponse { success: true, message: format!( "Undo merge completed: '{}' restored, {} faces reverted", history.source_identity.name, faces_reverted.rows_affected() ), data: Some(serde_json::json!({ "source_identity_restored": { "uuid": source_uuid, "name": history.source_identity.name, "status": "confirmed" }, "faces_reverted": faces_reverted.rows_affected(), "aliases_removed_from_target": history.aliases_added_to_target.len(), "metadata_fields_removed_from_target": history.metadata_fields_added.len() })), })) } /// Query merge history #[derive(Debug, Deserialize)] pub struct MergeHistoryQueryParams { pub source_uuid: Option, pub target_uuid: Option, pub merge_id: Option, pub undone: Option, pub page: Option, pub page_size: Option, } #[derive(Debug, Serialize)] pub struct MergeHistoryResponse { pub success: bool, pub total: u64, pub page: u32, pub page_size: u32, pub results: Vec, } pub async fn get_merge_history( Query(params): Query, ) -> Result, (StatusCode, Json)> { let mongo_store = crate::core::db::IdentityMergeHistoryStore::init() .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("MongoDB init failed: {}", e)})), ) })?; let query = crate::core::db::MergeHistoryQuery { source_uuid: params.source_uuid, target_uuid: params.target_uuid, merge_id: params.merge_id, undone: params.undone, }; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let (results, total) = mongo_store .query_merge_history(query, page, page_size) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("MongoDB query failed: {}", e)})), ) })?; Ok(Json(MergeHistoryResponse { success: true, total, page, page_size, results, })) } pub async fn redo_merge( Extension(auth): Extension, Path(merge_id): Path, ) -> Result>, (StatusCode, Json)> { let face_table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); let mongo_store = crate::core::db::IdentityMergeHistoryStore::init() .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("MongoDB init failed: {}", e)})), ) })?; let history = mongo_store .get_merge_history(&merge_id) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("MongoDB query failed: {}", e)})), ) })?; let history = history.ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Merge record not found"})), ))?; if !history.undone { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Merge is not undone, nothing to redo"})), )); } let now = chrono::Utc::now(); if now > history.undo_deadline { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Redo deadline expired", "undo_deadline": history.undo_deadline.to_rfc3339() })), )); } let snapshot = history.undone_snapshot.as_ref().ok_or(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Undone snapshot not found, cannot redo"})), ))?; let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Transfer faces from source back to target let faces_redone = sqlx::query(&format!( "UPDATE {} SET identity_id = $1, stranger_id = NULL WHERE identity_id = $2", face_table )) .bind(history.target_identity.id as i32) .bind(snapshot.source_identity_id as i32) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Build target metadata: start from undone_snapshot (clean state after undo) let mut target_meta = snapshot.target_metadata_at_undo.clone(); let meta_obj = target_meta.as_object_mut().unwrap(); // Add aliases from source let source_meta = &history.source_identity.metadata; let existing_aliases = meta_obj .get("aliases") .and_then(|a| a.as_array()) .cloned() .unwrap_or_default(); let mut new_aliases = existing_aliases.clone(); for alias_added in &history.aliases_added_to_target { let already_exists = new_aliases.iter().any(|a| { a.get("name").and_then(|n| n.as_str()) == Some(&alias_added.name) && a.get("locale").and_then(|l| l.as_str()) == Some(&alias_added.locale) }); if !already_exists { new_aliases.push(serde_json::json!({ "name": alias_added.name, "locale": alias_added.locale, "source": "merge" })); } } meta_obj.insert("aliases".into(), serde_json::Value::Array(new_aliases)); // Add metadata fields from source for field in &history.metadata_fields_added { if let Some(value) = source_meta.get(field) { meta_obj.insert(field.clone(), value.clone()); } } // Update target metadata sqlx::query(&format!( "UPDATE {} SET metadata = $1 WHERE id = $2", id_table )) .bind(&target_meta) .bind(history.target_identity.id as i32) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Handle source identity if history.merge_params.keep_history { sqlx::query(&format!( "UPDATE {} SET status = 'merged' WHERE id = $1", id_table )) .bind(snapshot.source_identity_id as i32) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; } else { sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table)) .bind(snapshot.source_identity_id as i32) .execute(&db) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; } // Sync identity JSON files let source_uuid_clean = snapshot.source_uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &source_uuid_clean).await { tracing::warn!("[redo] Failed to sync source identity file: {}", e); } let target_uuid_clean = history.target_identity.uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &target_uuid_clean).await { tracing::warn!("[redo] Failed to sync target identity file: {}", e); } // Mark as redone in MongoDB mongo_store .mark_as_redone(&merge_id, Some(&auth.user_id.to_string())) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to mark as redone: {}", e)})), ) })?; Ok(Json(ApiResponse { success: true, message: format!( "Redo merge completed: '{}' merged into '{}', {} faces transferred", snapshot.source_name, history.target_identity.name, faces_redone.rows_affected() ), data: Some(serde_json::json!({ "merge_id": merge_id, "faces_transferred": faces_redone.rows_affected(), "aliases_added": history.aliases_added_to_target.len(), "metadata_fields_added": history.metadata_fields_added.len() })), })) } // ── Bind Undo/Redo/History ────────────────────────────────────────── #[derive(Debug, Deserialize)] struct BindUndoRequest { steps: Option, } #[derive(Debug, Serialize)] struct BindUndoResponse { success: bool, identity_uuid: String, operation: String, undone_count: usize, affected_rows: i64, } async fn apply_bind_snapshot( pool: &sqlx::PgPool, face_table: &str, snapshot: &serde_json::Value, identity_id_value: Option, ) -> Result)> { let file_uuid = snapshot .get("file_uuid") .and_then(|v| v.as_str()) .ok_or_else(|| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Missing file_uuid in snapshot"})), ) })?; let id_val = match identity_id_value { Some(val) => val.as_i64(), None => None, }; if let Some(face_id) = snapshot.get("face_id").and_then(|v| v.as_str()) { let rows = sqlx::query(&format!( "UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3", face_table )) .bind(id_val) .bind(file_uuid) .bind(face_id) .execute(pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; Ok(rows.rows_affected() as i64) } else if let Some(trace_id) = snapshot.get("trace_id").and_then(|v| v.as_i64()) { let rows = sqlx::query(&format!( "UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = $3", face_table )) .bind(id_val) .bind(file_uuid) .bind(trace_id as i32) .execute(pool) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; Ok(rows.rows_affected() as i64) } else { Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Snapshot has neither face_id nor trace_id"})), )) } } pub async fn bind_undo( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { let uuid_clean = identity_uuid.replace('-', ""); let steps = req.steps.unwrap_or(1).max(1); let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); let face_table = crate::core::db::schema::table_name("face_detections"); let identity_id: i32 = sqlx::query_scalar(&format!( "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })? .ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity not found"})), ))?; let records: Vec<(i64, String, serde_json::Value)> = sqlx::query_as(&format!( "SELECT id, operation, before_snapshot FROM {} WHERE identity_id = $1 AND is_undone = false AND operation IN ('bind','unbind','bind_trace') ORDER BY created_at DESC LIMIT $2", history_table )) .bind(identity_id) .bind(steps as i64) .fetch_all(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to get history: {}", e)})), ) })?; if records.is_empty() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "No bind undo operations available"})), )); } let operation = records.last().unwrap().1.clone(); let before = &records.last().unwrap().2; let identity_id_before = before.get("identity_id_before").cloned(); let affected_rows = apply_bind_snapshot(state.db.pool(), &face_table, before, identity_id_before).await?; // Mark all as undone for (history_id, _, _) in &records { let _ = sqlx::query(&format!( "UPDATE {} SET is_undone = true, undone_at = NOW() WHERE id = $1", history_table )) .bind(history_id) .execute(state.db.pool()) .await; } Ok(Json(BindUndoResponse { success: true, identity_uuid: uuid_clean, operation, undone_count: records.len(), affected_rows, })) } pub async fn bind_redo( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { let uuid_clean = identity_uuid.replace('-', ""); let steps = req.steps.unwrap_or(1).max(1); let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); let face_table = crate::core::db::schema::table_name("face_detections"); let identity_id: i32 = sqlx::query_scalar(&format!( "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })? .ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity not found"})), ))?; let records: Vec<(i64, String, serde_json::Value)> = sqlx::query_as(&format!( "SELECT id, operation, after_snapshot FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace') ORDER BY created_at DESC LIMIT $2", history_table )) .bind(identity_id) .bind(steps as i64) .fetch_all(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to get history: {}", e)})), ) })?; if records.is_empty() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "No bind redo operations available"})), )); } let operation = records.last().unwrap().1.clone(); let after = &records.last().unwrap().2; let identity_id_after = after.get("identity_id_after").cloned(); let affected_rows = apply_bind_snapshot(state.db.pool(), &face_table, after, identity_id_after).await?; // Mark all as redone for (history_id, _, _) in &records { let _ = sqlx::query(&format!( "UPDATE {} SET is_undone = false, undone_at = NULL WHERE id = $1", history_table )) .bind(history_id) .execute(state.db.pool()) .await; } Ok(Json(BindUndoResponse { success: true, identity_uuid: uuid_clean, operation, undone_count: records.len(), affected_rows, })) } #[derive(Debug, Serialize)] struct BindHistoryItem { history_id: i64, operation: String, is_undone: bool, created_at: Option>, undone_at: Option>, } #[derive(Debug, Serialize)] struct BindHistoryResponse { success: bool, identity_uuid: String, total: i64, undo_stack_count: i64, redo_stack_count: i64, results: Vec, } #[derive(Debug, Deserialize)] struct BindHistoryQuery { limit: Option, page: Option, } pub async fn bind_history( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, Json)> { let uuid_clean = identity_uuid.replace('-', ""); let limit = params.limit.unwrap_or(20).max(1).min(100); let page = params.page.unwrap_or(1).max(1); let offset = ((page - 1) * limit) as i64; let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); let identity_id: i32 = sqlx::query_scalar(&format!( "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })? .ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity not found"})), ))?; let undo_stack_count: i64 = sqlx::query_scalar(&format!( "SELECT COUNT(*) FROM {} WHERE identity_id = $1 AND is_undone = false AND operation IN ('bind','unbind','bind_trace')", history_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })?; let redo_stack_count: i64 = sqlx::query_scalar(&format!( "SELECT COUNT(*) FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')", history_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })?; let rows = sqlx::query(&format!( "SELECT id, operation, is_undone, created_at, undone_at FROM {} WHERE identity_id = $1 AND operation IN ('bind','unbind','bind_trace') ORDER BY created_at DESC LIMIT $2 OFFSET $3", history_table )) .bind(identity_id) .bind(limit as i64) .bind(offset) .fetch_all(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("DB error: {}", e)})), ) })?; let results: Vec = rows .into_iter() .map(|r| BindHistoryItem { history_id: r.get::("id"), operation: r.get::("operation"), is_undone: r.get::("is_undone"), created_at: r.get::>, _>("created_at"), undone_at: r.get::>, _>("undone_at"), }) .collect(); let total = undo_stack_count + redo_stack_count; Ok(Json(BindHistoryResponse { success: true, identity_uuid: uuid_clean, total, undo_stack_count, redo_stack_count, results, })) } pub fn identity_binding_routes() -> Router { Router::new() .route("/api/v1/identity/:identity_uuid/bind", post(bind_identity)) .route( "/api/v1/identity/:identity_uuid/bind/trace", post(bind_identity_trace), ) .route( "/api/v1/identity/:identity_uuid/unbind", post(unbind_identity), ) .route( "/api/v1/identity/:identity_uuid/mergeinto", post(merge_identities), ) .route( "/api/v1/identity/:identity_uuid/traces", get(get_identity_traces), ) .route("/api/v1/identity/:identity_uuid/bind/undo", post(bind_undo)) .route("/api/v1/identity/:identity_uuid/bind/redo", post(bind_redo)) .route( "/api/v1/identity/:identity_uuid/bind/history", get(bind_history), ) .route("/api/v1/identity/merge/:merge_id/undo", post(undo_merge)) .route("/api/v1/identity/merge/:merge_id/redo", post(redo_merge)) .route("/api/v1/identity/merge/history", get(get_merge_history)) }