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
22 changes: 16 additions & 6 deletions src/llmo-customer-analysis/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,33 +321,43 @@ export async function runLlmoCustomerAnalysis(finalUrl, context, site, auditCont

const triggeredSteps = [];
const hasOptelData = await checkOptelData(domain, context);
const { configVersion, previousConfigVersion } = auditContext;
const { configVersion, previousConfigVersion, onboardingMode } = auditContext;
const isFirstTimeOnboarding = !previousConfigVersion;

// For brandalf-enabled orgs, resolve brand ID so the DRS scheduler can use v2 prompts
// For brandalf-enabled orgs, resolve brand ID so the DRS scheduler can use v2 prompts.
// If onboardingMode is explicitly 'v1' (set by api-service for mixed-state orgs with
// pre-Brandalf sites), skip v2 brand resolution — the org has brandalf=true but was
// onboarded via the v1 path, so no customer config brand exists yet.
let brandId;
let organizationId;
if (isFirstTimeOnboarding) {
const orgId = site.getOrganizationId?.() || auditContext.imsOrgId;
if (orgId) {
const isV2 = await isBrandalfEnabled(orgId, env, log);
const isV2 = onboardingMode !== 'v1' && await isBrandalfEnabled(orgId, env, log);
if (isV2) {
organizationId = orgId;
try {
const { postgrestClient } = context.dataAccess?.services || {};
if (postgrestClient?.from) {
// Prefer the brand whose baseSiteId (site_id column) matches this
// site — this is the primary brand created during onboarding with
// the correct base URL. Fall back to brand_sites join if no
// baseSiteId match exists (backward compat for brands created
// before baseSiteId was set during onboarding).
const { data: brands } = await postgrestClient
.from('brands')
.select('id, brand_sites(site_id)')
.select('id, site_id, brand_sites(site_id)')
.eq('organization_id', organizationId)
.eq('status', 'active');

const match = brands?.find(
const baseSiteMatch = brands?.find((b) => b.site_id === siteId);
const brandSiteMatch = !baseSiteMatch && brands?.find(
(b) => b.brand_sites?.some((bs) => bs.site_id === siteId),
);
const match = baseSiteMatch || brandSiteMatch;
if (match) {
brandId = match.id;
log.info(`Resolved brand ${brandId} for site ${siteId} (v2 onboarding)`);
log.info(`Resolved brand ${brandId} for site ${siteId} (v2 onboarding, via ${baseSiteMatch ? 'baseSiteId' : 'brand_sites'})`);
} else {
log.warn(`No brand found matching site ${siteId} in org ${organizationId} for v2 BP schedule`);
}
Expand Down
116 changes: 112 additions & 4 deletions test/audits/llmo-customer-analysis.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1587,7 +1587,7 @@ describe('LLMO Customer Analysis Handler', () => {
select: sandbox.stub().returns({
eq: sandbox.stub().returns({
eq: sandbox.stub().resolves({
data: [{ id: 'brand-uuid-1', brand_sites: [{ site_id: 'site-123' }] }],
data: [{ id: 'brand-uuid-1', site_id: 'site-123', brand_sites: [{ site_id: 'site-123' }] }],
}),
}),
}),
Expand Down Expand Up @@ -1652,7 +1652,7 @@ describe('LLMO Customer Analysis Handler', () => {
select: sandbox.stub().returns({
eq: sandbox.stub().returns({
eq: sandbox.stub().resolves({
data: [{ id: 'brand-fb', brand_sites: [{ site_id: 'site-123' }] }],
data: [{ id: 'brand-fb', site_id: null, brand_sites: [{ site_id: 'site-123' }] }],
}),
}),
}),
Expand Down Expand Up @@ -1694,7 +1694,7 @@ describe('LLMO Customer Analysis Handler', () => {
select: sandbox.stub().returns({
eq: sandbox.stub().returns({
eq: sandbox.stub().resolves({
data: [{ id: 'brand-no-sites' }],
data: [{ id: 'brand-no-sites', site_id: null }],
}),
}),
}),
Expand Down Expand Up @@ -1734,7 +1734,7 @@ describe('LLMO Customer Analysis Handler', () => {
select: sandbox.stub().returns({
eq: sandbox.stub().returns({
eq: sandbox.stub().resolves({
data: [{ id: 'brand-other', brand_sites: [{ site_id: 'other-site' }] }],
data: [{ id: 'brand-other', site_id: 'other-site', brand_sites: [{ site_id: 'other-site' }] }],
}),
}),
}),
Expand All @@ -1757,6 +1757,63 @@ describe('LLMO Customer Analysis Handler', () => {
expect(log.warn).to.have.been.calledWith(sinon.match(/No brand found matching site/));
});

it('should prefer baseSiteId match over brand_sites match', async () => {
const auditContext = {};

context.env.SPACECAT_API_BASE_URL = 'https://spacecat.example.com';
context.env.SPACECAT_API_KEY = 'test-api-key';

mockFetch.reset();
mockFetch.onFirstCall().resolves({
ok: true,
json: async () => [{ flagName: 'brandalf', flagValue: true }],
});
mockFetch.onSecondCall().resolves({
ok: true,
json: async () => ({ schedule_id: 'sched-001' }),
});
mockFetch.onThirdCall().resolves({
ok: true,
json: async () => ({}),
});

// Two brands: one with baseSiteId (site_id) match, one with only brand_sites match.
// The baseSiteId match should be preferred (it has the correct base URL).
const brandsQuery = {
select: sandbox.stub().returns({
eq: sandbox.stub().returns({
eq: sandbox.stub().resolves({
data: [
{ id: 'brand-sub', site_id: null, brand_sites: [{ site_id: 'site-123' }] },
{ id: 'brand-base', site_id: 'site-123', brand_sites: [{ site_id: 'site-123' }] },
],
}),
}),
}),
};
context.dataAccess.services = {
postgrestClient: { from: sandbox.stub().returns(brandsQuery) },
};

mockLlmoConfig.readConfig.resolves({
config: {
entities: {},
categories: {},
topics: {},
brands: { aliases: [] },
competitors: { competitors: [] },
},
});

await mockHandler.runLlmoCustomerAnalysis('https://example.com', context, site, auditContext);

// Should pick brand-base (baseSiteId match) over brand-sub (brand_sites only)
const createCall = mockFetch.getCalls().find((c) => c.args[0] === 'https://drs.example.com/api/schedules');
expect(createCall).to.exist;
const body = JSON.parse(createCall.args[1].body);
expect(body.brand_id).to.equal('brand-base');
});

it('should create BP schedule without brand_id when brand lookup fails', async () => {
const auditContext = {};

Expand Down Expand Up @@ -1987,6 +2044,57 @@ describe('LLMO Customer Analysis Handler', () => {
expect(body.brand_id).to.be.undefined;
});

it('should skip brandalf check and omit brand_id when onboardingMode is v1 (mixed-state org)', async () => {
// Mixed-state: org has brandalf=true but was onboarded via v1 path because it has
// pre-Brandalf sites. onboardingMode='v1' in auditContext must bypass isBrandalfEnabled
// so we don't send brand_id to DRS (no customer config brand exists for v1 onboarding).
const auditContext = { onboardingMode: 'v1' };

context.env.SPACECAT_API_BASE_URL = 'https://spacecat.example.com';
context.env.SPACECAT_API_KEY = 'test-api-key';

mockFetch.reset();
// Only DRS calls expected — feature-flags API must NOT be called
mockFetch.onFirstCall().resolves({
ok: true,
json: async () => ({ schedule_id: 'sched-v1' }),
});
mockFetch.onSecondCall().resolves({
ok: true,
json: async () => ({}),
});

mockLlmoConfig.readConfig.resolves({
config: {
entities: {},
categories: {},
topics: {},
brands: { aliases: [] },
competitors: { competitors: [] },
},
});

await mockHandler.runLlmoCustomerAnalysis(
'https://example.com',
context,
site,
auditContext,
);

// feature-flags API must NOT have been called
const featureFlagsCall = mockFetch.getCalls().find(
(c) => typeof c.args[0] === 'string' && c.args[0].includes('/feature-flags'),
);
expect(featureFlagsCall).to.not.exist;

// Schedule should be created without brand_id
const scheduleCall = mockFetch.getCalls().find((c) => c.args[0] === 'https://drs.example.com/api/schedules');
expect(scheduleCall).to.exist;
const scheduleBody = JSON.parse(scheduleCall.args[1].body);
expect(scheduleBody.brand_id).to.be.undefined;
expect(scheduleBody.spacecat_org_id).to.be.undefined;
});

});

});
Loading