From 0c2f421a1fcd3e280196c6754472300446d0c874 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Wed, 29 Apr 2026 23:38:24 +0000 Subject: [PATCH] faces: PATCH/POST /image/faces returns person_name with the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both create_face_handler and update_face_handler returned the bare FaceDetectionRow, so PATCH /image/faces/{id} (used by both bbox edits and person assignment) replied without person_name. The carousel overlay does an optimistic replace on this row — replacing the joined FaceWithPerson with a row that has person_name = undefined visibly dropped the VFD label off the bbox after every save. Add a small hydrate_face_with_person helper that does the persons lookup and assembles a FaceWithPerson, used by both handlers. The list endpoint already does the join, so the PATCH/POST shape now matches it. --- src/faces.rs | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/faces.rs b/src/faces.rs index 10cd0b6..66ddfca 100644 --- a/src/faces.rs +++ b/src/faces.rs @@ -117,6 +117,35 @@ struct InsertFaceDetection { created_at: i64, } +/// Build a [`FaceWithPerson`] from a freshly-mutated row by resolving the +/// person name via [`FaceDao::get_person`]. Used by `create_face_handler` +/// and `update_face_handler` so PATCH/POST responses match the join shape +/// `/image/faces` returns — without this the carousel overlay's +/// optimistic-replace would clobber the rendered name (the bare +/// [`FaceDetectionRow`] doesn't carry it). +fn hydrate_face_with_person( + dao: &mut D, + ctx: &opentelemetry::Context, + row: FaceDetectionRow, +) -> anyhow::Result { + let person_name = match row.person_id { + Some(pid) => dao.get_person(ctx, pid)?.map(|p| p.name), + None => None, + }; + Ok(FaceWithPerson { + id: row.id, + bbox_x: row.bbox_x.unwrap_or(0.0), + bbox_y: row.bbox_y.unwrap_or(0.0), + bbox_w: row.bbox_w.unwrap_or(0.0), + bbox_h: row.bbox_h.unwrap_or(0.0), + confidence: row.confidence.unwrap_or(0.0), + source: row.source, + person_id: row.person_id, + person_name, + model_version: row.model_version, + }) +} + /// Face row decorated with its assigned person's name. Returned by /// `/image/faces` for the rendering side (carousel overlay, person chips). #[derive(Serialize, Debug, Clone)] @@ -2099,7 +2128,10 @@ async fn create_face_handler( "Created manual face id={} library={} hash={} person_id={:?}", row.id, row.library_id, row.content_hash, row.person_id ); - HttpResponse::Created().json(row) + match hydrate_face_with_person(&mut *dao, &span_context, row) { + Ok(joined) => HttpResponse::Created().json(joined), + Err(e) => HttpResponse::InternalServerError().body(e.to_string()), + } } async fn update_face_handler( @@ -2205,8 +2237,16 @@ async fn update_face_handler( } let mut dao = face_dao.lock().expect("face dao lock"); - match dao.update_face(&span_context, id, person_patch, bbox_patch, new_embedding) { - Ok(row) => HttpResponse::Ok().json(row), + let row = match dao.update_face(&span_context, id, person_patch, bbox_patch, new_embedding) { + Ok(r) => r, + Err(e) => return HttpResponse::InternalServerError().body(e.to_string()), + }; + // Hydrate person_name so the response shape matches GET /image/faces + // — the carousel overlay does an optimistic replace on this row, and + // a bare FaceDetectionRow with no person_name would visibly drop the + // VFD label off the bbox even though the assignment didn't change. + match hydrate_face_with_person(&mut *dao, &span_context, row) { + Ok(joined) => HttpResponse::Ok().json(joined), Err(e) => HttpResponse::InternalServerError().body(e.to_string()), } }