diff --git a/Dockerfile b/Dockerfile index 623ca18..56cbb52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,12 @@ +FROM node:20-alpine AS client-build +WORKDIR /client + +COPY client/package*.json ./ +RUN npm ci + +COPY client/ ./ +RUN npm run build + FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production @@ -9,6 +18,7 @@ COPY server/index.js ./ COPY server/config ./config COPY server/routes ./routes COPY server/services ./services +COPY --from=client-build /client/dist ./public EXPOSE 3001 CMD ["node", "index.js"] diff --git a/client/src/App.jsx b/client/src/App.jsx index 29c28fa..0767099 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -9,19 +9,21 @@ import Templates from './pages/Templates'; import { Link } from 'react-router-dom'; function SubLayout({ children }) { - const { activeBusinessId } = useBusiness(); + const { activeBusinessId, hasGlobalSms } = useBusiness(); return (
- - - + {hasGlobalSms && ( + + + + )}
{children} @@ -31,8 +33,8 @@ function SubLayout({ children }) { ); } -// Guard: redirect to / if no active business in session -// Also enforce cURL-first: redirect to global-sms if no cURL is saved yet. +// Guard: redirect to / if no active business in session. +// Also enforce cURL-first: only the cURL profile route is available until an active profile exists. function BusinessGuard({ children, isGlobalSmsRoute }) { const { activeBusinessId, loading, hasGlobalSms } = useBusiness(); const location = useLocation(); @@ -49,10 +51,7 @@ function BusinessGuard({ children, isGlobalSmsRoute }) { return ; } - if (!hasGlobalSms && !isGlobalSmsRoute && !location.pathname.endsWith('/settings')) { - // Only allow global SMS page if cURL constraint is not met yet - // Optionally allow settings, but strictly planning says "must go to Global SMS first". - // We enforce only global SMS by redirecting other pages. + if (!hasGlobalSms && !isGlobalSmsRoute) { return ; } diff --git a/client/src/components/WhitelistModal.jsx b/client/src/components/WhitelistModal.jsx index 438ff28..0f68a87 100644 --- a/client/src/components/WhitelistModal.jsx +++ b/client/src/components/WhitelistModal.jsx @@ -1,64 +1,106 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import apiClient from '../api/client'; -export default function WhitelistModal({ businessId, template, onClose, onSuccess }) { +function getMissingProviderFields(profile) { + const provider = profile?.provider || {}; + const missing = []; + if (!provider.providerName) missing.push('providerName'); + if (!provider.senderId) missing.push('senderId'); + if (!provider.dltEntityId) missing.push('dltEntityId'); + return missing; +} + +export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) { + const [profile, setProfile] = useState(boundProfile); + const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); const [templateId, setTemplateId] = useState(''); const [toNumber, setToNumber] = useState(''); - const [saving, setSaving] = useState(false); + const [savingProvider, setSavingProvider] = useState(false); + const [publishing, setPublishing] = useState(false); const [error, setError] = useState(''); - - const [providers, setProviders] = useState(null); - const [form, setForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); - const [loadingProviders, setLoadingProviders] = useState(true); + const [step, setStep] = useState('provider'); useEffect(() => { - async function fetchProviders() { - try { - const res = await apiClient.get(`/api/businesses/${businessId}/providers`); - setProviders(res.data || {}); - setForm({ - providerName: res.data?.providerName || '', - senderId: res.data?.senderId || '', - dltEntityId: res.data?.dltEntityId || '' - }); - } catch { - setProviders({}); - } finally { - setLoadingProviders(false); - } - } - fetchProviders(); - }, [businessId]); + setProfile(boundProfile); + setProviderForm({ + providerName: boundProfile?.provider?.providerName || '', + senderId: boundProfile?.provider?.senderId || '', + dltEntityId: boundProfile?.provider?.dltEntityId || '', + }); + }, [boundProfile]); - async function handleSubmit(e) { + const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]); + + useEffect(() => { + if (!boundProfile) { + setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.'); + setStep('provider'); + return; + } + + setError(''); + setStep(missingFields.length > 0 ? 'provider' : 'publish'); + }, [boundProfile, missingFields]); + + async function handleProviderSubmit(e) { + e.preventDefault(); + if (!profile?.id) return; + + setSavingProvider(true); + setError(''); + + try { + const res = await apiClient.patch( + `/api/businesses/${businessId}/global-sms/profiles/${profile.id}`, + { + provider: { + providerName: providerForm.providerName, + senderId: providerForm.senderId.toUpperCase(), + dltEntityId: providerForm.dltEntityId, + }, + } + ); + + setProfile(res.data); + setProviderForm({ + providerName: res.data?.provider?.providerName || '', + senderId: res.data?.provider?.senderId || '', + dltEntityId: res.data?.provider?.dltEntityId || '', + }); + setStep(getMissingProviderFields(res.data).length > 0 ? 'provider' : 'publish'); + } catch (err) { + setError(err.response?.data?.error || 'Failed to save provider details'); + } finally { + setSavingProvider(false); + } + } + + async function handlePublish(e) { e.preventDefault(); if (!templateId.trim() || !toNumber.trim()) return; - setSaving(true); + + setPublishing(true); setError(''); + try { await apiClient.post(`/api/businesses/${businessId}/templates/${template.eventSlug}/publish`, { templateId: templateId.trim(), toNumber: toNumber.trim(), - providerName: form.providerName, - senderId: form.senderId.toUpperCase(), - dltEntityId: form.dltEntityId }); - onSuccess(template.eventSlug, templateId.trim()); + await Promise.resolve(onSuccess()); } catch (err) { - if (err.response?.data?.missingFields) { + if (err.response?.data?.missingFields?.length) { setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`); + setStep('provider'); } else { setError(err.response?.data?.error || 'Failed to publish template'); } } finally { - setSaving(false); + setPublishing(false); } } - const missingName = !providers?.providerName; - const missingSender = !providers?.senderId; - const missingDlt = !providers?.dltEntityId; - const hasMissingProviders = missingName || missingSender || missingDlt; + const isProfileMissing = !profile?.id; return (
@@ -66,13 +108,23 @@ export default function WhitelistModal({ businessId, template, onClose, onSucces
-

Publish Template

+ +

+ {step === 'provider' ? 'Complete Provider Details' : 'Publish Template'} +

- Provide your DLT details and a test number to publish: + {step === 'provider' + ? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.' + : 'Provide the DLT template ID and destination number to complete publish.'}

-

+

{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}

+ {profile && ( +

+ Bound Profile: {profile.name} +

+ )} {error && (
@@ -80,12 +132,77 @@ export default function WhitelistModal({ businessId, template, onClose, onSucces
)} - {loadingProviders ? ( -
- -
+ {step === 'provider' ? ( +
+ {missingFields.includes('providerName') && ( +
+ + setProviderForm(prev => ({ ...prev, providerName: e.target.value }))} + className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" + placeholder="e.g. MSG91" + autoFocus + required + /> +
+ )} + + {missingFields.includes('senderId') && ( +
+ + setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))} + className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" + placeholder="6 CHARS" + maxLength={6} + required + /> +
+ )} + + {missingFields.includes('dltEntityId') && ( +
+ + setProviderForm(prev => ({ ...prev, dltEntityId: e.target.value }))} + className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" + placeholder="19-digit DLT PE ID" + required + /> +
+ )} + +
+ + +
+
) : ( -
+
-

Number to send the initial request on publish

+

This sends the publish-triggering SMS request.

- {hasMissingProviders && ( -
-

You MUST provide missing provider details before publishing.

-
- {missingName && ( -
- - setForm({ ...form, providerName: e.target.value })} - className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm" - required - /> -
- )} - {missingSender && ( -
- - setForm({ ...form, senderId: e.target.value.toUpperCase() })} - maxLength={6} - className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm font-mono uppercase" - required - /> -
- )} - {missingDlt && ( -
- - setForm({ ...form, dltEntityId: e.target.value })} - className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm font-mono" - required - /> -
- )} -
-
- )} -
diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index d44bcf6..f0557e0 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import apiClient from '../api/client'; @@ -15,26 +15,25 @@ export default function Events() { const [error, setError] = useState(''); const [readyToGenerate, setReadyToGenerate] = useState(false); - async function loadEvents() { + const loadEvents = useCallback(async () => { setLoading(true); try { - const [eventsRes, providersRes, globalSmsRes] = await Promise.all([ + const [eventsRes, activeProfileRes] = await Promise.all([ apiClient.get(`/api/businesses/${businessId}/events`), - apiClient.get(`/api/businesses/${businessId}/providers`).catch(() => ({ data: {} })), - apiClient.get(`/api/businesses/${businessId}/global-sms`).catch(() => ({ data: {} })), + apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })), ]); setEvents(eventsRes.data.events || []); - const hasProviders = !!providersRes.data?.senderId; - const hasGlobalSms = !!globalSmsRes.data?.rawCurl; - setReadyToGenerate(hasProviders && hasGlobalSms); + setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl); } catch { setError('Failed to load events'); } finally { setLoading(false); } - } + }, [businessId]); - useEffect(() => { loadEvents(); }, [businessId]); + useEffect(() => { + loadEvents(); + }, [loadEvents]); async function handleAddEvent(e) { e.preventDefault(); @@ -64,7 +63,7 @@ export default function Events() { async function handleGenerate(slug) { if (!readyToGenerate) { - setError('Configure Provider and Global SMS cURL before generating templates.'); + setError('Configure and activate a cURL profile before generating templates.'); return; } setGenState(s => ({ ...s, [slug]: 'loading' })); @@ -122,7 +121,7 @@ export default function Events() { {!readyToGenerate && (
⚠️ - Set up Provider and Global SMS cURL before generating templates. + Set up and activate a cURL profile before generating templates.
)} diff --git a/client/src/pages/GlobalSms.jsx b/client/src/pages/GlobalSms.jsx index 9749cd6..61fdde0 100644 --- a/client/src/pages/GlobalSms.jsx +++ b/client/src/pages/GlobalSms.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import apiClient from '../api/client'; import { useBusiness } from '../context/BusinessContext'; @@ -20,25 +20,23 @@ export default function GlobalSms() { const [formCurl, setFormCurl] = useState(''); const [formSetActive, setFormSetActive] = useState(true); - useEffect(() => { - loadProfiles(); - }, [businessId]); - - async function loadProfiles() { + const loadProfiles = useCallback(async () => { try { setLoading(true); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`); setProfiles(res.data.profiles || []); setActiveProfileId(res.data.activeProfileId); - if (res.data.activeProfileId) { - setHasGlobalSms(true); - } - } catch (err) { + setHasGlobalSms(!!res.data.activeProfileId); + } catch { setError('Failed to load cURL profiles'); } finally { setLoading(false); } - } + }, [businessId, setHasGlobalSms]); + + useEffect(() => { + loadProfiles(); + }, [loadProfiles]); function handleAddClick() { setEditingId(null); diff --git a/client/src/pages/Providers.jsx b/client/src/pages/Providers.jsx index fd10770..01d433c 100644 --- a/client/src/pages/Providers.jsx +++ b/client/src/pages/Providers.jsx @@ -6,8 +6,9 @@ export default function Providers() { const { businessId } = useParams(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [activeProfile, setActiveProfile] = useState(null); const [form, setForm] = useState({ - providerName: 'MSG91', + providerName: '', senderId: '', dltEntityId: '', authKey: '', @@ -18,17 +19,20 @@ export default function Providers() { useEffect(() => { async function load() { try { - const res = await apiClient.get(`/api/businesses/${businessId}/providers`); - if (res.data && res.data.providerName) { - setForm({ - providerName: res.data.providerName || 'MSG91', - senderId: res.data.senderId || '', - dltEntityId: res.data.dltEntityId || '', - authKey: res.data.authKey || '', - }); - } - } catch { - // no providers yet — keep defaults + const [activeRes, providerRes] = await Promise.all([ + apiClient.get(`/api/businesses/${businessId}/global-sms/active`), + apiClient.get(`/api/businesses/${businessId}/providers`), + ]); + + setActiveProfile(activeRes.data?.activeProfile || null); + setForm({ + providerName: providerRes.data?.providerName || '', + senderId: providerRes.data?.senderId || '', + dltEntityId: providerRes.data?.dltEntityId || '', + authKey: providerRes.data?.authKey || '', + }); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load provider configuration'); } finally { setLoading(false); } @@ -51,7 +55,13 @@ export default function Providers() { return; } try { - await apiClient.post(`/api/businesses/${businessId}/providers`, form); + const res = await apiClient.post(`/api/businesses/${businessId}/providers`, form); + setForm({ + providerName: res.data?.providerName || '', + senderId: res.data?.senderId || '', + dltEntityId: res.data?.dltEntityId || '', + authKey: res.data?.authKey || '', + }); setSuccess('Provider configuration saved successfully.'); } catch (err) { setError(err.response?.data?.error || 'Failed to save configuration'); @@ -72,7 +82,12 @@ export default function Providers() {

Provider Configuration

-

Save your DLT-approved sender details so the extension can dispatch SMS via your vendor.

+

Edit the provider details stored on the active cURL profile.

+ {activeProfile && ( +

+ Active Profile: {activeProfile.name} +

+ )}
{error && ( diff --git a/client/src/pages/Templates.jsx b/client/src/pages/Templates.jsx index 1f2c8cc..b147bd7 100644 --- a/client/src/pages/Templates.jsx +++ b/client/src/pages/Templates.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import apiClient from '../api/client'; import WhitelistModal from '../components/WhitelistModal'; @@ -13,33 +13,41 @@ const STATUS_CONFIG = { export default function Templates() { const { businessId } = useParams(); const [templates, setTemplates] = useState([]); + const [profilesById, setProfilesById] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [whitelistTarget, setWhitelistTarget] = useState(null); const [testTarget, setTestTarget] = useState(null); const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending' - async function loadTemplates() { + const loadTemplates = useCallback(async () => { setLoading(true); + setError(''); try { - const res = await apiClient.get(`/api/businesses/${businessId}/templates`); - // Show all templates that have a selected template (status != generated or status exists) - const all = (res.data.templates || []).filter(t => t.selectedTemplate); + const [templatesRes, profilesRes] = await Promise.all([ + apiClient.get(`/api/businesses/${businessId}/templates`), + apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })), + ]); + + const all = (templatesRes.data.templates || []).filter(t => t.selectedTemplate); + const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map(profile => [profile.id, profile])); + setTemplates(all); + setProfilesById(profileMap); } catch { setError('Failed to load templates'); } finally { setLoading(false); } - } + }, [businessId]); - useEffect(() => { loadTemplates(); }, [businessId]); + useEffect(() => { + loadTemplates(); + }, [loadTemplates]); - function handleWhitelistSuccess(slug, templateId) { - setTemplates(ts => ts.map(t => - t.eventSlug === slug ? { ...t, status: 'whitelisted', templateId } : t - )); + async function handleWhitelistSuccess() { setWhitelistTarget(null); + await loadTemplates(); } if (loading) { @@ -52,7 +60,6 @@ export default function Templates() { return (
- {/* Header */}

Templates

Track whitelisting status and test your SMS templates.

@@ -65,7 +72,6 @@ export default function Templates() {
)} - {/* Tabs */}
+ {tmpl.templateId && ( +
+ +

+ {tmpl.templateId} +

+
)} - {tmpl.status === 'whitelisted' && ( - - )} - {tmpl.status === 'pending_whitelisting' && ( -

Submit to DLT portal, then enter your Template ID here.

+ + {tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && ( +
+ +
+ {Object.entries(tmpl.variableMap).map(([key, val]) => ( +
+ {key} + + {val} +
+ ))} +
+
)} + +
+ {!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && ( + + )} + {!isBoundProfileMissing && tmpl.status === 'whitelisted' && ( + + )} + {tmpl.status === 'pending_whitelisting' && !isBoundProfileMissing && ( +

Submit to the DLT portal, then complete publish from here.

+ )} +
-
- ); - })} -
+ ); + })} +
); })()} @@ -200,6 +220,7 @@ export default function Templates() { setWhitelistTarget(null)} onSuccess={handleWhitelistSuccess} /> diff --git a/server/index.js b/server/index.js index ae71d52..c3699e9 100644 --- a/server/index.js +++ b/server/index.js @@ -1,11 +1,17 @@ require('dotenv').config(); const express = require('express'); const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); const businessesRoutes = require('./routes/businesses'); const app = express(); const PORT = process.env.PORT || 3001; +const CLIENT_DIST_DIR = [ + path.join(__dirname, 'public'), + path.join(__dirname, '..', 'client', 'dist'), +].find((dir) => fs.existsSync(path.join(dir, 'index.html'))); app.use(cors()); app.use(express.json({ limit: '10mb' })); @@ -16,8 +22,28 @@ app.get('/api/health', (req, res) => res.json({ ok: true, timestamp: new Date(). // Routes app.use('/api/businesses', businessesRoutes); -// 404 -app.use('*', (req, res) => res.status(404).json({ error: 'Route not found' })); +// Serve the built client for same-origin deployment. +if (CLIENT_DIST_DIR) { + app.use(express.static(CLIENT_DIST_DIR)); +} + +// Preserve JSON 404s for unknown API routes. +app.use('/api', (req, res) => res.status(404).json({ error: 'Route not found' })); + +// SPA fallback for non-API browser routes. +app.use((req, res, next) => { + if (req.method !== 'GET') { + return res.status(404).json({ error: 'Route not found' }); + } + + if (!CLIENT_DIST_DIR) { + return res.status(404).json({ error: 'Route not found' }); + } + + res.sendFile(path.join(CLIENT_DIST_DIR, 'index.html'), (err) => { + if (err) next(err); + }); +}); // Error handler app.use((err, req, res, next) => { diff --git a/server/routes/businesses.js b/server/routes/businesses.js index e4c52eb..e7d91b7 100644 --- a/server/routes/businesses.js +++ b/server/routes/businesses.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { scrape } = require('../services/firecrawl'); -const { parseBrandContext, generateTemplates, processCurl } = require('../services/openai2'); +const { parseBrandContext, generateTemplates, processCurl, validateCurlFields } = require('../services/openai2'); const { sendViaWorkflow } = require('../services/workflowSender'); const { uploadJSON, @@ -40,6 +40,200 @@ async function saveIndex(merchantId, businesses) { await uploadJSON(indexPath(merchantId), 'index', { businesses }); } +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 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 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 @@ -151,58 +345,54 @@ router.delete('/:businessId', async (req, res) => { // GET /api/businesses/:businessId/providers router.get('/:businessId/providers', async (req, res) => { try { - const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers'); - res.json(data || {}); + const bizRoot = businessRoot(MERCHANT_ID(), 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) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); // POST /api/businesses/:businessId/providers -// Mandatory fields for publish/send: providerName, senderId, dltEntityId. -// Per plan: these are NOT required to save — user can save partial config and is only -// blocked when switching a template to Published. senderId format is still validated -// if provided, so the stored value is always valid. router.post('/:businessId/providers', async (req, res) => { try { - const { providerName, senderId, dltEntityId, authKey } = req.body; - - // If senderId is provided, it must still meet the format requirement - if (senderId && (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId))) { - return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' }); + const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const providerPatch = getProviderPatch(req.body); + const senderIdError = validateSenderId(providerPatch?.senderId || ''); + if (senderIdError) { + return res.status(400).json({ error: senderIdError }); } - const config = { - providerName: providerName || '', - senderId: senderId ? senderId.toUpperCase() : '', - dltEntityId: dltEntityId || '', - authKey: authKey || '', - updatedAt: new Date().toISOString(), - }; - await uploadJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers', config); - res.json(config); + 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) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); -// ─── Global SMS cURL (Compatibility layer — kept so existing sessions/frontend work) ──────────── -// The new multi-profile system is below. These two routes delegate to the active profile. +// ─── Global SMS cURL (Compatibility layer) ─────────────────────────────────── +// These routes delegate to the active/default profile model. // GET /api/businesses/:businessId/global-sms -// Returns the active cURL profile's rawCurl (or legacy global_sms.json as fallback). router.get('/:businessId/global-sms', async (req, res) => { try { const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); const activeProfile = await getActiveProfile(bizRoot); - if (activeProfile) { - return res.json({ rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt }); - } - // Fallback: legacy global_sms.json (present on businesses created before profile system) - const data = await fetchJSON(bizRoot, 'global_sms'); - res.json(data || {}); + res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {}); } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -211,70 +401,57 @@ router.get('/:businessId/global-sms', async (req, res) => { router.post('/:businessId/global-sms', async (req, res) => { try { const { rawCurl } = req.body; - if (!rawCurl || !rawCurl.trim()) { + if (!normalizeText(rawCurl)) { return res.status(400).json({ error: 'rawCurl is required' }); } - if (!rawCurl.trim().toLowerCase().startsWith('curl')) { + if (!isValidCurlCommand(rawCurl)) { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); - const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] }; - + 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 = rawCurl.trim(); + defaultProfile.rawCurl = normalizedCurl; + defaultProfile.provider = extractedProvider; defaultProfile.updatedAt = now; } else { - defaultProfile = { id: uuidv4(), name: 'Default', rawCurl: rawCurl.trim(), isDefault: true, createdAt: now, updatedAt: now }; + 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: rawCurl.trim(), updatedAt: now }); + res.json({ rawCurl: normalizedCurl, updatedAt: now }); } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); -// ─── cURL Profile Helpers ───────────────────────────────────────────────────── - -async function getActiveProfile(bizRoot) { - try { - const [profileData, activeRec] = await Promise.all([ - fetchJSON(bizRoot, 'global_sms_profiles'), - fetchJSON(bizRoot, 'active_curl_profile'), - ]); - if (!profileData?.profiles?.length) return null; - if (activeRec?.profileId) { - const found = profileData.profiles.find(p => p.id === activeRec.profileId); - if (found) return found; - } - // Fall back to first profile - return profileData.profiles[0]; - } catch { - return null; - } -} - // ─── cURL Profiles CRUD ──────────────────────────────────────────────────────── // GET /api/businesses/:businessId/global-sms/profiles router.get('/:businessId/global-sms/profiles', async (req, res) => { try { const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); - const [profileData, activeRec] = await Promise.all([ - fetchJSON(bizRoot, 'global_sms_profiles'), - fetchJSON(bizRoot, 'active_curl_profile'), - ]); - const profiles = profileData?.profiles || []; - const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null); + const { profileData, activeProfileId } = await getProfileState(bizRoot); + const profiles = profileData.profiles || []; res.json({ profiles, activeProfileId }); } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -282,21 +459,31 @@ router.get('/:businessId/global-sms/profiles', async (req, res) => { router.post('/:businessId/global-sms/profiles', async (req, res) => { try { const { name, rawCurl, setActive } = req.body; - if (!name || !String(name).trim()) { + if (!normalizeText(name)) { return res.status(400).json({ error: 'name is required' }); } - if (!rawCurl || !rawCurl.trim()) { + if (!normalizeText(rawCurl)) { return res.status(400).json({ error: 'rawCurl is required' }); } - if (!rawCurl.trim().toLowerCase().startsWith('curl')) { + if (!isValidCurlCommand(rawCurl)) { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); - const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] }; + const { profileData } = await getProfileState(bizRoot); const now = new Date().toISOString(); + const normalizedCurl = normalizeText(rawCurl); + const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl); - const newProfile = { id: uuidv4(), name: String(name).trim(), rawCurl: rawCurl.trim(), isDefault: false, createdAt: now, updatedAt: now }; + 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); @@ -307,7 +494,7 @@ router.post('/:businessId/global-sms/profiles', async (req, res) => { res.json(newProfile); } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -316,24 +503,35 @@ 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 (rawCurl !== undefined && !rawCurl.trim().toLowerCase().startsWith('curl')) { + 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(MERCHANT_ID(), businessId); - const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] }; + 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 = String(name).trim(); - if (rawCurl !== undefined) profile.rawCurl = rawCurl.trim(); - profile.updatedAt = new Date().toISOString(); + 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) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -342,7 +540,7 @@ router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => try { const { businessId, profileId } = req.params; const bizRoot = businessRoot(MERCHANT_ID(), businessId); - const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] }; + 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' }); @@ -361,7 +559,7 @@ router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => res.json({ ok: true }); } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -370,14 +568,14 @@ router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, try { const { businessId, profileId } = req.params; const bizRoot = businessRoot(MERCHANT_ID(), businessId); - const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] }; + 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) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -385,16 +583,10 @@ router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, router.get('/:businessId/global-sms/active', async (req, res) => { try { const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); - const [profileData, activeRec] = await Promise.all([ - fetchJSON(bizRoot, 'global_sms_profiles'), - fetchJSON(bizRoot, 'active_curl_profile'), - ]); - const profiles = profileData?.profiles || []; - const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null); - const activeProfile = profiles.find(p => p.id === activeProfileId) || profiles[0] || null; + const { activeProfile, activeProfileId } = await getProfileState(bizRoot); res.json({ activeProfile, activeProfileId }); } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -458,19 +650,11 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => { const { businessId, slug } = req.params; const bizRoot = businessRoot(MERCHANT_ID(), businessId); - // Requirements check - const [context, providers] = await Promise.all([ - fetchJSON(bizRoot, 'context'), - fetchJSON(bizRoot, 'providers'), - ]); + const context = await fetchJSON(bizRoot, 'context'); if (!context) return res.status(400).json({ error: 'Business context not found.' }); - if (!providers?.senderId) return res.status(400).json({ error: 'Provider details must be configured before generating templates.' }); - // Require an active cURL profile (new system), falling back to legacy global_sms.json const activeProfile = await getActiveProfile(bizRoot); - const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms'); - const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null; - if (!activeCurl) { + if (!activeProfile?.rawCurl) { return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' }); } @@ -487,7 +671,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => { selectedTemplate: null, status: 'generated', templateId: '', - curlProfileId: activeProfile?.id || null, + curlProfileId: activeProfile.id, rawCurl: '', processedCurl: '', variableMap: {}, @@ -557,11 +741,9 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => { const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); - // Resolve active cURL (new profile system first, legacy fallback) const activeProfile = await getActiveProfile(bizRoot); - const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms'); - const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null; - if (!activeCurl) { + 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' }); } @@ -571,7 +753,7 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => { tmpl.selectedTemplate = selectedVariant; tmpl.generatedVariants = []; // discard non-selected variants tmpl.status = 'pending_whitelisting'; - tmpl.curlProfileId = activeProfile?.id || null; // snapshot which profile was used + tmpl.curlProfileId = activeProfile.id; // snapshot which profile was used tmpl.rawCurl = activeCurl; tmpl.processedCurl = processedCurl; tmpl.variableMap = variableMap; @@ -618,12 +800,12 @@ router.post('/:businessId/templates/:slug/whitelist', async (req, res) => { router.post('/:businessId/templates/:slug/publish', async (req, res) => { try { const { businessId, slug } = req.params; - const { templateId, toNumber, providerName, senderId, dltEntityId, authKey } = req.body; + const { templateId, toNumber } = req.body; - if (!templateId || !String(templateId).trim()) { + if (!normalizeText(templateId)) { return res.status(400).json({ error: 'templateId is required' }); } - if (!toNumber || !String(toNumber).trim()) { + if (!normalizeText(toNumber)) { return res.status(400).json({ error: 'toNumber is required' }); } @@ -637,38 +819,23 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => { return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' }); } - // Merge any submitted provider fields over stored values - const storedProviders = await fetchJSON(bizRoot, 'providers') || {}; - const mergedProviders = { - providerName: providerName || storedProviders.providerName || '', - senderId: senderId ? senderId.toUpperCase() : (storedProviders.senderId || ''), - dltEntityId: dltEntityId || storedProviders.dltEntityId || '', - authKey: authKey || storedProviders.authKey || '', - }; - - // Validate mandatory fields - const missing = []; - if (!mergedProviders.providerName) missing.push('providerName'); - if (!mergedProviders.senderId) missing.push('senderId'); - if (!mergedProviders.dltEntityId) missing.push('dltEntityId'); - if (missing.length > 0) { + 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: missing, + missingFields, + code: 'MISSING_BOUND_PROFILE_FIELDS', }); } - // Validate senderId format - if (mergedProviders.senderId.length !== 6 || !/^[A-Za-z]+$/.test(mergedProviders.senderId)) { - return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' }); + const senderIdError = validateSenderId(boundProfile.provider.senderId); + if (senderIdError) { + return res.status(400).json({ error: senderIdError }); } - // Persist any updated provider data - const updatedProviders = { ...mergedProviders, updatedAt: new Date().toISOString() }; - await uploadJSON(bizRoot, 'providers', updatedProviders); - // Mark template as whitelisted - tmpl.templateId = String(templateId).trim(); + tmpl.templateId = normalizeText(templateId); tmpl.status = 'whitelisted'; tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); @@ -677,8 +844,8 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => { let sendResult; try { sendResult = await sendViaWorkflow({ - senderId: mergedProviders.senderId, - toNumber: String(toNumber).trim(), + senderId: boundProfile.provider.senderId, + toNumber: normalizeText(toNumber), content: tmpl.selectedTemplate || '', }); } catch (sendErr) { @@ -697,7 +864,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => { }); } catch (err) { console.error('Publish error:', err.message); - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); @@ -708,7 +875,7 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => { try { const { businessId, slug } = req.params; const { toNumber } = req.body; - if (!toNumber) return res.status(400).json({ error: 'toNumber is required' }); + if (!normalizeText(toNumber)) return res.status(400).json({ error: 'toNumber is required' }); const bizRoot = businessRoot(MERCHANT_ID(), businessId); const folder = `${bizRoot}/templates`; @@ -725,16 +892,25 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => { // 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 providers = await fetchJSON(bizRoot, 'providers') || {}; - if (!providers.senderId) { - return res.status(422).json({ error: 'Provider senderId is required for sending' }); + 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: providers.senderId, - toNumber: String(toNumber).trim(), + senderId: boundProfile.provider.senderId, + toNumber: normalizeText(toNumber), content: tmpl.selectedTemplate || '', }); } catch (sendErr) { @@ -762,7 +938,7 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => { res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data }); */ } catch (err) { - res.status(500).json({ error: err.message }); + sendRouteError(res, err); } }); diff --git a/server/services/openai2.js b/server/services/openai2.js index ed04a07..23002c6 100644 --- a/server/services/openai2.js +++ b/server/services/openai2.js @@ -4,10 +4,12 @@ const axios = require('axios'); 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; 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.'; @@ -130,4 +132,25 @@ async function processCurl(rawCurl, approvedTemplate, eventSlug) { }; } -module.exports = { parseBrandContext, generateTemplates, processCurl }; +async function validateCurlFields(rawCurl) { + const payload = { + curl_b64: Buffer.from(String(rawCurl || ''), 'utf8').toString('base64'), + }; + + const data = await postWorkflow(WORKFLOW_VALIDATE_FIELDS, payload); + const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {}); + const isValidCurl = output.is_valid_curl === true || String(output.is_valid_curl).toLowerCase() === 'true'; + + return { + isValidCurl, + provider: { + providerName: String(output.provider_name || '').trim(), + senderId: String(output.dlt_sender_id || '').trim().toUpperCase(), + dltEntityId: String(output.dlt_entity_id || '').trim(), + authKey: String(output.api_auth_key || '').trim(), + }, + reason: String(output.reason || '').trim(), + }; +} + +module.exports = { parseBrandContext, generateTemplates, processCurl, validateCurlFields };