const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { scrape } = require('../services/firecrawl'); const { parseBrandContext, generateTemplates, processCurl, validateCurlFields } = require('../services/openai2'); const { sendViaWorkflow } = require('../services/workflowSender'); const { uploadJSON, fetchJSON, uploadImageFromUrl, listImages, listTemplateFiles, deleteBusinessFiles, } = require('../services/pixelbin'); const DEFAULT_EVENTS = require('../config/defaultEvents'); const axios = require('axios'); const MERCHANT_ID = () => process.env.MERCHANT_ID; function normalizeScopeId(value) { if (typeof value === 'string') return value.trim(); if (typeof value === 'number' && Number.isFinite(value)) return String(value); return ''; } function getCompanyId(req) { return normalizeScopeId( req.get('x-company-id') || req.query?.companyId || req.query?.company_id || req.body?.companyId || req.body?.company_id || MERCHANT_ID() ); } function getApplicationId(req) { return normalizeScopeId( req.get('x-application-id') || req.query?.applicationId || req.query?.application_id || req.body?.applicationId || req.body?.application_id ); } // ─── Helpers ────────────────────────────────────────────────────────────────── function slugify(text) { return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); } function businessRoot(merchantId, businessId) { return `${merchantId}/${businessId}`; } function indexPath(merchantId) { return merchantId; // index.json lives at {merchantId}/index.json } async function getIndex(merchantId) { const data = await fetchJSON(indexPath(merchantId), 'index'); return Array.isArray(data?.businesses) ? data.businesses : []; } async function saveIndex(merchantId, businesses) { await uploadJSON(indexPath(merchantId), 'index', { businesses }); } async function findBusinessByApplicationId(merchantId, applicationId) { const normalizedApplicationId = normalizeScopeId(applicationId); if (!normalizedApplicationId) return null; const businesses = await getIndex(merchantId); const exactMatch = businesses.find((business) => { const storedApplicationId = normalizeScopeId(business.applicationId); const storedBusinessId = normalizeScopeId(business.businessId); return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId; }); if (exactMatch) return exactMatch; const normalizedBrandLookup = normalizedApplicationId.toLowerCase(); const brandMatches = businesses.filter((business) => normalizeText(business.brandName).toLowerCase() === normalizedBrandLookup); if (brandMatches.length > 1) { throw createHttpError( 409, 'Multiple businesses matched the provided applicationId brand fallback', { code: 'AMBIGUOUS_BUSINESS_MATCH', details: { companyId: merchantId, applicationId: normalizedApplicationId, matchedBusinesses: brandMatches.map((business) => ({ businessId: business.businessId, brandName: business.brandName, })), }, } ); } return brandMatches[0] || null; } async function findBusinessByBrandName(merchantId, brandName) { const normalizedBrandName = normalizeText(brandName).toLowerCase(); if (!normalizedBrandName) return null; const businesses = await getIndex(merchantId); const brandMatches = businesses.filter((business) => normalizeText(business.brandName).toLowerCase() === normalizedBrandName); if (brandMatches.length > 1) { throw createHttpError( 409, 'Multiple businesses matched the provided brand name', { code: 'AMBIGUOUS_BUSINESS_MATCH', details: { companyId: merchantId, brandName: normalizedBrandName, matchedBusinesses: brandMatches.map((business) => ({ businessId: business.businessId, brandName: business.brandName, })), }, } ); } return brandMatches[0] || null; } const PROVIDER_FIELDS = ['providerName', 'senderId', 'dltEntityId', 'authKey']; function createHttpError(status, message, extra = {}) { const err = new Error(message); err.status = status; Object.assign(err, extra); return err; } function sendRouteError(res, err) { const status = err.status || 500; const body = { error: err.message }; if (err.code) body.code = err.code; if (err.missingFields) body.missingFields = err.missingFields; if (err.template) body.template = err.template; if (err.details) body.details = err.details; res.status(status).json(body); } function normalizeText(value) { return typeof value === 'string' ? value.trim() : ''; } function normalizeSenderId(value) { return normalizeText(value).toUpperCase(); } function isValidCurlCommand(rawCurl) { return normalizeText(rawCurl).toLowerCase().startsWith('curl'); } function validateSenderId(senderId) { if (!senderId) return null; if (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId)) { return 'Sender ID must be exactly 6 alphabetic characters'; } return null; } function normalizeProvider(provider = {}, fallbackUpdatedAt = null) { const updatedAt = provider.updatedAt || fallbackUpdatedAt || new Date().toISOString(); return { providerName: normalizeText(provider.providerName), senderId: normalizeSenderId(provider.senderId), dltEntityId: normalizeText(provider.dltEntityId), authKey: normalizeText(provider.authKey), updatedAt, }; } function getShipmentPayload(body) { return body?.payload?.shipment && typeof body.payload.shipment === 'object' ? body.payload.shipment : null; } function getShipmentBrandName(body) { return normalizeText(body?.payload?.shipment?.bags?.[0]?.brand?.brand_name); } function getShipmentEventKey(body) { return normalizeText(body?.payload?.shipment?.status); } function getShipmentToNumber(body) { return normalizeText(body?.payload?.shipment?.user?.mobile); } const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g; function normalizeRenderableValue(value) { if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim(); if (typeof value === 'number' && Number.isFinite(value)) return String(value); if (typeof value === 'boolean') return value ? 'true' : 'false'; return ''; } function toCamelCase(text) { return String(text || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[^a-zA-Z0-9]+/g, ' ') .trim() .split(/\s+/) .filter(Boolean) .map((part, index) => { const lower = part.toLowerCase(); return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); }) .join(''); } function setValueIndexEntry(valueIndex, key, value) { if (!key || valueIndex.has(key)) return; valueIndex.set(key, value); } function indexShipmentValues(value, pathParts = [], valueIndex = new Map()) { if (Array.isArray(value)) { value.forEach((entry) => indexShipmentValues(entry, pathParts, valueIndex)); return valueIndex; } if (value && typeof value === 'object') { Object.entries(value).forEach(([key, entry]) => { indexShipmentValues(entry, [...pathParts, key], valueIndex); }); return valueIndex; } const normalizedValue = normalizeRenderableValue(value); if (!normalizedValue || pathParts.length === 0) return valueIndex; const leafKey = toCamelCase(pathParts[pathParts.length - 1]); const fullKey = toCamelCase(pathParts.join(' ')); setValueIndexEntry(valueIndex, leafKey, normalizedValue); setValueIndexEntry(valueIndex, fullKey, normalizedValue); return valueIndex; } function buildShipmentValueIndex(shipment) { const valueIndex = indexShipmentValues(shipment); const firstBag = shipment?.bags?.[0] || {}; const primaryTrackingUrl = normalizeRenderableValue( shipment?.delivery_partner_details?.track_url || shipment?.meta?.tracking_url || firstBag?.meta?.tracking_url || shipment?.affiliate_details?.shipment_meta?.tracking_url || shipment?.article_details?.dp_details?.track_url ); const primaryAwbNumber = normalizeRenderableValue( shipment?.delivery_partner_details?.awb_no || shipment?.meta?.awb_number || shipment?.article_details?.dp_details?.awb_no ); const primaryCourierName = normalizeRenderableValue( shipment?.delivery_partner_details?.display_name || shipment?.delivery_partner_details?.name || shipment?.meta?.courier_partner_name || shipment?.meta?.dp_name || firstBag?.meta?.dp_name ); const brandName = normalizeRenderableValue( shipment?.bags?.[0]?.brand?.brand_name || shipment?.bags?.[0]?.item?.attributes?.brand_name || shipment?.affiliate_details?.company_affiliate_tag ); setValueIndexEntry(valueIndex, 'firstName', normalizeRenderableValue(shipment?.user?.first_name)); setValueIndexEntry(valueIndex, 'lastName', normalizeRenderableValue(shipment?.user?.last_name)); setValueIndexEntry(valueIndex, 'fullName', normalizeRenderableValue( `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim() )); setValueIndexEntry(valueIndex, 'phone', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone)); setValueIndexEntry(valueIndex, 'mobile', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone)); setValueIndexEntry(valueIndex, 'toNumber', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone)); setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id)); setValueIndexEntry(valueIndex, 'shipmentId', normalizeRenderableValue(shipment?.shipment_id)); setValueIndexEntry(valueIndex, 'event', normalizeRenderableValue(shipment?.status)); setValueIndexEntry(valueIndex, 'status', normalizeRenderableValue(shipment?.status)); setValueIndexEntry(valueIndex, 'eventDisplayName', normalizeRenderableValue(shipment?.shipment_status?.display_name)); setValueIndexEntry(valueIndex, 'displayName', normalizeRenderableValue(shipment?.shipment_status?.display_name)); setValueIndexEntry(valueIndex, 'brandName', brandName); setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl); setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl); setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl); setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber); setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber); setValueIndexEntry(valueIndex, 'dpName', primaryCourierName); setValueIndexEntry(valueIndex, 'courierName', primaryCourierName); setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName); return valueIndex; } function validateRenderedPlaceholderValue(token, value, fieldName) { if (!value) { throw createHttpError(422, `No shipment value found for placeholder field "${fieldName}"`); } if (token === '{#numeric#}' && !/^\d+$/.test(value)) { throw createHttpError(422, `Field "${fieldName}" resolved to a non-numeric value for ${token}`); } if (token === '{#url#}' && !/^https?:\/\//i.test(value)) { throw createHttpError(422, `Field "${fieldName}" resolved to an invalid URL for ${token}`); } if (token === '{#cbn#}' && !/^\+?[0-9][0-9\s-]{5,}$/.test(value)) { throw createHttpError(422, `Field "${fieldName}" resolved to an invalid callback number for ${token}`); } } function renderShipmentTemplate(template, shipment, variableMap = {}) { const normalizedTemplate = normalizeText(template); const placeholderMatches = normalizedTemplate.match(DLT_PLACEHOLDER_REGEX) || []; if (placeholderMatches.length === 0) { return normalizedTemplate; } if (!variableMap || typeof variableMap !== 'object' || Object.keys(variableMap).length === 0) { throw createHttpError(422, 'Template has placeholders but no variableMap was found on the stored template'); } const shipmentValueIndex = buildShipmentValueIndex(shipment); let placeholderIndex = 0; return normalizedTemplate.replace(DLT_PLACEHOLDER_REGEX, (token) => { const mappingKey = `${token}[${placeholderIndex}]`; const mappedFieldName = normalizeText(variableMap[mappingKey]); if (!mappedFieldName) { throw createHttpError(422, `No variable mapping found for placeholder ${mappingKey}`, { details: { mappingKey, variableMap }, }); } const resolvedValue = shipmentValueIndex.get(toCamelCase(mappedFieldName)) || ''; validateRenderedPlaceholderValue(token, resolvedValue, mappedFieldName); placeholderIndex += 1; return resolvedValue; }); } function parseWorkflowPayload(data) { if (typeof data === 'string') { const trimmed = data.trim(); if (!trimmed) return {}; try { return JSON.parse(trimmed); } catch { return { status: trimmed }; } } return data && typeof data === 'object' ? data : {}; } async function sendResolveTemplateWorkflow({ content, toNumber }) { const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE); if (!workflowUrl) { throw createHttpError(500, 'WORKFLOW_URL_RESOLVE_TEMPLATE is not configured'); } const response = await axios.post( workflowUrl, { content, toNumber }, { timeout: 30000, headers: { 'Content-Type': 'application/json' }, validateStatus: () => true, } ); if (response.status < 200 || response.status >= 300) { throw createHttpError( 502, `Resolve-template workflow failed with status ${response.status}`, { details: response.data } ); } return { statusCode: response.status, response: response.data, }; } function normalizeEditedTemplateValidation(data) { const payload = parseWorkflowPayload(data); let approved = null; if (typeof payload.approved === 'boolean') approved = payload.approved; if (approved === null && typeof payload.isApproved === 'boolean') approved = payload.isApproved; if (approved === null && typeof payload.valid === 'boolean') approved = payload.valid; if (approved === null && typeof payload.is_valid === 'boolean') approved = payload.is_valid; if (approved === null) { const status = normalizeText(payload.status || payload.result || payload.decision).toLowerCase(); if (['approved', 'pass', 'passed', 'valid', 'ok'].includes(status)) approved = true; if (['rejected', 'not_approved', 'failed', 'fail', 'invalid', 'needs_changes'].includes(status)) approved = false; } if (approved === null) { throw createHttpError(502, 'Template edit validation workflow returned an unreadable response', { details: payload, }); } return { approved, why: normalizeText(payload.why || payload.reason || payload.message || payload.feedback), workflowResult: payload, }; } async function validateEditedTemplateWorkflow(editedTemplate) { const workflowUrl = normalizeText(process.env.WORKFLOW_URL_TEMPLATE_EDIT_CHECK); if (!workflowUrl) { throw createHttpError(500, 'WORKFLOW_URL_TEMPLATE_EDIT_CHECK is not configured'); } const response = await axios.post( workflowUrl, { editedTemplate }, { timeout: 30000, headers: { 'Content-Type': 'application/json' }, validateStatus: () => true, } ); if (response.status < 200 || response.status >= 300) { throw createHttpError( 502, `Template edit validation workflow failed with status ${response.status}`, { details: response.data } ); } return normalizeEditedTemplateValidation(response.data); } function getProviderPatch(input) { if (!input || typeof input !== 'object') return null; let hasField = false; const patch = {}; for (const field of PROVIDER_FIELDS) { if (!Object.prototype.hasOwnProperty.call(input, field)) continue; hasField = true; patch[field] = field === 'senderId' ? normalizeSenderId(input[field]) : normalizeText(input[field]); } return hasField ? patch : null; } function mergeProviderState(extractedProvider, currentProvider, providerPatch, options = {}) { const { preserveCurrent = true, updatedAt = new Date().toISOString() } = options; let merged = { ...normalizeProvider(extractedProvider, updatedAt), updatedAt }; if (preserveCurrent && currentProvider) { const normalizedCurrent = normalizeProvider(currentProvider, updatedAt); for (const field of PROVIDER_FIELDS) { if (normalizedCurrent[field]) { merged[field] = normalizedCurrent[field]; } } } if (providerPatch) { for (const field of PROVIDER_FIELDS) { if (Object.prototype.hasOwnProperty.call(providerPatch, field)) { merged[field] = providerPatch[field]; } } } merged.updatedAt = updatedAt; return merged; } function hydrateProfile(profile = {}) { return { ...profile, provider: normalizeProvider(profile.provider, profile.updatedAt), }; } function hydrateProfileData(profileData) { const profiles = Array.isArray(profileData?.profiles) ? profileData.profiles.map(hydrateProfile) : []; return { profiles }; } async function getProfileState(bizRoot) { const [rawProfileData, activeRec] = await Promise.all([ fetchJSON(bizRoot, 'global_sms_profiles'), fetchJSON(bizRoot, 'active_curl_profile'), ]); const profileData = hydrateProfileData(rawProfileData); const activeProfileId = activeRec?.profileId || (profileData.profiles[0]?.id ?? null); const activeProfile = profileData.profiles.find(p => p.id === activeProfileId) || profileData.profiles[0] || null; return { profileData, activeProfile, activeProfileId }; } async function getActiveProfile(bizRoot) { try { const { activeProfile } = await getProfileState(bizRoot); return activeProfile; } catch { return null; } } async function getBoundProfile(bizRoot, curlProfileId) { if (!curlProfileId) { throw createHttpError( 422, 'This template is not bound to a cURL profile. Re-select the template from Events before continuing.', { code: 'MISSING_BOUND_PROFILE' } ); } const { profileData } = await getProfileState(bizRoot); const boundProfile = profileData.profiles.find(profile => profile.id === curlProfileId); if (!boundProfile) { throw createHttpError( 422, 'The cURL profile bound to this template no longer exists. Re-select the template from Events before continuing.', { code: 'BOUND_PROFILE_NOT_FOUND' } ); } return boundProfile; } async function validateCurlAndExtractProvider(rawCurl) { try { const validation = await validateCurlFields(rawCurl); if (!validation.isValidCurl) { throw createHttpError(422, validation.reason || 'The provided cURL is invalid'); } const provider = normalizeProvider(validation.provider); const senderIdError = validateSenderId(provider.senderId); if (senderIdError) { throw createHttpError(422, senderIdError); } return provider; } catch (err) { if (err.status) throw err; throw createHttpError(502, `cURL validation failed: ${err.message}`); } } async function updateProfileProvider(profile, providerPatch, rawCurlOverride) { const effectiveCurl = normalizeText(rawCurlOverride !== undefined ? rawCurlOverride : profile.rawCurl); const extractedProvider = await validateCurlAndExtractProvider(effectiveCurl); const preserveCurrent = rawCurlOverride === undefined; const updatedAt = new Date().toISOString(); profile.provider = mergeProviderState( extractedProvider, profile.provider, providerPatch, { preserveCurrent, updatedAt } ); profile.updatedAt = updatedAt; return profile; } function getMissingMandatoryProviderFields(provider = {}) { const normalized = normalizeProvider(provider); const missing = []; if (!normalized.providerName) missing.push('providerName'); if (!normalized.senderId) missing.push('senderId'); if (!normalized.dltEntityId) missing.push('dltEntityId'); return missing; } // ─── Business CRUD ──────────────────────────────────────────────────────────── // GET /api/businesses router.get('/', async (req, res) => { try { const businesses = await getIndex(getCompanyId(req)); res.json({ businesses }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/businesses — create new business from websiteUrl router.post('/', async (req, res) => { try { const { websiteUrl } = req.body; if (!websiteUrl) return res.status(400).json({ error: 'websiteUrl is required' }); const merchantId = getCompanyId(req); const applicationId = getApplicationId(req); const businesses = await getIndex(merchantId); if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) { return res.status(409).json({ error: 'A business is already configured for this applicationId' }); } const businessId = uuidv4(); const bizRoot = businessRoot(merchantId, businessId); const imagesFolder = `${bizRoot}/images`; // 1. Scrape const scrapedData = await scrape(websiteUrl); // 2. Parse brand context const brandContext = await parseBrandContext(scrapedData); // 3. Upload relevant images const imagePaths = []; for (let i = 0; i < Math.min((brandContext.relevantImageUrls || []).length, 5); i++) { const url = await uploadImageFromUrl(brandContext.relevantImageUrls[i], imagesFolder, `image_${i + 1}`); if (url) imagePaths.push(url); } // 4. Build and upload context.json let domain = ''; try { domain = new URL(websiteUrl).hostname; } catch { } const contextJson = { businessId, merchantId, companyId: merchantId, applicationId, domain, brandName: brandContext.brandName || 'Unknown Brand', tone: brandContext.tone || 'professional', taglines: brandContext.taglines || [], colors: brandContext.colors || [], relevantImagePaths: imagePaths, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await uploadJSON(bizRoot, 'context', contextJson); // 5. Init events.json await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS }); // 6. Update index.json businesses.push({ businessId, companyId: merchantId, applicationId, brandName: contextJson.brandName, domain: contextJson.domain, createdAt: contextJson.createdAt, updatedAt: contextJson.updatedAt, }); await saveIndex(merchantId, businesses); res.json(contextJson); } catch (err) { console.error('Create business error:', err.message); res.status(500).json({ error: err.message }); } }); // GET /api/businesses/:businessId router.get('/:businessId', async (req, res) => { try { const { businessId } = req.params; const context = await fetchJSON(businessRoot(getCompanyId(req), businessId), 'context'); if (!context) return res.status(404).json({ error: 'Business not found' }); res.json(context); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/businesses/:businessId router.delete('/:businessId', async (req, res) => { try { const merchantId = getCompanyId(req); const { businessId } = req.params; await deleteBusinessFiles(merchantId, businessId); const businesses = await getIndex(merchantId); const updated = businesses.filter(b => b.businessId !== businessId); await saveIndex(merchantId, updated); res.json({ ok: true }); } catch (err) { console.error('Delete business error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/businesses/resolve-template router.post('/resolve-template', async (req, res) => { try { console.log('[ResolveTemplate] Incoming payload:', JSON.stringify(req.body, null, 2)); const companyId = getCompanyId(req); const shipment = getShipmentPayload(req.body); const brandName = getShipmentBrandName(req.body); const event = getShipmentEventKey(req.body); const toNumber = getShipmentToNumber(req.body); if (!companyId) return res.status(400).json({ error: 'companyId is required' }); if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' }); if (!brandName) return res.status(400).json({ error: 'payload.shipment.bags[0].brand.brand_name is required' }); if (!event) return res.status(400).json({ error: 'payload.shipment.status is required' }); if (!toNumber) return res.status(400).json({ error: 'payload.shipment.user.mobile is required' }); const business = await findBusinessByBrandName(companyId, brandName); if (!business) { return res.status(404).json({ error: 'Business not found for brand name' }); } const eventSlug = slugify(event); const folder = `${businessRoot(companyId, business.businessId)}/templates`; const tmpl = await fetchJSON(folder, eventSlug); if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) { return res.status(404).json({ error: 'Whitelisted template not found' }); } const resolvedTemplate = renderShipmentTemplate( tmpl.selectedTemplate, shipment, tmpl.variableMap || {} ); const workflowResult = await sendResolveTemplateWorkflow({ content: resolvedTemplate, toNumber, }); res.json({ success: true, companyId, businessId: business.businessId, brandName, event: eventSlug, templateId: normalizeText(tmpl.templateId), template: tmpl.selectedTemplate, content: resolvedTemplate, toNumber, workflowResult, }); } catch (err) { sendRouteError(res, err); } }); // ─── Providers ──────────────────────────────────────────────────────────────── // GET /api/businesses/:businessId/providers router.get('/:businessId/providers', async (req, res) => { try { const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const activeProfile = await getActiveProfile(bizRoot); if (!activeProfile) { return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' }); } res.json(activeProfile.provider || {}); } catch (err) { sendRouteError(res, err); } }); // POST /api/businesses/:businessId/providers router.post('/:businessId/providers', async (req, res) => { try { const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const providerPatch = getProviderPatch(req.body); const senderIdError = validateSenderId(providerPatch?.senderId || ''); if (senderIdError) { return res.status(400).json({ error: senderIdError }); } const { profileData, activeProfile, activeProfileId } = await getProfileState(bizRoot); if (!activeProfile || !activeProfileId) { return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' }); } const profile = profileData.profiles.find(item => item.id === activeProfileId); await updateProfileProvider(profile, providerPatch); await uploadJSON(bizRoot, 'global_sms_profiles', profileData); res.json(profile.provider); } catch (err) { sendRouteError(res, err); } }); // ─── Global SMS cURL (Compatibility layer) ─────────────────────────────────── // These routes delegate to the active/default profile model. // GET /api/businesses/:businessId/global-sms router.get('/:businessId/global-sms', async (req, res) => { try { const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const activeProfile = await getActiveProfile(bizRoot); res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {}); } catch (err) { sendRouteError(res, err); } }); // POST /api/businesses/:businessId/global-sms // Compat: creates/updates a default profile and sets it active. router.post('/:businessId/global-sms', async (req, res) => { try { const { rawCurl } = req.body; if (!normalizeText(rawCurl)) { return res.status(400).json({ error: 'rawCurl is required' }); } if (!isValidCurlCommand(rawCurl)) { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { profileData } = await getProfileState(bizRoot); const now = new Date().toISOString(); const normalizedCurl = normalizeText(rawCurl); const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl); // Find or create the default profile let defaultProfile = profileData.profiles.find(p => p.name === 'Default'); if (defaultProfile) { defaultProfile.rawCurl = normalizedCurl; defaultProfile.provider = extractedProvider; defaultProfile.updatedAt = now; } else { defaultProfile = { id: uuidv4(), name: 'Default', rawCurl: normalizedCurl, isDefault: true, provider: extractedProvider, createdAt: now, updatedAt: now, }; profileData.profiles.push(defaultProfile); } await uploadJSON(bizRoot, 'global_sms_profiles', profileData); await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now }); res.json({ rawCurl: normalizedCurl, updatedAt: now }); } catch (err) { sendRouteError(res, err); } }); // ─── cURL Profiles CRUD ──────────────────────────────────────────────────────── // GET /api/businesses/:businessId/global-sms/profiles router.get('/:businessId/global-sms/profiles', async (req, res) => { try { const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { profileData, activeProfileId } = await getProfileState(bizRoot); const profiles = profileData.profiles || []; res.json({ profiles, activeProfileId }); } catch (err) { sendRouteError(res, err); } }); // POST /api/businesses/:businessId/global-sms/profiles router.post('/:businessId/global-sms/profiles', async (req, res) => { try { const { name, rawCurl, setActive } = req.body; if (!normalizeText(name)) { return res.status(400).json({ error: 'name is required' }); } if (!normalizeText(rawCurl)) { return res.status(400).json({ error: 'rawCurl is required' }); } if (!isValidCurlCommand(rawCurl)) { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { profileData } = await getProfileState(bizRoot); const now = new Date().toISOString(); const normalizedCurl = normalizeText(rawCurl); const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl); const newProfile = { id: uuidv4(), name: normalizeText(name), rawCurl: normalizedCurl, isDefault: false, provider: extractedProvider, createdAt: now, updatedAt: now, }; profileData.profiles.push(newProfile); await uploadJSON(bizRoot, 'global_sms_profiles', profileData); // Activate this profile if requested or if it is the first one if (setActive || profileData.profiles.length === 1) { await uploadJSON(bizRoot, 'active_curl_profile', { profileId: newProfile.id, updatedAt: now }); } res.json(newProfile); } catch (err) { sendRouteError(res, err); } }); // PATCH /api/businesses/:businessId/global-sms/profiles/:profileId router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) => { try { const { businessId, profileId } = req.params; const { name, rawCurl } = req.body; const providerPatch = getProviderPatch(req.body.provider || req.body); if (name !== undefined && !normalizeText(name)) { return res.status(400).json({ error: 'name is required' }); } if (rawCurl !== undefined && !normalizeText(rawCurl)) { return res.status(400).json({ error: 'rawCurl is required' }); } if (rawCurl !== undefined && !isValidCurlCommand(rawCurl)) { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } const senderIdError = validateSenderId(providerPatch?.senderId || ''); if (senderIdError) { return res.status(400).json({ error: senderIdError }); } const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const profile = profileData.profiles.find(p => p.id === profileId); if (!profile) return res.status(404).json({ error: 'Profile not found' }); if (name !== undefined) profile.name = normalizeText(name); if (rawCurl !== undefined) profile.rawCurl = normalizeText(rawCurl); await updateProfileProvider(profile, providerPatch, rawCurl !== undefined ? profile.rawCurl : undefined); await uploadJSON(bizRoot, 'global_sms_profiles', profileData); res.json(profile); } catch (err) { sendRouteError(res, err); } }); // DELETE /api/businesses/:businessId/global-sms/profiles/:profileId router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => { try { const { businessId, profileId } = req.params; const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const idx = profileData.profiles.findIndex(p => p.id === profileId); if (idx === -1) return res.status(404).json({ error: 'Profile not found' }); if (profileData.profiles.length === 1) { return res.status(400).json({ error: 'Cannot delete the last cURL profile' }); } profileData.profiles.splice(idx, 1); await uploadJSON(bizRoot, 'global_sms_profiles', profileData); // If deleted profile was active, switch to first remaining const activeRec = await fetchJSON(bizRoot, 'active_curl_profile'); if (activeRec?.profileId === profileId) { await uploadJSON(bizRoot, 'active_curl_profile', { profileId: profileData.profiles[0].id, updatedAt: new Date().toISOString() }); } res.json({ ok: true }); } catch (err) { sendRouteError(res, err); } }); // POST /api/businesses/:businessId/global-sms/profiles/:profileId/activate router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, res) => { try { const { businessId, profileId } = req.params; const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const profile = profileData.profiles.find(p => p.id === profileId); if (!profile) return res.status(404).json({ error: 'Profile not found' }); await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() }); res.json({ activeProfileId: profileId }); } catch (err) { sendRouteError(res, err); } }); // GET /api/businesses/:businessId/global-sms/active router.get('/:businessId/global-sms/active', async (req, res) => { try { const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { activeProfile, activeProfileId } = await getProfileState(bizRoot); res.json({ activeProfile, activeProfileId }); } catch (err) { sendRouteError(res, err); } }); // ─── Events ─────────────────────────────────────────────────────────────────── // GET /api/businesses/:businessId/events router.get('/:businessId/events', async (req, res) => { try { const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events'); res.json(data || { events: DEFAULT_EVENTS }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/businesses/:businessId/events — add custom event router.post('/:businessId/events', async (req, res) => { try { const { label } = req.body; if (!label) return res.status(400).json({ error: 'label is required' }); const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] }; const slug = slugify(label); if (data.events.some(e => e.slug === slug)) { return res.status(409).json({ error: 'An event with this name already exists' }); } const newEvent = { slug, label, isDefault: false }; data.events.push(newEvent); await uploadJSON(bizRoot, 'events', data); res.json(newEvent); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/businesses/:businessId/events/:slug router.delete('/:businessId/events/:slug', async (req, res) => { try { const { businessId, slug } = req.params; const bizRoot = businessRoot(getCompanyId(req), businessId); const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] }; const event = data.events.find(e => e.slug === slug); if (!event) return res.status(404).json({ error: 'Event not found' }); if (event.isDefault) return res.status(403).json({ error: 'Cannot delete a default event' }); data.events = data.events.filter(e => e.slug !== slug); await uploadJSON(bizRoot, 'events', data); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/businesses/:businessId/events/:slug/generate router.post('/:businessId/events/:slug/generate', async (req, res) => { try { const { businessId, slug } = req.params; const bizRoot = businessRoot(getCompanyId(req), businessId); const context = await fetchJSON(bizRoot, 'context'); if (!context) return res.status(400).json({ error: 'Business context not found.' }); const activeProfile = await getActiveProfile(bizRoot); if (!activeProfile?.rawCurl) { return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' }); } const eventsData = await fetchJSON(bizRoot, 'events') || { events: DEFAULT_EVENTS }; const event = eventsData.events.find(e => e.slug === slug); if (!event) return res.status(404).json({ error: 'Event not found' }); const variants = await generateTemplates(context, slug, event.label); const templateJson = { eventSlug: slug, eventLabel: event.label, generatedVariants: variants, selectedTemplate: null, status: 'generated', templateId: '', curlProfileId: activeProfile.id, rawCurl: '', processedCurl: '', variableMap: {}, selectedImagePath: '', updatedAt: new Date().toISOString(), }; await uploadJSON(`${bizRoot}/templates`, slug, templateJson); res.json({ variants }); } catch (err) { console.error('Generate error:', err.message); res.status(500).json({ error: err.message }); } }); // ─── Templates ──────────────────────────────────────────────────────────────── // GET /api/businesses/:businessId/templates/images (must be before /:slug) router.get('/:businessId/templates/images', async (req, res) => { try { const images = await listImages(`${businessRoot(getCompanyId(req), req.params.businessId)}/images`); res.json({ images }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/businesses/:businessId/templates router.get('/:businessId/templates', async (req, res) => { try { const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const folder = `${bizRoot}/templates`; const slugs = await listTemplateFiles(folder); const templates = []; for (const slug of slugs) { const tmpl = await fetchJSON(folder, slug); if (tmpl) templates.push(tmpl); } res.json({ templates }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/businesses/:businessId/templates/:slug router.get('/:businessId/templates/:slug', async (req, res) => { try { const { businessId, slug } = req.params; const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); res.json(tmpl); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/businesses/:businessId/templates/:slug/validate-edit router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => { try { const { businessId, slug } = req.params; const editedTemplate = normalizeText(req.body?.editedTemplate); if (!editedTemplate) { return res.status(400).json({ error: 'editedTemplate is required' }); } const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`; const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); const validation = await validateEditedTemplateWorkflow(editedTemplate); res.json(validation); } catch (err) { sendRouteError(res, err); } }); // POST /api/businesses/:businessId/templates/:slug/select router.post('/:businessId/templates/:slug/select', async (req, res) => { try { const { businessId, slug } = req.params; const { selectedVariant } = req.body; if (!selectedVariant) return res.status(400).json({ error: 'selectedVariant is required' }); const bizRoot = businessRoot(getCompanyId(req), businessId); const folder = `${bizRoot}/templates`; const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); const activeProfile = await getActiveProfile(bizRoot); const activeCurl = activeProfile?.rawCurl || null; if (!activeProfile?.id || !activeCurl) { return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' }); } // Process the cURL against the selected template const { processedCurl, variableMap } = await processCurl(activeCurl, selectedVariant, slug); tmpl.selectedTemplate = selectedVariant; tmpl.generatedVariants = []; // discard non-selected variants tmpl.status = 'pending_whitelisting'; tmpl.curlProfileId = activeProfile.id; // snapshot which profile was used tmpl.rawCurl = activeCurl; tmpl.processedCurl = processedCurl; tmpl.variableMap = variableMap; tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); res.json(tmpl); } catch (err) { console.error('Select error:', err.message); res.status(500).json({ error: err.message }); } }); // POST /api/businesses/:businessId/templates/:slug/whitelist router.post('/:businessId/templates/:slug/whitelist', async (req, res) => { try { const { businessId, slug } = req.params; const { templateId } = req.body; if (!templateId || !String(templateId).trim()) { return res.status(400).json({ error: 'templateId is required' }); } const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`; const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); if (tmpl.status !== 'pending_whitelisting') { return res.status(400).json({ error: 'Template must be in pending_whitelisting status to whitelist' }); } tmpl.templateId = String(templateId).trim(); tmpl.status = 'whitelisted'; tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); res.json(tmpl); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/businesses/:businessId/templates/:slug/publish // Handles transition from pending_whitelisting -> whitelisted (Published). // Validates mandatory provider fields, collects toNumber, then calls workflow sender. router.post('/:businessId/templates/:slug/publish', async (req, res) => { try { const { businessId, slug } = req.params; const { templateId, toNumber } = req.body; if (!normalizeText(templateId)) { return res.status(400).json({ error: 'templateId is required' }); } if (!normalizeText(toNumber)) { return res.status(400).json({ error: 'toNumber is required' }); } const bizRoot = businessRoot(getCompanyId(req), businessId); const folder = `${bizRoot}/templates`; // Load template const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); if (tmpl.status !== 'pending_whitelisting') { return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' }); } const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId); const missingFields = getMissingMandatoryProviderFields(boundProfile.provider); if (missingFields.length > 0) { return res.status(422).json({ error: 'Missing mandatory provider fields', missingFields, code: 'MISSING_BOUND_PROFILE_FIELDS', }); } const senderIdError = validateSenderId(boundProfile.provider.senderId); if (senderIdError) { return res.status(400).json({ error: senderIdError }); } // Mark template as whitelisted tmpl.templateId = normalizeText(templateId); tmpl.status = 'whitelisted'; tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); // Send via workflow (Published path) let sendResult; try { sendResult = await sendViaWorkflow({ senderId: boundProfile.provider.senderId, toNumber: normalizeText(toNumber), content: tmpl.selectedTemplate || '', }); } catch (sendErr) { // Template is already whitelisted at this point; report send error separately return res.status(502).json({ error: 'Template published but send failed', details: sendErr.message, template: tmpl, }); } res.json({ success: true, template: tmpl, sendResult, }); } catch (err) { console.error('Publish error:', err.message); sendRouteError(res, err); } }); // POST /api/businesses/:businessId/templates/:slug/test // For Published (whitelisted) templates: routes to workflow sender (new path). // Legacy cURL execution code below (executeCurl) is kept intact and is NOT deleted. router.post('/:businessId/templates/:slug/test', async (req, res) => { try { const { businessId, slug } = req.params; const { toNumber } = req.body; if (!normalizeText(toNumber)) return res.status(400).json({ error: 'toNumber is required' }); const bizRoot = businessRoot(getCompanyId(req), businessId); const folder = `${bizRoot}/templates`; const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); if (tmpl.status !== 'whitelisted') { return res.status(400).json({ error: 'Template must be whitelisted before testing' }); } if (!tmpl.templateId) { return res.status(400).json({ error: 'templateId must be set before testing' }); } // ── Published send path: route to workflow sender ────────────────────────── // Per plan: Published (whitelisted) templates use the workflow sender module, // not the legacy cURL execution path. The cURL code below remains but is not // reached for whitelisted templates in this branch. const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId); if (!boundProfile.provider?.senderId) { return res.status(422).json({ error: 'Provider senderId is required for sending', missingFields: ['senderId'], code: 'MISSING_BOUND_PROFILE_FIELDS', }); } const senderIdError = validateSenderId(boundProfile.provider.senderId); if (senderIdError) { return res.status(400).json({ error: senderIdError }); } let smsResult; try { smsResult = await sendViaWorkflow({ senderId: boundProfile.provider.senderId, toNumber: normalizeText(toNumber), content: tmpl.selectedTemplate || '', }); } catch (sendErr) { return res.status(502).json({ error: 'SMS send failed', details: sendErr.message }); } res.json({ success: true, statusCode: smsResult.statusCode, response: smsResult.response }); // ── Legacy cURL execution path (preserved, not reached for whitelisted) ──── // The code below is kept for reference and future restoration. // It is NOT executed in the current Published send flow. /* let curlToExecute = String(tmpl.processedCurl || tmpl.rawCurl || ''); curlToExecute = curlToExecute.replace(/\{#toNumber#\}/g, toNumber); curlToExecute = curlToExecute.replace(/\{#to#\}/g, toNumber); curlToExecute = curlToExecute.replace(/\{#mobile#\}/g, toNumber); curlToExecute = curlToExecute.replace(/\{#phone#\}/g, toNumber); let legacyResult; try { legacyResult = await executeCurl(curlToExecute); } catch (curlErr) { return res.status(502).json({ error: 'SMS send failed', details: curlErr.message }); } res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data }); */ } catch (err) { sendRouteError(res, err); } }); /** * Minimal cURL parser: extracts method, URL, headers, and body from a cURL string, * then executes via axios. */ async function executeCurl(curlStr) { const method = /-X\s+([A-Z]+)/i.exec(curlStr)?.[1]?.toLowerCase() || 'post'; const urlMatch = /curl\s+(?:-[^\s]+\s+)*['"]?(https?:\/\/[^\s'"]+)['"]?/i.exec(curlStr); if (!urlMatch) throw new Error('Could not parse URL from cURL command'); const url = urlMatch[1]; // Extract headers (-H "Key: Value") const headers = {}; const headerRegex = /-H\s+['"]([^'"]+)['"]/gi; let hMatch; while ((hMatch = headerRegex.exec(curlStr)) !== null) { const parts = hMatch[1].split(/:\s*(.+)/); if (parts.length >= 2) headers[parts[0].trim()] = parts[1].trim(); } // Extract body (-d or --data or --data-raw) const bodyMatch = /(?:--data-raw|--data|-d)\s+['"]([^'"]+)['"]/i.exec(curlStr) || /(?:--data-raw|--data|-d)\s+(\S+)/i.exec(curlStr); const body = bodyMatch ? bodyMatch[1].replace(/\\n/g, '\n') : undefined; return axios({ method, url, headers, data: body, timeout: 15000, validateStatus: () => true, // don't throw on 4xx/5xx so we can relay the response }); } module.exports = router;