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 { buildPatchedCurlTemplateFromRequest, buildRequestBlueprintFromCurl, executeTemplatedCurl, normalizeHeaderEntries, 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 SENSITIVE_HEADER_KEYS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'api-key', 'x-auth-key']); 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 compactPlaceholderText(value) { return normalizeText(String(value || '')) .toLowerCase() .replace(/[^a-z0-9]+/g, ''); } const GENERIC_SECRET_PLACEHOLDER_COMPACTS = new Set([ 'password', 'passwd', 'passcode', 'secret', 'token', 'authtoken', 'accesstoken', 'bearertoken', 'apikey', 'authkey', 'clientsecret', 'credential', 'credentials', ]); const SECRET_PLACEHOLDER_PREFIXES = [ 'your', 'enter', 'insert', 'provide', 'replace', 'replacewith', 'set', 'use', 'sample', 'dummy', 'test', 'example', ]; const SECRET_PLACEHOLDER_SUFFIXES = ['here', 'value', 'placeholder']; function buildSecretPlaceholderCandidates(input = {}) { const candidates = new Set(GENERIC_SECRET_PLACEHOLDER_COMPACTS); const normalizedKey = compactPlaceholderText(input.key); const normalizedLabel = compactPlaceholderText(input.label); if (normalizedKey) candidates.add(normalizedKey); if (normalizedLabel) candidates.add(normalizedLabel); return candidates; } function matchesSecretPlaceholderCandidate(compactValue, input = {}) { if (!compactValue) return false; const candidates = buildSecretPlaceholderCandidates(input); if (candidates.has(compactValue)) return true; for (const candidate of candidates) { if (!candidate) continue; if (compactValue === `${candidate}here` || compactValue === `${candidate}value` || compactValue === `${candidate}placeholder`) { return true; } for (const prefix of SECRET_PLACEHOLDER_PREFIXES) { if (compactValue === `${prefix}${candidate}`) return true; for (const suffix of SECRET_PLACEHOLDER_SUFFIXES) { if (compactValue === `${prefix}${candidate}${suffix}`) return true; } } } return false; } function looksLikeWrappedPlaceholderInner(value = '') { const normalized = normalizeText(value); if (!normalized) return false; return /[_\-\s]/.test(normalized) || /^[A-Z0-9_:-]+$/.test(normalized); } function matchesWrappedSecretPlaceholderCandidate(value = '', input = {}) { const compactValue = compactPlaceholderText(value); if (!compactValue) return false; if (matchesSecretPlaceholderCandidate(compactValue, input)) return true; if (!looksLikeWrappedPlaceholderInner(value)) return false; const candidates = buildSecretPlaceholderCandidates(input); for (const candidate of candidates) { if (!candidate) continue; if (compactValue.startsWith(candidate) || compactValue.endsWith(candidate)) return true; } return false; } function isPlaceholderLikeSecretValue(value, input = {}) { const normalized = normalizeResolvedScalarText(value); if (!normalized) return false; const compactValue = compactPlaceholderText(normalized); if (matchesSecretPlaceholderCandidate(compactValue, input)) return true; const wrappedMatch = normalized.match(/^(?:<\s*([^>]+)\s*>|\{\{\s*([^}]+)\s*\}\}|\[\s*([^\]]+)\s*\])$/); if (!wrappedMatch) return false; const innerValue = wrappedMatch[1] || wrappedMatch[2] || wrappedMatch[3] || ''; return matchesWrappedSecretPlaceholderCandidate(innerValue, input); } function normalizeResolvedInputValue(value, input = {}) { const normalized = normalizeResolvedScalarText(value); if (!normalized) return ''; if (input?.secret === true && isPlaceholderLikeSecretValue(normalized, input)) return ''; return normalized; } function firstNonEmptyInputValue(input = {}, ...values) { for (const value of values) { const normalized = normalizeResolvedInputValue(value, input); 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: normalizeResolvedInputValue(input.currentValue || input.value, { key, label: normalizeText(input.label) || humanizeInputKey(key), secret: input.secret === true, }), }; } 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 firstNonEmptyInputValue( { key: 'authKey', label: 'Auth Key', secret: true }, 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: firstNonEmptyInputValue({ ...current, ...normalized, key: normalized.key, label: normalized.label || current?.label || humanizeInputKey(normalized.key), secret: normalized.secret === true || current?.secret === true, }, 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 = firstNonEmptyInputValue(input, 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) => !firstNonEmptyInputValue(input, 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 = firstNonEmptyInputValue(input, getStoredProfileValue(profile, input.key), input.currentValue); if (!value) return; output = output.split(token).join(input.secret && !revealSecrets ? MASKED_SECRET : value); }); return output; } function applyProfileInputsToRequestText(value = '', profile = {}, options = {}) { let output = String(value ?? ''); const revealSecrets = options.revealSecrets === true; getProfileInputDefinitions(profile).forEach((input) => { const token = normalizeText(input.token); const currentValue = firstNonEmptyInputValue(input, getStoredProfileValue(profile, input.key), input.currentValue); if (!currentValue) { if (token) { output = output.split(token).join(''); } return; } const replacement = input.secret && !revealSecrets ? MASKED_SECRET : currentValue; if (token) { output = output.split(token).join(replacement); } if (input.secret && !revealSecrets) { output = output.split(currentValue).join(MASKED_SECRET); } }); return output; } function applyProfileTokensToRequestText(value = '', profile = {}) { let output = normalizeScalarText(value); getProfileInputDefinitions(profile).forEach((input) => { const token = normalizeText(input.token); const currentValue = firstNonEmptyInputValue(input, getStoredProfileValue(profile, input.key), input.currentValue); if (!token || !currentValue || token === currentValue) return; output = output.split(currentValue).join(token); }); return output; } function isSensitiveRequestText(value = '', profile = {}) { const source = String(value ?? ''); if (!source) return false; return getProfileInputDefinitions(profile) .filter((input) => input.secret === true) .some((input) => { const token = normalizeText(input.token); const currentValue = firstNonEmptyInputValue(input, getStoredProfileValue(profile, input.key), input.currentValue); return (token && source.includes(token)) || (currentValue && source.includes(currentValue)); }); } function isSensitiveHeaderEntry(entry = {}, profile = {}) { const normalizedKey = normalizeText(entry.key).toLowerCase(); return SENSITIVE_HEADER_KEYS.has(normalizedKey) || isSensitiveRequestText(entry.key, profile) || isSensitiveRequestText(entry.value, profile); } function hasStoredSecretValueForHeaderEntry(entry = {}, profile = {}) { const keyText = String(entry.key ?? ''); const valueText = String(entry.value ?? ''); return getProfileInputDefinitions(profile) .filter((input) => input.secret === true) .some((input) => { const token = normalizeText(input.token); const currentValue = firstNonEmptyInputValue(input, getStoredProfileValue(profile, input.key), input.currentValue); if (!currentValue) return false; return (token && (keyText.includes(token) || valueText.includes(token))) || keyText.includes(currentValue) || valueText.includes(currentValue); }); } function maskSensitiveHeaderPreviewValue(entry = {}, resolvedValue = '', maskedCandidate = '') { const normalizedKey = normalizeText(entry.key).toLowerCase(); const baseValue = String(maskedCandidate || resolvedValue || ''); if (!baseValue) return ''; if (normalizedKey === 'authorization' || normalizedKey === 'proxy-authorization') { const schemeMatch = String(resolvedValue || baseValue).match(/^(\S+\s+)/); return schemeMatch ? `${schemeMatch[1]}${MASKED_SECRET}` : MASKED_SECRET; } if (SENSITIVE_HEADER_KEYS.has(normalizedKey)) { return MASKED_SECRET; } return maskedCandidate && maskedCandidate !== resolvedValue ? maskedCandidate : MASKED_SECRET; } function serializeRequestPreview(profile = {}, options = {}) { const hydratedProfile = hydrateProfile(profile); const rawCurlTemplate = getStoredCurlTemplate(hydratedProfile); if (!rawCurlTemplate) return null; let requestBlueprint = null; try { requestBlueprint = buildRequestBlueprintFromCurl(rawCurlTemplate); } catch { return null; } const revealSecrets = options.revealSecrets === true; const resolvedUrl = applyProfileInputsToRequestText(requestBlueprint.url, hydratedProfile, { revealSecrets: true }); const maskedUrl = applyProfileInputsToRequestText(requestBlueprint.url, hydratedProfile, { revealSecrets: false }); return { method: normalizeText(requestBlueprint.method).toUpperCase() || 'POST', url: revealSecrets ? resolvedUrl : maskedUrl, maskedUrl, urlMasked: !revealSecrets && maskedUrl !== resolvedUrl, headers: normalizeHeaderEntries(requestBlueprint.headerEntries).map((entry, index) => { const resolvedKey = applyProfileInputsToRequestText(entry.key, hydratedProfile, { revealSecrets: true }); const resolvedValue = applyProfileInputsToRequestText(entry.value, hydratedProfile, { revealSecrets: true }); const maskedCandidate = applyProfileInputsToRequestText(entry.value, hydratedProfile, { revealSecrets: false }); const secret = isSensitiveHeaderEntry(entry, hydratedProfile); const maskedValue = secret && hasStoredSecretValueForHeaderEntry(entry, hydratedProfile) ? maskSensitiveHeaderPreviewValue({ ...entry, key: resolvedKey }, resolvedValue, maskedCandidate) : maskedCandidate; return { id: normalizeText(entry.id) || `header-${index}`, key: resolvedKey, value: revealSecrets ? resolvedValue : maskedValue, maskedValue, masked: maskedValue !== resolvedValue, secret, enabled: entry.enabled !== false, }; }), }; } function collectRequestPatch(payload = {}) { const request = payload?.request; if (!request || typeof request !== 'object' || Array.isArray(request)) return null; const patch = {}; if (Object.prototype.hasOwnProperty.call(request, 'url')) { patch.url = normalizeScalarText(request.url); } if (Array.isArray(request.headers)) { patch.headers = request.headers.reduce((items, entry, index) => { const id = normalizeText(entry?.id) || `header-${index}`; const key = normalizeScalarText(entry?.key); const value = normalizeScalarText(entry?.value); const enabled = entry?.enabled !== false; if (!key && !value) return items; items.push({ id, key, value, enabled, }); return items; }, []); } return Object.keys(patch).length > 0 ? patch : null; } function validateRequestPatch(requestPatch = {}) { if (Object.prototype.hasOwnProperty.call(requestPatch, 'url')) { if (!isAbsoluteHttpUrl(requestPatch.url)) { throw createHttpError(400, 'Request URL must be a valid absolute http(s) URL'); } } if (Array.isArray(requestPatch.headers)) { requestPatch.headers.forEach((entry) => { if (!normalizeText(entry.key)) { throw createHttpError(400, 'Request headers must include a key'); } }); } } async function applyRequestPatch(profile, requestPatch = {}) { const currentRawCurl = getStoredCurlTemplate(profile); if (!currentRawCurl) { throw createHttpError(400, 'Profile does not have an editable request'); } let currentRequest = null; try { currentRequest = buildRequestBlueprintFromCurl(currentRawCurl); } catch { throw createHttpError(400, 'Profile does not have an editable request'); } const existingHeaderEntries = normalizeHeaderEntries(currentRequest.headerEntries); const existingHeadersById = new Map(existingHeaderEntries.map((entry) => [normalizeText(entry.id), entry])); const nextUrl = Object.prototype.hasOwnProperty.call(requestPatch, 'url') ? normalizeScalarText(requestPatch.url) : normalizeText(currentRequest.url); const safeUrl = nextUrl.includes(MASKED_SECRET) && isSensitiveRequestText(currentRequest.url, profile) ? normalizeText(currentRequest.url) : nextUrl; const nextHeaderEntries = (Array.isArray(requestPatch.headers) ? requestPatch.headers : existingHeaderEntries).reduce((items, entry, index) => { const fallbackEntry = existingHeaderEntries[index] || {}; const id = normalizeText(entry?.id) || normalizeText(fallbackEntry.id) || `header-${index}`; const existingEntry = existingHeadersById.get(id) || fallbackEntry; const key = normalizeScalarText(entry?.key); const rawValue = normalizeScalarText(entry?.value); const value = rawValue.includes(MASKED_SECRET) && isSensitiveHeaderEntry(existingEntry, profile) ? normalizeScalarText(existingEntry.value) : rawValue; if (!key && !value) return items; items.push({ id, key, value, enabled: entry?.enabled !== false, }); return items; }, []); const tokenizedUrl = applyProfileTokensToRequestText(safeUrl, profile); const tokenizedHeaderEntries = nextHeaderEntries.map((entry) => ({ ...entry, key: applyProfileTokensToRequestText(entry.key, profile), value: applyProfileTokensToRequestText(entry.value, profile), })); const patchedCurlTemplate = buildPatchedCurlTemplateFromRequest(currentRawCurl, { url: tokenizedUrl, headers: tokenizedHeaderEntries, }); const validation = await validateCurlAndExtractProvider(patchedCurlTemplate); return buildStoredProfileFromValidation(profile, validation); } 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)), requestPreview: serializeRequestPreview(hydratedProfile), 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 })), requestPreview: serializeRequestPreview(hydratedProfile, { 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 = firstNonEmptyInputValue( { key: 'authKey', label: 'Auth Key', secret: true }, 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: 'aarav.sharma@example.com', }, delivery_address: { name: 'Aarav Sharma', phone: '919876543210', email: 'aarav.sharma@example.com', city: 'Bengaluru', pincode: '560001', }, billing_address: { name: 'Aarav Sharma', phone: '919876543210', email: 'aarav.sharma@example.com', }, 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 = firstNonEmptyInputValue(input, 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 = firstNonEmptyInputValue(input, 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'; } } const ANALYTICS_STATUS_SCOPES = new Set([ 'all', 'live', 'paused', 'pending', 'custom', 'not_configured', ]); function normalizeAnalyticsStatusScope(value) { const normalizedValue = normalizeText(value).toLowerCase(); return ANALYTICS_STATUS_SCOPES.has(normalizedValue) ? normalizedValue : 'all'; } function parseAnalyticsEventSlugs(value) { const rawValues = Array.isArray(value) ? value : [value]; return [...new Set( rawValues .flatMap((entry) => String(entry || '').split(',')) .map((entry) => slugify(entry)) .filter(Boolean) )]; } function parsePaginationInteger(value, fallback, { min = 1, max = 100 } = {}) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function sortAnalyticsEventRows(rows = []) { return [...rows].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); }); } function filterAnalyticsEventRows(rows = [], filters = {}) { const statusScope = normalizeAnalyticsStatusScope(filters.statusScope); const selectedEventSlugs = new Set(parseAnalyticsEventSlugs(filters.eventSlugs)); return rows.filter((row) => { if (statusScope !== 'all' && row.status !== statusScope) { return false; } if (selectedEventSlugs.size > 0 && !selectedEventSlugs.has(normalizeText(row.eventSlug))) { return false; } return true; }); } function paginateAnalyticsRows(rows = [], page, pageSize) { const safePageSize = parsePaginationInteger(pageSize, 5, { min: 1, max: 100 }); const totalItems = rows.length; const totalPages = Math.max(1, Math.ceil(totalItems / safePageSize)); const currentPage = Math.min(parsePaginationInteger(page, 1, { min: 1, max: totalPages }), totalPages); const startIndex = (currentPage - 1) * safePageSize; return { rows: rows.slice(startIndex, startIndex + safePageSize), pagination: { page: currentPage, pageSize: safePageSize, totalItems, totalPages, }, }; } function hasExplicitAnalyticsFilter(filters = {}) { return normalizeAnalyticsStatusScope(filters.statusScope) !== 'all' || parseAnalyticsEventSlugs(filters.eventSlugs).length > 0; } function buildEmptyOverviewMetrics() { return { triggeredToday: 0, totalTriggered: 0, failedLast24Hours: 0, deliveryRate: { rate: null, mode: 'no_data', }, chart: [], }; } 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; } async function buildAnalyticsEventRows({ companyId, businessId, bizRoot }) { 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`, }; }); return { rows: sortAnalyticsEventRows(rows), mergedEvents, 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 = firstNonEmptyInputValue(input, 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 analyticsFilters = { statusScope: req.query?.statusScope, eventSlugs: req.query?.eventSlugs, }; const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot }); const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters); const scopedEventSlugs = filteredRows.map((row) => row.eventSlug); const hasExplicitFilter = hasExplicitAnalyticsFilter(analyticsFilters); const overviewMetrics = hasExplicitFilter && scopedEventSlugs.length === 0 ? buildEmptyOverviewMetrics() : await getOverviewMetrics({ companyId, businessId, eventSlugs: hasExplicitFilter ? scopedEventSlugs : [], }); const activeEventsCount = filteredRows.filter((row) => row.status === 'live').length; res.json({ metrics: { triggeredToday: overviewMetrics.triggeredToday, totalTriggered: overviewMetrics.totalTriggered, failedLast24Hours: overviewMetrics.failedLast24Hours, deliveryRate: overviewMetrics.deliveryRate.rate, deliveryRateMode: overviewMetrics.deliveryRate.mode, activeEvents: activeEventsCount, totalEvents: filteredRows.length, }, 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 analyticsFilters = { statusScope: req.query?.statusScope, eventSlugs: req.query?.eventSlugs, }; const page = req.query?.page; const pageSize = req.query?.pageSize; const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot }); const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters); const paginatedRows = paginateAnalyticsRows(filteredRows, page, pageSize); res.json({ events: paginatedRows.rows, allEvents: allRows, pagination: paginatedRows.pagination, }); } 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); const requestPatch = collectRequestPatch(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); if (requestPatch) { validateRequestPatch(requestPatch); } const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const profileIndex = profileData.profiles.findIndex((item) => item.id === profileId); if (profileIndex < 0) return res.status(404).json({ error: 'Profile not found' }); let profile = profileData.profiles[profileIndex]; if (name !== undefined) { profile.name = normalizeText(name); profile.isAutoNamed = false; } if (requestPatch) { profile = await applyRequestPatch(profile, requestPatch); profileData.profiles[profileIndex] = profile; } 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;