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) { return typeof value === 'string' ? value.trim() : ''; } 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; } 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 renderTemplateWithUsername(template, username) { return String(template || '').replace(/\{#var#\}/g, normalizeText(username)); } 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 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 { const companyId = getCompanyId(req); const applicationId = getApplicationId(req); const event = normalizeText(req.body?.event); const username = normalizeText(req.body?.username); if (!companyId) return res.status(400).json({ error: 'companyId is required' }); if (!applicationId) return res.status(400).json({ error: 'applicationId is required' }); if (!event) return res.status(400).json({ error: 'event is required' }); if (!username) return res.status(400).json({ error: 'username is required' }); const business = await findBusinessByApplicationId(companyId, applicationId); if (!business) { return res.status(404).json({ error: 'Business not found for applicationId' }); } 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' }); } res.json({ success: true, companyId, applicationId, event: eventSlug, username, templateId: normalizeText(tmpl.templateId), template: renderTemplateWithUsername(tmpl.selectedTemplate, username), }); } 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/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;