2023 lines
69 KiB
JavaScript
2023 lines
69 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { buildBrandContextPlan, collectBrandContextPages } = require('../services/firecrawl');
|
|
const { parseBrandContext, generateTemplates, processCurl, validateEditedTemplate, validateCurlFields } = require('../services/openai2');
|
|
const { sendViaWorkflow } = require('../services/workflowSender');
|
|
const { buildCrawlSummary } = require('../services/crawlSummary');
|
|
const {
|
|
uploadJSON,
|
|
fetchJSON,
|
|
uploadImageFromUrl,
|
|
listImages,
|
|
listTemplateFiles,
|
|
deleteBusinessFiles,
|
|
} = require('../services/pixelbin');
|
|
const {
|
|
businessRoot,
|
|
indexPath,
|
|
onboardingJobsRoot,
|
|
} = require('../services/storagePaths');
|
|
const DEFAULT_EVENTS = require('../config/defaultEvents');
|
|
const axios = require('axios');
|
|
|
|
const MERCHANT_ID = () => process.env.MERCHANT_ID;
|
|
|
|
function normalizeScopeId(value) {
|
|
if (typeof value === 'string') return value.trim();
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
return '';
|
|
}
|
|
|
|
function getCompanyId(req) {
|
|
return normalizeScopeId(
|
|
req.fdkSession?.company_id
|
|
|| req.get('x-company-id')
|
|
|| req.params?.companyId
|
|
|| req.params?.company_id
|
|
|| req.query?.companyId
|
|
|| req.query?.company_id
|
|
|| req.body?.companyId
|
|
|| req.body?.company_id
|
|
|| MERCHANT_ID()
|
|
);
|
|
}
|
|
|
|
function getApplicationId(req) {
|
|
return normalizeScopeId(
|
|
req.get('x-application-id')
|
|
|| req.body?.salesChannelId
|
|
|| req.query?.applicationId
|
|
|| req.query?.application_id
|
|
|| req.body?.applicationId
|
|
|| req.body?.application_id
|
|
);
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function slugify(text) {
|
|
return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
}
|
|
|
|
async function getIndex(merchantId) {
|
|
const data = await fetchJSON(indexPath(merchantId), 'index');
|
|
return Array.isArray(data?.businesses) ? data.businesses : [];
|
|
}
|
|
|
|
async function saveIndex(merchantId, businesses) {
|
|
await uploadJSON(indexPath(merchantId), 'index', { businesses });
|
|
}
|
|
|
|
async function findBusinessByApplicationId(merchantId, applicationId) {
|
|
const normalizedApplicationId = normalizeScopeId(applicationId);
|
|
if (!normalizedApplicationId) return null;
|
|
|
|
const businesses = await getIndex(merchantId);
|
|
const exactMatch = businesses.find((business) => {
|
|
const storedApplicationId = normalizeScopeId(business.applicationId);
|
|
const storedBusinessId = normalizeScopeId(business.businessId);
|
|
return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId;
|
|
});
|
|
|
|
return exactMatch || null;
|
|
}
|
|
|
|
async function findBusinessByBrandName(merchantId, brandName) {
|
|
const normalizedBrandName = normalizeText(brandName).toLowerCase();
|
|
if (!normalizedBrandName) return null;
|
|
|
|
const businesses = await getIndex(merchantId);
|
|
const brandMatches = businesses.filter((business) => normalizeText(business.brandName).toLowerCase() === normalizedBrandName);
|
|
|
|
if (brandMatches.length > 1) {
|
|
throw createHttpError(
|
|
409,
|
|
'Multiple businesses matched the provided brand name',
|
|
{
|
|
code: 'AMBIGUOUS_BUSINESS_MATCH',
|
|
details: {
|
|
companyId: merchantId,
|
|
brandName: normalizedBrandName,
|
|
matchedBusinesses: brandMatches.map((business) => ({
|
|
businessId: business.businessId,
|
|
brandName: business.brandName,
|
|
})),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
return brandMatches[0] || null;
|
|
}
|
|
|
|
const PROVIDER_FIELDS = ['providerName', 'senderId', 'dltEntityId', 'authKey'];
|
|
|
|
function createHttpError(status, message, extra = {}) {
|
|
const err = new Error(message);
|
|
err.status = status;
|
|
Object.assign(err, extra);
|
|
return err;
|
|
}
|
|
|
|
function sendRouteError(res, err) {
|
|
const status = err.status || 500;
|
|
const body = { error: err.message };
|
|
if (err.code) body.code = err.code;
|
|
if (err.missingFields) body.missingFields = err.missingFields;
|
|
if (err.template) body.template = err.template;
|
|
if (err.details) body.details = err.details;
|
|
res.status(status).json(body);
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function normalizeScalarText(value) {
|
|
if (typeof value === 'string') return value.trim();
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
return '';
|
|
}
|
|
|
|
function firstNonEmptyText(...values) {
|
|
for (const value of values) {
|
|
const normalized = normalizeScalarText(value);
|
|
if (normalized) return normalized;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function normalizeSenderId(value) {
|
|
return normalizeText(value).toUpperCase();
|
|
}
|
|
|
|
function isValidCurlCommand(rawCurl) {
|
|
return normalizeText(rawCurl).toLowerCase().startsWith('curl');
|
|
}
|
|
|
|
function validateSenderId(senderId) {
|
|
if (!senderId) return null;
|
|
if (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId)) {
|
|
return 'Sender ID must be exactly 6 alphabetic characters';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeProvider(provider = {}, fallbackUpdatedAt = null) {
|
|
const updatedAt = provider.updatedAt || fallbackUpdatedAt || new Date().toISOString();
|
|
return {
|
|
providerName: normalizeText(provider.providerName),
|
|
senderId: normalizeSenderId(provider.senderId),
|
|
dltEntityId: normalizeText(provider.dltEntityId),
|
|
authKey: normalizeText(provider.authKey),
|
|
updatedAt,
|
|
};
|
|
}
|
|
|
|
function normalizeWebsiteUrl(value) {
|
|
const rawValue = normalizeText(value);
|
|
if (!rawValue) return '';
|
|
|
|
const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`;
|
|
|
|
try {
|
|
const url = new URL(candidate);
|
|
return url.toString().replace(/\/$/, '');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function normalizeUrlList(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const seen = new Set();
|
|
return value
|
|
.map((entry) => normalizeText(entry))
|
|
.filter((entry) => {
|
|
if (!entry || seen.has(entry)) return false;
|
|
seen.add(entry);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function getBusinessPreviewSummary(source = {}) {
|
|
const taglines = Array.isArray(source?.taglines)
|
|
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
|
: [];
|
|
const relevantImagePaths = Array.isArray(source?.relevantImagePaths)
|
|
? source.relevantImagePaths.map((path) => normalizeText(path)).filter(Boolean)
|
|
: [];
|
|
|
|
return {
|
|
previewTagline: taglines[0] || '',
|
|
previewImagePath: relevantImagePaths[0] || '',
|
|
};
|
|
}
|
|
|
|
function mergeBusinessSummary(baseBusiness = {}, context = null) {
|
|
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
|
|
const relevantImagePaths = normalizeUrlList(
|
|
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
|
|
? baseBusiness.relevantImagePaths
|
|
: context?.relevantImagePaths
|
|
);
|
|
|
|
return {
|
|
...baseBusiness,
|
|
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
|
|
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
|
|
relevantImagePaths,
|
|
};
|
|
}
|
|
|
|
function buildScrapeArtifacts(crawlSummary, imagePaths = []) {
|
|
return {
|
|
cdnUrls: normalizeUrlList(imagePaths),
|
|
links: Array.isArray(crawlSummary?.links) ? crawlSummary.links : [],
|
|
json: crawlSummary && typeof crawlSummary === 'object' ? crawlSummary : {},
|
|
};
|
|
}
|
|
|
|
function extractAboutSummary(crawlSummary = {}) {
|
|
return normalizeText(
|
|
crawlSummary?.aboutPage?.excerpt
|
|
|| crawlSummary?.aboutPage?.description
|
|
|| crawlSummary?.homepage?.description
|
|
|| crawlSummary?.homepage?.excerpt
|
|
|| ''
|
|
);
|
|
}
|
|
|
|
function buildJobResponse(job) {
|
|
return {
|
|
jobId: normalizeText(job?.jobId),
|
|
status: normalizeText(job?.status),
|
|
stage: normalizeText(job?.stage),
|
|
companyId: normalizeScopeId(job?.companyId),
|
|
applicationId: normalizeScopeId(job?.applicationId),
|
|
websiteUrl: normalizeWebsiteUrl(job?.websiteUrl),
|
|
progress: job?.progress && typeof job.progress === 'object' ? job.progress : {},
|
|
business: job?.business && typeof job.business === 'object' ? job.business : null,
|
|
error: job?.error && typeof job.error === 'object' ? job.error : null,
|
|
createdAt: normalizeText(job?.createdAt),
|
|
updatedAt: normalizeText(job?.updatedAt),
|
|
};
|
|
}
|
|
|
|
function wait(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function loadOnboardingJob(companyId, jobId) {
|
|
return fetchJSON(onboardingJobsRoot(companyId), jobId);
|
|
}
|
|
|
|
async function loadOnboardingJobWithRetry(companyId, jobId, options = {}) {
|
|
const attempts = Number.isFinite(options.attempts) ? options.attempts : 6;
|
|
const delayMs = Number.isFinite(options.delayMs) ? options.delayMs : 350;
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
const job = await loadOnboardingJob(companyId, jobId);
|
|
if (job) return job;
|
|
|
|
if (attempt < attempts - 1) {
|
|
await wait(delayMs);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function saveOnboardingJob(job) {
|
|
const normalizedJob = {
|
|
...job,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
await uploadJSON(onboardingJobsRoot(normalizedJob.companyId), normalizedJob.jobId, normalizedJob);
|
|
return normalizedJob;
|
|
}
|
|
|
|
async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
|
|
const merchantId = job.companyId;
|
|
const applicationId = normalizeScopeId(job.applicationId);
|
|
const websiteUrl = normalizeWebsiteUrl(job.websiteUrl);
|
|
|
|
if (applicationId) {
|
|
const existingBusiness = await findBusinessByApplicationId(merchantId, applicationId);
|
|
if (existingBusiness) {
|
|
const existingContext = await fetchJSON(businessRoot(merchantId, existingBusiness.businessId), 'context').catch(() => null);
|
|
const mergedBusiness = existingContext ? { ...existingContext } : mergeBusinessSummary(existingBusiness);
|
|
return {
|
|
business: {
|
|
...mergedBusiness,
|
|
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, mergedBusiness.relevantImagePaths),
|
|
},
|
|
reusedExistingBusiness: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const businesses = await getIndex(merchantId);
|
|
const businessId = uuidv4();
|
|
const bizRoot = businessRoot(merchantId, businessId);
|
|
const imagesFolder = `${bizRoot}/images`;
|
|
const imagePaths = [];
|
|
const imageCandidates = normalizeUrlList(brandContext?.relevantImageUrls);
|
|
|
|
for (let i = 0; i < Math.min(imageCandidates.length, 6); i += 1) {
|
|
const uploaded = await uploadImageFromUrl(imageCandidates[i], imagesFolder, `image_${i + 1}`);
|
|
if (uploaded) imagePaths.push(uploaded);
|
|
}
|
|
|
|
let domain = normalizeText(crawlSummary?.domain);
|
|
if (!domain) {
|
|
try {
|
|
domain = new URL(websiteUrl).hostname;
|
|
} catch {
|
|
domain = '';
|
|
}
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const contextJson = {
|
|
businessId,
|
|
merchantId,
|
|
companyId: merchantId,
|
|
applicationId,
|
|
domain,
|
|
brandName: brandContext.brandName || 'Unknown Brand',
|
|
tone: brandContext.tone || 'professional',
|
|
taglines: Array.isArray(brandContext.taglines) ? brandContext.taglines : [],
|
|
colors: Array.isArray(brandContext.colors) ? brandContext.colors : [],
|
|
relevantImagePaths: imagePaths,
|
|
aboutSummary: normalizeText(brandContext.aboutSummary) || extractAboutSummary(crawlSummary),
|
|
websiteUrl,
|
|
crawlStats: crawlSummary?.siteStats || {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await uploadJSON(bizRoot, 'context', contextJson);
|
|
await uploadJSON(bizRoot, 'crawl_summary', crawlSummary);
|
|
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
|
|
|
|
const previewSummary = getBusinessPreviewSummary(contextJson);
|
|
businesses.push({
|
|
businessId,
|
|
companyId: merchantId,
|
|
applicationId,
|
|
brandName: contextJson.brandName,
|
|
domain: contextJson.domain,
|
|
previewTagline: previewSummary.previewTagline,
|
|
previewImagePath: previewSummary.previewImagePath,
|
|
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
|
|
createdAt: contextJson.createdAt,
|
|
updatedAt: contextJson.updatedAt,
|
|
});
|
|
await saveIndex(merchantId, businesses);
|
|
|
|
return {
|
|
business: {
|
|
...contextJson,
|
|
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, imagePaths),
|
|
},
|
|
reusedExistingBusiness: false,
|
|
};
|
|
}
|
|
|
|
async function advanceOnboardingJob(job) {
|
|
if (!job || typeof job !== 'object') {
|
|
throw createHttpError(404, 'Onboarding job not found');
|
|
}
|
|
|
|
if (job.status === 'completed' || job.status === 'failed') {
|
|
return job;
|
|
}
|
|
|
|
try {
|
|
let pagePlan = job?.pagePlan && typeof job.pagePlan === 'object' ? job.pagePlan : null;
|
|
if (!pagePlan) {
|
|
job.status = 'crawling';
|
|
job.stage = 'crawling';
|
|
await saveOnboardingJob(job);
|
|
|
|
pagePlan = await buildBrandContextPlan(job.websiteUrl);
|
|
job.pagePlan = pagePlan;
|
|
job.progress = {
|
|
...(job.progress || {}),
|
|
pagesProcessed: 1,
|
|
pagesDiscovered: 1
|
|
+ (pagePlan.aboutUrl ? 1 : 0)
|
|
+ (Array.isArray(pagePlan.productUrls) ? pagePlan.productUrls.length : 0)
|
|
+ (pagePlan.discoveryUrl ? 1 : 0),
|
|
imageCount: Array.isArray(pagePlan.homepage?.images) ? pagePlan.homepage.images.length : 0,
|
|
linkCount: Array.isArray(pagePlan.homepage?.links) ? pagePlan.homepage.links.length : 0,
|
|
};
|
|
return saveOnboardingJob(job);
|
|
}
|
|
|
|
let crawlSummary = job?.crawlSummary && typeof job.crawlSummary === 'object' ? job.crawlSummary : null;
|
|
if (!crawlSummary) {
|
|
job.status = 'summarizing';
|
|
job.stage = 'summarizing';
|
|
await saveOnboardingJob(job);
|
|
|
|
const pageSet = await collectBrandContextPages(pagePlan);
|
|
crawlSummary = buildCrawlSummary(pageSet, job.websiteUrl);
|
|
job.crawlSummary = crawlSummary;
|
|
delete job.pagePlan;
|
|
job.progress = {
|
|
...(job.progress || {}),
|
|
pagesProcessed: crawlSummary.pageCount || 0,
|
|
pagesDiscovered: crawlSummary.pageCount || 0,
|
|
representativePages: Array.isArray(crawlSummary.representativePages) ? crawlSummary.representativePages.length : 0,
|
|
imageCount: Array.isArray(crawlSummary.topImages) ? crawlSummary.topImages.length : 0,
|
|
linkCount: Array.isArray(crawlSummary.links) ? crawlSummary.links.length : 0,
|
|
};
|
|
return saveOnboardingJob(job);
|
|
}
|
|
|
|
let brandContext = job?.brandContext && typeof job.brandContext === 'object' ? job.brandContext : null;
|
|
if (!brandContext) {
|
|
job.status = 'parsing_brand';
|
|
job.stage = 'parsing_brand';
|
|
await saveOnboardingJob(job);
|
|
brandContext = await parseBrandContext(crawlSummary);
|
|
job.brandContext = brandContext;
|
|
job.status = 'finalizing_business';
|
|
job.stage = 'finalizing_business';
|
|
return saveOnboardingJob(job);
|
|
}
|
|
|
|
job.status = 'finalizing_business';
|
|
job.stage = 'finalizing_business';
|
|
await saveOnboardingJob(job);
|
|
|
|
const result = await finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext);
|
|
job.status = 'completed';
|
|
job.stage = 'completed';
|
|
job.business = result.business;
|
|
job.error = null;
|
|
return saveOnboardingJob(job);
|
|
} catch (error) {
|
|
job.status = 'failed';
|
|
job.stage = 'failed';
|
|
job.error = {
|
|
message: error.message || 'Business onboarding failed',
|
|
};
|
|
return saveOnboardingJob(job);
|
|
}
|
|
}
|
|
|
|
const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']);
|
|
const EVENT_TEMPLATE_FALLBACKS = {
|
|
bag_confirmed: ['confirmed'],
|
|
bag_packed: ['pack'],
|
|
bag_not_confirmed: ['cancelled'],
|
|
cancelled_customer: ['cancelled'],
|
|
cancelled_fynd: ['cancelled'],
|
|
cancelled_at_dp: ['cancelled'],
|
|
cancelled_failed_at_dp: ['cancelled'],
|
|
};
|
|
|
|
function mergeDefaultEvents(data = {}) {
|
|
const existingEvents = Array.isArray(data?.events) ? data.events : [];
|
|
const defaultEventBySlug = new Map(DEFAULT_EVENTS.map((event) => [event.slug, event]));
|
|
const existingEventBySlug = new Map(
|
|
existingEvents
|
|
.map((event) => ({ ...event, slug: normalizeText(event?.slug) }))
|
|
.filter((event) => event.slug)
|
|
.map((event) => [event.slug, event])
|
|
);
|
|
|
|
const mergedDefaults = DEFAULT_EVENTS.map((event) => {
|
|
const existing = existingEventBySlug.get(event.slug);
|
|
return existing
|
|
? { ...event, ...existing, slug: event.slug, label: existing.label || event.label, isDefault: true }
|
|
: { ...event };
|
|
});
|
|
|
|
const customEvents = existingEvents
|
|
.map((event) => ({ ...event, slug: normalizeText(event?.slug), label: normalizeText(event?.label) }))
|
|
.filter((event) => event.slug && !defaultEventBySlug.has(event.slug) && !LEGACY_DEFAULT_EVENT_SLUGS.has(event.slug))
|
|
.map((event) => ({ ...event, isDefault: false }));
|
|
|
|
return { events: [...mergedDefaults, ...customEvents] };
|
|
}
|
|
|
|
function getShipmentPayload(body) {
|
|
return body?.payload?.shipment && typeof body.payload.shipment === 'object'
|
|
? body.payload.shipment
|
|
: null;
|
|
}
|
|
|
|
function getShipmentBrandName(body) {
|
|
const shipment = getShipmentPayload(body);
|
|
return firstNonEmptyText(
|
|
shipment?.bags?.[0]?.brand?.brand_name,
|
|
shipment?.bags?.[0]?.item?.attributes?.brand_name,
|
|
shipment?.affiliate_details?.company_affiliate_tag
|
|
);
|
|
}
|
|
|
|
function getShipmentApplicationId(req) {
|
|
const shipment = getShipmentPayload(req.body);
|
|
return normalizeScopeId(
|
|
getApplicationId(req)
|
|
|| shipment?.application_id
|
|
|| shipment?.affiliate_details?.affiliate_id
|
|
|| shipment?.affiliate_details?.id
|
|
|| shipment?.affiliate_details?.config?.id
|
|
);
|
|
}
|
|
|
|
function getShipmentEventKey(body) {
|
|
const shipment = getShipmentPayload(body);
|
|
return firstNonEmptyText(
|
|
shipment?.status,
|
|
shipment?.shipment_status?.status,
|
|
shipment?.shipment_status?.current_shipment_status
|
|
);
|
|
}
|
|
|
|
function getShipmentToNumber(body) {
|
|
const shipment = getShipmentPayload(body);
|
|
return firstNonEmptyText(
|
|
shipment?.user?.mobile,
|
|
shipment?.delivery_address?.phone,
|
|
shipment?.billing_address?.phone
|
|
);
|
|
}
|
|
|
|
const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
|
|
|
|
function normalizeRenderableValue(value) {
|
|
return normalizeScalarText(value).replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function splitFullName(value) {
|
|
const fullName = normalizeRenderableValue(value);
|
|
if (!fullName) return { firstName: '', lastName: '', fullName: '' };
|
|
|
|
const parts = fullName.split(/\s+/).filter(Boolean);
|
|
return {
|
|
firstName: parts[0] || '',
|
|
lastName: parts.length > 1 ? parts.slice(1).join(' ') : '',
|
|
fullName,
|
|
};
|
|
}
|
|
|
|
function getEventLookupCandidates(eventSlug) {
|
|
const normalizedEventSlug = slugify(eventSlug || '');
|
|
const candidates = [
|
|
normalizedEventSlug,
|
|
...(EVENT_TEMPLATE_FALLBACKS[normalizedEventSlug] || []),
|
|
];
|
|
|
|
return [...new Set(candidates.filter(Boolean))];
|
|
}
|
|
|
|
async function resolveWhitelistedTemplate(folder, eventSlug) {
|
|
for (const candidate of getEventLookupCandidates(eventSlug)) {
|
|
const template = await fetchJSON(folder, candidate);
|
|
if (template && template.status === 'whitelisted' && normalizeText(template.selectedTemplate)) {
|
|
return { template, matchedSlug: candidate };
|
|
}
|
|
}
|
|
|
|
return { template: null, matchedSlug: '' };
|
|
}
|
|
|
|
function toCamelCase(text) {
|
|
return String(text || '')
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.map((part, index) => {
|
|
const lower = part.toLowerCase();
|
|
return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
function setValueIndexEntry(valueIndex, key, value) {
|
|
if (!key || valueIndex.has(key)) return;
|
|
valueIndex.set(key, value);
|
|
}
|
|
|
|
function indexShipmentValues(value, pathParts = [], valueIndex = new Map()) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach((entry) => indexShipmentValues(entry, pathParts, valueIndex));
|
|
return valueIndex;
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
Object.entries(value).forEach(([key, entry]) => {
|
|
indexShipmentValues(entry, [...pathParts, key], valueIndex);
|
|
});
|
|
return valueIndex;
|
|
}
|
|
|
|
const normalizedValue = normalizeRenderableValue(value);
|
|
if (!normalizedValue || pathParts.length === 0) return valueIndex;
|
|
|
|
const leafKey = toCamelCase(pathParts[pathParts.length - 1]);
|
|
const fullKey = toCamelCase(pathParts.join(' '));
|
|
setValueIndexEntry(valueIndex, leafKey, normalizedValue);
|
|
setValueIndexEntry(valueIndex, fullKey, normalizedValue);
|
|
|
|
return valueIndex;
|
|
}
|
|
|
|
function buildShipmentValueIndex(shipment) {
|
|
const valueIndex = indexShipmentValues(shipment);
|
|
const firstBag = shipment?.bags?.[0] || {};
|
|
const customerName = splitFullName(
|
|
firstNonEmptyText(
|
|
shipment?.user?.first_name && shipment?.user?.last_name
|
|
? `${shipment.user.first_name} ${shipment.user.last_name}`
|
|
: '',
|
|
shipment?.delivery_address?.name,
|
|
shipment?.delivery_address?.contact_person,
|
|
shipment?.billing_address?.name,
|
|
shipment?.billing_address?.contact_person
|
|
)
|
|
);
|
|
const primaryTrackingUrl = firstNonEmptyText(
|
|
shipment?.delivery_partner_details?.track_url,
|
|
shipment?.meta?.tracking_url,
|
|
firstBag?.meta?.tracking_url,
|
|
shipment?.affiliate_details?.shipment_meta?.tracking_url,
|
|
shipment?.article_details?.dp_details?.track_url
|
|
);
|
|
const primaryAwbNumber = firstNonEmptyText(
|
|
shipment?.delivery_partner_details?.awb_no,
|
|
shipment?.meta?.awb_number,
|
|
shipment?.article_details?.dp_details?.awb_no,
|
|
firstBag?.meta?.awb_number,
|
|
firstBag?.current_operational_status?.delivery_awb_number
|
|
);
|
|
const primaryCourierName = firstNonEmptyText(
|
|
shipment?.delivery_partner_details?.display_name,
|
|
shipment?.delivery_partner_details?.name,
|
|
shipment?.meta?.courier_partner_name,
|
|
shipment?.meta?.dp_name,
|
|
firstBag?.meta?.dp_name,
|
|
shipment?.affiliate_details?.shipment_meta?.courier_partner_name
|
|
);
|
|
const brandName = firstNonEmptyText(
|
|
shipment?.bags?.[0]?.brand?.brand_name,
|
|
shipment?.bags?.[0]?.item?.attributes?.brand_name,
|
|
shipment?.affiliate_details?.company_affiliate_tag
|
|
);
|
|
const toNumber = firstNonEmptyText(
|
|
shipment?.user?.mobile,
|
|
shipment?.delivery_address?.phone,
|
|
shipment?.billing_address?.phone
|
|
);
|
|
const emailAddress = firstNonEmptyText(
|
|
shipment?.user?.email,
|
|
shipment?.delivery_address?.email,
|
|
shipment?.billing_address?.email
|
|
);
|
|
const eventKey = firstNonEmptyText(
|
|
shipment?.status,
|
|
shipment?.shipment_status?.status,
|
|
shipment?.shipment_status?.current_shipment_status
|
|
);
|
|
const eventDisplayName = firstNonEmptyText(
|
|
shipment?.shipment_status?.display_name,
|
|
shipment?.shipment_status?.current_shipment_status
|
|
);
|
|
const shipmentId = firstNonEmptyText(
|
|
shipment?.shipment_id,
|
|
shipment?.shipment_status?.shipment_id
|
|
);
|
|
const resolvedFullName = firstNonEmptyText(
|
|
shipment?.user?.first_name || shipment?.user?.last_name
|
|
? `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
|
|
: '',
|
|
customerName.fullName
|
|
);
|
|
const resolvedFirstName = firstNonEmptyText(shipment?.user?.first_name, customerName.firstName);
|
|
const resolvedLastName = firstNonEmptyText(shipment?.user?.last_name, customerName.lastName);
|
|
|
|
setValueIndexEntry(valueIndex, 'firstName', resolvedFirstName);
|
|
setValueIndexEntry(valueIndex, 'lastName', resolvedLastName);
|
|
setValueIndexEntry(valueIndex, 'fullName', resolvedFullName);
|
|
setValueIndexEntry(valueIndex, 'customerFirstName', resolvedFirstName);
|
|
setValueIndexEntry(valueIndex, 'customerLastName', resolvedLastName);
|
|
setValueIndexEntry(valueIndex, 'customerName', resolvedFullName);
|
|
setValueIndexEntry(valueIndex, 'phone', toNumber);
|
|
setValueIndexEntry(valueIndex, 'mobile', toNumber);
|
|
setValueIndexEntry(valueIndex, 'toNumber', toNumber);
|
|
setValueIndexEntry(valueIndex, 'customerPhone', toNumber);
|
|
setValueIndexEntry(valueIndex, 'customerMobile', toNumber);
|
|
setValueIndexEntry(valueIndex, 'email', emailAddress);
|
|
setValueIndexEntry(valueIndex, 'customerEmail', emailAddress);
|
|
setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id));
|
|
setValueIndexEntry(valueIndex, 'orderNumber', normalizeRenderableValue(shipment?.order_id));
|
|
setValueIndexEntry(valueIndex, 'shipmentId', shipmentId);
|
|
setValueIndexEntry(valueIndex, 'event', eventKey);
|
|
setValueIndexEntry(valueIndex, 'status', eventKey);
|
|
setValueIndexEntry(valueIndex, 'eventDisplayName', eventDisplayName);
|
|
setValueIndexEntry(valueIndex, 'displayName', eventDisplayName);
|
|
setValueIndexEntry(valueIndex, 'brandName', brandName);
|
|
setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
|
|
setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
|
|
setValueIndexEntry(valueIndex, 'trackingLink', primaryTrackingUrl);
|
|
setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
|
|
setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
|
|
setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
|
|
setValueIndexEntry(valueIndex, 'awb', primaryAwbNumber);
|
|
setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
|
|
setValueIndexEntry(valueIndex, 'courierName', primaryCourierName);
|
|
setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName);
|
|
|
|
return valueIndex;
|
|
}
|
|
|
|
function validateRenderedPlaceholderValue(token, value, fieldName) {
|
|
if (!value) {
|
|
throw createHttpError(422, `No shipment value found for placeholder field "${fieldName}"`);
|
|
}
|
|
|
|
if (token === '{#numeric#}' && !/^\d+$/.test(value)) {
|
|
throw createHttpError(422, `Field "${fieldName}" resolved to a non-numeric value for ${token}`);
|
|
}
|
|
|
|
if (token === '{#url#}' && !/^https?:\/\//i.test(value)) {
|
|
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid URL for ${token}`);
|
|
}
|
|
|
|
if (token === '{#urlott#}' && !/^https?:\/\//i.test(value)) {
|
|
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid OTT URL for ${token}`);
|
|
}
|
|
|
|
if (token === '{#cbn#}' && !/^\+?[0-9][0-9\s-]{5,}$/.test(value)) {
|
|
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid callback number for ${token}`);
|
|
}
|
|
|
|
if (token === '{#email#}' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid email address for ${token}`);
|
|
}
|
|
|
|
if (token === '{#alphanumeric#}' && !/^[A-Za-z0-9]+$/.test(value)) {
|
|
throw createHttpError(422, `Field "${fieldName}" resolved to a non-alphanumeric value for ${token}`);
|
|
}
|
|
}
|
|
|
|
function renderShipmentTemplate(template, shipment, variableMap = {}) {
|
|
const normalizedTemplate = normalizeText(template);
|
|
const placeholderMatches = normalizedTemplate.match(DLT_PLACEHOLDER_REGEX) || [];
|
|
|
|
if (placeholderMatches.length === 0) {
|
|
return normalizedTemplate;
|
|
}
|
|
|
|
if (!variableMap || typeof variableMap !== 'object' || Object.keys(variableMap).length === 0) {
|
|
throw createHttpError(422, 'Template has placeholders but no variableMap was found on the stored template');
|
|
}
|
|
|
|
const shipmentValueIndex = buildShipmentValueIndex(shipment);
|
|
let placeholderIndex = 0;
|
|
|
|
return normalizedTemplate.replace(DLT_PLACEHOLDER_REGEX, (token) => {
|
|
const mappingKey = `${token}[${placeholderIndex}]`;
|
|
const mappedFieldName = normalizeText(variableMap[mappingKey]);
|
|
|
|
if (!mappedFieldName) {
|
|
throw createHttpError(422, `No variable mapping found for placeholder ${mappingKey}`, {
|
|
details: { mappingKey, variableMap },
|
|
});
|
|
}
|
|
|
|
const resolvedValue = shipmentValueIndex.get(toCamelCase(mappedFieldName)) || '';
|
|
validateRenderedPlaceholderValue(token, resolvedValue, mappedFieldName);
|
|
placeholderIndex += 1;
|
|
return resolvedValue;
|
|
});
|
|
}
|
|
|
|
async function sendResolveTemplateWorkflow({ content, toNumber }) {
|
|
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
|
|
if (!workflowUrl) {
|
|
throw createHttpError(500, 'WORKFLOW_URL_RESOLVE_TEMPLATE is not configured');
|
|
}
|
|
|
|
const response = await axios.post(
|
|
workflowUrl,
|
|
{ content, toNumber },
|
|
{
|
|
timeout: 30000,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
validateStatus: () => true,
|
|
}
|
|
);
|
|
|
|
if (response.status < 200 || response.status >= 300) {
|
|
throw createHttpError(
|
|
502,
|
|
`Resolve-template workflow failed with status ${response.status}`,
|
|
{ details: response.data }
|
|
);
|
|
}
|
|
|
|
return {
|
|
statusCode: response.status,
|
|
response: response.data,
|
|
};
|
|
}
|
|
|
|
function buildMissingField(field, error, acceptedPaths = []) {
|
|
return {
|
|
field,
|
|
error,
|
|
details: acceptedPaths.length > 0 ? { acceptedPaths } : undefined,
|
|
};
|
|
}
|
|
|
|
function buildResolveTemplateContext(req) {
|
|
const companyId = getCompanyId(req);
|
|
const shipment = getShipmentPayload(req.body);
|
|
const applicationId = getShipmentApplicationId(req);
|
|
const event = getShipmentEventKey(req.body);
|
|
const toNumber = getShipmentToNumber(req.body);
|
|
const missingFields = [];
|
|
|
|
if (!companyId) {
|
|
missingFields.push(buildMissingField('companyId', 'companyId is required'));
|
|
}
|
|
|
|
if (!shipment) {
|
|
missingFields.push(buildMissingField(
|
|
'shipment',
|
|
'payload.shipment is required',
|
|
['payload.shipment']
|
|
));
|
|
}
|
|
|
|
if (!applicationId) {
|
|
missingFields.push(buildMissingField(
|
|
'applicationId',
|
|
'A shipment applicationId is required',
|
|
[
|
|
'application_id',
|
|
'payload.shipment.application_id',
|
|
'payload.shipment.affiliate_details.affiliate_id',
|
|
'payload.shipment.affiliate_details.id',
|
|
'payload.shipment.affiliate_details.config.id',
|
|
]
|
|
));
|
|
}
|
|
|
|
if (!event) {
|
|
missingFields.push(buildMissingField(
|
|
'event',
|
|
'A shipment event status is required',
|
|
[
|
|
'payload.shipment.status',
|
|
'payload.shipment.shipment_status.status',
|
|
'payload.shipment.shipment_status.current_shipment_status',
|
|
]
|
|
));
|
|
}
|
|
|
|
if (!toNumber) {
|
|
missingFields.push(buildMissingField(
|
|
'toNumber',
|
|
'A shipment phone number is required',
|
|
[
|
|
'payload.shipment.user.mobile',
|
|
'payload.shipment.delivery_address.phone',
|
|
'payload.shipment.billing_address.phone',
|
|
]
|
|
));
|
|
}
|
|
|
|
return {
|
|
companyId,
|
|
shipment,
|
|
applicationId,
|
|
event,
|
|
toNumber,
|
|
missingFields,
|
|
brandName: getShipmentBrandName(req.body),
|
|
};
|
|
}
|
|
|
|
function getResolveTemplateMissingError(context) {
|
|
const firstMissingField = context.missingFields[0];
|
|
if (!firstMissingField) return null;
|
|
|
|
return createHttpError(400, firstMissingField.error, {
|
|
details: firstMissingField.details,
|
|
});
|
|
}
|
|
|
|
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 || {}
|
|
);
|
|
|
|
const workflowResult = await sendResolveTemplateWorkflow({
|
|
content: resolvedTemplate,
|
|
toNumber: context.toNumber,
|
|
});
|
|
|
|
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) {
|
|
if (!input || typeof input !== 'object') return null;
|
|
|
|
let hasField = false;
|
|
const patch = {};
|
|
for (const field of PROVIDER_FIELDS) {
|
|
if (!Object.prototype.hasOwnProperty.call(input, field)) continue;
|
|
hasField = true;
|
|
patch[field] = field === 'senderId'
|
|
? normalizeSenderId(input[field])
|
|
: normalizeText(input[field]);
|
|
}
|
|
|
|
return hasField ? patch : null;
|
|
}
|
|
|
|
function mergeProviderState(extractedProvider, currentProvider, providerPatch, options = {}) {
|
|
const { preserveCurrent = true, updatedAt = new Date().toISOString() } = options;
|
|
let merged = { ...normalizeProvider(extractedProvider, updatedAt), updatedAt };
|
|
|
|
if (preserveCurrent && currentProvider) {
|
|
const normalizedCurrent = normalizeProvider(currentProvider, updatedAt);
|
|
for (const field of PROVIDER_FIELDS) {
|
|
if (normalizedCurrent[field]) {
|
|
merged[field] = normalizedCurrent[field];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (providerPatch) {
|
|
for (const field of PROVIDER_FIELDS) {
|
|
if (Object.prototype.hasOwnProperty.call(providerPatch, field)) {
|
|
merged[field] = providerPatch[field];
|
|
}
|
|
}
|
|
}
|
|
|
|
merged.updatedAt = updatedAt;
|
|
return merged;
|
|
}
|
|
|
|
function hydrateProfile(profile = {}) {
|
|
return {
|
|
...profile,
|
|
provider: normalizeProvider(profile.provider, profile.updatedAt),
|
|
};
|
|
}
|
|
|
|
function hydrateProfileData(profileData) {
|
|
const profiles = Array.isArray(profileData?.profiles)
|
|
? profileData.profiles.map(hydrateProfile)
|
|
: [];
|
|
return { profiles };
|
|
}
|
|
|
|
async function getProfileState(bizRoot) {
|
|
const [rawProfileData, activeRec] = await Promise.all([
|
|
fetchJSON(bizRoot, 'global_sms_profiles'),
|
|
fetchJSON(bizRoot, 'active_curl_profile'),
|
|
]);
|
|
const profileData = hydrateProfileData(rawProfileData);
|
|
const activeProfileId = activeRec?.profileId || (profileData.profiles[0]?.id ?? null);
|
|
const activeProfile = profileData.profiles.find(p => p.id === activeProfileId) || profileData.profiles[0] || null;
|
|
|
|
return { profileData, activeProfile, activeProfileId };
|
|
}
|
|
|
|
async function getActiveProfile(bizRoot) {
|
|
try {
|
|
const { activeProfile } = await getProfileState(bizRoot);
|
|
return activeProfile;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getBoundProfile(bizRoot, curlProfileId) {
|
|
if (!curlProfileId) {
|
|
throw createHttpError(
|
|
422,
|
|
'This template is not bound to a cURL profile. Re-select the template from Events before continuing.',
|
|
{ code: 'MISSING_BOUND_PROFILE' }
|
|
);
|
|
}
|
|
|
|
const { profileData } = await getProfileState(bizRoot);
|
|
const boundProfile = profileData.profiles.find(profile => profile.id === curlProfileId);
|
|
if (!boundProfile) {
|
|
throw createHttpError(
|
|
422,
|
|
'The cURL profile bound to this template no longer exists. Re-select the template from Events before continuing.',
|
|
{ code: 'BOUND_PROFILE_NOT_FOUND' }
|
|
);
|
|
}
|
|
|
|
return boundProfile;
|
|
}
|
|
|
|
async function validateCurlAndExtractProvider(rawCurl) {
|
|
try {
|
|
const validation = await validateCurlFields(rawCurl);
|
|
if (!validation.isValidCurl) {
|
|
throw createHttpError(422, validation.reason || 'The provided cURL is invalid');
|
|
}
|
|
|
|
const provider = normalizeProvider(validation.provider);
|
|
const senderIdError = validateSenderId(provider.senderId);
|
|
if (senderIdError) {
|
|
throw createHttpError(422, senderIdError);
|
|
}
|
|
|
|
return provider;
|
|
} catch (err) {
|
|
if (err.status) throw err;
|
|
throw createHttpError(502, `cURL validation failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function updateProfileProvider(profile, providerPatch, rawCurlOverride) {
|
|
const effectiveCurl = normalizeText(rawCurlOverride !== undefined ? rawCurlOverride : profile.rawCurl);
|
|
const extractedProvider = await validateCurlAndExtractProvider(effectiveCurl);
|
|
const preserveCurrent = rawCurlOverride === undefined;
|
|
const updatedAt = new Date().toISOString();
|
|
|
|
profile.provider = mergeProviderState(
|
|
extractedProvider,
|
|
profile.provider,
|
|
providerPatch,
|
|
{ preserveCurrent, updatedAt }
|
|
);
|
|
profile.updatedAt = updatedAt;
|
|
|
|
return profile;
|
|
}
|
|
|
|
function getMissingMandatoryProviderFields(provider = {}) {
|
|
const normalized = normalizeProvider(provider);
|
|
const missing = [];
|
|
if (!normalized.providerName) missing.push('providerName');
|
|
if (!normalized.senderId) missing.push('senderId');
|
|
if (!normalized.dltEntityId) missing.push('dltEntityId');
|
|
return missing;
|
|
}
|
|
|
|
// ─── Business CRUD ────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/businesses
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const merchantId = getCompanyId(req);
|
|
const businesses = await getIndex(merchantId);
|
|
const hydratedBusinesses = await Promise.all(
|
|
businesses.map(async (business) => {
|
|
const hasPreviewSummary = normalizeText(business.previewTagline) || normalizeText(business.previewImagePath);
|
|
const hasRelevantImagePaths = Array.isArray(business.relevantImagePaths) && business.relevantImagePaths.length > 0;
|
|
|
|
if (hasPreviewSummary && hasRelevantImagePaths) {
|
|
return mergeBusinessSummary(business);
|
|
}
|
|
|
|
const context = await fetchJSON(businessRoot(merchantId, business.businessId), 'context').catch(() => null);
|
|
return mergeBusinessSummary(business, context);
|
|
})
|
|
);
|
|
|
|
res.json({ businesses: hydratedBusinesses });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses — start async business onboarding from websiteUrl with optional applicationId
|
|
router.post('/', async (req, res) => {
|
|
try {
|
|
const merchantId = getCompanyId(req);
|
|
if (!merchantId) {
|
|
throw createHttpError(400, 'companyId is required');
|
|
}
|
|
|
|
const applicationId = normalizeScopeId(
|
|
req.body?.applicationId
|
|
|| req.body?.application_id
|
|
|| req.body?.salesChannelId
|
|
|| getApplicationId(req)
|
|
);
|
|
const websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl);
|
|
|
|
if (!websiteUrl) {
|
|
throw createHttpError(
|
|
400,
|
|
'websiteUrl is required',
|
|
{ code: 'MISSING_WEBSITE_URL' }
|
|
);
|
|
}
|
|
|
|
const businesses = await getIndex(merchantId);
|
|
if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) {
|
|
return res.status(409).json({ error: 'A business is already configured for this applicationId' });
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const job = await saveOnboardingJob({
|
|
jobId: uuidv4(),
|
|
companyId: merchantId,
|
|
applicationId,
|
|
websiteUrl,
|
|
status: 'queued',
|
|
stage: 'queued',
|
|
progress: {
|
|
pagesProcessed: 0,
|
|
pagesDiscovered: 0,
|
|
representativePages: 0,
|
|
imageCount: 0,
|
|
linkCount: 0,
|
|
},
|
|
crawlSummary: null,
|
|
brandContext: null,
|
|
business: null,
|
|
error: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
res.status(202).json(buildJobResponse(job));
|
|
} catch (err) {
|
|
console.error('Start business onboarding error:', err.message);
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// GET /api/businesses/jobs/:jobId
|
|
router.get('/jobs/:jobId', async (req, res) => {
|
|
try {
|
|
const companyId = getCompanyId(req);
|
|
if (!companyId) {
|
|
throw createHttpError(400, 'companyId is required');
|
|
}
|
|
|
|
const job = await loadOnboardingJobWithRetry(companyId, req.params.jobId);
|
|
if (!job) {
|
|
throw createHttpError(404, 'Onboarding job not found');
|
|
}
|
|
|
|
const updatedJob = await advanceOnboardingJob(job);
|
|
res.json(buildJobResponse(updatedJob));
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// GET /api/businesses/:businessId
|
|
router.get('/:businessId', async (req, res) => {
|
|
try {
|
|
const { businessId } = req.params;
|
|
const merchantId = getCompanyId(req);
|
|
const root = businessRoot(merchantId, businessId);
|
|
const [context, crawlSummary] = await Promise.all([
|
|
fetchJSON(root, 'context'),
|
|
fetchJSON(root, 'crawl_summary').catch(() => null),
|
|
]);
|
|
|
|
if (!context) return res.status(404).json({ error: 'Business not found' });
|
|
|
|
if (!crawlSummary) {
|
|
return res.json(context);
|
|
}
|
|
|
|
res.json({
|
|
...context,
|
|
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, context.relevantImagePaths),
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/businesses/:businessId
|
|
router.delete('/:businessId', async (req, res) => {
|
|
try {
|
|
const merchantId = getCompanyId(req);
|
|
const { businessId } = req.params;
|
|
|
|
await deleteBusinessFiles(merchantId, businessId);
|
|
|
|
const businesses = await getIndex(merchantId);
|
|
const updated = businesses.filter(b => b.businessId !== businessId);
|
|
await saveIndex(merchantId, updated);
|
|
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('Delete business error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/resolve-template
|
|
router.post('/resolve-template', async (req, res) => {
|
|
try {
|
|
console.log('[ResolveTemplate] Incoming payload:', JSON.stringify(req.body, null, 2));
|
|
|
|
const context = buildResolveTemplateContext(req);
|
|
const missingError = getResolveTemplateMissingError(context);
|
|
if (missingError) throw missingError;
|
|
|
|
const result = await resolveTemplateRequest(context);
|
|
res.json({
|
|
success: true,
|
|
...result,
|
|
});
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// ─── Providers ────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/businesses/:businessId/providers
|
|
router.get('/:businessId/providers', async (req, res) => {
|
|
try {
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const activeProfile = await getActiveProfile(bizRoot);
|
|
if (!activeProfile) {
|
|
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
|
|
}
|
|
|
|
res.json(activeProfile.provider || {});
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/providers
|
|
router.post('/:businessId/providers', async (req, res) => {
|
|
try {
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const providerPatch = getProviderPatch(req.body);
|
|
const senderIdError = validateSenderId(providerPatch?.senderId || '');
|
|
if (senderIdError) {
|
|
return res.status(400).json({ error: senderIdError });
|
|
}
|
|
|
|
const { profileData, activeProfile, activeProfileId } = await getProfileState(bizRoot);
|
|
if (!activeProfile || !activeProfileId) {
|
|
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
|
|
}
|
|
|
|
const profile = profileData.profiles.find(item => item.id === activeProfileId);
|
|
await updateProfileProvider(profile, providerPatch);
|
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
|
|
|
res.json(profile.provider);
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// ─── Global SMS cURL (Compatibility layer) ───────────────────────────────────
|
|
// These routes delegate to the active/default profile model.
|
|
|
|
// GET /api/businesses/:businessId/global-sms
|
|
router.get('/:businessId/global-sms', async (req, res) => {
|
|
try {
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const activeProfile = await getActiveProfile(bizRoot);
|
|
res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {});
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/global-sms
|
|
// Compat: creates/updates a default profile and sets it active.
|
|
router.post('/:businessId/global-sms', async (req, res) => {
|
|
try {
|
|
const { rawCurl } = req.body;
|
|
if (!normalizeText(rawCurl)) {
|
|
return res.status(400).json({ error: 'rawCurl is required' });
|
|
}
|
|
if (!isValidCurlCommand(rawCurl)) {
|
|
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
|
}
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const { profileData } = await getProfileState(bizRoot);
|
|
const now = new Date().toISOString();
|
|
const normalizedCurl = normalizeText(rawCurl);
|
|
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
|
|
|
|
// Find or create the default profile
|
|
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
|
|
if (defaultProfile) {
|
|
defaultProfile.rawCurl = normalizedCurl;
|
|
defaultProfile.provider = extractedProvider;
|
|
defaultProfile.updatedAt = now;
|
|
} else {
|
|
defaultProfile = {
|
|
id: uuidv4(),
|
|
name: 'Default',
|
|
rawCurl: normalizedCurl,
|
|
isDefault: true,
|
|
provider: extractedProvider,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
profileData.profiles.push(defaultProfile);
|
|
}
|
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
|
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now });
|
|
|
|
res.json({ rawCurl: normalizedCurl, updatedAt: now });
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// ─── cURL Profiles CRUD ────────────────────────────────────────────────────────
|
|
|
|
// GET /api/businesses/:businessId/global-sms/profiles
|
|
router.get('/:businessId/global-sms/profiles', async (req, res) => {
|
|
try {
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const { profileData, activeProfileId } = await getProfileState(bizRoot);
|
|
const profiles = profileData.profiles || [];
|
|
res.json({ profiles, activeProfileId });
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/global-sms/profiles
|
|
router.post('/:businessId/global-sms/profiles', async (req, res) => {
|
|
try {
|
|
const { name, rawCurl, setActive } = req.body;
|
|
if (!normalizeText(name)) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
if (!normalizeText(rawCurl)) {
|
|
return res.status(400).json({ error: 'rawCurl is required' });
|
|
}
|
|
if (!isValidCurlCommand(rawCurl)) {
|
|
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
|
}
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const { profileData } = await getProfileState(bizRoot);
|
|
const now = new Date().toISOString();
|
|
const normalizedCurl = normalizeText(rawCurl);
|
|
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
|
|
|
|
const newProfile = {
|
|
id: uuidv4(),
|
|
name: normalizeText(name),
|
|
rawCurl: normalizedCurl,
|
|
isDefault: false,
|
|
provider: extractedProvider,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
profileData.profiles.push(newProfile);
|
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
|
|
|
// Activate this profile if requested or if it is the first one
|
|
if (setActive || profileData.profiles.length === 1) {
|
|
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: newProfile.id, updatedAt: now });
|
|
}
|
|
|
|
res.json(newProfile);
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// PATCH /api/businesses/:businessId/global-sms/profiles/:profileId
|
|
router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) => {
|
|
try {
|
|
const { businessId, profileId } = req.params;
|
|
const { name, rawCurl } = req.body;
|
|
const providerPatch = getProviderPatch(req.body.provider || req.body);
|
|
|
|
if (name !== undefined && !normalizeText(name)) {
|
|
return res.status(400).json({ error: 'name is required' });
|
|
}
|
|
if (rawCurl !== undefined && !normalizeText(rawCurl)) {
|
|
return res.status(400).json({ error: 'rawCurl is required' });
|
|
}
|
|
if (rawCurl !== undefined && !isValidCurlCommand(rawCurl)) {
|
|
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
|
}
|
|
const senderIdError = validateSenderId(providerPatch?.senderId || '');
|
|
if (senderIdError) {
|
|
return res.status(400).json({ error: senderIdError });
|
|
}
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const { profileData } = await getProfileState(bizRoot);
|
|
const profile = profileData.profiles.find(p => p.id === profileId);
|
|
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
|
|
|
if (name !== undefined) profile.name = normalizeText(name);
|
|
if (rawCurl !== undefined) profile.rawCurl = normalizeText(rawCurl);
|
|
await updateProfileProvider(profile, providerPatch, rawCurl !== undefined ? profile.rawCurl : undefined);
|
|
|
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
|
res.json(profile);
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// DELETE /api/businesses/:businessId/global-sms/profiles/:profileId
|
|
router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => {
|
|
try {
|
|
const { businessId, profileId } = req.params;
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const { profileData } = await getProfileState(bizRoot);
|
|
|
|
const idx = profileData.profiles.findIndex(p => p.id === profileId);
|
|
if (idx === -1) return res.status(404).json({ error: 'Profile not found' });
|
|
if (profileData.profiles.length === 1) {
|
|
return res.status(400).json({ error: 'Cannot delete the last cURL profile' });
|
|
}
|
|
|
|
profileData.profiles.splice(idx, 1);
|
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
|
|
|
// If deleted profile was active, switch to first remaining
|
|
const activeRec = await fetchJSON(bizRoot, 'active_curl_profile');
|
|
if (activeRec?.profileId === profileId) {
|
|
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: profileData.profiles[0].id, updatedAt: new Date().toISOString() });
|
|
}
|
|
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/global-sms/profiles/:profileId/activate
|
|
router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, res) => {
|
|
try {
|
|
const { businessId, profileId } = req.params;
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const { profileData } = await getProfileState(bizRoot);
|
|
const profile = profileData.profiles.find(p => p.id === profileId);
|
|
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
|
|
|
await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() });
|
|
res.json({ activeProfileId: profileId });
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// GET /api/businesses/:businessId/global-sms/active
|
|
router.get('/:businessId/global-sms/active', async (req, res) => {
|
|
try {
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const { activeProfile, activeProfileId } = await getProfileState(bizRoot);
|
|
res.json({ activeProfile, activeProfileId });
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// ─── Events ───────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/businesses/:businessId/events
|
|
router.get('/:businessId/events', async (req, res) => {
|
|
try {
|
|
const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events');
|
|
res.json(mergeDefaultEvents(data || {}));
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/events — add custom event
|
|
router.post('/:businessId/events', async (req, res) => {
|
|
try {
|
|
const { label } = req.body;
|
|
if (!label) return res.status(400).json({ error: 'label is required' });
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const data = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
|
|
const slug = slugify(label);
|
|
|
|
if (data.events.some(e => e.slug === slug)) {
|
|
return res.status(409).json({ error: 'An event with this name already exists' });
|
|
}
|
|
|
|
const newEvent = { slug, label, isDefault: false };
|
|
data.events.push(newEvent);
|
|
await uploadJSON(bizRoot, 'events', data);
|
|
res.json(newEvent);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/businesses/:businessId/events/:slug
|
|
router.delete('/:businessId/events/:slug', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const data = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
|
|
const event = data.events.find(e => e.slug === slug);
|
|
|
|
if (!event) return res.status(404).json({ error: 'Event not found' });
|
|
if (event.isDefault) return res.status(403).json({ error: 'Cannot delete a default event' });
|
|
|
|
data.events = data.events.filter(e => e.slug !== slug);
|
|
await uploadJSON(bizRoot, 'events', data);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/events/:slug/generate
|
|
router.post('/:businessId/events/:slug/generate', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const templateFolder = `${bizRoot}/templates`;
|
|
|
|
const context = await fetchJSON(bizRoot, 'context');
|
|
if (!context) return res.status(400).json({ error: 'Business context not found.' });
|
|
|
|
const activeProfile = await getActiveProfile(bizRoot);
|
|
if (!activeProfile?.rawCurl) {
|
|
return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' });
|
|
}
|
|
|
|
const eventsData = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
|
|
const event = eventsData.events.find(e => e.slug === slug);
|
|
if (!event) return res.status(404).json({ error: 'Event not found' });
|
|
|
|
const existingTemplate = await fetchJSON(templateFolder, slug).catch(() => null);
|
|
const preservedSelectedTemplate = normalizeText(existingTemplate?.selectedTemplate);
|
|
const preservedStatus = normalizeText(existingTemplate?.status)
|
|
|| (preservedSelectedTemplate ? 'pending_whitelisting' : 'generated');
|
|
|
|
const variants = await generateTemplates(context, slug, event.label, {
|
|
senderId: activeProfile?.provider?.senderId,
|
|
});
|
|
|
|
const templateJson = {
|
|
eventSlug: slug,
|
|
eventLabel: event.label,
|
|
brandName: normalizeText(context?.brandName),
|
|
brandTaglines: Array.isArray(context?.taglines) ? context.taglines : [],
|
|
generatedVariants: variants,
|
|
selectedTemplate: preservedSelectedTemplate || null,
|
|
status: preservedStatus,
|
|
templateId: normalizeText(existingTemplate?.templateId),
|
|
curlProfileId: normalizeText(existingTemplate?.curlProfileId) || activeProfile.id,
|
|
rawCurl: existingTemplate?.rawCurl || '',
|
|
processedCurl: existingTemplate?.processedCurl || '',
|
|
variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object'
|
|
? existingTemplate.variableMap
|
|
: {},
|
|
selectedImagePath: existingTemplate?.selectedImagePath || '',
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
await uploadJSON(templateFolder, slug, templateJson);
|
|
res.json({ variants });
|
|
} catch (err) {
|
|
console.error('Generate error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ─── Templates ────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/businesses/:businessId/templates/images (must be before /:slug)
|
|
router.get('/:businessId/templates/images', async (req, res) => {
|
|
try {
|
|
const images = await listImages(`${businessRoot(getCompanyId(req), req.params.businessId)}/images`);
|
|
res.json({ images });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/businesses/:businessId/templates
|
|
router.get('/:businessId/templates', async (req, res) => {
|
|
try {
|
|
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
|
|
const folder = `${bizRoot}/templates`;
|
|
const slugs = await listTemplateFiles(folder);
|
|
const templates = [];
|
|
for (const slug of slugs) {
|
|
const tmpl = await fetchJSON(folder, slug);
|
|
if (tmpl) templates.push(tmpl);
|
|
}
|
|
res.json({ templates });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/businesses/:businessId/templates/:slug
|
|
router.get('/:businessId/templates/:slug', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug);
|
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
|
res.json(tmpl);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/templates/:slug/validate-edit
|
|
router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const editedTemplate = normalizeText(req.body?.editedTemplate);
|
|
if (!editedTemplate) {
|
|
return res.status(400).json({ error: 'editedTemplate is required' });
|
|
}
|
|
|
|
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
|
|
const tmpl = await fetchJSON(folder, slug);
|
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
|
|
|
const boundProfile = tmpl.curlProfileId
|
|
? await getBoundProfile(businessRoot(getCompanyId(req), businessId), tmpl.curlProfileId).catch(() => null)
|
|
: null;
|
|
const validation = await validateEditedTemplate(editedTemplate, {
|
|
senderId: boundProfile?.provider?.senderId,
|
|
eventSlug: slug,
|
|
eventLabel: tmpl.eventLabel,
|
|
brandName: tmpl.brandName || '',
|
|
brandTaglines: Array.isArray(tmpl.brandTaglines) ? tmpl.brandTaglines : [],
|
|
});
|
|
res.json(validation);
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/templates/:slug/discard
|
|
router.post('/:businessId/templates/:slug/discard', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
|
|
const tmpl = 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 {
|
|
const { businessId, slug } = req.params;
|
|
const { selectedVariant } = req.body;
|
|
if (!selectedVariant) return res.status(400).json({ error: 'selectedVariant is required' });
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const folder = `${bizRoot}/templates`;
|
|
|
|
const tmpl = await fetchJSON(folder, slug);
|
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
|
|
|
const activeProfile = await getActiveProfile(bizRoot);
|
|
const activeCurl = activeProfile?.rawCurl || null;
|
|
if (!activeProfile?.id || !activeCurl) {
|
|
return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' });
|
|
}
|
|
|
|
// Process the cURL against the selected template
|
|
const { processedCurl, variableMap } = await processCurl(activeCurl, selectedVariant, slug);
|
|
|
|
tmpl.selectedTemplate = selectedVariant;
|
|
tmpl.generatedVariants = []; // discard non-selected variants
|
|
tmpl.status = 'pending_whitelisting';
|
|
tmpl.curlProfileId = activeProfile.id; // snapshot which profile was used
|
|
tmpl.rawCurl = activeCurl;
|
|
tmpl.processedCurl = processedCurl;
|
|
tmpl.variableMap = variableMap;
|
|
tmpl.updatedAt = new Date().toISOString();
|
|
|
|
await uploadJSON(folder, slug, tmpl);
|
|
res.json(tmpl);
|
|
} catch (err) {
|
|
console.error('Select error:', err.message);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/templates/:slug/whitelist
|
|
router.post('/:businessId/templates/:slug/whitelist', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const { templateId } = req.body;
|
|
if (!templateId || !String(templateId).trim()) {
|
|
return res.status(400).json({ error: 'templateId is required' });
|
|
}
|
|
|
|
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
|
|
const tmpl = await fetchJSON(folder, slug);
|
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
|
if (tmpl.status !== 'pending_whitelisting') {
|
|
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to whitelist' });
|
|
}
|
|
|
|
tmpl.templateId = String(templateId).trim();
|
|
tmpl.status = 'whitelisted';
|
|
tmpl.updatedAt = new Date().toISOString();
|
|
|
|
await uploadJSON(folder, slug, tmpl);
|
|
res.json(tmpl);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/templates/:slug/publish
|
|
// Handles transition from pending_whitelisting -> whitelisted (Published).
|
|
// Validates mandatory provider fields, collects toNumber, then calls workflow sender.
|
|
router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const { templateId, toNumber } = req.body;
|
|
|
|
if (!normalizeText(templateId)) {
|
|
return res.status(400).json({ error: 'templateId is required' });
|
|
}
|
|
if (!normalizeText(toNumber)) {
|
|
return res.status(400).json({ error: 'toNumber is required' });
|
|
}
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const folder = `${bizRoot}/templates`;
|
|
|
|
// Load template
|
|
const tmpl = await fetchJSON(folder, slug);
|
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
|
if (tmpl.status !== 'pending_whitelisting') {
|
|
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' });
|
|
}
|
|
|
|
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
|
|
const missingFields = getMissingMandatoryProviderFields(boundProfile.provider);
|
|
if (missingFields.length > 0) {
|
|
return res.status(422).json({
|
|
error: 'Missing mandatory provider fields',
|
|
missingFields,
|
|
code: 'MISSING_BOUND_PROFILE_FIELDS',
|
|
});
|
|
}
|
|
|
|
const senderIdError = validateSenderId(boundProfile.provider.senderId);
|
|
if (senderIdError) {
|
|
return res.status(400).json({ error: senderIdError });
|
|
}
|
|
|
|
// Mark template as whitelisted
|
|
tmpl.templateId = normalizeText(templateId);
|
|
tmpl.status = 'whitelisted';
|
|
tmpl.updatedAt = new Date().toISOString();
|
|
await uploadJSON(folder, slug, tmpl);
|
|
|
|
// Send via workflow (Published path)
|
|
let sendResult;
|
|
try {
|
|
sendResult = await sendViaWorkflow({
|
|
senderId: boundProfile.provider.senderId,
|
|
toNumber: normalizeText(toNumber),
|
|
content: tmpl.selectedTemplate || '',
|
|
});
|
|
} catch (sendErr) {
|
|
// Template is already whitelisted at this point; report send error separately
|
|
return res.status(502).json({
|
|
error: 'Template published but send failed',
|
|
details: sendErr.message,
|
|
template: tmpl,
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
template: tmpl,
|
|
sendResult,
|
|
});
|
|
} catch (err) {
|
|
console.error('Publish error:', err.message);
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
// POST /api/businesses/:businessId/templates/:slug/test
|
|
// For Published (whitelisted) templates: routes to workflow sender (new path).
|
|
// Legacy cURL execution code below (executeCurl) is kept intact and is NOT deleted.
|
|
router.post('/:businessId/templates/:slug/test', async (req, res) => {
|
|
try {
|
|
const { businessId, slug } = req.params;
|
|
const { toNumber } = req.body;
|
|
if (!normalizeText(toNumber)) return res.status(400).json({ error: 'toNumber is required' });
|
|
|
|
const bizRoot = businessRoot(getCompanyId(req), businessId);
|
|
const folder = `${bizRoot}/templates`;
|
|
const tmpl = await fetchJSON(folder, slug);
|
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
|
if (tmpl.status !== 'whitelisted') {
|
|
return res.status(400).json({ error: 'Template must be whitelisted before testing' });
|
|
}
|
|
if (!tmpl.templateId) {
|
|
return res.status(400).json({ error: 'templateId must be set before testing' });
|
|
}
|
|
|
|
// ── Published send path: route to workflow sender ──────────────────────────
|
|
// Per plan: Published (whitelisted) templates use the workflow sender module,
|
|
// not the legacy cURL execution path. The cURL code below remains but is not
|
|
// reached for whitelisted templates in this branch.
|
|
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
|
|
if (!boundProfile.provider?.senderId) {
|
|
return res.status(422).json({
|
|
error: 'Provider senderId is required for sending',
|
|
missingFields: ['senderId'],
|
|
code: 'MISSING_BOUND_PROFILE_FIELDS',
|
|
});
|
|
}
|
|
|
|
const senderIdError = validateSenderId(boundProfile.provider.senderId);
|
|
if (senderIdError) {
|
|
return res.status(400).json({ error: senderIdError });
|
|
}
|
|
|
|
let smsResult;
|
|
try {
|
|
smsResult = await sendViaWorkflow({
|
|
senderId: boundProfile.provider.senderId,
|
|
toNumber: normalizeText(toNumber),
|
|
content: tmpl.selectedTemplate || '',
|
|
});
|
|
} catch (sendErr) {
|
|
return res.status(502).json({ error: 'SMS send failed', details: sendErr.message });
|
|
}
|
|
|
|
res.json({ success: true, statusCode: smsResult.statusCode, response: smsResult.response });
|
|
|
|
// ── Legacy cURL execution path (preserved, not reached for whitelisted) ────
|
|
// The code below is kept for reference and future restoration.
|
|
// It is NOT executed in the current Published send flow.
|
|
/*
|
|
let curlToExecute = String(tmpl.processedCurl || tmpl.rawCurl || '');
|
|
curlToExecute = curlToExecute.replace(/\{#toNumber#\}/g, toNumber);
|
|
curlToExecute = curlToExecute.replace(/\{#to#\}/g, toNumber);
|
|
curlToExecute = curlToExecute.replace(/\{#mobile#\}/g, toNumber);
|
|
curlToExecute = curlToExecute.replace(/\{#phone#\}/g, toNumber);
|
|
|
|
let legacyResult;
|
|
try {
|
|
legacyResult = await executeCurl(curlToExecute);
|
|
} catch (curlErr) {
|
|
return res.status(502).json({ error: 'SMS send failed', details: curlErr.message });
|
|
}
|
|
res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data });
|
|
*/
|
|
} catch (err) {
|
|
sendRouteError(res, err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Minimal cURL parser: extracts method, URL, headers, and body from a cURL string,
|
|
* then executes via axios.
|
|
*/
|
|
async function executeCurl(curlStr) {
|
|
const method = /-X\s+([A-Z]+)/i.exec(curlStr)?.[1]?.toLowerCase() || 'post';
|
|
const urlMatch = /curl\s+(?:-[^\s]+\s+)*['"]?(https?:\/\/[^\s'"]+)['"]?/i.exec(curlStr);
|
|
if (!urlMatch) throw new Error('Could not parse URL from cURL command');
|
|
const url = urlMatch[1];
|
|
|
|
// Extract headers (-H "Key: Value")
|
|
const headers = {};
|
|
const headerRegex = /-H\s+['"]([^'"]+)['"]/gi;
|
|
let hMatch;
|
|
while ((hMatch = headerRegex.exec(curlStr)) !== null) {
|
|
const parts = hMatch[1].split(/:\s*(.+)/);
|
|
if (parts.length >= 2) headers[parts[0].trim()] = parts[1].trim();
|
|
}
|
|
|
|
// Extract body (-d or --data or --data-raw)
|
|
const bodyMatch = /(?:--data-raw|--data|-d)\s+['"]([^'"]+)['"]/i.exec(curlStr)
|
|
|| /(?:--data-raw|--data|-d)\s+(\S+)/i.exec(curlStr);
|
|
const body = bodyMatch ? bodyMatch[1].replace(/\\n/g, '\n') : undefined;
|
|
|
|
return axios({
|
|
method,
|
|
url,
|
|
headers,
|
|
data: body,
|
|
timeout: 15000,
|
|
validateStatus: () => true, // don't throw on 4xx/5xx so we can relay the response
|
|
});
|
|
}
|
|
|
|
module.exports = router;
|
|
module.exports.handleFyndWebhook = handleFyndWebhook;
|