Skip to content

feat(api): auto-approve requests per requester account (#2718) - #2761

Merged
vavallee merged 2 commits into
vavallee:mainfrom
tunglambk:feature/2718-auto-approve-requester
Sep 27, 2026
Merged

vavallee merged 2 commits into
vavallee:mainfrom
tunglambk:feature/2718-auto-approve-requester

Conversation

@tunglambk

@tunglambk tunglambk commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A requester can only ask, and every ask waits for an admin. On a lean requester UI that turns into a stream of approvals, which is exactly what the reporter wants to switch off for adults they trust. Closes #2718.

The switch is per account, on the user row (requests_auto_approve, migration 093), and off by default. An admin sets it from a new Auto-approve requests checkbox on the Users page, or PUT /api/v1/auth/users/{id}/auto-approve with {"enabled": true}. It sits on users rather than in the settings table because it is a per-account permission like role, and the admin users API already owns that surface. No existing account changes behaviour.

The existing flow, for reference: RequestHandler.Create stores the row as pending and that is where it waited. Approve claims the row with a compare-and-swap on its status, runs runApproval (which revalidates the stored payload, rechecks "already in the library" and calls the same add cores the Add dialog uses with the requester as owner), then Complete marks it approved. Auto-approval hooks in at that first step: Create hands the stored row to writeCreated, which reads the owner's flag and, when it is on, claims the row and runs the same add. There is no second pipeline. I split Approve so the claim, the renewer, the payload revalidation, the owner in the context and the release-on-failure are one function (runClaimedApproval) that both callers drive. decided_by stays NULL, so the queue can tell an automatic approval from a human one.

The approval body is the approve form's own starting point, since there is no form to fill in: a book request searches on add, an author request runs the ordinary catalogue sync. The form defaults author search off too, and auto-approving a batch of author requests with search on add would fan out over every monitored book of each author, so I kept it that way.

Quota

An auto-approved request never sits in pending, so the pending cap (CountPendingByOwner >= limit, default 25) stopped applying to it, and that was the missing ceiling. The claim now carries a daily quota, taken from the same requests.max_pending_per_user limit rather than a second number: once the account has had that many requests auto-approved since midnight UTC, the claim matches nothing and the request is left pending for a human, exactly as it is with the setting off. The count sits inside the claim UPDATE, the way pendingBelowCap guards Create, so a burst of creates cannot all pass a separate read. The quota is per account, resets the next day, and a human approval does not spend it.

Webhook

notifyCreated ran before writeCreated on both create paths, so an auto-approved request still sent requestCreated and the admin still got a ping per item. It now runs after the approval attempt and only when the request actually stayed pending. A request left pending by a failed add or a spent quota still notifies. You offered a distinct auto-approved event as the alternative; I went with suppressing the created ping, since the issue is about not getting pinged.

Route gate

The admin user routes now sit in registerUserAdminRoutes, with their own RequireAdmin group, so cmd/bindery/user_admin_routes_test.go drives the same registration main() uses. Moving /auth/users/{id}/auto-approve out of the group now fails TestUserAdminRoutesRequireAdmin/set_auto_approve with status = 204; want 403; on the reviewed head the equivalent move left ./cmd/... and the auto-approve tests green. The test covers the other /auth/users routes too, since none of them had a route-level test either.

Minors

I took two. An unknown user id on PUT /auth/users/{id}/auto-approve is now a 404 instead of a silent 200: SetRequestsAutoApprove returns db.ErrUserNotFound when the update matches no row, and the handler maps it. And users.autoApproveHint is now rendered, as a line above the users table.

I left two. The flag keeps its value when a requester is demoted and re-promoted. The column is a per-account property rather than a property of the role, and clearing it on a role change would be a destructive side effect of an unrelated action; the checkbox stays hidden while the role is user and comes back with its value. And there is still no dedicated list of what was auto-approved. The queue already lists approvals and decided_by IS NULL marks the automatic ones, so the data is there; a filter or a badge is UI work I would rather do on its own than grow this diff.

Boundary notes

I asked on the issue whether this should be blanket or limited to some other condition; no answer yet, so it is blanket for now, which is what the issue's Seerr comparison implies. I did not touch the wanted/sweep/scoring paths or the narrator token work.

Checklist

  • Commits signed off with git commit -s
  • Tests added or updated
  • docs/DEPLOYMENT.md updated if env vars, config, or upgrade path changed (no env or config change; migration 093 only adds a column with a default)
  • Added a changelog fragment under changelog.d/
  • Wiki pages updated if user-facing behaviour changed (no wiki page covers the requester role; docs/multi-user.md is the reference and is updated)

Test plan

Fail-before as a mutation check. With the flag lookup forced to false on the branch, the auto-approval tests fail:

--- FAIL: TestRequestsCreate_AutoApproveNeedsNoAdmin
    requests_auto_approve_test.go:42: status "pending", want approved without an admin
--- FAIL: TestRequestsCreate_AutoApproveOffRestoresTheManualPath
--- FAIL: TestRequestsCreate_AutoApproveLeavesQueuedRequestsAlone
--- FAIL: TestRequestsCreate_AutoApproveAuthorRequest
--- FAIL: TestRequestsCreate_AutoApproveReopenedRequest

TestRequestsCreate_WithoutAutoApproveStillWaits and the failure-fallback test pass either way, which is the point of both.

New in this revision:

  • TestRequestsCreate_AutoApproveDailyQuota: on a limit of 2, the third request stays pending, the adder is not run for it, a second account is unaffected, and a human can still approve the queued one.
  • TestRequestsCreate_AutoApproveSendsNoRequestCreated and TestRequestsCreate_AutoApproveFailureStillNotifies: no event when the request is approved, an event when the same setting leaves it pending.
  • TestUserAdminRoutesRequireAdmin / TestUserAdminRoutesAllowAdmin: the route-level gate, verified failing with the route moved out of the group.
  • TestRequestRepo_AutoApproveQuota: the tally ignores a human approval and rolls with the window.
  • TestMigration093_AppliesToPopulatedUsers: drops the column and its schema_migrations row on a populated database, re-runs the migrator, and checks the existing account defaults to 0 and the column still writes.
  • TestUserRepo_SetRequestsAutoApprove_UnknownUser: the 404 path.

Reproductions on the reviewed head, before the fix:

  • Webhook: with the flag on, a create of OL27482W captured requestCreated with {kind: book, requestId: 1, title: The War of the Worlds, username: reader}.
  • Ceiling: with requests.max_pending_per_user set to 3 and the flag on, 5 creates all returned approved and the add core ran 5 times.
  • Route gate: moving the route line out of the RequireAdmin group left go test ./cmd/... and the auto-approve tests green.

Validation on 82ac5b5:

  • gofmt -l . no output; go build ./..., go vet ./internal/api/... ./internal/db/... ./cmd/... exit 0
  • go test ./cmd/... ./internal/... passes apart from the twelve hardlink/rename/filesystem tests that fail identically on a clean main worktree in this sandbox: TestHardlinkFile, TestHardlinkDir, TestDrop_EbookHardlink, TestImportMode_DefaultHardlinkSameDevice, TestImportMode_DefaultHardlinkSameDevice_DstNotExist, TestResolveImportMode, TestStagedImport_RollbackRestoresMoveSourceSameFS, TestFlattenAudiobookDir_Hardlink, TestScannerFlatten_HardlinkMode, TestDiagnose_QbittorrentAllPass, TestMigrateFlatCache_MovesFilesAndSidecars, TestCheckSQLiteFile_AwkwardPath. The failure sets are identical on both trees.
  • Frontend under Node 24: npx tsc --noEmit exit 0; npm run lint 0 errors (7 pre-existing exhaustive-deps / unused-disable warnings across the app, one of them UsersPage.tsx); npx vitest run 95 files / 1086 tests passed; npm run build exit 0.

I could not run golangci-lint: the binary available here is v1.64.8 and the repo config is v2, so make lint and make check do not run in this environment. The Go and web steps above are the ones I ran.

A requester can only ask, and every ask waits for an admin. With the lean
requester UI that turns into a stream of approvals, which is the opposite
of what the role is for.

Add a per-account setting, off by default so no existing install changes
behaviour. It lives on the user row (migration 090) rather than in the
settings table because it is a per-account permission like role, and it is
set from the Users page or PUT /auth/users/{id}/auto-approve.

When it is on, Create claims the new request and runs the same add an
admin's Approve runs, with no deciding user recorded. The
already-in-the-library check, the stored-payload revalidation and the
pending cap all still apply. A book request searches on add; an author
request runs the ordinary catalogue sync. If the add fails the request
stays pending, so nothing is lost. The switch only affects the next
request; anything already queued stays for a human.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>
@github-actions github-actions Bot added the bindery-notified Discord notification already sent for this PR label Sep 24, 2026
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 26 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/api/requests.go 66.03% 12 Missing and 6 partials ⚠️
internal/db/auth.go 69.23% 2 Missing and 2 partials ⚠️
internal/api/auth_users.go 91.66% 2 Missing ⚠️
internal/db/requests.go 92.59% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@vavallee vavallee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely built. Reusing runClaimedApproval rather than forking a second approval pipeline is the right instinct and it is what makes the feature safe to reason about. The feature belongs too: this is the natural completion of the requester role, and per account is the right granularity, since flaevers' own "some of my users are adults" argument is exactly the case a global toggle would get wrong.

Two things block, and one of them changes what the feature is.

1. Migration 090 collides three ways. Main tops out at 089, and across the open PRs: 090 is claimed by this PR, by #2750 and by #2713. assertUniqueMigrationVersions (internal/db/db.go:685) refuses to boot when two files share a prefix, so whichever lands second bricks startup for anyone who takes both. Separately 086 is claimed twice, by #2673 and #2633.

Full map right now: 086 → #2673, #2633. 090 → #2761, #2750, #2713. 091 → #2714. 092 → #2737. Next free is 093, so please rename to 093_requests_auto_approve.sql.

2. Auto-approve removes the pending cap, so there is no ceiling left at all. The cap is CountPendingByOwner >= limit (internal/api/requests.go:500-506, default 25). An auto-approved request never sits in pending, so that count is permanently zero. With the cap set to 3 and the flag on, 20 of 20 creates were accepted and the adder ran 20 times.

The only remaining brake is the requester provider limiter, 20 burst then one every 3s, which is roughly 1200 adds an hour, each one dispatching an indexer search and a grab, and each author request running a full catalogue sync.

The PR body notes "the pending cap is unchanged since an auto-approved request never sits in pending". That is accurate, and it is the problem: the requester role is defined by two limits, an admin decides and 25 pending, and this switch removes both at once.

I do not want to block the feature over this, so concretely: a per account "auto-approve up to N per day, then fall back to queuing" keeps the midnight texts problem solved, leaves the admin a floor, and costs one counting query. I would rather have that now than ship the unbounded version and make the quota a breaking change later.

Two more worth fixing while you are in here.

The webhook still fires. notifyCreated runs before writeCreated on both paths (requests.go:543 and :559), so the admin still gets a requestCreated notification per auto-approved item. The whole point of the issue is not getting pinged, so either skip EventRequestCreated when the request auto-approves or emit a distinct auto-approved event.

The admin gate on the new route is not pinned. Moving r.Put("/auth/users/{id}/auto-approve", ...) (cmd/bindery/main.go:939) out of the RequireAdmin group leaves the whole suite green, because the two new tests call h.SetAutoApprove directly and never see middleware. To be fair the same is true of /auth/users/{id}/role, so this is pre-existing, but this one is a privilege flag and deserves a route level test.

Things I specifically checked that are fine, so you know they were looked at rather than assumed:

A requester cannot grant themselves the flag. SetRequestsAutoApprove (internal/db/auth.go:575) is the only writer, neither INSERT INTO users path touches the column so OIDC and first run provisioning leave it 0, and its one caller sits behind RequireAdmin and off the requester allow list. Blocked twice over.

No new exposure in disabled or local-only mode. Create requires a non zero user id (requests.go:432-436), so the signed out as admin caller those modes admit cannot make a request at all.

The unmonitored author guard that landed in 78aadafa does not silently eat these. The search is tagged OriginAdd, which is classified non automatic, so it is correct by construction rather than by luck.

Migration applied cleanly against a populated database, both existing users defaulted to 0, single statement so no half applied state.

On the issue's actual ask: book requests do search immediately, author requests do not, because autoApprove sets search only for RequestKindBook (requests.go:611), so an author request's catalogue books wait for the sweep. That may well be what you want, but it is worth a line in the docs since flaevers quoted the immediate search behaviour specifically. Also, #2739 is not a dependency here; I checked, it fixes the list sync and monitor flip paths, not add.

Minor, take or leave: SetAutoApprove returns 200 for an unknown user id so an admin typo silently succeeds; users.autoApproveHint is in en.json and never rendered; flipping a requester to user and back silently restores the flag because the column keeps its value while the checkbox is hidden; and there is no way for an admin to list what was auto approved, which matters most when they want to turn the switch back off.

Rename to 093, add the quota, suppress the created webhook, add the route test, and I think this is good to go.

…avallee#2718)

Renames 090_requests_auto_approve.sql to 093. Main tops out at 089 and
both vavallee#2750 and vavallee#2713 claim 090, so whichever of the three landed second
would trip assertUniqueMigrationVersions at boot.

Auto-approval removed the pending cap, because that cap counts pending
rows and an auto-approved request never sits in pending. The claim now
also carries a per-account daily quota, drawn from the same
requests.max_pending_per_user limit (25 by default). It is enforced
inside the claim statement, the way pendingBelowCap guards Create, so a
burst of creates cannot all pass a separate count. Once the account has
had that many requests auto-approved since midnight UTC, the next one is
left pending for a human, exactly as it is with the setting off.

An auto-approved request no longer sends requestCreated, so an admin is
not pinged for an item that was added without them. A request that stays
pending, whether the add failed or the day's quota is spent, still
notifies.

The admin user routes move into registerUserAdminRoutes so the route
level test drives the same registration the server uses. Moving
/auth/users/{id}/auto-approve out of the RequireAdmin group failed
nothing before this test; an unknown user id on that route is now a 404
rather than a silent success, and the Users page renders the
autoApproveHint key that was already in en.json.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>
@tunglambk

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read. All four are in, pushed as 82ac5b5.

  1. Renamed to 093_requests_auto_approve.sql, and the two migration 090 comments in internal/db/auth.go went with it.
  2. Quota added, using the existing requests.max_pending_per_user limit rather than a new number. It counts auto-approvals (decided_by IS NULL) since midnight UTC and the count is inside the claim UPDATE, so a burst of creates can't all pass a separate read. At the limit the request is left pending exactly as with the flag off. Per account, resets daily.
  3. requestCreated is suppressed for an auto-approved request. I moved notifyCreated behind the approval attempt and fire it only when the request actually stayed pending, so a failed add or a spent quota still pings.
  4. cmd/bindery/user_admin_routes_test.go pins the gate on the new route, plus the rest of /auth/users. Moving the route out of RequireAdmin now fails TestUserAdminRoutesRequireAdmin/set_auto_approve with 204 vs 403; on the old head the same move left the suite green.

I reproduced all three before fixing: with the flag on a create captured requestCreated; with the cap at 3 five creates all approved and ran the adder five times; the route move was green under go test ./cmd/....

Minors: took the 404 on an unknown user id and rendering users.autoApproveHint. Left the value retention across a demote/promote (the column is a per-account property, and clearing it on a role change felt like the wrong side effect) and a dedicated list of what was auto-approved (the queue has the rows and decided_by IS NULL marks the automatic ones; say the word and I'll add a filter). The book-searches-on-add / author-syncs distinction is called out in docs/multi-user.md, alongside the quota and the suppressed webhook.

No force-push: the branch is still the original head plus this commit, so your review anchor is intact. golangci-lint still doesn't run here (v1.64.8 binary against the v2 config), so that one is unverified locally; the Go and web runs are in the body.

@vavallee vavallee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four done, and done well. Checked each one rather than taking the commit message for it:

  • 093 is unique across main and every open PR right now.
  • The quota sits inside the claim statement, so a burst can't all pass a separate count. I also checked the one way it could silently do nothing: `decided_at` is a DATETIME written from a raw `time.Time` in `Complete`, and `since` is bound the same way, so both sides are the same text shape in UTC and `>=` orders them correctly. Disabling the predicate fails `TestRequestRepo_AutoApproveQuota` and `TestRequestsCreate_AutoApproveDailyQuota`.
  • The webhook. Moving `notifyCreated` back above the auto approve fails `TestRequestsCreate_AutoApproveSendsNoRequestCreated`.
  • The route test. Dropping `RequireAdmin` from `registerUserAdminRoutes` fails `TestUserAdminRoutesRequireAdmin`, and moving the registration into one function the server and the test both call is the right way to make that test mean something. It covers `/role` too now, which fixes the pre-existing gap as a side effect.

Merged onto current main it builds, vets, and `./internal/api`, `./internal/db` and `./cmd/bindery` pass.

@vavallee
vavallee merged commit be38c90 into vavallee:main Sep 27, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bindery-notified Discord notification already sent for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add auto-approve setting per requester account

2 participants