980 lines
35 KiB
JavaScript
980 lines
35 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;
|
|
|
|
// ─── 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 });
|
|
}
|
|
|
|
const PROVIDER_FIELDS = ['providerName', 'senderId', 'dltEntityId', 'authKey'];
|
|
|
|
function createHttpError(status, message, extra = {}) {
|
|
const err = new Error(message);
|
|
err.status = status;
|
|
Object.assign(err, extra);
|
|
return err;
|
|
}
|
|
|
|
function sendRouteError(res, err) {
|
|
const status = err.status || 500;
|
|
const body = { error: err.message };
|
|
if (err.code) body.code = err.code;
|
|
if (err.missingFields) body.missingFields = err.missingFields;
|
|
if (err.template) body.template = err.template;
|
|
if (err.details) body.details = err.details;
|
|
res.status(status).json(body);
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function normalizeSenderId(value) {
|
|
return normalizeText(value).toUpperCase();
|
|
}
|
|
|
|
function isValidCurlCommand(rawCurl) {
|
|
return normalizeText(rawCurl).toLowerCase().startsWith('curl');
|
|
}
|
|
|
|
function validateSenderId(senderId) {
|
|
if (!senderId) return null;
|
|
if (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId)) {
|
|
return 'Sender ID must be exactly 6 alphabetic characters';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeProvider(provider = {}, fallbackUpdatedAt = null) {
|
|
const updatedAt = provider.updatedAt || fallbackUpdatedAt || new Date().toISOString();
|
|
return {
|
|
providerName: normalizeText(provider.providerName),
|
|
senderId: normalizeSenderId(provider.senderId),
|
|
dltEntityId: normalizeText(provider.dltEntityId),
|
|
authKey: normalizeText(provider.authKey),
|
|
updatedAt,
|
|
};
|
|
}
|
|
|
|
function 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(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 bizRoot = businessRoot(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(MERCHANT_ID(), 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(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);
|
|
|
|
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(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' });
|
|
|
|
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(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 } = 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(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' });
|
|
}
|
|
|
|
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(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 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;
|