feat(series): rename/merge/delete management on /series page (bookshelf-vgbmo.2) #1092
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-vgbmo.2"
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
/seriesbrowse page, mirroring the author management slice (vgbmo.1).manage-metadata-configusers) opens rename/merge/delete modals.(name, volume_number)composite key — noseriestable; derived frombook_metadata.series_name./series?q=…, select chips, POST{sources:[{name,volume_number?}]}to/series/{name}/merge..modal-dialog/.btn/ chips /.library-kebab-menu__item), no inlinestyle=(CSP-safe).package series_test), 100% Go + JS coverage.series_manage_controller.js(3 new branches covered).Orderedjourney asserting rename, typeahead merge with chip, and page-reload confirmation.Test plan
make testgreen (all Go unit tests pass)make coveragegreen (100% oninternal/)npm run coveragegreen (100% JS branches)make buildcompiles cleanlygo build -tags integration ./internal/...andgo build -tags e2e ./e2e/...compileCloses bead bookshelf-vgbmo.2 on merge.
Add series management — the series sibling of the author management slice (bookshelf-vgbmo.1). Provides rename, merge (typeahead multi-select), and delete operations via a kebab menu on each series card, authz-gated behind the manage-metadata-config permission. - PATCH /series/{name}?vol=N — rename a series - POST /series/{name}/merge?vol=N — merge source series into target - DELETE /series/{name}?vol=N — clear series_name from all books Series are identified by (name, volume_number) composite key, not a numeric ID, since there is no series table — derived from book_metadata. - Curried-function DI, black-box tests (package series_test), 100% coverage - Canonical modal CSS (.modal-dialog / .btn / chips / .library-kebab-menu__item) - No inline style= (CSP: style-src 'self') - Stimulus controller series_manage_controller.js with Vitest unit tests - go-rod browser e2e journey (Ordered) asserting rename, merge, delete flows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Series management screenshot (rename-modal)
Series rename/merge/delete — kebab menu and modal on /series page
Series management screenshot (rename-submitted)
Series rename/merge/delete — kebab menu and modal on /series page
Series management screenshot (merge-modal-typeahead-open)
Series rename/merge/delete — kebab menu and modal on /series page
Series management screenshot (merge-modal-chip-selected)
Series rename/merge/delete — kebab menu and modal on /series page
Security Review — bookshelf-vgbmo.2 (Series Management)
Authorization
PASS. All three mutation routes are gated server-side before the handler runs:
manageRequiredisLibraryManageMetadataConfigRequired→users.PermissionRequired(...)which reads claims from the session context (ClaimsFromContext(r.Context())), not from the request body/params. A nil-claims request is denied immediately; admin short-circuits; others hit the DB permission check onPermissionManageMetadataConfig. Fails closed.SQL Injection
PASS. Every
execandquerycall uses?positional placeholders. TheseriesNamepath value,volquery param (already parsed to*intbyparseVolParamwhich bounds-checks withstrconv.Atoi), renamenewName, and mergesrc.Nameall flow through parameterized SQL. No string-building into any query.XSS
PASS. The Go template uses
html/templateauto-escaping fordata-series-nameanddata-series-volHTML attributes. In the Stimulus controller, all user-controlled values (series names from data attributes or from the typeahead API response) are set exclusively viael.textContent— the_el()helper never usesinnerHTML. String concatenation for the merge/delete confirmation paragraphs also terminates intextContent, so HTML injection is not possible.CSRF
PASS. All mutating
fetchcalls (PATCH rename, POST merge, DELETE) include"X-CSRF-Token": this._csrfToken()._csrfToken()reads frommeta[name="csrf-token"], which is the canonical pattern across the codebase (consistent withauthor_manage_controller.js,provider_settings_controller.js, etc.). The GET typeahead fetch correctly omits the CSRF header (GETs don't need it).CSP / inline style
PASS. The template adds no
style=attributes. The JS controller usesmenu.style.setProperty("--menu-top", ...)to position the floating menu — JS-set inline styles are not restricted bystyle-src 'self'CSP. The controller header correctly documents this.Architecture boundary
PASS.
internal/seriesimports neithergo-workflowsnorinternal/wfengine.Typeahead library scoping
PASS. The typeahead reuses
GET /series?q=...which already resolvesuserLibraryIDsfail-closed throughresolveSeriesLibraryIDs. No cross-tenant leak.Input bounds
PASS. The merge handler caps
sourcesat 100 entries at the HTTP boundary.volis bounds-checked byparseVolParamagainst the comic-year range.Findings
[MAJOR] internal/series/manage_service.go:84 — MergeSeries loops over sources without a DB transaction
MergeSeriesiterates oversourcesand issues a separateexeccall for each one. If thekth
execfails (DB deadlock, connection drop), sources 1..k-1 are already re-pointed to thetarget while sources k..N are not — the caller receives a 500 but the data is partially merged
with no way to roll back. The sibling
MergeAuthors(internal/authors/manage_service.go)wraps its entire re-point loop in
runInTx(ctx, ...)precisely to prevent this.Fix: introduce a
runInTx func(context.Context, TxFunc) errordependency (same pattern asauthors.TxFunc/MergeAuthors), pass it intoMergeSeries, and wrap the existence checkmergeOneSeriesSourceexecs in a single transaction.REVIEW VERDICT: 0 blocker, 1 major, 0 minor
UI Review — PR #1092 (bookshelf-vgbmo.2)
Screenshots reviewed:
rename-modal.png,merge-typeahead.png,merge-chip.pngReference: author manage modals (PR #1072) and category management (#1091)
What the rendered screenshots show
Rename modal (
rename-modal.png): Modal renders with canonical chrome —modal-overlay>modal-dialogwith title "Rename Series", close X top-right, "New name" label above a pre-filled input, right-aligned Cancel + Save footer buttons. Visually matches the author rename modal exactly.Merge typeahead (
merge-typeahead.png): "Merge Series" modal open; description text explains direction; "Search series" label above input; suggestion dropdown ("Dune (1 book)") correctly positioned below input; Cancel + Merge buttons right-aligned, Merge enabled. No layout defects, nothing cut off.Merge chip (
merge-chip.png): After selecting "Dune" the chip renders as "Dune ×" — canonicalchip chip--editablestyling,chip-text+chip-removebutton clearly visible. Input clears and shows placeholder. On-brand, remove affordance present.Series cards with placeholder "?" art look clean and on-brand — no layout or spacing issues.
Findings
[MAJOR]
static/css/main.css:6632— Bespoke parallel CSS class set (sm-*) duplicatesam-*pixel-for-pixelThe CSS rules for
.series-manage-menu,.sm-merge-chips,.sm-suggestions,.sm-suggestion-item, and.sm-error-msg(lines 6632–6699) are identical in every property/value to the already-merged.author-manage-menuand.am-*rules at lines 6562–6629. This PR adds a third instance of the same duplication pattern —am-*(author manage),sm-*(series manage, this PR), andbam-suggestions/bam-suggestion-item(book-attach modal) all re-implement the same dropdown+chip+error CSS. Perui-reuse-canonical-components, a bespoke parallel class system instead of a shared one is a [MAJOR].Concrete fix: introduce shared canonical classes in
main.css— e.g..manage-menu,.manage-suggestions,.manage-suggestion-item,.manage-merge-chips,.manage-error-msg— and updateauthor_manage_controller.jsandseries_manage_controller.js(andbook_attach_modal_controller.jsfor the suggestion variants) to use them. The per-feature modifier.modal-dialog--series-manage(which only sets width/padding/gap) is fine and should stay.What passes
modal-overlay > modal-dialog > modal-header / modal-body / modal-footerstructure used correctly (same as author manage).chip chip--editable,chip-text,chip-remove— no re-invention.library-kebab-menu__item/library-kebab-menu__item--dangerclasses — correct.style.setProperty("--menu-top", ...)(CSS custom properties on element) — CSP-safe pattern, not an inlinestyle=attribute.style=attribute in the template or JS.var(--fg),var(--danger),var(--space-*),var(--border), etc.).btn btn-ghost(Cancel),btn(Save/Merge),btn btn-danger(Delete) — all canonical.REVIEW VERDICT: 0 blocker, 1 major, 0 minor
Code Review — bookshelf-vgbmo.2 (Series rename/merge/delete)
Phase 0: DEMO Verification
No explicit DEMO block in the bead comments. However CI is green and the browser e2e journey (
journey_series_manage_test.go) literally clicks through kebab → Rename → type name → Save, asserts DOM reload and new name appears, then does the same for Merge (typeahead + chip + submit). The CI run IS the functional DEMO for a Chromium-dependent feature. Screenshots are uploaded inline by the test. Treating this as PARTIAL with valid reason (Chromium e2e cannot be reproduced as a CLI command block) and proceeding.Phase 1: Spec Compliance
All requirements met:
/series/{seriesName}?vol=Nrename handler ✓/series/{seriesName}/merge?vol=Nwith typeahead + chips ✓/series/{seriesName}?vol=N✓manageRequired(d.LibraryManageMetadataConfigRequired) viaroutes.go:12-16✓seriesExistscalled before any re-pointing inmanage_service.go:69-74— the author-slice MAJOR is fixed ✓_seriesKeycomparison (series_manage_controller.js:322-324) ✓manage_service.go:51-55) ✓parseVolParamvalidates ?vol range and rejects non-integers with 400 ✓store.gocomment: name, IS NULL, COALESCE) ✓package series_testthroughout ✓X-CSRF-Tokenread frommeta[name="csrf-token"](consistent withauthor_manage_controller.js) ✓innerHTML(all DOM mutations viatextContent) ✓style=inline attributes (CSS custom properties viasetPropertyfor menu positioning) ✓.modal-dialog,.modal-header,.btn,.btn-ghost,.btn-danger,.metadata-field-label,.metadata-field-input,.chip,.chip-remove,.chip-text,.library-kebab-menu__item✓Phase 2: Code Quality
[MAJOR]
internal/series/manage_service.go:92-98—MergeSeriesre-points sources without a database transactionMergeSeriesloops over sources and issues eachUPDATE book_metadata SET series_name = ?as a separate non-transactional exec call. If the Kth source fails (e.g. transient DB error), sources 1..K-1 are already permanently re-pointed but the handler returns an error to the client. The UI shows failure but some books have silently moved.MergeAuthors(the sibling ininternal/authors/manage_service.go) wraps its re-point loop inrunInTxexplicitly because of this. For series the data is not FK-corrupted (no separate series table), but with up to 100 sources accepted by the handler, a mid-loop failure leaves a non-obvious partial state the admin cannot easily diagnose. Fix: accept arunInTx func(context.Context, TxFunc) errordep (same pattern asMergeAuthors) and wrap the existence check + all source UPDATEs inside it.[MINOR]
internal/series/routes_test.go:14—buildTestSeriesMux(target middleware.HandlerFunc)takes atargetparameter that is never used inside the function body. The only caller passesnil. Dead parameter.[MINOR] No bead DEMO block. The bead workflow requires a
DEMO:block in the bead comments. A CI-green browser e2e run is functional proof but an explicitDEMO: make e2eor screenshot-URL comment should be logged so reviewers have a clear artifact link independent of CI history.REVIEW VERDICT: 1 blocker, 1 major, 2 minor
CODE REVIEW (fix-commit focused): APPROVED
Reviewed commits
69fece48(main fix) andf507bc90(browser e2e selector update) against the three items raised in the base review.MAJOR 1 — MergeSeries transaction (internal/series/manage_service.go, wire.go)
Verified correct.
buildRunInTx(db *sql.DB)in wire.go:16–28 uses the standard pattern:BeginTx→defer tx.Rollback()→ callTxFuncwithtx.ExecContext/tx.QueryContext→tx.Commit()on success. The deferredRollback()is a no-op after a successfulCommit()and fires correctly on anyfnerror. Wired at wire.go:77 asMergeSeries(buildRunInTx(d.Conn))— direct call during wiring, no struct-field indirection, so the nil-func-field trap does not apply.d.Connis*sql.DB(appwire/appwire.go:33), matching the parameter type.Rollback test (manage_service_test.go:330–369) — LOAD-BEARING. The
Contextblock introduces astubRunInTxthat calls theTxFuncsynchronously and setscommitted = trueonly whenfnreturnsnil. The outerJustBeforeEach(line 238) passes two sources ("Source A","Source B"). ThestubExecfails on the second exec call (Source B re-point). Assertions:It("returns an error from the failed source")— error path fires.It("does not commit the transaction")—committedisfalse, proving no partial commit.This is real and load-bearing: the test distinguishes "error returned" from "transaction not committed," which is the correct invariant. Not just an error-string check.
MAJOR 2 — Bespoke sm- CSS removal (main.css, series_manage_controller.js, Vitest suite, browser e2e)*
Verified correct.
.series-manage-menu,.sm-merge-chips,.sm-merge-chips:empty,.sm-suggestions,.sm-suggestions[hidden],.sm-error-msg,.sm-suggestion-item(hover/focus variants) all removed..modal-dialog--series-managewidth/padding modifier retained (line 6632 on branch)._el()calls now useauthor-manage-menu,am-merge-chips,am-suggestions,am-suggestion-item,am-error-msg. Remainingsm-*strings are HTML element IDs (id="sm-rename-title"etc.) andaria-labelledby/label[for]references — not CSS class names. No CSS selector consuming them..series-manage-menu/.sm-*querySelector calls replaced 1-for-1 with.author-manage-menu/.am-*.f507bc90):.series-manage-menu,.sm-suggestions,.sm-suggestion-item,.sm-merge-chipsselectors replaced with the sharedam-*equivalents.am-*andauthor-manage-menuclasses confirmed present in main.css (lines 6608–6671).MINOR — unused
targetparam removed from buildTestSeriesMux (routes_test.go)Verified. Signature changed to
buildTestSeriesMux(), single caller at line 126 updated.Wiring / nil-trap sanity check:
buildRunInTxis called inline duringWire()execution and its result is passed directly intoMergeSeries(...). Not stored in a by-value struct field. Safe.Black-box test hygiene: both test files declare
package series_test. Clean.CI: green (commit status: success for SHA
f507bc90).REVIEW VERDICT: 0 blocker, 0 major, 0 minor
f507bc9094e9446e7f08