use axum::{ extract::{Extension, Multipart, Path, Query, State}, http::StatusCode, response::{Html, Json}, routing::{get, patch, post}, Router, }; use serde::{Deserialize, Serialize}; use sqlx::Row; use std::process::Command; use crate::core::db::{QdrantDb, ResourceRecord}; pub fn identity_routes() -> Router { Router::new() .route("/api/v1/files", get(list_files)) .route("/api/v1/file/:file_uuid", get(get_file_detail)) .route( "/api/v1/file/:file_uuid/identities", get(get_file_identities), ) .route( "/api/v1/identity/:identity_uuid", get(get_identity_detail) .delete(delete_identity) .patch(update_identity), ) .route( "/api/v1/identity/:identity_uuid/files", get(get_identity_files), ) .route( "/api/v1/identity/:identity_uuid/chunks", get(get_identity_chunks), ) .route( "/api/v1/identity/:identity_uuid/faces", get(get_identity_faces), ) .route("/api/v1/file/:file_uuid/faces", get(get_file_faces)) .route("/api/v1/resource/register", post(register_resource)) .route("/api/v1/resource/heartbeat", post(heartbeat_resource)) .route("/api/v1/resources", get(list_resources)) .route("/api/v1/identity/upload", post(upload_identity)) .route( "/api/v1/identity/:identity_uuid/profile-image", post(upload_profile_image).get(get_profile_image), ) .route( "/api/v1/identity/:identity_uuid/profile-image/from-face", post(set_profile_from_face), ) .route( "/api/v1/identity/:identity_uuid/status", get(get_identity_status), ) .route( "/api/v1/identity/:identity_uuid/json", get(get_identity_json), ) // Experiment: identity text search (non-polluting, separate endpoint) .route("/api/v1/search/identity_text", get(search_identity_text)) .route("/api/v1/identities/search", get(search_identities_by_text)) // Undo/Redo/History .route("/api/v1/identity/:identity_uuid/undo", post(undo_identity)) .route("/api/v1/identity/:identity_uuid/redo", post(redo_identity)) .route( "/api/v1/identity/:identity_uuid/history", get(get_identity_history), ) } // --- Files Endpoints --- #[derive(Debug, Deserialize)] pub struct FilesQuery { pub page: Option, pub page_size: Option, pub status: Option, pub file_uuid: Option, } async fn list_files( State(state): State, Query(params): Query, ) -> Result, (StatusCode, String)> { let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); // If UUID is provided, fetch that specific file and return it as a list item if let Some(ref file_uuid) = params.file_uuid { let video = state .db .get_video_by_uuid(file_uuid) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data = if let Some(v) = video { vec![FileItem { file_uuid: v.file_uuid, file_name: v.file_name, file_path: v.file_path, status: v.status.as_str().to_string(), }] } else { vec![] }; return Ok(Json(FilesResponse { success: true, total: data.len() as i64, page, page_size, data, })); } // Default: List files with pagination let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .list_videos(page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data = records .0 .into_iter() .map(|r| FileItem { file_uuid: r.file_uuid, file_name: r.file_name, file_path: r.file_path, status: r.status.as_str().to_string(), }) .collect(); let total = records.1; Ok(Json(FilesResponse { success: true, total, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct FilesResponse { pub success: bool, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct FileItem { pub file_uuid: String, pub file_name: String, pub file_path: String, pub status: String, } #[derive(Debug, Serialize)] pub struct FileDetailResponse { pub success: bool, pub file_uuid: String, pub file_name: String, pub file_path: String, pub status: String, pub duration: f64, pub fps: f64, pub metadata: Option, pub created_at: Option>, } async fn get_file_detail( State(state): State, Path(file_uuid): Path, ) -> Result, (StatusCode, String)> { let file = state .db .get_video_by_uuid(&file_uuid) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match file { Some(f) => Ok(Json(FileDetailResponse { success: true, file_uuid: f.file_uuid, file_name: f.file_name, file_path: f.file_path, status: f.status.as_str().to_string(), duration: f.duration, fps: f.fps, metadata: f.probe_json, created_at: chrono::DateTime::parse_from_rfc3339(&f.created_at) .ok() .map(|d| d.into()), })), None => Err(( StatusCode::NOT_FOUND, format!("File not found: {}", file_uuid), )), } } #[derive(Debug, Serialize)] pub struct FileIdentitiesResponse { pub success: bool, pub file_uuid: String, pub fps: f64, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct FileIdentityItem { pub identity_id: i32, pub identity_uuid: Option, pub name: String, pub metadata: serde_json::Value, pub face_count: Option, pub speaker_count: Option, pub start_frame: Option, pub end_frame: Option, pub start_time: Option, pub end_time: Option, pub confidence: Option, } async fn get_file_identities( State(state): State, Path(file_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { 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); let records = state .db .get_file_identities(&file_uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let fps = 25.0; let data: Vec = records .into_iter() .map(|r| FileIdentityItem { identity_id: r.identity_id, identity_uuid: r.identity_uuid, name: r.name, metadata: r.metadata, face_count: r.face_count, speaker_count: r.speaker_count, start_frame: r.start_frame, end_frame: r.end_frame, start_time: r.start_time, end_time: r.end_time, confidence: r.confidence, }) .collect(); let fi_table = crate::core::db::schema::table_name("file_identities"); let total = match sqlx::query_scalar::<_, i64>( &format!( r#"SELECT COUNT(DISTINCT identity_id) FROM {} WHERE file_uuid = $1 AND identity_id IS NOT NULL"#, fi_table ) ) .bind(&file_uuid) .fetch_one(state.db.pool()) .await { Ok(c) => c, Err(_) => data.len() as i64, }; Ok(Json(FileIdentitiesResponse { success: true, file_uuid: file_uuid, fps, total, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct IdentityDetailResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub identity_type: Option, pub source: Option, pub status: Option, pub metadata: serde_json::Value, pub reference_data: serde_json::Value, pub tmdb_id: Option, pub tmdb_profile: Option, pub created_at: Option>, pub updated_at: Option>, } #[derive(Debug, Serialize)] pub struct IdentityStatusResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub has_json: bool, pub has_jpg: bool, pub error: Option, } fn strip_uuid(u: &uuid::Uuid) -> String { u.to_string().replace('-', "") } async fn get_identity_detail( State(state): State, Path(identity_uuid): Path, ) -> Result, (StatusCode, String)> { let uuid_clean = identity_uuid.replace('-', ""); let identity = state .db .get_identity_by_uuid(&uuid_clean) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match identity { Some(i) => Ok(Json(IdentityDetailResponse { success: true, identity_uuid: i.uuid.clone(), name: i.name, identity_type: i.identity_type, source: i.source, status: i.status, metadata: i.metadata, reference_data: i.reference_data, tmdb_id: i.tmdb_id, tmdb_profile: Some(format!( "{}/identities/{}/profile.jpg", crate::core::config::OUTPUT_DIR.as_str(), i.uuid.replace('-', "") )), created_at: i.created_at, updated_at: i.updated_at, })), None => Err(( StatusCode::NOT_FOUND, format!("Identity not found: {}", uuid_clean), )), } } async fn get_identity_status( State(state): State, Path(identity_uuid): Path, ) -> Result, (StatusCode, String)> { let uuid_clean = identity_uuid.replace('-', ""); let identity = state .db .get_identity_by_uuid(&uuid_clean) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match identity { Some(i) => { // Check both UUID formats (with and without hyphens) let dir_nohyphen = crate::core::identity::storage::identity_dir(&uuid_clean); let uuid_hyphen = i.uuid.clone(); let dir_hyphen = crate::core::identity::storage::identity_dir(&uuid_hyphen); let has_json = dir_nohyphen.join("identity.json").exists() || dir_hyphen.join("identity.json").exists(); let has_jpg = dir_nohyphen.join("profile.jpg").exists() || dir_hyphen.join("profile.jpg").exists(); Ok(Json(IdentityStatusResponse { success: true, identity_uuid: i.uuid.clone(), name: i.name, has_json, has_jpg, error: None, })) } None => Err(( StatusCode::NOT_FOUND, format!("Identity not found: {}", uuid_clean), )), } } #[derive(Debug, Serialize)] pub struct IdentityFilesResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } async fn delete_identity( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, ) -> Result { 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('-', ""); // Get identity_id + full snapshot before deletion let row: Option<(i32, serde_json::Value)> = sqlx::query_as(&format!( "SELECT id, jsonb_build_object('id', id, 'uuid', uuid::text, 'name', name, 'identity_type', identity_type, 'source', source, 'status', status, 'metadata', metadata, 'tmdb_id', tmdb_id, 'tmdb_profile', tmdb_profile) FROM {} WHERE replace(uuid::text, '-', '') = $1", id_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (identity_id, identity_snapshot) = row.ok_or(StatusCode::NOT_FOUND)?; // Delete identity file from disk let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean); // Capture unbound faces from Qdrant _faces before unbinding use crate::core::db::qdrant_db::QdrantDb; use serde_json::json; let qdrant = QdrantDb::new(); let face_filter = json!({ "must": [ {"key": "identity_id", "match": {"value": identity_id}} ] }); let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default(); let unbound_faces: Vec<(String, Option, Option)> = points.iter() .filter_map(|p| { let payload = &p["payload"]; let file_uuid = payload["file_uuid"].as_str()?.to_string(); let face_id = payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string()); let trace_id = payload["trace_id"].as_i64().map(|t| t as i32); Some((file_uuid, face_id, trace_id)) }) .collect(); let face_list: Vec = unbound_faces .into_iter() .map(|(fu, fid, tid)| { serde_json::json!({ "file_uuid": fu, "face_id": fid, "trace_id": tid }) }) .collect(); // Clear delete redo stack (if identity was previously restored via undo) let _ = sqlx::query(&format!( "DELETE FROM {} WHERE identity_id = $1 AND operation = 'delete' AND is_undone = true", history_table )) .bind(identity_id) .execute(state.db.pool()) .await; // Insert delete 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_snapshot = serde_json::json!({ "identity": identity_snapshot, "unbound_faces": face_list, }); let after_snapshot = serde_json::json!({}); let _ = sqlx::query(&format!( "INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'delete', $2, $3, false, $4, $5)", history_table )) .bind(identity_id) .bind(before_snapshot) .bind(after_snapshot) .bind(&uid) .bind(usrc) .execute(state.db.pool()) .await; // Unbind all faces in Qdrant _faces let qdrant = QdrantDb::new(); let filter = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": identity_id}} ] }); let payload = serde_json::json!({"identity_id": serde_json::Value::Null}); let _ = qdrant .update_payload_by_filter("_faces", filter, payload) .await; // Delete identity sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table)) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Serialize)] pub struct IdentityFileItem { pub file_uuid: String, pub file_name: String, pub file_path: String, pub status: String, pub face_count: Option, pub speaker_count: Option, pub first_appearance: Option, pub last_appearance: Option, pub confidence: Option, } async fn get_identity_files( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let uuid = identity_uuid.replace('-', ""); let id_table = crate::core::db::schema::table_name("identities"); let identity: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid) .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()))?; 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); let records = state .db .get_identity_files(&uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| IdentityFileItem { file_uuid: r.file_uuid, file_name: r.file_name, file_path: r.file_path, status: r.status, face_count: r.face_count, speaker_count: r.speaker_count, first_appearance: r.first_appearance, last_appearance: r.last_appearance, confidence: r.confidence, }) .collect(); // Get total from Qdrant _faces use crate::core::db::qdrant_db::QdrantDb; use serde_json::json; let qdrant = QdrantDb::new(); let face_filter = json!({ "must": [ {"key": "identity_id", "match": {"value": identity_id}} ] }); let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default(); let unique_files: std::collections::HashSet = points.iter() .filter_map(|p| p["payload"]["file_uuid"].as_str().map(|s| s.to_string())) .collect(); let total = unique_files.len() as i64; Ok(Json(IdentityFilesResponse { success: true, identity_uuid: uuid.to_string().replace('-', ""), name, total, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct IdentityFacesResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct IdentityFaceItem { pub id: i64, pub file_uuid: String, pub frame_number: i64, pub timestamp_secs: Option, pub face_id: Option, pub bbox: BBox, pub confidence: f64, } #[derive(Debug, Serialize)] pub struct BBox { pub x: f64, pub y: f64, pub width: f64, pub height: f64, } async fn get_identity_faces( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let uuid = identity_uuid.replace('-', ""); let id_table = crate::core::db::schema::table_name("identities"); let identity: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid) .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()))?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(50); let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .get_identity_faces(&uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| IdentityFaceItem { id: r.id, file_uuid: r.file_uuid, frame_number: r.frame_number, timestamp_secs: r.timestamp_secs, face_id: r.face_id, bbox: BBox { x: r.x, y: r.y, width: r.width, height: r.height, }, confidence: r.confidence, }) .collect(); let qdrant2 = QdrantDb::new(); let face_filter2 = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": identity_id}} ] }); let points2 = qdrant2.scroll_all_points("_faces", face_filter2, 2000).await.unwrap_or_default(); let total = points2.len() as i64; Ok(Json(IdentityFacesResponse { success: true, identity_uuid: uuid.to_string().replace('-', ""), name, total, page, page_size, data, })) } // --- File Faces Endpoint --- #[derive(Debug, Serialize)] pub struct FileFacesResponse { pub success: bool, pub file_uuid: String, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct FileFaceItem { pub id: i64, pub file_uuid: String, pub frame_number: i64, pub timestamp_secs: Option, pub face_id: Option, pub trace_id: Option, pub bbox: BBox, pub confidence: f64, pub binding: FaceBinding, } #[derive(Debug, Serialize)] #[serde(untagged)] pub enum FaceBinding { Identity { identity_id: i32, identity_uuid: String, identity_name: String, }, Stranger { stranger_id: i32, metadata: serde_json::Value, }, Dangling { old_identity_id: i32, }, Unbound, } #[derive(Debug, Deserialize)] pub struct FileFacesQuery { page: Option, page_size: Option, binding: Option, trace_id: Option, min_confidence: Option, start_frame: Option, end_frame: Option, } async fn get_file_faces( State(state): State, Path(file_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(50); let offset = ((page - 1) as i64) * (page_size as i64); let id_table = crate::core::db::schema::table_name("identities"); let st_table = crate::core::db::schema::table_name("strangers"); let video_table = crate::core::db::schema::table_name("videos"); // Get fps let fps: f64 = sqlx::query_scalar(&format!( "SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", video_table )) .bind(&file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .unwrap_or(25.0); // Get face points from Qdrant _faces use crate::core::db::qdrant_db::QdrantDb; use serde_json::json; let qdrant = QdrantDb::new(); let mut filter_conditions = vec![ json!({"key": "file_uuid", "match": {"value": file_uuid}}) ]; if let Some(ref binding) = params.binding { match binding.as_str() { "identity" => { filter_conditions.push(json!({"key": "identity_id", "exists": true})); } "stranger" => { filter_conditions.push(json!({"key": "stranger_id", "exists": true})); } "unbound" => { filter_conditions.push(json!({"key": "identity_id", "match": {"value": null}})); } _ => {} } } if let Some(tid) = params.trace_id { filter_conditions.push(json!({"key": "trace_id", "match": {"value": tid}})); } let face_filter = json!({"must": filter_conditions}); let points = qdrant.scroll_all_points("_faces", face_filter, 2000).await.unwrap_or_default(); // Apply additional filters in Rust let filtered: Vec<_> = points.into_iter().filter(|p| { let payload = &p["payload"]; let confidence = payload["confidence"].as_f64().unwrap_or(0.0); let frame = payload["frame"].as_i64().unwrap_or(0); if let Some(mc) = params.min_confidence { if confidence < mc { return false; } } if let Some(sf) = params.start_frame { if frame < sf { return false; } } if let Some(ef) = params.end_frame { if frame > ef { return false; } } true }).collect(); let total = filtered.len() as i64; // Apply pagination let paged: Vec<_> = filtered.into_iter().skip(offset as usize).take(page_size as usize).collect(); // Build response items let mut data = Vec::new(); for point in &paged { let payload = &point["payload"]; let bbox = &payload["bbox"]; let frame = payload["frame"].as_i64().unwrap_or(0); let confidence = payload["confidence"].as_f64().unwrap_or(0.0); let item = FileFaceItem { id: 0, file_uuid: file_uuid.clone(), frame_number: frame, timestamp_secs: Some(frame as f64 / fps), face_id: payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string()), trace_id: payload["trace_id"].as_i64().map(|t| t as i32), bbox: BBox { x: bbox["x"].as_f64().unwrap_or(0.0), y: bbox["y"].as_f64().unwrap_or(0.0), width: bbox["width"].as_f64().unwrap_or(0.0), height: bbox["height"].as_f64().unwrap_or(0.0), }, confidence, binding: FaceBinding::Unbound, }; data.push(item); } Ok(Json(FileFacesResponse { success: true, file_uuid, total, page: page as usize, page_size: page_size as usize, data, })) } // --- List Face Candidates --- #[derive(Debug, Serialize)] pub struct IdentityChunksResponse { pub success: bool, pub identity_uuid: String, pub name: String, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct IdentityChunkItem { pub id: i64, pub file_uuid: String, pub chunk_id: String, pub chunk_type: String, pub start_frame: i64, pub end_frame: i64, pub fps: f64, pub start_time: Option, pub end_time: Option, pub text_content: Option, } async fn get_identity_chunks( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let uuid = identity_uuid.replace('-', ""); let id_table = crate::core::db::schema::table_name("identities"); let identity: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table )) .bind(&uuid) .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()))?; 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); let records = state .db .get_identity_chunks(&uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| IdentityChunkItem { id: r.id as i64, file_uuid: r.file_uuid, chunk_id: r.chunk_id, chunk_type: r.chunk_type, start_frame: r.start_frame, end_frame: r.end_frame, fps: r.fps, start_time: r.start_time, end_time: r.end_time, text_content: r.text_content, }) .collect(); Ok(Json(IdentityChunksResponse { success: true, identity_uuid: uuid.to_string().replace('-', ""), name, total: data.len() as i64, page, page_size, data, })) } // --- Resource Registry Endpoints (Phase 5) --- #[derive(Debug, Deserialize)] pub struct RegisterResourceRequest { pub resource_id: String, pub resource_type: String, pub category: String, pub capabilities: Option, pub config: Option, pub metadata: Option, } #[derive(Debug, Serialize)] pub struct ResourceResponse { pub success: bool, pub message: String, pub data: Option>, } #[derive(Debug, Serialize)] pub struct ResourceItem { pub resource_id: String, pub resource_type: String, pub category: String, pub capabilities: Option, pub config: Option, pub metadata: Option, pub status: String, pub last_heartbeat: Option>, } async fn register_resource( State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let resource = ResourceRecord { resource_id: req.resource_id.clone(), resource_type: req.resource_type.clone(), category: req.category.clone(), capabilities: req.capabilities, config: req.config, metadata: req.metadata, status: "online".to_string(), last_heartbeat: None, created_at: None, }; state .db .register_resource(resource) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(ResourceResponse { success: true, message: "Resource registered successfully".to_string(), data: None, // We could return the full record, but simplified for now })) } #[derive(Debug, Deserialize)] pub struct HeartbeatRequest { pub resource_id: String, pub status: Option, } async fn heartbeat_resource( State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let status = req.status.unwrap_or("online".to_string()); state .db .heartbeat_resource(&req.resource_id, &status) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(ResourceResponse { success: true, message: "Heartbeat received".to_string(), data: None, })) } async fn list_resources( State(state): State, ) -> Result, (StatusCode, String)> { let records = state .db .list_resources() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| ResourceItem { resource_id: r.resource_id, resource_type: r.resource_type, category: r.category, capabilities: r.capabilities, config: r.config, metadata: r.metadata, status: r.status, last_heartbeat: r.last_heartbeat, }) .collect(); Ok(Json(ResourceResponse { success: true, message: "Resources listed".to_string(), data: Some(data), })) } // ── Identity Upload ────────────────────────────────────────── #[derive(Debug, Serialize)] struct IdentityUploadResponse { success: bool, identity_uuid: String, name: String, message: String, } async fn upload_identity( State(state): State, Json(payload): Json, ) -> Result, (StatusCode, Json)> { let parsed = uuid::Uuid::parse_str(&payload.identity_uuid) .map_err(|_| (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "message": format!("Invalid identity_uuid: {}", payload.identity_uuid) }))))?; // Upsert into identities table let identities_table = crate::core::db::schema::table_name("identities"); let metadata_json = serde_json::to_value(&payload.metadata).unwrap_or_default(); let result = sqlx::query_as::<_, (String,)>(&format!( "INSERT INTO {} (uuid, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ ON CONFLICT (uuid) DO UPDATE SET \ name = EXCLUDED.name, source = EXCLUDED.source, status = EXCLUDED.status, \ tmdb_id = EXCLUDED.tmdb_id, tmdb_profile = EXCLUDED.tmdb_profile, \ metadata = EXCLUDED.metadata \ RETURNING uuid::text", identities_table )) .bind(parsed) .bind(&payload.name) .bind(&payload.identity_type) .bind(&payload.source) .bind(&payload.status) .bind(payload.tmdb_id) .bind(&payload.tmdb_profile) .bind(&metadata_json) .fetch_optional(state.db.pool()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "message": format!("DB error: {}", e) }))))?; let uuid_str = match result { Some((u,)) => crate::core::identity::storage::update_index(&u, &payload.name) .and(Ok(u)) .unwrap_or_else(|_| payload.identity_uuid.clone()), None => payload.identity_uuid.clone(), }; // Write identity.json to filesystem (strip hyphens from UUID for directory name) let mut file_payload = payload.clone(); file_payload.identity_uuid = file_payload.identity_uuid.replace('-', ""); if let Err(e) = crate::core::identity::storage::write_identity_file(&file_payload) { tracing::warn!("[identity-upload] Failed to write identity.json: {}", e); } Ok(Json(IdentityUploadResponse { success: true, identity_uuid: uuid_str.replace('-', ""), name: file_payload.name, message: "Identity uploaded successfully".to_string(), })) } // ── Profile Image Upload ──────────────────────────────────── #[derive(Debug, Serialize)] struct ProfileImageResponse { success: bool, identity_uuid: String, path: String, message: String, } async fn upload_profile_image( State(state): State, Path(identity_uuid): Path, mut multipart: Multipart, ) -> Result, (StatusCode, Json)> { let uuid_clean = identity_uuid.replace('-', ""); // Verify identity exists if state .db .get_identity_by_uuid(&uuid_clean) .await .map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": "DB error"})), ) })? .is_none() { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "success": false, "message": "Identity not found" })), )); } // Process multipart upload let mut image_data: Option> = None; let mut ext: &str = "jpg"; while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name == "image" { let content_type = field.content_type().unwrap_or("image/jpeg").to_string(); ext = match content_type.as_str() { "image/png" => "png", "image/jpeg" | "image/jpg" => "jpg", _ => { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "message": "Unsupported image type. Use JPEG or PNG." })), )) } }; image_data = Some(field.bytes().await.map_err(|_| { (StatusCode::BAD_REQUEST, Json(serde_json::json!({"success": false, "message": "Failed to read image data"}))) })?.to_vec()); } } let data = image_data.ok_or_else(|| { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "message": "No image field found. Use field name 'image'." })), ) })?; // Write image file let dir = crate::core::identity::storage::identity_dir(&uuid_clean); std::fs::create_dir_all(&dir).map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to create dir: {}", e)}))) })?; let file_name = format!("profile.{}", ext); let file_path = dir.join(&file_name); std::fs::write(&file_path, &data).map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to write file: {}", e)}))) })?; // Sync identity JSON to reflect new profile image let pool = state.db.pool().clone(); let uuid_clone = uuid_clean.clone(); let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid_clone).await; Ok(Json(ProfileImageResponse { success: true, identity_uuid: uuid_clean, path: file_path.to_string_lossy().to_string(), message: format!("Profile image saved: {}", file_name), })) } async fn get_profile_image( Path(identity_uuid): Path, ) -> Result<(StatusCode, [(String, String); 1], Vec), StatusCode> { let uuid_clean = identity_uuid.replace('-', ""); let dir = crate::core::identity::storage::identity_dir(&uuid_clean); for ext in &["jpg", "png"] { let path = dir.join(format!("profile.{}", ext)); if path.exists() { let data = std::fs::read(&path).map_err(|_| StatusCode::NOT_FOUND)?; let content_type = if *ext == "png" { "image/png" } else { "image/jpeg" }; return Ok(( StatusCode::OK, [("content-type".to_string(), content_type.to_string())], data, )); } } Err(StatusCode::NOT_FOUND) } #[derive(Debug, Deserialize)] pub struct SetProfileFromFaceRequest { pub file_uuid: String, pub face_id: Option, pub id: Option, pub trace_id: Option, pub frame_number: Option, } async fn set_profile_from_face( State(state): State, Path(identity_uuid): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { use crate::core::db::schema; use crate::core::db::qdrant_db::QdrantDb; use serde_json::json; let videos_table = schema::table_name("videos"); let uuid_clean = identity_uuid.replace('-', ""); let (face_identifier, use_trace, use_frame) = match (&req.face_id, req.id, req.trace_id) { (Some(fid), _, _) => (fid.clone(), None, None), (None, Some(id), _) => (id.to_string(), None, None), (None, None, Some(trace_id)) => (trace_id.to_string(), Some(trace_id), req.frame_number), (None, None, None) => { return Err(( StatusCode::BAD_REQUEST, Json( serde_json::json!({"success": false, "message": "Either face_id, id, or trace_id is required"}), ), )); } }; // Get face data from Qdrant _faces let qdrant = QdrantDb::new(); let row: Option<(i64, i32, i32, i32, i32, f64)> = if let Some(trace_id) = use_trace { let mut filter_conds = vec![ json!({"key": "file_uuid", "match": {"value": req.file_uuid}}), json!({"key": "trace_id", "match": {"value": trace_id}}) ]; if let Some(frame) = use_frame { filter_conds.push(json!({"key": "frame", "match": {"value": frame}})); } let face_filter = json!({"must": filter_conds}); let points = qdrant.scroll_all_points("_faces", face_filter, 10).await.unwrap_or_default(); points.first().map(|p| { let payload = &p["payload"]; let bbox = &payload["bbox"]; ( payload["frame"].as_i64().unwrap_or(0), bbox["x"].as_f64().unwrap_or(0.0) as i32, bbox["y"].as_f64().unwrap_or(0.0) as i32, bbox["width"].as_f64().unwrap_or(0.0) as i32, bbox["height"].as_f64().unwrap_or(0.0) as i32, payload["confidence"].as_f64().unwrap_or(0.0), ) }) } else if req.id.is_some() { // id lookup not supported in Qdrant - skip None } else { // face_id lookup not supported in Qdrant - skip None }; let (frame_number, x, y, w, h, confidence) = row.ok_or(( StatusCode::NOT_FOUND, Json(serde_json::json!({"success": false, "message": "Face not found"})), ))?; let video_row: Option<(String, Option, Option)> = sqlx::query_as(&format!( "SELECT file_path, width, height FROM {} WHERE file_uuid = $1", videos_table )) .bind(&req.file_uuid) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})), ) })?; let (file_path, video_width, video_height) = video_row.ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(serde_json::json!({"success": false, "message": "Video file not found"})), ) })?; let vw = video_width.unwrap_or(1920); let vh = video_height.unwrap_or(1080); crate::core::thumbnail::validator::validate_crop(x, y, w, h, vw, vh).map_err(|e| { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"success": false, "message": format!("Crop validation failed: {}", e)})), ) })?; let select = format!("select=eq(n\\,{})", frame_number); let vf = format!("{},crop={}:{}:{}:{}", select, w, h, x, y); let output = Command::new("ffmpeg") .args([ "-i", &file_path, "-vf", &vf, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "-", ]) .output() .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("FFmpeg failed: {}", e)})), ) })?; if !output.status.success() { return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": "FFmpeg extraction failed"})), )); } crate::core::thumbnail::validator::validate_jpeg(&output.stdout).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("JPEG validation failed: {}", e)})), ) })?; let dir = crate::core::identity::storage::identity_dir(&uuid_clean); std::fs::create_dir_all(&dir).map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to create dir: {}", e)}))) })?; let file_name = "profile.jpg"; let file_path = dir.join(file_name); std::fs::write(&file_path, &output.stdout).map_err(|e| { (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to write file: {}", e)}))) })?; let pool = state.db.pool().clone(); let uuid_clone = uuid_clean.clone(); let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid_clone).await; Ok(Json(ProfileImageResponse { success: true, identity_uuid: uuid_clean, path: file_path.to_string_lossy().to_string(), message: format!( "Profile image set from face {} (frame {}, confidence {:.2})", face_identifier, frame_number, confidence ), })) } async fn get_identity_json( State(state): State, Path(identity_uuid): Path, ) -> Result<(StatusCode, [(String, String); 1], Vec), StatusCode> { let clean = identity_uuid.replace('-', ""); let with_hyphens = if clean.len() == 32 { format!( "{}-{}-{}-{}-{}", &clean[0..8], &clean[8..12], &clean[12..16], &clean[16..20], &clean[20..32] ) } else { identity_uuid.clone() }; // 1. Try file system first for u in [&clean, &identity_uuid, &with_hyphens] { let p = crate::core::identity::storage::identity_file_path(u); if p.exists() { let data = std::fs::read(&p).map_err(|_| StatusCode::NOT_FOUND)?; return Ok(( StatusCode::OK, [("content-type".to_string(), "application/json".to_string())], data, )); } } // 2. Lazy Sync: If file missing, generate from DB and save if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &clean).await { tracing::warn!("[identity-json] Lazy sync failed for {}: {}", clean, e); return Err(StatusCode::NOT_FOUND); } // 3. Read the newly generated file (try all UUID variants) for u in [&clean, &identity_uuid, &with_hyphens] { let p = crate::core::identity::storage::identity_file_path(u); if p.exists() { let data = std::fs::read(&p).map_err(|_| StatusCode::NOT_FOUND)?; return Ok(( StatusCode::OK, [("content-type".to_string(), "application/json".to_string())], data, )); } } Err(StatusCode::NOT_FOUND) } // ── Experiment: Identity Text Search ────────────────────────── // Separate endpoints — do not modify existing API behavior. #[derive(Debug, Deserialize)] struct IdentityTextQuery { #[serde(default)] file_uuid: Option, q: String, limit: Option, page: Option, page_size: Option, } #[derive(Debug, Serialize)] struct IdentityTextHit { file_uuid: String, chunk_id: String, start_time: f64, end_time: f64, text_content: Option, identity_id: Option, identity_name: Option, identity_source: Option, trace_id: Option, } #[derive(Debug, Serialize)] struct IdentityTextResponse { success: bool, total: i64, page: usize, page_size: usize, limit: usize, results: Vec, } /// Path A: Search chunk text → associated identities async fn search_identity_text( State(state): State, Query(params): Query, ) -> Result, StatusCode> { use crate::core::db::schema; let chunk_table = schema::table_name("chunk"); let id_table = schema::table_name("identities"); let ib_table = schema::table_name("identity_bindings"); let like_q = format!("%{}%", params.q.replace('%', "%%")); let limit = params.limit.unwrap_or(50).min(100); let sd_table = schema::table_name("speaker_detections"); let query = format!( r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content, i.id AS identity_id, i.name AS identity_name, i.source AS identity_source, (c.metadata->>'trace_id')::int AS trace_id FROM {} c LEFT JOIN {} ib ON ib.identity_value = c.metadata->>'trace_id' AND ib.identity_type = 'trace' LEFT JOIN {} i ON i.id = ib.identity_id WHERE ($1::text IS NULL OR c.file_uuid = $1) AND (LOWER(c.text_content) LIKE LOWER($2) OR LOWER(c.content::text) LIKE LOWER($2)) UNION ALL SELECT sd.file_uuid, COALESCE(c.chunk_id, sd.chunk_id), sd.start_time, sd.end_time, sd.text_content, sd.identity_id, i.name AS identity_name, i.source AS identity_source, NULL::int AS trace_id FROM {} sd JOIN {} i ON i.id = sd.identity_id LEFT JOIN {} c ON c.chunk_id = sd.chunk_id WHERE ($1::text IS NULL OR sd.file_uuid = $1) AND (LOWER(sd.text_content) LIKE LOWER($2) OR LOWER($2) = '%%') ORDER BY 3 LIMIT $3"#, chunk_table, ib_table, id_table, sd_table, id_table, chunk_table ); let rows = sqlx::query_as::< _, ( String, String, f64, f64, Option, Option, Option, Option, Option, ), >(&query) .bind(¶ms.file_uuid) .bind(&like_q) .bind(limit) .fetch_all(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let results: Vec = rows .into_iter() .map( |(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit { file_uuid: fu, chunk_id: cid, start_time: st, end_time: et, text_content: txt, identity_id: iid, identity_name: iname, identity_source: isrc, trace_id: tid, }, ) .collect(); let total = results.len() as i64; let page = params.page.unwrap_or(1).max(1); let page_size = params.page_size.unwrap_or(total as usize).max(1); let start = (page - 1) * page_size; let paged: Vec = results.into_iter().skip(start).take(page_size).collect(); let limit = params.limit.unwrap_or(50) as usize; Ok(Json(IdentityTextResponse { success: true, total, page, page_size, limit, results: paged, })) } #[derive(Debug, Deserialize)] struct IdentitySearchQuery { q: String, file_uuid: Option, page: Option, page_size: Option, limit: Option, } #[derive(Debug, Serialize)] struct IdentitySearchHit { identity_id: i32, name: String, source: Option, tmdb_id: Option, file_uuid: String, trace_id: Option, chunk_id: String, start_frame: i64, end_frame: i64, fps: f64, start_time: f64, end_time: f64, text_content: Option, } #[derive(Debug, Serialize)] struct IdentitySearchResponse { success: bool, page: i64, page_size: i64, total: i64, results: Vec, } /// Path B: Search identity name → associated chunk text async fn search_identities_by_text( State(state): State, Query(params): Query, ) -> Result, StatusCode> { use crate::core::db::schema; let id_table = schema::table_name("identities"); let chunk_table = schema::table_name("chunk"); let like_q = format!("%{}%", params.q.replace('%', "%%")); let page = params.page.unwrap_or(1).max(1); let page_size = params .page_size .or(params.limit) .unwrap_or(20) .min(100) .max(1); let offset = (page - 1) * page_size; let sd_table = schema::table_name("speaker_detections"); let ib_table = schema::table_name("identity_bindings"); let fi_table = schema::table_name("file_identities"); let query = format!( r#"WITH matched AS ( SELECT i.id::int, i.name, i.source, i.tmdb_id, c.file_uuid, (c.metadata->>'trace_id')::int AS trace_id, c.chunk_id, c.start_frame, c.end_frame, c.fps, c.start_time, c.end_time, c.text_content FROM {} i JOIN {} fi ON fi.identity_id = i.id JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' JOIN {} c ON c.file_uuid = fi.file_uuid AND c.metadata->>'trace_id' = ib.identity_value WHERE (i.name ILIKE $1 OR EXISTS ( SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a WHERE a->>'name' ILIKE $1 )) AND ($2::text IS NULL OR c.file_uuid = $2) UNION ALL SELECT i.id::int, i.name, i.source, i.tmdb_id, sd.file_uuid, NULL::int AS trace_id, COALESCE(c.chunk_id, sd.chunk_id) as chunk_id, c.start_frame, c.end_frame, c.fps, sd.start_time, sd.end_time, sd.text_content FROM {} i JOIN {} sd ON sd.identity_id = i.id LEFT JOIN {} c ON c.chunk_id = sd.chunk_id WHERE (i.name ILIKE $1 OR EXISTS ( SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a WHERE a->>'name' ILIKE $1 )) AND ($2::text IS NULL OR sd.file_uuid = $2) ), deduped AS ( SELECT DISTINCT ON (name, chunk_id) * FROM matched ORDER BY name, chunk_id, start_time ) SELECT *, COUNT(*) OVER() AS total_count FROM deduped ORDER BY name, start_time LIMIT $3 OFFSET $4"#, id_table, fi_table, ib_table, chunk_table, id_table, sd_table, chunk_table ); let rows = sqlx::query(&query) .bind(&like_q) .bind(¶ms.file_uuid) .bind(page_size) .bind(offset) .fetch_all(state.db.pool()) .await .map_err(|e| { tracing::error!("[identities/search] Query failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; let total = rows.first().map(|r| r.get::(13)).unwrap_or(0); let results: Vec = rows .into_iter() .map(|r| IdentitySearchHit { identity_id: r.get(0), name: r.get(1), source: r.get(2), tmdb_id: r.get(3), file_uuid: r.get(4), trace_id: r.get(5), chunk_id: r.get(6), start_frame: r.get(7), end_frame: r.get(8), fps: r.get(9), start_time: r.get(10), end_time: r.get(11), text_content: r.get(12), }) .collect(); Ok(Json(IdentitySearchResponse { success: true, page, page_size, total, results, })) } // ── PATCH /api/v1/identity/:identity_uuid ──────────────────── #[derive(Debug, Deserialize)] struct UpdateIdentityRequest { name: Option, metadata: Option, status: Option, identity_type: Option, } #[derive(Debug, Serialize)] struct UpdateIdentityResponse { success: bool, identity_uuid: String, updated_fields: Vec, } async fn update_identity( State(state): State, Extension(auth): Extension, Path(identity_uuid): Path, Json(req): Json, ) -> Result, (StatusCode, Json)> { let uuid_clean = identity_uuid.replace('-', ""); let uuid_parsed = uuid::Uuid::parse_str(&uuid_clean).map_err(|_| { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "error": "Invalid identity_uuid" })), ) })?; let table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); // Get before snapshot (current state) let before_snapshot: Option = sqlx::query_scalar(&format!( "SELECT jsonb_build_object('id', id, 'uuid', uuid::text, 'name', name, 'identity_type', identity_type, 'source', source, 'status', status, 'metadata', metadata, 'tmdb_id', tmdb_id, 'tmdb_profile', tmdb_profile) FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("DB error: {}", e) })), ) })?; // Use text-based UUID comparison to avoid UUID type encoding issues let existing: Option<(i32, String)> = sqlx::query_as(&format!( "SELECT id, name FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("DB error: {}", e) })), ) })?; let (identity_id, old_name) = existing.ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "success": false, "error": "Identity not found" })), ) })?; let mut updated_fields: Vec = Vec::new(); let mut set_clauses: Vec = Vec::new(); if let Some(ref name) = req.name { set_clauses.push(format!("name = ${}", set_clauses.len() + 1)); updated_fields.push("name".to_string()); } if let Some(ref metadata) = req.metadata { if !metadata.is_object() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "error": "metadata must be a JSON object" })), )); } set_clauses.push(format!( "metadata = jsonb_deep_merge(COALESCE(metadata, '{{}}'::jsonb), ${}::jsonb)", set_clauses.len() + 1 )); updated_fields.push("metadata".to_string()); } if let Some(ref status) = req.status { set_clauses.push(format!("status = ${}", set_clauses.len() + 1)); updated_fields.push("status".to_string()); } if let Some(ref identity_type) = req.identity_type { set_clauses.push(format!("identity_type = ${}", set_clauses.len() + 1)); updated_fields.push("identity_type".to_string()); } if set_clauses.is_empty() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "error": "No fields to update" })), )); } // Clear redo stack (only PATCH operations, not bind) sqlx::query(&format!( "DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation = 'update'", history_table )) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to clear redo stack: {}", e) })), ) })?; let set_sql = set_clauses.join(", "); let uuid_param = set_clauses.len() + 1; let update_sql = format!( "UPDATE {} SET {} WHERE REPLACE(uuid::text, '-', '') = ${}", table, set_sql, uuid_param ); let mut query = sqlx::query(&update_sql); if let Some(ref name) = req.name { query = query.bind(name); } if let Some(ref metadata) = req.metadata { query = query.bind(metadata); } if let Some(ref status) = req.status { query = query.bind(status); } if let Some(ref identity_type) = req.identity_type { query = query.bind(identity_type); } query = query.bind(&uuid_clean); query.execute(state.db.pool()).await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Update failed: {}", e) })), ) })?; // Get after snapshot let after_snapshot: Option = sqlx::query_scalar(&format!( "SELECT jsonb_build_object('id', id, 'uuid', uuid::text, 'name', name, 'identity_type', identity_type, 'source', source, 'status', status, 'metadata', metadata, 'tmdb_id', tmdb_id, 'tmdb_profile', tmdb_profile) FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to get after snapshot: {}", e) })), ) })?; // Insert history record with user tracking 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", }; sqlx::query(&format!( "INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'update', $2, $3, false, $4, $5)", history_table )) .bind(identity_id) .bind(before_snapshot) .bind(after_snapshot) .bind(&uid) .bind(usrc) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to insert history: {}", e) })), ) })?; // Cleanup: keep max 256 history records per identity let count: i64 = sqlx::query_scalar(&format!( "SELECT COUNT(*) FROM {} WHERE identity_id = $1", history_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to count history: {}", e) })), ) })?; if count > 256 { let delete_count = count - 256; sqlx::query(&format!( "DELETE FROM {} WHERE identity_id = $1 AND id IN (SELECT id FROM {} WHERE identity_id = $1 ORDER BY created_at ASC LIMIT $2)", history_table, history_table )) .bind(identity_id) .bind(delete_count) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to cleanup history: {}", e) })), ) })?; } // Sync identity.json to disk let _ = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean) .await; // If name changed, update _index.json if req.name.is_some() { let new_name = req.name.as_deref().unwrap_or(&old_name); let _ = crate::core::identity::storage::update_index(&uuid_clean, new_name); } Ok(Json(UpdateIdentityResponse { success: true, identity_uuid: uuid_clean, updated_fields, })) } // ── Undo/Redo APIs ──────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct UndoRequest { steps: Option, } #[derive(Debug, Serialize)] struct UndoResponse { success: bool, identity_uuid: String, undone_count: usize, current_state: serde_json::Value, } async fn undo_identity( 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 table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); // Try normal identity lookup let identity_row: Option<(i32,)> = sqlx::query_as(&format!( "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("DB error: {}", e) })), ) })?; let (identity_id,) = match identity_row { Some(row) => row, None => { // Identity might have been deleted — check for delete history let delete_record: Option<(i64, serde_json::Value)> = sqlx::query_as(&format!( "SELECT id, before_snapshot FROM {} WHERE operation = 'delete' AND is_undone = false AND REPLACE(before_snapshot->'identity'->>'uuid', '-', '') = $1 ORDER BY created_at DESC LIMIT 1", history_table )) .bind(&uuid_clean) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to check delete history: {}", e) })), ) })?; let (history_id, snapshot) = delete_record.ok_or_else(|| { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "error": "No undo operations available" })), ) })?; // Recreate identity from snapshot let identity_obj = snapshot.get("identity").ok_or_else(|| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": "Missing identity snapshot" })), ) })?; let new_id: i32 = sqlx::query_scalar(&format!( "INSERT INTO {} (uuid, name, identity_type, source, status, metadata, tmdb_id, tmdb_profile) VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8) RETURNING id", table )) .bind(identity_obj.get("uuid").and_then(|v| v.as_str()).unwrap_or("")) .bind(identity_obj.get("name").and_then(|v| v.as_str()).unwrap_or("")) .bind(identity_obj.get("identity_type").and_then(|v| v.as_str())) .bind(identity_obj.get("source").and_then(|v| v.as_str())) .bind(identity_obj.get("status").and_then(|v| v.as_str())) .bind(identity_obj.get("metadata").cloned().unwrap_or(serde_json::json!({}))) .bind(identity_obj.get("tmdb_id").and_then(|v| v.as_i64())) .bind(identity_obj.get("tmdb_profile").and_then(|v| v.as_str())) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to recreate identity: {}", e) })), ) })?; // Re-bind faces via Qdrant _faces if let Some(faces) = snapshot.get("unbound_faces").and_then(|v| v.as_array()) { let qdrant = QdrantDb::new(); for face in faces { let file_uuid = face.get("file_uuid").and_then(|v| v.as_str()); let trace_id = face.get("trace_id").and_then(|v| v.as_i64()); if let (Some(fu), Some(tid)) = (file_uuid, trace_id) { let filter = serde_json::json!({ "must": [ {"key": "file_uuid", "match": {"value": fu}}, {"key": "trace_id", "match": {"value": tid}} ] }); let payload = serde_json::json!({"identity_id": new_id}); let _ = qdrant .update_payload_by_filter("_faces", filter, payload) .await; } } } // Mark delete history as undone 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; // Sync identity.json let _ = crate::core::identity::storage::save_identity_file_by_pool( state.db.pool(), &uuid_clean, ) .await; // Update index let new_name = identity_obj .get("name") .and_then(|v| v.as_str()) .unwrap_or(""); let _ = crate::core::identity::storage::update_index(&uuid_clean, new_name); // Get current state let current_state: serde_json::Value = sqlx::query_scalar(&format!( "SELECT jsonb_build_object('id', id, 'uuid', uuid::text, 'name', name, 'identity_type', identity_type, 'source', source, 'status', status, 'metadata', metadata, 'tmdb_id', tmdb_id, 'tmdb_profile', tmdb_profile) FROM {} WHERE id = $1", table )) .bind(new_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to get current state: {}", e) })), ) })?; return Ok(Json(UndoResponse { success: true, identity_uuid: uuid_clean, undone_count: 1, current_state, })); } }; // ── Normal PATCH undo flow (identity exists) ── // Get recent N history records (is_undone=false, only 'update') let history_records: Vec<(i64, serde_json::Value)> = sqlx::query_as(&format!( "SELECT id, before_snapshot FROM {} WHERE identity_id = $1 AND is_undone = false AND operation = 'update' 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!({ "success": false, "error": format!("Failed to get history: {}", e) })), ) })?; if history_records.is_empty() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "error": "No undo operations available" })), )); } // Apply the last before_snapshot let (_, last_before) = history_records.last().unwrap(); let before = last_before.as_object().unwrap(); // Restore identity from before_snapshot sqlx::query(&format!( "UPDATE {} SET name = $1, identity_type = $2, source = $3, status = $4, metadata = $5, tmdb_id = $6, tmdb_profile = $7 WHERE id = $8", table )) .bind(before.get("name").and_then(|v| v.as_str()).unwrap_or("")) .bind(before.get("identity_type").and_then(|v| v.as_str())) .bind(before.get("source").and_then(|v| v.as_str())) .bind(before.get("status").and_then(|v| v.as_str())) .bind(before.get("metadata").cloned().unwrap_or(serde_json::json!({}))) .bind(before.get("tmdb_id").and_then(|v| v.as_i64())) .bind(before.get("tmdb_profile").and_then(|v| v.as_str())) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to restore identity: {}", e) })), ) })?; // Mark history records as undone for (history_id, _) in &history_records { sqlx::query(&format!( "UPDATE {} SET is_undone = true, undone_at = NOW() WHERE id = $1", history_table )) .bind(*history_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to mark history as undone: {}", e) })), ) })?; } // Sync identity.json let _ = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean) .await; // Update index if name changed let new_name = before.get("name").and_then(|v| v.as_str()).unwrap_or(""); let _ = crate::core::identity::storage::update_index(&uuid_clean, new_name); // Get current state let current_state: serde_json::Value = sqlx::query_scalar(&format!( "SELECT jsonb_build_object('id', id, 'uuid', uuid::text, 'name', name, 'identity_type', identity_type, 'source', source, 'status', status, 'metadata', metadata, 'tmdb_id', tmdb_id, 'tmdb_profile', tmdb_profile) FROM {} WHERE id = $1", table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to get current state: {}", e) })), ) })?; Ok(Json(UndoResponse { success: true, identity_uuid: uuid_clean, undone_count: history_records.len(), current_state, })) } #[derive(Debug, Deserialize)] struct RedoRequest { steps: Option, } #[derive(Debug, Serialize)] struct RedoResponse { success: bool, identity_uuid: String, redone_count: usize, current_state: serde_json::Value, } async fn redo_identity( 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 table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); // Get identity_id let identity_id: i32 = sqlx::query_scalar(&format!( "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", table )) .bind(&uuid_clean) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Identity not found: {}", e) })), ) })?; // Check for delete redo first (identity was previously restored via undo) let delete_record: Option<(i64,)> = sqlx::query_as(&format!( "SELECT id FROM {} WHERE identity_id = $1 AND operation = 'delete' AND is_undone = true ORDER BY created_at DESC LIMIT 1", history_table )) .bind(identity_id) .fetch_optional(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to check delete redo: {}", e) })), ) })?; if let Some((delete_history_id,)) = delete_record { // ── Delete redo: re-delete the identity ── let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean); // Unbind all faces in Qdrant _faces let qdrant = QdrantDb::new(); let filter = serde_json::json!({ "must": [ {"key": "identity_id", "match": {"value": identity_id}} ] }); let payload = serde_json::json!({"identity_id": serde_json::Value::Null}); let _ = qdrant .update_payload_by_filter("_faces", filter, payload) .await; // Delete identity sqlx::query(&format!("DELETE FROM {} WHERE id = $1", table)) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to delete identity: {}", e) })), ) })?; // Mark delete history as no longer undone let _ = sqlx::query(&format!( "UPDATE {} SET is_undone = false, undone_at = NULL WHERE id = $1", history_table )) .bind(delete_history_id) .execute(state.db.pool()) .await; return Ok(Json(RedoResponse { success: true, identity_uuid: uuid_clean, redone_count: 1, current_state: serde_json::json!({"deleted": true}), })); } // ── Normal PATCH redo flow ── // Get recent N history records (is_undone=true, operation='update') let history_records: Vec<(i64, serde_json::Value)> = sqlx::query_as(&format!( "SELECT id, after_snapshot FROM {} WHERE identity_id = $1 AND is_undone = true AND operation = 'update' 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!({ "success": false, "error": format!("Failed to get history: {}", e) })), ) })?; if history_records.is_empty() { return Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "success": false, "error": "No redo operations available" })), )); } // Apply the last after_snapshot let (_, last_after) = history_records.last().unwrap(); let after = last_after.as_object().unwrap(); // Restore identity from after_snapshot sqlx::query(&format!( "UPDATE {} SET name = $1, identity_type = $2, source = $3, status = $4, metadata = $5, tmdb_id = $6, tmdb_profile = $7 WHERE id = $8", table )) .bind(after.get("name").and_then(|v| v.as_str()).unwrap_or("")) .bind(after.get("identity_type").and_then(|v| v.as_str())) .bind(after.get("source").and_then(|v| v.as_str())) .bind(after.get("status").and_then(|v| v.as_str())) .bind(after.get("metadata").cloned().unwrap_or(serde_json::json!({}))) .bind(after.get("tmdb_id").and_then(|v| v.as_i64())) .bind(after.get("tmdb_profile").and_then(|v| v.as_str())) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to restore identity: {}", e) })), ) })?; // Mark history records as not undone for (history_id, _) in &history_records { sqlx::query(&format!( "UPDATE {} SET is_undone = false, undone_at = NULL WHERE id = $1", history_table )) .bind(*history_id) .execute(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to mark history as redone: {}", e) })), ) })?; } // Sync identity.json let _ = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean) .await; // Update index if name changed let new_name = after.get("name").and_then(|v| v.as_str()).unwrap_or(""); let _ = crate::core::identity::storage::update_index(&uuid_clean, new_name); // Get current state let current_state: serde_json::Value = sqlx::query_scalar(&format!( "SELECT jsonb_build_object('id', id, 'uuid', uuid::text, 'name', name, 'identity_type', identity_type, 'source', source, 'status', status, 'metadata', metadata, 'tmdb_id', tmdb_id, 'tmdb_profile', tmdb_profile) FROM {} WHERE id = $1", table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to get current state: {}", e) })), ) })?; Ok(Json(RedoResponse { success: true, identity_uuid: uuid_clean, redone_count: history_records.len(), current_state, })) } #[derive(Debug, Deserialize)] struct HistoryQuery { limit: Option, page: Option, } #[derive(Debug, Serialize)] struct HistoryResponse { success: bool, identity_uuid: String, total: i64, undo_stack_count: i64, redo_stack_count: i64, results: Vec, } #[derive(Debug, Serialize)] struct HistoryItem { history_id: i64, operation: String, is_undone: bool, created_at: Option>, undone_at: Option>, } async fn get_identity_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 table = crate::core::db::schema::table_name("identities"); let history_table = crate::core::db::schema::table_name("identity_history"); // Get identity_id let identity_id: i32 = sqlx::query_scalar(&format!( "SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", table )) .bind(&uuid_clean) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Identity not found: {}", e) })), ) })?; // Get counts let undo_stack_count: i64 = sqlx::query_scalar(&format!( "SELECT COUNT(*) FROM {} WHERE identity_id = $1 AND is_undone = false", history_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to count undo stack: {}", e) })), ) })?; let redo_stack_count: i64 = sqlx::query_scalar(&format!( "SELECT COUNT(*) FROM {} WHERE identity_id = $1 AND is_undone = true", history_table )) .bind(identity_id) .fetch_one(state.db.pool()) .await .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "success": false, "error": format!("Failed to count redo stack: {}", e) })), ) })?; // Get history records let rows = sqlx::query(&format!( "SELECT id, operation, is_undone, created_at, undone_at FROM {} WHERE identity_id = $1 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!({ "success": false, "error": format!("Failed to get history: {}", e) })), ) })?; let results: Vec = rows .into_iter() .map(|r| HistoryItem { 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(HistoryResponse { success: true, identity_uuid: uuid_clean, total, undo_stack_count, redo_stack_count, results, })) }