diff --git a/packages/spacecat-shared-data-access/src/models/site-enrollment/index.d.ts b/packages/spacecat-shared-data-access/src/models/site-enrollment/index.d.ts index 14a6ed362..72d392586 100644 --- a/packages/spacecat-shared-data-access/src/models/site-enrollment/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/site-enrollment/index.d.ts @@ -27,6 +27,8 @@ export interface SiteEnrollmentCollection extends BaseCollection { allBySiteId(siteId: string): Promise; allByEntitlementId(entitlementId: string): Promise; + allSiteIdsByProductCode(productCode: string): Promise; + allSiteIdsByTier(tier: string, productCode?: string): Promise; findBySiteId(siteId: string): Promise; findByEntitlementId(entitlementId: string): Promise; diff --git a/packages/spacecat-shared-data-access/src/models/site-enrollment/site-enrollment.collection.js b/packages/spacecat-shared-data-access/src/models/site-enrollment/site-enrollment.collection.js index 44c69f757..37dbe11ee 100644 --- a/packages/spacecat-shared-data-access/src/models/site-enrollment/site-enrollment.collection.js +++ b/packages/spacecat-shared-data-access/src/models/site-enrollment/site-enrollment.collection.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { hasText } from '@adobe/spacecat-shared-utils'; import BaseCollection from '../base/base.collection.js'; import DataAccessError from '../../errors/data-access.error.js'; @@ -47,6 +48,39 @@ class SiteEnrollmentCollection extends BaseCollection { return (data || []).map((row) => row.site_id); } + /** + * Returns all site IDs enrolled at a given entitlement tier (e.g. 'PAID', + * 'FREE_TRIAL', 'PLG') in a single JOIN query. Optionally narrows the tier + * match to a single product code. + * + * @param {string} tier - Entitlement tier to filter by. + * @param {string} [productCode] - Optional product code to further filter by. + * @returns {Promise} Array of siteId strings. + */ + async allSiteIdsByTier(tier, productCode) { + if (!hasText(tier)) { + throw new DataAccessError('tier is required', { entityName: 'SiteEnrollment', tableName: 'site_enrollments' }); + } + + let query = this.postgrestService + .from(this.tableName) + .select('site_id, entitlements!inner(tier, product_code)') + .eq('entitlements.tier', tier); + + if (productCode) { + query = query.eq('entitlements.product_code', productCode); + } + + const { data, error } = await query; + + if (error) { + this.log.error(`[SiteEnrollmentCollection] Failed to query site_enrollments by tier - ${error.message}`, error); + throw new DataAccessError('Failed to query site_enrollments by tier', { entityName: 'SiteEnrollment', tableName: 'site_enrollments' }, error); + } + + return [...new Set((data || []).map((row) => row.site_id))]; + } + async create(item, options = {}) { if (item?.siteId && item?.entitlementId) { const existing = await this.findByIndexKeys({ diff --git a/packages/spacecat-shared-data-access/src/models/site/index.d.ts b/packages/spacecat-shared-data-access/src/models/site/index.d.ts index 7b4bfe565..b88942256 100644 --- a/packages/spacecat-shared-data-access/src/models/site/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/site/index.d.ts @@ -409,6 +409,8 @@ export interface SiteCollection extends BaseCollection { allByProjectName(projectName: string): Promise; allByOrganizationIdAndProjectId(organizationId: string, projectId: string): Promise; allByOrganizationIdAndProjectName(organizationId: string, projectName: string): Promise; + allByEnrollmentProductCode(productCode: string, options?: object): Promise; + allByEnrollmentAndTier(tier: string, productCode?: string, options?: object): Promise; allSitesToAudit(): Promise; allWithLatestAudit(auditType: string, order?: string, deliveryType?: string): Promise; findByBaseURL(baseURL: string): Promise; diff --git a/packages/spacecat-shared-data-access/src/models/site/site.collection.js b/packages/spacecat-shared-data-access/src/models/site/site.collection.js index b59148be3..6e5842e38 100755 --- a/packages/spacecat-shared-data-access/src/models/site/site.collection.js +++ b/packages/spacecat-shared-data-access/src/models/site/site.collection.js @@ -174,6 +174,38 @@ class SiteCollection extends BaseCollection { return sites; } + /** + * Returns all sites enrolled at a given entitlement tier (e.g. 'PAID', + * 'FREE_TRIAL', 'PLG'). Optionally narrows the result to a single product + * code (e.g. 'LLMO'). + * + * Uses entityRegistry to chain through SiteEnrollmentCollection, then + * batch-fetches full Site objects. + * + * @param {string} tier - Entitlement tier to filter by. + * @param {string} [productCode] - Optional product code to further filter by. + * @param {object} [options] - batchGetByKeys options (e.g. attribute projection). + * @returns {Promise} + */ + async allByEnrollmentAndTier(tier, productCode, options = {}) { + if (!hasText(tier)) { + throw new DataAccessError('tier is required', this); + } + + const siteEnrollmentCollection = this.entityRegistry.getCollection('SiteEnrollmentCollection'); + + const siteIds = await siteEnrollmentCollection.allSiteIdsByTier(tier, productCode); + if (siteIds.length === 0) { + return []; + } + + const { data: sites } = await this.batchGetByKeys( + siteIds.map((siteId) => ({ siteId })), + options, + ); + return sites; + } + async allByOrganizationIdAndProjectName(organizationId, projectName) { if (!hasText(organizationId)) { throw new DataAccessError('organizationId is required', this); diff --git a/packages/spacecat-shared-data-access/test/it/site-enrollment/site-enrollment.test.js b/packages/spacecat-shared-data-access/test/it/site-enrollment/site-enrollment.test.js index 0f387ceed..c7b509628 100644 --- a/packages/spacecat-shared-data-access/test/it/site-enrollment/site-enrollment.test.js +++ b/packages/spacecat-shared-data-access/test/it/site-enrollment/site-enrollment.test.js @@ -72,6 +72,32 @@ describe('SiteEnrollment IT', async () => { } }); + it('gets all site IDs by tier', async () => { + const siteIds = await SiteEnrollment.allSiteIdsByTier('PAID'); + + expect(siteIds).to.be.an('array'); + expect(siteIds).to.have.members([ + '78fec9c7-2141-4600-b7b1-ea5c78752b91', + '56a691db-d32e-4308-ac99-a21de0580557', + ]); + }); + + it('gets all site IDs by tier filtered by product code', async () => { + const siteIds = await SiteEnrollment.allSiteIdsByTier('PAID', 'LLMO'); + + expect(siteIds).to.eql(['56a691db-d32e-4308-ac99-a21de0580557']); + }); + + it('returns empty array when no enrollments match the tier', async () => { + const siteIds = await SiteEnrollment.allSiteIdsByTier('PLG'); + + expect(siteIds).to.eql([]); + }); + + it('throws when tier is missing for allSiteIdsByTier', async () => { + await expect(SiteEnrollment.allSiteIdsByTier()).to.be.rejectedWith('tier is required'); + }); + it('adds a new site enrollment', async () => { const data = { siteId: sampleData.sites[0].getId(), diff --git a/packages/spacecat-shared-data-access/test/it/site/site.test.js b/packages/spacecat-shared-data-access/test/it/site/site.test.js index dd8e46be1..5a4fb30dd 100644 --- a/packages/spacecat-shared-data-access/test/it/site/site.test.js +++ b/packages/spacecat-shared-data-access/test/it/site/site.test.js @@ -576,6 +576,57 @@ describe('Site IT', async () => { expect(reloadedProfile.main_profile.id).to.equal(sample.main_profile.id); }); + describe('allByEnrollmentAndTier', () => { + it('returns sites enrolled at a given tier', async () => { + const sites = await Site.allByEnrollmentAndTier('PAID'); + + expect(sites).to.be.an('array'); + const ids = sites.map((s) => s.getId()).sort(); + expect(ids).to.eql([ + '56a691db-d32e-4308-ac99-a21de0580557', + '78fec9c7-2141-4600-b7b1-ea5c78752b91', + ]); + }); + + it('narrows by product code when supplied', async () => { + const sites = await Site.allByEnrollmentAndTier('PAID', 'LLMO'); + + expect(sites).to.be.an('array'); + expect(sites).to.have.lengthOf(1); + expect(sites[0].getId()).to.equal('56a691db-d32e-4308-ac99-a21de0580557'); + }); + + it('returns FREE_TRIAL enrolled sites', async () => { + const sites = await Site.allByEnrollmentAndTier('FREE_TRIAL', 'LLMO'); + + expect(sites).to.have.lengthOf(1); + expect(sites[0].getId()).to.equal('5d6d4439-6659-46c2-b646-92d110fa5a52'); + }); + + it('returns empty array when tier has no enrollments', async () => { + const sites = await Site.allByEnrollmentAndTier('PLG'); + + expect(sites).to.eql([]); + }); + + it('honors attribute projection via options', async () => { + const sites = await Site.allByEnrollmentAndTier('PAID', 'LLMO', { + attributes: ['siteId', 'baseURL'], + }); + + expect(sites).to.have.lengthOf(1); + const json = sites[0].toJSON(); + expect(json.siteId).to.equal('56a691db-d32e-4308-ac99-a21de0580557'); + expect(json.baseURL).to.be.a('string'); + expect(json.organizationId).to.be.undefined; + expect(json.deliveryType).to.be.undefined; + }); + + it('throws when tier is missing', async () => { + await expect(Site.allByEnrollmentAndTier('')).to.be.rejectedWith('tier is required'); + }); + }); + it('removes a site', async () => { const site = await Site.findById(sampleData.sites[0].getId()); diff --git a/packages/spacecat-shared-data-access/test/unit/models/site-enrollment/site-enrollment.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/site-enrollment/site-enrollment.collection.test.js index 6c1db7bd5..f446e18e8 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/site-enrollment/site-enrollment.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/site-enrollment/site-enrollment.collection.test.js @@ -162,4 +162,107 @@ describe('SiteEnrollmentCollection', () => { expect(mockLogger.error).to.have.been.called; }); }); + + describe('allSiteIdsByTier', () => { + let fromStub; + let selectStub; + let firstEqStub; + let secondEqStub; + + beforeEach(() => { + secondEqStub = sinon.stub(); + firstEqStub = sinon.stub().returns({ eq: secondEqStub }); + selectStub = sinon.stub().returns({ eq: firstEqStub }); + fromStub = sinon.stub().returns({ select: selectStub }); + instance.postgrestService.from = fromStub; + }); + + afterEach(() => { + sinon.restore(); + }); + + it('throws DataAccessError when tier is falsy', async () => { + await expect(instance.allSiteIdsByTier(null)).to.be.rejectedWith('tier is required'); + await expect(instance.allSiteIdsByTier(undefined)).to.be.rejectedWith('tier is required'); + await expect(instance.allSiteIdsByTier('')).to.be.rejectedWith('tier is required'); + }); + + it('deduplicates repeated site IDs from multiple matching entitlements', async () => { + firstEqStub.resolves({ + data: [ + { site_id: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }, + { site_id: 'd1e2f3a4-b5c6-7890-abcd-ef1234567890' }, + { site_id: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }, + ], + error: null, + }); + + const result = await instance.allSiteIdsByTier('PAID'); + + expect(result).to.deep.equal([ + 'cfa88998-a0a0-4136-b21d-0ff2aa127443', + 'd1e2f3a4-b5c6-7890-abcd-ef1234567890', + ]); + }); + + it('returns site IDs filtered by tier only when no productCode given', async () => { + firstEqStub.resolves({ + data: [ + { site_id: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }, + { site_id: 'd1e2f3a4-b5c6-7890-abcd-ef1234567890' }, + ], + error: null, + }); + + const result = await instance.allSiteIdsByTier('PAID'); + + expect(result).to.deep.equal([ + 'cfa88998-a0a0-4136-b21d-0ff2aa127443', + 'd1e2f3a4-b5c6-7890-abcd-ef1234567890', + ]); + expect(fromStub).to.have.been.calledOnceWithExactly('site_enrollments'); + expect(selectStub).to.have.been.calledOnceWithExactly('site_id, entitlements!inner(tier, product_code)'); + expect(firstEqStub).to.have.been.calledOnceWithExactly('entitlements.tier', 'PAID'); + expect(secondEqStub).to.not.have.been.called; + }); + + it('applies productCode filter when provided', async () => { + secondEqStub.resolves({ + data: [{ site_id: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }], + error: null, + }); + + const result = await instance.allSiteIdsByTier('FREE_TRIAL', 'LLMO'); + + expect(result).to.deep.equal(['cfa88998-a0a0-4136-b21d-0ff2aa127443']); + expect(firstEqStub).to.have.been.calledOnceWithExactly('entitlements.tier', 'FREE_TRIAL'); + expect(secondEqStub).to.have.been.calledOnceWithExactly('entitlements.product_code', 'LLMO'); + }); + + it('returns empty array when no enrollments match', async () => { + firstEqStub.resolves({ data: [], error: null }); + + const result = await instance.allSiteIdsByTier('PLG'); + + expect(result).to.deep.equal([]); + }); + + it('returns empty array when data is null', async () => { + firstEqStub.resolves({ data: null, error: null }); + + const result = await instance.allSiteIdsByTier('PAID'); + + expect(result).to.deep.equal([]); + }); + + it('logs error and throws DataAccessError when query fails', async () => { + const dbError = new Error('DB connection failed'); + firstEqStub.resolves({ data: null, error: dbError }); + + await expect(instance.allSiteIdsByTier('PAID')) + .to.be.rejectedWith('Failed to query site_enrollments by tier'); + + expect(mockLogger.error).to.have.been.called; + }); + }); }); diff --git a/packages/spacecat-shared-data-access/test/unit/models/site/site.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/site/site.collection.test.js index 744a744f8..88a770098 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/site/site.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/site/site.collection.test.js @@ -455,4 +455,73 @@ describe('SiteCollection', () => { ); }); }); + + describe('allByEnrollmentAndTier', () => { + let mockSiteEnrollmentCollection; + + beforeEach(() => { + mockSiteEnrollmentCollection = { + allSiteIdsByTier: stub(), + }; + mockEntityRegistry.getCollection = stub() + .withArgs('SiteEnrollmentCollection') + .returns(mockSiteEnrollmentCollection); + }); + + it('throws DataAccessError when tier is falsy', async () => { + await expect(instance.allByEnrollmentAndTier('')).to.be.rejectedWith('tier is required'); + await expect(instance.allByEnrollmentAndTier(null)).to.be.rejectedWith('tier is required'); + await expect(instance.allByEnrollmentAndTier(undefined)).to.be.rejectedWith('tier is required'); + }); + + it('returns empty array and skips batchGetByKeys when no site IDs found', async () => { + mockSiteEnrollmentCollection.allSiteIdsByTier.resolves([]); + instance.batchGetByKeys = stub(); + + const result = await instance.allByEnrollmentAndTier('PAID'); + + expect(result).to.deep.equal([]); + expect(mockSiteEnrollmentCollection.allSiteIdsByTier) + .to.have.been.calledOnceWithExactly('PAID', undefined); + expect(instance.batchGetByKeys).to.not.have.been.called; + }); + + it('returns sites fetched by batchGetByKeys with default empty options', async () => { + const siteIds = ['cfa88998-a0a0-4136-b21d-0ff2aa127443', 'd1e2f3a4-b5c6-7890-abcd-ef1234567890']; + const mockSites = [{ getId: () => siteIds[0] }, { getId: () => siteIds[1] }]; + mockSiteEnrollmentCollection.allSiteIdsByTier.resolves(siteIds); + instance.batchGetByKeys = stub().resolves({ data: mockSites }); + + const result = await instance.allByEnrollmentAndTier('FREE_TRIAL'); + + expect(result).to.deep.equal(mockSites); + expect(mockSiteEnrollmentCollection.allSiteIdsByTier) + .to.have.been.calledOnceWithExactly('FREE_TRIAL', undefined); + expect(instance.batchGetByKeys).to.have.been.calledOnceWithExactly( + [ + { siteId: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }, + { siteId: 'd1e2f3a4-b5c6-7890-abcd-ef1234567890' }, + ], + {}, + ); + }); + + it('forwards productCode and options to underlying calls', async () => { + const siteIds = ['cfa88998-a0a0-4136-b21d-0ff2aa127443']; + const mockSites = [{ getId: () => siteIds[0] }]; + const options = { attributes: ['siteId', 'baseURL'] }; + mockSiteEnrollmentCollection.allSiteIdsByTier.resolves(siteIds); + instance.batchGetByKeys = stub().resolves({ data: mockSites }); + + const result = await instance.allByEnrollmentAndTier('PLG', 'LLMO', options); + + expect(result).to.deep.equal(mockSites); + expect(mockSiteEnrollmentCollection.allSiteIdsByTier) + .to.have.been.calledOnceWithExactly('PLG', 'LLMO'); + expect(instance.batchGetByKeys).to.have.been.calledOnceWithExactly( + [{ siteId: 'cfa88998-a0a0-4136-b21d-0ff2aa127443' }], + options, + ); + }); + }); });