-
- {item.substeps && (
-
- )}
+ {item.enabled ? (
+
+ {SVG_ICONS[item.id]}
+ {item.label}
+
+ {!item.substeps &&
}
+ {item.substeps && (
+
+ )}
+
+
+ ) : (
+
+ {SVG_ICONS[item.id]}
+
{item.label}
+
+ {!item.substeps && }
+
-
+ )}
{item.expanded && item.substeps && (
-
- {item.substeps.map((substep) => (
-
-
- {substep.label}
-
- ))}
+
+
+
+ {item.substeps.map((substep) => (
+
+
+
+ {substep.label}
+
+
+ ))}
+
)}
diff --git a/server/routes/businesses.js b/server/routes/businesses.js
index 603dd7d..952f012 100644
--- a/server/routes/businesses.js
+++ b/server/routes/businesses.js
@@ -200,6 +200,163 @@ function getShipmentToNumber(body) {
return normalizeText(body?.payload?.shipment?.user?.mobile);
}
+const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g;
+
+function normalizeRenderableValue(value) {
+ if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim();
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
+ return '';
+}
+
+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 primaryTrackingUrl = normalizeRenderableValue(
+ 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 = normalizeRenderableValue(
+ shipment?.delivery_partner_details?.awb_no
+ || shipment?.meta?.awb_number
+ || shipment?.article_details?.dp_details?.awb_no
+ );
+ const primaryCourierName = normalizeRenderableValue(
+ shipment?.delivery_partner_details?.display_name
+ || shipment?.delivery_partner_details?.name
+ || shipment?.meta?.courier_partner_name
+ || shipment?.meta?.dp_name
+ || firstBag?.meta?.dp_name
+ );
+ const brandName = normalizeRenderableValue(
+ shipment?.bags?.[0]?.brand?.brand_name
+ || shipment?.bags?.[0]?.item?.attributes?.brand_name
+ || shipment?.affiliate_details?.company_affiliate_tag
+ );
+
+ setValueIndexEntry(valueIndex, 'firstName', normalizeRenderableValue(shipment?.user?.first_name));
+ setValueIndexEntry(valueIndex, 'lastName', normalizeRenderableValue(shipment?.user?.last_name));
+ setValueIndexEntry(valueIndex, 'fullName', normalizeRenderableValue(
+ `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
+ ));
+ setValueIndexEntry(valueIndex, 'phone', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
+ setValueIndexEntry(valueIndex, 'mobile', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
+ setValueIndexEntry(valueIndex, 'toNumber', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
+ setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id));
+ setValueIndexEntry(valueIndex, 'shipmentId', normalizeRenderableValue(shipment?.shipment_id));
+ setValueIndexEntry(valueIndex, 'event', normalizeRenderableValue(shipment?.status));
+ setValueIndexEntry(valueIndex, 'status', normalizeRenderableValue(shipment?.status));
+ setValueIndexEntry(valueIndex, 'eventDisplayName', normalizeRenderableValue(shipment?.shipment_status?.display_name));
+ setValueIndexEntry(valueIndex, 'displayName', normalizeRenderableValue(shipment?.shipment_status?.display_name));
+ setValueIndexEntry(valueIndex, 'brandName', brandName);
+ setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
+ setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
+ setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
+ setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
+ setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
+ setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
+ setValueIndexEntry(valueIndex, 'courierName', primaryCourierName);
+ setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName);
+
+ return valueIndex;
+}
+
+function validateRenderedPlaceholderValue(token, value, fieldName) {
+ if (!value) {
+ throw createHttpError(422, `No shipment value found for placeholder field "${fieldName}"`);
+ }
+
+ if (token === '{#numeric#}' && !/^\d+$/.test(value)) {
+ throw createHttpError(422, `Field "${fieldName}" resolved to a non-numeric value for ${token}`);
+ }
+
+ if (token === '{#url#}' && !/^https?:\/\//i.test(value)) {
+ throw createHttpError(422, `Field "${fieldName}" resolved to an invalid URL for ${token}`);
+ }
+
+ if (token === '{#cbn#}' && !/^\+?[0-9][0-9\s-]{5,}$/.test(value)) {
+ throw createHttpError(422, `Field "${fieldName}" resolved to an invalid callback number for ${token}`);
+ }
+}
+
+function renderShipmentTemplate(template, shipment, variableMap = {}) {
+ const normalizedTemplate = normalizeText(template);
+ const placeholderMatches = normalizedTemplate.match(DLT_PLACEHOLDER_REGEX) || [];
+
+ if (placeholderMatches.length === 0) {
+ return normalizedTemplate;
+ }
+
+ if (!variableMap || typeof variableMap !== 'object' || Object.keys(variableMap).length === 0) {
+ throw createHttpError(422, 'Template has placeholders but no variableMap was found on the stored template');
+ }
+
+ const shipmentValueIndex = buildShipmentValueIndex(shipment);
+ let placeholderIndex = 0;
+
+ return normalizedTemplate.replace(DLT_PLACEHOLDER_REGEX, (token) => {
+ const mappingKey = `${token}[${placeholderIndex}]`;
+ const mappedFieldName = normalizeText(variableMap[mappingKey]);
+
+ if (!mappedFieldName) {
+ throw createHttpError(422, `No variable mapping found for placeholder ${mappingKey}`, {
+ details: { mappingKey, variableMap },
+ });
+ }
+
+ const resolvedValue = shipmentValueIndex.get(toCamelCase(mappedFieldName)) || '';
+ validateRenderedPlaceholderValue(token, resolvedValue, mappedFieldName);
+ placeholderIndex += 1;
+ return resolvedValue;
+ });
+}
+
function parseWorkflowPayload(data) {
if (typeof data === 'string') {
const trimmed = data.trim();
@@ -214,7 +371,7 @@ function parseWorkflowPayload(data) {
return data && typeof data === 'object' ? data : {};
}
-async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }) {
+async function sendResolveTemplateWorkflow({ content, toNumber }) {
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
if (!workflowUrl) {
throw createHttpError(500, 'WORKFLOW_URL_RESOLVE_TEMPLATE is not configured');
@@ -222,7 +379,7 @@ async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }
const response = await axios.post(
workflowUrl,
- { template, toNumber, sourcePayload },
+ { content, toNumber },
{
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
@@ -589,10 +746,15 @@ router.post('/resolve-template', async (req, res) => {
return res.status(404).json({ error: 'Whitelisted template not found' });
}
+ const resolvedTemplate = renderShipmentTemplate(
+ tmpl.selectedTemplate,
+ shipment,
+ tmpl.variableMap || {}
+ );
+
const workflowResult = await sendResolveTemplateWorkflow({
- template: tmpl.selectedTemplate,
+ content: resolvedTemplate,
toNumber,
- sourcePayload: req.body,
});
res.json({
@@ -603,6 +765,7 @@ router.post('/resolve-template', async (req, res) => {
event: eventSlug,
templateId: normalizeText(tmpl.templateId),
template: tmpl.selectedTemplate,
+ content: resolvedTemplate,
toNumber,
workflowResult,
});