Skip to content

feat(brands): add explicit brand status-transition endpoint (LLMO-5587)#2621

Open
aliciadriani wants to merge 6 commits into
mainfrom
fix/LLMO-5587-brand-status-transition-endpoint
Open

feat(brands): add explicit brand status-transition endpoint (LLMO-5587)#2621
aliciadriani wants to merge 6 commits into
mainfrom
fix/LLMO-5587-brand-status-transition-endpoint

Conversation

@aliciadriani

Copy link
Copy Markdown
Collaborator

Why

Active brands can be silently demoted to pending by the per-brand write primitives (updateBrand / upsertBrand) — no error to the editor, no alert. A demoted brand stops binding in the scheduler (the resolver requires status == active) and its site goes dark. This took express.adobe.com offline on 2026-06-05 (brand 40f16625, org 5d4e5082); 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 PATCH is locked down.

  • PATCH /v2/orgs/{spaceCatId}/brands/{brandId}/statustransitionBrandStatusForOrg (capability organization:write)
  • New setBrandStatus storage fn — minimal (status + updated_by only, no child-table sync), deliberately separate from updateBrand so it carries no demotion guard and the generic path can.
  • Body { "status": "active" | "pending" }; 400 invalid status / activate-without-site, 403/404/503 mirror the sibling brand handlers.
  • No DB migration. The row invariant (active ⇒ site_id) stays at the data layer via chk_active_brand_has_site_id.

Credit — lifted from #2504 (@igor-grubic)

The 23514 / chk_active_brand_has_site_id400 mapping is lifted verbatim from @igor-grubic's #2504 (closed in favor of this staged work). PR3 will additionally adapt #2504's needsExistingFetch existing-row fetch (broadened to select('status')) for the demotion comparison — credit carried forward there too. Pattern parallel: #2593 (@byteclimber) anchors immutability in the same brands-storage.js block.

Staged rollout / dependency chain

  1. PR1 (this PR) — additive transition endpoint. Safe to merge/deploy independently.
  2. PR2 (elmo-ui) — migrate moveToPending (and approve) onto this endpoint. Must deploy before PR3.
  3. PR3 (api-service) — enable the 409 active→pending guard on the generic PATCH /brands/:brandId (+ upsertBrand by-name collision), re-land fix(brands): guard updateBrand against active-without-site_id (LLMO-5183) #2504's guards, add a BrandDemotionBlocked metric. Must NOT deploy before PR2 is live, or it breaks elmo-ui's still-generic moveToPending.

Related

Testing

  • setBrandStatus: 5 unit tests · transitionBrandStatusForOrg: 10 controller tests
  • Full suite green (11774 passing) · ESLint clean · docs:lint (OpenAPI) valid

🤖 Generated with Claude Code

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

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

  • deletedactive/pending reactivation 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 })

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread src/controllers/brands.js
if (!hasText(brandId)) {
return badRequest('Brand ID required');
}
if (status !== 'active' && status !== 'pending') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@aliciadriani aliciadriani self-assigned this Jun 19, 2026
… 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 aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review fix applied — b55974a0

Addressed the Should-Fix (deleted-brand reactivation). Full suite green (11818 passing); ESLint clean.

  • setBrandStatus now 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')

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

✅ 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.

@aliciadriani aliciadriani removed the request for review from byteclimber June 19, 2026 10:54
@aliciadriani aliciadriani requested a review from MysticatBot June 19, 2026 11:40
@aliciadriani aliciadriani marked this pull request as ready for review June 19, 2026 14:25
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants