Add bulk approval for selected admin reviews
Build and Publish / Build and Publish Docker Image (push) Successful in 6m10s

This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-23 01:12:54 +01:00
1 parent 59345a8d22
commit bcb5d378c8
3 files changed
+264 -33

No files matched your search

+132 -26
View File
@@ -432,6 +432,8 @@ struct JobRunDetailDto {
struct BulkReviewsResponse { struct BulkReviewsResponse {
ok: bool, ok: bool,
affected: u64, affected: u64,
failed: u64,
skipped: u64,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
@@ -989,10 +991,14 @@ pub async fn bulk_reviews(
} }
let action = body.action.trim(); let action = body.action.trim();
if action != "delete" && action != "requeue" { if action != "delete" && action != "requeue" && action != "approve" {
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action")); return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action"));
} }
if action == "approve" {
return Json(approve_selected_reviews(&db, pool, &body).await?).into_response();
}
let mode = body.mode.as_deref().unwrap_or("ids"); let mode = body.mode.as_deref().unwrap_or("ids");
let affected = if mode == "filter" { let affected = if mode == "filter" {
apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await? apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await?
@@ -1016,7 +1022,99 @@ pub async fn bulk_reviews(
} }
}; };
Json(BulkReviewsResponse { ok: true, affected }).into_response() Json(BulkReviewsResponse {
ok: true,
affected,
failed: 0,
skipped: 0,
})
.into_response()
}
fn selected_reviews_query(body: &BulkReviewsRequest) -> QueryBuilder<'static, Postgres> {
let mut qb = QueryBuilder::new("SELECT id FROM furumusic__pending_review WHERE 1=1");
if body.mode.as_deref() == Some("filter") {
let filter = body.filter.clone().unwrap_or_default();
push_review_filters(
&mut qb,
normalize_status(filter.status.as_deref()),
clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")),
);
} else {
qb.push(" AND id = ANY(");
qb.push_bind(body.ids.clone().unwrap_or_default());
qb.push(")");
}
qb.push(" ORDER BY id");
qb
}
async fn approve_selected_reviews(
db: &Database,
pool: &PgPool,
body: &BulkReviewsRequest,
) -> cot::Result<BulkReviewsResponse> {
let ids = selected_reviews_query(body)
.build_query_as::<IdRow>()
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let (config, _) = AppConfig::load_with_db(db).await;
let mut result = BulkReviewsResponse {
ok: true,
affected: 0,
failed: 0,
skipped: 0,
};
for row in ids {
let Some(mut review) = crate::scheduler::PendingReview::get_by_id(db, row.id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
else {
result.skipped += 1;
continue;
};
if review.status_str() != "pending" {
result.skipped += 1;
continue;
}
// Claim one row at a time so overlapping bulk requests cannot import it twice.
let claimed = sqlx::query(
"UPDATE furumusic__pending_review SET status = 'processing', updated_at = $2 \
WHERE id = $1 AND status = 'pending'",
)
.bind(row.id)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if claimed.rows_affected() == 0 {
result.skipped += 1;
continue;
}
let approval = async {
let normalized = serde_json::from_str::<crate::agent::dto::NormalizedFields>(
review.result_json_str(),
)?;
finalize_review_approval(db, pool, &config, &mut review, &normalized).await
}
.await;
match approval {
Ok(()) => result.affected += 1,
Err(error) => {
tracing::error!(review_id = row.id, ?error, "bulk review approval failed");
review
.set_failed(db, &format!("Review approval failed: {error:#}"))
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
result.failed += 1;
}
}
}
result.ok = result.failed == 0;
Ok(result)
} }
pub async fn approve_review( pub async fn approve_review(
@@ -1042,31 +1140,9 @@ pub async fn approve_review(
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
let context: serde_json::Value =
serde_json::from_str(review.context_json_str()).unwrap_or_default();
let input_path = review.input_path_str().to_owned();
let (live_config, _) = AppConfig::load_with_db(&db).await; let (live_config, _) = AppConfig::load_with_db(&db).await;
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id) match finalize_review_approval(&db, pool, &live_config, &mut review, &normalized).await {
.await Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
.unwrap_or(None);
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
match crate::jobs::inbox_process::finalize_approved(
&db,
pool,
&live_config,
&input_path,
&normalized,
&context,
&live_config.agent_storage_dir,
model_name.as_deref(),
)
.await
{
Ok(()) => {
let _ = review.set_approved(&db).await;
Json(serde_json::json!({ "ok": true })).into_response()
}
Err(error) => { Err(error) => {
tracing::error!(?error, "review approval failed"); tracing::error!(?error, "review approval failed");
let _ = review.set_rejected(&db).await; let _ = review.set_rejected(&db).await;
@@ -1078,6 +1154,36 @@ pub async fn approve_review(
} }
} }
async fn finalize_review_approval(
db: &Database,
pool: &PgPool,
config: &AppConfig,
review: &mut crate::scheduler::PendingReview,
normalized: &crate::agent::dto::NormalizedFields,
) -> anyhow::Result<()> {
let context: serde_json::Value =
serde_json::from_str(review.context_json_str()).unwrap_or_default();
let input_path = review.input_path_str().to_owned();
let stats = crate::scheduler::ProcessingStats::get_by_review_id(db, review.id_val())
.await
.unwrap_or(None);
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
crate::jobs::inbox_process::finalize_approved(
db,
pool,
config,
&input_path,
normalized,
&context,
&config.agent_storage_dir,
model_name.as_deref(),
)
.await?;
review.set_approved(db).await?;
Ok(())
}
pub async fn jobs( pub async fn jobs(
session: Session, session: Session,
db: Database, db: Database,
+28 -7
View File
@@ -2129,26 +2129,30 @@ tbody tr:hover {
<div class="action-strip"> <div class="action-strip">
<div class="toolbar"> <div class="toolbar">
<button class="btn" @click="selectVisibleReviews()"> <button class="btn" @click="selectVisibleReviews()" :disabled="reviewBulkBusy">
<i data-lucide="check-square"></i> <i data-lucide="check-square"></i>
Select visible Select visible
</button> </button>
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0"> <button class="btn" @click="selectReviewFilter()" :disabled="reviewBulkBusy || reviews.total === 0">
<i data-lucide="list-checks"></i> <i data-lucide="list-checks"></i>
<span x-text="`Select all (${fmt(reviews.total)})`"></span> <span x-text="`Select all (${fmt(reviews.total)})`"></span>
</button> </button>
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0"> <button class="btn" @click="clearReviewSelection()" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="x"></i> <i data-lucide="x"></i>
Clear Clear
</button> </button>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<span class="selection-summary" x-text="reviewSelectionSummary()"></span> <span class="selection-summary" x-text="reviewSelectionSummary()"></span>
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="selectedReviewCount() === 0"> <button class="btn primary" @click="bulkReviews('approve')" :disabled="reviewBulkBusy || selectedPendingReviewCount() === 0">
<i data-lucide="check"></i>
<span x-text="reviewBulkBusy && reviewBulkAction === 'approve' ? 'Approving…' : 'Approve selected'"></span>
</button>
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="rotate-ccw"></i> <i data-lucide="rotate-ccw"></i>
Requeue selected Requeue selected
</button> </button>
<button class="btn danger" @click="bulkReviews('delete')" :disabled="selectedReviewCount() === 0"> <button class="btn danger" @click="bulkReviews('delete')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
Delete selected Delete selected
</button> </button>
@@ -2172,7 +2176,7 @@ tbody tr:hover {
<template x-for="row in reviews.items" :key="row.id"> <template x-for="row in reviews.items" :key="row.id">
<tr @click="openReview(row)"> <tr @click="openReview(row)">
<td class="col-check" @click.stop> <td class="col-check" @click.stop>
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" /> <input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" :disabled="reviewBulkBusy" />
</td> </td>
<td><span class="badge" :class="row.status" x-text="row.status"></span></td> <td><span class="badge" :class="row.status" x-text="row.status"></span></td>
<td> <td>
@@ -3834,6 +3838,8 @@ function adminV2() {
reviewFilter: { status: null, search: '' }, reviewFilter: { status: null, search: '' },
selectedReviewIds: {}, selectedReviewIds: {},
reviewSelectionScope: 'ids', reviewSelectionScope: 'ids',
reviewBulkBusy: false,
reviewBulkAction: null,
activeReview: null, activeReview: null,
reviewDraft: { reviewDraft: {
title: '', title: '',
@@ -4830,6 +4836,11 @@ function adminV2() {
return Object.keys(this.selectedReviewIds).length; return Object.keys(this.selectedReviewIds).length;
}, },
selectedPendingReviewCount() {
if (this.reviewSelectionScope === 'filter') return this.filterStatusCounts().pending || 0;
return Object.values(this.selectedReviewIds).filter(status => status === 'pending').length;
},
reviewSelectionSummary() { reviewSelectionSummary() {
const total = this.selectedReviewCount(); const total = this.selectedReviewCount();
if (!total) return 'Nothing selected'; if (!total) return 'Nothing selected';
@@ -4852,8 +4863,10 @@ function adminV2() {
}, },
async bulkReviews(action) { async bulkReviews(action) {
if (this.reviewBulkBusy) return;
const count = this.selectedReviewCount(); const count = this.selectedReviewCount();
if (!count) return; if (!count) return;
if (action === 'approve' && !this.selectedPendingReviewCount()) return;
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return; if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
const payload = { const payload = {
action, action,
@@ -4864,16 +4877,24 @@ function adminV2() {
search: this.reviewFilter.search || null search: this.reviewFilter.search || null
} }
}; };
this.reviewBulkBusy = true;
this.reviewBulkAction = action;
try { try {
const result = await this.request(`${this.apiBase}/reviews/bulk`, { const result = await this.request(`${this.apiBase}/reviews/bulk`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
this.showToast(`${result.affected} review row(s) updated`); this.showToast(action === 'approve'
? `${result.affected} review(s) approved · ${result.failed || 0} failed · ${result.skipped || 0} skipped`
: `${result.affected} review row(s) updated`);
this.clearReviewSelection(); this.clearReviewSelection();
await this.loadReviews(false); await this.loadReviews(false);
if (action === 'approve') await this.loadLibrary(false);
} catch (error) { } catch (error) {
this.showToast(error.message); this.showToast(error.message);
} finally {
this.reviewBulkBusy = false;
this.reviewBulkAction = null;
} }
}, },
+104
View File
@@ -0,0 +1,104 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const vm = require('node:vm');
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
function createApp() {
const context = vm.createContext({});
vm.runInContext(script, context);
const app = context.adminV2();
app.showToast = message => { app.toastMessage = message; };
app.loadReviews = async () => { app.reviewsReloaded = true; };
app.loadLibrary = async () => { app.libraryReloaded = true; };
return app;
}
test('approves individually selected reviews and reports partial failures', async () => {
const app = createApp();
app.toggleReview({ id: 12, status: 'pending' });
app.toggleReview({ id: 34, status: 'pending' });
app.toggleReview({ id: 56, status: 'failed' });
assert.equal(app.selectedPendingReviewCount(), 2);
app.request = async (url, options) => {
assert.equal(url, '/admin/v2/api/reviews/bulk');
assert.equal(options.method, 'POST');
assert.deepEqual(JSON.parse(options.body), {
action: 'approve', mode: 'ids', ids: [12, 34, 56],
filter: { status: null, search: null }
});
assert.equal(app.reviewBulkBusy, true);
return { ok: false, affected: 1, failed: 1, skipped: 1 };
};
await app.bulkReviews('approve');
assert.equal(app.toastMessage, '1 review(s) approved · 1 failed · 1 skipped');
assert.equal(app.selectedReviewCount(), 0);
assert.equal(app.reviewsReloaded, true);
assert.equal(app.libraryReloaded, true);
assert.equal(app.reviewBulkBusy, false);
});
test('select all approves the whole searched filter across pages', async () => {
const app = createApp();
app.reviewFilter = { status: 'pending', search: 'Album' };
app.reviews = { total: 320, items: [{ id: 1, status: 'pending' }] };
app.selectReviewFilter();
assert.equal(app.selectedPendingReviewCount(), 320);
app.request = async (_url, options) => {
assert.deepEqual(JSON.parse(options.body), {
action: 'approve', mode: 'filter', ids: [],
filter: { status: 'pending', search: 'Album' }
});
return { affected: 320, failed: 0, skipped: 0 };
};
await app.bulkReviews('approve');
assert.equal(app.toastMessage, '320 review(s) approved · 0 failed · 0 skipped');
});
test('mixed status filter enables approval only when pending reviews exist', () => {
const app = createApp();
app.reviews = { total: 10, status_counts: [{ status: 'pending', count: 3 }, { status: 'queued', count: 7 }] };
app.selectReviewFilter();
assert.equal(app.selectedPendingReviewCount(), 3);
app.reviewFilter.status = 'queued';
assert.equal(app.selectedPendingReviewCount(), 0);
});
test('empty and non-pending selections do not submit approval', async () => {
const app = createApp();
app.request = async () => { assert.fail('unexpected request'); };
await app.bulkReviews('approve');
app.toggleReview({ id: 1, status: 'queued' });
await app.bulkReviews('approve');
});
test('blocks duplicate bulk submissions while approval is running', async () => {
const app = createApp();
app.toggleReview({ id: 1, status: 'pending' });
let finish;
let calls = 0;
app.request = async () => {
calls += 1;
return new Promise(resolve => { finish = resolve; });
};
const approval = app.bulkReviews('approve');
await app.bulkReviews('approve');
await app.bulkReviews('requeue');
assert.equal(calls, 1);
finish({ affected: 1 });
await approval;
assert.equal(app.reviewBulkBusy, false);
});
test('request failure retains selection and restores bulk controls', async () => {
const app = createApp();
app.toggleReview({ id: 1, status: 'pending' });
app.request = async () => { throw new Error('Connection lost'); };
await app.bulkReviews('approve');
assert.equal(app.selectedReviewCount(), 1);
assert.equal(app.toastMessage, 'Connection lost');
assert.equal(app.reviewBulkBusy, false);
assert.equal(app.reviewBulkAction, null);
});