+
+
+
Events
+
Generate SMS templates for each order event.
+
+
+
-
-
-
- {totalVisibleEvents} visible
-
-
-
-
-
-
- {!readyToGenerate && (
-
- ⚠️
- Set up and activate a cURL profile before generating templates.
-
- )}
-
- {error && (
-
- {error}
-
-
- )}
-
- {showAddForm && (
-
- )}
-
- {groupedEvents.length === 0 ? (
-
-
No events match your search.
-
Try a different keyword or clear the search to see the full lifecycle list.
-
- ) : (
-
- {groupedEvents.map((group) => {
- const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
- const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
-
- return (
-
+
+ setSearchTerm(e.target.value)}
+ placeholder="Search events"
+ className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-11 pr-10 text-sm font-medium text-gray-800 placeholder-gray-400 transition focus:border-primary-blue focus:outline-none focus:ring-2 focus:ring-indigo-100"
+ />
+ {searchTerm && (
+ )}
+
- {isExpanded && (
-
-
- {group.events.map((event) => {
- const state = genState[event.slug] || 'idle';
- const eventVariants = variants[event.slug] || [];
- const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
- const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
- const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
- const canViewTemplate = templateStatus !== 'unselected';
+
+
+ {totalVisibleEvents} visible
+
+
+
+
+
- return (
-
-
-
- {event.isDefault ? (
-
- ) : (
-
- )}
-
-
{event.label}
- {selectedTemplatePreview && (
-
-
Selected Template
-
- {selectedTemplatePreview.selectedTemplate}
-
-
- {selectedTemplatePreview.templateId ? (
-
- Template ID {selectedTemplatePreview.templateId}
-
- ) : (
-
- Template ID pending
-
- )}
-
+ {!readyToGenerate && (
+
+ ⚠️
+ Set up and activate a cURL profile before generating templates.
+
+ )}
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {showAddForm && (
+
+ )}
+
+ {groupedEvents.length === 0 ? (
+
+
No events match your search.
+
Try a different keyword or clear the search to see the full lifecycle list.
+
+ ) : (
+
+ {groupedEvents.map((group) => {
+ const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
+ const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
+
+ return (
+
+
+
+ {isExpanded && (
+
+
+ {group.events.map((event) => {
+ const state = genState[event.slug] || 'idle';
+ const eventVariants = variants[event.slug] || [];
+ const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
+ const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
+ const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
+ const canViewTemplate = templateStatus !== 'unselected';
+ const hasWorkspaceContent = eventVariants.length > 0 || !!selectedTemplatePreview;
+
+ return (
+
+
+
+ {event.isDefault ? (
+
+ ) : (
+
)}
+
+
{event.label}
+ {selectedTemplatePreview && (
+
+
Selected Template
+
+ {selectedTemplatePreview.selectedTemplate}
+
+
+ {selectedTemplatePreview.templateId ? (
+
+ Template ID {selectedTemplatePreview.templateId}
+
+ ) : (
+
+ Template ID pending
+
+ )}
+
+
+ )}
+
-
-
-
-
- {statusConfig.label}
-
- {canViewTemplate && (
+
+
+
+ {statusConfig.label}
+
+ {canViewTemplate && (
+
+ )}
- )}
-
-
-
-
- {eventVariants.length > 0 && (
-
-
Review, edit, and choose a variant
-
- {eventVariants.map((variant, index) => {
- const variantKey = getVariantKey(event.slug, index);
- const draft = variantDrafts[variantKey] || createVariantDraft(variant);
- const currentText = draft.currentText;
- const originalText = draft.originalText;
- const validationStatus = draft.validationStatus;
- const currentMatchesCheckedText = draft.lastCheckedText === currentText;
- const isEdited = currentText !== originalText;
- const dltTokenCount = countDltTokens(currentText);
- const invalidDltTokens = getInvalidDltTokens(currentText);
- const hasMalformedDltToken = hasMalformedDltFragments(currentText);
- const hasInvalidPlaceholder = invalidDltTokens.length > 0 || hasMalformedDltToken;
- const tooLong = currentText.length > MAX_SMS_LENGTH;
- const isSelectingThis = selectingVariantKey === variantKey;
- const isSelectingAnotherVariant = !!selectingVariantKey
- && selectingVariantKey !== variantKey
- && selectingVariantKey.startsWith(`${event.slug}:`);
- const canRunCheck = isEdited && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking';
- const canUseEdited = isEdited
- && validationStatus === 'approved'
- && currentMatchesCheckedText
- && !tooLong
- && !hasInvalidPlaceholder;
- const canInsertVariable = activeCaretVariantKey === variantKey;
-
- return (
-
-
-
-
- {isEdited ? 'Edited Draft' : 'Original Draft'}
-
-
- {validationStatus === 'checking' && (
-
- Checking edit…
-
- )}
-
- {validationStatus === 'approved' && currentMatchesCheckedText && (
-
- Edit passed check
-
- )}
-
- {validationStatus === 'rejected' && currentMatchesCheckedText && (
-
- Needs changes
-
- )}
-
-
-
{
- if (node) variableMenuRefs.current[variantKey] = node;
- else delete variableMenuRefs.current[variantKey];
- }}
- >
-
-
- {openVariableMenuKey === variantKey && (
-
-
-
- {DLT_VARIABLE_OPTIONS.map((option) => (
-
- ))}
-
-
- )}
-
-
-
-
- );
- })}
- )}
-
- );
- })}
+
+ );
+ })}
+
-
- )}
-
- );
- })}
-
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {workspaceEvent && (
+
handleChooseDraft(workspaceSlug, getVariantKey(workspaceSlug, index))}
+ onDraftChange={(nextText) => handleVariantChange(workspaceSlug, workspaceActiveDraftKey, nextText)}
+ onTrackSelection={(target) => trackTextareaSelection(workspaceActiveDraftKey, target)}
+ onToggleVariableMenu={() => handleVariableMenuToggle(workspaceActiveDraftKey)}
+ onInsertVariable={(token) => insertVariableToken(workspaceSlug, workspaceActiveDraftKey, token)}
+ onRevert={() => handleRevertVariant(workspaceSlug, workspaceActiveDraftKey)}
+ onCheck={() => handleValidateEdit(workspaceSlug, workspaceActiveDraftKey)}
+ onSelect={() => handleSelect(workspaceSlug, workspaceActiveDraftKey)}
+ onRegenerate={() => handleRegenerate(workspaceSlug)}
+ setVariableMenuRef={(node) => {
+ if (!workspaceActiveDraftKey) return;
+ if (node) variableMenuRefs.current[workspaceActiveDraftKey] = node;
+ else delete variableMenuRefs.current[workspaceActiveDraftKey];
+ }}
+ setTextareaRef={(node) => {
+ if (!workspaceActiveDraftKey) return;
+ if (node) textareaRefs.current[workspaceActiveDraftKey] = node;
+ else delete textareaRefs.current[workspaceActiveDraftKey];
+ }}
+ workspaceError={workspaceError}
+ selectingDraftKey={selectingVariantKey}
+ showClosePrompt={showClosePrompt}
+ closePromptTitle={workspaceClosePromptTitle}
+ closePromptDescription={workspaceClosePromptDescription}
+ discardingWorkspace={discardingWorkspace}
+ onKeepWorkspace={keepTemplateWorkspace}
+ onDiscardWorkspace={discardTemplateWorkspace}
+ />
)}
-
+ >
);
}
diff --git a/server/index.js b/server/index.js
index 576b9a8..446b083 100644
--- a/server/index.js
+++ b/server/index.js
@@ -8,6 +8,7 @@ const path = require('path');
const businessesRoutes = require('./routes/businesses');
const platformRoutes = require('./routes/platform');
const platformPublicRoutes = require('./routes/platformPublic');
+const platformWebhookRoutes = require('./routes/platformWebhooks');
const { fdkExtension, isFdkConfigured } = require('./fdk');
const app = express();
@@ -31,10 +32,12 @@ app.get('/api/health', (req, res) => res.json({
if (fdkExtension) {
app.use('/api/platform', platformPublicRoutes);
+ app.use('/api/platform', platformWebhookRoutes);
app.use(fdkExtension.fdkHandler);
app.use('/api/platform', fdkExtension.platformApiRoutes, platformRoutes);
} else {
app.use('/api/platform', platformPublicRoutes);
+ app.use('/api/platform', platformWebhookRoutes);
app.use('/api/platform', platformRoutes);
}
diff --git a/server/routes/businesses.js b/server/routes/businesses.js
index 78f46a3..8e1b405 100644
--- a/server/routes/businesses.js
+++ b/server/routes/businesses.js
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { buildBrandContextPlan, collectBrandContextPages } = require('../services/firecrawl');
-const { parseBrandContext, generateTemplates, processCurl, validateCurlFields } = require('../services/openai2');
+const { parseBrandContext, generateTemplates, processCurl, validateEditedTemplate, validateCurlFields } = require('../services/openai2');
const { sendViaWorkflow } = require('../services/workflowSender');
const { buildCrawlSummary } = require('../services/crawlSummary');
const {
@@ -13,6 +13,11 @@ const {
listTemplateFiles,
deleteBusinessFiles,
} = require('../services/pixelbin');
+const {
+ businessRoot,
+ indexPath,
+ onboardingJobsRoot,
+} = require('../services/storagePaths');
const DEFAULT_EVENTS = require('../config/defaultEvents');
const axios = require('axios');
@@ -55,14 +60,6 @@ function slugify(text) {
return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
-function businessRoot(merchantId, businessId) {
- return `${merchantId}/${businessId}`;
-}
-
-function indexPath(merchantId) {
- return merchantId; // index.json lives at {merchantId}/index.json
-}
-
async function getIndex(merchantId) {
const data = await fetchJSON(indexPath(merchantId), 'index');
return Array.isArray(data?.businesses) ? data.businesses : [];
@@ -236,10 +233,6 @@ function mergeBusinessSummary(baseBusiness = {}, context = null) {
};
}
-function onboardingJobsRoot(companyId) {
- return `${companyId}/jobs`;
-}
-
function buildScrapeArtifacts(crawlSummary, imagePaths = []) {
return {
cdnUrls: normalizeUrlList(imagePaths),
@@ -559,7 +552,7 @@ function getShipmentToNumber(body) {
);
}
-const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g;
+const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
function normalizeRenderableValue(value) {
return normalizeScalarText(value).replace(/\s+/g, ' ').trim();
@@ -687,6 +680,11 @@ function buildShipmentValueIndex(shipment) {
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,
@@ -720,6 +718,8 @@ function buildShipmentValueIndex(shipment) {
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);
@@ -755,9 +755,21 @@ function validateRenderedPlaceholderValue(token, value, fieldName) {
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 = {}) {
@@ -792,20 +804,6 @@ function renderShipmentTemplate(template, shipment, variableMap = {}) {
});
}
-function parseWorkflowPayload(data) {
- if (typeof data === 'string') {
- const trimmed = data.trim();
- if (!trimmed) return {};
- try {
- return JSON.parse(trimmed);
- } catch {
- return { status: trimmed };
- }
- }
-
- return data && typeof data === 'object' ? data : {};
-}
-
async function sendResolveTemplateWorkflow({ content, toNumber }) {
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
if (!workflowUrl) {
@@ -836,59 +834,179 @@ async function sendResolveTemplateWorkflow({ content, toNumber }) {
};
}
-function normalizeEditedTemplateValidation(data) {
- const payload = parseWorkflowPayload(data);
-
- let approved = null;
- if (typeof payload.approved === 'boolean') approved = payload.approved;
- if (approved === null && typeof payload.isApproved === 'boolean') approved = payload.isApproved;
- if (approved === null && typeof payload.valid === 'boolean') approved = payload.valid;
- if (approved === null && typeof payload.is_valid === 'boolean') approved = payload.is_valid;
-
- if (approved === null) {
- const status = normalizeText(payload.status || payload.result || payload.decision).toLowerCase();
- if (['approved', 'pass', 'passed', 'valid', 'ok'].includes(status)) approved = true;
- if (['rejected', 'not_approved', 'failed', 'fail', 'invalid', 'needs_changes'].includes(status)) approved = false;
- }
-
- if (approved === null) {
- throw createHttpError(502, 'Template edit validation workflow returned an unreadable response', {
- details: payload,
- });
- }
-
+function buildMissingField(field, error, acceptedPaths = []) {
return {
- approved,
- why: normalizeText(payload.why || payload.reason || payload.message || payload.feedback),
- workflowResult: payload,
+ field,
+ error,
+ details: acceptedPaths.length > 0 ? { acceptedPaths } : undefined,
};
}
-async function validateEditedTemplateWorkflow(editedTemplate) {
- const workflowUrl = normalizeText(process.env.WORKFLOW_URL_TEMPLATE_EDIT_CHECK);
- if (!workflowUrl) {
- throw createHttpError(500, 'WORKFLOW_URL_TEMPLATE_EDIT_CHECK is not configured');
+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'));
}
- const response = await axios.post(
- workflowUrl,
- { editedTemplate },
- {
- timeout: 30000,
- headers: { 'Content-Type': 'application/json' },
- validateStatus: () => true,
- }
+ 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,
+ });
+}
+
+async function resolveTemplateRequest(context) {
+ const business = 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');
+ }
+
+ const resolvedTemplate = renderShipmentTemplate(
+ tmpl.selectedTemplate,
+ context.shipment,
+ tmpl.variableMap || {}
);
- if (response.status < 200 || response.status >= 300) {
- throw createHttpError(
- 502,
- `Template edit validation workflow failed with status ${response.status}`,
- { details: response.data }
- );
- }
+ const workflowResult = await sendResolveTemplateWorkflow({
+ content: resolvedTemplate,
+ toNumber: context.toNumber,
+ });
- return normalizeEditedTemplateValidation(response.data);
+ return {
+ companyId: context.companyId,
+ businessId: business.businessId,
+ applicationId: context.applicationId,
+ brandName: business.brandName || context.brandName,
+ event: eventSlug,
+ matchedTemplateEvent: matchedSlug || eventSlug,
+ templateId: normalizeText(tmpl.templateId),
+ template: tmpl.selectedTemplate,
+ content: resolvedTemplate,
+ toNumber: context.toNumber,
+ workflowResult,
+ };
+}
+
+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',
+ });
+ }
+
+ if (context.missingFields.length > 0) {
+ return res.json({
+ success: true,
+ status: 'ignored',
+ reason: 'missing_required_fields',
+ missingFields: context.missingFields,
+ });
+ }
+
+ try {
+ const result = await resolveTemplateRequest(context);
+ return res.json({
+ success: true,
+ status: 'processed',
+ ...result,
+ });
+ } catch (err) {
+ 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 getProviderPatch(input) {
@@ -1191,90 +1309,14 @@ router.post('/resolve-template', async (req, res) => {
try {
console.log('[ResolveTemplate] Incoming payload:', JSON.stringify(req.body, null, 2));
- const companyId = getCompanyId(req);
- const shipment = getShipmentPayload(req.body);
- const applicationId = getShipmentApplicationId(req);
- const event = getShipmentEventKey(req.body);
- const toNumber = getShipmentToNumber(req.body);
-
- if (!companyId) return res.status(400).json({ error: 'companyId is required' });
- if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' });
- if (!applicationId) {
- return res.status(400).json({
- error: 'A shipment applicationId is required',
- details: {
- acceptedPaths: [
- '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) {
- return res.status(400).json({
- error: 'A shipment event status is required',
- details: {
- acceptedPaths: [
- 'payload.shipment.status',
- 'payload.shipment.shipment_status.status',
- 'payload.shipment.shipment_status.current_shipment_status',
- ],
- },
- });
- }
- if (!toNumber) {
- return res.status(400).json({
- error: 'A shipment phone number is required',
- details: {
- acceptedPaths: [
- 'payload.shipment.user.mobile',
- 'payload.shipment.delivery_address.phone',
- 'payload.shipment.billing_address.phone',
- ],
- },
- });
- }
-
- const business = await findBusinessByApplicationId(companyId, applicationId);
- if (!business) {
- return res.status(404).json({ error: 'Business not found for applicationId' });
- }
-
- const eventSlug = slugify(event);
- const folder = `${businessRoot(companyId, business.businessId)}/templates`;
- const { template: tmpl, matchedSlug } = await resolveWhitelistedTemplate(folder, eventSlug);
-
- if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) {
- return res.status(404).json({ error: 'Whitelisted template not found' });
- }
-
- const resolvedTemplate = renderShipmentTemplate(
- tmpl.selectedTemplate,
- shipment,
- tmpl.variableMap || {}
- );
-
- const workflowResult = await sendResolveTemplateWorkflow({
- content: resolvedTemplate,
- toNumber,
- });
+ const context = buildResolveTemplateContext(req);
+ const missingError = getResolveTemplateMissingError(context);
+ if (missingError) throw missingError;
+ const result = await resolveTemplateRequest(context);
res.json({
success: true,
- companyId,
- businessId: business.businessId,
- applicationId,
- brandName: business.brandName || getShipmentBrandName(req.body),
- event: eventSlug,
- matchedTemplateEvent: matchedSlug || eventSlug,
- templateId: normalizeText(tmpl.templateId),
- template: tmpl.selectedTemplate,
- content: resolvedTemplate,
- toNumber,
- workflowResult,
+ ...result,
});
} catch (err) {
sendRouteError(res, err);
@@ -1590,6 +1632,7 @@ 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.' });
@@ -1603,24 +1646,35 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => {
const event = eventsData.events.find(e => e.slug === slug);
if (!event) return res.status(404).json({ error: 'Event not found' });
- const variants = await generateTemplates(context, slug, event.label);
+ const existingTemplate = 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: null,
- status: 'generated',
- templateId: '',
- curlProfileId: activeProfile.id,
- rawCurl: '',
- processedCurl: '',
- variableMap: {},
- selectedImagePath: '',
+ selectedTemplate: preservedSelectedTemplate || null,
+ status: preservedStatus,
+ templateId: normalizeText(existingTemplate?.templateId),
+ curlProfileId: normalizeText(existingTemplate?.curlProfileId) || activeProfile.id,
+ rawCurl: existingTemplate?.rawCurl || '',
+ processedCurl: existingTemplate?.processedCurl || '',
+ variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object'
+ ? existingTemplate.variableMap
+ : {},
+ selectedImagePath: existingTemplate?.selectedImagePath || '',
updatedAt: new Date().toISOString(),
};
- await uploadJSON(`${bizRoot}/templates`, slug, templateJson);
+ await uploadJSON(templateFolder, slug, templateJson);
res.json({ variants });
} catch (err) {
console.error('Generate error:', err.message);
@@ -1682,13 +1736,40 @@ router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
- const validation = await validateEditedTemplateWorkflow(editedTemplate);
+ 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 = await fetchJSON(folder, slug);
+ if (!tmpl) return res.status(404).json({ error: 'Template not found' });
+
+ tmpl.generatedVariants = [];
+ tmpl.updatedAt = new Date().toISOString();
+
+ await uploadJSON(folder, slug, tmpl);
+ res.json({ ok: true, template: tmpl });
+ } catch (err) {
+ sendRouteError(res, err);
+ }
+});
+
// POST /api/businesses/:businessId/templates/:slug/select
router.post('/:businessId/templates/:slug/select', async (req, res) => {
try {
@@ -1938,3 +2019,4 @@ async function executeCurl(curlStr) {
}
module.exports = router;
+module.exports.handleFyndWebhook = handleFyndWebhook;
diff --git a/server/routes/platformWebhooks.js b/server/routes/platformWebhooks.js
new file mode 100644
index 0000000..2d865e5
--- /dev/null
+++ b/server/routes/platformWebhooks.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const businessesRoutes = require('./businesses');
+
+const router = express.Router();
+
+router.post('/fynd/webhook', businessesRoutes.handleFyndWebhook);
+
+module.exports = router;
diff --git a/server/services/openai2.js b/server/services/openai2.js
index ff4b4e7..55e90fa 100644
--- a/server/services/openai2.js
+++ b/server/services/openai2.js
@@ -1,18 +1,74 @@
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
const axios = require('axios');
+const OpenAI = require('openai');
-const WORKFLOW_URL_SCRAPE = process.env.WORKFLOW_URL_SCRAPE;
-const WORKFLOW_URL_TEMPLATE = process.env.WORKFLOW_URL_TEMPLATE;
-const WORKFLOW_URL_CHECK_CURL = process.env.WORKFLOW_URL_CHECK_CURL;
const WORKFLOW_VALIDATE_FIELDS = process.env.WORKFLOW_VALIDATE_FIELDS;
+const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
+const BRAND_LLM_MODEL = 'openai/gpt-4o';
+const TEMPLATE_LLM_MODEL = 'openai/gpt-4o';
+const CURL_LLM_MODEL = 'openai/gpt-4o-mini';
+const EDIT_CHECK_LLM_MODEL = 'openai/gpt-4o-mini';
-if (!WORKFLOW_URL_SCRAPE) throw new Error('Missing WORKFLOW_URL_SCRAPE environment variable');
-if (!WORKFLOW_URL_TEMPLATE) throw new Error('Missing WORKFLOW_URL_TEMPLATE environment variable');
-if (!WORKFLOW_URL_CHECK_CURL) throw new Error('Missing WORKFLOW_URL_CHECK_CURL environment variable');
if (!WORKFLOW_VALIDATE_FIELDS) throw new Error('Missing WORKFLOW_VALIDATE_FIELDS environment variable');
-const TRAI_RULES_TEXT = '1) Max 160 chars. 2) Dynamic vars use {#var#}. 3) Transactional: no promo/URLs unless required. 4) Sender ID DLT-compliant. 5) Allowed punctuation only. 6) Must match event type. 7) Avoid URLs unless explicitly needed. 8) Start with event/order context.';
+const DLT_VARIABLE_SPECS = [
+ {
+ token: '{#numeric#}',
+ label: '#numeric',
+ purpose: 'Digits-only dynamic values such as OTPs, amounts, or numeric IDs.',
+ validation: 'Only digits are allowed.',
+ },
+ {
+ token: '{#url#}',
+ label: '#url',
+ purpose: 'Web links.',
+ validation: 'Must resolve to a valid registered HTTP(S) URL.',
+ },
+ {
+ token: '{#urlott#}',
+ label: '#urlott',
+ purpose: 'OTT or app-download links.',
+ validation: 'Must resolve to a valid registered OTT or APK URL.',
+ },
+ {
+ token: '{#cbn#}',
+ label: '#cbn',
+ purpose: 'Callback phone numbers.',
+ validation: 'Must resolve to a valid registered callback number.',
+ },
+ {
+ token: '{#email#}',
+ label: '#email',
+ purpose: 'Email addresses.',
+ validation: 'Must resolve to a syntactically valid email address.',
+ },
+ {
+ token: '{#alphanumeric#}',
+ label: '#alphanumeric',
+ purpose: 'Mixed letter-and-number values such as order IDs or booking references.',
+ validation: 'Letters and numbers only; avoid spaces and special characters.',
+ },
+];
+const LEGACY_DLT_VAR_TOKEN = '{#var#}';
+const SUPPORTED_DLT_TOKENS = [LEGACY_DLT_VAR_TOKEN, ...DLT_VARIABLE_SPECS.map((spec) => spec.token)];
+const SUPPORTED_DLT_TOKEN_SET = new Set(SUPPORTED_DLT_TOKENS);
+const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
+const DLT_PLACEHOLDER_LIKE_REGEX = /\{#[^{}]*#\}/g;
+const TRAI_RULES_TEXT = [
+ '1) Keep the SMS within 160 characters.',
+ `2) Use only approved placeholders: ${SUPPORTED_DLT_TOKENS.join(', ')}.`,
+ `3) Prefer typed placeholders (${DLT_VARIABLE_SPECS.map((spec) => spec.token).join(', ')}) whenever the value clearly matches that type.`,
+ `4) Use ${LEGACY_DLT_VAR_TOKEN} only as a generic fallback for free-form values such as names, product titles, or addresses that do not fit a stricter typed token.`,
+ '5) Keep the message strictly transactional: no promotional language.',
+ '6) Do not include raw URLs unless the event genuinely requires a link and the placeholder type is appropriate.',
+ '7) Do not append a brand or sender signature in the message body unless the exact registered sender ID is explicitly known and required.',
+ '8) Sender identifiers must remain DLT-compliant.',
+ '9) Allowed punctuation only; avoid malformed symbols or placeholder fragments.',
+ '10) The message must match the event and start with clear order or event context.',
+].join(' ');
+
+const BRAND_CONTEXT_TONE_OPTIONS = ['friendly', 'professional', 'formal', 'casual', 'energetic'];
const EVENT_DESCRIPTIONS = {
placed: 'The customer has successfully placed an order',
confirmed: 'The order has been confirmed by the seller/warehouse',
@@ -22,6 +78,80 @@ const EVENT_DESCRIPTIONS = {
delivery_done: 'The order has been successfully delivered to the customer',
};
+let cachedClient = null;
+
+function normalizeText(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function describeDltVariableTypes() {
+ return DLT_VARIABLE_SPECS
+ .map((spec) => `- ${spec.token}: ${spec.purpose} ${spec.validation}`)
+ .join('\n');
+}
+
+function getUnsupportedDltTokens(text) {
+ return (String(text).match(DLT_PLACEHOLDER_LIKE_REGEX) || [])
+ .filter((token) => !SUPPORTED_DLT_TOKEN_SET.has(token));
+}
+
+function hasMalformedDltFragments(text) {
+ const stripped = String(text).replace(DLT_PLACEHOLDER_LIKE_REGEX, '');
+ return stripped.includes('{#') || stripped.includes('#}');
+}
+
+function validateTemplateStructure(text) {
+ const normalized = normalizeText(text);
+ if (!normalized) return 'Template is empty.';
+ if (normalized.length > 160) return 'Template exceeds 160 characters.';
+
+ const unsupportedTokens = getUnsupportedDltTokens(normalized);
+ if (unsupportedTokens.length > 0) {
+ return `Template uses unsupported placeholders: ${unsupportedTokens.join(', ')}.`;
+ }
+
+ if (hasMalformedDltFragments(normalized)) {
+ return 'Template contains malformed placeholder text.';
+ }
+
+ return '';
+}
+
+function escapeRegex(value) {
+ return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function buildPhraseRegex(phrase) {
+ const normalized = normalizeText(phrase).replace(/\s+/g, ' ');
+ if (!normalized) return null;
+
+ const parts = normalized.split(' ').filter(Boolean).map(escapeRegex);
+ if (parts.length === 0) return null;
+
+ return new RegExp(`(^|[^a-z0-9])${parts.join('\\s+')}([^a-z0-9]|$)`, 'i');
+}
+
+function getBlockedBrandPhrases(options = {}) {
+ const phrases = [
+ options?.brandName,
+ ...(Array.isArray(options?.brandTaglines) ? options.brandTaglines : []),
+ ]
+ .map((value) => normalizeText(value))
+ .filter(Boolean);
+
+ return [...new Set(phrases)];
+}
+
+function findBlockedBrandPhrase(text, options = {}) {
+ const normalizedText = normalizeText(text);
+ if (!normalizedText) return '';
+
+ return getBlockedBrandPhrases(options).find((phrase) => {
+ const matcher = buildPhraseRegex(phrase);
+ return matcher ? matcher.test(normalizedText) : false;
+ }) || '';
+}
+
function requestId(prefix) {
return `${prefix}_${Date.now()}`;
}
@@ -35,6 +165,116 @@ function parseJsonField(value, fallback) {
}
}
+function extractMessageText(content) {
+ if (typeof content === 'string') return content.trim();
+
+ if (Array.isArray(content)) {
+ return content
+ .map((entry) => {
+ if (typeof entry === 'string') return entry;
+ if (entry && typeof entry.text === 'string') return entry.text;
+ return '';
+ })
+ .join('')
+ .trim();
+ }
+
+ return '';
+}
+
+function tryParseJson(text) {
+ const trimmed = normalizeText(text);
+ if (!trimmed) return null;
+
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ // fall through
+ }
+
+ const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
+ if (fencedMatch?.[1]) {
+ try {
+ return JSON.parse(fencedMatch[1].trim());
+ } catch {
+ // fall through
+ }
+ }
+
+ const firstBrace = trimmed.indexOf('{');
+ const lastBrace = trimmed.lastIndexOf('}');
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
+ try {
+ return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
+ } catch {
+ // fall through
+ }
+ }
+
+ return null;
+}
+
+function isAbsoluteHttpUrl(value) {
+ if (!normalizeText(value)) return false;
+ try {
+ const parsed = new URL(value);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+function getLlmClient() {
+ if (cachedClient) return cachedClient;
+
+ const apiKey = normalizeText(process.env.OPENROUTER_API_KEY);
+ if (!apiKey) {
+ throw new Error('OPENROUTER_API_KEY is not configured');
+ }
+
+ const referer = normalizeText(process.env.EXTENSION_BASE_URL);
+ const appName = 'SMS Extension';
+ const defaultHeaders = {};
+
+ if (referer) defaultHeaders['HTTP-Referer'] = referer;
+ if (appName) defaultHeaders['X-Title'] = appName;
+
+ cachedClient = new OpenAI({
+ apiKey,
+ baseURL: OPENROUTER_BASE_URL,
+ defaultHeaders,
+ });
+
+ return cachedClient;
+}
+
+async function requestStructuredJson({ model, taskName, systemPrompt, userPrompt, temperature = 0.2 }) {
+ try {
+ const client = getLlmClient();
+ const completion = await client.chat.completions.create({
+ model,
+ temperature,
+ response_format: { type: 'json_object' },
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: userPrompt },
+ ],
+ });
+
+ const text = extractMessageText(completion?.choices?.[0]?.message?.content);
+ const parsed = tryParseJson(text);
+
+ if (!parsed || typeof parsed !== 'object') {
+ throw new Error(`${taskName} returned unreadable JSON`);
+ }
+
+ return parsed;
+ } catch (error) {
+ const details = error.response?.data ? ` | response: ${JSON.stringify(error.response.data)}` : '';
+ throw new Error(`${taskName} failed: ${error.message}${details}`);
+ }
+}
+
async function postWorkflow(url, payload) {
try {
const response = await axios.post(url, payload, {
@@ -49,6 +289,38 @@ async function postWorkflow(url, payload) {
}
}
+function sanitizeStringArray(value, options = {}) {
+ const { maxItems = Infinity, allowUrlsOnly = false } = options;
+ if (!Array.isArray(value)) return [];
+
+ const seen = new Set();
+ const items = [];
+
+ value.forEach((entry) => {
+ if (items.length >= maxItems) return;
+ const normalized = normalizeText(String(entry || ''));
+ if (!normalized) return;
+ if (allowUrlsOnly && !isAbsoluteHttpUrl(normalized)) return;
+ if (seen.has(normalized)) return;
+ seen.add(normalized);
+ items.push(normalized);
+ });
+
+ return items;
+}
+
+function sanitizeVariableMap(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 = normalizeText(String(rawValue || ''));
+ if (!normalizedKey || !normalizedValue) return accumulator;
+ accumulator[normalizedKey] = normalizedValue;
+ return accumulator;
+ }, {});
+}
+
async function parseBrandContext(scrapedData = {}) {
const representativePages = Array.isArray(scrapedData.representativePages)
? scrapedData.representativePages.slice(0, 20)
@@ -70,95 +342,267 @@ async function parseBrandContext(scrapedData = {}) {
.join('\n\n')
.slice(0, 14000);
- const payload = {
- task: 'parse_brand_context',
- request_id: requestId('parse_brand_context'),
- start_url: String(scrapedData.startUrl || ''),
- domain: String(scrapedData.domain || ''),
- site_stats_json: JSON.stringify(scrapedData.siteStats || {}),
- homepage_json: JSON.stringify(scrapedData.homepage || {}),
- about_page_json: JSON.stringify(scrapedData.aboutPage || {}),
- product_pages_json: JSON.stringify(productPages),
- contact_page_json: JSON.stringify(scrapedData.contactPage || {}),
- representative_pages_json: JSON.stringify(representativePages),
- representative_text_blocks_json: JSON.stringify(representativeTextBlocks),
- navigation_json: JSON.stringify(scrapedData.navigation || []),
- policy_pages_json: JSON.stringify(scrapedData.policyPages || []),
- links_json: JSON.stringify(scrapedData.links || []),
- top_images_json: JSON.stringify(scrapedData.topImages || []),
- screenshots_json: JSON.stringify(scrapedData.screenshots || []),
- branding_json: JSON.stringify(scrapedData.branding || {}),
- crawl_summary_json: JSON.stringify(scrapedData || {}),
- content_digest: contentDigest,
- output_schema_text: 'You are given homepage, about-page, product-page, branding, and image evidence for a storefront. Use that evidence to infer brand identity and product language. Return ONLY valid JSON object with exactly these keys: brandName (string), tone (one of: friendly, professional, formal, casual, energetic), taglines (array of strings, max 3), colors (array of hex color strings, or empty array), relevantImageUrls (array of 3-5 absolute image URLs for logo/hero/product images only; no icons/tracking/data URLs), aboutSummary (string, 2-4 sentences, concise customer-facing brand summary that explains what the brand is about, what it sells, and its vibe; do not copy the About Us page verbatim). No markdown, no prose, no extra keys.',
- must_return_json_only: 'true',
- };
+ const result = await requestStructuredJson({
+ model: BRAND_LLM_MODEL,
+ taskName: 'Brand context extraction',
+ temperature: 0.2,
+ systemPrompt: 'You are a brand analyst for ecommerce storefronts. Infer brand identity from crawl evidence and return only valid JSON that matches the requested schema exactly.',
+ userPrompt: [
+ 'Analyze the storefront evidence below and infer brand context.',
+ '',
+ 'Return only valid JSON with exactly these keys:',
+ '{',
+ ' "brandName": "string",',
+ ` "tone": "one of ${BRAND_CONTEXT_TONE_OPTIONS.join(', ')}",`,
+ ' "taglines": ["up to 3 strings"],',
+ ' "colors": ["hex colors only"],',
+ ' "relevantImageUrls": ["3-5 absolute http(s) image URLs only"],',
+ ' "aboutSummary": "2-4 concise customer-facing sentences"',
+ '}',
+ '',
+ 'Constraints:',
+ '- No markdown.',
+ '- No explanatory prose.',
+ '- Do not copy the About page verbatim.',
+ '- Exclude icons, tracking pixels, and data URLs from images.',
+ '',
+ `start_url: ${String(scrapedData.startUrl || '')}`,
+ `domain: ${String(scrapedData.domain || '')}`,
+ `site_stats_json: ${JSON.stringify(scrapedData.siteStats || {})}`,
+ `homepage_json: ${JSON.stringify(scrapedData.homepage || {})}`,
+ `about_page_json: ${JSON.stringify(scrapedData.aboutPage || {})}`,
+ `product_pages_json: ${JSON.stringify(productPages)}`,
+ `contact_page_json: ${JSON.stringify(scrapedData.contactPage || {})}`,
+ `representative_pages_json: ${JSON.stringify(representativePages)}`,
+ `representative_text_blocks_json: ${JSON.stringify(representativeTextBlocks)}`,
+ `navigation_json: ${JSON.stringify(scrapedData.navigation || [])}`,
+ `policy_pages_json: ${JSON.stringify(scrapedData.policyPages || [])}`,
+ `links_json: ${JSON.stringify(scrapedData.links || [])}`,
+ `top_images_json: ${JSON.stringify(scrapedData.topImages || [])}`,
+ `screenshots_json: ${JSON.stringify(scrapedData.screenshots || [])}`,
+ `branding_json: ${JSON.stringify(scrapedData.branding || {})}`,
+ `crawl_summary_json: ${JSON.stringify(scrapedData || {})}`,
+ `content_digest: ${contentDigest}`,
+ ].join('\n'),
+ });
- const data = await postWorkflow(WORKFLOW_URL_SCRAPE, payload);
- const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
+ const normalizedTone = normalizeText(String(result.tone || '')).toLowerCase();
return {
- brandName: String(output.brandName || '').trim() || 'Unknown Brand',
- tone: ['friendly', 'professional', 'formal', 'casual', 'energetic'].includes(String(output.tone || '').toLowerCase())
- ? String(output.tone).toLowerCase()
- : 'professional',
- taglines: Array.isArray(output.taglines) ? output.taglines.slice(0, 3).map(String) : [],
- colors: Array.isArray(output.colors) ? output.colors.map(String) : [],
- relevantImageUrls: Array.isArray(output.relevantImageUrls) ? output.relevantImageUrls.map(String) : [],
- aboutSummary: String(output.aboutSummary || '').trim(),
+ brandName: normalizeText(String(result.brandName || '')) || 'Unknown Brand',
+ tone: BRAND_CONTEXT_TONE_OPTIONS.includes(normalizedTone) ? normalizedTone : 'professional',
+ taglines: sanitizeStringArray(result.taglines, { maxItems: 3 }),
+ colors: sanitizeStringArray(result.colors),
+ relevantImageUrls: sanitizeStringArray(result.relevantImageUrls, { maxItems: 5, allowUrlsOnly: true }),
+ aboutSummary: normalizeText(String(result.aboutSummary || '')),
};
}
-async function generateTemplates(brandContext = {}, eventSlug, eventLabel) {
+async function generateTemplates(brandContext = {}, eventSlug, eventLabel, options = {}) {
const eventDesc = EVENT_DESCRIPTIONS[eventSlug] || `A "${eventLabel}" event in the order lifecycle`;
+ const registeredSenderId = normalizeText(options?.senderId).toUpperCase();
+ const blockedBrandPhrases = getBlockedBrandPhrases({
+ brandName: brandContext?.brandName,
+ brandTaglines: brandContext?.taglines,
+ });
+ const approvedTemplates = [];
+ const seenTemplates = new Set();
+ const rejectionReasons = [];
- const payload = {
- task: 'generate_sms_templates',
- request_id: requestId('generate_sms_templates'),
- brand_name: String(brandContext.brandName || ''),
- tone: String(brandContext.tone || ''),
- taglines_json: JSON.stringify(Array.isArray(brandContext.taglines) ? brandContext.taglines : []),
- event_slug: String(eventSlug || ''),
- event_label: String(eventLabel || ''),
- event_description: eventDesc,
- trai_rules_text: TRAI_RULES_TEXT,
- templates_count: '3',
- max_chars: '160',
- variable_format: '{#var#}',
- output_schema_text: 'Return ONLY valid JSON object with exactly one key: templates (array of exactly 3 strings). No extra keys.',
- must_return_json_only: 'true',
- };
+ for (let attempt = 0; attempt < 2 && approvedTemplates.length < 3; attempt += 1) {
+ const templateCount = attempt === 0 ? 6 : 8;
+ const result = await requestStructuredJson({
+ model: TEMPLATE_LLM_MODEL,
+ taskName: 'SMS template generation',
+ temperature: 0.45,
+ systemPrompt: 'You are an expert in Indian transactional SMS templates. Follow the provided constraints exactly, self-check against them, and return only valid JSON.',
+ userPrompt: [
+ `Generate exactly ${templateCount} distinct transactional SMS templates.`,
+ '',
+ `Brand: ${String(brandContext.brandName || '')}`,
+ `Tone: ${String(brandContext.tone || '')}`,
+ `Taglines: ${JSON.stringify(Array.isArray(brandContext.taglines) ? brandContext.taglines : [])}`,
+ `Event slug: ${String(eventSlug || '')}`,
+ `Event label: ${String(eventLabel || '')}`,
+ `Event description: ${eventDesc}`,
+ `Registered sender ID: ${registeredSenderId || 'Not provided. Do not append any brand or sender signature.'}`,
+ '',
+ `Rules: ${TRAI_RULES_TEXT}`,
+ '',
+ 'Approved placeholder types:',
+ describeDltVariableTypes(),
+ `- ${LEGACY_DLT_VAR_TOKEN}: Generic fallback for free-form values such as customer names, product names, or addresses when a stricter typed token does not fit.`,
+ '',
+ 'Each template must:',
+ '- be under 160 characters',
+ '- start with clear event or order context',
+ '- match the event accurately',
+ '- avoid promotional language',
+ '- avoid raw URLs unless clearly required for the event',
+ '- never mention the brand name or tagline in the message body unless the exact registered sender ID is explicitly required and provided',
+ blockedBrandPhrases.length > 0
+ ? `- specifically do not include these phrases: ${blockedBrandPhrases.join(', ')}`
+ : '',
+ '',
+ rejectionReasons.length > 0
+ ? `Avoid these issues seen in rejected drafts: ${rejectionReasons.slice(-6).join(' | ')}`
+ : '',
+ '',
+ 'Return only valid JSON with exactly this shape:',
+ `{ "templates": ["template 1", "template 2", "... up to ${templateCount} templates"] }`,
+ ].filter(Boolean).join('\n'),
+ });
- const data = await postWorkflow(WORKFLOW_URL_TEMPLATE, payload);
- const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
- const templates = Array.isArray(output.templates)
- ? output.templates
- : parseJsonField(output.templates_json, []);
+ const candidateTemplates = sanitizeStringArray(result.templates, { maxItems: templateCount });
- return Array.isArray(templates) ? templates.map(String).slice(0, 3) : [];
+ for (const candidate of candidateTemplates) {
+ if (approvedTemplates.length >= 3) break;
+ if (seenTemplates.has(candidate)) continue;
+ seenTemplates.add(candidate);
+
+ const structureIssue = validateTemplateStructure(candidate);
+ if (structureIssue) {
+ rejectionReasons.push(structureIssue);
+ continue;
+ }
+
+ const blockedPhrase = findBlockedBrandPhrase(candidate, {
+ brandName: brandContext?.brandName,
+ brandTaglines: brandContext?.taglines,
+ });
+ if (blockedPhrase) {
+ rejectionReasons.push(`Do not mention "${blockedPhrase}" in the SMS body.`);
+ continue;
+ }
+
+ const validation = await validateEditedTemplate(candidate, {
+ senderId: registeredSenderId,
+ eventSlug,
+ eventLabel,
+ brandName: brandContext?.brandName,
+ brandTaglines: brandContext?.taglines,
+ });
+
+ if (validation.approved) {
+ approvedTemplates.push(candidate);
+ continue;
+ }
+
+ if (validation.why) {
+ rejectionReasons.push(validation.why);
+ }
+ }
+ }
+
+ if (approvedTemplates.length < 3) {
+ throw new Error('Could not generate 3 compliant templates. Please try again.');
+ }
+
+ return approvedTemplates.slice(0, 3);
}
async function processCurl(rawCurl, approvedTemplate, eventSlug) {
- const payload = {
- task: 'process_provider_curl',
- request_id: requestId('process_provider_curl'),
- raw_curl: String(rawCurl || ''),
- approved_template: String(approvedTemplate || ''),
- event_slug: String(eventSlug || ''),
- instructions_text: 'Identify placeholders, map to semantic field names, normalize placeholders in curl to camelCase, and build positional mapping for DLT placeholder tokens in approved_template. Supported token types include {#var#}, {#numeric#}, {#url#}, and {#cbn#}. Preserve the actual token text in variableMap keys using the format "
[index]" based on appearance order within approved_template.',
- output_schema_text: 'Return ONLY valid JSON object with exactly these keys: processedCurl (string), variableMap (object where keys preserve the actual DLT token text in approved_template, such as {#var#}[0], {#numeric#}[1], {#url#}[2], and values are field names in camelCase). No extra keys.',
- must_return_json_only: 'true',
- };
-
- const data = await postWorkflow(WORKFLOW_URL_CHECK_CURL, payload);
- const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
- const variableMap = typeof output.variableMap === 'object' && output.variableMap !== null
- ? output.variableMap
- : parseJsonField(output.variable_map_json, {});
+ const result = await requestStructuredJson({
+ model: CURL_LLM_MODEL,
+ taskName: 'Provider cURL processing',
+ temperature: 0.1,
+ systemPrompt: 'You are an SMS provider integration expert. Analyze raw provider curls, infer semantic placeholders, and return only valid JSON.',
+ userPrompt: [
+ 'Analyze the provider cURL and return a structured placeholder mapping.',
+ '',
+ `Approved SMS template:\n${String(approvedTemplate || '')}`,
+ '',
+ `Event slug: ${String(eventSlug || '')}`,
+ '',
+ `Raw cURL:\n${String(rawCurl || '')}`,
+ '',
+ 'Instructions:',
+ '- identify all placeholder formats in the cURL',
+ '- infer semantic field names in camelCase',
+ '- normalize placeholders inside processedCurl using those camelCase field names',
+ '- build variableMap using the exact DLT token text from the approved template in appearance order',
+ `- supported DLT token types include ${SUPPORTED_DLT_TOKENS.join(', ')}`,
+ '',
+ 'Return only valid JSON with exactly this shape:',
+ '{',
+ ' "processedCurl": "string",',
+ ' "variableMap": { "{#numeric#}[0]": "fieldName", "{#var#}[1]": "fieldName" }',
+ '}',
+ ].join('\n'),
+ });
return {
- processedCurl: String(output.processedCurl || ''),
- variableMap: variableMap && typeof variableMap === 'object' ? variableMap : {},
+ processedCurl: String(result.processedCurl || ''),
+ variableMap: sanitizeVariableMap(result.variableMap),
+ };
+}
+
+async function validateEditedTemplate(editedTemplate, options = {}) {
+ const structureIssue = validateTemplateStructure(editedTemplate);
+ if (structureIssue) {
+ return {
+ approved: false,
+ why: structureIssue,
+ workflowResult: { approved: false, why: structureIssue, source: 'deterministic' },
+ };
+ }
+
+ const registeredSenderId = normalizeText(options?.senderId).toUpperCase();
+ const eventSlug = normalizeText(options?.eventSlug);
+ const eventLabel = normalizeText(options?.eventLabel);
+ const brandName = normalizeText(options?.brandName);
+ const blockedBrandPhrase = findBlockedBrandPhrase(editedTemplate, options);
+ if (blockedBrandPhrase) {
+ return {
+ approved: false,
+ why: `Remove the brand reference "${blockedBrandPhrase}" from the message body.`,
+ workflowResult: { approved: false, why: `Blocked brand phrase: ${blockedBrandPhrase}`, source: 'deterministic' },
+ };
+ }
+ const result = await requestStructuredJson({
+ model: EDIT_CHECK_LLM_MODEL,
+ taskName: 'Edited template validation',
+ temperature: 0,
+ systemPrompt: 'You validate Indian transactional SMS templates for compliance and clarity. Return only valid JSON.',
+ userPrompt: [
+ 'Review this edited SMS template and decide whether it should be approved.',
+ '',
+ `Template:\n${String(editedTemplate || '')}`,
+ '',
+ eventSlug ? `Event slug: ${eventSlug}` : '',
+ eventLabel ? `Event label: ${eventLabel}` : '',
+ brandName ? `Brand name: ${brandName}` : '',
+ `Registered sender ID: ${registeredSenderId || 'Not provided. Reject appended brand or sender signatures.'}`,
+ '',
+ `Rules: ${TRAI_RULES_TEXT}`,
+ '',
+ 'Approved placeholder types:',
+ describeDltVariableTypes(),
+ `- ${LEGACY_DLT_VAR_TOKEN}: Generic fallback for free-form values such as names, product names, or addresses when a stricter typed token does not fit.`,
+ '',
+ 'Approval guidance:',
+ '- approve only if the template is clear, transactional, and appears compliant with the rules',
+ '- approve typed placeholders like {#numeric#}, {#url#}, {#urlott#}, {#cbn#}, {#email#}, and {#alphanumeric#} when they match the intended dynamic value type',
+ `- allow ${LEGACY_DLT_VAR_TOKEN} only as a generic fallback for free-form content that does not fit a stricter typed token`,
+ '- reject if a more precise typed token should clearly replace a generic one for numeric, URL, callback, email, or alphanumeric values',
+ '- reject if the message mentions the brand name, tagline, or a brand-style signoff in the body',
+ '- reject if the message appends a sender signature that does not exactly match the registered sender ID',
+ '- reject if it is too promotional, malformed, ambiguous, or clearly non-compliant',
+ '- keep the explanation concise and actionable',
+ '',
+ 'Return only valid JSON with exactly this shape:',
+ '{ "approved": true, "why": "short explanation" }',
+ ].join('\n'),
+ });
+
+ const approved = typeof result.approved === 'boolean'
+ ? result.approved
+ : ['approved', 'pass', 'passed', 'valid', 'ok', 'true'].includes(normalizeText(String(result.approved || result.status || '')).toLowerCase());
+
+ return {
+ approved,
+ why: normalizeText(String(result.why || result.reason || result.message || '')),
+ workflowResult: result,
};
}
@@ -183,4 +627,10 @@ async function validateCurlFields(rawCurl) {
};
}
-module.exports = { parseBrandContext, generateTemplates, processCurl, validateCurlFields };
+module.exports = {
+ parseBrandContext,
+ generateTemplates,
+ processCurl,
+ validateEditedTemplate,
+ validateCurlFields,
+};
diff --git a/server/services/pixelbin.js b/server/services/pixelbin.js
index 1eba0f2..8f5406a 100644
--- a/server/services/pixelbin.js
+++ b/server/services/pixelbin.js
@@ -1,6 +1,7 @@
const { PixelbinConfig, PixelbinClient } = require('@pixelbin/admin');
const { Readable } = require('stream');
const axios = require('axios');
+const { businessRoot } = require('./storagePaths');
function getPixelbinClient() {
const apiToken = process.env.PIXELBIN_API_TOKEN;
@@ -122,7 +123,7 @@ async function listFilesWithId(folderPath) {
* Business root: {merchantId}/{businessId}/
*/
async function deleteBusinessFiles(merchantId, businessId) {
- const root = `${merchantId}/${businessId}`;
+ const root = businessRoot(merchantId, businessId);
const [rootFiles, templateFiles, imageFiles] = await Promise.all([
listFilesWithId(root),
listFilesWithId(`${root}/templates`),
diff --git a/server/services/storagePaths.js b/server/services/storagePaths.js
new file mode 100644
index 0000000..a5e583f
--- /dev/null
+++ b/server/services/storagePaths.js
@@ -0,0 +1,32 @@
+const STORAGE_NAMESPACE = 'Omni-SMS Extension';
+
+function normalizeSegment(value) {
+ return String(value || '').trim().replace(/^\/+|\/+$/g, '');
+}
+
+function joinStoragePath(...segments) {
+ return [STORAGE_NAMESPACE, ...segments]
+ .map(normalizeSegment)
+ .filter(Boolean)
+ .join('/');
+}
+
+function businessRoot(companyId, businessId) {
+ return joinStoragePath(companyId, businessId);
+}
+
+function indexPath(companyId) {
+ return joinStoragePath(companyId);
+}
+
+function onboardingJobsRoot(companyId) {
+ return joinStoragePath(companyId, 'jobs');
+}
+
+module.exports = {
+ STORAGE_NAMESPACE,
+ joinStoragePath,
+ businessRoot,
+ indexPath,
+ onboardingJobsRoot,
+};