Fix review findings on bulk delete, share editing, selection
ci / docker (push) Successful in 13m43s

- expiry editor commits on blur/Enter with explicit clear button — no more
  per-keystroke PATCHes transiently expiring live links
- shift-range walks the filtered view, anchors reset on clear, updaters
  kept pure (StrictMode-safe)
- bulk delete surfaces errors, 404s on zero deletions, and shares one
  delete+cleanup path with single and album delete (s3::photo_prefix is
  the only prefix source)
- expiry end-of-day convention extracted; share rows update from the
  PATCH response instead of reloading the list
- Gallery container always renders, restoring the simple observer effect
This commit is contained in:
2026-07-17 16:42:28 +02:00
parent 61f1bd1474
commit 900287b7aa
10 changed files with 172 additions and 93 deletions
+8 -12
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::models::{Album, Photo, PhotoStatus};
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
@@ -232,17 +232,13 @@ pub async fn delete(
if locked.is_none() {
return Err(ApiError::not_found());
}
// Delete photos and enqueue their S3 cleanup atomically, in one statement.
sqlx::query(
"with deleted as (delete from photos where album_id = $1 returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(album_id)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
// Delete photos through the shared path so the S3 cleanup convention
// lives in one place; the album lock above keeps this set complete.
let photo_ids: Vec<Uuid> = sqlx::query_scalar("select id from photos where album_id = $1")
.bind(album_id)
.fetch_all(&mut *tx)
.await?;
super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?;
sqlx::query("delete from albums where id = $1")
.bind(album_id)
.execute(&mut *tx)
+36 -24
View File
@@ -189,24 +189,42 @@ pub async fn by_hash(
photo.map(Json).ok_or_else(ApiError::not_found)
}
/// The one deletion path: delete the given photos and enqueue their S3
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
/// so the key layout has a single source of truth. Returns how many photos
/// were actually deleted.
pub(super) async fn delete_with_cleanup(
tx: &mut sqlx::PgConnection,
ids: &[Uuid],
) -> Result<u64, sqlx::Error> {
let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
.bind(ids)
.fetch_all(&mut *tx)
.await?;
if deleted.is_empty() {
return Ok(0);
}
let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
sqlx::query(
"insert into jobs (kind, payload)
select $1, jsonb_build_object('prefix', p)
from unnest($2::text[]) as p",
)
.bind(JobKind::DeleteS3Prefix.as_str())
.bind(&prefixes)
.execute(&mut *tx)
.await?;
Ok(deleted.len() as u64)
}
pub async fn delete(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id)
.execute(&mut *tx)
.await?;
if deleted.rows_affected() == 0 {
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 {
return Err(ApiError::not_found());
}
jobs::enqueue(
&mut *tx,
JobKind::DeleteS3Prefix,
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
@@ -216,8 +234,6 @@ pub struct DeleteManyBody {
ids: Vec<Uuid>,
}
/// Bulk delete: photos vanish and their S3 cleanup jobs are enqueued in one
/// statement, same as album deletion.
pub async fn delete_many(
State(state): State<AppState>,
Json(body): Json<DeleteManyBody>,
@@ -226,18 +242,14 @@ pub async fn delete_many(
return Err(ApiError::bad_request("ids must not be empty"));
}
let mut tx = state.db.begin().await?;
let jobs = sqlx::query(
"with deleted as (delete from photos where id = any($1) returning id)
insert into jobs (kind, payload)
select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
from deleted",
)
.bind(&body.ids)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
// Consistent with the single-photo route: deleting nothing is an error,
// not a silent success (e.g. a stale tab re-deleting already-gone photos).
if deleted == 0 {
return Err(ApiError::not_found());
}
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true, "deleted": jobs.rows_affected() })))
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}
pub async fn reprocess(