Actual curl being used/whitelisting is pending on commerce side, this will take a few days
This commit is contained in:
parent
5508f094a5
commit
b80d9404c4
|
|
@ -11,6 +11,8 @@ FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar';
|
||||||
import Businesses from './pages/Businesses';
|
import Businesses from './pages/Businesses';
|
||||||
import Providers from './pages/Providers';
|
import Providers from './pages/Providers';
|
||||||
import GlobalSms from './pages/GlobalSms';
|
import GlobalSms from './pages/GlobalSms';
|
||||||
|
import Analytics from './pages/Analytics';
|
||||||
import Events from './pages/Events';
|
import Events from './pages/Events';
|
||||||
import Templates from './pages/Templates';
|
import Templates from './pages/Templates';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
@ -111,6 +112,9 @@ export default function App() {
|
||||||
<Route path="/:businessId/global-sms" element={
|
<Route path="/:businessId/global-sms" element={
|
||||||
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/:businessId/analytics" element={
|
||||||
|
<BusinessGuard><SubLayout><Analytics /></SubLayout></BusinessGuard>
|
||||||
|
} />
|
||||||
<Route path="/:businessId/events" element={
|
<Route path="/:businessId/events" element={
|
||||||
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
||||||
} />
|
} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useBusiness } from '../context/BusinessContext';
|
import { useBusiness } from '../context/BusinessContext';
|
||||||
|
|
||||||
const SVG_ICONS = {
|
const SVG_ICONS = {
|
||||||
|
analytics: (
|
||||||
|
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 20V10m5 10V4m5 16v-6M4 20h16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
globalSms: (
|
globalSms: (
|
||||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
|
@ -92,10 +97,12 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const businessImage = getSidebarBusinessImage(activeBusiness);
|
const businessImage = getSidebarBusinessImage(activeBusiness);
|
||||||
|
|
||||||
|
const analyticsPath = `/${activeBusinessId}/analytics`;
|
||||||
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
||||||
const eventsPath = `/${activeBusinessId}/events`;
|
const eventsPath = `/${activeBusinessId}/events`;
|
||||||
const templatesPath = `/${activeBusinessId}/templates`;
|
const templatesPath = `/${activeBusinessId}/templates`;
|
||||||
|
|
||||||
|
const isAnalyticsRoute = location.pathname === analyticsPath;
|
||||||
const isGlobalSmsRoute = location.pathname === globalSmsPath;
|
const isGlobalSmsRoute = location.pathname === globalSmsPath;
|
||||||
const isEventsRoute = location.pathname === eventsPath;
|
const isEventsRoute = location.pathname === eventsPath;
|
||||||
const isTemplatesRoute = location.pathname === templatesPath;
|
const isTemplatesRoute = location.pathname === templatesPath;
|
||||||
|
|
@ -206,6 +213,27 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 px-3 pt-5">
|
<nav className="flex-1 px-3 pt-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
{isSetupComplete ? (
|
||||||
|
<NavLink
|
||||||
|
to={analyticsPath}
|
||||||
|
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold transition-colors ${
|
||||||
|
isAnalyticsRoute
|
||||||
|
? 'bg-gray-100 text-gray-900'
|
||||||
|
: 'text-gray-600 hover:bg-page-bg hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SVG_ICONS.analytics}
|
||||||
|
<span className="flex-1 truncate">Analytics</span>
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-gray-300 cursor-not-allowed select-none">
|
||||||
|
{SVG_ICONS.analytics}
|
||||||
|
<span className="flex-1 truncate">Analytics</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{stepItems.map((item, index) => (
|
{stepItems.map((item, index) => (
|
||||||
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">
|
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,10 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
|
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
|
||||||
const provider = boundProfile?.provider || {};
|
const provider = boundProfile?.provider || {};
|
||||||
const samplePayloadText = JSON.stringify(samplePayload, null, 2);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
||||||
|
|
@ -201,7 +205,7 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-gray-700">Preview</p>
|
<p className="text-sm font-semibold text-gray-700">Preview</p>
|
||||||
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||||||
Sample render
|
Deterministic sample render
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
|
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
|
||||||
|
|
@ -209,6 +213,13 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
|
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{(fallbackCount > 0 || unresolvedCount > 0) && (
|
||||||
|
<p className={`text-xs font-medium ${unresolvedCount > 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.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,6 +265,29 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-500">Render Strategy</label>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
||||||
|
{executionMeta.renderStrategy === 'deterministic_sample_payload'
|
||||||
|
? 'Deterministic sample payload'
|
||||||
|
: 'Template variable mapping'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-500">Execution Inputs</label>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
||||||
|
{executionInputCount} stored input{executionInputCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-500">Template Variables</label>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
||||||
|
{Number.isFinite(executionMeta.placeholderCount) ? executionMeta.placeholderCount : 0} placeholder{executionMeta.placeholderCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
|
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
|
||||||
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,19 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
||||||
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
|
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
|
||||||
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{result.renderedContent && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Rendered SMS Content</label>
|
||||||
|
<pre className="p-3 bg-white border border-gray-200 rounded-lg text-xs font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap break-words">
|
||||||
|
{result.renderedContent}
|
||||||
|
</pre>
|
||||||
|
{Array.isArray(result.renderState?.fallbackPlaceholders) && result.renderState.fallbackPlaceholders.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs font-medium text-gray-500">
|
||||||
|
{result.renderState.fallbackPlaceholders.length} placeholder{result.renderState.fallbackPlaceholders.length === 1 ? '' : 's'} used deterministic sample fallback values.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{result.response && (
|
{result.response && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,96 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
function getMissingProviderFields(profile) {
|
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||||
const provider = profile?.provider || {};
|
|
||||||
const missing = [];
|
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||||
if (!provider.providerName) missing.push('providerName');
|
const provider = {};
|
||||||
if (!provider.senderId) missing.push('senderId');
|
const profileInputValues = {};
|
||||||
if (!provider.dltEntityId) missing.push('dltEntityId');
|
|
||||||
return missing;
|
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 }) {
|
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
|
||||||
const [profile, setProfile] = useState(boundProfile);
|
const [profile, setProfile] = useState(boundProfile);
|
||||||
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
const [profileForm, setProfileForm] = useState({});
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [toNumber, setToNumber] = useState('');
|
const [toNumber, setToNumber] = useState('');
|
||||||
const [savingProvider, setSavingProvider] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [step, setStep] = useState('provider');
|
const [step, setStep] = useState('profile');
|
||||||
|
|
||||||
|
const missingInputs = useMemo(
|
||||||
|
() => profile?.executionReadiness?.missingProfileInputs || [],
|
||||||
|
[profile],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfile(boundProfile);
|
setProfile(boundProfile);
|
||||||
setProviderForm({
|
|
||||||
providerName: boundProfile?.provider?.providerName || '',
|
|
||||||
senderId: boundProfile?.provider?.senderId || '',
|
|
||||||
dltEntityId: boundProfile?.provider?.dltEntityId || '',
|
|
||||||
});
|
|
||||||
}, [boundProfile]);
|
}, [boundProfile]);
|
||||||
|
|
||||||
const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]);
|
useEffect(() => {
|
||||||
|
setProfileForm(getInitialValues(missingInputs));
|
||||||
|
}, [missingInputs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!boundProfile) {
|
if (!boundProfile) {
|
||||||
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
||||||
setStep('provider');
|
setStep('profile');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setStep(missingFields.length > 0 ? 'provider' : 'publish');
|
setStep(missingInputs.length > 0 ? 'profile' : 'publish');
|
||||||
}, [boundProfile, missingFields]);
|
}, [boundProfile, missingInputs]);
|
||||||
|
|
||||||
async function handleProviderSubmit(e) {
|
async function handleProfileSubmit(event) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
if (!profile?.id) return;
|
if (!profile?.id || missingInputs.length === 0) return;
|
||||||
|
|
||||||
setSavingProvider(true);
|
setSavingProfile(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload = buildProfilePatchPayload(missingInputs, profileForm);
|
||||||
const res = await apiClient.patch(
|
const res = await apiClient.patch(
|
||||||
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
||||||
{
|
payload,
|
||||||
provider: {
|
|
||||||
providerName: providerForm.providerName,
|
|
||||||
senderId: providerForm.senderId.toUpperCase(),
|
|
||||||
dltEntityId: providerForm.dltEntityId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setProfile(res.data);
|
setProfile(res.data);
|
||||||
setProviderForm({
|
setStep(res.data?.executionReadiness?.missingProfileInputs?.length > 0 ? 'profile' : 'publish');
|
||||||
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) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to save provider details');
|
setError(err.response?.data?.error || 'Failed to save required profile fields');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingProvider(false);
|
setSavingProfile(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublish(e) {
|
async function handlePublish(event) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
if (!templateId.trim() || !toNumber.trim()) return;
|
if (!templateId.trim() || !toNumber.trim()) return;
|
||||||
|
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
|
|
@ -90,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
||||||
await Promise.resolve(onSuccess());
|
await Promise.resolve(onSuccess());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.data?.missingFields?.length) {
|
if (err.response?.data?.missingFields?.length) {
|
||||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`);
|
||||||
setStep('provider');
|
setStep('profile');
|
||||||
} else {
|
} else {
|
||||||
setError(err.response?.data?.error || 'Failed to publish template');
|
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;
|
const isProfileMissing = !profile?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10">
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-900/50 pb-10 pt-10 backdrop-blur-sm">
|
||||||
<div className="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto">
|
<div className="my-auto w-full max-w-md rounded-lg border border-border-main bg-surface-white p-5">
|
||||||
<div className="w-12 h-12 rounded-full bg-white border border-gray-200 flex items-center justify-center mx-auto mb-4">
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-white">
|
||||||
<span className="text-xl">✅</span>
|
<span className="text-xl">✅</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-bold text-text-primary text-center mb-1">
|
<h3 className="mb-1 text-center text-lg font-bold text-text-primary">
|
||||||
{step === 'provider' ? 'Complete Provider Details' : 'Publish Template'}
|
{step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-text-muted text-center mb-1">
|
<p className="mb-1 text-center text-sm text-text-muted">
|
||||||
{step === 'provider'
|
{step === 'profile'
|
||||||
? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.'
|
? 'Complete the required fields on the bound cURL profile before publishing.'
|
||||||
: 'Provide the DLT template ID and destination number to complete publish.'}
|
: 'Provide the DLT template ID and destination number to complete publish.'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-text-primary text-center mb-2 capitalize">
|
<p className="mb-2 text-center text-sm font-semibold capitalize text-text-primary">
|
||||||
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
||||||
</p>
|
</p>
|
||||||
{profile && (
|
{profile && (
|
||||||
<p className="text-xs text-text-muted text-center mb-6 uppercase tracking-wide font-semibold">
|
<p className="mb-6 text-center text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||||
Bound Profile: {profile.name}
|
Bound Profile: {profile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-4 py-2 rounded-md text-error-text bg-white border border-gray-200 text-sm font-medium">
|
<div className="mb-4 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'provider' ? (
|
{step === 'profile' ? (
|
||||||
<form onSubmit={handleProviderSubmit} className="space-y-4">
|
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||||
{missingFields.includes('providerName') && (
|
{missingInputs.map((input) => (
|
||||||
<div>
|
<div key={input.key}>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type={input.secret ? 'password' : 'text'}
|
||||||
value={providerForm.providerName}
|
value={profileForm[input.key] || ''}
|
||||||
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
|
onChange={(event) => setProfileForm((current) => ({
|
||||||
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"
|
...current,
|
||||||
placeholder="e.g. MSG91"
|
[input.key]: input.key === 'senderId'
|
||||||
autoFocus
|
? event.target.value.toUpperCase()
|
||||||
required
|
: 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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{missingFields.includes('senderId') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Sender ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={providerForm.senderId}
|
|
||||||
onChange={e => 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
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{missingFields.includes('dltEntityId') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Entity ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={providerForm.dltEntityId}
|
|
||||||
onChange={e => 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
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={savingProvider}
|
disabled={savingProfile}
|
||||||
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={savingProvider || isProfileMissing || missingFields.some(field => {
|
disabled={savingProfile || isProfileMissing || missingInputs.some((input) => !String(profileForm[input.key] || '').trim())}
|
||||||
if (field === 'providerName') return !providerForm.providerName.trim();
|
className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
if (field === 'senderId') return !providerForm.senderId.trim();
|
|
||||||
if (field === 'dltEntityId') return !providerForm.dltEntityId.trim();
|
|
||||||
return false;
|
|
||||||
})}
|
|
||||||
className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
{savingProvider ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving…</> : 'Save Details'}
|
{savingProfile ? 'Saving…' : 'Save Details'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handlePublish} className="space-y-4">
|
<form onSubmit={handlePublish} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">DLT Template ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={templateId}
|
value={templateId}
|
||||||
onChange={e => setTemplateId(e.target.value)}
|
onChange={(event) => setTemplateId(event.target.value)}
|
||||||
placeholder="e.g. 1234567890987654321"
|
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
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Destination Phone Number</label>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Destination Phone Number</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={toNumber}
|
value={toNumber}
|
||||||
onChange={e => setToNumber(e.target.value)}
|
onChange={(event) => setToNumber(event.target.value)}
|
||||||
placeholder="e.g. 919876543210"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p>
|
<p className="mt-1 text-xs text-text-muted">This sends the publish-triggering SMS request.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
|
|
@ -234,16 +219,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={publishing}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={publishing || !templateId.trim() || !toNumber.trim()}
|
disabled={publishing || !templateId.trim() || !toNumber.trim()}
|
||||||
className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
|
className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing…</> : 'Publish'}
|
{publishing ? 'Publishing…' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ export function BusinessProvider({ children }) {
|
||||||
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
|
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const updateReadyState = useCallback((activeProfile, templates = []) => {
|
const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => {
|
||||||
const hasProfile = !!activeProfile;
|
const hasProfile = !!activeProfile;
|
||||||
setHasGlobalSms(hasProfile);
|
const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride;
|
||||||
const p = activeProfile?.provider || {};
|
setHasGlobalSms(hasGlobalSmsProfiles);
|
||||||
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
|
const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true;
|
||||||
setIsSetupComplete(nextIsSetupComplete);
|
setIsSetupComplete(nextIsSetupComplete);
|
||||||
const nextHasSelectedTemplates = Array.isArray(templates)
|
const nextHasSelectedTemplates = Array.isArray(templates)
|
||||||
? templates.some((template) => !!template?.selectedTemplate)
|
? templates.some((template) => !!template?.selectedTemplate)
|
||||||
|
|
@ -26,7 +26,7 @@ export function BusinessProvider({ children }) {
|
||||||
setHasSelectedTemplates(nextHasSelectedTemplates);
|
setHasSelectedTemplates(nextHasSelectedTemplates);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasGlobalSms: hasProfile,
|
hasGlobalSms: hasGlobalSmsProfiles,
|
||||||
isSetupComplete: nextIsSetupComplete,
|
isSetupComplete: nextIsSetupComplete,
|
||||||
hasSelectedTemplates: nextHasSelectedTemplates,
|
hasSelectedTemplates: nextHasSelectedTemplates,
|
||||||
};
|
};
|
||||||
|
|
@ -51,7 +51,11 @@ export function BusinessProvider({ children }) {
|
||||||
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
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]);
|
}, [activeBusiness?.businessId, updateReadyState]);
|
||||||
|
|
||||||
// On mount: rehydrate from sessionStorage and refresh from API
|
// On mount: rehydrate from sessionStorage and refresh from API
|
||||||
|
|
@ -75,7 +79,11 @@ export function BusinessProvider({ children }) {
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
setActiveBusinessState(bizRes.data);
|
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({
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
|
||||||
businessId,
|
businessId,
|
||||||
companyId: runtimeCompanyId || companyId || '',
|
companyId: runtimeCompanyId || companyId || '',
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className={`mb-4 h-1.5 w-20 rounded-full ${accentClassName}`} />
|
||||||
|
<p className="text-sm font-semibold text-gray-500">{title}</p>
|
||||||
|
<p className="mt-3 text-4xl font-bold tracking-tight text-gray-900">{value}</p>
|
||||||
|
<p className="mt-3 text-sm text-gray-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Trigger Volume, Last 30 Days</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Triggered vs failed SMS attempts</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-5 text-sm font-medium text-gray-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-primary-blue" />
|
||||||
|
Triggered
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||||
|
Failed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="h-[280px] w-full">
|
||||||
|
{gridLines.map((line) => (
|
||||||
|
<g key={line.y}>
|
||||||
|
<line
|
||||||
|
x1={padding.left}
|
||||||
|
y1={line.y}
|
||||||
|
x2={width - padding.right}
|
||||||
|
y2={line.y}
|
||||||
|
stroke="#E5E7EB"
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={padding.left - 10}
|
||||||
|
y={line.y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize="11"
|
||||||
|
fill="#94A3B8"
|
||||||
|
>
|
||||||
|
{line.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="#3838C4"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
points={triggeredPoints}
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="#F87171"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
points={failedPoints}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<g key={row.key}>
|
||||||
|
<circle cx={x} cy={triggeredY} r="3.5" fill="#3838C4" />
|
||||||
|
<circle cx={x} cy={failedY} r="3.5" fill="#F87171" />
|
||||||
|
{showLabel && (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={height - 8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="11"
|
||||||
|
fill="#94A3B8"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-primary-blue" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm font-medium text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<div className="border-b border-gray-200 pb-5">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Analytics</h1>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-500">
|
||||||
|
Event trigger counts, operational health, and fallback delivery performance for this business.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<StatCard
|
||||||
|
title="Events Triggered Today"
|
||||||
|
value={formatNumber(metrics.triggeredToday)}
|
||||||
|
subtitle="Unique business events received today"
|
||||||
|
accentClassName="bg-primary-blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Global Trigger Count"
|
||||||
|
value={formatNumber(metrics.totalTriggered)}
|
||||||
|
subtitle="All tracked event executions"
|
||||||
|
accentClassName="bg-sky-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Delivery Rate"
|
||||||
|
value={formatRate(metrics.deliveryRate)}
|
||||||
|
subtitle={deliveryRateSubtitle}
|
||||||
|
accentClassName="bg-emerald-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Failed (24h)"
|
||||||
|
value={formatNumber(metrics.failedLast24Hours)}
|
||||||
|
subtitle="Send failures and failed delivery outcomes"
|
||||||
|
accentClassName="bg-red-500"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Events"
|
||||||
|
value={formatNumber(metrics.activeEvents)}
|
||||||
|
subtitle={`of ${formatNumber(metrics.totalEvents)} total events`}
|
||||||
|
accentClassName="bg-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnalyticsTrendChart rows={chartRows} />
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Event Health</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Per-event trigger counts and runtime status</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/${businessId}/events`}
|
||||||
|
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
|
||||||
|
>
|
||||||
|
View all events
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eventRows.length === 0 ? (
|
||||||
|
<div className="px-6 py-10 text-center text-sm text-gray-500">
|
||||||
|
No analytics have been recorded for this business yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-100">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr className="text-left text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">
|
||||||
|
<th className="px-6 py-4">Event</th>
|
||||||
|
<th className="px-6 py-4">Status</th>
|
||||||
|
<th className="px-6 py-4">Triggered Today</th>
|
||||||
|
<th className="px-6 py-4">Total Trigger Count</th>
|
||||||
|
<th className="px-6 py-4">Delivery Rate</th>
|
||||||
|
<th className="px-6 py-4">Last Triggered</th>
|
||||||
|
<th className="px-6 py-4 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
|
{eventRows.map((row) => (
|
||||||
|
<tr key={row.eventSlug} className="hover:bg-gray-50/70">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-semibold text-gray-900">{row.eventLabel}</div>
|
||||||
|
<div className="mt-1 font-mono text-xs text-gray-400">{row.eventSlug}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${getStatusAppearance(row.status)}`}>
|
||||||
|
{row.statusLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
||||||
|
{formatNumber(row.triggeredToday)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
||||||
|
{formatNumber(row.totalTriggerCount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm font-semibold text-gray-900">{formatRate(row.deliveryRate)}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-400">
|
||||||
|
{row.deliveryRateMode === 'send_fallback' ? 'Send fallback' : row.deliveryRateMode === 'callback' ? 'Callback-based' : 'No data'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{formatLastTriggered(row.lastTriggeredAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<Link
|
||||||
|
to={row.actionPath}
|
||||||
|
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -116,6 +116,10 @@ function buildSelectedTemplatePreview(template = {}) {
|
||||||
variableMap: template?.variableMap && typeof template.variableMap === 'object'
|
variableMap: template?.variableMap && typeof template.variableMap === 'object'
|
||||||
? template.variableMap
|
? template.variableMap
|
||||||
: {},
|
: {},
|
||||||
|
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
|
||||||
|
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
|
||||||
|
? template.executionMeta
|
||||||
|
: {},
|
||||||
curlProfileId: String(template?.curlProfileId || '').trim(),
|
curlProfileId: String(template?.curlProfileId || '').trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +209,7 @@ function createVariantDraft(text = '') {
|
||||||
currentText: text,
|
currentText: text,
|
||||||
validationStatus: 'idle',
|
validationStatus: 'idle',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -562,9 +567,20 @@ function TemplateGenerationWorkspaceModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{validationStatus === 'rejected' && currentMatchesCheckedText && activeDraft?.why && (
|
{validationStatus === 'rejected' && currentMatchesCheckedText && (activeDraft?.issues?.length > 0 || activeDraft?.why) && (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||||||
<span className="font-semibold">Why it did not pass:</span> {activeDraft.why}
|
<p className="font-semibold">Why it did not pass:</p>
|
||||||
|
{activeDraft?.issues?.length > 0 ? (
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
{activeDraft.issues.map((issue, index) => (
|
||||||
|
<li key={`${issue.code || 'issue'}-${index}`}>
|
||||||
|
{issue.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2">{activeDraft.why}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -733,7 +749,7 @@ export default function Events() {
|
||||||
} = buildTemplateUiState(templates);
|
} = buildTemplateUiState(templates);
|
||||||
|
|
||||||
setEvents(eventsRes.data.events || []);
|
setEvents(eventsRes.data.events || []);
|
||||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl);
|
||||||
setVariants(nextVariants);
|
setVariants(nextVariants);
|
||||||
setGenState(nextGenState);
|
setGenState(nextGenState);
|
||||||
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||||||
|
|
@ -1033,6 +1049,7 @@ export default function Events() {
|
||||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||||
validationStatus: 'checking',
|
validationStatus: 'checking',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1043,12 +1060,24 @@ export default function Events() {
|
||||||
editedTemplate,
|
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) => ({
|
setVariantDrafts((currentDrafts) => ({
|
||||||
...currentDrafts,
|
...currentDrafts,
|
||||||
[draftKey]: {
|
[draftKey]: {
|
||||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||||
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
||||||
why: res.data?.why || '',
|
why: String(res.data?.why || issues[0]?.message || ''),
|
||||||
|
issues,
|
||||||
lastCheckedText: editedTemplate,
|
lastCheckedText: editedTemplate,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1060,6 +1089,7 @@ export default function Events() {
|
||||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||||
validationStatus: 'idle',
|
validationStatus: 'idle',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1114,6 +1144,7 @@ export default function Events() {
|
||||||
currentText: nextText,
|
currentText: nextText,
|
||||||
validationStatus: 'idle',
|
validationStatus: 'idle',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,175 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import { useBusiness } from '../context/BusinessContext';
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||||
|
<div className="border-b border-gray-200 px-6 py-5">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Delete cURL Profile</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{preview.profile?.name || 'This profile'} will be deleted. Bound templates will be removed, but event definitions will stay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
{impactedTemplates.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Affected templates</p>
|
||||||
|
<div className="max-h-72 space-y-3 overflow-y-auto">
|
||||||
|
{impactedTemplates.map((template) => (
|
||||||
|
<div key={`${template.eventSlug}-${template.status}`} className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-medium text-gray-900">{template.eventLabel || template.eventSlug}</p>
|
||||||
|
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
|
||||||
|
{template.status || 'generated'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{template.templateId && (
|
||||||
|
<p className="mt-2 font-mono text-xs text-gray-500">DLT Template ID: {template.templateId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||||
|
No templates are currently bound to this profile.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 border-t border-gray-200 px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete Profile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlobalSms() {
|
export default function GlobalSms() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
|
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [profiles, setProfiles] = useState([]);
|
const [profiles, setProfiles] = useState([]);
|
||||||
const [activeProfileId, setActiveProfileId] = useState(null);
|
const [activeProfileId, setActiveProfileId] = useState(null);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [savingInputs, setSavingInputs] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
// Form state for Create / Edit Profile
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
const [formCurl, setFormCurl] = useState('');
|
const [formCurl, setFormCurl] = useState('');
|
||||||
const [formSetActive, setFormSetActive] = useState(true);
|
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 activeProfile = useMemo(
|
||||||
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
() => profiles.find((profile) => profile.id === activeProfileId) || null,
|
||||||
const [savingProvider, setSavingProvider] = useState(false);
|
[profiles, activeProfileId],
|
||||||
|
);
|
||||||
|
const missingInputs = activeProfile?.executionReadiness?.missingProfileInputs || [];
|
||||||
|
const hasProfiles = profiles.length > 0;
|
||||||
const eventsPath = `/${businessId}/events`;
|
const eventsPath = `/${businessId}/events`;
|
||||||
|
const analyticsPath = `/${businessId}/analytics`;
|
||||||
|
|
||||||
const loadProfiles = useCallback(async () => {
|
const loadProfiles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
||||||
const fetchedProfiles = res.data.profiles || [];
|
const fetchedProfiles = res.data?.profiles || [];
|
||||||
const fetchActiveId = res.data.activeProfileId;
|
const nextActiveProfileId = res.data?.activeProfileId || null;
|
||||||
|
const nextActiveProfile = fetchedProfiles.find((profile) => profile.id === nextActiveProfileId) || null;
|
||||||
|
const nextIsSetupComplete = nextActiveProfile?.executionReadiness?.isSetupComplete === true;
|
||||||
|
|
||||||
setProfiles(fetchedProfiles);
|
setProfiles(fetchedProfiles);
|
||||||
setActiveProfileId(fetchActiveId);
|
setActiveProfileId(nextActiveProfileId);
|
||||||
|
setHasGlobalSms(fetchedProfiles.length > 0);
|
||||||
|
setIsSetupComplete(nextIsSetupComplete);
|
||||||
|
|
||||||
const activeProfile = fetchedProfiles.find(p => p.id === fetchActiveId) || null;
|
return {
|
||||||
const hasProfile = !!activeProfile;
|
activeProfile: nextActiveProfile,
|
||||||
setHasGlobalSms(hasProfile);
|
hasProfile: !!nextActiveProfile,
|
||||||
|
complete: nextIsSetupComplete,
|
||||||
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 };
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load cURL profiles');
|
setError('Failed to load cURL profiles');
|
||||||
setHasGlobalSms(false);
|
setHasGlobalSms(false);
|
||||||
|
|
@ -66,81 +185,39 @@ export default function GlobalSms() {
|
||||||
}, [loadProfiles]);
|
}, [loadProfiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editProfileId = searchParams.get('editProfile');
|
setInputForm(getInputInitialValues(missingInputs));
|
||||||
if (!editProfileId || profiles.length === 0) return;
|
}, [activeProfileId, missingInputs]);
|
||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const ensureRevealData = useCallback(async (profileId) => {
|
||||||
nextParams.delete('editProfile');
|
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
|
||||||
|
|
||||||
const matchingProfile = profiles.find((profile) => profile.id === editProfileId);
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
|
||||||
if (matchingProfile) {
|
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
|
||||||
setEditingId(matchingProfile.id);
|
return res.data;
|
||||||
setFormName(matchingProfile.name);
|
}, [businessId, revealedProfiles]);
|
||||||
setFormCurl(matchingProfile.rawCurl);
|
|
||||||
setFormSetActive(false);
|
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
} else {
|
|
||||||
setError('The requested profile could not be found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearchParams(nextParams, { replace: true });
|
async function handleSubmit(event) {
|
||||||
}, [profiles, searchParams, setSearchParams]);
|
event.preventDefault();
|
||||||
|
|
||||||
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();
|
|
||||||
if (!formName.trim() || !formCurl.trim()) return;
|
if (!formName.trim() || !formCurl.trim()) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
const shouldAutoAdvance = !isSetupComplete;
|
const shouldAutoAdvance = !isSetupComplete;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
|
||||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${editingId}`, {
|
name: formName.trim(),
|
||||||
name: formName,
|
rawCurl: formCurl.trim(),
|
||||||
rawCurl: formCurl,
|
setActive: formSetActive,
|
||||||
});
|
});
|
||||||
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();
|
|
||||||
setFormName('');
|
setFormName('');
|
||||||
setFormCurl('');
|
setFormCurl('');
|
||||||
setEditingId(null);
|
setFormSetActive(true);
|
||||||
|
setSuccess('Profile created successfully.');
|
||||||
|
|
||||||
|
const nextState = await loadProfiles();
|
||||||
if (shouldAutoAdvance && nextState.complete) {
|
if (shouldAutoAdvance && nextState.complete) {
|
||||||
navigate(eventsPath);
|
navigate(eventsPath);
|
||||||
}
|
}
|
||||||
|
|
@ -151,21 +228,15 @@ export default function GlobalSms() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id) {
|
async function handleActivate(profileId) {
|
||||||
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) {
|
|
||||||
const shouldAutoAdvance = !isSetupComplete;
|
const shouldAutoAdvance = !isSetupComplete;
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
try {
|
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();
|
const nextState = await loadProfiles();
|
||||||
|
setSuccess('Active profile updated.');
|
||||||
if (shouldAutoAdvance && nextState.complete) {
|
if (shouldAutoAdvance && nextState.complete) {
|
||||||
navigate(eventsPath);
|
navigate(eventsPath);
|
||||||
}
|
}
|
||||||
|
|
@ -174,326 +245,380 @@ export default function GlobalSms() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleProviderSubmit(e) {
|
async function handleCopyCurl(profile) {
|
||||||
e.preventDefault();
|
try {
|
||||||
if (!activeProfileId) return;
|
const revealData = await ensureRevealData(profile.id);
|
||||||
setSavingProvider(true);
|
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('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
const shouldAutoAdvance = !isSetupComplete;
|
const shouldAutoAdvance = !isSetupComplete;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, {
|
const payload = buildProfilePatchPayload(missingInputs, inputForm);
|
||||||
provider: {
|
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
|
||||||
providerName: providerForm.providerName,
|
setSuccess('Required profile fields saved.');
|
||||||
senderId: providerForm.senderId.toUpperCase(),
|
|
||||||
dltEntityId: providerForm.dltEntityId,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setSuccess('Provider details saved successfully!');
|
|
||||||
const nextState = await loadProfiles();
|
const nextState = await loadProfiles();
|
||||||
if (shouldAutoAdvance && nextState.complete) {
|
if (shouldAutoAdvance && nextState.complete) {
|
||||||
navigate(eventsPath);
|
navigate(eventsPath);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to save provider details');
|
setError(err.response?.data?.error || 'Failed to save required profile fields');
|
||||||
} finally {
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<span className="w-8 h-8 border-4 border-spinner-track border-t-primary-blue rounded-full animate-spin" />
|
<span className="h-8 w-8 animate-spin rounded-full border-4 border-spinner-track border-t-primary-blue" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
<>
|
||||||
<div>
|
<DeleteProfileModal
|
||||||
<h2 className="text-2xl font-bold text-text-primary mb-2">Omni-channel SMS</h2>
|
preview={deletePreview}
|
||||||
<p className="text-sm text-text-muted">
|
deleting={deletingProfileId === deletePreview?.profile?.id}
|
||||||
Complete this flow to configure your cURL profile and brand provider data. You must finish setup before generating templates.
|
onCancel={() => setDeletePreview(null)}
|
||||||
</p>
|
onConfirm={handleDeleteConfirm}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{error && (
|
<div className="mx-auto max-w-4xl space-y-8 pb-12">
|
||||||
<div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-error-text text-sm font-medium flex justify-between items-center">
|
<div>
|
||||||
{error}
|
<h2 className="mb-2 text-2xl font-bold text-text-primary">Omni-channel SMS</h2>
|
||||||
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</button>
|
<p className="text-sm text-text-muted">
|
||||||
|
Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 text-sm font-medium flex justify-between items-center">
|
|
||||||
{success}
|
|
||||||
<button onClick={() => setSuccess('')} className="text-gray-700 hover:opacity-75 font-bold">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active Profile Setup Review Block */}
|
{error && (
|
||||||
{activeProfile && (
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text">
|
||||||
<div className={`p-5 rounded-lg border ${isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'} `}>
|
{error}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<button type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
|
||||||
<h3 className="font-bold text-text-primary text-lg">Active Setup: {activeProfile.name}</h3>
|
×
|
||||||
{isSetupComplete ? (
|
</button>
|
||||||
<span className="px-3 py-1 bg-white text-gray-700 border border-gray-200 rounded-full text-xs font-bold uppercase tracking-wide">Setup Complete</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-3 py-1 bg-white text-gray-700 border border-gray-200 rounded-full text-xs font-bold uppercase tracking-wide">Missing Information</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-5">
|
{success && (
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
||||||
<p className="text-sm font-medium text-text-primary">Parsed Provider Data:</p>
|
{success}
|
||||||
<ul className="space-y-2 text-sm">
|
<button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-700 hover:opacity-75">
|
||||||
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft">
|
×
|
||||||
<span className="text-text-muted">Provider:</span>
|
</button>
|
||||||
<span className="font-bold text-text-primary">{pData.providerName || <span className="text-error-text text-xs uppercase">Missing</span>}</span>
|
</div>
|
||||||
</li>
|
)}
|
||||||
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft">
|
|
||||||
<span className="text-text-muted">Sender ID:</span>
|
{activeProfile ? (
|
||||||
<span className="font-bold text-text-primary">{pData.senderId || <span className="text-error-text text-xs uppercase">Missing</span>}</span>
|
<div className={`rounded-lg border p-5 ${activeProfile.executionReadiness?.isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'}`}>
|
||||||
</li>
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft">
|
<h3 className="text-lg font-bold text-text-primary">Active Setup: {activeProfile.name}</h3>
|
||||||
<span className="text-text-muted">Entity ID:</span>
|
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-gray-700">
|
||||||
<span className="font-bold text-text-primary">{pData.dltEntityId || <span className="text-error-text text-xs uppercase">Missing</span>}</span>
|
{activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'}
|
||||||
</li>
|
</span>
|
||||||
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft">
|
|
||||||
<span className="text-text-muted">Auth Key:</span>
|
|
||||||
<span className="font-semibold text-text-primary font-mono">{pData.authKey ? '••••••••' : 'None setup'}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSetupComplete && (
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
<div className="bg-surface-white p-4 rounded-lg border border-border-main ">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-semibold text-text-primary mb-3">Please fill in the missing fields:</p>
|
<p className="text-sm font-medium text-text-primary">Current Profile Summary</p>
|
||||||
<form onSubmit={handleProviderSubmit} className="space-y-3">
|
<ul className="space-y-2 text-sm">
|
||||||
{missingFields.includes('providerName') && (
|
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
|
||||||
<input
|
<span className="text-text-muted">Provider</span>
|
||||||
type="text"
|
<span className="font-bold text-text-primary">
|
||||||
placeholder="Provider Name (e.g. MSG91)"
|
{activeProfile.provider?.providerName || <span className="text-xs uppercase text-error-text">Missing</span>}
|
||||||
value={providerForm.providerName}
|
</span>
|
||||||
onChange={e => setProviderForm({ ...providerForm, providerName: e.target.value })}
|
</li>
|
||||||
className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg"
|
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
|
||||||
required
|
<span className="text-text-muted">Sender ID</span>
|
||||||
/>
|
<span className="font-bold text-text-primary">
|
||||||
)}
|
{activeProfile.provider?.senderId || <span className="text-xs uppercase text-error-text">Missing</span>}
|
||||||
{missingFields.includes('senderId') && (
|
</span>
|
||||||
<input
|
</li>
|
||||||
type="text"
|
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
|
||||||
placeholder="Sender ID (6 letters)"
|
<span className="text-text-muted">DLT Entity ID</span>
|
||||||
maxLength={6}
|
<span className="font-bold text-text-primary">
|
||||||
value={providerForm.senderId}
|
{activeProfile.provider?.dltEntityId || <span className="text-xs uppercase text-error-text">Missing</span>}
|
||||||
onChange={e => setProviderForm({ ...providerForm, senderId: e.target.value.toUpperCase() })}
|
</span>
|
||||||
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"
|
</li>
|
||||||
required
|
<li className="rounded border border-border-soft bg-surface-white p-3">
|
||||||
/>
|
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">Setup Status</p>
|
||||||
)}
|
<p className="mt-2 text-sm text-text-primary">{getProfileSummary(activeProfile)}</p>
|
||||||
{missingFields.includes('dltEntityId') && (
|
</li>
|
||||||
<input
|
</ul>
|
||||||
type="text"
|
|
||||||
placeholder="19-digit DLT PE ID"
|
|
||||||
value={providerForm.dltEntityId}
|
|
||||||
onChange={e => 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
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={savingProvider}
|
|
||||||
className="w-full py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-bold rounded transition disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{savingProvider ? 'Saving...' : 'Save Required Details'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{isSetupComplete && (
|
{!activeProfile.executionReadiness?.isSetupComplete ? (
|
||||||
<div className="flex flex-col justify-center items-center h-full space-y-4">
|
<div className="rounded-lg border border-border-main bg-surface-white p-4">
|
||||||
<p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
|
<p className="mb-3 text-sm font-semibold text-text-primary">Complete the required fields</p>
|
||||||
<button
|
<form onSubmit={handleProviderSubmit} className="space-y-3">
|
||||||
onClick={() => navigate(eventsPath)}
|
{missingInputs.map((input) => (
|
||||||
className="px-6 py-2 bg-primary-blue hover:bg-primary-dark text-white rounded-lg font-semibold text-sm transition w-full"
|
<div key={input.key}>
|
||||||
>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
|
||||||
Continue to Events →
|
<input
|
||||||
</button>
|
type={input.secret ? 'password' : 'text'}
|
||||||
</div>
|
value={inputForm[input.key] || ''}
|
||||||
)}
|
onChange={(event) => setInputForm((current) => ({
|
||||||
</div>
|
...current,
|
||||||
</div>
|
[input.key]: input.key === 'senderId'
|
||||||
)}
|
? event.target.value.toUpperCase()
|
||||||
|
: event.target.value,
|
||||||
{hasProfiles && (
|
}))}
|
||||||
<div className="space-y-4 pt-4 border-t border-border-soft">
|
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"
|
||||||
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3>
|
placeholder={input.label}
|
||||||
{profiles.map(p => {
|
required={input.required !== false}
|
||||||
const isActive = p.id === activeProfileId;
|
/>
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className={`rounded-xl border p-5 transition-colors ${
|
|
||||||
isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
|
||||||
<h3 className="truncate text-base font-bold text-text-primary">{p.name}</h3>
|
|
||||||
{isActive && (
|
|
||||||
<span className="shrink-0 rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-primary-dark">
|
|
||||||
Active Profile
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{p.isDefault && !isActive && (
|
|
||||||
<span className="shrink-0 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-gray-600">
|
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mb-3 text-xs font-medium text-text-muted">
|
|
||||||
Updated: {new Date(p.updatedAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
|
|
||||||
<div className="border-b border-gray-800 px-4 py-2">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Raw cURL</p>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
|
))}
|
||||||
<code>{p.rawCurl}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
|
|
||||||
{!isActive && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleActivate(p.id)}
|
|
||||||
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
|
|
||||||
>
|
|
||||||
Use this cURL
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditClick(p)}
|
type="submit"
|
||||||
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-primary-blue hover:bg-page-bg hover:text-primary-blue"
|
disabled={savingInputs || missingInputs.some((input) => !String(inputForm[input.key] || '').trim())}
|
||||||
|
className="w-full rounded bg-primary-blue py-2 text-sm font-bold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Edit
|
{savingInputs ? 'Saving...' : 'Save Required Details'}
|
||||||
</button>
|
</button>
|
||||||
{profiles.length > 1 && (
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||||
|
<p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(eventsPath)}
|
||||||
|
className="w-full rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
Continue to Events →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : hasProfiles ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
|
Select an active cURL profile to continue. Your saved profiles are still available below.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasProfiles && (
|
||||||
|
<div className="space-y-4 border-t border-border-soft pt-4">
|
||||||
|
<h3 className="text-lg font-bold text-text-primary">Saved Profiles</h3>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
className={`rounded-xl border p-5 transition-colors ${isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<h3 className="truncate text-base font-bold text-text-primary">{profile.name}</h3>
|
||||||
|
{isActive && (
|
||||||
|
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-primary-dark">
|
||||||
|
Active Profile
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider ${profile.executionReadiness?.isSetupComplete ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-amber-200 bg-amber-50 text-amber-700'}`}>
|
||||||
|
{profile.executionReadiness?.isSetupComplete ? 'Ready' : 'Needs Fields'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-xs font-medium text-text-muted">Updated: {formatUpdatedAt(profile.updatedAt)}</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">{getProfileSummary(profile)}</p>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Stored cURL</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleCurl(profile)}
|
||||||
|
className="text-xs font-semibold text-gray-300 transition hover:text-white"
|
||||||
|
>
|
||||||
|
{isVisible ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
|
||||||
|
<code>{displayCurl || 'No cURL stored.'}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleActivate(profile.id)}
|
||||||
|
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
Use this cURL
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(p.id)}
|
type="button"
|
||||||
|
onClick={() => handleCopyCurl(profile)}
|
||||||
|
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-primary-blue hover:bg-page-bg hover:text-primary-blue"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteRequest(profile)}
|
||||||
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-error-text hover:bg-red-50 hover:text-error-text"
|
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-error-text hover:bg-red-50 hover:text-error-text"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inline Form (Create / Edit) */}
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden rounded-xl border ${
|
|
||||||
isCreatingFirstProfile ? 'border-indigo-100 bg-white shadow-sm' : 'border-border-main bg-surface-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex items-start justify-between gap-4 px-6 py-5 ${
|
|
||||||
isCreatingFirstProfile ? 'border-b border-indigo-100 bg-indigo-50/60' : 'border-b border-border-main bg-table-header'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-text-primary text-md">
|
|
||||||
{editingId ? 'Edit Profile' : isCreatingFirstProfile ? 'Create Your First cURL Profile' : 'Add New Profile'}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
|
||||||
Give this profile a recognizable name, then paste the full provider cURL command below.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{editingId && (
|
)}
|
||||||
<button onClick={handleAddClick} className="text-sm font-semibold text-primary-blue hover:text-primary-dark hover:underline">
|
|
||||||
Switch to Add New
|
<div className={`overflow-hidden rounded-xl border ${!hasProfiles ? 'border-indigo-100 bg-white shadow-sm' : 'border-border-main bg-surface-white'}`}>
|
||||||
</button>
|
<div className={`flex items-start justify-between gap-4 px-6 py-5 ${!hasProfiles ? 'border-b border-indigo-100 bg-indigo-50/60' : 'border-b border-border-main bg-table-header'}`}>
|
||||||
)}
|
<div>
|
||||||
</div>
|
<h3 className="text-md font-bold text-text-primary">Add New Profile</h3>
|
||||||
<div className="p-6">
|
|
||||||
{isCreatingFirstProfile && (
|
|
||||||
<div className="mb-6 rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-4">
|
|
||||||
<p className="text-sm font-semibold text-text-primary">Start by adding a cURL profile</p>
|
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
|
||||||
<div>
|
<div className="p-6">
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label>
|
{!hasProfiles && (
|
||||||
<p className="mb-2 text-xs font-medium text-text-muted">
|
<div className="mb-6 rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-4">
|
||||||
Use a name you will recognize later, such as `Production SMS` or `Backup Provider`.
|
<p className="text-sm font-semibold text-text-primary">Start by adding a cURL profile</p>
|
||||||
</p>
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
<input
|
This becomes the base for validating provider details and unlocking event template generation.
|
||||||
type="text"
|
</p>
|
||||||
value={formName}
|
</div>
|
||||||
onChange={e => 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"
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
required
|
<div>
|
||||||
/>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Profile Name</label>
|
||||||
</div>
|
<input
|
||||||
<div>
|
type="text"
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Raw cURL Command</label>
|
value={formName}
|
||||||
<p className="mb-2 text-xs font-medium text-text-muted">
|
onChange={(event) => setFormName(event.target.value)}
|
||||||
Paste the full request exactly as supplied by your SMS provider. You can include the entire command.
|
placeholder="e.g. Production SMS"
|
||||||
</p>
|
className="w-full rounded-lg border border-border-main bg-white px-4 py-2 text-sm text-text-primary transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
<textarea
|
required
|
||||||
value={formCurl}
|
/>
|
||||||
onChange={e => setFormCurl(e.target.value)}
|
</div>
|
||||||
placeholder="curl --request POST --url ..."
|
|
||||||
className="h-48 w-full resize-none rounded-lg border border-border-main bg-white px-4 py-3 font-mono text-sm leading-relaxed text-text-primary placeholder-placeholder-bg transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
<div>
|
||||||
required
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider cURL Command</label>
|
||||||
spellCheck="false"
|
<textarea
|
||||||
/>
|
value={formCurl}
|
||||||
</div>
|
onChange={(event) => setFormCurl(event.target.value)}
|
||||||
{!editingId && hasProfiles && (
|
placeholder="curl --request POST --url ..."
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
className="h-48 w-full resize-none rounded-lg border border-border-main bg-white px-4 py-3 font-mono text-sm leading-relaxed text-text-primary transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
|
required
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 text-primary-blue rounded border-border-main focus:ring-primary-blue"
|
className="h-4 w-4 rounded border-border-main text-primary-blue focus:ring-primary-blue"
|
||||||
checked={formSetActive}
|
checked={formSetActive}
|
||||||
onChange={e => setFormSetActive(e.target.checked)}
|
onChange={(event) => setFormSetActive(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold text-text-primary">Set as active profile immediately</span>
|
<span className="text-sm font-semibold text-text-primary">Set as active profile immediately</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={saving}
|
|
||||||
className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm transition disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving…</> : (editingId ? 'Update Profile' : 'Save Profile')}
|
|
||||||
</button>
|
|
||||||
{editingId && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
onClick={handleAddClick}
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-5 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg font-medium text-sm transition"
|
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel Edit
|
{saving ? 'Saving…' : 'Save Profile'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,123 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import { useBusiness } from '../context/BusinessContext';
|
import { useBusiness } from '../context/BusinessContext';
|
||||||
|
|
||||||
function getMissingProviderFields(profile) {
|
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||||
const provider = profile?.provider || {};
|
|
||||||
const missing = [];
|
|
||||||
|
|
||||||
if (!provider.providerName) missing.push('Provider Name');
|
function normalizeCurlForDisplay(value) {
|
||||||
if (!provider.senderId) missing.push('Sender ID');
|
if (!value) return '';
|
||||||
if (!provider.dltEntityId) missing.push('DLT Entity ID');
|
|
||||||
|
|
||||||
return missing;
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\\r\\n/g, '\n')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\t/g, ' ')
|
||||||
|
.replace(/\\'/g, '\'')
|
||||||
|
.replace(/\\"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProviderSetupComplete(profile) {
|
function stripWrappingQuotes(value) {
|
||||||
return getMissingProviderFields(profile).length === 0;
|
if (!value || value.length < 2) return value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.startsWith('\'') && value.endsWith('\''))
|
||||||
|
|| (value.startsWith('"') && value.endsWith('"'))
|
||||||
|
) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurlCommand(normalizedCurl) {
|
||||||
|
if (!normalizedCurl) return '';
|
||||||
|
|
||||||
|
let output = normalizedCurl;
|
||||||
|
|
||||||
|
if (!output.includes('\n')) {
|
||||||
|
output = output
|
||||||
|
.replace(/^curl\s+/, 'curl\n ')
|
||||||
|
.replace(/\s+(--request|-X|--url|--header|-H|--data-raw|--data|-d|--compressed|--location|--insecure|--fail)\b/g, '\n $1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCurlBody(normalizedCurl) {
|
||||||
|
if (!normalizedCurl) return '';
|
||||||
|
|
||||||
|
const quotedMatch = normalizedCurl.match(
|
||||||
|
/(?:--data-raw|--data|-d)\s+(["'])([\s\S]*?)\1(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
|
||||||
|
);
|
||||||
|
if (quotedMatch?.[2]) return stripWrappingQuotes(quotedMatch[2].trim());
|
||||||
|
|
||||||
|
const braceMatch = normalizedCurl.match(
|
||||||
|
/(?:--data-raw|--data|-d)\s+({[\s\S]*})(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
return braceMatch?.[1]?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurlViewModel(value) {
|
||||||
|
const normalizedCurl = normalizeCurlForDisplay(value);
|
||||||
|
const headers = [
|
||||||
|
...normalizedCurl.matchAll(/(?:--header|-H)\s+(?:"([^"]+)"|'([^']+)')/g),
|
||||||
|
]
|
||||||
|
.map((match) => (match[1] || match[2] || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const methodMatch = normalizedCurl.match(/(?:--request|-X)\s+([A-Z]+)/i);
|
||||||
|
const method = (methodMatch?.[1] || (/(?:--data-raw|--data|-d)\b/i.test(normalizedCurl) ? 'POST' : 'GET')).toUpperCase();
|
||||||
|
const url = normalizedCurl.match(/https?:\/\/[^\s'"]+/i)?.[0] || '';
|
||||||
|
const rawBody = extractCurlBody(normalizedCurl);
|
||||||
|
|
||||||
|
let payload = stripWrappingQuotes(rawBody || '').trim();
|
||||||
|
let prettyPayload = '';
|
||||||
|
let payloadFormat = '';
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
prettyPayload = JSON.stringify(parsed, null, 2);
|
||||||
|
payloadFormat = 'json';
|
||||||
|
} catch {
|
||||||
|
prettyPayload = payload;
|
||||||
|
payloadFormat = 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = '';
|
||||||
|
try {
|
||||||
|
host = url ? new URL(url).host : '';
|
||||||
|
} catch {
|
||||||
|
host = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellLines = [];
|
||||||
|
if (url) {
|
||||||
|
shellLines.push('curl \\');
|
||||||
|
shellLines.push(` --request ${method} \\`);
|
||||||
|
shellLines.push(` --url '${url}'${headers.length || rawBody ? ' \\' : ''}`);
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const hasTrailingSection = index < headers.length - 1 || Boolean(rawBody);
|
||||||
|
shellLines.push(` --header '${header}'${hasTrailingSection ? ' \\' : ''}`);
|
||||||
|
});
|
||||||
|
if (rawBody) {
|
||||||
|
shellLines.push(` --data-raw '${payloadFormat === 'json' ? '<payload shown below>' : rawBody}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: shellLines.length > 0 ? shellLines.join('\n') : formatCurlCommand(normalizedCurl),
|
||||||
|
headers,
|
||||||
|
host,
|
||||||
|
method,
|
||||||
|
payload: prettyPayload,
|
||||||
|
payloadFormat,
|
||||||
|
url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUpdatedAt(value) {
|
function formatUpdatedAt(value) {
|
||||||
|
|
@ -28,15 +130,46 @@ function formatUpdatedAt(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProviderSummary(profile) {
|
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||||
const provider = profile?.provider || {};
|
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 getInitialFormValues(inputs = []) {
|
||||||
|
return inputs.reduce((accumulator, input) => {
|
||||||
|
accumulator[input.key] = input.secret ? '' : (input.value || '');
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileSummary(profile) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const provider = profile?.provider || {};
|
||||||
|
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
|
||||||
|
|
||||||
if (provider.providerName) parts.push(provider.providerName);
|
if (provider.providerName) parts.push(provider.providerName);
|
||||||
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
|
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
|
||||||
if (provider.dltEntityId) parts.push('DLT added');
|
if (provider.dltEntityId) parts.push('DLT ready');
|
||||||
|
if (missingCount > 0) parts.push(`${missingCount} pending`);
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(' • ') : 'Provider details not completed yet';
|
return parts.join(' • ') || 'Profile saved. Complete the required fields to use it everywhere.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileStatusPill({ complete }) {
|
function ProfileStatusPill({ complete }) {
|
||||||
|
|
@ -48,11 +181,20 @@ function ProfileStatusPill({ complete }) {
|
||||||
: 'border-amber-200 bg-amber-50 text-amber-700'
|
: 'border-amber-200 bg-amber-50 text-amber-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{complete ? 'Complete' : 'Missing Fields'}
|
{complete ? 'Ready' : 'Needs Fields'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InspectorRow({ label, value, valueClassName = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">{label}</p>
|
||||||
|
<p className={`mt-2 text-sm font-medium text-gray-900 ${valueClassName}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Providers() {
|
export default function Providers() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -62,16 +204,11 @@ export default function Providers() {
|
||||||
const [profiles, setProfiles] = useState([]);
|
const [profiles, setProfiles] = useState([]);
|
||||||
const [activeProfileId, setActiveProfileId] = useState('');
|
const [activeProfileId, setActiveProfileId] = useState('');
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||||
const [form, setForm] = useState({
|
const [formValues, setFormValues] = useState({});
|
||||||
providerName: '',
|
const [revealedProfiles, setRevealedProfiles] = useState({});
|
||||||
senderId: '',
|
const [showSecretsByProfileId, setShowSecretsByProfileId] = useState({});
|
||||||
dltEntityId: '',
|
|
||||||
authKey: '',
|
|
||||||
});
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [copiedProfileId, setCopiedProfileId] = useState('');
|
|
||||||
const copyTimeoutRef = useRef(null);
|
|
||||||
|
|
||||||
const globalSmsPath = `/${businessId}/global-sms`;
|
const globalSmsPath = `/${businessId}/global-sms`;
|
||||||
|
|
||||||
|
|
@ -100,37 +237,41 @@ export default function Providers() {
|
||||||
loadProfiles();
|
loadProfiles();
|
||||||
}, [loadProfiles]);
|
}, [loadProfiles]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
const selectedProfile = useMemo(
|
||||||
if (copyTimeoutRef.current) {
|
() => profiles.find((profile) => profile.id === selectedProfileId) || null,
|
||||||
clearTimeout(copyTimeoutRef.current);
|
[profiles, selectedProfileId],
|
||||||
}
|
);
|
||||||
}, []);
|
const selectedProfileInputs = selectedProfile?.profileInputs || [];
|
||||||
|
const isSelectedProfileRevealed = selectedProfile ? showSecretsByProfileId[selectedProfile.id] === true : false;
|
||||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) || null;
|
const selectedRevealData = selectedProfile ? revealedProfiles[selectedProfile.id] : null;
|
||||||
|
const selectedDisplayCurl = selectedProfile
|
||||||
|
? (isSelectedProfileRevealed
|
||||||
|
? (selectedRevealData?.rawCurl || selectedProfile.maskedCurl)
|
||||||
|
: selectedProfile.maskedCurl)
|
||||||
|
: '';
|
||||||
|
const selectedCurlView = useMemo(
|
||||||
|
() => buildCurlViewModel(selectedDisplayCurl),
|
||||||
|
[selectedDisplayCurl],
|
||||||
|
);
|
||||||
|
const missingInputCount = selectedProfile?.executionReadiness?.missingProfileInputs?.length || 0;
|
||||||
|
const curlWarnings = selectedProfile?.curlAnalysis?.warnings || [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProfile) {
|
if (!selectedProfile) {
|
||||||
setForm({
|
setFormValues({});
|
||||||
providerName: '',
|
|
||||||
senderId: '',
|
|
||||||
dltEntityId: '',
|
|
||||||
authKey: '',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = selectedProfile.provider || {};
|
setFormValues(getInitialFormValues(selectedProfile.profileInputs));
|
||||||
setForm({
|
|
||||||
providerName: provider.providerName || '',
|
|
||||||
senderId: provider.senderId || '',
|
|
||||||
dltEntityId: provider.dltEntityId || '',
|
|
||||||
authKey: provider.authKey || '',
|
|
||||||
});
|
|
||||||
}, [selectedProfile]);
|
}, [selectedProfile]);
|
||||||
|
|
||||||
function handleChange(field, value) {
|
const ensureRevealData = useCallback(async (profileId) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
|
||||||
}
|
|
||||||
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
|
||||||
|
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
|
||||||
|
return res.data;
|
||||||
|
}, [businessId, revealedProfiles]);
|
||||||
|
|
||||||
function handleSelectProfile(profileId) {
|
function handleSelectProfile(profileId) {
|
||||||
setSelectedProfileId(profileId);
|
setSelectedProfileId(profileId);
|
||||||
|
|
@ -160,53 +301,55 @@ export default function Providers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleReveal(profile) {
|
||||||
|
if (!profile?.id) return;
|
||||||
|
|
||||||
|
const shouldReveal = !showSecretsByProfileId[profile.id];
|
||||||
|
if (shouldReveal) {
|
||||||
|
try {
|
||||||
|
const revealData = await ensureRevealData(profile.id);
|
||||||
|
const revealedValues = (revealData?.profileInputs || []).reduce((accumulator, input) => {
|
||||||
|
accumulator[input.key] = input.value || '';
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
setFormValues((current) => ({ ...current, ...revealedValues }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to reveal saved values');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowSecretsByProfileId((current) => ({
|
||||||
|
...current,
|
||||||
|
[profile.id]: shouldReveal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCopyCurl(profile) {
|
async function handleCopyCurl(profile) {
|
||||||
if (!profile?.rawCurl) return;
|
if (!profile?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!navigator?.clipboard?.writeText) {
|
const revealData = await ensureRevealData(profile.id);
|
||||||
throw new Error('Clipboard API unavailable');
|
if (!revealData?.rawCurl) return;
|
||||||
}
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(profile.rawCurl);
|
await navigator.clipboard.writeText(revealData.rawCurl);
|
||||||
setCopiedProfileId(profile.id);
|
setSuccess(`Copied ${profile.name} cURL.`);
|
||||||
|
} catch (err) {
|
||||||
if (copyTimeoutRef.current) {
|
setError(err.response?.data?.error || 'Failed to copy the cURL command.');
|
||||||
clearTimeout(copyTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
copyTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setCopiedProfileId('');
|
|
||||||
}, 1800);
|
|
||||||
} catch {
|
|
||||||
setError('Failed to copy the cURL command.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(event) {
|
async function handleSave(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!selectedProfile?.id) return;
|
if (!selectedProfile?.id) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
|
|
||||||
if (form.senderId && !/^[A-Za-z]{6}$/.test(form.senderId)) {
|
|
||||||
setError('DLT Sender ID must be exactly 6 alphabet characters');
|
|
||||||
setSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, {
|
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
|
||||||
provider: {
|
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
|
||||||
providerName: form.providerName,
|
|
||||||
senderId: form.senderId.toUpperCase(),
|
|
||||||
dltEntityId: form.dltEntityId,
|
|
||||||
authKey: form.authKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
await refreshOnboardingState(businessId).catch(() => null);
|
await refreshOnboardingState(businessId).catch(() => null);
|
||||||
|
|
@ -221,7 +364,7 @@ export default function Providers() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +375,7 @@ export default function Providers() {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
|
||||||
<p className="mt-1 text-sm font-medium text-gray-500">
|
<p className="mt-1 text-sm font-medium text-gray-500">
|
||||||
Review the provider details stored against each saved cURL profile.
|
Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -252,6 +395,7 @@ export default function Providers() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
||||||
{success}
|
{success}
|
||||||
|
|
@ -293,7 +437,7 @@ export default function Providers() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{profiles.map((profile) => {
|
{profiles.map((profile) => {
|
||||||
const isActive = profile.id === activeProfileId;
|
const isActive = profile.id === activeProfileId;
|
||||||
const complete = isProviderSetupComplete(profile);
|
const complete = profile.executionReadiness?.isSetupComplete === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -305,7 +449,7 @@ export default function Providers() {
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p>
|
<p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-gray-500">{buildProviderSummary(profile)}</p>
|
<p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
|
|
@ -352,10 +496,10 @@ export default function Providers() {
|
||||||
Active profile
|
Active profile
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ProfileStatusPill complete={isProviderSetupComplete(selectedProfile)} />
|
<ProfileStatusPill complete={selectedProfile.executionReadiness?.isSetupComplete === true} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
Review the exact saved request, then update the provider fields tied to this profile.
|
The stored cURL is immutable after validation. You can review it, reveal it, and update the profile fields it depends on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -371,17 +515,17 @@ export default function Providers() {
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCopyCurl(selectedProfile)}
|
onClick={() => handleToggleReveal(selectedProfile)}
|
||||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
||||||
>
|
>
|
||||||
{copiedProfileId === selectedProfile.id ? 'Copied' : 'Copy cURL'}
|
{showSecretsByProfileId[selectedProfile.id] ? 'Hide Values' : 'Reveal Values'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`${globalSmsPath}?editProfile=${encodeURIComponent(selectedProfile.id)}`)}
|
onClick={() => handleCopyCurl(selectedProfile)}
|
||||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
||||||
>
|
>
|
||||||
Edit cURL
|
Copy cURL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -389,130 +533,169 @@ export default function Providers() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 px-6 py-6">
|
<div className="space-y-6 px-6 py-6">
|
||||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-950">
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px] xl:items-start">
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-gray-800 px-4 py-3">
|
<div className="overflow-hidden rounded-[28px] border border-slate-200 bg-slate-950 shadow-[0_28px_60px_-42px_rgba(15,23,42,0.75)]">
|
||||||
<div>
|
<div className="border-b border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.2),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.96))] px-5 py-5">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Preview</p>
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
</div>
|
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||||
<span className="rounded-full border border-gray-700 bg-gray-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-300">
|
{selectedCurlView.method}
|
||||||
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
</span>
|
||||||
</span>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<p className="truncate text-sm font-semibold text-white">
|
||||||
<pre className="max-h-72 overflow-y-auto overscroll-contain whitespace-pre-wrap break-all px-4 py-4 text-xs leading-relaxed text-gray-100">
|
{selectedCurlView.url || 'Endpoint not detected from stored cURL'}
|
||||||
<code>{selectedProfile.rawCurl}</code>
|
</p>
|
||||||
</pre>
|
<p className="mt-1 text-xs font-medium text-slate-400">
|
||||||
</div>
|
{isSelectedProfileRevealed
|
||||||
|
? 'Saved values are currently rendered inside this request preview.'
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_260px]">
|
: 'Sensitive values stay masked until you explicitly reveal them.'}
|
||||||
<form onSubmit={handleSave} className="overflow-hidden rounded-2xl border border-gray-200 bg-white">
|
</p>
|
||||||
<div className="border-b border-gray-200 px-5 py-4">
|
</div>
|
||||||
<p className="text-sm font-semibold text-gray-900">Provider Details</p>
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
||||||
These fields are stored against this profile and are used during template publishing.
|
</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5 px-5 py-5">
|
<div className="space-y-5 px-5 py-5">
|
||||||
<div>
|
<div>
|
||||||
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}>
|
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shell View</p>
|
||||||
Provider Name {!form.providerName && <span className="text-error-text">*</span>}
|
<pre className="max-h-[26rem] overflow-y-auto overscroll-contain rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-[13px] leading-7 text-slate-100 shadow-inner">
|
||||||
</label>
|
<code>{selectedCurlView.command || 'No cURL stored.'}</code>
|
||||||
<input
|
</pre>
|
||||||
type="text"
|
</div>
|
||||||
value={form.providerName}
|
|
||||||
onChange={(event) => handleChange('providerName', event.target.value)}
|
{selectedCurlView.payload && (
|
||||||
className={`w-full rounded-lg border px-4 py-2 text-sm font-medium text-text-primary placeholder-placeholder-bg transition focus:border-transparent focus:outline-none focus:ring-2 ${!form.providerName ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} bg-surface-white`}
|
<div>
|
||||||
placeholder="e.g. MSG91, Gupshup"
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Request Payload</p>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||||
|
{selectedCurlView.payloadFormat === 'json' ? 'JSON' : 'Text'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-[22rem] overflow-y-auto overscroll-contain rounded-2xl border border-emerald-400/10 bg-emerald-400/5 px-4 py-4 text-[13px] leading-7 text-emerald-50 shadow-inner">
|
||||||
|
<code>{selectedCurlView.payload}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="rounded-[28px] border border-gray-200 bg-gray-50 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Profile Inspector</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Keep the request front and center, then reveal stored values only when you need to inspect or edit them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${isSelectedProfileRevealed ? 'border-indigo-200 bg-indigo-50 text-primary-dark' : 'border-gray-200 bg-white text-gray-500'}`}>
|
||||||
|
{isSelectedProfileRevealed ? 'Values visible' : 'Values hidden'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<InspectorRow
|
||||||
|
label="Profile State"
|
||||||
|
value={selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Provider"
|
||||||
|
value={selectedProfile.provider?.providerName || selectedProfile.curlAnalysis?.providerName || 'Awaiting provider name'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Endpoint Host"
|
||||||
|
value={selectedCurlView.host || 'Not detected'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Auth Mode"
|
||||||
|
value={selectedProfile.curlAnalysis?.authMode || 'Not detected'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Profile Fields"
|
||||||
|
value={`${selectedProfileInputs.length} stored value${selectedProfileInputs.length === 1 ? '' : 's'}`}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Setup"
|
||||||
|
value={selectedProfile.executionReadiness?.isSetupComplete
|
||||||
|
? 'All required profile inputs are complete.'
|
||||||
|
: `${missingInputCount} required field${missingInputCount === 1 ? '' : 's'} still missing`}
|
||||||
|
valueClassName={selectedProfile.executionReadiness?.isSetupComplete ? '' : 'text-amber-700'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
{curlWarnings.length > 0 && (
|
||||||
<div>
|
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||||
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">Warnings</p>
|
||||||
DLT Sender ID {!form.senderId && <span className="text-error-text">*</span>}
|
<ul className="mt-2 space-y-2 text-sm text-amber-900">
|
||||||
</label>
|
{curlWarnings.map((warning) => (
|
||||||
<input
|
<li key={warning}>{warning}</li>
|
||||||
type="text"
|
))}
|
||||||
value={form.senderId}
|
</ul>
|
||||||
onChange={(event) => handleChange('senderId', event.target.value.toUpperCase())}
|
|
||||||
maxLength={6}
|
|
||||||
className={`w-full rounded-lg border px-4 py-2 font-mono text-sm uppercase tracking-widest text-text-primary placeholder-placeholder-bg transition focus:border-transparent focus:outline-none focus:ring-2 ${!form.senderId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} bg-surface-white`}
|
|
||||||
placeholder="6 CHARS"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs font-medium text-gray-500">Exactly 6 alphabetic characters.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
|
||||||
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.dltEntityId ? 'text-error-text' : 'text-text-primary'}`}>
|
|
||||||
DLT Entity ID {!form.dltEntityId && <span className="text-error-text">*</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.dltEntityId}
|
|
||||||
onChange={(event) => handleChange('dltEntityId', event.target.value)}
|
|
||||||
className={`w-full rounded-lg border px-4 py-2 font-mono text-sm text-text-primary placeholder-placeholder-bg transition focus:border-transparent focus:outline-none focus:ring-2 ${!form.dltEntityId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} bg-surface-white`}
|
|
||||||
placeholder="19-digit DLT PE ID"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-semibold tracking-wide text-text-primary">
|
|
||||||
API Auth Key <span className="text-xs font-normal text-text-muted">(Optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.authKey}
|
|
||||||
onChange={(event) => handleChange('authKey', event.target.value)}
|
|
||||||
className="w-full rounded-lg border border-border-main bg-surface-white px-4 py-2 font-mono text-sm text-text-primary placeholder-placeholder-bg transition focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
|
||||||
placeholder="Authorization key for your SMS provider"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs font-medium text-gray-500">
|
|
||||||
Used as the Authorization header in your SMS requests.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
|
{isSelectedProfileRevealed ? (
|
||||||
<button
|
<form onSubmit={handleSave} className="overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-sm">
|
||||||
type="submit"
|
<div className="border-b border-gray-200 px-5 py-4">
|
||||||
disabled={saving}
|
<p className="text-sm font-semibold text-gray-900">Stored Profile Values</p>
|
||||||
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
>
|
These fields appear only in reveal mode and stay tied to this immutable cURL profile.
|
||||||
{saving ? (
|
</p>
|
||||||
<>
|
</div>
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
|
||||||
Saving…
|
|
||||||
</>
|
|
||||||
) : 'Save Configuration'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<aside className="rounded-2xl border border-gray-200 bg-gray-50 p-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<p className="text-sm font-semibold text-gray-900">Current Status</p>
|
{selectedProfileInputs.length > 0 ? selectedProfileInputs.map((input) => (
|
||||||
<ul className="mt-4 space-y-3 text-sm">
|
<div key={input.key} className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4">
|
||||||
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
|
<label className={`mb-1.5 block text-sm font-semibold ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'text-error-text' : 'text-gray-900'}`}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Profile State</p>
|
{input.label} {input.required ? <span className="text-error-text">*</span> : null}
|
||||||
<p className="mt-2 font-medium text-gray-900">
|
</label>
|
||||||
{selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formValues[input.key] || ''}
|
||||||
|
onChange={(event) => setFormValues((current) => ({
|
||||||
|
...current,
|
||||||
|
[input.key]: input.key === 'senderId'
|
||||||
|
? event.target.value.toUpperCase()
|
||||||
|
: event.target.value,
|
||||||
|
}))}
|
||||||
|
className={`w-full rounded-xl border bg-white px-4 py-2.5 text-sm font-medium text-gray-900 transition focus:border-transparent focus:outline-none focus:ring-2 ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'border-error-text focus:ring-error-text' : 'border-gray-200 focus:ring-primary-blue'}`}
|
||||||
|
placeholder={input.label}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs font-medium text-gray-500">
|
||||||
|
{input.secret
|
||||||
|
? 'Sensitive value revealed for this inspection session.'
|
||||||
|
: input.source === 'embedded'
|
||||||
|
? 'Extracted from the accepted cURL and stored against this profile.'
|
||||||
|
: 'Stored on the profile before publish and runtime sends continue.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-sm text-gray-600">
|
||||||
|
No profile-level stored values were extracted from this cURL.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-[28px] border border-dashed border-gray-300 bg-white px-5 py-5">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Values stay hidden by default</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-gray-500">
|
||||||
|
Reveal mode will render stored values inside the cURL on the left and open the editable field inspector here.
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</div>
|
||||||
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
|
)}
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Provider Setup</p>
|
|
||||||
<p className="mt-2 font-medium text-gray-900">
|
|
||||||
{isProviderSetupComplete(selectedProfile)
|
|
||||||
? 'All mandatory provider fields are complete.'
|
|
||||||
: getMissingProviderFields(selectedProfile).join(', ')}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Auth Key</p>
|
|
||||||
<p className="mt-2 font-medium text-gray-900">
|
|
||||||
{selectedProfile.provider?.authKey ? 'Saved on this profile' : 'Not added'}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,519 @@
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInteger(value) {
|
||||||
|
return Number.isInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectionString() {
|
||||||
|
return normalizeText(
|
||||||
|
process.env.FDK_STORAGE_CONNECTION_STRING
|
||||||
|
|| process.env.DATABASE_URL
|
||||||
|
|| process.env.POSTGRES_CONNECTION_STRING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let analyticsPool = null;
|
||||||
|
|
||||||
|
function getPool() {
|
||||||
|
if (analyticsPool) return analyticsPool;
|
||||||
|
|
||||||
|
const connectionString = getConnectionString();
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error('Analytics database is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
analyticsPool = new Pool({ connectionString });
|
||||||
|
return analyticsPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPhoneMetadata(value) {
|
||||||
|
const digits = String(value || '').replace(/\D/g, '');
|
||||||
|
if (!digits) {
|
||||||
|
return {
|
||||||
|
toNumberHash: '',
|
||||||
|
toNumberLast4: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toNumberHash: sha256(digits),
|
||||||
|
toNumberLast4: digits.slice(-4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSourceEventKey({ applicationId, shipmentId, orderId, eventSlug, payload = null }) {
|
||||||
|
const normalizedParts = {
|
||||||
|
applicationId: normalizeText(applicationId),
|
||||||
|
shipmentId: normalizeText(shipmentId),
|
||||||
|
orderId: normalizeText(orderId),
|
||||||
|
eventSlug: normalizeText(eventSlug),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasStableIdentifiers = normalizedParts.applicationId && normalizedParts.eventSlug
|
||||||
|
&& (normalizedParts.shipmentId || normalizedParts.orderId);
|
||||||
|
|
||||||
|
if (hasStableIdentifiers) {
|
||||||
|
return sha256(JSON.stringify(normalizedParts));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sha256(JSON.stringify({
|
||||||
|
...normalizedParts,
|
||||||
|
payload,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatusFingerprint(entry = {}) {
|
||||||
|
return sha256(JSON.stringify({
|
||||||
|
messageExecutionId: entry.messageExecutionId,
|
||||||
|
statusSource: normalizeText(entry.statusSource),
|
||||||
|
statusType: normalizeText(entry.statusType),
|
||||||
|
normalizedStatus: normalizeText(entry.normalizedStatus),
|
||||||
|
providerMessageId: normalizeText(entry.providerMessageId),
|
||||||
|
providerStatus: normalizeText(entry.providerStatus),
|
||||||
|
providerStatusCode: normalizeText(entry.providerStatusCode),
|
||||||
|
errorCode: normalizeText(entry.errorCode),
|
||||||
|
errorMessage: normalizeText(entry.errorMessage),
|
||||||
|
payload: entry.payload || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProviderMessageId(value, depth = 0) {
|
||||||
|
if (!value || depth > 4) return '';
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
const nestedMatch = extractProviderMessageId(entry, depth + 1);
|
||||||
|
if (nestedMatch) return nestedMatch;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const candidates = [
|
||||||
|
value.message_id,
|
||||||
|
value.messageId,
|
||||||
|
value.msg_id,
|
||||||
|
value.msgid,
|
||||||
|
value.sms_id,
|
||||||
|
value.smsId,
|
||||||
|
value.request_id,
|
||||||
|
value.requestId,
|
||||||
|
value.id,
|
||||||
|
]
|
||||||
|
.map((entry) => normalizeText(entry))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (candidates.length > 0) return candidates[0];
|
||||||
|
|
||||||
|
for (const nestedValue of Object.values(value)) {
|
||||||
|
const nestedMatch = extractProviderMessageId(nestedValue, depth + 1);
|
||||||
|
if (nestedMatch) return nestedMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecutionFilters({ companyId, businessId }) {
|
||||||
|
const values = [];
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (normalizeText(companyId)) {
|
||||||
|
values.push(normalizeText(companyId));
|
||||||
|
conditions.push(`company_id = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeText(businessId)) {
|
||||||
|
values.push(normalizeText(businessId));
|
||||||
|
conditions.push(`business_id = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
throw new Error('Analytics queries require at least one scope filter');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
whereClause: conditions.join(' AND '),
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCount(value) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFallbackRate({
|
||||||
|
deliveredCount = 0,
|
||||||
|
deliveryFailedCount = 0,
|
||||||
|
acceptedCount = 0,
|
||||||
|
sendFailedCount = 0,
|
||||||
|
}) {
|
||||||
|
const deliveryTerminalTotal = deliveredCount + deliveryFailedCount;
|
||||||
|
if (deliveryTerminalTotal > 0) {
|
||||||
|
return {
|
||||||
|
rate: deliveredCount / deliveryTerminalTotal,
|
||||||
|
mode: 'callback',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendTerminalTotal = acceptedCount + sendFailedCount;
|
||||||
|
if (sendTerminalTotal > 0) {
|
||||||
|
return {
|
||||||
|
rate: acceptedCount / sendTerminalTotal,
|
||||||
|
mode: 'send_fallback',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rate: null,
|
||||||
|
mode: 'no_data',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrRefreshExecution(entry = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO sms_message_executions (
|
||||||
|
company_id,
|
||||||
|
business_id,
|
||||||
|
application_id,
|
||||||
|
source_type,
|
||||||
|
source_event_key,
|
||||||
|
event_slug,
|
||||||
|
event_label,
|
||||||
|
provider_name,
|
||||||
|
shipment_id,
|
||||||
|
order_id,
|
||||||
|
to_number_hash,
|
||||||
|
to_number_last4,
|
||||||
|
trigger_payload,
|
||||||
|
trigger_status,
|
||||||
|
send_status,
|
||||||
|
delivery_status,
|
||||||
|
triggered_at,
|
||||||
|
is_test
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, COALESCE($17, NOW()), $18
|
||||||
|
)
|
||||||
|
ON CONFLICT (company_id, business_id, source_type, source_event_key)
|
||||||
|
DO UPDATE SET
|
||||||
|
event_label = COALESCE(EXCLUDED.event_label, sms_message_executions.event_label),
|
||||||
|
provider_name = COALESCE(EXCLUDED.provider_name, sms_message_executions.provider_name),
|
||||||
|
shipment_id = COALESCE(EXCLUDED.shipment_id, sms_message_executions.shipment_id),
|
||||||
|
order_id = COALESCE(EXCLUDED.order_id, sms_message_executions.order_id),
|
||||||
|
to_number_hash = COALESCE(EXCLUDED.to_number_hash, sms_message_executions.to_number_hash),
|
||||||
|
to_number_last4 = COALESCE(EXCLUDED.to_number_last4, sms_message_executions.to_number_last4),
|
||||||
|
trigger_payload = COALESCE(EXCLUDED.trigger_payload, sms_message_executions.trigger_payload),
|
||||||
|
triggered_at = COALESCE(sms_message_executions.triggered_at, EXCLUDED.triggered_at)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
normalizeText(entry.companyId),
|
||||||
|
normalizeText(entry.businessId),
|
||||||
|
normalizeText(entry.applicationId),
|
||||||
|
normalizeText(entry.sourceType) || 'fynd_webhook',
|
||||||
|
normalizeText(entry.sourceEventKey),
|
||||||
|
normalizeText(entry.eventSlug),
|
||||||
|
normalizeText(entry.eventLabel),
|
||||||
|
normalizeText(entry.providerName),
|
||||||
|
normalizeText(entry.shipmentId),
|
||||||
|
normalizeText(entry.orderId),
|
||||||
|
normalizeText(entry.toNumberHash),
|
||||||
|
normalizeText(entry.toNumberLast4),
|
||||||
|
entry.triggerPayload || null,
|
||||||
|
normalizeText(entry.triggerStatus) || 'processed',
|
||||||
|
normalizeText(entry.sendStatus) || 'not_attempted',
|
||||||
|
normalizeText(entry.deliveryStatus) || 'unknown',
|
||||||
|
entry.triggeredAt || null,
|
||||||
|
entry.isTest === true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markExecutionAccepted(entry = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE sms_message_executions
|
||||||
|
SET event_label = COALESCE($2, event_label),
|
||||||
|
matched_template_event = COALESCE($3, matched_template_event),
|
||||||
|
template_slug = COALESCE($4, template_slug),
|
||||||
|
template_id = COALESCE($5, template_id),
|
||||||
|
curl_profile_id = COALESCE($6, curl_profile_id),
|
||||||
|
provider_name = COALESCE($7, provider_name),
|
||||||
|
provider_message_id = COALESCE($8, provider_message_id),
|
||||||
|
provider_response = COALESCE($9, provider_response),
|
||||||
|
provider_http_status = COALESCE($10, provider_http_status),
|
||||||
|
trigger_status = 'processed',
|
||||||
|
send_status = 'accepted',
|
||||||
|
delivery_status = CASE
|
||||||
|
WHEN delivery_status = 'delivered' THEN 'delivered'
|
||||||
|
WHEN delivery_status = 'failed' THEN 'failed'
|
||||||
|
ELSE 'pending'
|
||||||
|
END,
|
||||||
|
send_attempted_at = COALESCE($11, send_attempted_at, NOW()),
|
||||||
|
accepted_at = COALESCE($12, accepted_at, NOW())
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
entry.id,
|
||||||
|
normalizeText(entry.eventLabel),
|
||||||
|
normalizeText(entry.matchedTemplateEvent),
|
||||||
|
normalizeText(entry.templateSlug),
|
||||||
|
normalizeText(entry.templateId),
|
||||||
|
normalizeText(entry.curlProfileId),
|
||||||
|
normalizeText(entry.providerName),
|
||||||
|
normalizeText(entry.providerMessageId),
|
||||||
|
entry.providerResponse || null,
|
||||||
|
normalizeInteger(entry.providerHttpStatus),
|
||||||
|
entry.sendAttemptedAt || null,
|
||||||
|
entry.acceptedAt || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markExecutionIgnored(entry = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE sms_message_executions
|
||||||
|
SET event_label = COALESCE($2, event_label),
|
||||||
|
trigger_status = 'ignored',
|
||||||
|
send_status = CASE
|
||||||
|
WHEN send_status = 'accepted' THEN send_status
|
||||||
|
ELSE 'not_attempted'
|
||||||
|
END,
|
||||||
|
ignore_reason = COALESCE($3, ignore_reason),
|
||||||
|
failure_stage = COALESCE($4, failure_stage),
|
||||||
|
failure_code = COALESCE($5, failure_code),
|
||||||
|
failure_reason = COALESCE($6, failure_reason)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
entry.id,
|
||||||
|
normalizeText(entry.eventLabel),
|
||||||
|
normalizeText(entry.ignoreReason),
|
||||||
|
normalizeText(entry.failureStage),
|
||||||
|
normalizeText(entry.failureCode),
|
||||||
|
normalizeText(entry.failureReason),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markExecutionFailed(entry = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE sms_message_executions
|
||||||
|
SET event_label = COALESCE($2, event_label),
|
||||||
|
matched_template_event = COALESCE($3, matched_template_event),
|
||||||
|
template_slug = COALESCE($4, template_slug),
|
||||||
|
template_id = COALESCE($5, template_id),
|
||||||
|
curl_profile_id = COALESCE($6, curl_profile_id),
|
||||||
|
provider_name = COALESCE($7, provider_name),
|
||||||
|
provider_message_id = COALESCE($8, provider_message_id),
|
||||||
|
provider_response = COALESCE($9, provider_response),
|
||||||
|
provider_http_status = COALESCE($10, provider_http_status),
|
||||||
|
trigger_status = 'processed',
|
||||||
|
send_status = 'send_failed',
|
||||||
|
delivery_status = 'failed',
|
||||||
|
failure_stage = COALESCE($11, failure_stage, 'send'),
|
||||||
|
failure_code = COALESCE($12, failure_code),
|
||||||
|
failure_reason = COALESCE($13, failure_reason),
|
||||||
|
send_attempted_at = COALESCE($14, send_attempted_at, NOW()),
|
||||||
|
failed_at = COALESCE($15, failed_at, NOW())
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
entry.id,
|
||||||
|
normalizeText(entry.eventLabel),
|
||||||
|
normalizeText(entry.matchedTemplateEvent),
|
||||||
|
normalizeText(entry.templateSlug),
|
||||||
|
normalizeText(entry.templateId),
|
||||||
|
normalizeText(entry.curlProfileId),
|
||||||
|
normalizeText(entry.providerName),
|
||||||
|
normalizeText(entry.providerMessageId),
|
||||||
|
entry.providerResponse || null,
|
||||||
|
normalizeInteger(entry.providerHttpStatus),
|
||||||
|
normalizeText(entry.failureStage),
|
||||||
|
normalizeText(entry.failureCode),
|
||||||
|
normalizeText(entry.failureReason),
|
||||||
|
entry.sendAttemptedAt || null,
|
||||||
|
entry.failedAt || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertStatusHistory(entry = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const statusFingerprint = normalizeText(entry.statusFingerprint) || buildStatusFingerprint(entry);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO sms_message_status_history (
|
||||||
|
message_execution_id,
|
||||||
|
status_fingerprint,
|
||||||
|
status_source,
|
||||||
|
status_type,
|
||||||
|
normalized_status,
|
||||||
|
provider_name,
|
||||||
|
provider_message_id,
|
||||||
|
provider_status,
|
||||||
|
provider_status_code,
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
payload,
|
||||||
|
headers,
|
||||||
|
occurred_at
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, COALESCE($14, NOW())
|
||||||
|
)
|
||||||
|
ON CONFLICT (status_fingerprint) DO NOTHING
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
entry.messageExecutionId,
|
||||||
|
statusFingerprint,
|
||||||
|
normalizeText(entry.statusSource) || 'internal',
|
||||||
|
normalizeText(entry.statusType),
|
||||||
|
normalizeText(entry.normalizedStatus),
|
||||||
|
normalizeText(entry.providerName),
|
||||||
|
normalizeText(entry.providerMessageId),
|
||||||
|
normalizeText(entry.providerStatus),
|
||||||
|
normalizeText(entry.providerStatusCode),
|
||||||
|
normalizeText(entry.errorCode),
|
||||||
|
normalizeText(entry.errorMessage),
|
||||||
|
entry.payload || null,
|
||||||
|
entry.headers || null,
|
||||||
|
entry.occurredAt || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverviewMetrics(scope = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const filters = buildExecutionFilters(scope);
|
||||||
|
|
||||||
|
const [summaryResult, chartResult] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*)::int AS total_triggered,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(triggered_at, created_at) >= CURRENT_DATE)::int AS triggered_today,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE COALESCE(failed_at, accepted_at, send_attempted_at, triggered_at, created_at) >= NOW() - INTERVAL '24 hours'
|
||||||
|
AND (send_status = 'send_failed' OR delivery_status = 'failed')
|
||||||
|
)::int AS failed_last_24_hours,
|
||||||
|
COUNT(*) FILTER (WHERE send_status = 'accepted')::int AS accepted_count,
|
||||||
|
COUNT(*) FILTER (WHERE send_status = 'send_failed')::int AS send_failed_count,
|
||||||
|
COUNT(*) FILTER (WHERE delivery_status = 'delivered')::int AS delivered_count,
|
||||||
|
COUNT(*) FILTER (WHERE delivery_status = 'failed')::int AS delivery_failed_count
|
||||||
|
FROM sms_message_executions
|
||||||
|
WHERE ${filters.whereClause}`,
|
||||||
|
filters.values,
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT
|
||||||
|
DATE(COALESCE(triggered_at, created_at)) AS day,
|
||||||
|
COUNT(*)::int AS triggered_count,
|
||||||
|
COUNT(*) FILTER (WHERE send_status = 'send_failed' OR delivery_status = 'failed')::int AS failed_count
|
||||||
|
FROM sms_message_executions
|
||||||
|
WHERE ${filters.whereClause}
|
||||||
|
AND COALESCE(triggered_at, created_at) >= CURRENT_DATE - INTERVAL '29 days'
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 ASC`,
|
||||||
|
filters.values,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summaryRow = summaryResult.rows[0] || {};
|
||||||
|
const deliveryRate = computeFallbackRate({
|
||||||
|
deliveredCount: parseCount(summaryRow.delivered_count),
|
||||||
|
deliveryFailedCount: parseCount(summaryRow.delivery_failed_count),
|
||||||
|
acceptedCount: parseCount(summaryRow.accepted_count),
|
||||||
|
sendFailedCount: parseCount(summaryRow.send_failed_count),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTriggered: parseCount(summaryRow.total_triggered),
|
||||||
|
triggeredToday: parseCount(summaryRow.triggered_today),
|
||||||
|
failedLast24Hours: parseCount(summaryRow.failed_last_24_hours),
|
||||||
|
acceptedCount: parseCount(summaryRow.accepted_count),
|
||||||
|
sendFailedCount: parseCount(summaryRow.send_failed_count),
|
||||||
|
deliveredCount: parseCount(summaryRow.delivered_count),
|
||||||
|
deliveryFailedCount: parseCount(summaryRow.delivery_failed_count),
|
||||||
|
deliveryRate,
|
||||||
|
chart: chartResult.rows.map((row) => ({
|
||||||
|
date: row.day instanceof Date ? row.day.toISOString().slice(0, 10) : String(row.day),
|
||||||
|
triggeredCount: parseCount(row.triggered_count),
|
||||||
|
failedCount: parseCount(row.failed_count),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEventMetrics(scope = {}) {
|
||||||
|
const pool = getPool();
|
||||||
|
const filters = buildExecutionFilters(scope);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
event_slug,
|
||||||
|
MAX(NULLIF(event_label, '')) AS event_label,
|
||||||
|
COUNT(*)::int AS total_trigger_count,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(triggered_at, created_at) >= CURRENT_DATE)::int AS triggered_today,
|
||||||
|
MAX(COALESCE(triggered_at, created_at)) AS last_triggered_at,
|
||||||
|
COUNT(*) FILTER (WHERE send_status = 'accepted')::int AS accepted_count,
|
||||||
|
COUNT(*) FILTER (WHERE send_status = 'send_failed')::int AS send_failed_count,
|
||||||
|
COUNT(*) FILTER (WHERE delivery_status = 'delivered')::int AS delivered_count,
|
||||||
|
COUNT(*) FILTER (WHERE delivery_status = 'failed')::int AS delivery_failed_count
|
||||||
|
FROM sms_message_executions
|
||||||
|
WHERE ${filters.whereClause}
|
||||||
|
GROUP BY event_slug
|
||||||
|
ORDER BY total_trigger_count DESC, event_slug ASC`,
|
||||||
|
filters.values,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
eventSlug: normalizeText(row.event_slug),
|
||||||
|
eventLabel: normalizeText(row.event_label),
|
||||||
|
totalTriggerCount: parseCount(row.total_trigger_count),
|
||||||
|
triggeredToday: parseCount(row.triggered_today),
|
||||||
|
lastTriggeredAt: row.last_triggered_at || null,
|
||||||
|
acceptedCount: parseCount(row.accepted_count),
|
||||||
|
sendFailedCount: parseCount(row.send_failed_count),
|
||||||
|
deliveredCount: parseCount(row.delivered_count),
|
||||||
|
deliveryFailedCount: parseCount(row.delivery_failed_count),
|
||||||
|
deliveryRate: computeFallbackRate({
|
||||||
|
deliveredCount: parseCount(row.delivered_count),
|
||||||
|
deliveryFailedCount: parseCount(row.delivery_failed_count),
|
||||||
|
acceptedCount: parseCount(row.accepted_count),
|
||||||
|
sendFailedCount: parseCount(row.send_failed_count),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildPhoneMetadata,
|
||||||
|
buildSourceEventKey,
|
||||||
|
computeFallbackRate,
|
||||||
|
createOrRefreshExecution,
|
||||||
|
extractProviderMessageId,
|
||||||
|
getOverviewMetrics,
|
||||||
|
getEventMetrics,
|
||||||
|
insertStatusHistory,
|
||||||
|
markExecutionAccepted,
|
||||||
|
markExecutionFailed,
|
||||||
|
markExecutionIgnored,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const STATUS_MARKER = '__CODEX_HTTP_STATUS__:';
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30000;
|
||||||
|
const MAX_CAPTURE_LENGTH = 1024 * 1024;
|
||||||
|
const DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']);
|
||||||
|
const STRIP_VALUE_FLAGS = new Set(['--write-out', '-w', '--output', '-o', '--dump-header', '-D']);
|
||||||
|
const STRIP_BOOLEAN_FLAGS = new Set([
|
||||||
|
'--silent',
|
||||||
|
'-s',
|
||||||
|
'--show-error',
|
||||||
|
'-S',
|
||||||
|
'--include',
|
||||||
|
'-i',
|
||||||
|
'--verbose',
|
||||||
|
'-v',
|
||||||
|
'--remote-name',
|
||||||
|
'-O',
|
||||||
|
'--remote-header-name',
|
||||||
|
'-J',
|
||||||
|
'--fail',
|
||||||
|
'-f',
|
||||||
|
'--fail-with-body',
|
||||||
|
]);
|
||||||
|
const TOKEN_REGEX = /__(?:PROFILE|SMS)_[A-Z0-9_]+__/g;
|
||||||
|
|
||||||
|
function createExecutionError(message, extra = {}) {
|
||||||
|
const error = new Error(message);
|
||||||
|
Object.assign(error, extra);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCommand(command) {
|
||||||
|
return String(command || '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\\\n\s*/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeCurlCommand(command) {
|
||||||
|
const input = normalizeCommand(command);
|
||||||
|
const tokens = [];
|
||||||
|
let current = '';
|
||||||
|
let quote = null;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < input.length; index += 1) {
|
||||||
|
const char = input[index];
|
||||||
|
|
||||||
|
if (escaping) {
|
||||||
|
current += char;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote === '\'') {
|
||||||
|
if (char === '\'') {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote === '"') {
|
||||||
|
if (char === '"') {
|
||||||
|
quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
const nextChar = input[index + 1];
|
||||||
|
if (nextChar) {
|
||||||
|
current += nextChar;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\'' || char === '"') {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (escaping) {
|
||||||
|
current += '\\';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
throw createExecutionError('Stored cURL contains an unterminated quoted value.', {
|
||||||
|
code: 'INVALID_CURL_TEMPLATE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCurlCommand(command) {
|
||||||
|
const tokens = tokenizeCurlCommand(command);
|
||||||
|
if (tokens.length === 0 || tokens[0] !== 'curl') {
|
||||||
|
throw createExecutionError('Stored cURL template must start with "curl".', {
|
||||||
|
code: 'INVALID_CURL_TEMPLATE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: 'curl',
|
||||||
|
args: tokens.slice(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTokensInString(value, tokenValues = {}) {
|
||||||
|
let output = String(value || '');
|
||||||
|
const entries = Object.entries(tokenValues).sort((left, right) => right[0].length - left[0].length);
|
||||||
|
|
||||||
|
entries.forEach(([token, replacement]) => {
|
||||||
|
if (!token) return;
|
||||||
|
output = output.split(token).join(String(replacement ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTokensInJsonValue(value, tokenValues = {}) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => replaceTokensInJsonValue(entry, tokenValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.entries(value).reduce((accumulator, [key, entry]) => {
|
||||||
|
accumulator[key] = replaceTokensInJsonValue(entry, tokenValues);
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return replaceTokensInString(value, tokenValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateDataArgument(rawArgument, tokenValues = {}) {
|
||||||
|
const trimmed = String(rawArgument || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|
||||||
|
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
return JSON.stringify(replaceTokensInJsonValue(parsed, tokenValues));
|
||||||
|
} catch {
|
||||||
|
return replaceTokensInString(rawArgument, tokenValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return replaceTokensInString(rawArgument, tokenValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateCurlArgs(args = [], tokenValues = {}) {
|
||||||
|
const hydratedArgs = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const argument = args[index];
|
||||||
|
|
||||||
|
if (DATA_FLAGS.has(argument) && index + 1 < args.length) {
|
||||||
|
hydratedArgs.push(argument);
|
||||||
|
hydratedArgs.push(hydrateDataArgument(args[index + 1], tokenValues));
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataFlagWithValue = Array.from(DATA_FLAGS).find((flag) => argument.startsWith(`${flag}=`));
|
||||||
|
if (dataFlagWithValue) {
|
||||||
|
const rawValue = argument.slice(dataFlagWithValue.length + 1);
|
||||||
|
hydratedArgs.push(`${dataFlagWithValue}=${hydrateDataArgument(rawValue, tokenValues)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydratedArgs.push(replaceTokensInString(argument, tokenValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hydratedArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExecutionArgs(args = []) {
|
||||||
|
const normalizedArgs = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const argument = args[index];
|
||||||
|
|
||||||
|
if (STRIP_BOOLEAN_FLAGS.has(argument)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripValueFlag = Array.from(STRIP_VALUE_FLAGS).find((flag) => argument === flag || argument.startsWith(`${flag}=`));
|
||||||
|
if (stripValueFlag) {
|
||||||
|
if (argument === stripValueFlag) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedArgs.push(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedArgs.push(
|
||||||
|
'--silent',
|
||||||
|
'--show-error',
|
||||||
|
'--output',
|
||||||
|
'-',
|
||||||
|
'--write-out',
|
||||||
|
`\n${STATUS_MARKER}%{http_code}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizedArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUnresolvedTokens(args = []) {
|
||||||
|
const unresolved = new Set();
|
||||||
|
|
||||||
|
args.forEach((argument) => {
|
||||||
|
const matches = String(argument || '').match(TOKEN_REGEX) || [];
|
||||||
|
matches.forEach((token) => unresolved.add(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendChunk(buffer, chunk) {
|
||||||
|
const nextValue = `${buffer}${chunk}`;
|
||||||
|
if (nextValue.length <= MAX_CAPTURE_LENGTH) return nextValue;
|
||||||
|
return nextValue.slice(nextValue.length - MAX_CAPTURE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCurlStdout(stdout = '') {
|
||||||
|
const marker = `\n${STATUS_MARKER}`;
|
||||||
|
const markerIndex = stdout.lastIndexOf(marker);
|
||||||
|
|
||||||
|
if (markerIndex < 0) {
|
||||||
|
return {
|
||||||
|
statusCode: 0,
|
||||||
|
body: stdout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = stdout.slice(markerIndex + marker.length).trim();
|
||||||
|
const parsedStatusCode = Number.parseInt(statusText, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: Number.isFinite(parsedStatusCode) ? parsedStatusCode : 0,
|
||||||
|
body: stdout.slice(0, markerIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponseBody(body = '') {
|
||||||
|
const normalizedBody = String(body || '').trim();
|
||||||
|
if (!normalizedBody) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(normalizedBody);
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeParsedCurl(command, args = [], options = {}) {
|
||||||
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, normalizeExecutionArgs(args), {
|
||||||
|
shell: false,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let timedOut = false;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled) child.kill('SIGKILL');
|
||||||
|
}, 1500).unref();
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout = appendChunk(stdout, chunk.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr = appendChunk(stderr, chunk.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(createExecutionError(`Failed to start curl: ${error.message}`, {
|
||||||
|
code: 'CURL_EXECUTION_START_FAILED',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (exitCode, signal) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
const parsedStdout = parseCurlStdout(stdout);
|
||||||
|
const response = parseResponseBody(parsedStdout.body);
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
reject(createExecutionError(`curl execution timed out after ${timeoutMs}ms`, {
|
||||||
|
code: 'CURL_EXECUTION_TIMEOUT',
|
||||||
|
details: {
|
||||||
|
timeoutMs,
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
statusCode: parsedStdout.statusCode,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
reject(createExecutionError('curl execution failed', {
|
||||||
|
code: 'CURL_EXECUTION_FAILED',
|
||||||
|
details: {
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
statusCode: parsedStdout.statusCode,
|
||||||
|
response,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
success: parsedStdout.statusCode >= 200 && parsedStdout.statusCode < 300,
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
statusCode: parsedStdout.statusCode,
|
||||||
|
response,
|
||||||
|
stderr: stderr.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeTemplatedCurl(curlTemplate, tokenValues = {}, options = {}) {
|
||||||
|
const parsed = parseCurlCommand(curlTemplate);
|
||||||
|
const hydratedArgs = hydrateCurlArgs(parsed.args, tokenValues);
|
||||||
|
const unresolvedTokens = findUnresolvedTokens(hydratedArgs);
|
||||||
|
|
||||||
|
if (unresolvedTokens.length > 0) {
|
||||||
|
throw createExecutionError('Stored cURL still contains unresolved execution tokens.', {
|
||||||
|
code: 'UNRESOLVED_CURL_TOKENS',
|
||||||
|
details: {
|
||||||
|
unresolvedTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeParsedCurl(parsed.command, hydratedArgs, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
executeTemplatedCurl,
|
||||||
|
parseCurlCommand,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user