feat(brands): add explicit brand status-transition endpoint (LLMO-5587)#2621
feat(brands): add explicit brand status-transition endpoint (LLMO-5587)#2621aliciadriani wants to merge 6 commits into
Conversation
PR1 of the staged active->pending demotion fix. Adds an additive, intentful status-transition path so legitimate demotions have a sanctioned route before the generic PATCH is locked down in PR3. - PATCH /v2/orgs/:spaceCatId/brands/:brandId/status -> transitionBrandStatusForOrg - setBrandStatus storage fn (minimal: status + updated_by, no child-table sync) - 23514 chk_active_brand_has_site_id -> 400 mapping lifted from #2504 (Igor Grubic) - OpenAPI spec + capability + tests (5 storage, 10 controller) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
This PR will trigger a minor release when merged. |
aliciadriani
left a comment
There was a problem hiding this comment.
Review — LLMO-5587 brand status-transition endpoint (#2621)
Summary: Adds an additive, intentful PATCH /v2/orgs/{spaceCatId}/brands/{brandId}/status endpoint backed by a minimal setBrandStatus (status + updated_by only, no child sync). This is the sanctioned demotion path the #2637 guard routes callers to. Clean and purely additive; full suite green. One real gap inline.
Sequencing
This PR should merge first — #2637's 409 message references this endpoint as the demotion path. This PR is independently functional on its own.
Should Fix
deleted→active/pendingreactivation isn't guarded or tested (inline on brands-storage.js / controller).
What's Good
- Right design: a focused, intentful status setter kept separate from the guarded generic
updateBrand, so the demotion guard can't be bypassed accidentally. - 23514
chk_active_brand_has_site_id→ 400 mapping lifted from #2504 and credited to Igor. - Mirrors sibling brand handlers exactly (org/access/postgrest/resolve guards); OpenAPI + capability wired; good test coverage.
|
|
||
| const { data, error } = await postgrestClient | ||
| .from('brands') | ||
| .update({ status, updated_by: updatedBy }) |
There was a problem hiding this comment.
Should Fix: this update isn't scoped against soft-deleted brands. If resolveBrandUuid resolves a status='deleted' brand, this endpoint will silently reactivate it. Either intend that (document it) or block it — simplest is to add .neq('status', 'deleted') to the filter so a deleted brand matches no row and the controller returns 404. Add a test for the deleted-brand case.
| if (!hasText(brandId)) { | ||
| return badRequest('Brand ID required'); | ||
| } | ||
| if (status !== 'active' && status !== 'pending') { |
There was a problem hiding this comment.
Note: status is validated to {active, pending} but the brand's current status isn't checked — see the storage comment about not resurrecting deleted brands. Worth a test asserting a transition on a soft-deleted brand returns 404.
… brand (PR #2621 review) setBrandStatus now filters the update with .neq('status','deleted'), so a soft-deleted brand matches no row and the transition endpoint returns 404 instead of silently reactivating it. Adds storage + controller tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aliciadriani
left a comment
There was a problem hiding this comment.
Review fix applied — b55974a0
Addressed the Should-Fix (deleted-brand reactivation). Full suite green (11818 passing); ESLint clean.
setBrandStatusnow filters the update with.neq('status','deleted'), so a soft-deleted brand matches no row and the endpoint returns 404 rather than silently reactivating it.- Added tests: storage (
does not resurrect a soft-deleted brand → null) and controller (transitionBrandStatusForOrg returns 404 when the brand is soft-deleted).
Sequencing reminder: merge this PR before #2637 (its 409 message points here).
| // Do not resurrect a soft-deleted brand via a status transition — a deleted | ||
| // brand matches no row here, so the caller gets a 404 (use a dedicated | ||
| // undelete flow if reactivation is ever needed). | ||
| .neq('status', 'deleted') |
There was a problem hiding this comment.
✅ Fixed (b55974a0): .neq('status','deleted') excludes soft-deleted brands from the status update — they match no row, so setBrandStatus returns null and the controller responds 404 (no accidental undelete). Covered by new storage + controller tests.
…ForOrg (LLMO-5587) codecov flagged 2 uncovered diff lines (brands.js: the !hasText(spaceCatId) -> 'Organization ID required' branch). PR1 tested the invalid-UUID case but not a missing org id. Add that test. Test-only; no source change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why
Active brands can be silently demoted to
pendingby the per-brand write primitives (updateBrand/upsertBrand) — no error to the editor, no alert. A demoted brand stops binding in the scheduler (the resolver requiresstatus == active) and its site goes dark. This took express.adobe.com offline on 2026-06-05 (brand40f16625, org5d4e5082); same class as the MongoDB and Merck/MSD incidents documented in #2593. Incident: LLMO-5587.What — PR1 of a staged rollout (additive)
Expand → migrate → contract. This is the additive expand step: a dedicated, intentful status-transition endpoint so legitimate demotions have a sanctioned path before the generic
PATCHis locked down.PATCH /v2/orgs/{spaceCatId}/brands/{brandId}/status→transitionBrandStatusForOrg(capabilityorganization:write)setBrandStatusstorage fn — minimal (status +updated_byonly, no child-table sync), deliberately separate fromupdateBrandso it carries no demotion guard and the generic path can.{ "status": "active" | "pending" };400invalid status / activate-without-site,403/404/503mirror the sibling brand handlers.active ⇒ site_id) stays at the data layer viachk_active_brand_has_site_id.Credit — lifted from #2504 (@igor-grubic)
The
23514/chk_active_brand_has_site_id→400mapping is lifted verbatim from @igor-grubic's #2504 (closed in favor of this staged work). PR3 will additionally adapt #2504'sneedsExistingFetchexisting-row fetch (broadened toselect('status')) for the demotion comparison — credit carried forward there too. Pattern parallel: #2593 (@byteclimber) anchors immutability in the samebrands-storage.jsblock.Staged rollout / dependency chain
moveToPending(andapprove) onto this endpoint. Must deploy before PR3.409active→pending guard on the genericPATCH /brands/:brandId(+upsertBrandby-name collision), re-land fix(brands): guard updateBrand against active-without-site_id (LLMO-5183) #2504's guards, add aBrandDemotionBlockedmetric. Must NOT deploy before PR2 is live, or it breaks elmo-ui's still-genericmoveToPending.Related
baseSiteIdimmutable (merged); same primitive / enforcement patternuq_brand_name_per_org(open)Testing
setBrandStatus: 5 unit tests ·transitionBrandStatusForOrg: 10 controller testsdocs:lint(OpenAPI) valid🤖 Generated with Claude Code