sms-extension/server/routes/businesses.js

1828 lines
62 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 { getPlatformClientForCompany } = require('../fdk');
const axios = require('axios');
const MERCHANT_ID = () => process.env.MERCHANT_ID;
function normalizeScopeId(value) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return '';
}
function getCompanyId(req) {
return normalizeScopeId(
req.fdkSession?.company_id
|| req.get('x-company-id')
|| req.params?.companyId
|| req.params?.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.body?.salesChannelId
|| req.query?.applicationId
|| req.query?.application_id
|| req.body?.applicationId
|| req.body?.application_id
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function slugify(text) {
return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function businessRoot(merchantId, businessId) {
return `${merchantId}/${businessId}`;
}
function indexPath(merchantId) {
return merchantId; // index.json lives at {merchantId}/index.json
}
async function getIndex(merchantId) {
const data = await fetchJSON(indexPath(merchantId), 'index');
return Array.isArray(data?.businesses) ? data.businesses : [];
}
async function saveIndex(merchantId, businesses) {
await uploadJSON(indexPath(merchantId), 'index', { businesses });
}
async function findBusinessByApplicationId(merchantId, applicationId) {
const normalizedApplicationId = normalizeScopeId(applicationId);
if (!normalizedApplicationId) return null;
const businesses = await getIndex(merchantId);
const exactMatch = businesses.find((business) => {
const storedApplicationId = normalizeScopeId(business.applicationId);
const storedBusinessId = normalizeScopeId(business.businessId);
return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId;
});
if (exactMatch) return exactMatch;
const normalizedBrandLookup = normalizedApplicationId.toLowerCase();
const brandMatches = businesses.filter((business) => normalizeText(business.brandName).toLowerCase() === normalizedBrandLookup);
if (brandMatches.length > 1) {
throw createHttpError(
409,
'Multiple businesses matched the provided applicationId brand fallback',
{
code: 'AMBIGUOUS_BUSINESS_MATCH',
details: {
companyId: merchantId,
applicationId: normalizedApplicationId,
matchedBusinesses: brandMatches.map((business) => ({
businessId: business.businessId,
brandName: business.brandName,
})),
},
}
);
}
return brandMatches[0] || null;
}
async function findBusinessByBrandName(merchantId, brandName) {
const normalizedBrandName = normalizeText(brandName).toLowerCase();
if (!normalizedBrandName) return null;
const businesses = await getIndex(merchantId);
const brandMatches = businesses.filter((business) => normalizeText(business.brandName).toLowerCase() === normalizedBrandName);
if (brandMatches.length > 1) {
throw createHttpError(
409,
'Multiple businesses matched the provided brand name',
{
code: 'AMBIGUOUS_BUSINESS_MATCH',
details: {
companyId: merchantId,
brandName: normalizedBrandName,
matchedBusinesses: brandMatches.map((business) => ({
businessId: business.businessId,
brandName: business.brandName,
})),
},
}
);
}
return brandMatches[0] || null;
}
const PROVIDER_FIELDS = ['providerName', 'senderId', 'dltEntityId', 'authKey'];
function createHttpError(status, message, extra = {}) {
const err = new Error(message);
err.status = status;
Object.assign(err, extra);
return err;
}
function sendRouteError(res, err) {
const status = err.status || 500;
const body = { error: err.message };
if (err.code) body.code = err.code;
if (err.missingFields) body.missingFields = err.missingFields;
if (err.template) body.template = err.template;
if (err.details) body.details = err.details;
res.status(status).json(body);
}
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeScalarText(value) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return '';
}
function firstNonEmptyText(...values) {
for (const value of values) {
const normalized = normalizeScalarText(value);
if (normalized) return normalized;
}
return '';
}
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 normalizeWebsiteUrl(value) {
const rawValue = normalizeText(value);
if (!rawValue) return '';
const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`;
try {
const url = new URL(candidate);
return url.toString().replace(/\/$/, '');
} catch {
return '';
}
}
function extractDomainName(domain) {
if (!domain) return '';
if (typeof domain === 'string') return normalizeText(domain);
if (typeof domain === 'object') {
return firstNonEmptyText(domain.name, domain.domain_url, domain.url, domain.host);
}
return '';
}
function rankDomain(domain) {
if (!domain || typeof domain !== 'object') return 0;
let score = 0;
if (domain.is_primary) score += 4;
if (domain.verified) score += 2;
if (!domain.is_shortlink) score += 1;
return score;
}
function pickPreferredDomain(...sources) {
const candidates = [];
for (const source of sources) {
if (!source) continue;
if (Array.isArray(source)) {
for (const item of source) {
const name = extractDomainName(item);
if (name) candidates.push({ raw: item, name });
}
continue;
}
const name = extractDomainName(source);
if (name) candidates.push({ raw: source, name });
}
if (!candidates.length) return '';
candidates.sort((left, right) => rankDomain(right.raw) - rankDomain(left.raw));
return candidates[0].name;
}
function normalizeSalesChannel(application = {}, domains = []) {
const domainName = pickPreferredDomain(application.domain, application.domains, domains);
const id = normalizeScopeId(application._id || application.id || application.application_id);
return {
id,
salesChannelId: id,
applicationId: id,
name: firstNonEmptyText(application.name, application.display_name, application.slug),
domain: domainName,
websiteUrl: normalizeWebsiteUrl(domainName),
isActive: application.is_active !== false,
logoUrl: firstNonEmptyText(
application.logo?.secure_url,
application.logo?.url,
application.logo,
application.favicon?.secure_url,
application.favicon?.url
),
};
}
async function getPlatformClient(req, companyId) {
if (req?.platformClient) return req.platformClient;
return getPlatformClientForCompany(companyId);
}
async function listAllSalesChannels(platformClient, query = '') {
const items = [];
const pageSize = 100;
let pageNo = 1;
while (true) {
const response = await platformClient.configuration.getApplications({
pageNo,
pageSize,
q: normalizeText(query) || undefined,
});
const batch = Array.isArray(response?.items) ? response.items : [];
items.push(...batch);
const totalPages = Number(response?.page?.total_page) || Number(response?.page?.total_pages) || 0;
if (!batch.length || batch.length < pageSize || (totalPages && pageNo >= totalPages)) {
break;
}
pageNo += 1;
}
return items;
}
async function getSalesChannelDetails(platformClient, salesChannelId) {
const applicationClient = platformClient.application(salesChannelId).configuration;
const [applicationResponse, domainsResponse] = await Promise.allSettled([
applicationClient.getApplicationById(),
applicationClient.getDomains(),
]);
if (applicationResponse.status !== 'fulfilled' && domainsResponse.status !== 'fulfilled') {
const error = applicationResponse.reason || domainsResponse.reason || new Error('Unable to fetch sales channel details');
throw error;
}
const application = applicationResponse.status === 'fulfilled'
? applicationResponse.value
: { _id: salesChannelId };
const domains = domainsResponse.status === 'fulfilled' ? domainsResponse.value?.domains || [] : [];
return normalizeSalesChannel(application, domains);
}
const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']);
const EVENT_TEMPLATE_FALLBACKS = {
bag_confirmed: ['confirmed'],
bag_packed: ['pack'],
bag_not_confirmed: ['cancelled'],
cancelled_customer: ['cancelled'],
cancelled_fynd: ['cancelled'],
cancelled_at_dp: ['cancelled'],
cancelled_failed_at_dp: ['cancelled'],
};
function mergeDefaultEvents(data = {}) {
const existingEvents = Array.isArray(data?.events) ? data.events : [];
const defaultEventBySlug = new Map(DEFAULT_EVENTS.map((event) => [event.slug, event]));
const existingEventBySlug = new Map(
existingEvents
.map((event) => ({ ...event, slug: normalizeText(event?.slug) }))
.filter((event) => event.slug)
.map((event) => [event.slug, event])
);
const mergedDefaults = DEFAULT_EVENTS.map((event) => {
const existing = existingEventBySlug.get(event.slug);
return existing
? { ...event, ...existing, slug: event.slug, label: existing.label || event.label, isDefault: true }
: { ...event };
});
const customEvents = existingEvents
.map((event) => ({ ...event, slug: normalizeText(event?.slug), label: normalizeText(event?.label) }))
.filter((event) => event.slug && !defaultEventBySlug.has(event.slug) && !LEGACY_DEFAULT_EVENT_SLUGS.has(event.slug))
.map((event) => ({ ...event, isDefault: false }));
return { events: [...mergedDefaults, ...customEvents] };
}
function getShipmentPayload(body) {
return body?.payload?.shipment && typeof body.payload.shipment === 'object'
? body.payload.shipment
: null;
}
function getShipmentBrandName(body) {
const shipment = getShipmentPayload(body);
return firstNonEmptyText(
shipment?.bags?.[0]?.brand?.brand_name,
shipment?.bags?.[0]?.item?.attributes?.brand_name,
shipment?.affiliate_details?.company_affiliate_tag
);
}
function getShipmentEventKey(body) {
const shipment = getShipmentPayload(body);
return firstNonEmptyText(
shipment?.status,
shipment?.shipment_status?.status,
shipment?.shipment_status?.current_shipment_status
);
}
function getShipmentToNumber(body) {
const shipment = getShipmentPayload(body);
return firstNonEmptyText(
shipment?.user?.mobile,
shipment?.delivery_address?.phone,
shipment?.billing_address?.phone
);
}
const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g;
function normalizeRenderableValue(value) {
return normalizeScalarText(value).replace(/\s+/g, ' ').trim();
}
function splitFullName(value) {
const fullName = normalizeRenderableValue(value);
if (!fullName) return { firstName: '', lastName: '', fullName: '' };
const parts = fullName.split(/\s+/).filter(Boolean);
return {
firstName: parts[0] || '',
lastName: parts.length > 1 ? parts.slice(1).join(' ') : '',
fullName,
};
}
function getEventLookupCandidates(eventSlug) {
const normalizedEventSlug = slugify(eventSlug || '');
const candidates = [
normalizedEventSlug,
...(EVENT_TEMPLATE_FALLBACKS[normalizedEventSlug] || []),
];
return [...new Set(candidates.filter(Boolean))];
}
async function resolveWhitelistedTemplate(folder, eventSlug) {
for (const candidate of getEventLookupCandidates(eventSlug)) {
const template = await fetchJSON(folder, candidate);
if (template && template.status === 'whitelisted' && normalizeText(template.selectedTemplate)) {
return { template, matchedSlug: candidate };
}
}
return { template: null, matchedSlug: '' };
}
function toCamelCase(text) {
return String(text || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part, index) => {
const lower = part.toLowerCase();
return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
})
.join('');
}
function setValueIndexEntry(valueIndex, key, value) {
if (!key || valueIndex.has(key)) return;
valueIndex.set(key, value);
}
function indexShipmentValues(value, pathParts = [], valueIndex = new Map()) {
if (Array.isArray(value)) {
value.forEach((entry) => indexShipmentValues(entry, pathParts, valueIndex));
return valueIndex;
}
if (value && typeof value === 'object') {
Object.entries(value).forEach(([key, entry]) => {
indexShipmentValues(entry, [...pathParts, key], valueIndex);
});
return valueIndex;
}
const normalizedValue = normalizeRenderableValue(value);
if (!normalizedValue || pathParts.length === 0) return valueIndex;
const leafKey = toCamelCase(pathParts[pathParts.length - 1]);
const fullKey = toCamelCase(pathParts.join(' '));
setValueIndexEntry(valueIndex, leafKey, normalizedValue);
setValueIndexEntry(valueIndex, fullKey, normalizedValue);
return valueIndex;
}
function buildShipmentValueIndex(shipment) {
const valueIndex = indexShipmentValues(shipment);
const firstBag = shipment?.bags?.[0] || {};
const customerName = splitFullName(
firstNonEmptyText(
shipment?.user?.first_name && shipment?.user?.last_name
? `${shipment.user.first_name} ${shipment.user.last_name}`
: '',
shipment?.delivery_address?.name,
shipment?.delivery_address?.contact_person,
shipment?.billing_address?.name,
shipment?.billing_address?.contact_person
)
);
const primaryTrackingUrl = firstNonEmptyText(
shipment?.delivery_partner_details?.track_url,
shipment?.meta?.tracking_url,
firstBag?.meta?.tracking_url,
shipment?.affiliate_details?.shipment_meta?.tracking_url,
shipment?.article_details?.dp_details?.track_url
);
const primaryAwbNumber = firstNonEmptyText(
shipment?.delivery_partner_details?.awb_no,
shipment?.meta?.awb_number,
shipment?.article_details?.dp_details?.awb_no,
firstBag?.meta?.awb_number,
firstBag?.current_operational_status?.delivery_awb_number
);
const primaryCourierName = firstNonEmptyText(
shipment?.delivery_partner_details?.display_name,
shipment?.delivery_partner_details?.name,
shipment?.meta?.courier_partner_name,
shipment?.meta?.dp_name,
firstBag?.meta?.dp_name,
shipment?.affiliate_details?.shipment_meta?.courier_partner_name
);
const brandName = firstNonEmptyText(
shipment?.bags?.[0]?.brand?.brand_name,
shipment?.bags?.[0]?.item?.attributes?.brand_name,
shipment?.affiliate_details?.company_affiliate_tag
);
const toNumber = firstNonEmptyText(
shipment?.user?.mobile,
shipment?.delivery_address?.phone,
shipment?.billing_address?.phone
);
const eventKey = firstNonEmptyText(
shipment?.status,
shipment?.shipment_status?.status,
shipment?.shipment_status?.current_shipment_status
);
const eventDisplayName = firstNonEmptyText(
shipment?.shipment_status?.display_name,
shipment?.shipment_status?.current_shipment_status
);
const shipmentId = firstNonEmptyText(
shipment?.shipment_id,
shipment?.shipment_status?.shipment_id
);
const resolvedFullName = firstNonEmptyText(
shipment?.user?.first_name || shipment?.user?.last_name
? `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
: '',
customerName.fullName
);
const resolvedFirstName = firstNonEmptyText(shipment?.user?.first_name, customerName.firstName);
const resolvedLastName = firstNonEmptyText(shipment?.user?.last_name, customerName.lastName);
setValueIndexEntry(valueIndex, 'firstName', resolvedFirstName);
setValueIndexEntry(valueIndex, 'lastName', resolvedLastName);
setValueIndexEntry(valueIndex, 'fullName', resolvedFullName);
setValueIndexEntry(valueIndex, 'customerFirstName', resolvedFirstName);
setValueIndexEntry(valueIndex, 'customerLastName', resolvedLastName);
setValueIndexEntry(valueIndex, 'customerName', resolvedFullName);
setValueIndexEntry(valueIndex, 'phone', toNumber);
setValueIndexEntry(valueIndex, 'mobile', toNumber);
setValueIndexEntry(valueIndex, 'toNumber', toNumber);
setValueIndexEntry(valueIndex, 'customerPhone', toNumber);
setValueIndexEntry(valueIndex, 'customerMobile', toNumber);
setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'orderNumber', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'shipmentId', shipmentId);
setValueIndexEntry(valueIndex, 'event', eventKey);
setValueIndexEntry(valueIndex, 'status', eventKey);
setValueIndexEntry(valueIndex, 'eventDisplayName', eventDisplayName);
setValueIndexEntry(valueIndex, 'displayName', eventDisplayName);
setValueIndexEntry(valueIndex, 'brandName', brandName);
setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackingLink', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awb', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
setValueIndexEntry(valueIndex, 'courierName', primaryCourierName);
setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName);
return valueIndex;
}
function validateRenderedPlaceholderValue(token, value, fieldName) {
if (!value) {
throw createHttpError(422, `No shipment value found for placeholder field "${fieldName}"`);
}
if (token === '{#numeric#}' && !/^\d+$/.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to a non-numeric value for ${token}`);
}
if (token === '{#url#}' && !/^https?:\/\//i.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid URL for ${token}`);
}
if (token === '{#cbn#}' && !/^\+?[0-9][0-9\s-]{5,}$/.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid callback number for ${token}`);
}
}
function renderShipmentTemplate(template, shipment, variableMap = {}) {
const normalizedTemplate = normalizeText(template);
const placeholderMatches = normalizedTemplate.match(DLT_PLACEHOLDER_REGEX) || [];
if (placeholderMatches.length === 0) {
return normalizedTemplate;
}
if (!variableMap || typeof variableMap !== 'object' || Object.keys(variableMap).length === 0) {
throw createHttpError(422, 'Template has placeholders but no variableMap was found on the stored template');
}
const shipmentValueIndex = buildShipmentValueIndex(shipment);
let placeholderIndex = 0;
return normalizedTemplate.replace(DLT_PLACEHOLDER_REGEX, (token) => {
const mappingKey = `${token}[${placeholderIndex}]`;
const mappedFieldName = normalizeText(variableMap[mappingKey]);
if (!mappedFieldName) {
throw createHttpError(422, `No variable mapping found for placeholder ${mappingKey}`, {
details: { mappingKey, variableMap },
});
}
const resolvedValue = shipmentValueIndex.get(toCamelCase(mappedFieldName)) || '';
validateRenderedPlaceholderValue(token, resolvedValue, mappedFieldName);
placeholderIndex += 1;
return resolvedValue;
});
}
function parseWorkflowPayload(data) {
if (typeof data === 'string') {
const trimmed = data.trim();
if (!trimmed) return {};
try {
return JSON.parse(trimmed);
} catch {
return { status: trimmed };
}
}
return data && typeof data === 'object' ? data : {};
}
async function sendResolveTemplateWorkflow({ content, toNumber }) {
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
if (!workflowUrl) {
throw createHttpError(500, 'WORKFLOW_URL_RESOLVE_TEMPLATE is not configured');
}
const response = await axios.post(
workflowUrl,
{ content, toNumber },
{
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
validateStatus: () => true,
}
);
if (response.status < 200 || response.status >= 300) {
throw createHttpError(
502,
`Resolve-template workflow failed with status ${response.status}`,
{ details: response.data }
);
}
return {
statusCode: response.status,
response: response.data,
};
}
function normalizeEditedTemplateValidation(data) {
const payload = parseWorkflowPayload(data);
let approved = null;
if (typeof payload.approved === 'boolean') approved = payload.approved;
if (approved === null && typeof payload.isApproved === 'boolean') approved = payload.isApproved;
if (approved === null && typeof payload.valid === 'boolean') approved = payload.valid;
if (approved === null && typeof payload.is_valid === 'boolean') approved = payload.is_valid;
if (approved === null) {
const status = normalizeText(payload.status || payload.result || payload.decision).toLowerCase();
if (['approved', 'pass', 'passed', 'valid', 'ok'].includes(status)) approved = true;
if (['rejected', 'not_approved', 'failed', 'fail', 'invalid', 'needs_changes'].includes(status)) approved = false;
}
if (approved === null) {
throw createHttpError(502, 'Template edit validation workflow returned an unreadable response', {
details: payload,
});
}
return {
approved,
why: normalizeText(payload.why || payload.reason || payload.message || payload.feedback),
workflowResult: payload,
};
}
async function validateEditedTemplateWorkflow(editedTemplate) {
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_TEMPLATE_EDIT_CHECK);
if (!workflowUrl) {
throw createHttpError(500, 'WORKFLOW_URL_TEMPLATE_EDIT_CHECK is not configured');
}
const response = await axios.post(
workflowUrl,
{ editedTemplate },
{
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
validateStatus: () => true,
}
);
if (response.status < 200 || response.status >= 300) {
throw createHttpError(
502,
`Template edit validation workflow failed with status ${response.status}`,
{ details: response.data }
);
}
return normalizeEditedTemplateValidation(response.data);
}
function getProviderPatch(input) {
if (!input || typeof input !== 'object') return null;
let hasField = false;
const patch = {};
for (const field of PROVIDER_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(input, field)) continue;
hasField = true;
patch[field] = field === 'senderId'
? normalizeSenderId(input[field])
: normalizeText(input[field]);
}
return hasField ? patch : null;
}
function mergeProviderState(extractedProvider, currentProvider, providerPatch, options = {}) {
const { preserveCurrent = true, updatedAt = new Date().toISOString() } = options;
let merged = { ...normalizeProvider(extractedProvider, updatedAt), updatedAt };
if (preserveCurrent && currentProvider) {
const normalizedCurrent = normalizeProvider(currentProvider, updatedAt);
for (const field of PROVIDER_FIELDS) {
if (normalizedCurrent[field]) {
merged[field] = normalizedCurrent[field];
}
}
}
if (providerPatch) {
for (const field of PROVIDER_FIELDS) {
if (Object.prototype.hasOwnProperty.call(providerPatch, field)) {
merged[field] = providerPatch[field];
}
}
}
merged.updatedAt = updatedAt;
return merged;
}
function hydrateProfile(profile = {}) {
return {
...profile,
provider: normalizeProvider(profile.provider, profile.updatedAt),
};
}
function hydrateProfileData(profileData) {
const profiles = Array.isArray(profileData?.profiles)
? profileData.profiles.map(hydrateProfile)
: [];
return { profiles };
}
async function getProfileState(bizRoot) {
const [rawProfileData, activeRec] = await Promise.all([
fetchJSON(bizRoot, 'global_sms_profiles'),
fetchJSON(bizRoot, 'active_curl_profile'),
]);
const profileData = hydrateProfileData(rawProfileData);
const activeProfileId = activeRec?.profileId || (profileData.profiles[0]?.id ?? null);
const activeProfile = profileData.profiles.find(p => p.id === activeProfileId) || profileData.profiles[0] || null;
return { profileData, activeProfile, activeProfileId };
}
async function getActiveProfile(bizRoot) {
try {
const { activeProfile } = await getProfileState(bizRoot);
return activeProfile;
} catch {
return null;
}
}
async function getBoundProfile(bizRoot, curlProfileId) {
if (!curlProfileId) {
throw createHttpError(
422,
'This template is not bound to a cURL profile. Re-select the template from Events before continuing.',
{ code: 'MISSING_BOUND_PROFILE' }
);
}
const { profileData } = await getProfileState(bizRoot);
const boundProfile = profileData.profiles.find(profile => profile.id === curlProfileId);
if (!boundProfile) {
throw createHttpError(
422,
'The cURL profile bound to this template no longer exists. Re-select the template from Events before continuing.',
{ code: 'BOUND_PROFILE_NOT_FOUND' }
);
}
return boundProfile;
}
async function validateCurlAndExtractProvider(rawCurl) {
try {
const validation = await validateCurlFields(rawCurl);
if (!validation.isValidCurl) {
throw createHttpError(422, validation.reason || 'The provided cURL is invalid');
}
const provider = normalizeProvider(validation.provider);
const senderIdError = validateSenderId(provider.senderId);
if (senderIdError) {
throw createHttpError(422, senderIdError);
}
return provider;
} catch (err) {
if (err.status) throw err;
throw createHttpError(502, `cURL validation failed: ${err.message}`);
}
}
async function updateProfileProvider(profile, providerPatch, rawCurlOverride) {
const effectiveCurl = normalizeText(rawCurlOverride !== undefined ? rawCurlOverride : profile.rawCurl);
const extractedProvider = await validateCurlAndExtractProvider(effectiveCurl);
const preserveCurrent = rawCurlOverride === undefined;
const updatedAt = new Date().toISOString();
profile.provider = mergeProviderState(
extractedProvider,
profile.provider,
providerPatch,
{ preserveCurrent, updatedAt }
);
profile.updatedAt = updatedAt;
return profile;
}
function getMissingMandatoryProviderFields(provider = {}) {
const normalized = normalizeProvider(provider);
const missing = [];
if (!normalized.providerName) missing.push('providerName');
if (!normalized.senderId) missing.push('senderId');
if (!normalized.dltEntityId) missing.push('dltEntityId');
return missing;
}
// ─── Business CRUD ────────────────────────────────────────────────────────────
// GET /api/businesses
router.get('/', async (req, res) => {
try {
const businesses = await getIndex(getCompanyId(req));
res.json({ businesses });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/businesses/sales-channels
router.get('/sales-channels', async (req, res) => {
try {
const companyId = getCompanyId(req);
if (!companyId) {
throw createHttpError(400, 'companyId is required');
}
const platformClient = await getPlatformClient(req, companyId);
const applications = await listAllSalesChannels(platformClient);
const salesChannels = applications
.map((application) => normalizeSalesChannel(application))
.filter((channel) => channel.id && channel.name)
.sort((left, right) => left.name.localeCompare(right.name));
res.json({ salesChannels });
} catch (err) {
if (err.message === 'FDK is not configured') {
return res.status(503).json({
error: 'Sales channel fetch is unavailable',
code: 'SALES_CHANNEL_FETCH_UNAVAILABLE',
});
}
sendRouteError(res, createHttpError(
err.status || 502,
err.message || 'Failed to fetch sales channels',
err.code ? { code: err.code, details: err.details } : {}
));
}
});
// POST /api/businesses — create new business from sales channel or websiteUrl fallback
router.post('/', async (req, res) => {
try {
const merchantId = getCompanyId(req);
if (!merchantId) {
throw createHttpError(400, 'companyId is required');
}
const requestedSalesChannelId = normalizeScopeId(
req.body?.salesChannelId
|| req.body?.applicationId
|| req.body?.application_id
);
let websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl);
let salesChannel = null;
if (!requestedSalesChannelId && !websiteUrl) {
throw createHttpError(
400,
'Either salesChannelId or websiteUrl is required',
{ code: 'MISSING_BUSINESS_SOURCE' }
);
}
if (requestedSalesChannelId) {
try {
const platformClient = await getPlatformClient(req, merchantId);
salesChannel = await getSalesChannelDetails(platformClient, requestedSalesChannelId);
} catch (error) {
if (!websiteUrl) {
throw createHttpError(
error.message === 'FDK is not configured' ? 503 : 502,
'Unable to fetch sales channel details',
{
code: 'SALES_CHANNEL_DETAILS_UNAVAILABLE',
details: { salesChannelId: requestedSalesChannelId, reason: error.message },
}
);
}
}
websiteUrl = websiteUrl || salesChannel?.websiteUrl || '';
}
if (!websiteUrl) {
throw createHttpError(
422,
'A website URL could not be derived from the selected sales channel. Please enter it manually.',
{ code: 'MISSING_SALES_CHANNEL_WEBSITE' }
);
}
const applicationId = requestedSalesChannelId || 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,
salesChannel: salesChannel ? {
salesChannelId: salesChannel.id,
name: salesChannel.name,
domain: salesChannel.domain,
} : null,
});
} catch (err) {
console.error('Create business error:', err.message);
sendRouteError(res, err);
}
});
// GET /api/businesses/:businessId
router.get('/:businessId', async (req, res) => {
try {
const { businessId } = req.params;
const context = await fetchJSON(businessRoot(getCompanyId(req), businessId), 'context');
if (!context) return res.status(404).json({ error: 'Business not found' });
res.json(context);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/businesses/:businessId
router.delete('/:businessId', async (req, res) => {
try {
const merchantId = getCompanyId(req);
const { businessId } = req.params;
await deleteBusinessFiles(merchantId, businessId);
const businesses = await getIndex(merchantId);
const updated = businesses.filter(b => b.businessId !== businessId);
await saveIndex(merchantId, updated);
res.json({ ok: true });
} catch (err) {
console.error('Delete business error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses/resolve-template
router.post('/resolve-template', async (req, res) => {
try {
console.log('[ResolveTemplate] Incoming payload:', JSON.stringify(req.body, null, 2));
const companyId = getCompanyId(req);
const shipment = getShipmentPayload(req.body);
const brandName = getShipmentBrandName(req.body);
const event = getShipmentEventKey(req.body);
const toNumber = getShipmentToNumber(req.body);
if (!companyId) return res.status(400).json({ error: 'companyId is required' });
if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' });
if (!brandName) {
return res.status(400).json({
error: 'A shipment brand name is required',
details: {
acceptedPaths: [
'payload.shipment.bags[0].brand.brand_name',
'payload.shipment.bags[0].item.attributes.brand_name',
'payload.shipment.affiliate_details.company_affiliate_tag',
],
},
});
}
if (!event) {
return res.status(400).json({
error: 'A shipment event status is required',
details: {
acceptedPaths: [
'payload.shipment.status',
'payload.shipment.shipment_status.status',
'payload.shipment.shipment_status.current_shipment_status',
],
},
});
}
if (!toNumber) {
return res.status(400).json({
error: 'A shipment phone number is required',
details: {
acceptedPaths: [
'payload.shipment.user.mobile',
'payload.shipment.delivery_address.phone',
'payload.shipment.billing_address.phone',
],
},
});
}
const business = await findBusinessByBrandName(companyId, brandName);
if (!business) {
return res.status(404).json({ error: 'Business not found for brand name' });
}
const eventSlug = slugify(event);
const folder = `${businessRoot(companyId, business.businessId)}/templates`;
const { template: tmpl, matchedSlug } = await resolveWhitelistedTemplate(folder, eventSlug);
if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) {
return res.status(404).json({ error: 'Whitelisted template not found' });
}
const resolvedTemplate = renderShipmentTemplate(
tmpl.selectedTemplate,
shipment,
tmpl.variableMap || {}
);
const workflowResult = await sendResolveTemplateWorkflow({
content: resolvedTemplate,
toNumber,
});
res.json({
success: true,
companyId,
businessId: business.businessId,
brandName,
event: eventSlug,
matchedTemplateEvent: matchedSlug || eventSlug,
templateId: normalizeText(tmpl.templateId),
template: tmpl.selectedTemplate,
content: resolvedTemplate,
toNumber,
workflowResult,
});
} catch (err) {
sendRouteError(res, err);
}
});
// ─── Providers ────────────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/providers
router.get('/:businessId/providers', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const activeProfile = await getActiveProfile(bizRoot);
if (!activeProfile) {
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
}
res.json(activeProfile.provider || {});
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/providers
router.post('/:businessId/providers', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const providerPatch = getProviderPatch(req.body);
const senderIdError = validateSenderId(providerPatch?.senderId || '');
if (senderIdError) {
return res.status(400).json({ error: senderIdError });
}
const { profileData, activeProfile, activeProfileId } = await getProfileState(bizRoot);
if (!activeProfile || !activeProfileId) {
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
}
const profile = profileData.profiles.find(item => item.id === activeProfileId);
await updateProfileProvider(profile, providerPatch);
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
res.json(profile.provider);
} catch (err) {
sendRouteError(res, err);
}
});
// ─── Global SMS cURL (Compatibility layer) ───────────────────────────────────
// These routes delegate to the active/default profile model.
// GET /api/businesses/:businessId/global-sms
router.get('/:businessId/global-sms', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const activeProfile = await getActiveProfile(bizRoot);
res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {});
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/global-sms
// Compat: creates/updates a default profile and sets it active.
router.post('/:businessId/global-sms', async (req, res) => {
try {
const { rawCurl } = req.body;
if (!normalizeText(rawCurl)) {
return res.status(400).json({ error: 'rawCurl is required' });
}
if (!isValidCurlCommand(rawCurl)) {
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
}
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const { profileData } = await getProfileState(bizRoot);
const now = new Date().toISOString();
const normalizedCurl = normalizeText(rawCurl);
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
// Find or create the default profile
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
if (defaultProfile) {
defaultProfile.rawCurl = normalizedCurl;
defaultProfile.provider = extractedProvider;
defaultProfile.updatedAt = now;
} else {
defaultProfile = {
id: uuidv4(),
name: 'Default',
rawCurl: normalizedCurl,
isDefault: true,
provider: extractedProvider,
createdAt: now,
updatedAt: now,
};
profileData.profiles.push(defaultProfile);
}
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now });
res.json({ rawCurl: normalizedCurl, updatedAt: now });
} catch (err) {
sendRouteError(res, err);
}
});
// ─── cURL Profiles CRUD ────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/global-sms/profiles
router.get('/:businessId/global-sms/profiles', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const { profileData, activeProfileId } = await getProfileState(bizRoot);
const profiles = profileData.profiles || [];
res.json({ profiles, activeProfileId });
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/global-sms/profiles
router.post('/:businessId/global-sms/profiles', async (req, res) => {
try {
const { name, rawCurl, setActive } = req.body;
if (!normalizeText(name)) {
return res.status(400).json({ error: 'name is required' });
}
if (!normalizeText(rawCurl)) {
return res.status(400).json({ error: 'rawCurl is required' });
}
if (!isValidCurlCommand(rawCurl)) {
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
}
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const { profileData } = await getProfileState(bizRoot);
const now = new Date().toISOString();
const normalizedCurl = normalizeText(rawCurl);
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
const newProfile = {
id: uuidv4(),
name: normalizeText(name),
rawCurl: normalizedCurl,
isDefault: false,
provider: extractedProvider,
createdAt: now,
updatedAt: now,
};
profileData.profiles.push(newProfile);
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
// Activate this profile if requested or if it is the first one
if (setActive || profileData.profiles.length === 1) {
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: newProfile.id, updatedAt: now });
}
res.json(newProfile);
} catch (err) {
sendRouteError(res, err);
}
});
// PATCH /api/businesses/:businessId/global-sms/profiles/:profileId
router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) => {
try {
const { businessId, profileId } = req.params;
const { name, rawCurl } = req.body;
const providerPatch = getProviderPatch(req.body.provider || req.body);
if (name !== undefined && !normalizeText(name)) {
return res.status(400).json({ error: 'name is required' });
}
if (rawCurl !== undefined && !normalizeText(rawCurl)) {
return res.status(400).json({ error: 'rawCurl is required' });
}
if (rawCurl !== undefined && !isValidCurlCommand(rawCurl)) {
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
}
const senderIdError = validateSenderId(providerPatch?.senderId || '');
if (senderIdError) {
return res.status(400).json({ error: senderIdError });
}
const bizRoot = businessRoot(getCompanyId(req), businessId);
const { profileData } = await getProfileState(bizRoot);
const profile = profileData.profiles.find(p => p.id === profileId);
if (!profile) return res.status(404).json({ error: 'Profile not found' });
if (name !== undefined) profile.name = normalizeText(name);
if (rawCurl !== undefined) profile.rawCurl = normalizeText(rawCurl);
await updateProfileProvider(profile, providerPatch, rawCurl !== undefined ? profile.rawCurl : undefined);
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
res.json(profile);
} catch (err) {
sendRouteError(res, err);
}
});
// DELETE /api/businesses/:businessId/global-sms/profiles/:profileId
router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => {
try {
const { businessId, profileId } = req.params;
const bizRoot = businessRoot(getCompanyId(req), businessId);
const { profileData } = await getProfileState(bizRoot);
const idx = profileData.profiles.findIndex(p => p.id === profileId);
if (idx === -1) return res.status(404).json({ error: 'Profile not found' });
if (profileData.profiles.length === 1) {
return res.status(400).json({ error: 'Cannot delete the last cURL profile' });
}
profileData.profiles.splice(idx, 1);
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
// If deleted profile was active, switch to first remaining
const activeRec = await fetchJSON(bizRoot, 'active_curl_profile');
if (activeRec?.profileId === profileId) {
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: profileData.profiles[0].id, updatedAt: new Date().toISOString() });
}
res.json({ ok: true });
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/global-sms/profiles/:profileId/activate
router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, res) => {
try {
const { businessId, profileId } = req.params;
const bizRoot = businessRoot(getCompanyId(req), businessId);
const { profileData } = await getProfileState(bizRoot);
const profile = profileData.profiles.find(p => p.id === profileId);
if (!profile) return res.status(404).json({ error: 'Profile not found' });
await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() });
res.json({ activeProfileId: profileId });
} catch (err) {
sendRouteError(res, err);
}
});
// GET /api/businesses/:businessId/global-sms/active
router.get('/:businessId/global-sms/active', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const { activeProfile, activeProfileId } = await getProfileState(bizRoot);
res.json({ activeProfile, activeProfileId });
} catch (err) {
sendRouteError(res, err);
}
});
// ─── Events ───────────────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/events
router.get('/:businessId/events', async (req, res) => {
try {
const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events');
res.json(mergeDefaultEvents(data || {}));
} 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 = mergeDefaultEvents(await fetchJSON(bizRoot, '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 = mergeDefaultEvents(await fetchJSON(bizRoot, '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 = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
const event = eventsData.events.find(e => e.slug === slug);
if (!event) return res.status(404).json({ error: 'Event not found' });
const variants = await generateTemplates(context, slug, event.label);
const templateJson = {
eventSlug: slug,
eventLabel: event.label,
generatedVariants: variants,
selectedTemplate: null,
status: 'generated',
templateId: '',
curlProfileId: activeProfile.id,
rawCurl: '',
processedCurl: '',
variableMap: {},
selectedImagePath: '',
updatedAt: new Date().toISOString(),
};
await uploadJSON(`${bizRoot}/templates`, slug, templateJson);
res.json({ variants });
} catch (err) {
console.error('Generate error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Templates ────────────────────────────────────────────────────────────────
// GET /api/businesses/:businessId/templates/images (must be before /:slug)
router.get('/:businessId/templates/images', async (req, res) => {
try {
const images = await listImages(`${businessRoot(getCompanyId(req), req.params.businessId)}/images`);
res.json({ images });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/businesses/:businessId/templates
router.get('/:businessId/templates', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const folder = `${bizRoot}/templates`;
const slugs = await listTemplateFiles(folder);
const templates = [];
for (const slug of slugs) {
const tmpl = await fetchJSON(folder, slug);
if (tmpl) templates.push(tmpl);
}
res.json({ templates });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/businesses/:businessId/templates/:slug
router.get('/:businessId/templates/:slug', async (req, res) => {
try {
const { businessId, slug } = req.params;
const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
res.json(tmpl);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses/:businessId/templates/:slug/validate-edit
router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
try {
const { businessId, slug } = req.params;
const editedTemplate = normalizeText(req.body?.editedTemplate);
if (!editedTemplate) {
return res.status(400).json({ error: 'editedTemplate is required' });
}
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
const validation = await validateEditedTemplateWorkflow(editedTemplate);
res.json(validation);
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/templates/:slug/select
router.post('/:businessId/templates/:slug/select', async (req, res) => {
try {
const { businessId, slug } = req.params;
const { selectedVariant } = req.body;
if (!selectedVariant) return res.status(400).json({ error: 'selectedVariant is required' });
const bizRoot = businessRoot(getCompanyId(req), businessId);
const folder = `${bizRoot}/templates`;
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
const activeProfile = await getActiveProfile(bizRoot);
const activeCurl = activeProfile?.rawCurl || null;
if (!activeProfile?.id || !activeCurl) {
return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' });
}
// Process the cURL against the selected template
const { processedCurl, variableMap } = await processCurl(activeCurl, selectedVariant, slug);
tmpl.selectedTemplate = selectedVariant;
tmpl.generatedVariants = []; // discard non-selected variants
tmpl.status = 'pending_whitelisting';
tmpl.curlProfileId = activeProfile.id; // snapshot which profile was used
tmpl.rawCurl = activeCurl;
tmpl.processedCurl = processedCurl;
tmpl.variableMap = variableMap;
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json(tmpl);
} catch (err) {
console.error('Select error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses/:businessId/templates/:slug/whitelist
router.post('/:businessId/templates/:slug/whitelist', async (req, res) => {
try {
const { businessId, slug } = req.params;
const { templateId } = req.body;
if (!templateId || !String(templateId).trim()) {
return res.status(400).json({ error: 'templateId is required' });
}
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
if (tmpl.status !== 'pending_whitelisting') {
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to whitelist' });
}
tmpl.templateId = String(templateId).trim();
tmpl.status = 'whitelisted';
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json(tmpl);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses/:businessId/templates/:slug/publish
// Handles transition from pending_whitelisting -> whitelisted (Published).
// Validates mandatory provider fields, collects toNumber, then calls workflow sender.
router.post('/:businessId/templates/:slug/publish', async (req, res) => {
try {
const { businessId, slug } = req.params;
const { templateId, toNumber } = req.body;
if (!normalizeText(templateId)) {
return res.status(400).json({ error: 'templateId is required' });
}
if (!normalizeText(toNumber)) {
return res.status(400).json({ error: 'toNumber is required' });
}
const bizRoot = businessRoot(getCompanyId(req), businessId);
const folder = `${bizRoot}/templates`;
// Load template
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
if (tmpl.status !== 'pending_whitelisting') {
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' });
}
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
const missingFields = getMissingMandatoryProviderFields(boundProfile.provider);
if (missingFields.length > 0) {
return res.status(422).json({
error: 'Missing mandatory provider fields',
missingFields,
code: 'MISSING_BOUND_PROFILE_FIELDS',
});
}
const senderIdError = validateSenderId(boundProfile.provider.senderId);
if (senderIdError) {
return res.status(400).json({ error: senderIdError });
}
// Mark template as whitelisted
tmpl.templateId = normalizeText(templateId);
tmpl.status = 'whitelisted';
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
// Send via workflow (Published path)
let sendResult;
try {
sendResult = await sendViaWorkflow({
senderId: boundProfile.provider.senderId,
toNumber: normalizeText(toNumber),
content: tmpl.selectedTemplate || '',
});
} catch (sendErr) {
// Template is already whitelisted at this point; report send error separately
return res.status(502).json({
error: 'Template published but send failed',
details: sendErr.message,
template: tmpl,
});
}
res.json({
success: true,
template: tmpl,
sendResult,
});
} catch (err) {
console.error('Publish error:', err.message);
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/templates/:slug/test
// For Published (whitelisted) templates: routes to workflow sender (new path).
// Legacy cURL execution code below (executeCurl) is kept intact and is NOT deleted.
router.post('/:businessId/templates/:slug/test', async (req, res) => {
try {
const { businessId, slug } = req.params;
const { toNumber } = req.body;
if (!normalizeText(toNumber)) return res.status(400).json({ error: 'toNumber is required' });
const bizRoot = businessRoot(getCompanyId(req), businessId);
const folder = `${bizRoot}/templates`;
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
if (tmpl.status !== 'whitelisted') {
return res.status(400).json({ error: 'Template must be whitelisted before testing' });
}
if (!tmpl.templateId) {
return res.status(400).json({ error: 'templateId must be set before testing' });
}
// ── Published send path: route to workflow sender ──────────────────────────
// Per plan: Published (whitelisted) templates use the workflow sender module,
// not the legacy cURL execution path. The cURL code below remains but is not
// reached for whitelisted templates in this branch.
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
if (!boundProfile.provider?.senderId) {
return res.status(422).json({
error: 'Provider senderId is required for sending',
missingFields: ['senderId'],
code: 'MISSING_BOUND_PROFILE_FIELDS',
});
}
const senderIdError = validateSenderId(boundProfile.provider.senderId);
if (senderIdError) {
return res.status(400).json({ error: senderIdError });
}
let smsResult;
try {
smsResult = await sendViaWorkflow({
senderId: boundProfile.provider.senderId,
toNumber: normalizeText(toNumber),
content: tmpl.selectedTemplate || '',
});
} catch (sendErr) {
return res.status(502).json({ error: 'SMS send failed', details: sendErr.message });
}
res.json({ success: true, statusCode: smsResult.statusCode, response: smsResult.response });
// ── Legacy cURL execution path (preserved, not reached for whitelisted) ────
// The code below is kept for reference and future restoration.
// It is NOT executed in the current Published send flow.
/*
let curlToExecute = String(tmpl.processedCurl || tmpl.rawCurl || '');
curlToExecute = curlToExecute.replace(/\{#toNumber#\}/g, toNumber);
curlToExecute = curlToExecute.replace(/\{#to#\}/g, toNumber);
curlToExecute = curlToExecute.replace(/\{#mobile#\}/g, toNumber);
curlToExecute = curlToExecute.replace(/\{#phone#\}/g, toNumber);
let legacyResult;
try {
legacyResult = await executeCurl(curlToExecute);
} catch (curlErr) {
return res.status(502).json({ error: 'SMS send failed', details: curlErr.message });
}
res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data });
*/
} catch (err) {
sendRouteError(res, err);
}
});
/**
* Minimal cURL parser: extracts method, URL, headers, and body from a cURL string,
* then executes via axios.
*/
async function executeCurl(curlStr) {
const method = /-X\s+([A-Z]+)/i.exec(curlStr)?.[1]?.toLowerCase() || 'post';
const urlMatch = /curl\s+(?:-[^\s]+\s+)*['"]?(https?:\/\/[^\s'"]+)['"]?/i.exec(curlStr);
if (!urlMatch) throw new Error('Could not parse URL from cURL command');
const url = urlMatch[1];
// Extract headers (-H "Key: Value")
const headers = {};
const headerRegex = /-H\s+['"]([^'"]+)['"]/gi;
let hMatch;
while ((hMatch = headerRegex.exec(curlStr)) !== null) {
const parts = hMatch[1].split(/:\s*(.+)/);
if (parts.length >= 2) headers[parts[0].trim()] = parts[1].trim();
}
// Extract body (-d or --data or --data-raw)
const bodyMatch = /(?:--data-raw|--data|-d)\s+['"]([^'"]+)['"]/i.exec(curlStr)
|| /(?:--data-raw|--data|-d)\s+(\S+)/i.exec(curlStr);
const body = bodyMatch ? bodyMatch[1].replace(/\\n/g, '\n') : undefined;
return axios({
method,
url,
headers,
data: body,
timeout: 15000,
validateStatus: () => true, // don't throw on 4xx/5xx so we can relay the response
});
}
module.exports = router;