Changing endpoint config and adding switch for published templates
This commit is contained in:
parent
c03fef4d95
commit
7acb26602e
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 "<token>[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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user