sms-extension-1777874553/server/routes/businesses.js
2026-03-26 14:19:26 +05:30

804 lines
31 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { scrape } = require('../services/firecrawl');
const { parseBrandContext, generateTemplates, processCurl } = 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;
// ─── 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 });
}
// ─── Business CRUD ────────────────────────────────────────────────────────────
// GET /api/businesses
router.get('/', async (req, res) => {
try {
const businesses = await getIndex(MERCHANT_ID());
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 = MERCHANT_ID();
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,
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
const businesses = await getIndex(merchantId);
businesses.push({
businessId,
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(MERCHANT_ID(), 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 = MERCHANT_ID();
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 });
}
});
// ─── Providers ────────────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/providers
router.get('/:businessId/providers', async (req, res) => {
try {
const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers');
res.json(data || {});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses/:businessId/providers
// Mandatory fields for publish/send: providerName, senderId, dltEntityId.
// Per plan: these are NOT required to save — user can save partial config and is only
// blocked when switching a template to Published. senderId format is still validated
// if provided, so the stored value is always valid.
router.post('/:businessId/providers', async (req, res) => {
try {
const { providerName, senderId, dltEntityId, authKey } = req.body;
// If senderId is provided, it must still meet the format requirement
if (senderId && (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId))) {
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
}
const config = {
providerName: providerName || '',
senderId: senderId ? senderId.toUpperCase() : '',
dltEntityId: dltEntityId || '',
authKey: authKey || '',
updatedAt: new Date().toISOString(),
};
await uploadJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers', config);
res.json(config);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Global SMS cURL (Compatibility layer — kept so existing sessions/frontend work) ────────────
// The new multi-profile system is below. These two routes delegate to the active profile.
// GET /api/businesses/:businessId/global-sms
// Returns the active cURL profile's rawCurl (or legacy global_sms.json as fallback).
router.get('/:businessId/global-sms', async (req, res) => {
try {
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
const activeProfile = await getActiveProfile(bizRoot);
if (activeProfile) {
return res.json({ rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt });
}
// Fallback: legacy global_sms.json (present on businesses created before profile system)
const data = await fetchJSON(bizRoot, 'global_sms');
res.json(data || {});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 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 (!rawCurl || !rawCurl.trim()) {
return res.status(400).json({ error: 'rawCurl is required' });
}
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
}
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
const now = new Date().toISOString();
// Find or create the default profile
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
if (defaultProfile) {
defaultProfile.rawCurl = rawCurl.trim();
defaultProfile.updatedAt = now;
} else {
defaultProfile = { id: uuidv4(), name: 'Default', rawCurl: rawCurl.trim(), isDefault: true, 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: rawCurl.trim(), updatedAt: now });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── cURL Profile Helpers ─────────────────────────────────────────────────────
async function getActiveProfile(bizRoot) {
try {
const [profileData, activeRec] = await Promise.all([
fetchJSON(bizRoot, 'global_sms_profiles'),
fetchJSON(bizRoot, 'active_curl_profile'),
]);
if (!profileData?.profiles?.length) return null;
if (activeRec?.profileId) {
const found = profileData.profiles.find(p => p.id === activeRec.profileId);
if (found) return found;
}
// Fall back to first profile
return profileData.profiles[0];
} catch {
return null;
}
}
// ─── cURL Profiles CRUD ────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/global-sms/profiles
router.get('/:businessId/global-sms/profiles', async (req, res) => {
try {
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
const [profileData, activeRec] = await Promise.all([
fetchJSON(bizRoot, 'global_sms_profiles'),
fetchJSON(bizRoot, 'active_curl_profile'),
]);
const profiles = profileData?.profiles || [];
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
res.json({ profiles, activeProfileId });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses/:businessId/global-sms/profiles
router.post('/:businessId/global-sms/profiles', async (req, res) => {
try {
const { name, rawCurl, setActive } = req.body;
if (!name || !String(name).trim()) {
return res.status(400).json({ error: 'name is required' });
}
if (!rawCurl || !rawCurl.trim()) {
return res.status(400).json({ error: 'rawCurl is required' });
}
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
}
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
const now = new Date().toISOString();
const newProfile = { id: uuidv4(), name: String(name).trim(), rawCurl: rawCurl.trim(), isDefault: false, 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) {
res.status(500).json({ error: err.message });
}
});
// 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;
if (rawCurl !== undefined && !rawCurl.trim().toLowerCase().startsWith('curl')) {
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
}
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
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 = String(name).trim();
if (rawCurl !== undefined) profile.rawCurl = rawCurl.trim();
profile.updatedAt = new Date().toISOString();
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
res.json(profile);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 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(MERCHANT_ID(), businessId);
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
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) {
res.status(500).json({ error: err.message });
}
});
// 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(MERCHANT_ID(), businessId);
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
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) {
res.status(500).json({ error: err.message });
}
});
// GET /api/businesses/:businessId/global-sms/active
router.get('/:businessId/global-sms/active', async (req, res) => {
try {
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
const [profileData, activeRec] = await Promise.all([
fetchJSON(bizRoot, 'global_sms_profiles'),
fetchJSON(bizRoot, 'active_curl_profile'),
]);
const profiles = profileData?.profiles || [];
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
const activeProfile = profiles.find(p => p.id === activeProfileId) || profiles[0] || null;
res.json({ activeProfile, activeProfileId });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Events ───────────────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/events
router.get('/:businessId/events', async (req, res) => {
try {
const data = await fetchJSON(businessRoot(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), businessId);
// Requirements check
const [context, providers] = await Promise.all([
fetchJSON(bizRoot, 'context'),
fetchJSON(bizRoot, 'providers'),
]);
if (!context) return res.status(400).json({ error: 'Business context not found.' });
if (!providers?.senderId) return res.status(400).json({ error: 'Provider details must be configured before generating templates.' });
// Require an active cURL profile (new system), falling back to legacy global_sms.json
const activeProfile = await getActiveProfile(bizRoot);
const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
if (!activeCurl) {
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 || null,
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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), businessId);
const folder = `${bizRoot}/templates`;
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
// Resolve active cURL (new profile system first, legacy fallback)
const activeProfile = await getActiveProfile(bizRoot);
const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
if (!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 || null; // 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(MERCHANT_ID(), 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, providerName, senderId, dltEntityId, authKey } = req.body;
if (!templateId || !String(templateId).trim()) {
return res.status(400).json({ error: 'templateId is required' });
}
if (!toNumber || !String(toNumber).trim()) {
return res.status(400).json({ error: 'toNumber is required' });
}
const bizRoot = businessRoot(MERCHANT_ID(), 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' });
}
// Merge any submitted provider fields over stored values
const storedProviders = await fetchJSON(bizRoot, 'providers') || {};
const mergedProviders = {
providerName: providerName || storedProviders.providerName || '',
senderId: senderId ? senderId.toUpperCase() : (storedProviders.senderId || ''),
dltEntityId: dltEntityId || storedProviders.dltEntityId || '',
authKey: authKey || storedProviders.authKey || '',
};
// Validate mandatory fields
const missing = [];
if (!mergedProviders.providerName) missing.push('providerName');
if (!mergedProviders.senderId) missing.push('senderId');
if (!mergedProviders.dltEntityId) missing.push('dltEntityId');
if (missing.length > 0) {
return res.status(422).json({
error: 'Missing mandatory provider fields',
missingFields: missing,
});
}
// Validate senderId format
if (mergedProviders.senderId.length !== 6 || !/^[A-Za-z]+$/.test(mergedProviders.senderId)) {
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
}
// Persist any updated provider data
const updatedProviders = { ...mergedProviders, updatedAt: new Date().toISOString() };
await uploadJSON(bizRoot, 'providers', updatedProviders);
// Mark template as whitelisted
tmpl.templateId = String(templateId).trim();
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: mergedProviders.senderId,
toNumber: String(toNumber).trim(),
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);
res.status(500).json({ error: err.message });
}
});
// 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 (!toNumber) return res.status(400).json({ error: 'toNumber is required' });
const bizRoot = businessRoot(MERCHANT_ID(), 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 providers = await fetchJSON(bizRoot, 'providers') || {};
if (!providers.senderId) {
return res.status(422).json({ error: 'Provider senderId is required for sending' });
}
let smsResult;
try {
smsResult = await sendViaWorkflow({
senderId: providers.senderId,
toNumber: String(toNumber).trim(),
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) {
res.status(500).json({ error: err.message });
}
});
/**
* 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;