3798 lines
129 KiB
JavaScript
3798 lines
129 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'];
|
|
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
|
|
|
|
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 buildAutomaticProfileName(source = {}) {
|
|
const senderId = normalizeSenderId(
|
|
source?.provider?.senderId
|
|
|| source?.senderId
|
|
);
|
|
|
|
return senderId || PENDING_SENDER_ID_PROFILE_NAME;
|
|
}
|
|
|
|
function syncAutomaticProfileName(profile = {}) {
|
|
if (profile?.isAutoNamed !== true) return profile;
|
|
|
|
profile.name = buildAutomaticProfileName(profile);
|
|
profile.isAutoNamed = true;
|
|
return profile;
|
|
}
|
|
|
|
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 = syncAutomaticProfileName(hydrateProfile(profile));
|
|
return {
|
|
...hydratedProfile,
|
|
isAutoNamed: hydratedProfile.isAutoNamed === true,
|
|
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 = syncAutomaticProfileName(hydrateProfile(profile));
|
|
const normalizedAuthKey = firstNonEmptyResolvedText(
|
|
hydratedProfile.profileInputValues?.authKey,
|
|
hydratedProfile.provider?.authKey,
|
|
);
|
|
|
|
return {
|
|
id: hydratedProfile.id,
|
|
name: normalizeText(hydratedProfile.name),
|
|
isAutoNamed: hydratedProfile.isAutoNamed === true,
|
|
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;
|
|
});
|
|
}
|
|
|
|
const LOGO_URL_POSITIVE_REGEX = /(?:^|[\/_.-])(logo|wordmark|brandmark|site-logo|header-logo)(?:[\/_.-]|$)/i;
|
|
const LOGO_URL_ICON_REGEX = /(?:^|[\/_.-])(favicon|apple-touch-icon|mask-icon|mstile|icon)(?:[\/_.-]|$)/i;
|
|
const LOGO_URL_NEGATIVE_REGEX = /(hero|banner|product|products|collection|collections|slide|carousel|thumbnail|thumb|promo|cover|background)/i;
|
|
const LOGO_URL_SOCIAL_REGEX = /(facebook|instagram|twitter|linkedin|youtube|pinterest|avatar|profile)/i;
|
|
|
|
function isAbsoluteHttpUrl(value) {
|
|
const normalized = normalizeText(value);
|
|
if (!normalized) return false;
|
|
|
|
try {
|
|
const url = new URL(normalized);
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function scoreLogoCandidateUrl(url, sources = new Set()) {
|
|
const normalized = normalizeText(url).toLowerCase();
|
|
if (!isAbsoluteHttpUrl(normalized)) return -100;
|
|
|
|
let score = 0;
|
|
|
|
if (sources.has('branding')) score += 70;
|
|
if (sources.has('model')) score += 45;
|
|
if (sources.has('relevant') && (LOGO_URL_POSITIVE_REGEX.test(normalized) || LOGO_URL_ICON_REGEX.test(normalized))) score += 12;
|
|
if (sources.has('crawl') && (LOGO_URL_POSITIVE_REGEX.test(normalized) || LOGO_URL_ICON_REGEX.test(normalized))) score += 6;
|
|
|
|
if (LOGO_URL_POSITIVE_REGEX.test(normalized)) score += 30;
|
|
if (LOGO_URL_ICON_REGEX.test(normalized)) score += 18;
|
|
if (normalized.endsWith('.svg') || normalized.includes('.svg?')) score += 12;
|
|
if (normalized.endsWith('.png') || normalized.includes('.png?')) score += 6;
|
|
if (normalized.endsWith('.webp') || normalized.includes('.webp?')) score += 3;
|
|
|
|
if (LOGO_URL_NEGATIVE_REGEX.test(normalized)) score -= 35;
|
|
if (LOGO_URL_SOCIAL_REGEX.test(normalized)) score -= 25;
|
|
if (/\/products?\//i.test(normalized) || /\/collections?\//i.test(normalized)) score -= 20;
|
|
|
|
return score;
|
|
}
|
|
|
|
function collectLogoCandidates(crawlSummary = {}, brandContext = {}) {
|
|
const candidatesByUrl = new Map();
|
|
let order = 0;
|
|
|
|
function append(values, source) {
|
|
normalizeUrlList(values).forEach((url) => {
|
|
if (!isAbsoluteHttpUrl(url)) return;
|
|
|
|
if (!candidatesByUrl.has(url)) {
|
|
candidatesByUrl.set(url, {
|
|
url,
|
|
sources: new Set(),
|
|
order,
|
|
});
|
|
order += 1;
|
|
}
|
|
|
|
candidatesByUrl.get(url).sources.add(source);
|
|
});
|
|
}
|
|
|
|
append([brandContext?.logoUrl], 'model');
|
|
append([crawlSummary?.branding?.primaryLogoUrl], 'branding');
|
|
append(crawlSummary?.branding?.logos, 'branding');
|
|
append(crawlSummary?.branding?.logoCandidates, 'branding');
|
|
append(brandContext?.relevantImageUrls, 'relevant');
|
|
append(crawlSummary?.topImages, 'crawl');
|
|
|
|
return Array.from(candidatesByUrl.values())
|
|
.map((candidate) => ({
|
|
...candidate,
|
|
score: scoreLogoCandidateUrl(candidate.url, candidate.sources),
|
|
}))
|
|
.sort((left, right) => right.score - left.score || left.order - right.order);
|
|
}
|
|
|
|
function selectCanonicalLogoSourceUrl(crawlSummary = {}, brandContext = {}) {
|
|
const bestCandidate = collectLogoCandidates(crawlSummary, brandContext)[0];
|
|
if (!bestCandidate || bestCandidate.score <= 0) return '';
|
|
|
|
if (
|
|
bestCandidate.sources.has('model')
|
|
|| bestCandidate.sources.has('branding')
|
|
|| LOGO_URL_POSITIVE_REGEX.test(bestCandidate.url)
|
|
|| LOGO_URL_ICON_REGEX.test(bestCandidate.url)
|
|
) {
|
|
return bestCandidate.url;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getBusinessPreviewSummary(source = {}) {
|
|
const taglines = Array.isArray(source?.taglines)
|
|
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
|
: [];
|
|
const logoUrl = normalizeText(source?.logoUrl);
|
|
const relevantImagePaths = Array.isArray(source?.relevantImagePaths)
|
|
? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean)
|
|
: [];
|
|
|
|
return {
|
|
previewTagline: taglines[0] || '',
|
|
previewImagePath: logoUrl || relevantImagePaths[0] || '',
|
|
};
|
|
}
|
|
|
|
function mergeBusinessSummary(baseBusiness = {}, context = null, crawlSummary = null) {
|
|
const logoUrl = firstNonEmptyText(
|
|
context?.logoUrl,
|
|
baseBusiness?.logoUrl,
|
|
selectCanonicalLogoSourceUrl(crawlSummary, context || baseBusiness)
|
|
);
|
|
const previewSummary = getBusinessPreviewSummary({
|
|
...(context || baseBusiness),
|
|
logoUrl,
|
|
});
|
|
const relevantImagePaths = normalizeUrlList(
|
|
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
|
|
? baseBusiness.relevantImagePaths
|
|
: context?.relevantImagePaths
|
|
);
|
|
|
|
return {
|
|
...baseBusiness,
|
|
logoUrl,
|
|
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
|
|
previewImagePath: logoUrl || 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, existingContext, crawlSummary),
|
|
}
|
|
: mergeBusinessSummary(existingBusiness, null, crawlSummary);
|
|
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 uploadedImageBySourceUrl = new Map();
|
|
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);
|
|
uploadedImageBySourceUrl.set(imageCandidates[i], uploaded);
|
|
}
|
|
}
|
|
|
|
const selectedLogoSourceUrl = selectCanonicalLogoSourceUrl(crawlSummary, brandContext);
|
|
const logoUploadUrl = selectedLogoSourceUrl
|
|
? (
|
|
uploadedImageBySourceUrl.get(selectedLogoSourceUrl)
|
|
|| await uploadImageFromUrl(selectedLogoSourceUrl, imagesFolder, 'logo')
|
|
)
|
|
: '';
|
|
const logoUrl = normalizeText(logoUploadUrl || selectedLogoSourceUrl);
|
|
const relevantImagePaths = normalizeUrlList(
|
|
logoUrl ? [logoUrl, ...imagePaths] : imagePaths
|
|
);
|
|
|
|
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 : [],
|
|
logoUrl,
|
|
relevantImagePaths,
|
|
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,
|
|
logoUrl: contextJson.logoUrl,
|
|
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, relevantImagePaths),
|
|
},
|
|
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.isAutoNamed = baseProfile.isAutoNamed === true
|
|
|| (!Object.prototype.hasOwnProperty.call(baseProfile, 'isAutoNamed') && !normalizeText(baseProfile.name));
|
|
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 syncAutomaticProfileName(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 syncAutomaticProfileName(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);
|
|
}
|
|
|
|
const normalizedProvider = normalizeProvider({
|
|
...provider,
|
|
...(resolvedSenderId ? { senderId: resolvedSenderId } : {}),
|
|
});
|
|
|
|
return {
|
|
...validation,
|
|
provider: normalizedProvider,
|
|
};
|
|
} 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)
|
|
|| normalizeText(business.logoUrl);
|
|
const hasRelevantImagePaths = Array.isArray(business.relevantImagePaths) && business.relevantImagePaths.length > 0;
|
|
const hasLogoUrl = normalizeText(business.logoUrl);
|
|
|
|
if (hasPreviewSummary && (hasRelevantImagePaths || hasLogoUrl)) {
|
|
return mergeBusinessSummary(business);
|
|
}
|
|
|
|
const root = businessRoot(merchantId, business.businessId);
|
|
const [context, crawlSummary] = await Promise.all([
|
|
fetchJSON(root, 'context').catch(() => null),
|
|
fetchJSON(root, 'crawl_summary').catch(() => null),
|
|
]);
|
|
|
|
return mergeBusinessSummary(business, context, crawlSummary);
|
|
})
|
|
);
|
|
|
|
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(mergeBusinessSummary(context));
|
|
}
|
|
|
|
const business = mergeBusinessSummary(context, null, crawlSummary);
|
|
res.json({
|
|
...business,
|
|
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, business.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(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 normalizedRequestedName = normalizeText(name);
|
|
const validation = await validateCurlAndExtractProvider(normalizedCurl);
|
|
|
|
const newProfile = buildStoredProfileFromValidation({
|
|
id: uuidv4(),
|
|
...(normalizedRequestedName ? { name: normalizedRequestedName } : {}),
|
|
isAutoNamed: !normalizedRequestedName,
|
|
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);
|
|
profile.isAutoNamed = false;
|
|
}
|
|
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;
|