Skip to content
Open
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,967 changes: 2,967 additions & 0 deletions packages/spacecat-shared-data-access/package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface SiteEnrollmentCollection extends
BaseCollection<SiteEnrollment> {
allBySiteId(siteId: string): Promise<SiteEnrollment[]>;
allByEntitlementId(entitlementId: string): Promise<SiteEnrollment[]>;
allSiteIdsByProductCode(productCode: string): Promise<string[]>;
allSiteIdsByTier(tier: string, productCode?: string): Promise<string[]>;

findBySiteId(siteId: string): Promise<SiteEnrollment | null>;
findByEntitlementId(entitlementId: string): Promise<SiteEnrollment | null>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,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<string[]>} Array of siteId strings.
*/
async allSiteIdsByTier(tier, productCode) {
if (!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 (data || []).map((row) => row.site_id);
}

async create(item, options = {}) {
if (item?.siteId && item?.entitlementId) {
const existing = await this.findByIndexKeys({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ export interface SiteCollection extends BaseCollection<Site> {
allByProjectName(projectName: string): Promise<Site[]>;
allByOrganizationIdAndProjectId(organizationId: string, projectId: string): Promise<Site[]>;
allByOrganizationIdAndProjectName(organizationId: string, projectName: string): Promise<Site[]>;
allByEnrollmentProductCode(productCode: string, options?: object): Promise<Site[]>;
allByEnrollmentAndTier(tier: string, productCode?: string, options?: object): Promise<Site[]>;
allSitesToAudit(): Promise<string[]>;
allWithLatestAudit(auditType: string, order?: string, deliveryType?: string): Promise<Site[]>;
findByBaseURL(baseURL: string): Promise<Site | null>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Site[]>}
*/
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
51 changes: 51 additions & 0 deletions packages/spacecat-shared-data-access/test/it/site/site.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,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());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,89 @@ 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('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;
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
});
Loading