Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ paths:
$ref: './brands-v2-api.yaml#/v2-brands-for-org'
/v2/orgs/{spaceCatId}/brands/{brandId}:
$ref: './brands-v2-api.yaml#/v2-brand-for-org'
/v2/orgs/{spaceCatId}/brands/{brandId}/status:
$ref: './brands-v2-api.yaml#/v2-brand-status-for-org'
/v2/orgs/{spaceCatId}/sites/{siteId}/brand:
$ref: './brands-v2-api.yaml#/v2-brand-for-org-site'
/v2/orgs/{spaceCatId}/categories:
Expand Down
64 changes: 64 additions & 0 deletions docs/openapi/brands-v2-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,70 @@ v2-brand-for-org:
'503':
description: PostgREST unavailable (DATA_SERVICE_PROVIDER=postgres required)

v2-brand-status-for-org:
parameters:
- name: spaceCatId
in: path
required: true
description: SpaceCat Organization ID (UUID)
schema:
type: string
format: uuid
- name: brandId
in: path
required: true
description: Brand ID (UUID)
schema:
type: string
format: uuid
patch:
tags:
- brands
- customer-config
summary: Transition a brand's status (v2)
description: |
Explicitly transitions a brand's lifecycle status (approve -> `active`,
move-to-pending -> `pending`). This is the sanctioned path for an
active -> pending demotion. Once the companion demotion guard ships
(LLMO-5587 PR3), the generic
`PATCH /v2/orgs/{spaceCatId}/brands/{brandId}` will refuse that transition.
operationId: transitionBrandStatusForOrgV2
security:
- ims_key: []
- api_key: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- status
properties:
status:
type: string
enum:
- active
- pending
description: Target brand status.
responses:
'200':
description: Brand status updated successfully
content:
application/json:
schema:
$ref: './schemas.yaml#/V2Brand'
'400':
$ref: './responses.yaml#/400'
'403':
description: User does not have access to this organization
'404':
description: Organization or brand not found
'500':
$ref: './responses.yaml#/500'
'503':
description: PostgREST unavailable (DATA_SERVICE_PROVIDER=postgres required)

v2-brand-for-org-site:
parameters:
- name: spaceCatId
Expand Down
62 changes: 62 additions & 0 deletions src/controllers/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
upsertBrand,
updateBrand,
deleteBrand,
setBrandStatus,
getBrandById,
getBrandBySite,
getBrandCompetitors,
Expand Down Expand Up @@ -1969,6 +1970,66 @@ function BrandsController(ctx, log, env) {
}
};

// Explicit, intentful brand status transition (approve -> active, move-to-pending ->
// pending). This is the sanctioned path for an active->pending demotion: the generic
// PATCH /brands/:brandId refuses that transition (LLMO-5587), routing intent here.
const transitionBrandStatusForOrg = async (context) => {
const { spaceCatId, brandId } = context.params || {};
const { status } = context.data || {};

try {
if (!hasText(spaceCatId)) {
return badRequest('Organization ID required');
}
if (!isValidUUID(spaceCatId)) {
return badRequest('Organization ID must be a valid UUID');
}
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.

return badRequest("status must be one of 'active' or 'pending'");
}

const organization = await getOrganizationOrNotFound(spaceCatId);
if (organization.status) {
return organization;
}
if (!await accessControlUtil.hasAccess(organization)) {
return forbidden('User does not have access to this organization');
}

const unavailable = requirePostgrestForV2Config(context);
if (unavailable) {
return unavailable;
}

const { postgrestClient } = context.dataAccess.services;
const updatedBy = context.attributes?.authInfo?.profile?.email || 'system';

const brandUuid = await resolveBrandUuid(spaceCatId, brandId, postgrestClient);
if (!brandUuid) {
return notFound(`Brand not found: ${brandId}`);
}

const updated = await setBrandStatus({
organizationId: spaceCatId,
brandId: brandUuid,
status,
postgrestClient,
updatedBy,
});

if (!updated) {
return notFound(`Brand not found: ${brandId}`);
}
return ok(updated);
} catch (error) {
log.error(`Error transitioning status for brand ${brandId} in organization ${spaceCatId}:`, error);
return createErrorResponse(error);
}
};

return {
getBrandsForOrganization,
getBrandGuidelinesForSite,
Expand All @@ -1986,6 +2047,7 @@ function BrandsController(ctx, log, env) {
createBrandForOrg,
updateBrandForOrg,
deleteBrandForOrg,
transitionBrandStatusForOrg,
listPromptsByBrand,
getPromptByBrandAndId,
getPromptStatsByBrand,
Expand Down
1 change: 1 addition & 0 deletions src/routes/facs-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ const routeFacsCapabilities = {
'PATCH /tools/import/jobs/:jobId': 'llmo/can_configure',
'PATCH /trial-users/email-preferences': 'llmo/can_configure',
'PATCH /v2/orgs/:spaceCatId/brands/:brandId': 'llmo/can_configure',
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status': 'llmo/can_configure',
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId': 'llmo/can_configure',
'PATCH /v2/orgs/:spaceCatId/categories/:categoryId': 'llmo/can_configure',
'PATCH /v2/orgs/:spaceCatId/topics/:topicId': 'llmo/can_configure',
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export default function getRouteHandlers(
'DELETE /v2/orgs/:spaceCatId/topics/:topicId': brandsController.deleteTopicForOrg,
'POST /v2/orgs/:spaceCatId/brands': brandsController.createBrandForOrg,
'PATCH /v2/orgs/:spaceCatId/brands/:brandId': brandsController.updateBrandForOrg,
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status': brandsController.transitionBrandStatusForOrg,
'DELETE /v2/orgs/:spaceCatId/brands/:brandId': brandsController.deleteBrandForOrg,
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts': serenityController.listPrompts,
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts': serenityController.createPrompts,
Expand Down
1 change: 1 addition & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ const routeRequiredCapabilities = {
'DELETE /v2/orgs/:spaceCatId/topics/:topicId': 'organization:write',
'POST /v2/orgs/:spaceCatId/brands': 'organization:write',
'PATCH /v2/orgs/:spaceCatId/brands/:brandId': 'organization:write',
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status': 'organization:write',
'DELETE /v2/orgs/:spaceCatId/brands/:brandId': 'organization:write',
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts': 'organization:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats': 'organization:read',
Expand Down
58 changes: 58 additions & 0 deletions src/support/brands-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,64 @@ export async function deleteBrand(organizationId, brandId, postgrestClient, upda
return !!data;
}

/**
* Explicitly sets a brand's lifecycle status (the intentful status-transition path,
* e.g. approve -> active, move-to-pending -> pending).
*
* This is deliberately kept separate from updateBrand and minimal (status + updated_by
* only, no child-table sync). The generic updateBrand path carries the active->pending
* demotion guard (LLMO-5587); legitimate, intended transitions route through here so they
* are not blocked by that guard.
*
* @param {object} params
* @param {string} params.organizationId - SpaceCat organization UUID
* @param {string} params.brandId - Brand UUID
* @param {string} params.status - Target status ('active' | 'pending')
* @param {object} params.postgrestClient - PostgREST client
* @param {string} [params.updatedBy] - User performing the operation
* @returns {Promise<object|null>} Updated brand in V2 shape, or null if not found
*/
export async function setBrandStatus({
organizationId,
brandId,
status,
postgrestClient,
updatedBy = 'system',
}) {
if (!postgrestClient?.from) {
throw new Error('PostgREST client is required');
}

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.

.eq('organization_id', organizationId)
.eq('id', brandId)
// 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.

.select('id')
.maybeSingle();

if (error) {
// Lifted from Igor Grubic's PR #2504 (LLMO-5183): the data layer enforces
// chk_active_brand_has_site_id (an active brand must have a base site_id). Map the
// CheckViolation to a typed 400 rather than surfacing a generic 500.
if (error.code === '23514' && error.message?.includes('chk_active_brand_has_site_id')) {
const err = new Error('Cannot activate a brand without a base site URL');
err.status = 400;
throw err;
}
throw new Error(`Failed to set brand status: ${error.message}`);
}

if (!data) {
return null;
}
return getBrandById(organizationId, brandId, postgrestClient);
}

/**
* Lists all regions (available markets) from the regions reference table.
*
Expand Down
Loading
Loading