Skip to content

Commit e25d9be

Browse files
nsdereclaude
andauthored
feat(prompts): add GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats endpoint (#2528)
## Summary Adds a new aggregate endpoint that returns prompt counts per brand for the `project-elmo-ui` Prompts Management tab status bar. - `GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats` - Returns `{ branded, unbranded, intents: { informational, instructional, comparative, transactional, planning, delegation } }` - Backed by the `rpc_brand_prompt_stats` PostgREST RPC in `mysticat-data-service` - all classification logic stays in SQL, no write-path changes or backfill required on the API side ## Design note This replaces the originally-spec'd approach of storing a derived `branded` column on `prompts` and reclassifying inside brand-mutation transactions. The RPC computes counts at read time using the current brand name + aliases, which removes the consistency model complexity (transactional reclassify, 250K row-count gate, backfill script) at the cost of slightly slower reads. Partial index on `(organization_id, brand_id, status) WHERE status = 'active'` keeps the query fast. Intent counts read 0 until the DRS to Postgres ingestion workstream forwards `intent` on prompts upsert (separate, prerequisite workstream). ## Test plan - [ ] Unit tests for `getPromptStats` (RPC happy path, error path, missing intents hydration) - [ ] Unit tests for `getPromptStatsByBrand` controller (auth, org not found, brand not found, postgrest unavailable) - [ ] Integration test in `test/it/postgres/` - [ ] Manual smoke against staging: `curl /v2/orgs/<orgId>/brands/<brandId>/prompts/stats` returns flat shape with all 6 intent keys Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6804015 commit e25d9be

7 files changed

Lines changed: 352 additions & 0 deletions

File tree

src/controllers/brands.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
deletePromptById,
3838
bulkDeletePrompts,
3939
checkPromptsExist,
40+
getPromptStats,
4041
resolveBrandUuid,
4142
} from '../support/prompts-storage.js';
4243
import {
@@ -657,6 +658,53 @@ function BrandsController(ctx, log, env) {
657658
}
658659
};
659660

661+
const getPromptStatsByBrand = async (context) => {
662+
const { spaceCatId, brandId } = context.params || {};
663+
664+
try {
665+
if (!hasText(spaceCatId)) {
666+
return badRequest('Organization ID required');
667+
}
668+
if (!isValidUUID(spaceCatId)) {
669+
return badRequest('Organization ID must be a valid UUID');
670+
}
671+
if (!hasText(brandId)) {
672+
return badRequest('Brand ID required');
673+
}
674+
675+
const organization = await getOrganizationOrNotFound(spaceCatId);
676+
if (organization.status) {
677+
return organization;
678+
}
679+
if (!await accessControlUtil.hasAccess(organization)) {
680+
return forbidden('User does not have access to this organization');
681+
}
682+
683+
const unavailable = requirePostgrestForV2Config(context);
684+
if (unavailable) {
685+
return unavailable;
686+
}
687+
688+
const { postgrestClient } = context.dataAccess.services;
689+
690+
const brandUuid = await resolveBrandUuid(spaceCatId, brandId, postgrestClient);
691+
if (!brandUuid) {
692+
return notFound(`Brand not found: ${brandId}`);
693+
}
694+
695+
const stats = await getPromptStats({
696+
organizationId: spaceCatId,
697+
brandUuid,
698+
postgrestClient,
699+
});
700+
701+
return ok(stats);
702+
} catch (error) {
703+
log.error('Error fetching prompt stats', { brandId, error });
704+
return createErrorResponse(error);
705+
}
706+
};
707+
660708
// ── Brand list (v2, reads from normalized tables) ──
661709

662710
const getBrandForOrg = async (context) => {
@@ -1384,6 +1432,7 @@ function BrandsController(ctx, log, env) {
13841432
deleteBrandForOrg,
13851433
listPromptsByBrand,
13861434
getPromptByBrandAndId,
1435+
getPromptStatsByBrand,
13871436
createPromptsByBrand,
13881437
updatePromptByBrandAndId,
13891438
deletePromptByBrandAndId,

src/routes/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export default function getRouteHandlers(
213213
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/tags': serenityController.listTags,
214214
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/models': serenityController.listModels,
215215
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts': brandsController.listPromptsByBrand,
216+
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats': brandsController.getPromptStatsByBrand,
216217
'POST /v2/orgs/:spaceCatId/brands/:brandId/prompts': brandsController.createPromptsByBrand,
217218
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId': brandsController.getPromptByBrandAndId,
218219
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId': brandsController.updatePromptByBrandAndId,

src/routes/required-capabilities.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ const routeRequiredCapabilities = {
257257
'PATCH /v2/orgs/:spaceCatId/brands/:brandId': 'organization:write',
258258
'DELETE /v2/orgs/:spaceCatId/brands/:brandId': 'organization:write',
259259
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts': 'organization:read',
260+
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats': 'organization:read',
260261
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId': 'organization:read',
261262
'POST /v2/orgs/:spaceCatId/brands/:brandId/prompts': 'organization:write',
262263
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId': 'organization:write',

src/support/prompts-storage.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,3 +815,36 @@ export async function checkPromptsExist({ brandUuid, prompts, postgrestClient })
815815

816816
return data ?? [];
817817
}
818+
819+
const INTENT_VALUES = ['informational', 'instructional', 'comparative', 'transactional', 'planning', 'delegation'];
820+
821+
export async function getPromptStats({ organizationId, brandUuid, postgrestClient }) {
822+
if (!postgrestClient?.rpc) {
823+
throw new Error('PostgREST client is required');
824+
}
825+
826+
const { data, error } = await postgrestClient.rpc('rpc_brand_prompt_stats', {
827+
p_organization_id: organizationId,
828+
p_brand_id: brandUuid,
829+
});
830+
831+
if (error) {
832+
throw new Error(`getPromptStats RPC failed: ${error.message}`);
833+
}
834+
835+
const row = Array.isArray(data) && data.length > 0 ? data[0] : (data ?? {});
836+
const intents = Object.fromEntries(INTENT_VALUES.map((k) => [k, 0]));
837+
if (row.intents && typeof row.intents === 'object') {
838+
for (const [k, v] of Object.entries(row.intents)) {
839+
if (INTENT_VALUES.includes(k)) {
840+
intents[k] = Number(v) || 0;
841+
}
842+
}
843+
}
844+
845+
return {
846+
branded: Number(row.branded) || 0,
847+
unbranded: Number(row.unbranded) || 0,
848+
intents,
849+
};
850+
}

test/controllers/brands.test.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,6 +2060,150 @@ describe('Brands Controller', () => {
20602060
});
20612061
});
20622062

2063+
describe('getPromptStatsByBrand', () => {
2064+
const BRAND_UUID = 'd1111111-1111-4111-b111-111111111111';
2065+
const STATS_RESPONSE = {
2066+
branded: 42,
2067+
unbranded: 1208,
2068+
intents: {
2069+
informational: 410,
2070+
instructional: 180,
2071+
comparative: 95,
2072+
transactional: 250,
2073+
planning: 60,
2074+
delegation: 15,
2075+
},
2076+
};
2077+
2078+
beforeEach(() => {
2079+
mockDataAccess.services.postgrestClient = {
2080+
from: sandbox.stub().callsFake((table) => ({
2081+
select: sandbox.stub().returnsThis(),
2082+
eq: sandbox.stub().returnsThis(),
2083+
maybeSingle: sandbox.stub().callsFake(() => {
2084+
if (table === 'brands') {
2085+
return Promise.resolve({ data: { id: BRAND_UUID }, error: null });
2086+
}
2087+
return Promise.resolve({ data: null, error: null });
2088+
}),
2089+
})),
2090+
rpc: sandbox.stub().resolves({ data: STATS_RESPONSE, error: null }),
2091+
};
2092+
brandsController = BrandsController(context, loggerStub, mockEnv);
2093+
});
2094+
2095+
it('returns 400 when spaceCatId is missing', async () => {
2096+
const response = await brandsController.getPromptStatsByBrand({
2097+
...context,
2098+
params: { brandId: BRAND_UUID },
2099+
dataAccess: mockDataAccess,
2100+
});
2101+
expect(response.status).to.equal(400);
2102+
});
2103+
2104+
it('returns 400 when spaceCatId is not a valid UUID', async () => {
2105+
const response = await brandsController.getPromptStatsByBrand({
2106+
...context,
2107+
params: { spaceCatId: 'not-a-uuid', brandId: BRAND_UUID },
2108+
dataAccess: mockDataAccess,
2109+
});
2110+
expect(response.status).to.equal(400);
2111+
});
2112+
2113+
it('returns 400 when brandId is missing', async () => {
2114+
const response = await brandsController.getPromptStatsByBrand({
2115+
...context,
2116+
params: { spaceCatId: ORGANIZATION_ID },
2117+
dataAccess: mockDataAccess,
2118+
});
2119+
expect(response.status).to.equal(400);
2120+
});
2121+
2122+
it('returns 404 when organization is not found', async () => {
2123+
mockDataAccess.Organization.findById = sinon.stub().resolves(null);
2124+
const response = await brandsController.getPromptStatsByBrand({
2125+
...context,
2126+
params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID },
2127+
dataAccess: mockDataAccess,
2128+
});
2129+
expect(response.status).to.equal(404);
2130+
});
2131+
2132+
it('returns 403 when user lacks access to the organization', async () => {
2133+
const authContextUser = {
2134+
attributes: {
2135+
authInfo: new AuthInfo()
2136+
.withType('jwt')
2137+
.withScopes([{ name: 'user' }])
2138+
.withProfile({ is_admin: false })
2139+
.withAuthenticated(true),
2140+
},
2141+
};
2142+
const unauthorizedController = BrandsController({
2143+
dataAccess: mockDataAccess,
2144+
pathInfo: { headers: { 'x-product': 'llmo' } },
2145+
...authContextUser,
2146+
}, loggerStub, mockEnv);
2147+
const response = await unauthorizedController.getPromptStatsByBrand({
2148+
params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID },
2149+
dataAccess: mockDataAccess,
2150+
});
2151+
expect(response.status).to.equal(403);
2152+
});
2153+
2154+
it('returns 503 when postgrestClient is not available', async () => {
2155+
mockDataAccess.services.postgrestClient = null;
2156+
const response = await brandsController.getPromptStatsByBrand({
2157+
...context,
2158+
params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID },
2159+
dataAccess: mockDataAccess,
2160+
});
2161+
expect(response.status).to.equal(503);
2162+
});
2163+
2164+
it('returns 404 when brand is not found', async () => {
2165+
mockDataAccess.services.postgrestClient.from = sandbox.stub().callsFake(() => ({
2166+
select: sandbox.stub().returnsThis(),
2167+
eq: sandbox.stub().returnsThis(),
2168+
maybeSingle: sandbox.stub().resolves({ data: null, error: null }),
2169+
}));
2170+
const response = await brandsController.getPromptStatsByBrand({
2171+
...context,
2172+
params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID },
2173+
dataAccess: mockDataAccess,
2174+
});
2175+
expect(response.status).to.equal(404);
2176+
});
2177+
2178+
it('returns 200 with the flat stats shape on success', async () => {
2179+
const response = await brandsController.getPromptStatsByBrand({
2180+
...context,
2181+
params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID },
2182+
dataAccess: mockDataAccess,
2183+
});
2184+
expect(response.status).to.equal(200);
2185+
const body = await response.json();
2186+
expect(body).to.have.property('branded', 42);
2187+
expect(body).to.have.property('unbranded', 1208);
2188+
expect(body).to.have.property('intents').that.includes({ informational: 410, delegation: 15 });
2189+
});
2190+
2191+
it('returns 500 when storage throws and logs the error', async () => {
2192+
const dbError = new Error('RPC failure');
2193+
mockDataAccess.services.postgrestClient.rpc = sandbox.stub().rejects(dbError);
2194+
const response = await brandsController.getPromptStatsByBrand({
2195+
...context,
2196+
params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID },
2197+
dataAccess: mockDataAccess,
2198+
});
2199+
expect(response.status).to.equal(500);
2200+
expect(loggerStub.error).to.have.been.calledWith('Error fetching prompt stats', {
2201+
brandId: BRAND_UUID,
2202+
error: dbError,
2203+
});
2204+
});
2205+
});
2206+
20632207
describe('listBrandsForOrg', () => {
20642208
beforeEach(() => {
20652209
mockDataAccess.services.postgrestClient = {

test/routes/index.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@ describe('getRouteHandlers', () => {
740740
'PATCH /v2/orgs/:spaceCatId/brands/:brandId',
741741
'DELETE /v2/orgs/:spaceCatId/brands/:brandId',
742742
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts',
743+
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats',
743744
'POST /v2/orgs/:spaceCatId/brands/:brandId/prompts',
744745
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId',
745746
'PATCH /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId',

0 commit comments

Comments
 (0)