sms-extension-1777538290/server/routes/businesses.js

3634 lines
123 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { buildBrandContextPlan, collectBrandContextPages } = require('../services/firecrawl');
const { parseBrandContext, generateTemplates, processCurl, validateEditedTemplate, validateCurlFields } = require('../services/openai2');
const { executeTemplatedCurl, parseCurlCommand } = require('../services/curlExecutor');
const { buildCrawlSummary } = require('../services/crawlSummary');
const {
uploadJSON,
fetchJSON,
uploadImageFromUrl,
listImages,
listTemplateFiles,
listFilesWithId,
deleteFile,
deleteBusinessFiles,
} = require('../services/pixelbin');
const {
businessRoot,
indexPath,
onboardingJobsRoot,
} = require('../services/storagePaths');
const {
buildPhoneMetadata,
buildSourceEventKey,
createOrRefreshExecution,
extractProviderMessageId,
getEventMetrics,
getOverviewMetrics,
insertStatusHistory,
markExecutionAccepted,
markExecutionFailed,
markExecutionIgnored,
} = require('../services/analyticsStore');
const DEFAULT_EVENTS = require('../config/defaultEvents');
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, '');
}
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;
});
return exactMatch || 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'];
const BASE_PROFILE_INPUT_KEYS = ['providerName', 'senderId', 'dltEntityId'];
const MASKED_SECRET = '••••••••';
const RUNTIME_TOKEN_MAP = {
toNumber: '__SMS_TO_NUMBER__',
content: '__SMS_CONTENT__',
templateId: '__SMS_TEMPLATE_ID__',
senderId: '__SMS_SENDER_ID__',
dltEntityId: '__SMS_DLT_ENTITY_ID__',
};
const RUNTIME_TOKEN_LABELS = {
toNumber: 'Destination Number',
content: 'SMS Content',
templateId: 'DLT Template ID',
senderId: 'Sender ID',
dltEntityId: 'DLT Entity ID',
};
const CURL_DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']);
const DETERMINISTIC_SENDER_ID_KEYS = ['sender_id', 'senderId', 'sender', 'sender_code', 'senderCode'];
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 hasUnresolvedExecutionToken(value) {
const normalized = normalizeScalarText(value);
const referenceCandidate = normalized.replace(/['"]/g, '');
return /__(?:PROFILE|SMS)_[A-Z0-9_]+__/.test(normalized)
|| /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(referenceCandidate)
|| /^(?:YOUR_[A-Z0-9_]+|CHANGE_ME|REPLACE_ME|INSERT_[A-Z0-9_]+)$/i.test(referenceCandidate);
}
function normalizeResolvedScalarText(value) {
const normalized = normalizeScalarText(value);
return hasUnresolvedExecutionToken(normalized) ? '' : normalized;
}
function firstNonEmptyText(...values) {
for (const value of values) {
const normalized = normalizeScalarText(value);
if (normalized) return normalized;
}
return '';
}
function firstNonEmptyResolvedText(...values) {
for (const value of values) {
const normalized = normalizeResolvedScalarText(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 getSenderIdFromStructuredValue(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
for (const key of DETERMINISTIC_SENDER_ID_KEYS) {
const normalized = normalizeSenderId(value[key]);
if (normalized) return normalized;
}
return '';
}
function getSenderIdFromFormEncodedValue(value = '') {
const normalizedValue = normalizeText(value);
if (!normalizedValue || !normalizedValue.includes('=')) return '';
const params = new URLSearchParams(normalizedValue);
for (const key of DETERMINISTIC_SENDER_ID_KEYS) {
const normalized = normalizeSenderId(params.get(key));
if (normalized) return normalized;
}
return '';
}
function extractDeterministicSenderIdFromCurl(rawCurl = '') {
try {
const parsed = parseCurlCommand(rawCurl);
const urlArg = parsed.args.find((arg) => /^https?:\/\//i.test(String(arg || '')));
if (urlArg) {
try {
const url = new URL(urlArg);
for (const key of DETERMINISTIC_SENDER_ID_KEYS) {
const normalized = normalizeSenderId(url.searchParams.get(key));
if (normalized) return normalized;
}
} catch {
// Ignore malformed URLs here and continue checking data arguments.
}
}
for (let index = 0; index < parsed.args.length; index += 1) {
const argument = parsed.args[index];
let rawValue = '';
if (CURL_DATA_FLAGS.has(argument) && index + 1 < parsed.args.length) {
rawValue = String(parsed.args[index + 1] || '');
index += 1;
} else {
const inlineDataFlag = Array.from(CURL_DATA_FLAGS).find((flag) => argument.startsWith(`${flag}=`));
if (!inlineDataFlag) continue;
rawValue = argument.slice(inlineDataFlag.length + 1);
}
const trimmed = rawValue.trim();
if (!trimmed) continue;
if (
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
const parsedJson = JSON.parse(trimmed);
const structuredSenderId = getSenderIdFromStructuredValue(parsedJson);
if (structuredSenderId) return structuredSenderId;
} catch {
// Fall through to form-encoded parsing when JSON parsing fails.
}
}
const formEncodedSenderId = getSenderIdFromFormEncodedValue(trimmed);
if (formEncodedSenderId) return formEncodedSenderId;
}
} catch {
return '';
}
return '';
}
function pickBestSenderIdCandidate(...values) {
let fallback = '';
for (const value of values) {
const normalized = normalizeSenderId(value);
if (!normalized) continue;
if (!fallback) fallback = normalized;
if (!validateSenderId(normalized)) return normalized;
}
return fallback;
}
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: normalizeResolvedScalarText(provider.authKey),
updatedAt,
};
}
function humanizeInputKey(key) {
return normalizeText(String(key || ''))
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (char) => char.toUpperCase());
}
function normalizeInputKey(value) {
return toCamelCase(value || '');
}
function normalizeRequiredInput(input = {}) {
const key = normalizeInputKey(input.key || input.name || input.field || input.slot);
if (!key) return null;
const requestedSource = ['embedded', 'profile', 'runtime'].includes(normalizeText(input.source))
? normalizeText(input.source)
: 'profile';
return {
key,
label: normalizeText(input.label) || humanizeInputKey(key),
required: input.required !== false,
secret: input.secret === true,
source: ['toNumber', 'content', 'templateId'].includes(key)
? 'runtime'
: requestedSource,
token: normalizeText(input.token),
currentValue: normalizeResolvedScalarText(input.currentValue || input.value),
};
}
function normalizeCurlAnalysis(curlAnalysis = {}, fallbackProvider = {}) {
const requiredInputs = Array.isArray(curlAnalysis.requiredInputs)
? curlAnalysis.requiredInputs.map(normalizeRequiredInput).filter(Boolean)
: [];
return {
providerName: normalizeText(curlAnalysis.providerName || fallbackProvider.providerName),
authMode: normalizeText(curlAnalysis.authMode),
requiredInputs,
slotMap: curlAnalysis.slotMap && typeof curlAnalysis.slotMap === 'object' && !Array.isArray(curlAnalysis.slotMap)
? Object.entries(curlAnalysis.slotMap).reduce((accumulator, [key, value]) => {
const normalizedKey = normalizeInputKey(key);
const normalizedValue = normalizeInputKey(value);
if (normalizedKey && normalizedValue) accumulator[normalizedKey] = normalizedValue;
return accumulator;
}, {})
: {},
warnings: Array.isArray(curlAnalysis.warnings)
? curlAnalysis.warnings.map((warning) => normalizeText(warning)).filter(Boolean)
: [],
normalizedCurlTemplate: normalizeText(curlAnalysis.normalizedCurlTemplate || curlAnalysis.rawCurlTemplate),
};
}
function normalizeProfileInputValues(values = {}) {
if (!values || typeof values !== 'object' || Array.isArray(values)) return {};
return Object.entries(values).reduce((accumulator, [key, value]) => {
const normalizedKey = normalizeInputKey(key);
const normalizedValue = normalizeResolvedScalarText(value);
if (!normalizedKey || !normalizedValue) return accumulator;
accumulator[normalizedKey] = normalizedValue;
return accumulator;
}, {});
}
function getStoredCurlTemplate(profile = {}) {
return normalizeText(profile.rawCurlTemplate || profile.rawCurl || profile.curlAnalysis?.normalizedCurlTemplate);
}
function getStoredProfileValue(profile = {}, key) {
const normalizedKey = normalizeInputKey(key);
if (!normalizedKey) return '';
if (PROVIDER_FIELDS.includes(normalizedKey)) {
return normalizeResolvedScalarText(profile.provider?.[normalizedKey]);
}
if (normalizedKey === 'authKey') {
return firstNonEmptyResolvedText(profile.profileInputValues?.authKey, profile.provider?.authKey);
}
return normalizeResolvedScalarText(profile.profileInputValues?.[normalizedKey]);
}
function setStoredProfileValue(profile = {}, key, value) {
const normalizedKey = normalizeInputKey(key);
const normalizedValue = normalizeResolvedScalarText(value);
if (!normalizedKey) return;
if (PROVIDER_FIELDS.includes(normalizedKey)) {
profile.provider = normalizeProvider({
...profile.provider,
[normalizedKey]: normalizedValue,
}, profile.updatedAt);
return;
}
profile.profileInputValues = {
...(profile.profileInputValues || {}),
[normalizedKey]: normalizedValue,
};
}
function buildBaseProfileInputs(profile = {}) {
return BASE_PROFILE_INPUT_KEYS.map((key) => ({
key,
label: key === 'providerName'
? 'Provider Name'
: key === 'senderId'
? 'Sender ID'
: 'DLT Entity ID',
required: true,
secret: false,
source: 'profile',
token: '',
currentValue: getStoredProfileValue(profile, key),
}));
}
function getProfileInputDefinitions(profile = {}) {
const mergedInputs = new Map();
buildBaseProfileInputs(profile).forEach((input) => {
mergedInputs.set(input.key, input);
});
const requiredInputs = Array.isArray(profile.curlAnalysis?.requiredInputs)
? profile.curlAnalysis.requiredInputs
: [];
requiredInputs
.filter((input) => input?.source !== 'runtime')
.forEach((input) => {
const normalized = normalizeRequiredInput(input);
if (!normalized) return;
const current = mergedInputs.get(normalized.key);
mergedInputs.set(normalized.key, {
...current,
...normalized,
label: normalized.label || current?.label || humanizeInputKey(normalized.key),
required: normalized.required !== false || current?.required === true,
secret: normalized.secret === true || current?.secret === true,
currentValue: firstNonEmptyResolvedText(
getStoredProfileValue(profile, normalized.key),
normalized.currentValue,
current?.currentValue
),
});
});
return Array.from(mergedInputs.values());
}
function serializeProfileInput(profile, input, options = {}) {
const revealSecrets = options.revealSecrets === true;
const value = firstNonEmptyResolvedText(getStoredProfileValue(profile, input.key), input.currentValue);
const hasValue = Boolean(value);
return {
key: input.key,
label: input.label,
required: input.required !== false,
secret: input.secret === true,
source: input.source || 'profile',
token: input.token || '',
hasValue,
value: input.secret && !revealSecrets ? '' : value,
maskedValue: input.secret && hasValue ? MASKED_SECRET : '',
};
}
function getMissingProfileInputs(profile = {}) {
return getProfileInputDefinitions(profile)
.filter((input) => input.required !== false)
.filter((input) => !firstNonEmptyResolvedText(getStoredProfileValue(profile, input.key), input.currentValue))
.map((input) => serializeProfileInput(profile, input));
}
function getExecutionReadiness(profile = {}) {
const missingProfileInputs = getMissingProfileInputs(profile);
return {
isSetupComplete: missingProfileInputs.length === 0,
missingProfileInputKeys: missingProfileInputs.map((input) => input.key),
missingProfileInputs,
};
}
function buildDisplayCurl(profile = {}, options = {}) {
let output = getStoredCurlTemplate(profile);
if (!output) return '';
const revealSecrets = options.revealSecrets === true;
const inputs = getProfileInputDefinitions(profile);
inputs.forEach((input) => {
const token = normalizeText(input.token);
if (!token) return;
const value = firstNonEmptyResolvedText(getStoredProfileValue(profile, input.key), input.currentValue);
if (!value) return;
output = output.split(token).join(input.secret && !revealSecrets ? MASKED_SECRET : value);
});
return output;
}
function serializeCurlAnalysis(profile = {}) {
const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, profile.provider);
return {
providerName: curlAnalysis.providerName,
authMode: curlAnalysis.authMode,
requiredInputs: curlAnalysis.requiredInputs.map((input) => ({
key: input.key,
label: input.label,
required: input.required !== false,
secret: input.secret === true,
source: input.source,
token: input.token,
})),
slotMap: curlAnalysis.slotMap,
warnings: curlAnalysis.warnings,
normalizedCurlTemplate: curlAnalysis.normalizedCurlTemplate,
};
}
function getProfileDisplayValue(profile = {}, key) {
const storedValue = getStoredProfileValue(profile, key);
if (storedValue) return storedValue;
const matchingInput = getProfileInputDefinitions(profile)
.find((input) => input.key === normalizeInputKey(key) && input.source !== 'runtime');
return firstNonEmptyResolvedText(matchingInput?.currentValue);
}
function serializeProfile(profile = {}) {
const hydratedProfile = hydrateProfile(profile);
return {
...hydratedProfile,
rawCurl: undefined,
rawCurlTemplate: undefined,
profileInputValues: undefined,
provider: {
providerName: getProfileDisplayValue(hydratedProfile, 'providerName'),
senderId: getProfileDisplayValue(hydratedProfile, 'senderId'),
dltEntityId: getProfileDisplayValue(hydratedProfile, 'dltEntityId'),
updatedAt: hydratedProfile.provider?.updatedAt || hydratedProfile.updatedAt,
},
hasStoredCurl: Boolean(getStoredCurlTemplate(hydratedProfile)),
maskedCurl: buildDisplayCurl(hydratedProfile),
curlAnalysis: serializeCurlAnalysis(hydratedProfile),
profileInputs: getProfileInputDefinitions(hydratedProfile).map((input) => serializeProfileInput(hydratedProfile, input)),
executionReadiness: getExecutionReadiness(hydratedProfile),
};
}
function getProfileRevealPayload(profile = {}) {
const hydratedProfile = hydrateProfile(profile);
return {
rawCurl: buildDisplayCurl(hydratedProfile, { revealSecrets: true }),
profileInputs: getProfileInputDefinitions(hydratedProfile)
.map((input) => serializeProfileInput(hydratedProfile, input, { revealSecrets: true })),
};
}
function sanitizeStoredCurlAnalysis(profile = {}) {
const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, profile.provider);
return {
providerName: curlAnalysis.providerName,
authMode: curlAnalysis.authMode,
requiredInputs: curlAnalysis.requiredInputs.map((input) => ({
key: input.key,
label: input.label,
required: input.required !== false,
secret: input.secret === true,
source: input.source,
token: input.token,
})),
slotMap: curlAnalysis.slotMap,
warnings: curlAnalysis.warnings,
normalizedCurlTemplate: normalizeText(curlAnalysis.normalizedCurlTemplate),
};
}
function persistableProfile(profile = {}) {
const hydratedProfile = hydrateProfile(profile);
const normalizedAuthKey = firstNonEmptyResolvedText(
hydratedProfile.profileInputValues?.authKey,
hydratedProfile.provider?.authKey,
);
return {
id: hydratedProfile.id,
name: normalizeText(hydratedProfile.name),
rawCurl: getStoredCurlTemplate(hydratedProfile),
isDefault: hydratedProfile.isDefault === true,
provider: {
...normalizeProvider(hydratedProfile.provider, hydratedProfile.updatedAt),
authKey: '',
},
profileInputValues: normalizeProfileInputValues({
...hydratedProfile.profileInputValues,
...(normalizedAuthKey ? { authKey: normalizedAuthKey } : {}),
}),
curlAnalysis: sanitizeStoredCurlAnalysis(hydratedProfile),
createdAt: hydratedProfile.createdAt,
updatedAt: hydratedProfile.updatedAt,
};
}
async function saveGlobalSmsProfiles(bizRoot, profileData = {}) {
const profiles = Array.isArray(profileData?.profiles)
? profileData.profiles.map((profile) => persistableProfile(profile))
: [];
await uploadJSON(bizRoot, 'global_sms_profiles', { profiles });
}
function clearActiveProfileSelection(bizRoot) {
return uploadJSON(bizRoot, 'active_curl_profile', {
profileId: null,
updatedAt: new Date().toISOString(),
});
}
function isTemplateRuntimeEnabled(template = {}) {
return template?.isRuntimeEnabled !== false;
}
function withTemplateRuntimeDefaults(template) {
if (!template || typeof template !== 'object') return template;
return {
...template,
isRuntimeEnabled: isTemplateRuntimeEnabled(template),
};
}
function normalizeTemplateVariableMap(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((accumulator, [key, rawValue]) => {
const normalizedKey = normalizeText(String(key || ''));
const normalizedValue = normalizeInputKey(rawValue);
if (!normalizedKey || !normalizedValue) return accumulator;
accumulator[normalizedKey] = normalizedValue;
return accumulator;
}, {});
}
function normalizeTemplateRequiredInputs(value) {
if (!Array.isArray(value)) return [];
return value
.map(normalizeRequiredInput)
.filter(Boolean)
.map(({ currentValue, ...input }) => input);
}
function normalizeTemplateExecutionMeta(meta = {}, fallback = {}) {
const placeholderTokens = Array.isArray(meta?.placeholderTokens) && meta.placeholderTokens.length > 0
? meta.placeholderTokens.map((token) => normalizeText(token)).filter(Boolean)
: (normalizeText(fallback.selectedTemplate).match(DLT_PLACEHOLDER_REGEX) || []);
const runtimeInputKeys = Array.isArray(meta?.runtimeInputKeys)
? meta.runtimeInputKeys.map((key) => normalizeInputKey(key)).filter(Boolean)
: [];
const profileInputKeys = Array.isArray(meta?.profileInputKeys)
? meta.profileInputKeys.map((key) => normalizeInputKey(key)).filter(Boolean)
: [];
const requiredInputs = Array.isArray(fallback.requiredInputs) ? fallback.requiredInputs : [];
const processedCurlTemplate = normalizeText(fallback.processedCurlTemplate);
const derivedRuntimeKeys = requiredInputs
.filter((input) => input.source === 'runtime')
.map((input) => input.key);
const derivedProfileKeys = requiredInputs
.filter((input) => input.source !== 'runtime')
.map((input) => input.key);
Object.entries(RUNTIME_TOKEN_MAP).forEach(([key, token]) => {
if (processedCurlTemplate.includes(token)) {
derivedRuntimeKeys.push(normalizeInputKey(key));
}
});
return {
eventSlug: normalizeText(meta?.eventSlug || fallback.eventSlug),
renderStrategy: normalizeText(meta?.renderStrategy) || 'deterministic_sample_payload',
placeholderCount: Number.isFinite(meta?.placeholderCount)
? meta.placeholderCount
: placeholderTokens.length,
placeholderTokens,
runtimeInputKeys: [...new Set([...runtimeInputKeys, ...derivedRuntimeKeys].filter(Boolean))],
profileInputKeys: [...new Set([...profileInputKeys, ...derivedProfileKeys].filter(Boolean))],
hasContentToken: typeof meta?.hasContentToken === 'boolean'
? meta.hasContentToken
: processedCurlTemplate.includes(RUNTIME_TOKEN_MAP.content),
hasToNumberToken: typeof meta?.hasToNumberToken === 'boolean'
? meta.hasToNumberToken
: processedCurlTemplate.includes(RUNTIME_TOKEN_MAP.toNumber),
hasTemplateIdToken: typeof meta?.hasTemplateIdToken === 'boolean'
? meta.hasTemplateIdToken
: processedCurlTemplate.includes(RUNTIME_TOKEN_MAP.templateId),
};
}
function withTemplateExecutionDefaults(template) {
if (!template || typeof template !== 'object') return template;
const processedCurlTemplate = normalizeText(
template.processedCurlTemplate
|| template.processedCurl
|| template.rawCurl
);
const requiredInputs = normalizeTemplateRequiredInputs(template.requiredInputs);
const slotMap = template.slotMap && typeof template.slotMap === 'object' && !Array.isArray(template.slotMap)
? Object.entries(template.slotMap).reduce((accumulator, [key, value]) => {
const normalizedKey = normalizeInputKey(key);
const normalizedValue = normalizeInputKey(value);
if (normalizedKey && normalizedValue) accumulator[normalizedKey] = normalizedValue;
return accumulator;
}, {})
: {};
return {
...template,
processedCurl: processedCurlTemplate,
processedCurlTemplate,
variableMap: normalizeTemplateVariableMap(template.variableMap),
requiredInputs,
slotMap,
executionMeta: normalizeTemplateExecutionMeta(template.executionMeta, {
eventSlug: template.eventSlug,
selectedTemplate: template.selectedTemplate,
requiredInputs,
processedCurlTemplate,
}),
};
}
function withTemplateDefaults(template) {
return withTemplateExecutionDefaults(withTemplateRuntimeDefaults(template));
}
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 normalizeUrlList(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((entry) => normalizeText(entry))
.filter((entry) => {
if (!entry || seen.has(entry)) return false;
seen.add(entry);
return true;
});
}
function getBusinessPreviewSummary(source = {}) {
const taglines = Array.isArray(source?.taglines)
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
: [];
const relevantImagePaths = Array.isArray(source?.relevantImagePaths)
? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean)
: [];
return {
previewTagline: taglines[0] || '',
previewImagePath: relevantImagePaths[0] || '',
};
}
function mergeBusinessSummary(baseBusiness = {}, context = null) {
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
const relevantImagePaths = normalizeUrlList(
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
? baseBusiness.relevantImagePaths
: context?.relevantImagePaths
);
return {
...baseBusiness,
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
relevantImagePaths,
};
}
function buildScrapeArtifacts(crawlSummary, imagePaths = []) {
return {
cdnUrls: normalizeUrlList(imagePaths),
links: Array.isArray(crawlSummary?.links) ? crawlSummary.links : [],
json: crawlSummary && typeof crawlSummary === 'object' ? crawlSummary : {},
};
}
function extractAboutSummary(crawlSummary = {}) {
return normalizeText(
crawlSummary?.aboutPage?.excerpt
|| crawlSummary?.aboutPage?.description
|| crawlSummary?.homepage?.description
|| crawlSummary?.homepage?.excerpt
|| ''
);
}
function buildJobResponse(job) {
return {
jobId: normalizeText(job?.jobId),
status: normalizeText(job?.status),
stage: normalizeText(job?.stage),
companyId: normalizeScopeId(job?.companyId),
applicationId: normalizeScopeId(job?.applicationId),
websiteUrl: normalizeWebsiteUrl(job?.websiteUrl),
progress: job?.progress && typeof job.progress === 'object' ? job.progress : {},
business: job?.business && typeof job.business === 'object' ? job.business : null,
error: job?.error && typeof job.error === 'object' ? job.error : null,
createdAt: normalizeText(job?.createdAt),
updatedAt: normalizeText(job?.updatedAt),
};
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function loadOnboardingJob(companyId, jobId) {
return fetchJSON(onboardingJobsRoot(companyId), jobId);
}
async function loadOnboardingJobWithRetry(companyId, jobId, options = {}) {
const attempts = Number.isFinite(options.attempts) ? options.attempts : 6;
const delayMs = Number.isFinite(options.delayMs) ? options.delayMs : 350;
for (let attempt = 0; attempt < attempts; attempt += 1) {
const job = await loadOnboardingJob(companyId, jobId);
if (job) return job;
if (attempt < attempts - 1) {
await wait(delayMs);
}
}
return null;
}
async function saveOnboardingJob(job) {
const normalizedJob = {
...job,
updatedAt: new Date().toISOString(),
};
await uploadJSON(onboardingJobsRoot(normalizedJob.companyId), normalizedJob.jobId, normalizedJob);
return normalizedJob;
}
async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
const merchantId = job.companyId;
const applicationId = normalizeScopeId(job.applicationId);
const websiteUrl = normalizeWebsiteUrl(job.websiteUrl);
if (applicationId) {
const existingBusiness = await findBusinessByApplicationId(merchantId, applicationId);
if (existingBusiness) {
const existingContext = await fetchJSON(businessRoot(merchantId, existingBusiness.businessId), 'context').catch(() => null);
const mergedBusiness = existingContext ? { ...existingContext } : mergeBusinessSummary(existingBusiness);
return {
business: {
...mergedBusiness,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, mergedBusiness.relevantImagePaths),
},
reusedExistingBusiness: true,
};
}
}
const businesses = await getIndex(merchantId);
const businessId = uuidv4();
const bizRoot = businessRoot(merchantId, businessId);
const imagesFolder = `${bizRoot}/images`;
const imagePaths = [];
const imageCandidates = normalizeUrlList(brandContext?.relevantImageUrls);
for (let i = 0; i < Math.min(imageCandidates.length, 6); i += 1) {
const uploaded = await uploadImageFromUrl(imageCandidates[i], imagesFolder, `image_${i + 1}`);
if (uploaded) imagePaths.push(uploaded);
}
let domain = normalizeText(crawlSummary?.domain);
if (!domain) {
try {
domain = new URL(websiteUrl).hostname;
} catch {
domain = '';
}
}
const now = new Date().toISOString();
const contextJson = {
businessId,
merchantId,
companyId: merchantId,
applicationId,
domain,
brandName: brandContext.brandName || 'Unknown Brand',
tone: brandContext.tone || 'professional',
taglines: Array.isArray(brandContext.taglines) ? brandContext.taglines : [],
colors: Array.isArray(brandContext.colors) ? brandContext.colors : [],
relevantImagePaths: imagePaths,
aboutSummary: normalizeText(brandContext.aboutSummary) || extractAboutSummary(crawlSummary),
websiteUrl,
crawlStats: crawlSummary?.siteStats || {},
createdAt: now,
updatedAt: now,
};
await uploadJSON(bizRoot, 'context', contextJson);
await uploadJSON(bizRoot, 'crawl_summary', crawlSummary);
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
const previewSummary = getBusinessPreviewSummary(contextJson);
businesses.push({
businessId,
companyId: merchantId,
applicationId,
brandName: contextJson.brandName,
domain: contextJson.domain,
previewTagline: previewSummary.previewTagline,
previewImagePath: previewSummary.previewImagePath,
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
createdAt: contextJson.createdAt,
updatedAt: contextJson.updatedAt,
});
await saveIndex(merchantId, businesses);
return {
business: {
...contextJson,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, imagePaths),
},
reusedExistingBusiness: false,
};
}
async function advanceOnboardingJob(job) {
if (!job || typeof job !== 'object') {
throw createHttpError(404, 'Onboarding job not found');
}
if (job.status === 'completed' || job.status === 'failed') {
return job;
}
try {
let pagePlan = job?.pagePlan && typeof job.pagePlan === 'object' ? job.pagePlan : null;
if (!pagePlan) {
job.status = 'crawling';
job.stage = 'crawling';
await saveOnboardingJob(job);
pagePlan = await buildBrandContextPlan(job.websiteUrl);
job.pagePlan = pagePlan;
job.progress = {
...(job.progress || {}),
pagesProcessed: 1,
pagesDiscovered: 1
+ (pagePlan.aboutUrl ? 1 : 0)
+ (Array.isArray(pagePlan.productUrls) ? pagePlan.productUrls.length : 0)
+ (pagePlan.discoveryUrl ? 1 : 0),
imageCount: Array.isArray(pagePlan.homepage?.images) ? pagePlan.homepage.images.length : 0,
linkCount: Array.isArray(pagePlan.homepage?.links) ? pagePlan.homepage.links.length : 0,
};
return saveOnboardingJob(job);
}
let crawlSummary = job?.crawlSummary && typeof job.crawlSummary === 'object' ? job.crawlSummary : null;
if (!crawlSummary) {
job.status = 'summarizing';
job.stage = 'summarizing';
await saveOnboardingJob(job);
const pageSet = await collectBrandContextPages(pagePlan);
crawlSummary = buildCrawlSummary(pageSet, job.websiteUrl);
job.crawlSummary = crawlSummary;
delete job.pagePlan;
job.progress = {
...(job.progress || {}),
pagesProcessed: crawlSummary.pageCount || 0,
pagesDiscovered: crawlSummary.pageCount || 0,
representativePages: Array.isArray(crawlSummary.representativePages) ? crawlSummary.representativePages.length : 0,
imageCount: Array.isArray(crawlSummary.topImages) ? crawlSummary.topImages.length : 0,
linkCount: Array.isArray(crawlSummary.links) ? crawlSummary.links.length : 0,
};
return saveOnboardingJob(job);
}
let brandContext = job?.brandContext && typeof job.brandContext === 'object' ? job.brandContext : null;
if (!brandContext) {
job.status = 'parsing_brand';
job.stage = 'parsing_brand';
await saveOnboardingJob(job);
brandContext = await parseBrandContext(crawlSummary);
job.brandContext = brandContext;
job.status = 'finalizing_business';
job.stage = 'finalizing_business';
return saveOnboardingJob(job);
}
job.status = 'finalizing_business';
job.stage = 'finalizing_business';
await saveOnboardingJob(job);
const result = await finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext);
job.status = 'completed';
job.stage = 'completed';
job.business = result.business;
job.error = null;
return saveOnboardingJob(job);
} catch (error) {
job.status = 'failed';
job.stage = 'failed';
job.error = {
message: error.message || 'Business onboarding failed',
};
return saveOnboardingJob(job);
}
}
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 getShipmentApplicationId(req) {
const shipment = getShipmentPayload(req.body);
return normalizeScopeId(
getApplicationId(req)
|| shipment?.application_id
|| shipment?.affiliate_details?.affiliate_id
|| shipment?.affiliate_details?.id
|| shipment?.affiliate_details?.config?.id
);
}
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|urlott|cbn|email|alphanumeric)#\}/g;
const PLACEHOLDER_SAMPLE_FIELD_CANDIDATES = {
'{#var#}': ['firstName', 'customerName', 'fullName', 'brandName', 'eventDisplayName'],
'{#numeric#}': ['otp', 'amount', 'refundAmount', 'pincode', 'toNumber'],
'{#url#}': ['trackingUrl', 'url', 'trackUrl', 'trackingLink'],
'{#urlott#}': ['trackingUrl', 'url', 'trackUrl', 'trackingLink'],
'{#cbn#}': ['callbackNumber', 'toNumber', 'customerPhone', 'mobile', 'phone'],
'{#email#}': ['email', 'customerEmail'],
'{#alphanumeric#}': ['orderId', 'transactionId', 'shipmentId', 'awbNumber', 'awbNo'],
};
const EVENT_SAMPLE_OVERRIDES = {
payment_failed: {
shipment: {
payment_status: 'failed',
transaction_id: 'TXN9012457812',
amount: '2499',
failure_reason: 'UPI mandate expired',
},
},
payment_initiated: {
shipment: {
payment_status: 'initiated',
transaction_id: 'TXN9012457812',
amount: '2499',
},
},
refund_initiated: {
shipment: {
refund_status: 'initiated',
refund_amount: '2499',
refund_id: 'RFD1204982',
},
},
refund_completed: {
shipment: {
refund_status: 'completed',
refund_amount: '2499',
refund_id: 'RFD1204982',
},
},
out_for_delivery: {
shipment: {
otp: '482193',
estimated_delivery_slot: '6:00 PM to 8:00 PM',
},
},
delivery_attempt_failed: {
shipment: {
failure_reason: 'Customer unavailable',
callback_number: '919876543210',
},
},
delivery_done: {
shipment: {
delivered_at: '2026-04-06T14:18:00.000Z',
otp: '482193',
},
},
order_placed: {
shipment: {
payment_status: 'paid',
expected_dispatch_date: '2026-04-07',
},
},
};
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 mergeDeep(baseValue, overrideValue) {
if (Array.isArray(baseValue) || Array.isArray(overrideValue)) {
return overrideValue !== undefined ? overrideValue : baseValue;
}
if (baseValue && typeof baseValue === 'object' && overrideValue && typeof overrideValue === 'object') {
const nextValue = { ...baseValue };
Object.entries(overrideValue).forEach(([key, value]) => {
nextValue[key] = key in nextValue ? mergeDeep(nextValue[key], value) : value;
});
return nextValue;
}
return overrideValue !== undefined ? overrideValue : baseValue;
}
function titleCaseFromSlug(slug) {
return String(slug || '')
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
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: withTemplateDefaults(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 emailAddress = firstNonEmptyText(
shipment?.user?.email,
shipment?.delivery_address?.email,
shipment?.billing_address?.email
);
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, 'email', emailAddress);
setValueIndexEntry(valueIndex, 'customerEmail', emailAddress);
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 isRenderablePreviewValueForToken(token, value) {
const normalizedValue = normalizeRenderableValue(value);
if (!normalizedValue) return false;
switch (token) {
case '{#numeric#}':
return /^\d+$/.test(normalizedValue);
case '{#url#}':
case '{#urlott#}':
return /^https?:\/\//i.test(normalizedValue);
case '{#cbn#}':
return /^\+?[0-9][0-9\s-]{5,}$/.test(normalizedValue);
case '{#email#}':
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedValue);
case '{#alphanumeric#}':
return /^[A-Za-z0-9]+$/.test(normalizedValue);
default:
return true;
}
}
function resolvePlaceholderSampleFallback(token, shipmentValueIndex) {
const candidateFields = PLACEHOLDER_SAMPLE_FIELD_CANDIDATES[token] || [];
for (const fieldName of candidateFields) {
const resolvedValue = shipmentValueIndex.get(fieldName) || '';
if (isRenderablePreviewValueForToken(token, resolvedValue)) {
return {
fieldName,
value: resolvedValue,
};
}
}
return null;
}
function getTemplateSamplePayload(template = {}) {
const eventSlug = normalizeText(template?.eventSlug);
const eventLabel = normalizeText(template?.eventLabel) || titleCaseFromSlug(eventSlug);
const brandName = normalizeText(template?.brandName) || 'Your Brand';
const override = EVENT_SAMPLE_OVERRIDES[eventSlug] || {};
const basePayload = {
payload: {
event: eventSlug,
company_id: 'dev_merchant_001',
application_id: 'application-demo-001',
shipment: {
application_id: 'application-demo-001',
order_id: 'FY5E53AFAA091115C235',
shipment_id: 'SHP784512',
status: eventSlug || 'order_placed',
shipment_status: {
status: eventSlug || 'order_placed',
current_shipment_status: eventSlug || 'order_placed',
display_name: eventLabel || 'Order Update',
shipment_id: 'SHP784512',
},
user: {
first_name: 'Aarav',
last_name: 'Sharma',
mobile: '919876543210',
email: '[email protected]',
},
delivery_address: {
name: 'Aarav Sharma',
phone: '919876543210',
email: '[email protected]',
city: 'Bengaluru',
pincode: '560001',
},
billing_address: {
name: 'Aarav Sharma',
phone: '919876543210',
email: '[email protected]',
},
delivery_partner_details: {
display_name: 'Blue Dart',
track_url: 'https://tracking.example.com/SHP784512',
awb_no: '78451236985',
},
affiliate_details: {
affiliate_id: 'application-demo-001',
company_affiliate_tag: brandName,
shipment_meta: {
tracking_url: 'https://tracking.example.com/SHP784512',
courier_partner_name: 'Blue Dart',
},
},
meta: {
tracking_url: 'https://tracking.example.com/SHP784512',
awb_number: '78451236985',
courier_partner_name: 'Blue Dart',
},
bags: [
{
brand: { brand_name: brandName },
item: {
name: 'Midnight Duffle',
attributes: { brand_name: brandName },
},
meta: {
tracking_url: 'https://tracking.example.com/SHP784512',
awb_number: '78451236985',
},
},
],
},
},
};
return mergeDeep(basePayload, override);
}
function buildTemplateSampleRender(templateText, variableMap = {}, samplePayload = {}) {
const text = String(templateText || '');
if (!text) {
return {
text: '',
fallbackPlaceholders: [],
unresolvedPlaceholders: [],
};
}
const shipment = samplePayload?.payload?.shipment || samplePayload?.shipment || {};
const shipmentValueIndex = buildShipmentValueIndex(shipment);
let placeholderIndex = 0;
const fallbackPlaceholders = [];
const unresolvedPlaceholders = [];
const renderedText = text.replace(DLT_PLACEHOLDER_REGEX, (token) => {
const mappingKey = `${token}[${placeholderIndex}]`;
const mappedFieldName = normalizeScalarText(variableMap?.[mappingKey] || variableMap?.[token]);
placeholderIndex += 1;
const resolvedMappedValue = mappedFieldName
? shipmentValueIndex.get(toCamelCase(mappedFieldName)) || ''
: '';
if (resolvedMappedValue) return resolvedMappedValue;
const fallback = resolvePlaceholderSampleFallback(token, shipmentValueIndex);
if (fallback) {
fallbackPlaceholders.push({
mappingKey,
token,
mappedFieldName,
sampleFieldName: fallback.fieldName,
});
return fallback.value;
}
unresolvedPlaceholders.push({
mappingKey,
token,
mappedFieldName,
});
return token;
});
return {
text: renderedText,
fallbackPlaceholders,
unresolvedPlaceholders,
};
}
function renderTemplateWithDeterministicSample(template = {}) {
const normalizedTemplate = withTemplateDefaults(template);
const samplePayload = getTemplateSamplePayload(normalizedTemplate);
const renderState = buildTemplateSampleRender(
normalizedTemplate.selectedTemplate,
normalizedTemplate.variableMap,
samplePayload,
);
if (renderState.unresolvedPlaceholders.length > 0) {
throw createHttpError(422, 'Template contains unresolved placeholders for deterministic sample rendering.', {
code: 'UNRESOLVED_TEMPLATE_PLACEHOLDERS',
details: {
unresolvedPlaceholders: renderState.unresolvedPlaceholders,
fallbackPlaceholders: renderState.fallbackPlaceholders,
},
});
}
return {
content: renderState.text,
renderState,
samplePayload,
};
}
function getTemplateExecutionSnapshot(template = {}, boundProfile = {}) {
const normalizedTemplate = withTemplateDefaults(template);
const profileCurlAnalysis = normalizeCurlAnalysis(boundProfile?.curlAnalysis, boundProfile?.provider);
const templateRequiredInputs = Array.isArray(normalizedTemplate.requiredInputs) ? normalizedTemplate.requiredInputs : [];
const requiredInputs = normalizeTemplateRequiredInputs(
templateRequiredInputs.length > 0
? templateRequiredInputs
: profileCurlAnalysis.requiredInputs,
);
const slotMap = normalizedTemplate.slotMap && Object.keys(normalizedTemplate.slotMap).length > 0
? normalizedTemplate.slotMap
: profileCurlAnalysis.slotMap || {};
const processedCurlTemplate = normalizeText(
normalizedTemplate.processedCurlTemplate
|| getStoredCurlTemplate(boundProfile)
|| normalizedTemplate.processedCurl
|| normalizedTemplate.rawCurl,
);
return {
processedCurlTemplate,
variableMap: normalizeTemplateVariableMap(normalizedTemplate.variableMap),
requiredInputs,
slotMap,
executionMeta: normalizeTemplateExecutionMeta(normalizedTemplate.executionMeta, {
eventSlug: normalizedTemplate.eventSlug,
selectedTemplate: normalizedTemplate.selectedTemplate,
requiredInputs,
processedCurlTemplate,
}),
};
}
function getRuntimeTokenValue(key, runtimeValues = {}, boundProfile = {}) {
switch (normalizeInputKey(key)) {
case 'toNumber':
return normalizeScalarText(runtimeValues.toNumber);
case 'content':
return normalizeScalarText(runtimeValues.content);
case 'templateId':
return normalizeScalarText(runtimeValues.templateId);
case 'senderId':
return normalizeSenderId(firstNonEmptyText(runtimeValues.senderId, boundProfile?.provider?.senderId));
case 'dltEntityId':
return normalizeScalarText(firstNonEmptyText(runtimeValues.dltEntityId, boundProfile?.provider?.dltEntityId));
default:
return normalizeScalarText(runtimeValues[key]);
}
}
function buildExecutionTokenValues(boundProfile = {}, executionSnapshot = {}, runtimeValues = {}) {
const tokenValues = {};
getProfileInputDefinitions(boundProfile).forEach((input) => {
const token = normalizeText(input.token);
const value = firstNonEmptyResolvedText(getStoredProfileValue(boundProfile, input.key), input.currentValue);
if (token && value) {
tokenValues[token] = value;
}
});
(executionSnapshot.requiredInputs || []).forEach((input) => {
const token = normalizeText(input.token);
if (!token) return;
if (input.source === 'runtime') {
const runtimeValue = getRuntimeTokenValue(input.key, runtimeValues, boundProfile);
if (runtimeValue) tokenValues[token] = runtimeValue;
return;
}
const value = firstNonEmptyResolvedText(getStoredProfileValue(boundProfile, input.key), input.currentValue);
if (value) tokenValues[token] = value;
});
Object.entries(RUNTIME_TOKEN_MAP).forEach(([key, token]) => {
const runtimeValue = getRuntimeTokenValue(key, runtimeValues, boundProfile);
if (runtimeValue) {
tokenValues[token] = runtimeValue;
}
});
return tokenValues;
}
function getExpectedExecutionTokens(executionSnapshot = {}) {
const expectedTokens = new Map();
const processedCurlTemplate = normalizeText(executionSnapshot.processedCurlTemplate);
(executionSnapshot.requiredInputs || []).forEach((input) => {
const token = normalizeText(input.token);
if (!token || !processedCurlTemplate.includes(token)) return;
expectedTokens.set(token, {
key: input.key,
label: input.label || humanizeInputKey(input.key),
source: input.source || 'profile',
});
});
Object.entries(RUNTIME_TOKEN_MAP).forEach(([key, token]) => {
if (!processedCurlTemplate.includes(token)) return;
expectedTokens.set(token, {
key,
label: RUNTIME_TOKEN_LABELS[key] || humanizeInputKey(key),
source: 'runtime',
});
});
return Array.from(expectedTokens.entries()).map(([token, descriptor]) => ({
token,
...descriptor,
}));
}
function getMissingExecutionValues(executionSnapshot = {}, tokenValues = {}) {
return getExpectedExecutionTokens(executionSnapshot)
.filter((descriptor) => !normalizeScalarText(tokenValues[descriptor.token]));
}
async function sendTemplateViaCurl({ boundProfile, template, runtimeValues = {}, timeoutMs = 30000 }) {
const executionSnapshot = getTemplateExecutionSnapshot(template, boundProfile);
if (!executionSnapshot.processedCurlTemplate) {
throw createHttpError(
422,
'This template does not have an executable cURL snapshot. Re-select the template from Events before continuing.',
{ code: 'MISSING_EXECUTION_SNAPSHOT' },
);
}
const tokenValues = buildExecutionTokenValues(boundProfile, executionSnapshot, runtimeValues);
const missingValues = getMissingExecutionValues(executionSnapshot, tokenValues);
if (missingValues.length > 0) {
throw createHttpError(
422,
'Missing execution values for the stored cURL template.',
{
code: 'MISSING_EXECUTION_VALUES',
missingFields: missingValues.map((item) => item.key),
details: {
missingValues,
},
},
);
}
try {
const result = await executeTemplatedCurl(
executionSnapshot.processedCurlTemplate,
tokenValues,
{ timeoutMs },
);
return {
...result,
transport: 'curl',
};
} catch (error) {
if (error.code === 'UNRESOLVED_CURL_TOKENS') {
throw createHttpError(
422,
'Stored cURL still contains unresolved execution tokens. Re-select the template from Events before continuing.',
{
code: error.code,
details: error.details,
},
);
}
if (error.code && error.code.startsWith('CURL_EXECUTION_')) {
throw createHttpError(502, 'SMS send failed', {
code: error.code,
details: error.details || error.message,
});
}
throw error;
}
}
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 === '{#urlott#}' && !/^https?:\/\//i.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid OTT 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}`);
}
if (token === '{#email#}' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid email address for ${token}`);
}
if (token === '{#alphanumeric#}' && !/^[A-Za-z0-9]+$/.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to a non-alphanumeric value 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 buildMissingField(field, error, acceptedPaths = []) {
return {
field,
error,
details: acceptedPaths.length > 0 ? { acceptedPaths } : undefined,
};
}
function buildResolveTemplateContext(req) {
const companyId = getCompanyId(req);
const shipment = getShipmentPayload(req.body);
const applicationId = getShipmentApplicationId(req);
const event = getShipmentEventKey(req.body);
const toNumber = getShipmentToNumber(req.body);
const missingFields = [];
if (!companyId) {
missingFields.push(buildMissingField('companyId', 'companyId is required'));
}
if (!shipment) {
missingFields.push(buildMissingField(
'shipment',
'payload.shipment is required',
['payload.shipment']
));
}
if (!applicationId) {
missingFields.push(buildMissingField(
'applicationId',
'A shipment applicationId is required',
[
'application_id',
'payload.shipment.application_id',
'payload.shipment.affiliate_details.affiliate_id',
'payload.shipment.affiliate_details.id',
'payload.shipment.affiliate_details.config.id',
]
));
}
if (!event) {
missingFields.push(buildMissingField(
'event',
'A shipment event status is required',
[
'payload.shipment.status',
'payload.shipment.shipment_status.status',
'payload.shipment.shipment_status.current_shipment_status',
]
));
}
if (!toNumber) {
missingFields.push(buildMissingField(
'toNumber',
'A shipment phone number is required',
[
'payload.shipment.user.mobile',
'payload.shipment.delivery_address.phone',
'payload.shipment.billing_address.phone',
]
));
}
return {
companyId,
shipment,
applicationId,
event,
toNumber,
missingFields,
brandName: getShipmentBrandName(req.body),
};
}
function getResolveTemplateMissingError(context) {
const firstMissingField = context.missingFields[0];
if (!firstMissingField) return null;
return createHttpError(400, firstMissingField.error, {
details: firstMissingField.details,
});
}
function getShipmentOrderId(shipment = {}) {
return firstNonEmptyText(
shipment?.order_id,
shipment?.order?.id
);
}
function getShipmentRecordId(shipment = {}) {
return firstNonEmptyText(
shipment?.shipment_id,
shipment?.shipment_status?.shipment_id
);
}
async function runAnalyticsWrite(label, handler) {
try {
return await handler();
} catch (error) {
console.warn(`[Analytics] ${label} failed: ${error.message}`);
return null;
}
}
async function createWebhookAnalyticsExecution({ context, business, payload }) {
if (!business?.businessId) return null;
const eventSlug = slugify(context.event);
const { toNumberHash, toNumberLast4 } = buildPhoneMetadata(context.toNumber);
return runAnalyticsWrite('create execution', () => createOrRefreshExecution({
companyId: context.companyId,
businessId: business.businessId,
applicationId: context.applicationId,
sourceType: 'fynd_webhook',
sourceEventKey: buildSourceEventKey({
applicationId: context.applicationId,
shipmentId: getShipmentRecordId(context.shipment),
orderId: getShipmentOrderId(context.shipment),
eventSlug,
payload,
}),
eventSlug,
eventLabel: titleCaseFromSlug(eventSlug),
shipmentId: getShipmentRecordId(context.shipment),
orderId: getShipmentOrderId(context.shipment),
toNumberHash,
toNumberLast4,
triggerPayload: payload,
triggeredAt: new Date().toISOString(),
isTest: false,
}));
}
async function appendAnalyticsStatusHistory(messageExecutionId, entry = {}) {
if (!messageExecutionId) return null;
return runAnalyticsWrite('insert status history', () => insertStatusHistory({
messageExecutionId,
statusSource: entry.statusSource || 'internal',
statusType: entry.statusType,
normalizedStatus: entry.normalizedStatus,
providerName: entry.providerName,
providerMessageId: entry.providerMessageId,
providerStatus: entry.providerStatus,
providerStatusCode: entry.providerStatusCode,
errorCode: entry.errorCode,
errorMessage: entry.errorMessage,
payload: entry.payload || null,
headers: entry.headers || null,
occurredAt: entry.occurredAt || new Date().toISOString(),
}));
}
async function resolveTemplateRequest(context, resolvedBusiness = null) {
const business = resolvedBusiness || await findBusinessByApplicationId(context.companyId, context.applicationId);
if (!business) {
throw createHttpError(404, 'Business not found for applicationId');
}
const eventSlug = slugify(context.event);
const folder = `${businessRoot(context.companyId, business.businessId)}/templates`;
const { template: tmpl, matchedSlug } = await resolveWhitelistedTemplate(folder, eventSlug);
if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) {
throw createHttpError(404, 'Whitelisted template not found');
}
if (!isTemplateRuntimeEnabled(tmpl)) {
throw createHttpError(409, 'Template runtime is paused', {
code: 'RUNTIME_DISABLED',
template: withTemplateDefaults(tmpl),
});
}
const boundProfile = await getBoundProfile(
businessRoot(context.companyId, business.businessId),
tmpl.curlProfileId,
);
const missingFields = getMissingProfileInputKeys(boundProfile);
if (missingFields.length > 0) {
throw createHttpError(422, 'Missing mandatory profile fields', {
code: 'MISSING_BOUND_PROFILE_FIELDS',
missingFields,
});
}
const resolvedTemplate = renderShipmentTemplate(
tmpl.selectedTemplate,
context.shipment,
tmpl.variableMap || {}
);
const sendResult = await sendTemplateViaCurl({
boundProfile,
template: tmpl,
runtimeValues: {
content: resolvedTemplate,
toNumber: context.toNumber,
templateId: tmpl.templateId,
senderId: boundProfile.provider?.senderId,
dltEntityId: boundProfile.provider?.dltEntityId,
},
});
if (!sendResult.success) {
throw createHttpError(
502,
`Provider cURL failed with status ${sendResult.statusCode || 0}`,
{
code: 'CURL_PROVIDER_ERROR',
details: {
statusCode: sendResult.statusCode,
response: sendResult.response,
},
},
);
}
return {
companyId: context.companyId,
businessId: business.businessId,
applicationId: context.applicationId,
brandName: business.brandName || context.brandName,
event: eventSlug,
eventLabel: normalizeText(tmpl.eventLabel) || titleCaseFromSlug(eventSlug),
matchedTemplateEvent: matchedSlug || eventSlug,
templateSlug: normalizeText(tmpl.eventSlug) || eventSlug,
templateId: normalizeText(tmpl.templateId),
curlProfileId: normalizeText(tmpl.curlProfileId),
providerName: normalizeText(boundProfile.provider?.providerName),
providerMessageId: extractProviderMessageId(sendResult.response),
template: tmpl.selectedTemplate,
content: resolvedTemplate,
toNumber: context.toNumber,
sendResult,
};
}
async function handleFyndWebhook(req, res) {
try {
console.log('[FyndWebhook] Incoming payload:', JSON.stringify(req.body, null, 2));
const context = buildResolveTemplateContext(req);
if (!context.shipment) {
return res.json({
success: true,
status: 'acknowledged',
action: 'noop',
reason: 'test_or_unsupported_payload',
});
}
const resolvedBusiness = context.applicationId
? await findBusinessByApplicationId(context.companyId, context.applicationId)
: null;
const analyticsExecution = await createWebhookAnalyticsExecution({
context,
business: resolvedBusiness,
payload: req.body,
});
if (context.missingFields.length > 0) {
if (analyticsExecution?.id) {
await runAnalyticsWrite('mark missing-field execution ignored', () => markExecutionIgnored({
id: analyticsExecution.id,
eventLabel: titleCaseFromSlug(slugify(context.event)),
ignoreReason: 'missing_required_fields',
failureStage: 'validation',
failureCode: 'MISSING_REQUIRED_FIELDS',
failureReason: 'Webhook payload is missing required business event fields.',
}));
await appendAnalyticsStatusHistory(analyticsExecution.id, {
statusType: 'webhook_ignored',
normalizedStatus: 'ignored',
errorCode: 'MISSING_REQUIRED_FIELDS',
errorMessage: 'Webhook payload is missing required business event fields.',
payload: {
reason: 'missing_required_fields',
missingFields: context.missingFields,
},
});
}
return res.json({
success: true,
status: 'ignored',
reason: 'missing_required_fields',
missingFields: context.missingFields,
});
}
try {
const result = await resolveTemplateRequest(context, resolvedBusiness);
if (analyticsExecution?.id) {
await runAnalyticsWrite('mark execution accepted', () => markExecutionAccepted({
id: analyticsExecution.id,
eventLabel: result.eventLabel,
matchedTemplateEvent: result.matchedTemplateEvent,
templateSlug: result.templateSlug,
templateId: result.templateId,
curlProfileId: result.curlProfileId,
providerName: result.providerName,
providerMessageId: result.providerMessageId,
providerResponse: result.sendResult?.response || null,
providerHttpStatus: result.sendResult?.statusCode,
sendAttemptedAt: new Date().toISOString(),
acceptedAt: new Date().toISOString(),
}));
await appendAnalyticsStatusHistory(analyticsExecution.id, {
statusType: 'send_accepted',
normalizedStatus: 'accepted',
providerName: result.providerName,
providerMessageId: result.providerMessageId,
providerStatusCode: String(result.sendResult?.statusCode || ''),
payload: {
transport: result.sendResult?.transport || 'curl',
providerHttpStatus: result.sendResult?.statusCode || null,
response: result.sendResult?.response || null,
},
});
}
return res.json({
success: true,
status: 'processed',
...result,
});
} catch (err) {
if (analyticsExecution?.id) {
if (err.status && [404, 409, 422].includes(err.status)) {
await runAnalyticsWrite('mark execution ignored', () => markExecutionIgnored({
id: analyticsExecution.id,
eventLabel: titleCaseFromSlug(slugify(context.event)),
ignoreReason: err.code || 'template_resolution_skipped',
failureStage: 'validation',
failureCode: err.code || 'TEMPLATE_RESOLUTION_SKIPPED',
failureReason: err.message,
}));
await appendAnalyticsStatusHistory(analyticsExecution.id, {
statusType: 'webhook_ignored',
normalizedStatus: 'ignored',
errorCode: err.code || 'TEMPLATE_RESOLUTION_SKIPPED',
errorMessage: err.message,
payload: {
reason: err.code || 'template_resolution_skipped',
details: err.details || null,
},
});
} else {
const providerHttpStatus = Number.isInteger(err?.details?.statusCode)
? err.details.statusCode
: null;
const providerResponse = err?.details?.response || err?.details || null;
await runAnalyticsWrite('mark execution failed', () => markExecutionFailed({
id: analyticsExecution.id,
eventLabel: titleCaseFromSlug(slugify(context.event)),
providerHttpStatus,
providerResponse,
failureStage: 'send',
failureCode: normalizeText(err.code) || 'SMS_SEND_FAILED',
failureReason: normalizeText(err.message) || 'SMS send failed',
sendAttemptedAt: new Date().toISOString(),
failedAt: new Date().toISOString(),
}));
await appendAnalyticsStatusHistory(analyticsExecution.id, {
statusType: 'send_failed',
normalizedStatus: 'send_failed',
providerStatusCode: providerHttpStatus ? String(providerHttpStatus) : '',
errorCode: normalizeText(err.code) || 'SMS_SEND_FAILED',
errorMessage: normalizeText(err.message) || 'SMS send failed',
payload: {
providerHttpStatus,
details: providerResponse,
},
});
}
}
if (err.status && [404, 409, 422].includes(err.status)) {
return res.json({
success: true,
status: 'ignored',
reason: err.code || 'template_resolution_skipped',
error: err.message,
details: err.details,
});
}
throw err;
}
} catch (err) {
sendRouteError(res, err);
}
}
function getAnalyticsEventStatus(event, template) {
if (template?.status === 'whitelisted') {
return template?.isRuntimeEnabled === false ? 'paused' : 'live';
}
if (normalizeText(template?.selectedTemplate)) {
return 'pending';
}
return event?.isDefault ? 'not_configured' : 'custom';
}
function getAnalyticsEventStatusLabel(status) {
switch (status) {
case 'live':
return 'Live';
case 'paused':
return 'Paused';
case 'pending':
return 'Pending';
case 'custom':
return 'Custom';
default:
return 'Not Configured';
}
}
async function loadBusinessTemplates(bizRoot) {
const templateFolder = `${bizRoot}/templates`;
const slugs = await listTemplateFiles(templateFolder).catch(() => []);
const templates = [];
for (const slug of slugs) {
const template = await fetchJSON(templateFolder, slug).catch(() => null);
if (template) templates.push(withTemplateDefaults(template));
}
return templates;
}
function hydrateProfile(profile = {}) {
const provider = normalizeProvider(profile.provider, profile.updatedAt);
const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, provider);
const rawCurlTemplate = getStoredCurlTemplate({
...profile,
provider,
curlAnalysis,
}) || curlAnalysis.normalizedCurlTemplate;
const profileInputValues = normalizeProfileInputValues({
...(profile.profileInputValues || {}),
...(provider.authKey ? { authKey: provider.authKey } : {}),
});
return {
...profile,
rawCurl: rawCurlTemplate,
rawCurlTemplate,
provider,
profileInputValues,
curlAnalysis: {
...curlAnalysis,
normalizedCurlTemplate: rawCurlTemplate,
},
};
}
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 requestedActiveProfileId = normalizeText(activeRec?.profileId);
const activeProfileId = profileData.profiles.some((profile) => profile.id === requestedActiveProfileId)
? requestedActiveProfileId
: null;
const activeProfile = activeProfileId
? profileData.profiles.find((profile) => profile.id === activeProfileId) || null
: 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 getProfileDeleteImpact(bizRoot, profileId) {
const templateFolder = `${bizRoot}/templates`;
const templateFiles = await listFilesWithId(templateFolder);
const impactedTemplates = [];
for (const file of templateFiles) {
const slug = normalizeText(String(file.name || '').replace(/\.json$/i, ''));
if (!slug) continue;
const template = await fetchJSON(templateFolder, slug).catch(() => null);
if (!template || normalizeText(template.curlProfileId) !== normalizeText(profileId)) continue;
impactedTemplates.push({
eventSlug: normalizeText(template.eventSlug) || slug,
eventLabel: normalizeText(template.eventLabel) || slug.replace(/_/g, ' '),
status: normalizeText(template.status) || 'generated',
templateId: normalizeText(template.templateId),
fileId: file.fileId,
fileName: file.name,
});
}
return impactedTemplates;
}
async function deleteTemplatesBoundToProfile(bizRoot, profileId) {
const impactedTemplates = await getProfileDeleteImpact(bizRoot, profileId);
await Promise.all(
impactedTemplates
.filter((template) => template.fileId)
.map((template) => deleteFile(template.fileId))
);
return impactedTemplates;
}
function buildStoredProfileFromValidation(baseProfile = {}, validation = {}) {
const now = new Date().toISOString();
const provider = normalizeProvider({
...baseProfile.provider,
...validation.provider,
updatedAt: now,
}, now);
const curlAnalysis = normalizeCurlAnalysis({
providerName: validation.provider?.providerName,
authMode: validation.authMode,
requiredInputs: validation.requiredInputs,
slotMap: validation.slotMap,
warnings: validation.warnings,
normalizedCurlTemplate: validation.normalizedCurlTemplate,
}, provider);
const profile = hydrateProfile({
...baseProfile,
rawCurl: validation.normalizedCurlTemplate,
rawCurlTemplate: validation.normalizedCurlTemplate,
provider,
profileInputValues: baseProfile.profileInputValues,
curlAnalysis,
updatedAt: now,
});
getProfileInputDefinitions(profile).forEach((input) => {
const currentValue = firstNonEmptyResolvedText(getStoredProfileValue(profile, input.key), input.currentValue);
if (currentValue) {
setStoredProfileValue(profile, input.key, currentValue);
}
});
if (provider.authKey) {
setStoredProfileValue(profile, 'authKey', provider.authKey);
}
profile.updatedAt = now;
profile.provider = normalizeProvider({
...profile.provider,
authKey: '',
}, now);
profile.profileInputValues = normalizeProfileInputValues(profile.profileInputValues);
profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile);
profile.rawCurl = getStoredCurlTemplate(profile);
profile.rawCurlTemplate = profile.rawCurl;
return profile;
}
function collectProfileInputPatch(payload = {}) {
const patch = {};
const providerPayload = payload?.provider && typeof payload.provider === 'object'
? payload.provider
: payload;
BASE_PROFILE_INPUT_KEYS.forEach((field) => {
if (!Object.prototype.hasOwnProperty.call(providerPayload, field)) return;
patch[field] = field === 'senderId'
? normalizeSenderId(providerPayload[field])
: normalizeScalarText(providerPayload[field]);
});
Object.entries(providerPayload).forEach(([key, value]) => {
const normalizedKey = normalizeInputKey(key);
if (!normalizedKey || BASE_PROFILE_INPUT_KEYS.includes(normalizedKey)) return;
patch[normalizedKey] = normalizeScalarText(value);
});
const profileInputValues = payload?.profileInputValues && typeof payload.profileInputValues === 'object'
? payload.profileInputValues
: {};
Object.entries(profileInputValues).forEach(([key, value]) => {
const normalizedKey = normalizeInputKey(key);
if (!normalizedKey) return;
patch[normalizedKey] = normalizeScalarText(value);
});
return patch;
}
function applyProfileInputPatch(profile, patch = {}) {
const inputDefinitions = new Map(
getProfileInputDefinitions(profile).map((input) => [input.key, input])
);
Object.entries(patch).forEach(([key, value]) => {
const inputDefinition = inputDefinitions.get(normalizeInputKey(key));
if (inputDefinition?.secret && !normalizeScalarText(value)) return;
setStoredProfileValue(profile, key, value);
});
profile.updatedAt = new Date().toISOString();
profile.provider = normalizeProvider({
...profile.provider,
authKey: '',
}, profile.updatedAt);
profile.profileInputValues = normalizeProfileInputValues(profile.profileInputValues);
profile.curlAnalysis = sanitizeStoredCurlAnalysis(profile);
return profile;
}
function validateProfileInputPatch(patch = {}) {
const senderIdError = validateSenderId(patch.senderId || '');
if (senderIdError) {
throw createHttpError(400, senderIdError);
}
}
function getMissingProfileInputKeys(profile = {}) {
return getExecutionReadiness(profile).missingProfileInputKeys;
}
async function validateCurlAndExtractProvider(rawCurl) {
try {
const validation = await validateCurlFields(rawCurl);
if (!validation.isValidCurl) {
throw createHttpError(422, validation.reason || 'The provided cURL is invalid');
}
const deterministicSenderId = extractDeterministicSenderIdFromCurl(rawCurl);
const provider = normalizeProvider({
...validation.provider,
...(deterministicSenderId ? { senderId: deterministicSenderId } : {}),
});
const resolvedSenderId = pickBestSenderIdCandidate(
deterministicSenderId,
provider.senderId,
validation.requiredInputs?.find((input) => input.key === 'senderId')?.currentValue
);
const senderIdError = validateSenderId(resolvedSenderId);
if (senderIdError) {
throw createHttpError(422, senderIdError);
}
return {
...validation,
provider,
};
} catch (err) {
if (err.status) throw err;
throw createHttpError(502, `cURL validation failed: ${err.message}`);
}
}
// ─── Business CRUD ────────────────────────────────────────────────────────────
// GET /api/businesses
router.get('/', async (req, res) => {
try {
const merchantId = getCompanyId(req);
const businesses = await getIndex(merchantId);
const hydratedBusinesses = await Promise.all(
businesses.map(async (business) => {
const hasPreviewSummary = normalizeText(business.previewTagline) || normalizeText(business.previewImagePath);
const hasRelevantImagePaths = Array.isArray(business.relevantImagePaths) && business.relevantImagePaths.length > 0;
if (hasPreviewSummary && hasRelevantImagePaths) {
return mergeBusinessSummary(business);
}
const context = await fetchJSON(businessRoot(merchantId, business.businessId), 'context').catch(() => null);
return mergeBusinessSummary(business, context);
})
);
res.json({ businesses: hydratedBusinesses });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/businesses — start async business onboarding from websiteUrl with optional applicationId
router.post('/', async (req, res) => {
try {
const merchantId = getCompanyId(req);
if (!merchantId) {
throw createHttpError(400, 'companyId is required');
}
const applicationId = normalizeScopeId(
req.body?.applicationId
|| req.body?.application_id
|| req.body?.salesChannelId
|| getApplicationId(req)
);
const websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl);
if (!websiteUrl) {
throw createHttpError(
400,
'websiteUrl is required',
{ code: 'MISSING_WEBSITE_URL' }
);
}
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 now = new Date().toISOString();
const job = await saveOnboardingJob({
jobId: uuidv4(),
companyId: merchantId,
applicationId,
websiteUrl,
status: 'queued',
stage: 'queued',
progress: {
pagesProcessed: 0,
pagesDiscovered: 0,
representativePages: 0,
imageCount: 0,
linkCount: 0,
},
crawlSummary: null,
brandContext: null,
business: null,
error: null,
createdAt: now,
updatedAt: now,
});
res.status(202).json(buildJobResponse(job));
} catch (err) {
console.error('Start business onboarding error:', err.message);
sendRouteError(res, err);
}
});
// GET /api/businesses/jobs/:jobId
router.get('/jobs/:jobId', async (req, res) => {
try {
const companyId = getCompanyId(req);
if (!companyId) {
throw createHttpError(400, 'companyId is required');
}
const job = await loadOnboardingJobWithRetry(companyId, req.params.jobId);
if (!job) {
throw createHttpError(404, 'Onboarding job not found');
}
const updatedJob = await advanceOnboardingJob(job);
res.json(buildJobResponse(updatedJob));
} catch (err) {
sendRouteError(res, err);
}
});
// GET /api/businesses/:businessId
router.get('/:businessId', async (req, res) => {
try {
const { businessId } = req.params;
const merchantId = getCompanyId(req);
const root = businessRoot(merchantId, businessId);
const [context, crawlSummary] = await Promise.all([
fetchJSON(root, 'context'),
fetchJSON(root, 'crawl_summary').catch(() => null),
]);
if (!context) return res.status(404).json({ error: 'Business not found' });
if (!crawlSummary) {
return res.json(context);
}
res.json({
...context,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, context.relevantImagePaths),
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/businesses/:businessId/analytics/overview
router.get('/:businessId/analytics/overview', async (req, res) => {
try {
const { businessId } = req.params;
const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId);
const [overviewMetrics, eventsData, templates] = await Promise.all([
getOverviewMetrics({ companyId, businessId }),
fetchJSON(bizRoot, 'events').catch(() => null),
loadBusinessTemplates(bizRoot),
]);
const mergedEvents = mergeDefaultEvents(eventsData || {});
const activeEventsCount = templates.filter((template) => (
template?.status === 'whitelisted' && template?.isRuntimeEnabled !== false
)).length;
res.json({
metrics: {
triggeredToday: overviewMetrics.triggeredToday,
totalTriggered: overviewMetrics.totalTriggered,
failedLast24Hours: overviewMetrics.failedLast24Hours,
deliveryRate: overviewMetrics.deliveryRate.rate,
deliveryRateMode: overviewMetrics.deliveryRate.mode,
activeEvents: activeEventsCount,
totalEvents: Array.isArray(mergedEvents.events) ? mergedEvents.events.length : 0,
},
chart: overviewMetrics.chart,
});
} catch (err) {
const status = /Analytics database is not configured/i.test(err.message) ? 503 : 500;
res.status(status).json({ error: err.message });
}
});
// GET /api/businesses/:businessId/analytics/events
router.get('/:businessId/analytics/events', async (req, res) => {
try {
const { businessId } = req.params;
const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId);
const [eventsData, templates, analyticsEventMetrics] = await Promise.all([
fetchJSON(bizRoot, 'events').catch(() => null),
loadBusinessTemplates(bizRoot),
getEventMetrics({ companyId, businessId }),
]);
const mergedEvents = mergeDefaultEvents(eventsData || {});
const templateBySlug = new Map(
templates.map((template) => [normalizeText(template?.eventSlug), template])
);
const analyticsBySlug = new Map(
analyticsEventMetrics.map((metric) => [normalizeText(metric.eventSlug), metric])
);
const rows = (mergedEvents.events || []).map((event) => {
const slug = normalizeText(event?.slug);
const template = templateBySlug.get(slug) || null;
const metric = analyticsBySlug.get(slug) || null;
const status = getAnalyticsEventStatus(event, template);
return {
eventSlug: slug,
eventLabel: normalizeText(event?.label) || titleCaseFromSlug(slug),
status,
statusLabel: getAnalyticsEventStatusLabel(status),
triggeredToday: metric?.triggeredToday || 0,
totalTriggerCount: metric?.totalTriggerCount || 0,
deliveryRate: metric?.deliveryRate?.rate ?? null,
deliveryRateMode: metric?.deliveryRate?.mode || 'no_data',
lastTriggeredAt: metric?.lastTriggeredAt || null,
actionPath: normalizeText(template?.selectedTemplate)
? `/${businessId}/templates?event=${encodeURIComponent(slug)}`
: `/${businessId}/events`,
};
}).sort((left, right) => {
const statusRank = {
live: 0,
paused: 1,
pending: 2,
custom: 3,
not_configured: 4,
};
const rankDiff = (statusRank[left.status] ?? 99) - (statusRank[right.status] ?? 99);
if (rankDiff !== 0) return rankDiff;
const triggerDiff = (right.totalTriggerCount || 0) - (left.totalTriggerCount || 0);
if (triggerDiff !== 0) return triggerDiff;
return left.eventLabel.localeCompare(right.eventLabel);
});
res.json({ events: rows });
} catch (err) {
const status = /Analytics database is not configured/i.test(err.message) ? 503 : 500;
res.status(status).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 context = buildResolveTemplateContext(req);
const missingError = getResolveTemplateMissingError(context);
if (missingError) throw missingError;
const result = await resolveTemplateRequest(context);
res.json({
success: true,
...result,
});
} 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(serializeProfile(activeProfile));
} 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 { 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);
const profilePatch = collectProfileInputPatch(req.body);
validateProfileInputPatch(profilePatch);
applyProfileInputPatch(profile, profilePatch);
await saveGlobalSmsProfiles(bizRoot, profileData);
res.json(serializeProfile(profile));
} 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 ? {
maskedCurl: buildDisplayCurl(activeProfile),
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 validation = await validateCurlAndExtractProvider(normalizedCurl);
// Find or create the default profile
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
if (defaultProfile) {
return res.status(409).json({
error: 'Accepted cURL profiles are immutable. Create a new profile instead of editing the stored one.',
code: 'IMMUTABLE_CURL_PROFILE',
});
} else {
defaultProfile = buildStoredProfileFromValidation({
id: uuidv4(),
name: 'Default',
isDefault: true,
createdAt: now,
updatedAt: now,
}, validation);
profileData.profiles.push(defaultProfile);
}
await saveGlobalSmsProfiles(bizRoot, profileData);
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now });
res.json(serializeProfile(defaultProfile));
} 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 || []).map((profile) => serializeProfile(profile));
res.json({ profiles, activeProfileId });
} catch (err) {
sendRouteError(res, err);
}
});
// GET /api/businesses/:businessId/global-sms/profiles/:profileId/reveal
router.get('/:businessId/global-sms/profiles/:profileId/reveal', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const { profileData } = await getProfileState(bizRoot);
const profile = profileData.profiles.find((item) => item.id === req.params.profileId);
if (!profile) return res.status(404).json({ error: 'Profile not found' });
res.json(getProfileRevealPayload(profile));
} catch (err) {
sendRouteError(res, err);
}
});
// GET /api/businesses/:businessId/global-sms/profiles/:profileId/delete-impact
router.get('/:businessId/global-sms/profiles/:profileId/delete-impact', async (req, res) => {
try {
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const { profileData, activeProfileId } = await getProfileState(bizRoot);
const profile = profileData.profiles.find((item) => item.id === req.params.profileId);
if (!profile) return res.status(404).json({ error: 'Profile not found' });
const impactedTemplates = await getProfileDeleteImpact(bizRoot, req.params.profileId);
res.json({
profile: {
id: profile.id,
name: profile.name,
isActive: activeProfileId === profile.id,
},
impactedTemplates: impactedTemplates.map((template) => ({
eventSlug: template.eventSlug,
eventLabel: template.eventLabel,
status: template.status,
templateId: template.templateId,
})),
});
} 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 validation = await validateCurlAndExtractProvider(normalizedCurl);
const newProfile = buildStoredProfileFromValidation({
id: uuidv4(),
name: normalizeText(name),
isDefault: false,
createdAt: now,
updatedAt: now,
}, validation);
profileData.profiles.push(newProfile);
await saveGlobalSmsProfiles(bizRoot, 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(serializeProfile(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 profilePatch = collectProfileInputPatch(req.body);
if (name !== undefined && !normalizeText(name)) {
return res.status(400).json({ error: 'name is required' });
}
if (rawCurl !== undefined) {
return res.status(409).json({
error: 'Accepted cURL profiles are immutable. Create a new profile instead of editing the stored one.',
code: 'IMMUTABLE_CURL_PROFILE',
});
}
validateProfileInputPatch(profilePatch);
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);
applyProfileInputPatch(profile, profilePatch);
await saveGlobalSmsProfiles(bizRoot, profileData);
res.json(serializeProfile(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, activeProfileId } = await getProfileState(bizRoot);
const idx = profileData.profiles.findIndex(p => p.id === profileId);
if (idx === -1) return res.status(404).json({ error: 'Profile not found' });
const deletedProfile = profileData.profiles[idx];
const impactedTemplates = await deleteTemplatesBoundToProfile(bizRoot, profileId);
profileData.profiles.splice(idx, 1);
await saveGlobalSmsProfiles(bizRoot, profileData);
if (activeProfileId === profileId) {
await clearActiveProfileSelection(bizRoot);
}
res.json({
ok: true,
deletedProfileId: deletedProfile.id,
deletedTemplateCount: impactedTemplates.length,
impactedTemplates: impactedTemplates.map((template) => ({
eventSlug: template.eventSlug,
eventLabel: template.eventLabel,
status: template.status,
templateId: template.templateId,
})),
});
} 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, profileData } = await getProfileState(bizRoot);
res.json({
activeProfile: activeProfile ? serializeProfile(activeProfile) : null,
activeProfileId,
hasProfiles: Array.isArray(profileData?.profiles) && profileData.profiles.length > 0,
});
} 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 templateFolder = `${bizRoot}/templates`;
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 existingTemplate = withTemplateDefaults(await fetchJSON(templateFolder, slug).catch(() => null));
const preservedSelectedTemplate = normalizeText(existingTemplate?.selectedTemplate);
const preservedStatus = normalizeText(existingTemplate?.status)
|| (preservedSelectedTemplate ? 'pending_whitelisting' : 'generated');
const variants = await generateTemplates(context, slug, event.label, {
senderId: activeProfile?.provider?.senderId,
});
const templateJson = {
eventSlug: slug,
eventLabel: event.label,
brandName: normalizeText(context?.brandName),
brandTaglines: Array.isArray(context?.taglines) ? context.taglines : [],
generatedVariants: variants,
selectedTemplate: preservedSelectedTemplate || null,
status: preservedStatus,
templateId: normalizeText(existingTemplate?.templateId),
curlProfileId: normalizeText(existingTemplate?.curlProfileId) || activeProfile.id,
rawCurl: existingTemplate?.rawCurl || '',
processedCurl: existingTemplate?.processedCurl || '',
processedCurlTemplate: existingTemplate?.processedCurlTemplate || existingTemplate?.processedCurl || '',
variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object'
? existingTemplate.variableMap
: {},
requiredInputs: Array.isArray(existingTemplate?.requiredInputs) ? existingTemplate.requiredInputs : [],
slotMap: existingTemplate?.slotMap && typeof existingTemplate.slotMap === 'object'
? existingTemplate.slotMap
: {},
executionMeta: existingTemplate?.executionMeta && typeof existingTemplate.executionMeta === 'object'
? existingTemplate.executionMeta
: {},
isRuntimeEnabled: isTemplateRuntimeEnabled(existingTemplate),
selectedImagePath: existingTemplate?.selectedImagePath || '',
updatedAt: new Date().toISOString(),
};
await uploadJSON(templateFolder, 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(withTemplateDefaults(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 = withTemplateDefaults(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 });
}
});
// PATCH /api/businesses/:businessId/templates/:slug/runtime
router.patch('/:businessId/templates/:slug/runtime', async (req, res) => {
try {
const { businessId, slug } = req.params;
const nextRuntimeState = req.body?.isRuntimeEnabled;
if (typeof nextRuntimeState !== 'boolean') {
return res.status(400).json({ error: 'isRuntimeEnabled must be a boolean' });
}
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
const tmpl = withTemplateDefaults(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: 'Only published templates can change runtime state' });
}
tmpl.isRuntimeEnabled = nextRuntimeState;
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json(withTemplateDefaults(tmpl));
} catch (err) {
sendRouteError(res, err);
}
});
// 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 = withTemplateDefaults(await fetchJSON(folder, slug));
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
const boundProfile = tmpl.curlProfileId
? await getBoundProfile(businessRoot(getCompanyId(req), businessId), tmpl.curlProfileId).catch(() => null)
: null;
const validation = await validateEditedTemplate(editedTemplate, {
senderId: boundProfile?.provider?.senderId,
eventSlug: slug,
eventLabel: tmpl.eventLabel,
brandName: tmpl.brandName || '',
brandTaglines: Array.isArray(tmpl.brandTaglines) ? tmpl.brandTaglines : [],
});
res.json(validation);
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/templates/:slug/discard
router.post('/:businessId/templates/:slug/discard', async (req, res) => {
try {
const { businessId, slug } = req.params;
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
const tmpl = withTemplateDefaults(await fetchJSON(folder, slug));
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
tmpl.generatedVariants = [];
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json({ ok: true, template: withTemplateDefaults(tmpl) });
} 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 = withTemplateDefaults(await fetchJSON(folder, slug));
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
const activeProfile = await getActiveProfile(bizRoot);
const activeCurl = getStoredCurlTemplate(activeProfile) || null;
if (!activeProfile?.id || !activeCurl) {
return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' });
}
const { processedCurlTemplate, variableMap, requiredInputs, slotMap, executionMeta } = await processCurl(
activeCurl,
selectedVariant,
slug,
{
normalizedCurlTemplate: getStoredCurlTemplate(activeProfile),
requiredInputs: activeProfile?.curlAnalysis?.requiredInputs,
slotMap: activeProfile?.curlAnalysis?.slotMap,
},
);
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 = processedCurlTemplate;
tmpl.processedCurlTemplate = processedCurlTemplate;
tmpl.variableMap = variableMap;
tmpl.requiredInputs = Array.isArray(requiredInputs) ? requiredInputs : [];
tmpl.slotMap = slotMap && typeof slotMap === 'object' ? slotMap : {};
tmpl.executionMeta = executionMeta && typeof executionMeta === 'object' ? executionMeta : {};
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json(withTemplateDefaults(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 = withTemplateDefaults(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.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json(withTemplateDefaults(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 executes the bound real cURL.
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 = withTemplateDefaults(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 = getMissingProfileInputKeys(boundProfile);
if (missingFields.length > 0) {
return res.status(422).json({
error: 'Missing mandatory profile fields',
missingFields,
code: 'MISSING_BOUND_PROFILE_FIELDS',
});
}
const senderIdError = validateSenderId(boundProfile.provider.senderId);
if (senderIdError) {
return res.status(400).json({ error: senderIdError });
}
const deterministicRender = renderTemplateWithDeterministicSample(tmpl);
const publishedTemplate = withTemplateDefaults({
...tmpl,
templateId: normalizeText(templateId),
status: 'whitelisted',
isRuntimeEnabled: isTemplateRuntimeEnabled(tmpl),
updatedAt: new Date().toISOString(),
});
let sendResult;
try {
sendResult = await sendTemplateViaCurl({
boundProfile,
template: publishedTemplate,
runtimeValues: {
content: deterministicRender.content,
toNumber: normalizeText(toNumber),
templateId: publishedTemplate.templateId,
senderId: boundProfile.provider?.senderId,
dltEntityId: boundProfile.provider?.dltEntityId,
},
});
} catch (sendErr) {
return res.status(sendErr.status || 502).json({
error: 'Template send test failed; template not published',
code: sendErr.code,
details: sendErr.details || sendErr.message,
renderedContent: deterministicRender.content,
renderState: deterministicRender.renderState,
template: withTemplateDefaults(tmpl),
});
}
if (!sendResult.success) {
return res.status(502).json({
error: 'Template send test failed; template not published',
code: 'CURL_PROVIDER_ERROR',
details: {
statusCode: sendResult.statusCode,
response: sendResult.response,
},
renderedContent: deterministicRender.content,
renderState: deterministicRender.renderState,
template: withTemplateDefaults(tmpl),
});
}
await uploadJSON(folder, slug, publishedTemplate);
res.json({
success: true,
renderedContent: deterministicRender.content,
renderState: deterministicRender.renderState,
template: publishedTemplate,
transport: sendResult.transport,
sendResult,
});
} catch (err) {
console.error('Publish error:', err.message);
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/templates/:slug/test
// For Published (whitelisted) templates: executes the bound real cURL.
// 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 = withTemplateDefaults(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' });
}
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
const missingFields = getMissingProfileInputKeys(boundProfile);
if (missingFields.length > 0 || !boundProfile.provider?.senderId) {
return res.status(422).json({
error: 'Missing mandatory profile fields',
missingFields: [...new Set([...missingFields, 'senderId'])],
code: 'MISSING_BOUND_PROFILE_FIELDS',
});
}
const senderIdError = validateSenderId(boundProfile.provider.senderId);
if (senderIdError) {
return res.status(400).json({ error: senderIdError });
}
const deterministicRender = renderTemplateWithDeterministicSample(tmpl);
let smsResult;
try {
smsResult = await sendTemplateViaCurl({
boundProfile,
template: tmpl,
runtimeValues: {
content: deterministicRender.content,
toNumber: normalizeText(toNumber),
templateId: tmpl.templateId,
senderId: boundProfile.provider?.senderId,
dltEntityId: boundProfile.provider?.dltEntityId,
},
});
} catch (sendErr) {
return res.status(sendErr.status || 502).json({
error: 'SMS send failed',
code: sendErr.code,
details: sendErr.details || sendErr.message,
});
}
if (!smsResult.success) {
return res.status(502).json({
error: 'SMS send failed',
code: 'CURL_PROVIDER_ERROR',
details: {
statusCode: smsResult.statusCode,
response: smsResult.response,
},
renderedContent: deterministicRender.content,
renderState: deterministicRender.renderState,
});
}
res.json({
success: true,
statusCode: smsResult.statusCode,
response: smsResult.response,
transport: smsResult.transport,
renderedContent: deterministicRender.content,
renderState: deterministicRender.renderState,
});
} catch (err) {
sendRouteError(res, err);
}
});
module.exports = router;
module.exports.handleFyndWebhook = handleFyndWebhook;