bolt-templates-sms-extensio.../server/routes/businesses.js
2026-03-26 19:15:08 +05:30

1071 lines
38 KiB
JavaScript

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);
return businesses.find((business) => {
const storedApplicationId = normalizeScopeId(business.applicationId);
const storedBusinessId = normalizeScopeId(business.businessId);
return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId;
}) || 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;