feat(authors): rename/merge/delete management [shot:author-manage] (bookshelf-vgbmo.1) #1072
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-vgbmo.1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds admin-gated author management on the existing
/authorsbrowse page:PATCH /authors/{id}: updates the author name, returns 409 on UNIQUE conflict, refreshessort_author_nameon affected booksPOST /authors/{id}/merge: re-points source author mappings onto the target inside a DB transaction, deletes orphaned source authors, refreshes sortDELETE /authors/{id}: removes author (FK cascade clears mappings), refreshes sortAll three endpoints require
permission_manage_metadata_config(admin or explicitly granted).UI: per-card kebab (⋮) button opens a Stimulus context menu. Each action opens a canonical modal (
modal-dialog,modal-overlay,btn, etc.). No inlinestyle=(CSP compliant).CanManageMetadataadded toCurrentUserso the template can gate the kebab UI.Test plan
internal/authors/manage_service_test.go— curried-function tests for RenameAuthor, MergeAuthors, DeleteAuthor; all error branches coveredinternal/authors/manage_handler_test.go— HTTP handler tests: 204 success, 400 bad path/JSON, 409 conflict, 404 not found, 500 errorstatic/js/test/author_manage_controller.test.js— 35 Vitest specs; menu, rename/merge/delete modals, fetch paths, Escape/Tab keyboardmake coveragepasses)npm run coveragepasses)Journey: Author Management Modalposts a screenshot via[shot:author-manage]make e2e-policy-checkpasses (Ordered journey)Closes bead bookshelf-vgbmo.1 on merge.
Adds admin-gated author management on the /authors browse page: - PATCH /authors/{id} — rename (409 on UNIQUE conflict) - POST /authors/{id}/merge — merge N source authors into target - DELETE /authors/{id} — delete author (FK cascade clears mappings) All three endpoints are guarded by LibraryManageMetadataConfigRequired. MergeAuthors runs inside a DB transaction (TxFunc injection pattern); sort_author_name is refreshed via RefreshSortAuthorNameBatch after each mutation. UI: per-card kebab button opens a context menu (Rename / Merge / Delete). Each action opens a canonical modal (modal-dialog/modal-overlay classes). No inline style= attributes (CSP: style-src 'self'). CanManageMetadata added to CurrentUser so templates can gate the UI. 100% Go coverage maintained (manage_service_test.go + manage_handler_test.go). 100% JS coverage maintained (author_manage_controller.test.js, 35 specs). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
UI Review — PR #1072 (bookshelf-vgbmo.1)
Screenshot reviewed: authors page with kebab button and open "Rename Author" modal (vgbmo_authors.png).
What I see in the rendered screenshot
⋮(vertical ellipsis) button renders below the author name as its own right-aligned row, outside/below the card photo area.templates/layouts/base.htmlor any sidebar CSS/JS (confirmed by diff). Not a finding against this PR.Findings
[MINOR]
templates/pages/authors_index.html:22-29— kebab placement feels visually disconnected from the cardThe
.author-card-actionsdiv is a third row in theflex-direction:columncard layout, sitting below the photo-wrap and author name. In the screenshot the⋮button appears as a standalone button clearly separated from the card photo, giving a bolted-on appearance. The sibling pattern for the book-count badge (.author-card-count-badge) usesposition: absolute; top/rightinside.author-card-photo-wrap. Putting the kebab in the same layer —position: absolute; top: var(--space-2); right: var(--space-2)on.author-card-actionswith.author-card-photo-wrap { position: relative }— would visually anchor it to the card. As-is, the button is functional and discoverable, just inconsistent with the card-overlay convention.[MINOR]
static/js/controllers/author_manage_controller.js:112,201,291— variant class name diverges from themodal-dialog--*conventionEvery other modal variant in the app uses
modal-dialog modal-dialog--{name}(e.g..modal-dialog--shelf-assign,.modal-dialog--create-shelf,.modal-dialog--extract-pattern— seemain.csslines 2480–2577). The new dialogs usemodal-dialog author-manage-dialog(canonical + bespoke name). The CSS selector.author-manage-dialog .modal-body(main.css line 6557) inherits this. Fix: rename tomodal-dialog--author-managein JS and update the CSS selector to.modal-dialog--author-manage .modal-body.[MINOR]
static/css/main.css(new.am-error-msgrule) — uses undefined--color-dangertoken instead of--dangercolor: var(--color-danger, #c0392b). The project root defines--danger: #f87171(main.css line 12);--color-dangeris never declared at:root, so this property always falls back to#c0392b— a different hue from the app's established danger red. Fix:color: var(--danger).Component reuse (pass)
div.modal-overlay>div.modal-dialog>.modal-header/.modal-body/.modal-footer+.modal-title/.modal-close-btn. No bespoke parallel modal class system..metadata-field-label+.metadata-field-input— canonical.btn btn-ghost(Cancel),btn(Save/Merge,btn-primaryis an alias forbtnper main.css:944),btn btn-danger(Delete) — all canonical.btn-iconis canonical (main.css line 931).style=in any template. JS useselement.style.setProperty("--menu-top", ...)to set CSS custom properties (CSP-safe — not blocked bystyle-src 'self').--space-*tokens. Colors usevar(--bg-card),var(--border),var(--fg),var(--accent),var(--danger)throughout (except the.am-error-msgnit above).REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Security Review — PR #1072 (bookshelf-vgbmo.1): admin author rename/merge/delete
Independent adversarial security review of the diff (
origin/main...origin/bd-bookshelf-vgbmo.1). Read-only; did not run tests.Authorization (HARD RULE) — PASS. All three destructive routes are gated by the admin permission in
internal/authors/routes.go:26-28:PATCH /authors/{id},POST /authors/{id}/merge, andDELETE /authors/{id}are each wrapped withmanageRequired(...), bound tod.LibraryManageMetadataConfigRequiredinwire.go:71-73, which isusers.PermissionRequired(... PermissionManageMetadataConfig)(app.go:356). That middleware fails closed (403 on no-claims / no-perms-row / check-false) and admin-short-circuits. Defense in depth is correct: the template gates the kebab + controller behind{{if .CurrentUser.CanManageMetadata}}(authors_index.html) AND the server enforces independently. No route left ungated. SQL is fully parameterized; author IDs areParseInt-validated; theauthor_idFK (fk_book_metadata_author_mapping_author ... ON DELETE CASCADE, migration 0001:286) means a merge remap to a nonexistent target rolls back rather than orphaning rows. XSS is clean — the JS controller builds all modal content viatextContent/_el(...), neverinnerHTML, and the template auto-escapes.Name.Two findings below.
[BLOCKER] static/js/controllers/author_manage_controller.js:172,266,341 — mutation fetches omit the X-CSRF-Token header; every rename/merge/delete returns 403
All three state-changing fetches (
_patchRenameL172,_postMergeL266,_deleteAuthorL341) send onlyContent-Type: application/json(or no headers for DELETE) and NOX-CSRF-Tokenheader. The global CSRF middleware (internal/middleware/csrf.go:91-99) validates every unsafe method (POST/PATCH/DELETE) against the double-submit token;/authors/*is not in the exempt list. With no header and a JSON (not form) body,csrfTokenFromRequestreturns "" ->tokenEqualfails ->403 "CSRF token mismatch". Result: the feature is 100% non-functional in production — no author can be renamed, merged, or deleted. This escapes CI because the browser e2e (journey_author_manage_test.go) only opens the rename modal and asserts the input is pre-filled; it never submits a mutation. Fix: read thebookshelf_csrfcookie and add"X-CSRF-Token": <token>to all three fetches — mirror the existing_csrfToken()helper used by every other mutation controller (e.g.column_picker_controller.js:48,bookdrop_bulk_edit_controller.js:132). Add an e2e/Vitest assertion that a mutation actually returns 204 (not just that the modal opens) so this cannot regress.[MAJOR] internal/authors/manage_service.go:216 (primaryBookIDs) + RefreshSortAuthorNameBatch — unbounded query + single-statement IN() can exceed MySQL's placeholder limit on a large author
primaryBookIDsrunsSELECT book_id ... WHERE author_id = ? AND sort_order = 0with no LIMIT, then the collected slice is passed straight toRefreshSortAuthorNameBatch, which expands to one?placeholder per book in a singleUPDATE ... WHERE bm.book_id IN (?,?,...)statement (internal/db/sqlc/metadata.sql.go:437-449). At the project's stated target scale (hundreds of thousands of books/library), a catch-all author like "Unknown"/"Various" can map to >65,535 books; the batch UPDATE then fails with "Prepared statement contains too many placeholders", and merge/delete/rename of that author breaks. It also runs synchronously inline in the request handler (conventions push expensive per-book fan-out to background/batched work). AdditionallyMergeHandlercapssource_idsat "not empty" but has no upper bound (onlyMaxByteslimits it indirectly). Fix: chunk the book-ID set into fixed-size batches for the sort refresh, or cap/paginateprimaryBookIDs; and add an explicit upper bound onsource_idslength in the handler.REVIEW VERDICT: 1 blocker, 1 major, 0 minor
BLOCKER fixes: - Add X-CSRF-Token header (from meta[name=csrf-token]) to all three state- changing fetches in author_manage_controller.js (PATCH rename, POST merge, DELETE). Without this the global CSRF middleware rejected every mutation. - RenameAuthor now checks RowsAffected() after UPDATE and returns ErrNotFound when 0 rows were updated (author does not exist), mirroring DeleteAuthor. Added tests for the 0-rows-affected and RowsAffected-error paths. MAJOR fix: - primaryBookIDs now adds LIMIT 65000 to the SELECT so a catch-all author cannot load unbounded rows into memory. - refreshSortChunked() splits the book-ID slice into 1000-ID batches before calling refreshSortBatch, keeping each UPDATE...IN() well below MySQL's 65535-placeholder limit. All three service call sites updated. - MergeHandler caps source_ids at 100 entries (returns 400 over the limit). - Tests: RenameAuthor with 1001 book IDs asserts refresh is called >1 time (black-box chunking coverage); MergeHandler 101-IDs case asserts 400. MINOR fixes: - Modal class renamed from author-manage-dialog to modal-dialog--author-manage (canonical modal-dialog--{name} convention) in JS and CSS. - CSS .am-error-msg changed from var(--color-danger, #c0392b) to var(--danger) (undefined token → defined root variable). - Kebab button moved inside .author-card-photo-wrap with position:absolute (top/left overlay) instead of a separate .author-card-actions row below. JS tests: three new CSRF-header assertions (rename, merge, delete). E2E browser: new It step submits a real rename (proves CSRF path, reloads page). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author modal diagnostic screenshot (kebab-menu)
Author modal diagnostic screenshot (rename-modal)
Author modal diagnostic screenshot (merge-modal)
Author modal diagnostic screenshot (delete-modal)
Author modal diagnostic screenshot (kebab-menu)
Author modal diagnostic screenshot (rename-modal)
Author modal diagnostic screenshot (merge-modal)
Author modal diagnostic screenshot (delete-modal)
Author modal diagnostic screenshot (kebab-menu)
Author modal diagnostic screenshot (rename-modal)
Author modal diagnostic screenshot (merge-modal)
Author modal diagnostic screenshot (delete-modal)
UI Screenshot — author kebab menu restyled
The author-card kebab dropdown now matches the canonical sidebar kebab menu:
0 4px 16px rgba(0,0,0,0.4)) for clear elevationvar(--fg-muted)with brand-purple hoverrgba(124, 140, 248, 0.08)--dangermodifier: red text + danger-alpha hoverUI Screenshot — author kebab menu restyled
The author-card kebab dropdown now matches the canonical sidebar kebab menu:
0 4px 16px rgba(0,0,0,0.4)) for clear elevationvar(--fg-muted)with brand-purple hoverrgba(124, 140, 248, 0.08)--dangermodifier: red text + danger-alpha hoverf47897751f69c2919cacAuthor management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
UI Review — author-card kebab dropdown restyle (bookshelf-vgbmo.1)
Screenshot reviewed:
/authorspage with the⋯kebab open on Charles Dickens showing Rename / Merge / Delete (Delete in red). Screenshot confirmed rendered and readable.What looks right
var(--bg-card)background, visible1px var(--border)border, and the same0 4px 16px rgba(0,0,0,0.4)box-shadow as the canonical sidebar kebab menus. No longer bare floating items..library-kebab-menu/.shelf-kebab-menuexactly..author-manage-menu-btnmatch.library-kebab-menu__itemexactly:var(--space-2) var(--space-4)padding,0.875remfont-size,var(--fg-muted)base color,rgba(124,140,248,0.08)hover background.color: var(--danger), hover usesvar(--danger-alpha)— consistent with sidebar kebab destructive items and the rest of the app.z-index: 600overlays cleanly; no clipping, no overlap with card content.z-index: 600is appropriately higher than the sidebar's200since this is a document-level overlay.modal-overlay,modal-dialog,modal-header,modal-body,modal-footer,modal-close-btn,modal-title. Buttons usebtn btn-ghost,btn,btn btn-danger. Fields usemetadata-field-label,metadata-field-input. No bespoke modal class system.style=attributes in the template or JS-generated HTML. The JS positions viamenu.style.setProperty("--menu-top", …)(CSS custom properties via script, which isscript-src-governed — not blocked bystyle-src 'self'). CSP-safe..author-manage-menuis a body-anchored popup with legitimately different positioning from the sidebar-relative.library-kebab-menu; having a distinct class for the container is reasonable given the different lifecycle (dynamically created/removed from body vs static DOM).Findings
[MINOR]
static/css/main.css:6568—border-radius: var(--radius-sm)vs canonicalvar(--radius)The canonical
.library-kebab-menuusesborder-radius: var(--radius)(0.5rem). The.author-manage-menuusesvar(--radius-sm)(0.25rem). The corners are perceptibly less rounded than the sidebar kebab panels. Since this is a restyle to match the canonical, the radius token should bevar(--radius)to complete the match.[MINOR]
static/css/main.css:6577—.author-manage-menu-btnduplicates.library-kebab-menu__itemstylesThe 30-line
.author-manage-menu-btn/--danger/--danger:hoverblock is byte-for-byte identical to the canonical.library-kebab-menu__itemrules (same padding, font-size, colors, hover, danger). Because.library-kebab-menu__itemis not scoped to a parent selector, the button items inside the body-anchored menu could simply use the canonical class instead, avoiding duplicated rules. Fix: replaceauthor-manage-menu-btnwithlibrary-kebab-menu__itemin the controller's_el("button", cls, a.label)call and remove the now-redundant CSS rules.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
148ab2e12b99c2e1e6ce99c2e1e6ceede12c6077Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
zombor referenced this pull request2026-07-10 03:18:07 +00:00
ede12c6077fec7d96a63Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-typeahead-open)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-chip-selected)
Author rename/merge/delete — kebab menu and modal on /authors page
rod's Timeout() sets an ABSOLUTE deadline — not a rolling per-operation budget. My test created the page with Timeout(30s) and never called refreshPageTimeout, so later It steps (rename-submit, merge typeahead) ran with an exhausted context and panicked with "context deadline exceeded". Fix: switch to pageTimeout (60s) + BeforeEach{page = refreshPageTimeout(page)} — the established convention used by every other Ordered journey in this suite. Also remove the no-op page.Timeout(Xs) calls inside each It block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-typeahead-open)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-chip-selected)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (delete-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-typeahead-open)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-chip-selected)
Author rename/merge/delete — kebab menu and modal on /authors page
UI Review — PR #1072 (bookshelf-vgbmo.1)
Reviewed five screenshots: rename modal (open + submitted), merge modal (typeahead open + chip selected), delete modal.
What I actually see in the pixels
Rename modal — correct header/close-button chrome, padded body with label + input, right-aligned Cancel/Save footer. Width is consistent (~480px centred). Rounded corners and dark overlay match every other modal in the app.
Merge modal — same header/footer structure. Typeahead dropdown renders as a bordered card below the input; on-brand and readable. Chip ("Charles Dickens ×") renders as the canonical pill (
.chip.chip--editable) with a clearly visible × remove affordance.Delete modal — same chrome. "Delete" button renders in red (
.btn-danger), correctly distinct from the ghost Cancel button.Canonical-component reuse — PASS
Every modal is built with the canonical stack:
.modal-overlay > .modal-dialog.modal-dialog--author-manage > .modal-header / .modal-body / .modal-footer.modal-dialog--author-managefollows the exact samewidth/padding/gapvariant pattern as the 10+ other modal variants inmain.css.btn(primary),.btn-ghost(cancel),.btn-danger(delete) — all canonical.chip.chip--editable/.chip-text/.chip-remove— canonical reuse, not reinvented.library-kebab-menu__item/.library-kebab-menu__item--danger— smart cross-domain reuse of existing CSSstyle=attributes; menu positioning uses CSS custom properties set via CSSOM, which is not blocked bystyle-src 'self'CSPFindings
[MINOR] static/css/main.css:6557 —
.author-card-kebabusescolor: #fff(hardcoded)color: #fffdoes not adapt to light themes. Usevar(--bg)instead — in the dark themevar(--bg)is near-white, so the visual result is identical, but the value follows the design system. Note:color: #fffappears in ~8 other pre-existing places inmain.css, so this follows existing convention; but the pattern is worth not extending further.[MINOR] static/css/main.css:6556,6569 —
.author-card-kebaband.author-manage-menuuse hardcodedrgba()valuesbackground: rgba(0, 0, 0, 0.55)(kebab button overlay) andbox-shadow: 0 4px 16px rgba(0, 0, 0, 0.4)(popup menu) are not tokenised. Both values appear verbatim in ~8 other pre-existing rules inmain.css, so this follows the existing (un-tokenised) convention. Worth a follow-up token extraction bead rather than blocking this PR.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Security Review — PR #1072 (bookshelf-vgbmo.1)
Reviewed: author-management UI rework (kebab menu + Rename/Merge/Delete modals, typeahead GET /authors?q=).
Focus areas: AuthZ gating, XSS via typeahead DOM insertion, CSRF on mutation POSTs, SQL injection on ?q=, PII logging.
Findings
[MINOR] internal/authors/manage_service.go:276 —
isDuplicateKeyuses fragile string matching instead of typed error assertionThe function checks for MySQL duplicate-key error (1062) via
strings.Contains(err.Error(), "1062")andstrings.Contains(err.Error(), "Duplicate entry"). Thego-sql-driver/mysqldriver exposes*mysql.MySQLErrorwith aNumberfield; the correct approach iserrors.As(err, &mysqlErr) && mysqlErr.Number == 1062. The string check is fragile: a future driver version that changes its error formatting could silently stop detecting conflicts, returning a 500 instead of the intended 409.Suggested fix: type-assert using
errors.As(err, &mysqlErr)from thegithub.com/go-sql-driver/mysqlpackage.[MINOR] internal/authors/manage_service.go:63 —
RenameAuthorconflates "author not found" with "name unchanged"MySQL's
RowsAffectedreturns 0 when anUPDATEchanges no values (i.e., the new name equals the existing name), unless the connection usesCLIENT_FOUND_ROWS. The current code treats 0 rows affected asErrNotFound, so renaming an author to their current name returns a 404 to the caller rather than a no-op 204. The JS client will display "Rename failed (status 404). Please try again." No security impact, but the behavior is incorrect and surprising to users.Suggested fix: check existence before the UPDATE (a
SELECT id FROM author WHERE id = ?), or acceptCLIENT_FOUND_ROWSin the DSN and verify affected > 0 only for the "name changed" path.Confirmed safe
Authorization: PATCH
/authors/{id}, POST/authors/{id}/merge, and DELETE/authors/{id}are all wrapped inmanageRequired(d.LibraryManageMetadataConfigRequired), which resolves tousers.PermissionRequired(q.GetUserPermissions, func(row) bool { return row.PermissionManageMetadataConfig }). The check is server-side against the authenticated session claims (ClaimsFromContext), fails closed when no permissions row exists (sql.ErrNoRows → 403), and short-circuits for admin. No client-side-only gating.XSS: The JS controller uses the
_el()helper for all user-data strings, which assigns viael.textContent(notinnerHTML). The foursuggEl.innerHTML = ""calls are clears-only. Author names from the typeahead API (author.name) are inserted viali.textContentand_el("span", "chip-text", author.name).removeBtn.setAttribute("aria-label", ...)is safe (attribute assignment, not HTML parsing).this._activeNameoriginates fromdata-author-name="{{.Name}}"in the template (Go html/template HTML-encodes attributes) and is then set viatextContent. No XSS path found.CSRF: All mutation fetches include
"X-CSRF-Token": this._csrfToken()(reads from<meta name="csrf-token">). The global CSRF middleware (wired atapp.go:803) enforces this for all unsafe methods (POST, PATCH, DELETE) using constant-time comparison with an empty-string guard (tokenEqualreturns false when either side is empty). The typeaheadGET /authors?q=correctly omits the CSRF header (safe method, exempt).SQL injection: The
?q=prefix search is fully parameterized:a.name LIKE ?withp.Query + "%"as the bound argument (service.go:124–125). No string interpolation into the query.Limit capping: The typeahead sends
limit=10; the server clamps all limits server-side viaclampLimit(max 200). No resource-exhaustion risk from a craftedlimitparameter.PII / secrets: Error messages include author IDs only. No author names, usernames, or tokens appear in logged/returned error strings.
Multi-user typeahead scoping:
GET /authors?q=goes through the existingListHandlerwhich enforcesuserLibraryIDsscoping (session-derived, not request-supplied). No cross-tenant author leakage beyond what the existing authors list exposes.REVIEW VERDICT: 0 blocker, 0 major, 2 minor
CODE REVIEW: NOT APPROVED
Phase 0: DEMO Verification
No DEMO block was present in the bead description or comments — this bead relied on the go-rod e2e journey and CI as functional verification. CI is green and the PR is mergeable, so Phase 0 is treated as passing on that basis.
Phase 1: Spec Compliance
Branch
bd-bookshelf-vgbmo.1implements all three stated operations (rename, merge, delete), usesmanageRequiredpermission gate on all three mutation routes, provides a typeahead merge UI (debounced, chips, excludes target), uses canonical.modal-dialog--author-manage/.btn/.metadata-field-*classes. Spec requirements are met.Phase 2: Code Quality
[MAJOR] internal/authors/manage_service.go — MergeAuthors does not validate that the target author exists before remapping source book-author mappings
RenameAuthorandDeleteAuthorboth checkRowsAffected() == 0after their UPDATE/DELETE and returnmiddleware.ErrNotFound(→ 404) when the author does not exist.MergeAuthors/mergeOneSourcehas no equivalent guard. If a caller POSTs{"source_ids":[N]}to/authors/{nonExistentID}/merge:primaryBookIDsfor the source runs fine.UPDATE book_metadata_author_mapping SET author_id = {nonExistentID} WHERE author_id = {sourceID}— with MySQL 8.0+InnoDB and a FK onbook_metadata_author_mapping.author_id → author.idthis fails with error 1452 (FK violation) → transaction rolls back → 500 Internal Server Error instead of 404.The fix: at the start of the transaction, do
SELECT 1 FROM author WHERE id = targetIDand returnErrNotFound(zero rows) before proceeding.MergeHandleralready wraps errors with%wsoerrors.Is(err, middleware.ErrNotFound)would propagate to 404 correctly.[MINOR] static/js/controllers/author_manage_controller.js:openMenu — duplicate
var ctrl = thisdeclarationvar ctrl = thisappears twice in the same function scope (once before theactionsarray, once before_outsideClickHandler). ES5varhoisting makes the second declaration a no-op, so this is not a bug, but it triggers linter redeclaration warnings and is dead code.[MINOR] static/js/controllers/author_manage_controller.js:openMenu — redundant
menu.dataset.anchorTop/anchorLeftassignmentsLines
menu.dataset.anchorTop = ...andmenu.dataset.anchorLeft = ...are set but never read — the CSS usesvar(--menu-top)/var(--menu-left)fromstyle.setProperty(...)only. The dataset values are dead code.[MINOR] e2e/browser/journey_author_manage_test.go (last line) —
_ = time.Secondimport workaroundtimeis imported but only used indirectly via package-level helpers. The_ = time.Secondblank assignment is a workaround to prevent goimports from dropping the import. Either usetimedirectly (e.g. in aTimeoutcall) or remove the import and the blank.[MINOR] static/js/controllers/author_manage_controller.js:_renderMergeSuggestions — no "no results" feedback
When the typeahead fetch returns an empty author list (or all results are filtered out), the suggestion dropdown stays hidden with no user-visible indicator. The user types a name, gets no response, and has no feedback that the search found nothing. A "No authors found"
<li>item would close the UX gap.REVIEW VERDICT: 1 major, 4 minor
[MAJOR] MergeAuthors target-existence guard: SELECT 1 FROM author WHERE id=targetID at top of tx; return ErrNotFound (404 in MergeHandler) when target is absent — prevents FK violations and ghost data. [MINOR-sec] isDuplicateKey: replace strings.Contains("1062") with typed errors.As(*mysql.MySQLError) && Number==1062 to avoid false-positives on error messages that happen to contain "1062". [MINOR-sec] RenameAuthor same-name no-op: 0 rows affected by UPDATE no longer unconditionally returns ErrNotFound; follows up with authorExists to distinguish "author missing" (→ ErrNotFound) from "name unchanged" (→ no-op 204). [MINOR] openMenu: remove duplicate `var ctrl = this` declaration and dead menu.dataset.anchorTop/Left writes (CSS uses --menu-top/--menu-left custom properties, not dataset attributes). [MINOR] _renderMergeSuggestions: show "No authors found" li item instead of hiding dropdown when filtered list is empty. [MINOR] e2e: remove `_ = time.Second` import-preservation hack — time is unused in journey_author_manage_test.go. 100% Go coverage + 3735 JS tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Author management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-typeahead-open)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-chip-selected)
Author rename/merge/delete — kebab menu and modal on /authors page
a120db41be96ef90b8aaAuthor management screenshot (rename-modal)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (rename-submitted)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-typeahead-open)
Author rename/merge/delete — kebab menu and modal on /authors page
Author management screenshot (merge-modal-chip-selected)
Author rename/merge/delete — kebab menu and modal on /authors page