From d983ff70a85d6d5684c8bc68021d5a581685c6f3 Mon Sep 17 00:00:00 2001 From: Alex-GF Date: Mon, 23 Mar 2026 18:34:22 +0100 Subject: [PATCH 1/3] feat: view and download pricings YAML & view pricing rendering (beta) --- api/src/main/controllers/ServiceController.ts | 33 +++ .../mongoose/models/PricingMongoose.ts | 1 + api/src/main/routes/ServiceRoutes.ts | 8 + .../main/services/FeatureEvaluationService.ts | 3 + api/src/main/services/ServiceService.ts | 141 ++++++++++--- api/src/main/types/models/Pricing.ts | 4 + api/src/main/types/models/Service.ts | 4 +- frontend/src/api/services/servicesApi.ts | 27 +++ .../drag-and-drop-pricings/index.tsx | 198 +++++++++++++++++- frontend/src/types/Services.ts | 6 +- 10 files changed, 390 insertions(+), 35 deletions(-) diff --git a/api/src/main/controllers/ServiceController.ts b/api/src/main/controllers/ServiceController.ts index 4512d25..566622a 100644 --- a/api/src/main/controllers/ServiceController.ts +++ b/api/src/main/controllers/ServiceController.ts @@ -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); @@ -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; diff --git a/api/src/main/repositories/mongoose/models/PricingMongoose.ts b/api/src/main/repositories/mongoose/models/PricingMongoose.ts index 0eec89c..dd21e98 100644 --- a/api/src/main/repositories/mongoose/models/PricingMongoose.ts +++ b/api/src/main/repositories/mongoose/models/PricingMongoose.ts @@ -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 }, diff --git a/api/src/main/routes/ServiceRoutes.ts b/api/src/main/routes/ServiceRoutes.ts index 46395aa..75731f4 100644 --- a/api/src/main/routes/ServiceRoutes.ts +++ b/api/src/main/routes/ServiceRoutes.ts @@ -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) @@ -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) diff --git a/api/src/main/services/FeatureEvaluationService.ts b/api/src/main/services/FeatureEvaluationService.ts index c89c41d..56cdf05 100644 --- a/api/src/main/services/FeatureEvaluationService.ts +++ b/api/src/main/services/FeatureEvaluationService.ts @@ -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); diff --git a/api/src/main/services/ServiceService.ts b/api/src/main/services/ServiceService.ts index 1538bba..f831453 100644 --- a/api/src/main/services/ServiceService.ts +++ b/api/src/main/services/ServiceService.ts @@ -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) { @@ -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); @@ -184,8 +187,20 @@ 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) { @@ -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) { @@ -255,6 +276,7 @@ class ServiceService { const pricingData: ExpectedPricingType & { _serviceName: string; _organizationId: string } = { _serviceName: uploadedPricing.saasName, _organizationId: organizationId, + yamlPath: pricingYamlPath, ...parsePricingToSpacePricingObject(uploadedPricing), }; @@ -478,9 +500,29 @@ class ServiceService { } async _createFromUrl(pricingUrl: string, organizationId: string, serviceName?: string) { - const uploadedPricing: Pricing = await this._getPricingFromRemoteUrl(pricingUrl); + const remotePricingYaml = await this._fetchRemotePricingYaml(pricingUrl); + const uploadedPricing: Pricing = retrievePricingFromText(remotePricingYaml); + const pricingYamlPath = this._savePricingYamlContent( + remotePricingYaml, + organizationId, + uploadedPricing.saasName, + uploadedPricing.version + ); const formattedPricingVersion = escapeVersion(uploadedPricing.version); + const pricingData: ExpectedPricingType & { _serviceName: string; _organizationId: string } = { + _serviceName: uploadedPricing.saasName, + _organizationId: organizationId, + yamlPath: pricingYamlPath, + ...parsePricingToSpacePricingObject(uploadedPricing), + }; + + const validationErrors: string[] = validatePricingData(pricingData); + + if (validationErrors.length > 0) { + throw new Error(`Validation errors: ${validationErrors.join(', ')}`); + } + if (!serviceName) { // Create a new service or re-enable a disabled one const existingEnabled = await this.serviceRepository.findByName( @@ -498,6 +540,12 @@ class ServiceService { throw new Error(`Invalid request: Service ${uploadedPricing.saasName} already exists`); } + const savedPricing = await this.pricingRepository.create(pricingData); + + if (!savedPricing) { + throw new Error(`Pricing ${uploadedPricing.version} not saved`); + } + if (existingDisabled) { const newArchived: Record = { ...(existingDisabled.archivedPricings || {}) }; @@ -526,11 +574,7 @@ class ServiceService { const updateData: any = { disabled: false, organizationId: organizationId, - activePricings: { - [formattedPricingVersion]: { - url: pricingUrl, - }, - }, + activePricings: new Map([[formattedPricingVersion, { id: savedPricing.id }]]), archivedPricings: newArchived, }; @@ -550,11 +594,7 @@ class ServiceService { name: uploadedPricing.saasName, disabled: false, organizationId: organizationId, - activePricings: { - [formattedPricingVersion]: { - url: pricingUrl, - }, - }, + activePricings: new Map([[formattedPricingVersion, { id: savedPricing.id }]]), }; const service = await this.serviceRepository.create(serviceData); @@ -620,16 +660,22 @@ class ServiceService { updatePayload.disabled = false; updatePayload.organizationId = organizationId; - updatePayload.activePricings = { - [formattedPricingVersion]: { - url: pricingUrl, - }, - }; + const savedPricing = await this.pricingRepository.create(pricingData); + + if (!savedPricing) { + throw new Error(`Pricing ${uploadedPricing.version} not saved`); + } + + updatePayload.activePricings = new Map([[formattedPricingVersion, { id: savedPricing.id }]]); updatePayload.archivedPricings = newArchived; } else { - updatePayload[`activePricings.${formattedPricingVersion}`] = { - url: pricingUrl, - }; + const savedPricing = await this.pricingRepository.create(pricingData); + + if (!savedPricing) { + throw new Error(`Pricing ${uploadedPricing.version} not saved`); + } + + updatePayload[`activePricings.${formattedPricingVersion}`] = { id: savedPricing.id }; } const updatedService = await this.serviceRepository.update( @@ -1083,9 +1129,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); @@ -1105,7 +1191,12 @@ 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); } diff --git a/api/src/main/types/models/Pricing.ts b/api/src/main/types/models/Pricing.ts index 18a523a..9071ca3 100644 --- a/api/src/main/types/models/Pricing.ts +++ b/api/src/main/types/models/Pricing.ts @@ -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` @@ -12,6 +14,8 @@ export interface LeanPricing { } export interface ExpectedPricingType { + url?: string; + yamlPath?: string; version: string; currency: string; createdAt: Date; diff --git a/api/src/main/types/models/Service.ts b/api/src/main/types/models/Service.ts index 8b3d5d9..24236f7 100644 --- a/api/src/main/types/models/Service.ts +++ b/api/src/main/types/models/Service.ts @@ -1,6 +1,6 @@ export interface PricingEntry { - id: string; - url: string; + id?: string; + url?: string; } export interface LeanService { diff --git a/frontend/src/api/services/servicesApi.ts b/frontend/src/api/services/servicesApi.ts index 9f148a8..d7989cf 100644 --- a/frontend/src/api/services/servicesApi.ts +++ b/frontend/src/api/services/servicesApi.ts @@ -73,6 +73,33 @@ export async function getPricingVersion( .catch(() => null); } +export async function getPublicPricingUrl( + apiKey: string, + organizationId: string, + serviceName: string, + version: string +): Promise<{ path: string; url: string }> { + return axios + .get( + `/organizations/${organizationId}/services/${serviceName}/pricings/${version}/public-url`, + { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + timeout: DEFAULT_TIMEOUT, + } + ) + .then(response => response.data as { path: string; url: string }) + .catch(error => { + throw new Error( + `Failed to retrieve public YAML URL for pricing version ${version}. Error: ${ + error.response?.data?.error || error.message + }` + ); + }); +} + export async function changePricingAvailability( apiKey: string, organizationId: string, diff --git a/frontend/src/components/drag-and-drop-pricings/index.tsx b/frontend/src/components/drag-and-drop-pricings/index.tsx index 29f6e77..b48f745 100644 --- a/frontend/src/components/drag-and-drop-pricings/index.tsx +++ b/frontend/src/components/drag-and-drop-pricings/index.tsx @@ -1,9 +1,9 @@ import type { Pricing } from '@/types/Services'; import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { FiZap, FiArchive } from 'react-icons/fi'; +import { FiZap, FiArchive, FiMoreVertical, FiDownload, FiExternalLink } from 'react-icons/fi'; import { useCustomConfirm } from '@/hooks/useCustomConfirm'; -import { deletePricingVersion } from '@/api/services/servicesApi'; +import { deletePricingVersion, getPublicPricingUrl } from '@/api/services/servicesApi'; import useAuth from '@/hooks/useAuth'; import { useOrganization } from '@/hooks/useOrganization'; import { useCustomAlert } from '@/hooks/useCustomAlert'; @@ -26,12 +26,121 @@ export default function DragDropPricings({ null ); const [overDelete, setOverDelete] = useState(false); + const [openMenuVersion, setOpenMenuVersion] = useState(null); + const [menuActionLoadingVersion, setMenuActionLoadingVersion] = useState(null); const {showConfirm, confirmElement} = useCustomConfirm(); const {showAlert, alertElement} = useCustomAlert(); const { user } = useAuth(); const { currentOrganization } = useOrganization(); - const serviceName = window.location.pathname.split('/').pop() || ''; + const serviceName = decodeURIComponent(window.location.pathname.split('/').pop() || ''); + + function getAbsoluteUrlFromPath(path: string): string { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + const apiBaseUrl = (import.meta.env.VITE_SPACE_BASE_URL ?? 'http://localhost:3000/api/v1').replace(/\/+$/, ''); + const apiOrigin = apiBaseUrl.replace(/\/api\/v1$/, ''); + const withoutPublicPrefix = path.startsWith('public/') ? path.replace(/^public\/+/, '') : path; + const normalizedPath = withoutPublicPrefix.startsWith('/') ? withoutPublicPrefix : `/${withoutPublicPrefix}`; + + return `${apiOrigin}${normalizedPath}`; + } + + async function resolvePublicYamlUrl(pricing: Pricing): Promise { + if (pricing.yamlPath) { + return getAbsoluteUrlFromPath(pricing.yamlPath); + } + + if (!currentOrganization?.id) { + await showAlert('Organization not found. Please select an organization and try again.'); + return null; + } + + try { + setMenuActionLoadingVersion(pricing.version); + const result = await getPublicPricingUrl( + user.apiKey, + currentOrganization.id, + serviceName, + pricing.version + ); + return result.url; + } catch (error: any) { + await showAlert(error.message || 'Failed to prepare public pricing YAML URL'); + return null; + } finally { + setMenuActionLoadingVersion(null); + } + } + + async function resolveViewYamlUrl(pricing: Pricing): Promise { + if (pricing.url) { + return getAbsoluteUrlFromPath(pricing.url); + } + + return resolvePublicYamlUrl(pricing); + } + + async function downloadYamlFile(url: string, fileName: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = fileName; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(objectUrl); + } catch { + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); + } + } + + async function handleViewYaml(pricing: Pricing) { + const yamlUrl = await resolveViewYamlUrl(pricing); + + if (!yamlUrl) { + return; + } + + window.open(yamlUrl, '_blank', 'noopener,noreferrer'); + } + + async function handleDownloadYaml(pricing: Pricing) { + const yamlUrl = await resolveViewYamlUrl(pricing); + + if (!yamlUrl) { + return; + } + + await downloadYamlFile(yamlUrl, `${pricing.version}.yaml`); + } + + async function handleOpenInSphere(pricing: Pricing) { + const publicYamlUrl = await resolvePublicYamlUrl(pricing); + + if (!publicYamlUrl) { + return; + } + + const sphereUrl = `https://sphere.score.us.es/editor?pricingUrl=${encodeURI(publicYamlUrl)}`; + window.open(sphereUrl, '_blank', 'noopener,noreferrer'); + } function handleDragStart(e: React.DragEvent, pricing: Pricing, from: 'active' | 'archived') { setDragged({ pricing, from }); @@ -156,9 +265,86 @@ export default function DragDropPricings({ {pricing.version} {pricing.currency} - - {new Date(pricing.createdAt).toLocaleDateString()} - +
+ + {new Date(pricing.createdAt).toLocaleDateString()} + +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setOpenMenuVersion(null); + } + }} + > + + + + {openMenuVersion === pricing.version && ( + e.stopPropagation()} + onClick={e => e.stopPropagation()} + > + + + + + )} + +
+
))} diff --git a/frontend/src/types/Services.ts b/frontend/src/types/Services.ts index 7275bae..a8e1312 100644 --- a/frontend/src/types/Services.ts +++ b/frontend/src/types/Services.ts @@ -11,12 +11,14 @@ export interface RetrievedService { } export interface PricingEntry { - id: string; - url: string; + id?: string; + url?: string; } export interface Pricing { id?: string; + url?: string; + yamlPath?: string; version: string; currency: string; createdAt: Date; // o Date si no haces `JSON.stringify` From a269cc548353b3178c313d96d892e23a8ef6d62e Mon Sep 17 00:00:00 2001 From: Alex-GF Date: Mon, 23 Mar 2026 18:48:00 +0100 Subject: [PATCH 2/3] feat: added service_healthy check to dependencies in docker compose --- docker-compose.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index af48d97..8cb8eab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,8 +50,10 @@ services: volumes: - 'space-statics:/usr/src/server/public' depends_on: - - mongodb - - redis + mongodb: + condition: service_healthy + redis: + condition: service_healthy networks: - space-network healthcheck: @@ -70,7 +72,8 @@ services: VITE_SPACE_BASE_URL: http://localhost:5403/api/v1 # Change to http://localhost/api/v1 if running SPACE in kubernetes # VITE_FRONTEND_BASE_PATH: /space # Uncomment and set to the base path of the frontend if it's not served at the root (e.g., /space) depends_on: - - space-server + space-server: + condition: service_healthy networks: - space-network healthcheck: @@ -94,8 +97,10 @@ services: ports: - 5403:5403 depends_on: - - space-server - - space-client + space-server: + condition: service_healthy + space-client: + condition: service_healthy networks: - space-network healthcheck: From 4f1d5a9dd8d3f0a655e53d5d7ed9b08ad217f7a8 Mon Sep 17 00:00:00 2001 From: Alex-GF Date: Mon, 23 Mar 2026 19:13:32 +0100 Subject: [PATCH 3/3] fix: management of remote pricings --- api/src/main/services/ServiceService.ts | 133 ++++++++++++------------ 1 file changed, 65 insertions(+), 68 deletions(-) diff --git a/api/src/main/services/ServiceService.ts b/api/src/main/services/ServiceService.ts index f831453..f67c4e2 100644 --- a/api/src/main/services/ServiceService.ts +++ b/api/src/main/services/ServiceService.ts @@ -500,29 +500,9 @@ class ServiceService { } async _createFromUrl(pricingUrl: string, organizationId: string, serviceName?: string) { - const remotePricingYaml = await this._fetchRemotePricingYaml(pricingUrl); - const uploadedPricing: Pricing = retrievePricingFromText(remotePricingYaml); - const pricingYamlPath = this._savePricingYamlContent( - remotePricingYaml, - organizationId, - uploadedPricing.saasName, - uploadedPricing.version - ); + const uploadedPricing: Pricing = await this._getPricingFromRemoteUrl(pricingUrl); const formattedPricingVersion = escapeVersion(uploadedPricing.version); - const pricingData: ExpectedPricingType & { _serviceName: string; _organizationId: string } = { - _serviceName: uploadedPricing.saasName, - _organizationId: organizationId, - yamlPath: pricingYamlPath, - ...parsePricingToSpacePricingObject(uploadedPricing), - }; - - const validationErrors: string[] = validatePricingData(pricingData); - - if (validationErrors.length > 0) { - throw new Error(`Validation errors: ${validationErrors.join(', ')}`); - } - if (!serviceName) { // Create a new service or re-enable a disabled one const existingEnabled = await this.serviceRepository.findByName( @@ -540,12 +520,6 @@ class ServiceService { throw new Error(`Invalid request: Service ${uploadedPricing.saasName} already exists`); } - const savedPricing = await this.pricingRepository.create(pricingData); - - if (!savedPricing) { - throw new Error(`Pricing ${uploadedPricing.version} not saved`); - } - if (existingDisabled) { const newArchived: Record = { ...(existingDisabled.archivedPricings || {}) }; @@ -574,7 +548,11 @@ class ServiceService { const updateData: any = { disabled: false, organizationId: organizationId, - activePricings: new Map([[formattedPricingVersion, { id: savedPricing.id }]]), + activePricings: { + [formattedPricingVersion]: { + url: pricingUrl, + }, + }, archivedPricings: newArchived, }; @@ -594,7 +572,11 @@ class ServiceService { name: uploadedPricing.saasName, disabled: false, organizationId: organizationId, - activePricings: new Map([[formattedPricingVersion, { id: savedPricing.id }]]), + activePricings: { + [formattedPricingVersion]: { + url: pricingUrl, + }, + }, }; const service = await this.serviceRepository.create(serviceData); @@ -660,22 +642,16 @@ class ServiceService { updatePayload.disabled = false; updatePayload.organizationId = organizationId; - const savedPricing = await this.pricingRepository.create(pricingData); - - if (!savedPricing) { - throw new Error(`Pricing ${uploadedPricing.version} not saved`); - } - - updatePayload.activePricings = new Map([[formattedPricingVersion, { id: savedPricing.id }]]); + updatePayload.activePricings = { + [formattedPricingVersion]: { + url: pricingUrl, + }, + }; updatePayload.archivedPricings = newArchived; } else { - const savedPricing = await this.pricingRepository.create(pricingData); - - if (!savedPricing) { - throw new Error(`Pricing ${uploadedPricing.version} not saved`); - } - - updatePayload[`activePricings.${formattedPricingVersion}`] = { id: savedPricing.id }; + updatePayload[`activePricings.${formattedPricingVersion}`] = { + url: pricingUrl, + }; } const updatedService = await this.serviceRepository.update( @@ -726,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); @@ -759,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) { @@ -776,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}`; @@ -1201,13 +1198,13 @@ class ServiceService { } async _removeServiceFromContracts(serviceName: string, organizationId: string): Promise { - 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 = { @@ -1215,42 +1212,42 @@ class ServiceService { 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 = { @@ -1259,16 +1256,16 @@ class ServiceService { autoRenew: false, renewalDays: 0, }; - + contractsToDisable.push(newContract); continue; } - + novatedContracts.push(newContract); } return true; - }catch(err){ + } catch (err) { return false; } }