From b80d9404c4c1f763eb33095db3a52b6af52a4879 Mon Sep 17 00:00:00 2001 From: Ritul Date: Wed, 8 Apr 2026 17:27:31 +0530 Subject: [PATCH] Actual curl being used/whitelisting is pending on commerce side, this will take a few days --- Dockerfile | 2 + client/src/App.jsx | 4 + client/src/components/Sidebar.jsx | 28 + .../TemplateDetailWorkspaceModal.jsx | 36 +- client/src/components/TestSmsModal.jsx | 13 + client/src/components/WhitelistModal.jsx | 215 +- client/src/context/BusinessContext.jsx | 22 +- client/src/pages/Analytics.jsx | 376 +++ client/src/pages/Events.jsx | 39 +- client/src/pages/GlobalSms.jsx | 857 ++++--- client/src/pages/Providers.jsx | 581 +++-- server/routes/businesses.js | 2078 ++++++++++++++--- server/services/analyticsStore.js | 519 ++++ server/services/curlExecutor.js | 398 ++++ server/services/openai2.js | 1223 ++++++++-- 15 files changed, 5278 insertions(+), 1113 deletions(-) create mode 100644 client/src/pages/Analytics.jsx create mode 100644 server/services/analyticsStore.js create mode 100644 server/services/curlExecutor.js diff --git a/Dockerfile b/Dockerfile index 2cd01ce..d2b9ff0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,8 @@ FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production +RUN apk add --no-cache curl + COPY server/package*.json ./ RUN npm ci --omit=dev diff --git a/client/src/App.jsx b/client/src/App.jsx index 5a1814d..25bf72c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar'; import Businesses from './pages/Businesses'; import Providers from './pages/Providers'; import GlobalSms from './pages/GlobalSms'; +import Analytics from './pages/Analytics'; import Events from './pages/Events'; import Templates from './pages/Templates'; import { Link } from 'react-router-dom'; @@ -111,6 +112,9 @@ export default function App() { } /> + + } /> } /> diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx index 02eee4a..cd3d57b 100644 --- a/client/src/components/Sidebar.jsx +++ b/client/src/components/Sidebar.jsx @@ -2,6 +2,11 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { useBusiness } from '../context/BusinessContext'; const SVG_ICONS = { + analytics: ( + + + + ), globalSms: ( @@ -92,10 +97,12 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr const location = useLocation(); const businessImage = getSidebarBusinessImage(activeBusiness); + const analyticsPath = `/${activeBusinessId}/analytics`; const globalSmsPath = `/${activeBusinessId}/global-sms`; const eventsPath = `/${activeBusinessId}/events`; const templatesPath = `/${activeBusinessId}/templates`; + const isAnalyticsRoute = location.pathname === analyticsPath; const isGlobalSmsRoute = location.pathname === globalSmsPath; const isEventsRoute = location.pathname === eventsPath; const isTemplatesRoute = location.pathname === templatesPath; @@ -206,6 +213,27 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr {/* Nav */}
+ {isSetupComplete ? ( + + {SVG_ICONS.analytics} + Analytics + + ) : ( +
+ {SVG_ICONS.analytics} + Analytics +
+ )} +
+
{stepItems.map((item, index) => (
diff --git a/client/src/components/TemplateDetailWorkspaceModal.jsx b/client/src/components/TemplateDetailWorkspaceModal.jsx index a1d0f7f..7f2e676 100644 --- a/client/src/components/TemplateDetailWorkspaceModal.jsx +++ b/client/src/components/TemplateDetailWorkspaceModal.jsx @@ -126,6 +126,10 @@ export default function TemplateDetailWorkspaceModal({ const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet'; const provider = boundProfile?.provider || {}; const samplePayloadText = JSON.stringify(samplePayload, null, 2); + const executionMeta = template?.executionMeta || {}; + const executionInputCount = Array.isArray(template?.requiredInputs) ? template.requiredInputs.length : 0; + const fallbackCount = previewState.fallbackPlaceholders.length; + const unresolvedCount = previewState.unresolvedPlaceholders.length; return (
@@ -201,7 +205,7 @@ export default function TemplateDetailWorkspaceModal({

Preview

- Sample render + Deterministic sample render
@@ -209,6 +213,13 @@ export default function TemplateDetailWorkspaceModal({ {renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}

+ {(fallbackCount > 0 || unresolvedCount > 0) && ( +

0 ? 'text-amber-700' : 'text-gray-500'}`}> + {unresolvedCount > 0 + ? `${unresolvedCount} placeholder${unresolvedCount === 1 ? '' : 's'} still need explicit mapping.` + : `${fallbackCount} placeholder${fallbackCount === 1 ? '' : 's'} used deterministic sample fallback values.`} +

+ )}
@@ -254,6 +265,29 @@ export default function TemplateDetailWorkspaceModal({
+
+ +
+ {executionMeta.renderStrategy === 'deterministic_sample_payload' + ? 'Deterministic sample payload' + : 'Template variable mapping'} +
+
+ +
+ +
+ {executionInputCount} stored input{executionInputCount === 1 ? '' : 's'} +
+
+ +
+ +
+ {Number.isFinite(executionMeta.placeholderCount) ? executionMeta.placeholderCount : 0} placeholder{executionMeta.placeholderCount === 1 ? '' : 's'} +
+
+
diff --git a/client/src/components/TestSmsModal.jsx b/client/src/components/TestSmsModal.jsx index 1770b59..a1b2875 100644 --- a/client/src/components/TestSmsModal.jsx +++ b/client/src/components/TestSmsModal.jsx @@ -83,6 +83,19 @@ export default function TestSmsModal({ businessId, template, onClose }) { {result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'} {result.statusCode && HTTP {result.statusCode}}
+ {result.renderedContent && ( +
+ +
+                  {result.renderedContent}
+                
+ {Array.isArray(result.renderState?.fallbackPlaceholders) && result.renderState.fallbackPlaceholders.length > 0 && ( +

+ {result.renderState.fallbackPlaceholders.length} placeholder{result.renderState.fallbackPlaceholders.length === 1 ? '' : 's'} used deterministic sample fallback values. +

+ )} +
+ )} {result.response && (
diff --git a/client/src/components/WhitelistModal.jsx b/client/src/components/WhitelistModal.jsx index a547ee1..17ca130 100644 --- a/client/src/components/WhitelistModal.jsx +++ b/client/src/components/WhitelistModal.jsx @@ -1,82 +1,96 @@ import { useEffect, useMemo, useState } from 'react'; import apiClient from '../api/client'; -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; +const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']); + +function buildProfilePatchPayload(inputs = [], values = {}) { + const provider = {}; + const profileInputValues = {}; + + inputs.forEach((input) => { + const rawValue = String(values[input.key] ?? '').trim(); + if (!rawValue) return; + + if (BASE_PROFILE_KEYS.has(input.key)) { + provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue; + return; + } + + profileInputValues[input.key] = rawValue; + }); + + return { + ...(Object.keys(provider).length > 0 ? { provider } : {}), + ...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}), + }; +} + +function getInitialValues(inputs = []) { + return inputs.reduce((accumulator, input) => { + accumulator[input.key] = input.value || ''; + return accumulator; + }, {}); } export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) { const [profile, setProfile] = useState(boundProfile); - const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); + const [profileForm, setProfileForm] = useState({}); const [templateId, setTemplateId] = useState(''); const [toNumber, setToNumber] = useState(''); - const [savingProvider, setSavingProvider] = useState(false); + const [savingProfile, setSavingProfile] = useState(false); const [publishing, setPublishing] = useState(false); const [error, setError] = useState(''); - const [step, setStep] = useState('provider'); + const [step, setStep] = useState('profile'); + + const missingInputs = useMemo( + () => profile?.executionReadiness?.missingProfileInputs || [], + [profile], + ); useEffect(() => { setProfile(boundProfile); - setProviderForm({ - providerName: boundProfile?.provider?.providerName || '', - senderId: boundProfile?.provider?.senderId || '', - dltEntityId: boundProfile?.provider?.dltEntityId || '', - }); }, [boundProfile]); - const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]); + useEffect(() => { + setProfileForm(getInitialValues(missingInputs)); + }, [missingInputs]); useEffect(() => { if (!boundProfile) { setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.'); - setStep('provider'); + setStep('profile'); return; } setError(''); - setStep(missingFields.length > 0 ? 'provider' : 'publish'); - }, [boundProfile, missingFields]); + setStep(missingInputs.length > 0 ? 'profile' : 'publish'); + }, [boundProfile, missingInputs]); - async function handleProviderSubmit(e) { - e.preventDefault(); - if (!profile?.id) return; + async function handleProfileSubmit(event) { + event.preventDefault(); + if (!profile?.id || missingInputs.length === 0) return; - setSavingProvider(true); + setSavingProfile(true); setError(''); try { + const payload = buildProfilePatchPayload(missingInputs, profileForm); const res = await apiClient.patch( `/api/businesses/${businessId}/global-sms/profiles/${profile.id}`, - { - provider: { - providerName: providerForm.providerName, - senderId: providerForm.senderId.toUpperCase(), - dltEntityId: providerForm.dltEntityId, - }, - } + payload, ); 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'); + setStep(res.data?.executionReadiness?.missingProfileInputs?.length > 0 ? 'profile' : 'publish'); } catch (err) { - setError(err.response?.data?.error || 'Failed to save provider details'); + setError(err.response?.data?.error || 'Failed to save required profile fields'); } finally { - setSavingProvider(false); + setSavingProfile(false); } } - async function handlePublish(e) { - e.preventDefault(); + async function handlePublish(event) { + event.preventDefault(); if (!templateId.trim() || !toNumber.trim()) return; setPublishing(true); @@ -90,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC await Promise.resolve(onSuccess()); } catch (err) { if (err.response?.data?.missingFields?.length) { - setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`); - setStep('provider'); + setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`); + setStep('profile'); } else { setError(err.response?.data?.error || 'Failed to publish template'); } @@ -103,130 +117,101 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC const isProfileMissing = !profile?.id; return ( -
-
-
+
+
+
-

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

+ {step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'}

-

- {step === 'provider' - ? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.' +

+ {step === 'profile' + ? 'Complete the required 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 && ( -
+
{error}
)} - {step === 'provider' ? ( -
- {missingFields.includes('providerName') && ( -
- + {step === 'profile' ? ( + + {missingInputs.map((input) => ( +
+ setProviderForm(prev => ({ ...prev, providerName: e.target.value }))} - className="w-full px-4 py-2 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 + type={input.secret ? 'password' : 'text'} + value={profileForm[input.key] || ''} + onChange={(event) => setProfileForm((current) => ({ + ...current, + [input.key]: input.key === 'senderId' + ? event.target.value.toUpperCase() + : event.target.value, + }))} + className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue" + placeholder={input.label} + required={input.required !== false} + autoFocus={input.key === missingInputs[0]?.key} />
- )} - - {missingFields.includes('senderId') && ( -
- - setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))} - className="w-full px-4 py-2 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 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 - /> -
- )} + ))}
) : (
- + setTemplateId(e.target.value)} + onChange={(event) => setTemplateId(event.target.value)} placeholder="e.g. 1234567890987654321" - className="w-full px-4 py-2 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" + className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue" autoFocus required />
- + setToNumber(e.target.value)} + onChange={(event) => setToNumber(event.target.value)} placeholder="e.g. 919876543210" - className="w-full px-4 py-2 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" + className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue" required /> -

This sends the publish-triggering SMS request.

+

This sends the publish-triggering SMS request.

@@ -234,16 +219,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC type="button" onClick={onClose} disabled={publishing} - className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50" + className="flex-1 rounded-lg border border-border-main py-2 text-sm font-medium text-text-primary transition hover:bg-page-bg disabled:opacity-50" > Cancel
diff --git a/client/src/context/BusinessContext.jsx b/client/src/context/BusinessContext.jsx index 3b6bb03..0657b4f 100644 --- a/client/src/context/BusinessContext.jsx +++ b/client/src/context/BusinessContext.jsx @@ -14,11 +14,11 @@ export function BusinessProvider({ children }) { const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false); const [loading, setLoading] = useState(true); - const updateReadyState = useCallback((activeProfile, templates = []) => { + const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => { const hasProfile = !!activeProfile; - setHasGlobalSms(hasProfile); - const p = activeProfile?.provider || {}; - const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId; + const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride; + setHasGlobalSms(hasGlobalSmsProfiles); + const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true; setIsSetupComplete(nextIsSetupComplete); const nextHasSelectedTemplates = Array.isArray(templates) ? templates.some((template) => !!template?.selectedTemplate) @@ -26,7 +26,7 @@ export function BusinessProvider({ children }) { setHasSelectedTemplates(nextHasSelectedTemplates); return { - hasGlobalSms: hasProfile, + hasGlobalSms: hasGlobalSmsProfiles, isSetupComplete: nextIsSetupComplete, hasSelectedTemplates: nextHasSelectedTemplates, }; @@ -51,7 +51,11 @@ export function BusinessProvider({ children }) { apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })), ]); - return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []); + return updateReadyState( + smsRes.data?.activeProfile, + templatesRes.data?.templates || [], + smsRes.data?.hasProfiles === true, + ); }, [activeBusiness?.businessId, updateReadyState]); // On mount: rehydrate from sessionStorage and refresh from API @@ -75,7 +79,11 @@ export function BusinessProvider({ children }) { ]), ]); setActiveBusinessState(bizRes.data); - updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []); + updateReadyState( + smsRes[0].data?.activeProfile, + smsRes[1].data?.templates || [], + smsRes[0].data?.hasProfiles === true, + ); sessionStorage.setItem(SESSION_KEY, JSON.stringify({ businessId, companyId: runtimeCompanyId || companyId || '', diff --git a/client/src/pages/Analytics.jsx b/client/src/pages/Analytics.jsx new file mode 100644 index 0000000..60b2958 --- /dev/null +++ b/client/src/pages/Analytics.jsx @@ -0,0 +1,376 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import apiClient from '../api/client'; + +function formatNumber(value) { + return new Intl.NumberFormat().format(Number(value || 0)); +} + +function formatRate(value) { + if (typeof value !== 'number' || Number.isNaN(value)) return '—'; + return `${(value * 100).toFixed(1)}%`; +} + +function formatLastTriggered(value) { + if (!value) return '—'; + + try { + return new Date(value).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } catch { + return '—'; + } +} + +function buildLast30DaysSeries(rows = []) { + const rowByDate = new Map( + rows.map((row) => [String(row.date || ''), row]) + ); + const output = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + for (let offset = 29; offset >= 0; offset -= 1) { + const date = new Date(today); + date.setDate(today.getDate() - offset); + const key = date.toISOString().slice(0, 10); + const row = rowByDate.get(key); + + output.push({ + key, + label: date.toLocaleDateString([], { month: 'short', day: 'numeric' }), + triggeredCount: Number(row?.triggeredCount || 0), + failedCount: Number(row?.failedCount || 0), + }); + } + + return output; +} + +function getStatusAppearance(status) { + switch (status) { + case 'live': + return 'border-emerald-200 bg-emerald-50 text-emerald-700'; + case 'paused': + return 'border-slate-200 bg-slate-50 text-slate-600'; + case 'pending': + return 'border-amber-200 bg-amber-50 text-amber-700'; + case 'custom': + return 'border-violet-200 bg-violet-50 text-violet-700'; + default: + return 'border-gray-200 bg-gray-50 text-gray-500'; + } +} + +function StatCard({ title, value, subtitle, accentClassName }) { + return ( +
+
+

{title}

+

{value}

+

{subtitle}

+
+ ); +} + +function AnalyticsTrendChart({ rows }) { + const width = 720; + const height = 280; + const padding = { top: 18, right: 18, bottom: 34, left: 40 }; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const maxValue = Math.max( + 1, + ...rows.flatMap((row) => [row.triggeredCount, row.failedCount]), + ); + + const triggeredPoints = rows.map((row, index) => { + const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); + const y = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight; + return `${x},${y}`; + }).join(' '); + + const failedPoints = rows.map((row, index) => { + const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); + const y = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight; + return `${x},${y}`; + }).join(' '); + + const gridLines = Array.from({ length: 4 }, (_, index) => { + const ratio = index / 3; + const y = padding.top + innerHeight - ratio * innerHeight; + const label = Math.round(ratio * maxValue); + return { y, label }; + }); + + return ( +
+
+
+

Trigger Volume, Last 30 Days

+

Triggered vs failed SMS attempts

+
+
+
+ + Triggered +
+
+ + Failed +
+
+
+ + + {gridLines.map((line) => ( + + + + {line.label} + + + ))} + + + + + {rows.map((row, index) => { + const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); + const triggeredY = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight; + const failedY = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight; + const showLabel = index % 5 === 0 || index === rows.length - 1; + + return ( + + + + {showLabel && ( + + {row.label} + + )} + + ); + })} + +
+ ); +} + +export default function Analytics() { + const { businessId } = useParams(); + const [overview, setOverview] = useState(null); + const [eventRows, setEventRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const loadAnalytics = useCallback(async () => { + setLoading(true); + setError(''); + + try { + const [overviewRes, eventsRes] = await Promise.all([ + apiClient.get(`/api/businesses/${businessId}/analytics/overview`), + apiClient.get(`/api/businesses/${businessId}/analytics/events`), + ]); + + setOverview(overviewRes.data); + setEventRows(eventsRes.data?.events || []); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load analytics'); + } finally { + setLoading(false); + } + }, [businessId]); + + useEffect(() => { + loadAnalytics(); + }, [loadAnalytics]); + + const chartRows = useMemo( + () => buildLast30DaysSeries(overview?.chart || []), + [overview?.chart], + ); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + const metrics = overview?.metrics || {}; + const deliveryRateSubtitle = metrics.deliveryRateMode === 'send_fallback' + ? 'Using send success until provider callbacks are connected' + : 'Based on delivery outcomes recorded so far'; + + return ( +
+
+

Analytics

+

+ Event trigger counts, operational health, and fallback delivery performance for this business. +

+
+ +
+ + + + + +
+ + + +
+
+
+

Event Health

+

Per-event trigger counts and runtime status

+
+ + View all events + +
+ + {eventRows.length === 0 ? ( +
+ No analytics have been recorded for this business yet. +
+ ) : ( +
+ + + + + + + + + + + + + + {eventRows.map((row) => ( + + + + + + + + + + ))} + +
EventStatusTriggered TodayTotal Trigger CountDelivery RateLast TriggeredAction
+
{row.eventLabel}
+
{row.eventSlug}
+
+ + {row.statusLabel} + + + {formatNumber(row.triggeredToday)} + + {formatNumber(row.totalTriggerCount)} + +
{formatRate(row.deliveryRate)}
+
+ {row.deliveryRateMode === 'send_fallback' ? 'Send fallback' : row.deliveryRateMode === 'callback' ? 'Callback-based' : 'No data'} +
+
+ {formatLastTriggered(row.lastTriggeredAt)} + + + Manage + +
+
+ )} +
+
+ ); +} diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index 4b54d76..f8df962 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -116,6 +116,10 @@ function buildSelectedTemplatePreview(template = {}) { variableMap: template?.variableMap && typeof template.variableMap === 'object' ? template.variableMap : {}, + requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [], + executionMeta: template?.executionMeta && typeof template.executionMeta === 'object' + ? template.executionMeta + : {}, curlProfileId: String(template?.curlProfileId || '').trim(), }; } @@ -205,6 +209,7 @@ function createVariantDraft(text = '') { currentText: text, validationStatus: 'idle', why: '', + issues: [], lastCheckedText: '', }; } @@ -562,9 +567,20 @@ function TemplateGenerationWorkspaceModal({
)} - {validationStatus === 'rejected' && currentMatchesCheckedText && activeDraft?.why && ( + {validationStatus === 'rejected' && currentMatchesCheckedText && (activeDraft?.issues?.length > 0 || activeDraft?.why) && (
- Why it did not pass: {activeDraft.why} +

Why it did not pass:

+ {activeDraft?.issues?.length > 0 ? ( +
    + {activeDraft.issues.map((issue, index) => ( +
  • + {issue.message} +
  • + ))} +
+ ) : ( +

{activeDraft.why}

+ )}
)}
@@ -733,7 +749,7 @@ export default function Events() { } = buildTemplateUiState(templates); setEvents(eventsRes.data.events || []); - setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl); + setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl); setVariants(nextVariants); setGenState(nextGenState); setTemplateStatusBySlug(nextTemplateStatusBySlug); @@ -1033,6 +1049,7 @@ export default function Events() { ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), validationStatus: 'checking', why: '', + issues: [], lastCheckedText: '', }, })); @@ -1043,12 +1060,24 @@ export default function Events() { editedTemplate, }); + const issues = Array.isArray(res.data?.issues) + ? res.data.issues + .filter((issue) => issue && typeof issue === 'object') + .map((issue) => ({ + code: String(issue.code || '').trim(), + message: String(issue.message || '').trim(), + evidence: String(issue.evidence || '').trim(), + })) + .filter((issue) => issue.message) + : []; + setVariantDrafts((currentDrafts) => ({ ...currentDrafts, [draftKey]: { ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), validationStatus: res.data?.approved ? 'approved' : 'rejected', - why: res.data?.why || '', + why: String(res.data?.why || issues[0]?.message || ''), + issues, lastCheckedText: editedTemplate, }, })); @@ -1060,6 +1089,7 @@ export default function Events() { ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), validationStatus: 'idle', why: '', + issues: [], lastCheckedText: '', }, })); @@ -1114,6 +1144,7 @@ export default function Events() { currentText: nextText, validationStatus: 'idle', why: '', + issues: [], lastCheckedText: '', }, })); diff --git a/client/src/pages/GlobalSms.jsx b/client/src/pages/GlobalSms.jsx index 40ee9f2..05e897c 100644 --- a/client/src/pages/GlobalSms.jsx +++ b/client/src/pages/GlobalSms.jsx @@ -1,56 +1,175 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import apiClient from '../api/client'; import { useBusiness } from '../context/BusinessContext'; +const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']); + +function formatUpdatedAt(value) { + if (!value) return 'Not updated yet'; + + try { + return new Date(value).toLocaleString(); + } catch { + return 'Not updated yet'; + } +} + +function buildProfilePatchPayload(inputs = [], values = {}) { + const provider = {}; + const profileInputValues = {}; + + inputs.forEach((input) => { + const rawValue = String(values[input.key] ?? '').trim(); + if (!rawValue) return; + + if (BASE_PROFILE_KEYS.has(input.key)) { + provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue; + return; + } + + profileInputValues[input.key] = rawValue; + }); + + return { + ...(Object.keys(provider).length > 0 ? { provider } : {}), + ...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}), + }; +} + +function getInputInitialValues(inputs = []) { + return inputs.reduce((accumulator, input) => { + accumulator[input.key] = input.value || ''; + return accumulator; + }, {}); +} + +function getProfileSummary(profile) { + const parts = []; + const provider = profile?.provider || {}; + const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0; + + if (provider.providerName) parts.push(provider.providerName); + if (provider.senderId) parts.push(`Sender ${provider.senderId}`); + if (provider.dltEntityId) parts.push('DLT ready'); + if (missingCount > 0) parts.push(`${missingCount} required field${missingCount === 1 ? '' : 's'} pending`); + + return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.'; +} + +function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) { + if (!preview) return null; + + const impactedTemplates = Array.isArray(preview.impactedTemplates) ? preview.impactedTemplates : []; + + return ( +
+
+
+

Delete cURL Profile

+

+ {preview.profile?.name || 'This profile'} will be deleted. Bound templates will be removed, but event definitions will stay. +

+
+ +
+ {impactedTemplates.length > 0 ? ( + <> +

Affected templates

+
+ {impactedTemplates.map((template) => ( +
+
+

{template.eventLabel || template.eventSlug}

+ + {template.status || 'generated'} + +
+ {template.templateId && ( +

DLT Template ID: {template.templateId}

+ )} +
+ ))} +
+ + ) : ( +
+ No templates are currently bound to this profile. +
+ )} +
+ +
+ + +
+
+
+ ); +} + export default function GlobalSms() { const { businessId } = useParams(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness(); const [loading, setLoading] = useState(true); const [profiles, setProfiles] = useState([]); const [activeProfileId, setActiveProfileId] = useState(null); - const [saving, setSaving] = useState(false); + const [savingInputs, setSavingInputs] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); - - // Form state for Create / Edit Profile - const [editingId, setEditingId] = useState(null); const [formName, setFormName] = useState(''); const [formCurl, setFormCurl] = useState(''); const [formSetActive, setFormSetActive] = useState(true); + const [inputForm, setInputForm] = useState({}); + const [revealedProfiles, setRevealedProfiles] = useState({}); + const [visibleProfileIds, setVisibleProfileIds] = useState({}); + const [deletePreview, setDeletePreview] = useState(null); + const [deletingProfileId, setDeletingProfileId] = useState(''); - // Form state for Missing Provider Fields - const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); - const [savingProvider, setSavingProvider] = useState(false); + const activeProfile = useMemo( + () => profiles.find((profile) => profile.id === activeProfileId) || null, + [profiles, activeProfileId], + ); + const missingInputs = activeProfile?.executionReadiness?.missingProfileInputs || []; + const hasProfiles = profiles.length > 0; const eventsPath = `/${businessId}/events`; + const analyticsPath = `/${businessId}/analytics`; const loadProfiles = useCallback(async () => { try { setLoading(true); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`); - const fetchedProfiles = res.data.profiles || []; - const fetchActiveId = res.data.activeProfileId; + const fetchedProfiles = res.data?.profiles || []; + const nextActiveProfileId = res.data?.activeProfileId || null; + const nextActiveProfile = fetchedProfiles.find((profile) => profile.id === nextActiveProfileId) || null; + const nextIsSetupComplete = nextActiveProfile?.executionReadiness?.isSetupComplete === true; + setProfiles(fetchedProfiles); - setActiveProfileId(fetchActiveId); + setActiveProfileId(nextActiveProfileId); + setHasGlobalSms(fetchedProfiles.length > 0); + setIsSetupComplete(nextIsSetupComplete); - const activeProfile = fetchedProfiles.find(p => p.id === fetchActiveId) || null; - const hasProfile = !!activeProfile; - setHasGlobalSms(hasProfile); - - const p = activeProfile?.provider || {}; - const complete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId; - setIsSetupComplete(complete); - - setProviderForm({ - providerName: p.providerName || '', - senderId: p.senderId || '', - dltEntityId: p.dltEntityId || '', - }); - - return { activeProfile, hasProfile, complete }; + return { + activeProfile: nextActiveProfile, + hasProfile: !!nextActiveProfile, + complete: nextIsSetupComplete, + }; } catch { setError('Failed to load cURL profiles'); setHasGlobalSms(false); @@ -66,81 +185,39 @@ export default function GlobalSms() { }, [loadProfiles]); useEffect(() => { - const editProfileId = searchParams.get('editProfile'); - if (!editProfileId || profiles.length === 0) return; + setInputForm(getInputInitialValues(missingInputs)); + }, [activeProfileId, missingInputs]); - const nextParams = new URLSearchParams(searchParams); - nextParams.delete('editProfile'); + const ensureRevealData = useCallback(async (profileId) => { + if (revealedProfiles[profileId]) return revealedProfiles[profileId]; - const matchingProfile = profiles.find((profile) => profile.id === editProfileId); - if (matchingProfile) { - setEditingId(matchingProfile.id); - setFormName(matchingProfile.name); - setFormCurl(matchingProfile.rawCurl); - setFormSetActive(false); - setError(''); - setSuccess(''); - } else { - setError('The requested profile could not be found.'); - } + const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`); + setRevealedProfiles((current) => ({ ...current, [profileId]: res.data })); + return res.data; + }, [businessId, revealedProfiles]); - setSearchParams(nextParams, { replace: true }); - }, [profiles, searchParams, setSearchParams]); - - const activeProfile = profiles.find(p => p.id === activeProfileId) || null; - const hasProfiles = profiles.length > 0; - const isCreatingFirstProfile = !hasProfiles && !editingId; - const pData = activeProfile?.provider || {}; - const missingFields = []; - if (activeProfile && !pData.providerName) missingFields.push('providerName'); - if (activeProfile && !pData.senderId) missingFields.push('senderId'); - if (activeProfile && !pData.dltEntityId) missingFields.push('dltEntityId'); - - function handleAddClick() { - setEditingId(null); - setFormName(''); - setFormCurl(''); - setFormSetActive(profiles.length === 0); - setError(''); - setSuccess(''); - } - - function handleEditClick(profile) { - setEditingId(profile.id); - setFormName(profile.name); - setFormCurl(profile.rawCurl); - setFormSetActive(false); - setError(''); - setSuccess(''); - } - - async function handleSubmit(e) { - e.preventDefault(); + async function handleSubmit(event) { + event.preventDefault(); if (!formName.trim() || !formCurl.trim()) return; + setSaving(true); setError(''); setSuccess(''); const shouldAutoAdvance = !isSetupComplete; try { - if (editingId) { - await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${editingId}`, { - name: formName, - rawCurl: formCurl, - }); - setSuccess('Profile updated successfully.'); - } else { - await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, { - name: formName, - rawCurl: formCurl, - setActive: formSetActive, - }); - setSuccess('Profile created successfully.'); - } - const nextState = await loadProfiles(); + await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, { + name: formName.trim(), + rawCurl: formCurl.trim(), + setActive: formSetActive, + }); + setFormName(''); setFormCurl(''); - setEditingId(null); + setFormSetActive(true); + setSuccess('Profile created successfully.'); + + const nextState = await loadProfiles(); if (shouldAutoAdvance && nextState.complete) { navigate(eventsPath); } @@ -151,21 +228,15 @@ export default function GlobalSms() { } } - async function handleDelete(id) { - if (!window.confirm('Delete this cURL profile?')) return; - try { - await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${id}`); - await loadProfiles(); - } catch (err) { - setError(err.response?.data?.error || 'Failed to delete profile'); - } - } - - async function handleActivate(id) { + async function handleActivate(profileId) { const shouldAutoAdvance = !isSetupComplete; + setError(''); + setSuccess(''); + try { - await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${id}/activate`); + await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`); const nextState = await loadProfiles(); + setSuccess('Active profile updated.'); if (shouldAutoAdvance && nextState.complete) { navigate(eventsPath); } @@ -174,326 +245,380 @@ export default function GlobalSms() { } } - async function handleProviderSubmit(e) { - e.preventDefault(); - if (!activeProfileId) return; - setSavingProvider(true); + async function handleCopyCurl(profile) { + try { + const revealData = await ensureRevealData(profile.id); + const textToCopy = revealData?.rawCurl || profile.maskedCurl || ''; + if (!textToCopy) return; + + await navigator.clipboard.writeText(textToCopy); + setSuccess(`Copied ${profile.name} cURL.`); + } catch { + setError('Failed to copy the cURL command.'); + } + } + + async function handleToggleCurl(profile) { + setError(''); + + if (!visibleProfileIds[profile.id]) { + try { + await ensureRevealData(profile.id); + } catch (err) { + setError(err.response?.data?.error || 'Failed to reveal stored cURL'); + return; + } + } + + setVisibleProfileIds((current) => ({ + ...current, + [profile.id]: !current[profile.id], + })); + } + + async function handleProviderSubmit(event) { + event.preventDefault(); + if (!activeProfileId || missingInputs.length === 0) return; + + setSavingInputs(true); setError(''); setSuccess(''); const shouldAutoAdvance = !isSetupComplete; try { - await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, { - provider: { - providerName: providerForm.providerName, - senderId: providerForm.senderId.toUpperCase(), - dltEntityId: providerForm.dltEntityId, - } - }); - setSuccess('Provider details saved successfully!'); + const payload = buildProfilePatchPayload(missingInputs, inputForm); + await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload); + setSuccess('Required profile fields saved.'); const nextState = await loadProfiles(); if (shouldAutoAdvance && nextState.complete) { navigate(eventsPath); } } catch (err) { - setError(err.response?.data?.error || 'Failed to save provider details'); + setError(err.response?.data?.error || 'Failed to save required profile fields'); } finally { - setSavingProvider(false); + setSavingInputs(false); + } + } + + async function handleDeleteRequest(profile) { + setError(''); + setSuccess(''); + + try { + const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/delete-impact`); + setDeletePreview(res.data); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load delete impact'); + } + } + + async function handleDeleteConfirm() { + if (!deletePreview?.profile?.id) return; + + setDeletingProfileId(deletePreview.profile.id); + setError(''); + setSuccess(''); + + try { + await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${deletePreview.profile.id}`); + setDeletePreview(null); + setVisibleProfileIds((current) => { + const nextState = { ...current }; + delete nextState[deletePreview.profile.id]; + return nextState; + }); + setRevealedProfiles((current) => { + const nextState = { ...current }; + delete nextState[deletePreview.profile.id]; + return nextState; + }); + await loadProfiles(); + setSuccess('Profile deleted successfully.'); + } catch (err) { + setError(err.response?.data?.error || 'Failed to delete profile'); + } finally { + setDeletingProfileId(''); } } if (loading) { return (
- +
); } return ( -
-
-

Omni-channel SMS

-

- Complete this flow to configure your cURL profile and brand provider data. You must finish setup before generating templates. -

-
+ <> + setDeletePreview(null)} + onConfirm={handleDeleteConfirm} + /> - {error && ( -
- {error} - +
+
+

Omni-channel SMS

+

+ Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup. +

- )} - {success && ( -
- {success} - -
- )} - {/* Active Profile Setup Review Block */} - {activeProfile && ( -
-
-

Active Setup: {activeProfile.name}

- {isSetupComplete ? ( - Setup Complete - ) : ( - Missing Information - )} + {error && ( +
+ {error} +
+ )} -
-
-

Parsed Provider Data:

-
    -
  • - Provider: - {pData.providerName || Missing} -
  • -
  • - Sender ID: - {pData.senderId || Missing} -
  • -
  • - Entity ID: - {pData.dltEntityId || Missing} -
  • -
  • - Auth Key: - {pData.authKey ? '••••••••' : 'None setup'} -
  • -
+ {success && ( +
+ {success} + +
+ )} + + {activeProfile ? ( +
+
+

Active Setup: {activeProfile.name}

+ + {activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'} +
- {!isSetupComplete && ( -
-

Please fill in the missing fields:

-
- {missingFields.includes('providerName') && ( - setProviderForm({ ...providerForm, providerName: e.target.value })} - className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg" - required - /> - )} - {missingFields.includes('senderId') && ( - setProviderForm({ ...providerForm, senderId: e.target.value.toUpperCase() })} - className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg uppercase" - required - /> - )} - {missingFields.includes('dltEntityId') && ( - setProviderForm({ ...providerForm, dltEntityId: e.target.value })} - className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg" - required - /> - )} - -
+
+
+

Current Profile Summary

+
    +
  • + Provider + + {activeProfile.provider?.providerName || Missing} + +
  • +
  • + Sender ID + + {activeProfile.provider?.senderId || Missing} + +
  • +
  • + DLT Entity ID + + {activeProfile.provider?.dltEntityId || Missing} + +
  • +
  • +

    Setup Status

    +

    {getProfileSummary(activeProfile)}

    +
  • +
- )} - {isSetupComplete && ( -
-

Your active cURL profile is fully configured.

- -
- )} -
-
- )} - - {hasProfiles && ( -
-

All Profiles

- {profiles.map(p => { - const isActive = p.id === activeProfileId; - return ( -
-
-
-
-

{p.name}

- {isActive && ( - - Active Profile - - )} - {p.isDefault && !isActive && ( - - Default - - )} -
-

- Updated: {new Date(p.updatedAt).toLocaleString()} -

-
-
-

Raw cURL

+ {!activeProfile.executionReadiness?.isSetupComplete ? ( +
+

Complete the required fields

+
+ {missingInputs.map((input) => ( +
+ + setInputForm((current) => ({ + ...current, + [input.key]: input.key === 'senderId' + ? event.target.value.toUpperCase() + : event.target.value, + }))} + className="w-full rounded border border-border-main bg-page-bg px-3 py-2 text-sm text-text-primary focus:ring-1 focus:ring-primary-blue" + placeholder={input.label} + required={input.required !== false} + />
-
-                        {p.rawCurl}
-                      
-
-
-
- {!isActive && ( - - )} + ))} - {profiles.length > 1 && ( + +
+ ) : ( +
+

Your active cURL profile is fully configured.

+ +
+ )} +
+
+ ) : hasProfiles ? ( +
+ Select an active cURL profile to continue. Your saved profiles are still available below. +
+ ) : null} + + {hasProfiles && ( +
+

Saved Profiles

+ {profiles.map((profile) => { + const isActive = profile.id === activeProfileId; + const isVisible = visibleProfileIds[profile.id] === true; + const revealedProfile = revealedProfiles[profile.id]; + const displayCurl = isVisible ? (revealedProfile?.rawCurl || profile.maskedCurl) : profile.maskedCurl; + + return ( +
+
+
+
+

{profile.name}

+ {isActive && ( + + Active Profile + + )} + + {profile.executionReadiness?.isSetupComplete ? 'Ready' : 'Needs Fields'} + +
+

Updated: {formatUpdatedAt(profile.updatedAt)}

+

{getProfileSummary(profile)}

+
+
+

Stored cURL

+ +
+
+                          {displayCurl || 'No cURL stored.'}
+                        
+
+
+ +
+ {!isActive && ( + + )} + - )} +
-
- ); - })} -
- )} - - {/* Inline Form (Create / Edit) */} -
-
-
-

- {editingId ? 'Edit Profile' : isCreatingFirstProfile ? 'Create Your First cURL Profile' : 'Add New Profile'} -

-

- Give this profile a recognizable name, then paste the full provider cURL command below. -

+ ); + })}
- {editingId && ( - - )} -
-
- {isCreatingFirstProfile && ( -
-

Start by adding a cURL profile

+ )} + +
+
+
+

Add New Profile

- This becomes the base for validating provider details and unlocking event template generation. + Paste a provider cURL exactly once. After validation, the stored cURL becomes immutable and can only be replaced by creating a new profile.

- )} -
-
- -

- Use a name you will recognize later, such as `Production SMS` or `Backup Provider`. -

- setFormName(e.target.value)} - placeholder="e.g. Production SMS, Staging Twilio" - className="w-full rounded-lg border border-border-main bg-white px-4 py-2 text-sm text-text-primary placeholder-placeholder-bg transition focus:outline-none focus:ring-2 focus:ring-primary-blue" - required - /> -
-
- -

- Paste the full request exactly as supplied by your SMS provider. You can include the entire command. -

-