feat(backend): per-file review status with DSL filter and bulk endpoint

Replaces the old "untagged" sentinel tag with a proper per-file workflow
status: needs_review starts true on upload/import and is cleared by an
explicit action (no auto-clear on tagging). Surfaced as a filter token
(r=1 needs review, r=0 done) so it combines with tag/MIME conditions, and
toggled via POST /files/bulk/review (single id or many, edit-ACL enforced,
audit-logged as file_review).

needs_review lives on data.files (column added to the original 003 migration,
partial index in 006, action type seeded in 007).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:16:47 +03:00
parent 4d11beb296
commit 48e901cac1
12 changed files with 215 additions and 9 deletions
@@ -21,6 +21,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@@ -1339,6 +1340,58 @@ func TestImportOrdersByMtime(t *testing.T) {
assert.Less(t, idx["b_middle.jpg"], idx["a_newest.jpg"], "middle should be processed before newest")
}
// TestFileReviewStatus verifies the per-file "needs review" flag: new uploads
// start as needs_review=true, POST /files/bulk/review clears it, and the DSL
// filter r=0/r=1 selects reviewed/unreviewed files (also combined with others).
func TestFileReviewStatus(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
tok := h.login("admin", "admin")
file := h.uploadJPEG(tok, "review-me.jpg")
fileID := file["id"].(string)
assert.Equal(t, true, file["needs_review"], "new upload should need review")
getReview := func() bool {
r := h.doJSON("GET", "/files/"+fileID, nil, tok)
require.Equal(t, http.StatusOK, r.StatusCode, r.String())
var obj map[string]any
r.decode(t, &obj)
return obj["needs_review"].(bool)
}
listIDs := func(dsl string) []string {
r := h.doJSON("GET", "/files?filter="+url.QueryEscape(dsl), nil, tok)
require.Equal(t, http.StatusOK, r.StatusCode, r.String())
var page map[string]any
r.decode(t, &page)
ids := []string{}
for _, it := range page["items"].([]any) {
ids = append(ids, it.(map[string]any)["id"].(string))
}
return ids
}
// Before marking done: appears under r=1 (needs review), not under r=0.
assert.True(t, getReview())
assert.Contains(t, listIDs("{r=1}"), fileID)
assert.NotContains(t, listIDs("{r=0}"), fileID)
// Mark as reviewed (done) via the bulk endpoint (single-element list).
resp := h.doJSON("POST", "/files/bulk/review", map[string]any{
"file_ids": []string{fileID},
"needs_review": false,
}, tok)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Now reviewed: appears under r=0, not r=1; combines with a MIME predicate.
assert.False(t, getReview(), "file should no longer need review")
assert.Contains(t, listIDs("{r=0}"), fileID)
assert.NotContains(t, listIDs("{r=1}"), fileID)
assert.Contains(t, listIDs("{r=0,&,m~image/%}"), fileID)
}
// TestContentRangeRequests verifies the original-content endpoint answers a
// byte-range request with 206 Partial Content (so the browser can seek within
// audio/video) rather than streaming the whole body.