diff --git a/packages/spacecat-shared-data-access/src/models/entitlement/entitlement.collection.js b/packages/spacecat-shared-data-access/src/models/entitlement/entitlement.collection.js index baa01f6e1..7250b3ff0 100644 --- a/packages/spacecat-shared-data-access/src/models/entitlement/entitlement.collection.js +++ b/packages/spacecat-shared-data-access/src/models/entitlement/entitlement.collection.js @@ -11,6 +11,8 @@ */ import BaseCollection from '../base/base.collection.js'; +import DataAccessError from '../../errors/data-access.error.js'; +import { DEFAULT_PAGE_SIZE } from '../../util/postgrest.utils.js'; /** * EntitlementCollection - A collection class responsible for managing Entitlement entities. @@ -22,7 +24,67 @@ import BaseCollection from '../base/base.collection.js'; class EntitlementCollection extends BaseCollection { static COLLECTION_NAME = 'EntitlementCollection'; - // add custom methods here + /** + * Finds all entitlements for a given product code with their parent organization + * data embedded via PostgREST resource embedding (INNER JOIN). This avoids N+1 + * queries when you need both entitlement and organization data. + * + * Returns plain objects, not model instances, since the result combines fields + * from two entities. Callers should access properties directly + * (e.g., `result.entitlement.tier`), not via getter methods. + * + * @param {string} productCode - Product code to filter by (e.g., 'LLMO'). + * @returns {Promise>} + */ + async allByProductCodeWithOrganization(productCode) { + if (!productCode) { + throw new DataAccessError('productCode is required', { entityName: 'Entitlement', tableName: 'entitlements' }); + } + + const allResults = []; + let offset = 0; + let keepGoing = true; + + while (keepGoing) { + // eslint-disable-next-line no-await-in-loop + const { data, error } = await this.postgrestService + .from('entitlements') + .select('id, product_code, tier, organizations!inner(id, name, ims_org_id)') + .eq('product_code', productCode) + .range(offset, offset + DEFAULT_PAGE_SIZE - 1); + + if (error) { + this.log.error(`[Entitlement] Failed to query entitlements with organizations - ${error.message}`, error); + throw new DataAccessError( + 'Failed to query entitlements with organizations', + { entityName: 'Entitlement', tableName: 'entitlements' }, + error, + ); + } + + if (!data || data.length === 0) { + keepGoing = false; + } else { + allResults.push(...data); + keepGoing = data.length >= DEFAULT_PAGE_SIZE; + offset += DEFAULT_PAGE_SIZE; + } + } + + return allResults.map((row) => ({ + entitlement: { + id: row.id, + productCode: row.product_code, + tier: row.tier, + }, + organization: { + id: row.organizations.id, + name: row.organizations.name, + imsOrgId: row.organizations.ims_org_id, + }, + })); + } } export default EntitlementCollection; diff --git a/packages/spacecat-shared-data-access/src/models/entitlement/index.d.ts b/packages/spacecat-shared-data-access/src/models/entitlement/index.d.ts index 06d32770b..d0f384433 100644 --- a/packages/spacecat-shared-data-access/src/models/entitlement/index.d.ts +++ b/packages/spacecat-shared-data-access/src/models/entitlement/index.d.ts @@ -31,6 +31,19 @@ export interface Entitlement extends BaseModel { setQuotas(quotas: object): Entitlement; } +export interface EntitlementWithOrganization { + entitlement: { + id: string; + productCode: EntitlementProductCode; + tier: EntitlementTier; + }; + organization: { + id: string; + name: string; + imsOrgId: string; + } | null; +} + export interface EntitlementCollection extends BaseCollection { allByOrganizationId(organizationId: string): Promise; @@ -44,4 +57,8 @@ export interface EntitlementCollection extends organizationId: string, productCode: EntitlementProductCode, ): Promise; + + allByProductCodeWithOrganization( + productCode: EntitlementProductCode, + ): Promise; } diff --git a/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js b/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js index dd95caef6..c1d645399 100644 --- a/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js +++ b/packages/spacecat-shared-data-access/test/it/entitlement/entitlement.test.js @@ -145,6 +145,66 @@ describe('Entitlement IT', async () => { ); }); + describe('allByProductCodeWithOrganization', () => { + it('returns LLMO entitlements with embedded organization data', async () => { + const results = await Entitlement.allByProductCodeWithOrganization('LLMO'); + + expect(results).to.be.an('array'); + expect(results.length).to.be.greaterThan(0); + + for (const { entitlement, organization } of results) { + expect(entitlement).to.be.an('object'); + expect(entitlement.id).to.be.a('string'); + expect(entitlement.productCode).to.equal('LLMO'); + expect(entitlement.tier).to.be.oneOf(['FREE_TRIAL', 'PAID']); + + expect(organization).to.be.an('object'); + expect(organization.id).to.be.a('string'); + expect(organization.name).to.be.a('string'); + expect(organization.imsOrgId).to.be.a('string'); + } + }); + + it('returns only entitlements matching the given product code', async () => { + const llmoResults = await Entitlement.allByProductCodeWithOrganization('LLMO'); + const asoResults = await Entitlement.allByProductCodeWithOrganization('ASO'); + + // Verify product code filtering: no cross-contamination + for (const { entitlement } of llmoResults) { + expect(entitlement.productCode).to.equal('LLMO'); + } + for (const { entitlement } of asoResults) { + expect(entitlement.productCode).to.equal('ASO'); + } + + // LLMO should have results (seed data has 2 + 1 created by earlier test) + expect(llmoResults.length).to.be.greaterThanOrEqual(1); + + // Verify no ID overlap between product codes + const llmoIds = new Set(llmoResults.map((r) => r.entitlement.id)); + for (const { entitlement } of asoResults) { + expect(llmoIds.has(entitlement.id)).to.be.false; + } + }); + + it('returns correct organization data for each entitlement', async () => { + const results = await Entitlement.allByProductCodeWithOrganization('LLMO'); + + // Verify that each entitlement's organization has a valid imsOrgId + const orgIds = results.map(({ organization }) => organization.imsOrgId); + for (const imsOrgId of orgIds) { + expect(imsOrgId).to.match(/@AdobeOrg$/); + } + }); + + it('returns empty array for product code with no entitlements', async () => { + const results = await Entitlement.allByProductCodeWithOrganization('ACO'); + + expect(results).to.be.an('array'); + expect(results).to.have.lengthOf(0); + }); + }); + it('removes an entitlement', async () => { const entitlement = await Entitlement.findById(sampleData.entitlements[0].getId()); diff --git a/packages/spacecat-shared-data-access/test/unit/models/entitlement/entitlement.collection.test.js b/packages/spacecat-shared-data-access/test/unit/models/entitlement/entitlement.collection.test.js index 2110b6222..9c19e94a7 100644 --- a/packages/spacecat-shared-data-access/test/unit/models/entitlement/entitlement.collection.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/entitlement/entitlement.collection.test.js @@ -14,9 +14,12 @@ import { expect, use as chaiUse } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import DataAccessError from '../../../../src/errors/data-access.error.js'; import Entitlement from '../../../../src/models/entitlement/entitlement.model.js'; +import { DEFAULT_PAGE_SIZE } from '../../../../src/util/postgrest.utils.js'; import { createElectroMocks } from '../../util.js'; @@ -82,4 +85,111 @@ describe('EntitlementCollection', () => { expect(trialUserActivitiesRef.isRemoveDependents()).to.be.false; }); }); + + describe('allByProductCodeWithOrganization', () => { + let rangeStub; + + function setupPostgrestChain(result) { + rangeStub = sinon.stub().resolves(result); + const eqStub = sinon.stub().returns({ range: rangeStub }); + const selectStub = sinon.stub().returns({ eq: eqStub }); + instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); + return { + selectStub, eqStub, rangeStub, + }; + } + + it('returns entitlements with embedded organization data', async () => { + setupPostgrestChain({ + data: [ + { + id: 'ent-1', + product_code: 'LLMO', + tier: 'PAID', + organizations: { id: 'org-1', name: 'Acme Corp', ims_org_id: 'acme@AdobeOrg' }, + }, + { + id: 'ent-2', + product_code: 'LLMO', + tier: 'FREE_TRIAL', + organizations: { id: 'org-2', name: 'Beta Inc', ims_org_id: 'beta@AdobeOrg' }, + }, + ], + error: null, + }); + + const results = await instance.allByProductCodeWithOrganization('LLMO'); + + expect(results).to.have.lengthOf(2); + expect(results[0]).to.deep.equal({ + entitlement: { id: 'ent-1', productCode: 'LLMO', tier: 'PAID' }, + organization: { id: 'org-1', name: 'Acme Corp', imsOrgId: 'acme@AdobeOrg' }, + }); + expect(results[1]).to.deep.equal({ + entitlement: { id: 'ent-2', productCode: 'LLMO', tier: 'FREE_TRIAL' }, + organization: { id: 'org-2', name: 'Beta Inc', imsOrgId: 'beta@AdobeOrg' }, + }); + }); + + it('returns empty array when no entitlements exist', async () => { + setupPostgrestChain({ data: [], error: null }); + + const results = await instance.allByProductCodeWithOrganization('LLMO'); + + expect(results).to.deep.equal([]); + }); + + it('returns empty array when data is null', async () => { + setupPostgrestChain({ data: null, error: null }); + + const results = await instance.allByProductCodeWithOrganization('LLMO'); + + expect(results).to.deep.equal([]); + }); + + it('throws DataAccessError when productCode is missing', async () => { + await expect(instance.allByProductCodeWithOrganization(null)) + .to.be.rejectedWith(DataAccessError, 'productCode is required'); + await expect(instance.allByProductCodeWithOrganization('')) + .to.be.rejectedWith(DataAccessError, 'productCode is required'); + }); + + it('throws DataAccessError on PostgREST error', async () => { + setupPostgrestChain({ data: null, error: { message: 'connection refused' } }); + + await expect(instance.allByProductCodeWithOrganization('LLMO')) + .to.be.rejectedWith(DataAccessError, 'Failed to query entitlements with organizations'); + expect(mockLogger.error).to.have.been.called; + }); + + it('paginates when results exceed page size', async () => { + const page1 = Array.from({ length: DEFAULT_PAGE_SIZE }, (_, i) => ({ + id: `ent-${i}`, + product_code: 'LLMO', + tier: 'PAID', + organizations: { id: `org-${i}`, name: `Org ${i}`, ims_org_id: `org${i}@AdobeOrg` }, + })); + const page2 = [{ + id: `ent-${DEFAULT_PAGE_SIZE}`, + product_code: 'LLMO', + tier: 'FREE_TRIAL', + organizations: { id: `org-${DEFAULT_PAGE_SIZE}`, name: `Org ${DEFAULT_PAGE_SIZE}`, ims_org_id: `org${DEFAULT_PAGE_SIZE}@AdobeOrg` }, + }]; + + rangeStub = sinon.stub(); + rangeStub.onFirstCall().resolves({ data: page1, error: null }); + rangeStub.onSecondCall().resolves({ data: page2, error: null }); + const eqStub = sinon.stub().returns({ range: rangeStub }); + const selectStub = sinon.stub().returns({ eq: eqStub }); + instance.postgrestService.from = sinon.stub().returns({ select: selectStub }); + + const results = await instance.allByProductCodeWithOrganization('LLMO'); + + expect(results).to.have.lengthOf(DEFAULT_PAGE_SIZE + 1); + expect(rangeStub).to.have.been.calledTwice; + expect(rangeStub.firstCall.args).to.deep.equal([0, DEFAULT_PAGE_SIZE - 1]); + expect(rangeStub.secondCall.args) + .to.deep.equal([DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE * 2 - 1]); + }); + }); });