faces: PATCH/POST /image/faces returns person_name with the row

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.
This commit is contained in:
Cameron Cordes
2026-04-29 23:38:24 +00:00
parent 43cb60d3ad
commit 0c2f421a1f

View File

@@ -117,6 +117,35 @@ struct InsertFaceDetection {
created_at: i64, 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<D: FaceDao>(
dao: &mut D,
ctx: &opentelemetry::Context,
row: FaceDetectionRow,
) -> anyhow::Result<FaceWithPerson> {
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 /// Face row decorated with its assigned person's name. Returned by
/// `/image/faces` for the rendering side (carousel overlay, person chips). /// `/image/faces` for the rendering side (carousel overlay, person chips).
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
@@ -2099,7 +2128,10 @@ async fn create_face_handler<D: FaceDao>(
"Created manual face id={} library={} hash={} person_id={:?}", "Created manual face id={} library={} hash={} person_id={:?}",
row.id, row.library_id, row.content_hash, row.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<D: FaceDao>( async fn update_face_handler<D: FaceDao>(
@@ -2205,8 +2237,16 @@ async fn update_face_handler<D: FaceDao>(
} }
let mut dao = face_dao.lock().expect("face dao lock"); let mut dao = face_dao.lock().expect("face dao lock");
match dao.update_face(&span_context, id, person_patch, bbox_patch, new_embedding) { let row = match dao.update_face(&span_context, id, person_patch, bbox_patch, new_embedding) {
Ok(row) => HttpResponse::Ok().json(row), 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()), Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
} }
} }