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
33 changes: 33 additions & 0 deletions api/src/main/controllers/ServiceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ServiceController {
this.indexPricings = this.indexPricings.bind(this);
this.show = this.show.bind(this);
this.showPricing = this.showPricing.bind(this);
this.showPublicPricingUrl = this.showPublicPricingUrl.bind(this);
this.create = this.create.bind(this);
this.update = this.update.bind(this);
this.updatePricingAvailability = this.updatePricingAvailability.bind(this);
Expand Down Expand Up @@ -118,6 +119,38 @@ class ServiceController {
}
}

async showPublicPricingUrl(req: any, res: any) {
try {
const serviceName = req.params.serviceName;
const pricingVersion = req.params.pricingVersion;
const organizationId = req.org ? req.org.id : req.params.organizationId;

if (!organizationId) {
return res.status(400).send({
error:
'Organization ID is required. You can either provide an organization scoped API key or use the /organizations/*/services/** paths',
});
}

const pricingPath = await this.serviceService.showPublicPricingUrl(
serviceName,
pricingVersion,
organizationId
);

return res.json({
path: pricingPath,
url: `${req.protocol}://${req.get('host')}${pricingPath}`,
});
} catch (err: any) {
if (err.message.toLowerCase().includes('not found')) {
res.status(404).send({ error: err.message });
} else {
res.status(500).send({ error: err.message });
}
}
}

async create(req: any, res: any) {
try {
const receivedFile = req.file;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const pricingSchema = new Schema(
{
_serviceName: { type: String },
_organizationId: { type: String },
yamlPath: { type: String },
version: { type: String, required: true },
currency: { type: String, required: true },
createdAt: { type: Date, required: true, default: Date.now },
Expand Down
8 changes: 8 additions & 0 deletions api/src/main/routes/ServiceRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const loadFileRoutes = function (app: express.Application) {
.get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.indexPricings)
.post(memberRole, hasPermission(['OWNER','ADMIN', 'MANAGER']), upload, serviceController.addPricingToService);

app
.route(baseUrl + '/organizations/:organizationId/services/:serviceName/pricings/:pricingVersion/public-url')
.get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.showPublicPricingUrl);

app
.route(baseUrl + '/organizations/:organizationId/services/:serviceName/pricings/:pricingVersion')
.get(memberRole, hasPermission(['OWNER', 'ADMIN', 'MANAGER', 'EVALUATOR']), serviceController.showPricing)
Expand Down Expand Up @@ -63,6 +67,10 @@ const loadFileRoutes = function (app: express.Application) {
.get(serviceController.indexPricings)
.post(upload, serviceController.addPricingToService);

app
.route(baseUrl + '/services/:serviceName/pricings/:pricingVersion/public-url')
.get(serviceController.showPublicPricingUrl);

app
.route(baseUrl + '/services/:serviceName/pricings/:pricingVersion')
.get(serviceController.showPricing)
Expand Down
3 changes: 3 additions & 0 deletions api/src/main/services/FeatureEvaluationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ class FeatureEvaluationService {
// Try cache first
let pricing = await this.cacheService.get(`pricing.url.${url}`);
if (!pricing) {
if (!url) {
throw new Error(`Pricing version ${version} for service ${serviceName} does not have a valid URL`);
}
pricing = await this.serviceService._getPricingFromUrl(url);
try {
await this.cacheService.set(`pricing.url.${url}`, pricing, 3600, true);
Expand Down
150 changes: 119 additions & 31 deletions api/src/main/services/ServiceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ class ServiceService {
const batchResults = await Promise.all(
batch.map(async version => {
const url = pricingsToReturn.get(version)?.url;
if (!url) {
return null;
}
// Try cache first
let pricing = await this.cacheService.get(`pricing.url.${url}`);
if (!pricing) {
Expand All @@ -114,10 +117,10 @@ class ServiceService {
console.debug('Cache set failed for pricing.url.' + url, err);
}
}
return pricing;
return { ...pricing, url };
})
);
remotePricings.push(...batchResults);
remotePricings.push(...batchResults.filter(Boolean));
}

return (locallySavedPricings as unknown as LeanPricing[]).concat(remotePricings);
Expand Down Expand Up @@ -184,10 +187,22 @@ class ServiceService {
await this.cacheService.set(`pricing.url.${pricingLocator.url}`, pricing, 3600, true);
}

return pricing;
return { ...pricing, url: pricingLocator.url };
}
}

async showPublicPricingUrl(serviceName: string, pricingVersion: string, organizationId: string) {
const pricing = await this.showPricing(serviceName, pricingVersion, organizationId);

if (pricing?.yamlPath) {
return pricing.yamlPath;
}

throw new Error(
`Pricing version ${pricingVersion} for service ${serviceName} does not have a persisted yamlPath`
);
}

async create(receivedPricing: any, pricingType: 'file' | 'url', organizationId: string) {
try {
await this.cacheService.del('features.*');
Expand Down Expand Up @@ -229,6 +244,12 @@ class ServiceService {
// Step 1: Parse and validate pricing

const uploadedPricing: Pricing = await this._getPricingFromPath(pricingFile.path);
const pricingYamlPath = this._savePricingYamlFile(
pricingFile.path,
organizationId,
uploadedPricing.saasName,
uploadedPricing.version
);
const formattedPricingVersion = escapeVersion(uploadedPricing.version);
// Step 1.1: Load the service if already exists
if (serviceName) {
Expand All @@ -255,6 +276,7 @@ class ServiceService {
const pricingData: ExpectedPricingType & { _serviceName: string; _organizationId: string } = {
_serviceName: uploadedPricing.saasName,
_organizationId: organizationId,
yamlPath: pricingYamlPath,
...parsePricingToSpacePricingObject(uploadedPricing),
};

Expand Down Expand Up @@ -680,19 +702,27 @@ class ServiceService {
}

if (newServiceData.organizationId && newServiceData.organizationId !== organizationId) {
const organization = await this.organizationRepository.findById(newServiceData.organizationId);
const organization = await this.organizationRepository.findById(
newServiceData.organizationId
);
if (!organization) {
throw new Error(`INVALID DATA: Organization with id ${newServiceData.organizationId} not found`);
throw new Error(
`INVALID DATA: Organization with id ${newServiceData.organizationId} not found`
);
}

const contracts = await this.contractRepository.findByFilters({filters: {services: [service.name]}, organizationId});

const contracts = await this.contractRepository.findByFilters({
filters: { services: [service.name] },
organizationId,
});

for (const contract of contracts) {
if (Object.keys(contract.contractedServices).length > 1) {
contractsToRemoveService.push(contract);
}else{
contractsToRemoveService.push(contract);
} else {
if (dataToUpdate.name) {
contract.contractedServices[dataToUpdate.name] = contract.contractedServices[service.name];
contract.contractedServices[dataToUpdate.name] =
contract.contractedServices[service.name];
delete contract.contractedServices[service.name];
}
contractsToUpdateOrgId.push(contract);
Expand All @@ -713,10 +743,19 @@ class ServiceService {
await this.cacheService.del(cacheKey);
serviceName = dataToUpdate.name;

await this.contractRepository.changeServiceName(service.name, dataToUpdate.name, organizationId);
const updatedContracts = await this.contractRepository.findByFilters({filters: {services: [dataToUpdate.name]}, organizationId: dataToUpdate.organizationId || organizationId});
await this.contractRepository.changeServiceName(
service.name,
dataToUpdate.name,
organizationId
);
const updatedContracts = await this.contractRepository.findByFilters({
filters: { services: [dataToUpdate.name] },
organizationId: dataToUpdate.organizationId || organizationId,
});

await this.cacheService.delMany(updatedContracts.map(c => `contracts.${c.userContact.userId}`));
await this.cacheService.delMany(
updatedContracts.map(c => `contracts.${c.userContact.userId}`)
);
}

if (dataToUpdate.organizationId) {
Expand All @@ -730,8 +769,12 @@ class ServiceService {
}
await this.contractRepository.bulkUpdate(contractsToUpdateOrgId);

await this.cacheService.delMany(contractsToRemoveService.map(c => `contracts.${c.userContact.userId}`));
await this.cacheService.delMany(contractsToUpdateOrgId.map(c => `contracts.${c.userContact.userId}`));
await this.cacheService.delMany(
contractsToRemoveService.map(c => `contracts.${c.userContact.userId}`)
);
await this.cacheService.delMany(
contractsToUpdateOrgId.map(c => `contracts.${c.userContact.userId}`)
);
}

let newCacheKey = `service.${organizationId}.${serviceName}`;
Expand Down Expand Up @@ -1083,9 +1126,49 @@ class ServiceService {
}
}

async _getPricingFromRemoteUrl(url: string) {
_normalizePricingPathPart(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase();
}

_buildPricingYamlRelativePath(organizationId: string, serviceName: string, version: string) {
const safeOrganizationId = this._normalizePricingPathPart(organizationId);
const safeServiceName = this._normalizePricingPathPart(serviceName);
const safeVersion = this._normalizePricingPathPart(version);

return `/static/pricings/${safeOrganizationId}-${safeServiceName}-${safeVersion}.yaml`;
}

_savePricingYamlContent(
yamlContent: string,
organizationId: string,
serviceName: string,
version: string
) {
const relativePath = this._buildPricingYamlRelativePath(organizationId, serviceName, version);
const absolutePath = path.resolve('public', `.${relativePath}`);

fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, yamlContent, 'utf8');

return relativePath;
}

_savePricingYamlFile(
sourcePath: string,
organizationId: string,
serviceName: string,
version: string
) {
const yamlContent = fs.readFileSync(sourcePath, 'utf8');
return this._savePricingYamlContent(yamlContent, organizationId, serviceName, version);
}

async _fetchRemotePricingYaml(url: string) {
const agent = new https.Agent({ rejectUnauthorized: false });
// Abort fetch if it takes longer than timeoutMs
const timeoutMs = 5000;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
Expand All @@ -1105,61 +1188,66 @@ class ServiceService {
if (!response.ok) {
throw new Error(`Failed to fetch pricing from URL: ${url}, status: ${response.status}`);
}
const remotePricingYaml = await response.text();

return response.text();
}

async _getPricingFromRemoteUrl(url: string) {
const remotePricingYaml = await this._fetchRemotePricingYaml(url);
return retrievePricingFromText(remotePricingYaml);
}

async _removeServiceFromContracts(serviceName: string, organizationId: string): Promise<boolean> {
try{
try {
const contracts: LeanContract[] = await this.contractRepository.findByFilters({
organizationId,
});
const novatedContracts: LeanContract[] = [];
const contractsToDisable: LeanContract[] = [];

for (const contract of contracts) {
// Remove this service from the subscription objects
const newSubscription: Record<string, any> = {
contractedServices: {},
subscriptionPlans: {},
subscriptionAddOns: {},
};

// Rebuild subscription objects without the service to be removed
for (const key in contract.contractedServices) {
if (key !== serviceName) {
newSubscription.contractedServices[key] = contract.contractedServices[key];
}
}

for (const key in contract.subscriptionPlans) {
if (key !== serviceName) {
newSubscription.subscriptionPlans[key] = contract.subscriptionPlans[key];
}
}

for (const key in contract.subscriptionAddOns) {
if (key !== serviceName) {
newSubscription.subscriptionAddOns[key] = contract.subscriptionAddOns[key];
}
}

// Check if objects have the same content by comparing their JSON string representation
const hasContractChanged =
JSON.stringify(contract.contractedServices) !==
JSON.stringify(newSubscription.contractedServices);

// If objects are equal, skip this contract
if (!hasContractChanged) {
continue;
}

const newContract = performNovation(contract, newSubscription);

if (contract.usageLevels[serviceName]) {
delete contract.usageLevels[serviceName];
}

if (Object.keys(newSubscription.contractedServices).length === 0) {
newContract.usageLevels = {};
newContract.billingPeriod = {
Expand All @@ -1168,16 +1256,16 @@ class ServiceService {
autoRenew: false,
renewalDays: 0,
};

contractsToDisable.push(newContract);
continue;
}

novatedContracts.push(newContract);
}

return true;
}catch(err){
} catch (err) {
return false;
}
}
Expand Down
4 changes: 4 additions & 0 deletions api/src/main/types/models/Pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { AddOn, Feature, Plan, UsageLimit } from "pricing4ts";

export interface LeanPricing {
id?: string;
url?: string;
yamlPath?: string;
version: string;
currency: string;
createdAt: Date; // o Date si no haces `JSON.stringify`
Expand All @@ -12,6 +14,8 @@ export interface LeanPricing {
}

export interface ExpectedPricingType {
url?: string;
yamlPath?: string;
version: string;
currency: string;
createdAt: Date;
Expand Down
Loading
Loading