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, QdrantDb}; 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), })) } /// 綁定 identity 到 face trace(V4.0: via Qdrant _faces collection) pub async fn bind_identity( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { tracing::info!("[bind_identity] req: {:?}", req); 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)})), ) })?; let face_identifier = match (&req.face_id, req.id) { (Some(fid), _) => fid.clone(), (None, Some(id)) => id.to_string(), (None, None) => { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Either face_id or id is required"})), )); } }; let use_id_field = req.id.is_some(); // Get trace_id from request let trace_id: Option = req.trace_id; // Update Qdrant _faces collection with identity_id (trace-scoped) let qdrant = QdrantDb::new(); if let Some(tid) = trace_id { let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": req.file_uuid}}, {"key": "trace_id", "match": {"value": tid}} ] }); let payload = serde_json::json!({ "identity_id": identity_id }); if let Err(e) = qdrant .update_payload_by_filter("_faces", filter, payload) .await { tracing::warn!("[bind_identity] Failed to update Qdrant payload: {}", e); } else { tracing::info!( "[bind_identity] Updated Qdrant _faces trace {} for {}", tid, req.file_uuid ); } } else { tracing::warn!( "[bind_identity] No trace_id found for {} / {}", req.file_uuid, face_identifier ); } // Update TKG if trace_id exists if let Some(tid) = trace_id { let tkg_table = crate::core::db::schema::table_name("tkg_nodes"); let ext_id = format!("face_track_{}", tid); let identity_ref = format!("{}:identity_{}", req.file_uuid, identity_id); let _ = sqlx::query(&format!( "UPDATE {} SET properties = properties || $1::jsonb - 'stranger_ref' \ WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3", tkg_table )) .bind(serde_json::json!({ "identity_uuid": uuid_clean, "identity_ref": identity_ref })) .bind(&req.file_uuid) .bind(&ext_id) .execute(state.db.pool()) .await; } // 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": face_identifier, "identity_id_before": None:: as Option }); let after = serde_json::json!({ "file_uuid": req.file_uuid, "face_id": face_identifier, "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; 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 {}", face_identifier, req.file_uuid, name ), data: Some(serde_json::json!({"trace_id": trace_id})), })) } /// 解綁 identity 從 face trace(V4.0: via Qdrant _faces collection) pub async fn unbind_identity( State(state): State, Extension(auth): Extension, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let id_table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); let face_identifier = match (&req.face_id, req.id) { (Some(fid), _) => fid.clone(), (None, Some(id)) => id.to_string(), (None, None) => { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Either face_id or id is required"})), )); } }; let use_id_field = req.id.is_some(); // Get old binding from Qdrant _faces let qdrant = QdrantDb::new(); let old_binding: Option<(Option, Option)> = if let Some(tid) = req.trace_id { // Direct trace_id lookup let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": req.file_uuid}}, {"key": "trace_id", "match": {"value": tid}} ] }); let points = qdrant .scroll_points("_faces", filter, 1, None) .await .unwrap_or_default(); points.0.first().map(|p| { let identity_id = p["payload"]["identity_id"].as_i64().map(|i| i as i32); (identity_id, Some(tid)) }) } else if use_id_field { // id lookup not supported in Qdrant tracing::warn!("[unbind_identity] id field lookup not supported, use trace_id instead"); None } else { // face_id lookup not supported in Qdrant tracing::warn!( "[unbind_identity] face_id field lookup not supported, use trace_id instead" ); None }; let (old_identity_id, trace_id) = old_binding.unzip(); let trace_id = trace_id.flatten(); // Update Qdrant _faces collection: clear identity_id (trace-scoped) if let Some(tid) = trace_id { let qdrant = QdrantDb::new(); let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": req.file_uuid}}, {"key": "trace_id", "match": {"value": tid}} ] }); let payload = serde_json::json!({ "identity_id": serde_json::Value::Null }); if let Err(e) = qdrant .update_payload_by_filter("_faces", filter, payload) .await { tracing::warn!("[unbind_identity] Failed to update Qdrant payload: {}", e); } else { tracing::info!( "[unbind_identity] Cleared identity_id in Qdrant _faces trace {} for {}", tid, req.file_uuid ); } } else { tracing::warn!( "[unbind_identity] No trace_id found for {} / {}", req.file_uuid, face_identifier ); } // Update TKG: restore stranger_ref and remove identity fields if let Some(tid) = trace_id { let tkg_table = crate::core::db::schema::table_name("tkg_nodes"); let ext_id = format!("face_track_{}", tid); let stranger_ref = format!("{}:stranger_trace_{}", req.file_uuid, tid); let _ = sqlx::query(&format!( "UPDATE {} SET properties = properties || $1::jsonb - 'identity_uuid' - 'identity_ref' - 'identity_id' - 'identity_name' \ WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3", tkg_table )) .bind(serde_json::json!({ "stranger_ref": stranger_ref })) .bind(&req.file_uuid) .bind(&ext_id) .execute(state.db.pool()) .await; } if let Some(identity_id) = old_identity_id { 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; 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": face_identifier, "identity_id_before": old_identity_id }); let after = serde_json::json!({ "file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": serde_json::Value::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 {}", face_identifier, req.file_uuid), data: Some(serde_json::json!({"trace_id": trace_id})), })) } 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 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 (from Qdrant _faces) let qdrant = QdrantDb::new(); let face_filter = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": from_id}} ] }); let points = qdrant .scroll_all_points("_faces", face_filter, 1000) .await .unwrap_or_default(); let face_ids: Vec<(Option, Option)> = points .iter() .map(|p| { let face_id = p["payload"] .get("face_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let trace_id = p["payload"]["trace_id"].as_i64().map(|t| t as i32); (face_id, trace_id) }) .collect(); 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 (from Qdrant) let file_uuid: Option = points .first() .and_then(|p| p["payload"]["file_uuid"].as_str()) .map(|s| s.to_string()); // Capture target metadata BEFORE merge let into_meta_before = into_metadata.clone().unwrap_or(serde_json::json!({})); // Transfer all face bindings from source → target via Qdrant _faces let qdrant = QdrantDb::new(); let transfer_filter = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": from_id}} ] }); let transfer_payload = serde_json::json!({ "identity_id": into_id, "stranger_id": serde_json::Value::Null }); if let Err(e) = qdrant .update_payload_by_filter("_faces", transfer_filter, transfer_payload) .await { tracing::warn!("[merge] Qdrant transfer failed: {}", e); } // 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, faces_count, if keep { "history kept" } else { "source deleted" } ), data: Some(serde_json::json!({ "merge_id": merge_id, "faces_transferred": faces_count, "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, } /// 綁定 identity 到整個 trace(V4.0: via Qdrant _faces collection) pub async fn bind_identity_trace( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { 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)})), ) })?; // Update Qdrant _faces collection with identity_id (trace-scoped) let qdrant = QdrantDb::new(); let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": req.file_uuid}}, {"key": "trace_id", "match": {"value": req.trace_id}} ] }); let payload = serde_json::json!({ "identity_id": identity_id }); if let Err(e) = qdrant .update_payload_by_filter("_faces", filter, payload) .await { tracing::warn!( "[bind_identity_trace] Failed to update Qdrant payload: {}", e ); } else { tracing::info!( "[bind_identity_trace] Updated Qdrant _faces trace {} for {}", req.trace_id, req.file_uuid ); } // Update TKG node properties let nodes_table = crate::core::db::schema::table_name("tkg_nodes"); let external_id = format!("face_track_{}", req.trace_id); let identity_name: Option = sqlx::query_scalar(&format!("SELECT name FROM {} WHERE id = $1", id_table)) .bind(identity_id) .fetch_optional(state.db.pool()) .await .ok() .flatten(); let _ = sqlx::query(&format!( "UPDATE {} SET properties = jsonb_set(\ jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\ '{{identity_name}}', $2::jsonb, false)\ WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4", nodes_table )) .bind(identity_id) .bind(identity_name.as_deref()) .bind(&req.file_uuid) .bind(&external_id) .execute(state.db.pool()) .await; // 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": None:: as Option }); 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!({"trace_id": req.trace_id})), })) } 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 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 traces for this identity from Qdrant _faces let qdrant = QdrantDb::new(); let scroll_filter = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": identity_id}} ] }); let all_points = qdrant .scroll_all_points("_faces", scroll_filter, 1000) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Group by (file_uuid, trace_id) and aggregate use std::collections::BTreeMap; let mut trace_map: BTreeMap<(String, i32), Vec<&serde_json::Value>> = BTreeMap::new(); for point in &all_points { let payload = &point["payload"]; if let (Some(fu), Some(tid)) = (payload["file_uuid"].as_str(), payload["trace_id"].as_i64()) { trace_map .entry((fu.to_string(), tid as i32)) .or_default() .push(point); } } let total_traces = trace_map.len(); let trace_vec: Vec<((String, i32), Vec<&serde_json::Value>)> = trace_map.into_iter().collect(); let paginated: Vec<_> = trace_vec .into_iter() .skip(offset as usize) .take(page_size) .collect(); // Look up FPS for each unique file_uuid let mut fps_cache: std::collections::HashMap = std::collections::HashMap::new(); let video_table = crate::core::db::schema::table_name("videos"); for ((fu, _), _) in &paginated { if !fps_cache.contains_key(fu) { let fps: f64 = sqlx::query_scalar(&format!( "SELECT COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1", video_table )) .bind(fu) .fetch_optional(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .unwrap_or(30.0); fps_cache.insert(fu.clone(), fps); } } let mut traces = Vec::new(); let mut total_faces: i64 = 0; for ((file_uuid, trace_id), points) in &paginated { let frame_count = points.len() as i64; total_faces += frame_count; let first_frame = points .iter() .filter_map(|p| p["payload"]["frame"].as_i64()) .min() .unwrap_or(0) as i32; let last_frame = points .iter() .filter_map(|p| p["payload"]["frame"].as_i64()) .max() .unwrap_or(0) as i32; let avg_confidence = points .iter() .filter_map(|p| p["payload"]["confidence"].as_f64()) .sum::() / frame_count as f64; let fps = fps_cache.get(file_uuid).copied().unwrap_or(30.0); traces.push(IdentityTraceInfo { file_uuid: file_uuid.clone(), trace_id: *trace_id, frame_count, first_frame, last_frame, first_sec: if fps > 0.0 { first_frame as f64 / fps } else { 0.0 }, last_sec: if fps > 0.0 { last_frame as f64 / fps } else { 0.0 }, avg_confidence: (avg_confidence * 10000.0).round() / 10000.0, }); } traces.sort_by(|a, b| a.trace_id.cmp(&b.trace_id)); 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 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 via Qdrant _faces let qdrant = QdrantDb::new(); for &tid in &history.faces_transferred.trace_ids { let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": history.faces_transferred.file_uuid}}, {"key": "trace_id", "match": {"value": tid}} ] }); let payload = serde_json::json!({"identity_id": source_id as i32}); let _ = qdrant .update_payload_by_filter("_faces", filter, payload) .await; } let faces_reverted_count = history.faces_transferred.count; // 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_count ), data: Some(serde_json::json!({ "source_identity_restored": { "uuid": source_uuid, "name": history.source_identity.name, "status": "confirmed" }, "faces_reverted": faces_reverted_count, "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 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 via Qdrant _faces let qdrant = QdrantDb::new(); let filter = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": snapshot.source_identity_id as i32}} ] }); let payload = serde_json::json!({ "identity_id": history.target_identity.id as i32, "stranger_id": serde_json::Value::Null }); let _ = qdrant .update_payload_by_filter("_faces", filter, payload) .await; let faces_redone_count = history.faces_transferred.count; // 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_count ), data: Some(serde_json::json!({ "merge_id": merge_id, "faces_transferred": faces_redone_count, "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(trace_id) = snapshot.get("trace_id").and_then(|v| v.as_i64()) { // trace_id path: update Qdrant _faces collection let qdrant = QdrantDb::new(); let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": file_uuid}}, {"key": "trace_id", "match": {"value": trace_id}} ] }); let payload = serde_json::json!({ "identity_id": id_val }); qdrant .update_payload_by_filter("_faces", filter, payload) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; Ok(1) } else { // Legacy face_id path not supported - no trace_id in snapshot Err(( StatusCode::INTERNAL_SERVER_ERROR, Json( serde_json::json!({"error": "Snapshot has no trace_id, legacy face_id path not supported"}), ), )) } } 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 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(), "", 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 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(), "", 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, })) } // ============================================================================ // Pending Person API (file-scoped) // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreatePendingPersonRequest { #[serde(default)] pub trace_ids: Vec, pub name: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingPersonItem { pub identity_uuid: String, pub identity_id: i32, pub name: String, pub created_at: String, pub trace_count: i64, pub bound_traces: Option>, } /// Create a pending person under a file, optionally binding traces. pub async fn create_pending_person( State(state): State, Extension(_auth): Extension, Path(file_uuid): Path, Json(req): Json, ) -> Result>, (StatusCode, Json)> { let id_table = crate::core::db::schema::table_name("identities"); let nodes_table = crate::core::db::schema::table_name("tkg_nodes"); // Auto-generate name if not provided let name = if let Some(n) = &req.name { n.clone() } else { let count: i64 = sqlx::query_scalar(&format!( "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND status = 'pending'", id_table )) .bind(&file_uuid) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; format!("Person {}", count + 1) }; // Create identity with pending status let identity_row: (i32, String) = sqlx::query_as(&format!( "INSERT INTO {} (name, identity_type, source, status, file_uuid) VALUES ($1, 'people', 'manual', 'pending', $2) RETURNING id, uuid::text", id_table )) .bind(&name) .bind(&file_uuid) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("Failed to create identity: {}", e)})), ) })?; let (identity_id, identity_uuid): (i32, String) = identity_row; // Bind traces if provided let bound_traces = if !req.trace_ids.is_empty() { // Update Qdrant _faces for each trace let qdrant = QdrantDb::new(); for &tid in &req.trace_ids { let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": file_uuid}}, {"key": "trace_id", "match": {"value": tid}} ] }); let payload = serde_json::json!({"identity_id": identity_id}); if let Err(e) = qdrant .update_payload_by_filter("_faces", filter, payload) .await { tracing::warn!( "[create_pending_person] Qdrant update failed for trace {}: {}", tid, e ); } } // Update TKG nodes for &tid in &req.trace_ids { let external_id = format!("face_track_{}", tid); let _ = sqlx::query(&format!( "UPDATE {} SET properties = jsonb_set(\ jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\ '{{identity_name}}', $2::jsonb, false)\ WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4", nodes_table )) .bind(identity_id) .bind(&name) .bind(&file_uuid) .bind(&external_id) .execute(state.db.pool()) .await; } Some(req.trace_ids.clone()) } else { None }; // Sync identity file let _ = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &identity_uuid) .await; Ok(Json(ApiResponse { success: true, message: format!("Created pending person: {} (uuid: {})", name, identity_uuid), data: Some(serde_json::json!({ "identity_uuid": identity_uuid, "identity_id": identity_id, "name": name, "bound_traces": bound_traces.map(|v| v.len()).unwrap_or(0), })), })) } /// List pending persons for a file. pub async fn list_pending_persons( State(state): State, Extension(_auth): Extension, Path(file_uuid): Path, ) -> Result>>, (StatusCode, Json)> { let id_table = crate::core::db::schema::table_name("identities"); let rows: Vec<(i32, String, String, chrono::NaiveDateTime)> = sqlx::query_as(&format!( "SELECT id, uuid::text, name, created_at FROM {} WHERE file_uuid = $1 AND status = 'pending' ORDER BY created_at DESC", id_table )) .bind(&file_uuid) .fetch_all(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})), ) })?; // Get trace counts from Qdrant _faces let qdrant = QdrantDb::new(); let mut items = Vec::new(); for (id, uuid, name, created_at) in rows { let face_filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": file_uuid}}, {"key": "identity_id", "match": {"value": id}} ] }); let points = qdrant .scroll_all_points("_faces", face_filter, 500) .await .unwrap_or_default(); let unique_traces: std::collections::HashSet = points .iter() .filter_map(|p| p["payload"]["trace_id"].as_i64()) .collect(); let trace_count = unique_traces.len() as i64; items.push(PendingPersonItem { identity_uuid: uuid, identity_id: id, name, created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(), trace_count, bound_traces: None, }); } Ok(Json(ApiResponse { success: true, message: format!("Found {} pending persons for {}", items.len(), file_uuid), data: Some(items), })) } 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)) .route( "/api/v1/file/:file_uuid/pending-person", post(create_pending_person), ) .route( "/api/v1/file/:file_uuid/pending-persons", get(list_pending_persons), ) }