- Identity agent: per-face max matching, multi-round with derived seeds from high-confidence faces, angle diversity filter (cosine sim < 0.90) - Pending person API: POST /file/:file_uuid/pending-person + GET /file/:file_uuid/pending-persons with status=pending, source=manual - Update API docs (07_identity.md)
2671 lines
85 KiB
Rust
2671 lines
85 KiB
Rust
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::ResourceRecord;
|
|
|
|
pub fn identity_routes() -> Router<crate::api::types::AppState> {
|
|
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<usize>,
|
|
pub page_size: Option<usize>,
|
|
pub status: Option<String>,
|
|
pub file_uuid: Option<String>,
|
|
}
|
|
|
|
async fn list_files(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Query(params): Query<FilesQuery>,
|
|
) -> Result<Json<FilesResponse>, (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<FileItem>,
|
|
}
|
|
|
|
#[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<serde_json::Value>,
|
|
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
async fn get_file_detail(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(file_uuid): Path<String>,
|
|
) -> Result<Json<FileDetailResponse>, (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<FileIdentityItem>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FileIdentityItem {
|
|
pub identity_id: i32,
|
|
pub identity_uuid: Option<String>,
|
|
pub name: String,
|
|
pub metadata: serde_json::Value,
|
|
pub face_count: Option<i32>,
|
|
pub speaker_count: Option<i32>,
|
|
pub start_frame: Option<i32>,
|
|
pub end_frame: Option<i32>,
|
|
pub start_time: Option<f64>,
|
|
pub end_time: Option<f64>,
|
|
pub confidence: Option<f64>,
|
|
}
|
|
|
|
async fn get_file_identities(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(file_uuid): Path<String>,
|
|
Query(params): Query<FilesQuery>,
|
|
) -> Result<Json<FileIdentitiesResponse>, (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<FileIdentityItem> = 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 total = match sqlx::query_scalar::<_, i64>(
|
|
&format!(
|
|
"SELECT COUNT(DISTINCT fd.identity_id) FROM {} fd WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL",
|
|
crate::core::db::schema::table_name("face_detections")
|
|
)
|
|
)
|
|
.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<String>,
|
|
pub source: Option<String>,
|
|
pub status: Option<String>,
|
|
pub metadata: serde_json::Value,
|
|
pub reference_data: serde_json::Value,
|
|
pub tmdb_id: Option<i32>,
|
|
pub tmdb_profile: Option<String>,
|
|
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
fn strip_uuid(u: &uuid::Uuid) -> String {
|
|
u.to_string().replace('-', "")
|
|
}
|
|
|
|
async fn get_identity_detail(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
) -> Result<Json<IdentityDetailResponse>, (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<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
) -> Result<Json<IdentityStatusResponse>, (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<IdentityFileItem>,
|
|
}
|
|
|
|
async fn delete_identity(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Extension(auth): Extension<crate::api::middleware::UserAuth>,
|
|
Path(identity_uuid): Path<String>,
|
|
) -> Result<StatusCode, StatusCode> {
|
|
let table = crate::core::db::schema::table_name("face_detections");
|
|
let id_table = crate::core::db::schema::table_name("identities");
|
|
let history_table = crate::core::db::schema::table_name("identity_history");
|
|
|
|
let uuid_clean = identity_uuid.replace('-', "");
|
|
|
|
// 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 before unbinding
|
|
let unbound_faces: Vec<(String, Option<String>, Option<i32>)> = sqlx::query_as(&format!(
|
|
"SELECT file_uuid, face_id, trace_id FROM {} WHERE identity_id = $1",
|
|
table
|
|
))
|
|
.bind(identity_id)
|
|
.fetch_all(state.db.pool())
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
let face_list: Vec<serde_json::Value> = 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
|
|
sqlx::query(&format!(
|
|
"UPDATE {} SET identity_id = NULL WHERE identity_id = $1",
|
|
table
|
|
))
|
|
.bind(identity_id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
// 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<i32>,
|
|
pub speaker_count: Option<i32>,
|
|
pub first_appearance: Option<f64>,
|
|
pub last_appearance: Option<f64>,
|
|
pub confidence: Option<f64>,
|
|
}
|
|
|
|
async fn get_identity_files(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
Query(params): Query<FilesQuery>,
|
|
) -> Result<Json<IdentityFilesResponse>, (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<IdentityFileItem> = 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();
|
|
|
|
let total = match sqlx::query_scalar::<_, i64>(&format!(
|
|
"SELECT COUNT(DISTINCT fd.file_uuid) FROM {} fd WHERE fd.identity_id = $1",
|
|
crate::core::db::schema::table_name("face_detections"),
|
|
))
|
|
.bind(identity_id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => data.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<IdentityFaceItem>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct IdentityFaceItem {
|
|
pub id: i64,
|
|
pub file_uuid: String,
|
|
pub frame_number: i64,
|
|
pub timestamp_secs: Option<f64>,
|
|
pub face_id: Option<String>,
|
|
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<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
Query(params): Query<FilesQuery>,
|
|
) -> Result<Json<IdentityFacesResponse>, (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<IdentityFaceItem> = 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 total = match sqlx::query_scalar::<_, i64>(&format!(
|
|
"SELECT COUNT(*) FROM {} fd WHERE fd.identity_id = $1",
|
|
crate::core::db::schema::table_name("face_detections"),
|
|
))
|
|
.bind(identity_id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => data.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<FileFaceItem>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FileFaceItem {
|
|
pub id: i64,
|
|
pub file_uuid: String,
|
|
pub frame_number: i64,
|
|
pub timestamp_secs: Option<f64>,
|
|
pub face_id: Option<String>,
|
|
pub trace_id: Option<i32>,
|
|
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<usize>,
|
|
page_size: Option<usize>,
|
|
binding: Option<String>,
|
|
trace_id: Option<i32>,
|
|
min_confidence: Option<f64>,
|
|
start_frame: Option<i64>,
|
|
end_frame: Option<i64>,
|
|
}
|
|
|
|
async fn get_file_faces(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(file_uuid): Path<String>,
|
|
Query(params): Query<FileFacesQuery>,
|
|
) -> Result<Json<FileFacesResponse>, (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 fd_table = crate::core::db::schema::table_name("face_detections");
|
|
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");
|
|
|
|
// Build WHERE clauses
|
|
let mut where_clauses = vec![format!(
|
|
"fd.file_uuid = '{}'",
|
|
file_uuid.replace('\'', "''")
|
|
)];
|
|
|
|
if let Some(ref binding) = params.binding {
|
|
match binding.as_str() {
|
|
"identity" => {
|
|
where_clauses.push(format!("fd.identity_id IN (SELECT id FROM {})", id_table));
|
|
}
|
|
"stranger" => {
|
|
where_clauses.push("fd.stranger_id IS NOT NULL".to_string());
|
|
}
|
|
"dangling" => {
|
|
where_clauses.push(format!(
|
|
"fd.identity_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM {} WHERE id = fd.identity_id)",
|
|
id_table
|
|
));
|
|
}
|
|
"unbound" => {
|
|
where_clauses.push("fd.identity_id IS NULL AND fd.stranger_id IS NULL".to_string());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if let Some(tid) = params.trace_id {
|
|
where_clauses.push(format!("fd.trace_id = {}", tid));
|
|
}
|
|
if let Some(mc) = params.min_confidence {
|
|
where_clauses.push(format!("fd.confidence >= {}", mc));
|
|
}
|
|
if let Some(sf) = params.start_frame {
|
|
where_clauses.push(format!("fd.frame_number >= {}", sf));
|
|
}
|
|
if let Some(ef) = params.end_frame {
|
|
where_clauses.push(format!("fd.frame_number <= {}", ef));
|
|
}
|
|
|
|
let where_sql = where_clauses.join(" AND ");
|
|
|
|
let select_sql = format!(
|
|
"SELECT fd.id::bigint as id, fd.file_uuid, \
|
|
fd.frame_number::bigint as frame_number, \
|
|
(fd.frame_number::float8 / NULLIF(v.fps, 0)) as timestamp_secs, \
|
|
fd.face_id, fd.trace_id, \
|
|
fd.x::float8 as x, fd.y::float8 as y, \
|
|
fd.width::float8 as width, fd.height::float8 as height, \
|
|
fd.confidence::float8 as confidence, \
|
|
fd.identity_id, fd.stranger_id, \
|
|
i.uuid::text as identity_uuid, i.name as identity_name, \
|
|
s.metadata as stranger_metadata \
|
|
FROM {} fd \
|
|
JOIN {} v ON v.file_uuid = fd.file_uuid \
|
|
LEFT JOIN {} i ON i.id = fd.identity_id \
|
|
LEFT JOIN {} s ON s.id = fd.stranger_id \
|
|
WHERE {} \
|
|
ORDER BY fd.frame_number, fd.trace_id \
|
|
LIMIT {} OFFSET {}",
|
|
fd_table, video_table, id_table, st_table, where_sql, page_size as i64, offset
|
|
);
|
|
|
|
let count_sql = format!(
|
|
"SELECT COUNT(*) FROM {} fd \
|
|
WHERE {}",
|
|
fd_table, where_sql
|
|
);
|
|
|
|
use sqlx::Row;
|
|
let rows = sqlx::query(&select_sql)
|
|
.fetch_all(state.db.pool())
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let data: Vec<FileFaceItem> = rows
|
|
.into_iter()
|
|
.map(|r| {
|
|
let identity_id: Option<i32> = r.get("identity_id");
|
|
let identity_uuid: Option<String> = r.get("identity_uuid");
|
|
let identity_name: Option<String> = r.get("identity_name");
|
|
let stranger_id: Option<i32> = r.get("stranger_id");
|
|
|
|
let binding = if let (Some(iid), Some(iuuid), Some(iname)) =
|
|
(identity_id, identity_uuid, identity_name)
|
|
{
|
|
FaceBinding::Identity {
|
|
identity_id: iid,
|
|
identity_uuid: iuuid,
|
|
identity_name: iname,
|
|
}
|
|
} else if let Some(sid) = stranger_id {
|
|
FaceBinding::Stranger {
|
|
stranger_id: sid,
|
|
metadata: r
|
|
.get::<Option<serde_json::Value>, _>("stranger_metadata")
|
|
.unwrap_or(serde_json::Value::Null),
|
|
}
|
|
} else if let Some(iid) = identity_id {
|
|
FaceBinding::Dangling {
|
|
old_identity_id: iid,
|
|
}
|
|
} else {
|
|
FaceBinding::Unbound
|
|
};
|
|
|
|
FileFaceItem {
|
|
id: r.get("id"),
|
|
file_uuid: r.get("file_uuid"),
|
|
frame_number: r.get("frame_number"),
|
|
timestamp_secs: r.get("timestamp_secs"),
|
|
face_id: r.get("face_id"),
|
|
trace_id: r.get("trace_id"),
|
|
bbox: BBox {
|
|
x: r.get("x"),
|
|
y: r.get("y"),
|
|
width: r.get("width"),
|
|
height: r.get("height"),
|
|
},
|
|
confidence: r.get("confidence"),
|
|
binding,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(FileFacesResponse {
|
|
success: true,
|
|
file_uuid,
|
|
total,
|
|
page,
|
|
page_size,
|
|
data,
|
|
}))
|
|
}
|
|
|
|
#[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<IdentityChunkItem>,
|
|
}
|
|
|
|
#[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<f64>,
|
|
pub end_time: Option<f64>,
|
|
pub text_content: Option<String>,
|
|
}
|
|
|
|
async fn get_identity_chunks(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
Query(params): Query<FilesQuery>,
|
|
) -> Result<Json<IdentityChunksResponse>, (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<IdentityChunkItem> = 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<serde_json::Value>,
|
|
pub config: Option<serde_json::Value>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ResourceResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub data: Option<Vec<ResourceItem>>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ResourceItem {
|
|
pub resource_id: String,
|
|
pub resource_type: String,
|
|
pub category: String,
|
|
pub capabilities: Option<serde_json::Value>,
|
|
pub config: Option<serde_json::Value>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
pub status: String,
|
|
pub last_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
async fn register_resource(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Json(req): Json<RegisterResourceRequest>,
|
|
) -> Result<Json<ResourceResponse>, (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<String>,
|
|
}
|
|
|
|
async fn heartbeat_resource(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Json(req): Json<HeartbeatRequest>,
|
|
) -> Result<Json<ResourceResponse>, (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<crate::api::types::AppState>,
|
|
) -> Result<Json<ResourceResponse>, (StatusCode, String)> {
|
|
let records = state
|
|
.db
|
|
.list_resources()
|
|
.await
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
let data: Vec<ResourceItem> = 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<crate::api::types::AppState>,
|
|
Json(payload): Json<crate::core::identity::storage::IdentityFile>,
|
|
) -> Result<Json<IdentityUploadResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
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<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
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<Vec<u8>> = 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<String>,
|
|
) -> Result<(StatusCode, [(String, String); 1], Vec<u8>), 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<String>,
|
|
pub id: Option<i64>,
|
|
}
|
|
|
|
async fn set_profile_from_face(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
Json(req): Json<SetProfileFromFaceRequest>,
|
|
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
use crate::core::db::schema;
|
|
let fd_table = schema::table_name("face_detections");
|
|
let videos_table = schema::table_name("videos");
|
|
|
|
let uuid_clean = identity_uuid.replace('-', "");
|
|
|
|
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!({"success": false, "message": "Either face_id or id is required"})),
|
|
));
|
|
}
|
|
};
|
|
|
|
let use_id_field = req.id.is_some();
|
|
|
|
let row: Option<(i64, i32, i32, i32, i32, f64)> = if use_id_field {
|
|
sqlx::query_as(&format!(
|
|
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND id = $2",
|
|
fd_table
|
|
))
|
|
.bind(&req.file_uuid)
|
|
.bind(req.id.unwrap())
|
|
.fetch_optional(state.db.pool())
|
|
.await
|
|
} else {
|
|
sqlx::query_as(&format!(
|
|
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
|
fd_table
|
|
))
|
|
.bind(&req.file_uuid)
|
|
.bind(&face_identifier)
|
|
.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 (frame_number, x, y, width, height, confidence) = row.ok_or_else(|| {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({"success": false, "message": "Face not found"})),
|
|
)
|
|
})?;
|
|
|
|
let video_row: Option<(String, Option<i32>, Option<i32>)> = 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, width, height, 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, width, height, 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<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
) -> Result<(StatusCode, [(String, String); 1], Vec<u8>), 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<String>,
|
|
q: String,
|
|
limit: Option<i64>,
|
|
page: Option<usize>,
|
|
page_size: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct IdentityTextHit {
|
|
file_uuid: String,
|
|
chunk_id: String,
|
|
start_time: f64,
|
|
end_time: f64,
|
|
text_content: Option<String>,
|
|
identity_id: Option<i32>,
|
|
identity_name: Option<String>,
|
|
identity_source: Option<String>,
|
|
trace_id: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct IdentityTextResponse {
|
|
success: bool,
|
|
total: i64,
|
|
page: usize,
|
|
page_size: usize,
|
|
limit: usize,
|
|
results: Vec<IdentityTextHit>,
|
|
}
|
|
|
|
/// Path A: Search chunk text → associated identities
|
|
async fn search_identity_text(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Query(params): Query<IdentityTextQuery>,
|
|
) -> Result<Json<IdentityTextResponse>, StatusCode> {
|
|
use crate::core::db::schema;
|
|
let chunk_table = schema::table_name("chunk");
|
|
let fd_table = schema::table_name("face_detections");
|
|
let id_table = schema::table_name("identities");
|
|
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,
|
|
fd.identity_id, i.name AS identity_name, i.source AS identity_source,
|
|
fd.trace_id
|
|
FROM {} c
|
|
LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid
|
|
AND fd.frame_number BETWEEN c.start_frame AND c.end_frame
|
|
AND fd.identity_id IS NOT NULL
|
|
LEFT JOIN {} i ON i.id = fd.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, fd_table, id_table, sd_table, id_table, chunk_table
|
|
);
|
|
|
|
let rows = sqlx::query_as::<
|
|
_,
|
|
(
|
|
String,
|
|
String,
|
|
f64,
|
|
f64,
|
|
Option<String>,
|
|
Option<i32>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<i32>,
|
|
),
|
|
>(&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<IdentityTextHit> = 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<IdentityTextHit> = 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<String>,
|
|
page: Option<i64>,
|
|
page_size: Option<i64>,
|
|
limit: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct IdentitySearchHit {
|
|
identity_id: i32,
|
|
name: String,
|
|
source: Option<String>,
|
|
tmdb_id: Option<i32>,
|
|
file_uuid: String,
|
|
trace_id: Option<i32>,
|
|
chunk_id: String,
|
|
start_frame: i64,
|
|
end_frame: i64,
|
|
fps: f64,
|
|
start_time: f64,
|
|
end_time: f64,
|
|
text_content: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct IdentitySearchResponse {
|
|
success: bool,
|
|
page: i64,
|
|
page_size: i64,
|
|
total: i64,
|
|
results: Vec<IdentitySearchHit>,
|
|
}
|
|
|
|
/// Path B: Search identity name → associated chunk text
|
|
async fn search_identities_by_text(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Query(params): Query<IdentitySearchQuery>,
|
|
) -> Result<Json<IdentitySearchResponse>, StatusCode> {
|
|
use crate::core::db::schema;
|
|
let id_table = schema::table_name("identities");
|
|
let fd_table = schema::table_name("face_detections");
|
|
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 query = format!(
|
|
r#"WITH matched AS (
|
|
SELECT i.id::int, i.name, i.source, i.tmdb_id,
|
|
fd.file_uuid, fd.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 {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace'
|
|
JOIN {} fd ON fd.trace_id = ib.identity_value::int
|
|
JOIN {} c ON c.file_uuid = fd.file_uuid
|
|
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0)
|
|
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0)
|
|
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 fd.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, ib_table, fd_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::<i64, _>(13)).unwrap_or(0);
|
|
let results: Vec<IdentitySearchHit> = 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<String>,
|
|
metadata: Option<serde_json::Value>,
|
|
status: Option<String>,
|
|
identity_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct UpdateIdentityResponse {
|
|
success: bool,
|
|
identity_uuid: String,
|
|
updated_fields: Vec<String>,
|
|
}
|
|
|
|
async fn update_identity(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Extension(auth): Extension<crate::api::middleware::UserAuth>,
|
|
Path(identity_uuid): Path<String>,
|
|
Json(req): Json<UpdateIdentityRequest>,
|
|
) -> Result<Json<UpdateIdentityResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
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<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 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<String> = Vec::new();
|
|
let mut set_clauses: Vec<String> = 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<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 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<usize>,
|
|
}
|
|
|
|
#[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<crate::api::types::AppState>,
|
|
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
|
|
Path(identity_uuid): Path<String>,
|
|
Json(req): Json<UndoRequest>,
|
|
) -> Result<Json<UndoResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
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");
|
|
let face_table = crate::core::db::schema::table_name("face_detections");
|
|
|
|
// 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
|
|
if let Some(faces) = snapshot.get("unbound_faces").and_then(|v| v.as_array()) {
|
|
for face in faces {
|
|
let file_uuid = face.get("file_uuid").and_then(|v| v.as_str());
|
|
let face_id = face.get("face_id").and_then(|v| v.as_str());
|
|
let trace_id = face.get("trace_id").and_then(|v| v.as_i64());
|
|
if let (Some(fu), Some(fid)) = (file_uuid, face_id) {
|
|
let _ = sqlx::query(&format!(
|
|
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
|
face_table
|
|
))
|
|
.bind(new_id)
|
|
.bind(fu)
|
|
.bind(fid)
|
|
.execute(state.db.pool())
|
|
.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<usize>,
|
|
}
|
|
|
|
#[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<crate::api::types::AppState>,
|
|
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
|
|
Path(identity_uuid): Path<String>,
|
|
Json(req): Json<RedoRequest>,
|
|
) -> Result<Json<RedoResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
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");
|
|
let face_table = crate::core::db::schema::table_name("face_detections");
|
|
|
|
// 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
|
|
let _ = sqlx::query(&format!(
|
|
"UPDATE {} SET identity_id = NULL WHERE identity_id = $1",
|
|
face_table
|
|
))
|
|
.bind(identity_id)
|
|
.execute(state.db.pool())
|
|
.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<usize>,
|
|
page: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct HistoryResponse {
|
|
success: bool,
|
|
identity_uuid: String,
|
|
total: i64,
|
|
undo_stack_count: i64,
|
|
redo_stack_count: i64,
|
|
results: Vec<HistoryItem>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct HistoryItem {
|
|
history_id: i64,
|
|
operation: String,
|
|
is_undone: bool,
|
|
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
undone_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|
|
|
|
async fn get_identity_history(
|
|
State(state): State<crate::api::types::AppState>,
|
|
Path(identity_uuid): Path<String>,
|
|
Query(params): Query<HistoryQuery>,
|
|
) -> Result<Json<HistoryResponse>, (StatusCode, Json<serde_json::Value>)> {
|
|
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<HistoryItem> = rows
|
|
.into_iter()
|
|
.map(|r| HistoryItem {
|
|
history_id: r.get::<i64, _>("id"),
|
|
operation: r.get::<String, _>("operation"),
|
|
is_undone: r.get::<bool, _>("is_undone"),
|
|
created_at: r.get::<Option<chrono::DateTime<chrono::Utc>>, _>("created_at"),
|
|
undone_at: r.get::<Option<chrono::DateTime<chrono::Utc>>, _>("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,
|
|
}))
|
|
}
|