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
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar';
|
|||
import Businesses from './pages/Businesses';
|
||||
import Providers from './pages/Providers';
|
||||
import GlobalSms from './pages/GlobalSms';
|
||||
import Analytics from './pages/Analytics';
|
||||
import Events from './pages/Events';
|
||||
import Templates from './pages/Templates';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
|
@ -111,6 +112,9 @@ export default function App() {
|
|||
<Route path="/:businessId/global-sms" element={
|
||||
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/analytics" element={
|
||||
<BusinessGuard><SubLayout><Analytics /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/events" element={
|
||||
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
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: (
|
||||
<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" />
|
||||
|
|
@ -92,10 +97,12 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
|||
const location = useLocation();
|
||||
const businessImage = getSidebarBusinessImage(activeBusiness);
|
||||
|
||||
const analyticsPath = `/${activeBusinessId}/analytics`;
|
||||
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
||||
const eventsPath = `/${activeBusinessId}/events`;
|
||||
const templatesPath = `/${activeBusinessId}/templates`;
|
||||
|
||||
const isAnalyticsRoute = location.pathname === analyticsPath;
|
||||
const isGlobalSmsRoute = location.pathname === globalSmsPath;
|
||||
const isEventsRoute = location.pathname === eventsPath;
|
||||
const isTemplatesRoute = location.pathname === templatesPath;
|
||||
|
|
@ -206,6 +213,27 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
|||
|
||||
{/* Nav */}
|
||||
<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">
|
||||
{stepItems.map((item, index) => (
|
||||
<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 provider = boundProfile?.provider || {};
|
||||
const samplePayloadText = JSON.stringify(samplePayload, null, 2);
|
||||
const executionMeta = template?.executionMeta || {};
|
||||
const executionInputCount = Array.isArray(template?.requiredInputs) ? template.requiredInputs.length : 0;
|
||||
const fallbackCount = previewState.fallbackPlaceholders.length;
|
||||
const unresolvedCount = previewState.unresolvedPlaceholders.length;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
Sample render
|
||||
Deterministic sample render
|
||||
</span>
|
||||
</div>
|
||||
<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.'}
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -254,6 +265,29 @@ export default function TemplateDetailWorkspaceModal({
|
|||
</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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -83,6 +83,19 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
|
||||
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
||||
</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 && (
|
||||
<div>
|
||||
<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 apiClient from '../api/client';
|
||||
|
||||
function getMissingProviderFields(profile) {
|
||||
const provider = profile?.provider || {};
|
||||
const missing = [];
|
||||
if (!provider.providerName) missing.push('providerName');
|
||||
if (!provider.senderId) missing.push('senderId');
|
||||
if (!provider.dltEntityId) missing.push('dltEntityId');
|
||||
return missing;
|
||||
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||
|
||||
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||
const provider = {};
|
||||
const profileInputValues = {};
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const rawValue = String(values[input.key] ?? '').trim();
|
||||
if (!rawValue) return;
|
||||
|
||||
if (BASE_PROFILE_KEYS.has(input.key)) {
|
||||
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
|
||||
return;
|
||||
}
|
||||
|
||||
profileInputValues[input.key] = rawValue;
|
||||
});
|
||||
|
||||
return {
|
||||
...(Object.keys(provider).length > 0 ? { provider } : {}),
|
||||
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialValues(inputs = []) {
|
||||
return inputs.reduce((accumulator, input) => {
|
||||
accumulator[input.key] = input.value || '';
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
|
||||
const [profile, setProfile] = useState(boundProfile);
|
||||
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
||||
const [profileForm, setProfileForm] = useState({});
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [toNumber, setToNumber] = useState('');
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [step, setStep] = useState('provider');
|
||||
const [step, setStep] = useState('profile');
|
||||
|
||||
const missingInputs = useMemo(
|
||||
() => profile?.executionReadiness?.missingProfileInputs || [],
|
||||
[profile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setProfile(boundProfile);
|
||||
setProviderForm({
|
||||
providerName: boundProfile?.provider?.providerName || '',
|
||||
senderId: boundProfile?.provider?.senderId || '',
|
||||
dltEntityId: boundProfile?.provider?.dltEntityId || '',
|
||||
});
|
||||
}, [boundProfile]);
|
||||
|
||||
const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]);
|
||||
useEffect(() => {
|
||||
setProfileForm(getInitialValues(missingInputs));
|
||||
}, [missingInputs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boundProfile) {
|
||||
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
||||
setStep('provider');
|
||||
setStep('profile');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setStep(missingFields.length > 0 ? 'provider' : 'publish');
|
||||
}, [boundProfile, missingFields]);
|
||||
setStep(missingInputs.length > 0 ? 'profile' : 'publish');
|
||||
}, [boundProfile, missingInputs]);
|
||||
|
||||
async function handleProviderSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!profile?.id) return;
|
||||
async function handleProfileSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!profile?.id || missingInputs.length === 0) return;
|
||||
|
||||
setSavingProvider(true);
|
||||
setSavingProfile(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const payload = buildProfilePatchPayload(missingInputs, profileForm);
|
||||
const res = await apiClient.patch(
|
||||
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
||||
{
|
||||
provider: {
|
||||
providerName: providerForm.providerName,
|
||||
senderId: providerForm.senderId.toUpperCase(),
|
||||
dltEntityId: providerForm.dltEntityId,
|
||||
},
|
||||
}
|
||||
payload,
|
||||
);
|
||||
|
||||
setProfile(res.data);
|
||||
setProviderForm({
|
||||
providerName: res.data?.provider?.providerName || '',
|
||||
senderId: res.data?.provider?.senderId || '',
|
||||
dltEntityId: res.data?.provider?.dltEntityId || '',
|
||||
});
|
||||
setStep(getMissingProviderFields(res.data).length > 0 ? 'provider' : 'publish');
|
||||
setStep(res.data?.executionReadiness?.missingProfileInputs?.length > 0 ? 'profile' : 'publish');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save provider details');
|
||||
setError(err.response?.data?.error || 'Failed to save required profile fields');
|
||||
} finally {
|
||||
setSavingProvider(false);
|
||||
setSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish(e) {
|
||||
e.preventDefault();
|
||||
async function handlePublish(event) {
|
||||
event.preventDefault();
|
||||
if (!templateId.trim() || !toNumber.trim()) return;
|
||||
|
||||
setPublishing(true);
|
||||
|
|
@ -90,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
await Promise.resolve(onSuccess());
|
||||
} catch (err) {
|
||||
if (err.response?.data?.missingFields?.length) {
|
||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
||||
setStep('provider');
|
||||
setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`);
|
||||
setStep('profile');
|
||||
} else {
|
||||
setError(err.response?.data?.error || 'Failed to publish template');
|
||||
}
|
||||
|
|
@ -103,130 +117,101 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
const isProfileMissing = !profile?.id;
|
||||
|
||||
return (
|
||||
<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="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto">
|
||||
<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="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="my-auto w-full max-w-md rounded-lg border border-border-main bg-surface-white p-5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-text-primary text-center mb-1">
|
||||
{step === 'provider' ? 'Complete Provider Details' : 'Publish Template'}
|
||||
<h3 className="mb-1 text-center text-lg font-bold text-text-primary">
|
||||
{step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted text-center mb-1">
|
||||
{step === 'provider'
|
||||
? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.'
|
||||
<p className="mb-1 text-center text-sm text-text-muted">
|
||||
{step === 'profile'
|
||||
? 'Complete the required fields on the bound cURL profile before publishing.'
|
||||
: 'Provide the DLT template ID and destination number to complete publish.'}
|
||||
</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, ' ')}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'provider' ? (
|
||||
<form onSubmit={handleProviderSubmit} className="space-y-4">
|
||||
{missingFields.includes('providerName') && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
|
||||
{step === 'profile' ? (
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
{missingInputs.map((input) => (
|
||||
<div key={input.key}>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={providerForm.providerName}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
|
||||
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||
placeholder="e.g. MSG91"
|
||||
autoFocus
|
||||
required
|
||||
type={input.secret ? 'password' : 'text'}
|
||||
value={profileForm[input.key] || ''}
|
||||
onChange={(event) => setProfileForm((current) => ({
|
||||
...current,
|
||||
[input.key]: input.key === 'senderId'
|
||||
? event.target.value.toUpperCase()
|
||||
: event.target.value,
|
||||
}))}
|
||||
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
placeholder={input.label}
|
||||
required={input.required !== false}
|
||||
autoFocus={input.key === missingInputs[0]?.key}
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={savingProvider}
|
||||
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"
|
||||
disabled={savingProfile}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProvider || isProfileMissing || missingFields.some(field => {
|
||||
if (field === 'providerName') return !providerForm.providerName.trim();
|
||||
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"
|
||||
disabled={savingProfile || isProfileMissing || missingInputs.some((input) => !String(profileForm[input.key] || '').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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePublish} className="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
value={templateId}
|
||||
onChange={e => setTemplateId(e.target.value)}
|
||||
onChange={(event) => setTemplateId(event.target.value)}
|
||||
placeholder="e.g. 1234567890987654321"
|
||||
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</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
|
||||
type="text"
|
||||
value={toNumber}
|
||||
onChange={e => setToNumber(e.target.value)}
|
||||
onChange={(event) => setToNumber(event.target.value)}
|
||||
placeholder="e.g. 919876543210"
|
||||
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||
required
|
||||
/>
|
||||
<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 className="flex gap-3 pt-4">
|
||||
|
|
@ -234,16 +219,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={publishing}
|
||||
className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
|
||||
className="flex-1 rounded-lg border border-border-main py-2 text-sm font-medium text-text-primary transition hover:bg-page-bg disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ export function BusinessProvider({ children }) {
|
|||
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updateReadyState = useCallback((activeProfile, templates = []) => {
|
||||
const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => {
|
||||
const hasProfile = !!activeProfile;
|
||||
setHasGlobalSms(hasProfile);
|
||||
const p = activeProfile?.provider || {};
|
||||
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
|
||||
const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride;
|
||||
setHasGlobalSms(hasGlobalSmsProfiles);
|
||||
const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true;
|
||||
setIsSetupComplete(nextIsSetupComplete);
|
||||
const nextHasSelectedTemplates = Array.isArray(templates)
|
||||
? templates.some((template) => !!template?.selectedTemplate)
|
||||
|
|
@ -26,7 +26,7 @@ export function BusinessProvider({ children }) {
|
|||
setHasSelectedTemplates(nextHasSelectedTemplates);
|
||||
|
||||
return {
|
||||
hasGlobalSms: hasProfile,
|
||||
hasGlobalSms: hasGlobalSmsProfiles,
|
||||
isSetupComplete: nextIsSetupComplete,
|
||||
hasSelectedTemplates: nextHasSelectedTemplates,
|
||||
};
|
||||
|
|
@ -51,7 +51,11 @@ export function BusinessProvider({ children }) {
|
|||
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
||||
]);
|
||||
|
||||
return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []);
|
||||
return updateReadyState(
|
||||
smsRes.data?.activeProfile,
|
||||
templatesRes.data?.templates || [],
|
||||
smsRes.data?.hasProfiles === true,
|
||||
);
|
||||
}, [activeBusiness?.businessId, updateReadyState]);
|
||||
|
||||
// On mount: rehydrate from sessionStorage and refresh from API
|
||||
|
|
@ -75,7 +79,11 @@ export function BusinessProvider({ children }) {
|
|||
]),
|
||||
]);
|
||||
setActiveBusinessState(bizRes.data);
|
||||
updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []);
|
||||
updateReadyState(
|
||||
smsRes[0].data?.activeProfile,
|
||||
smsRes[1].data?.templates || [],
|
||||
smsRes[0].data?.hasProfiles === true,
|
||||
);
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
|
||||
businessId,
|
||||
companyId: runtimeCompanyId || companyId || '',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
? template.variableMap
|
||||
: {},
|
||||
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
|
||||
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
|
||||
? template.executionMeta
|
||||
: {},
|
||||
curlProfileId: String(template?.curlProfileId || '').trim(),
|
||||
};
|
||||
}
|
||||
|
|
@ -205,6 +209,7 @@ function createVariantDraft(text = '') {
|
|||
currentText: text,
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
};
|
||||
}
|
||||
|
|
@ -562,9 +567,20 @@ function TemplateGenerationWorkspaceModal({
|
|||
</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">
|
||||
<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>
|
||||
|
|
@ -733,7 +749,7 @@ export default function Events() {
|
|||
} = buildTemplateUiState(templates);
|
||||
|
||||
setEvents(eventsRes.data.events || []);
|
||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl);
|
||||
setVariants(nextVariants);
|
||||
setGenState(nextGenState);
|
||||
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||||
|
|
@ -1033,6 +1049,7 @@ export default function Events() {
|
|||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: 'checking',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
|
|
@ -1043,12 +1060,24 @@ export default function Events() {
|
|||
editedTemplate,
|
||||
});
|
||||
|
||||
const issues = Array.isArray(res.data?.issues)
|
||||
? res.data.issues
|
||||
.filter((issue) => issue && typeof issue === 'object')
|
||||
.map((issue) => ({
|
||||
code: String(issue.code || '').trim(),
|
||||
message: String(issue.message || '').trim(),
|
||||
evidence: String(issue.evidence || '').trim(),
|
||||
}))
|
||||
.filter((issue) => issue.message)
|
||||
: [];
|
||||
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[draftKey]: {
|
||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
||||
why: res.data?.why || '',
|
||||
why: String(res.data?.why || issues[0]?.message || ''),
|
||||
issues,
|
||||
lastCheckedText: editedTemplate,
|
||||
},
|
||||
}));
|
||||
|
|
@ -1060,6 +1089,7 @@ export default function Events() {
|
|||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
|
|
@ -1114,6 +1144,7 @@ export default function Events() {
|
|||
currentText: nextText,
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,56 +1,175 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||
|
||||
function formatUpdatedAt(value) {
|
||||
if (!value) return 'Not updated yet';
|
||||
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return 'Not updated yet';
|
||||
}
|
||||
}
|
||||
|
||||
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||
const provider = {};
|
||||
const profileInputValues = {};
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const rawValue = String(values[input.key] ?? '').trim();
|
||||
if (!rawValue) return;
|
||||
|
||||
if (BASE_PROFILE_KEYS.has(input.key)) {
|
||||
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
|
||||
return;
|
||||
}
|
||||
|
||||
profileInputValues[input.key] = rawValue;
|
||||
});
|
||||
|
||||
return {
|
||||
...(Object.keys(provider).length > 0 ? { provider } : {}),
|
||||
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getInputInitialValues(inputs = []) {
|
||||
return inputs.reduce((accumulator, input) => {
|
||||
accumulator[input.key] = input.value || '';
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getProfileSummary(profile) {
|
||||
const parts = [];
|
||||
const provider = profile?.provider || {};
|
||||
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
|
||||
|
||||
if (provider.providerName) parts.push(provider.providerName);
|
||||
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
|
||||
if (provider.dltEntityId) parts.push('DLT ready');
|
||||
if (missingCount > 0) parts.push(`${missingCount} required field${missingCount === 1 ? '' : 's'} pending`);
|
||||
|
||||
return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.';
|
||||
}
|
||||
|
||||
function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) {
|
||||
if (!preview) return null;
|
||||
|
||||
const impactedTemplates = Array.isArray(preview.impactedTemplates) ? preview.impactedTemplates : [];
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const { businessId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [activeProfileId, setActiveProfileId] = useState(null);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingInputs, setSavingInputs] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
// Form state for Create / Edit Profile
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formCurl, setFormCurl] = useState('');
|
||||
const [formSetActive, setFormSetActive] = useState(true);
|
||||
const [inputForm, setInputForm] = useState({});
|
||||
const [revealedProfiles, setRevealedProfiles] = useState({});
|
||||
const [visibleProfileIds, setVisibleProfileIds] = useState({});
|
||||
const [deletePreview, setDeletePreview] = useState(null);
|
||||
const [deletingProfileId, setDeletingProfileId] = useState('');
|
||||
|
||||
// Form state for Missing Provider Fields
|
||||
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const activeProfile = useMemo(
|
||||
() => profiles.find((profile) => profile.id === activeProfileId) || null,
|
||||
[profiles, activeProfileId],
|
||||
);
|
||||
const missingInputs = activeProfile?.executionReadiness?.missingProfileInputs || [];
|
||||
const hasProfiles = profiles.length > 0;
|
||||
const eventsPath = `/${businessId}/events`;
|
||||
const analyticsPath = `/${businessId}/analytics`;
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
||||
const fetchedProfiles = res.data.profiles || [];
|
||||
const fetchActiveId = res.data.activeProfileId;
|
||||
const fetchedProfiles = res.data?.profiles || [];
|
||||
const nextActiveProfileId = res.data?.activeProfileId || null;
|
||||
const nextActiveProfile = fetchedProfiles.find((profile) => profile.id === nextActiveProfileId) || null;
|
||||
const nextIsSetupComplete = nextActiveProfile?.executionReadiness?.isSetupComplete === true;
|
||||
|
||||
setProfiles(fetchedProfiles);
|
||||
setActiveProfileId(fetchActiveId);
|
||||
setActiveProfileId(nextActiveProfileId);
|
||||
setHasGlobalSms(fetchedProfiles.length > 0);
|
||||
setIsSetupComplete(nextIsSetupComplete);
|
||||
|
||||
const activeProfile = fetchedProfiles.find(p => p.id === fetchActiveId) || null;
|
||||
const hasProfile = !!activeProfile;
|
||||
setHasGlobalSms(hasProfile);
|
||||
|
||||
const p = activeProfile?.provider || {};
|
||||
const complete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
|
||||
setIsSetupComplete(complete);
|
||||
|
||||
setProviderForm({
|
||||
providerName: p.providerName || '',
|
||||
senderId: p.senderId || '',
|
||||
dltEntityId: p.dltEntityId || '',
|
||||
});
|
||||
|
||||
return { activeProfile, hasProfile, complete };
|
||||
return {
|
||||
activeProfile: nextActiveProfile,
|
||||
hasProfile: !!nextActiveProfile,
|
||||
complete: nextIsSetupComplete,
|
||||
};
|
||||
} catch {
|
||||
setError('Failed to load cURL profiles');
|
||||
setHasGlobalSms(false);
|
||||
|
|
@ -66,81 +185,39 @@ export default function GlobalSms() {
|
|||
}, [loadProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const editProfileId = searchParams.get('editProfile');
|
||||
if (!editProfileId || profiles.length === 0) return;
|
||||
setInputForm(getInputInitialValues(missingInputs));
|
||||
}, [activeProfileId, missingInputs]);
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete('editProfile');
|
||||
const ensureRevealData = useCallback(async (profileId) => {
|
||||
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
|
||||
|
||||
const matchingProfile = profiles.find((profile) => profile.id === editProfileId);
|
||||
if (matchingProfile) {
|
||||
setEditingId(matchingProfile.id);
|
||||
setFormName(matchingProfile.name);
|
||||
setFormCurl(matchingProfile.rawCurl);
|
||||
setFormSetActive(false);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
} else {
|
||||
setError('The requested profile could not be found.');
|
||||
}
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
|
||||
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
|
||||
return res.data;
|
||||
}, [businessId, revealedProfiles]);
|
||||
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}, [profiles, searchParams, setSearchParams]);
|
||||
|
||||
const activeProfile = profiles.find(p => p.id === activeProfileId) || null;
|
||||
const hasProfiles = profiles.length > 0;
|
||||
const isCreatingFirstProfile = !hasProfiles && !editingId;
|
||||
const pData = activeProfile?.provider || {};
|
||||
const missingFields = [];
|
||||
if (activeProfile && !pData.providerName) missingFields.push('providerName');
|
||||
if (activeProfile && !pData.senderId) missingFields.push('senderId');
|
||||
if (activeProfile && !pData.dltEntityId) missingFields.push('dltEntityId');
|
||||
|
||||
function handleAddClick() {
|
||||
setEditingId(null);
|
||||
setFormName('');
|
||||
setFormCurl('');
|
||||
setFormSetActive(profiles.length === 0);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}
|
||||
|
||||
function handleEditClick(profile) {
|
||||
setEditingId(profile.id);
|
||||
setFormName(profile.name);
|
||||
setFormCurl(profile.rawCurl);
|
||||
setFormSetActive(false);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!formName.trim() || !formCurl.trim()) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
const shouldAutoAdvance = !isSetupComplete;
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${editingId}`, {
|
||||
name: formName,
|
||||
rawCurl: formCurl,
|
||||
});
|
||||
setSuccess('Profile updated successfully.');
|
||||
} else {
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
|
||||
name: formName,
|
||||
rawCurl: formCurl,
|
||||
setActive: formSetActive,
|
||||
});
|
||||
setSuccess('Profile created successfully.');
|
||||
}
|
||||
const nextState = await loadProfiles();
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
|
||||
name: formName.trim(),
|
||||
rawCurl: formCurl.trim(),
|
||||
setActive: formSetActive,
|
||||
});
|
||||
|
||||
setFormName('');
|
||||
setFormCurl('');
|
||||
setEditingId(null);
|
||||
setFormSetActive(true);
|
||||
setSuccess('Profile created successfully.');
|
||||
|
||||
const nextState = await loadProfiles();
|
||||
if (shouldAutoAdvance && nextState.complete) {
|
||||
navigate(eventsPath);
|
||||
}
|
||||
|
|
@ -151,21 +228,15 @@ export default function GlobalSms() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!window.confirm('Delete this cURL profile?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${id}`);
|
||||
await loadProfiles();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete profile');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(id) {
|
||||
async function handleActivate(profileId) {
|
||||
const shouldAutoAdvance = !isSetupComplete;
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${id}/activate`);
|
||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`);
|
||||
const nextState = await loadProfiles();
|
||||
setSuccess('Active profile updated.');
|
||||
if (shouldAutoAdvance && nextState.complete) {
|
||||
navigate(eventsPath);
|
||||
}
|
||||
|
|
@ -174,326 +245,380 @@ export default function GlobalSms() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleProviderSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!activeProfileId) return;
|
||||
setSavingProvider(true);
|
||||
async function handleCopyCurl(profile) {
|
||||
try {
|
||||
const revealData = await ensureRevealData(profile.id);
|
||||
const textToCopy = revealData?.rawCurl || profile.maskedCurl || '';
|
||||
if (!textToCopy) return;
|
||||
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setSuccess(`Copied ${profile.name} cURL.`);
|
||||
} catch {
|
||||
setError('Failed to copy the cURL command.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleCurl(profile) {
|
||||
setError('');
|
||||
|
||||
if (!visibleProfileIds[profile.id]) {
|
||||
try {
|
||||
await ensureRevealData(profile.id);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to reveal stored cURL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleProfileIds((current) => ({
|
||||
...current,
|
||||
[profile.id]: !current[profile.id],
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleProviderSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!activeProfileId || missingInputs.length === 0) return;
|
||||
|
||||
setSavingInputs(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
const shouldAutoAdvance = !isSetupComplete;
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, {
|
||||
provider: {
|
||||
providerName: providerForm.providerName,
|
||||
senderId: providerForm.senderId.toUpperCase(),
|
||||
dltEntityId: providerForm.dltEntityId,
|
||||
}
|
||||
});
|
||||
setSuccess('Provider details saved successfully!');
|
||||
const payload = buildProfilePatchPayload(missingInputs, inputForm);
|
||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
|
||||
setSuccess('Required profile fields saved.');
|
||||
const nextState = await loadProfiles();
|
||||
if (shouldAutoAdvance && nextState.complete) {
|
||||
navigate(eventsPath);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to save provider details');
|
||||
setError(err.response?.data?.error || 'Failed to save required profile fields');
|
||||
} finally {
|
||||
setSavingProvider(false);
|
||||
setSavingInputs(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRequest(profile) {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/delete-impact`);
|
||||
setDeletePreview(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load delete impact');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm() {
|
||||
if (!deletePreview?.profile?.id) return;
|
||||
|
||||
setDeletingProfileId(deletePreview.profile.id);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${deletePreview.profile.id}`);
|
||||
setDeletePreview(null);
|
||||
setVisibleProfileIds((current) => {
|
||||
const nextState = { ...current };
|
||||
delete nextState[deletePreview.profile.id];
|
||||
return nextState;
|
||||
});
|
||||
setRevealedProfiles((current) => {
|
||||
const nextState = { ...current };
|
||||
delete nextState[deletePreview.profile.id];
|
||||
return nextState;
|
||||
});
|
||||
await loadProfiles();
|
||||
setSuccess('Profile deleted successfully.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete profile');
|
||||
} finally {
|
||||
setDeletingProfileId('');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-2">Omni-channel SMS</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Complete this flow to configure your cURL profile and brand provider data. You must finish setup before generating templates.
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<DeleteProfileModal
|
||||
preview={deletePreview}
|
||||
deleting={deletingProfileId === deletePreview?.profile?.id}
|
||||
onCancel={() => setDeletePreview(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</button>
|
||||
<div className="mx-auto max-w-4xl space-y-8 pb-12">
|
||||
<div>
|
||||
<h2 className="mb-2 text-2xl font-bold text-text-primary">Omni-channel SMS</h2>
|
||||
<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>
|
||||
)}
|
||||
{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 */}
|
||||
{activeProfile && (
|
||||
<div className={`p-5 rounded-lg border ${isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'} `}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h3 className="font-bold text-text-primary text-lg">Active Setup: {activeProfile.name}</h3>
|
||||
{isSetupComplete ? (
|
||||
<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>
|
||||
)}
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-text-primary">Parsed Provider Data:</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft">
|
||||
<span className="text-text-muted">Provider:</span>
|
||||
<span className="font-bold text-text-primary">{pData.providerName || <span className="text-error-text text-xs uppercase">Missing</span>}</span>
|
||||
</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>
|
||||
<span className="font-bold text-text-primary">{pData.senderId || <span className="text-error-text text-xs uppercase">Missing</span>}</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft">
|
||||
<span className="text-text-muted">Entity ID:</span>
|
||||
<span className="font-bold text-text-primary">{pData.dltEntityId || <span className="text-error-text text-xs uppercase">Missing</span>}</span>
|
||||
</li>
|
||||
<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>
|
||||
{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">
|
||||
{success}
|
||||
<button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-700 hover:opacity-75">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProfile ? (
|
||||
<div className={`rounded-lg border p-5 ${activeProfile.executionReadiness?.isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h3 className="text-lg font-bold text-text-primary">Active Setup: {activeProfile.name}</h3>
|
||||
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-gray-700">
|
||||
{activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isSetupComplete && (
|
||||
<div className="bg-surface-white p-4 rounded-lg border border-border-main ">
|
||||
<p className="text-sm font-semibold text-text-primary mb-3">Please fill in the missing fields:</p>
|
||||
<form onSubmit={handleProviderSubmit} className="space-y-3">
|
||||
{missingFields.includes('providerName') && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Provider Name (e.g. MSG91)"
|
||||
value={providerForm.providerName}
|
||||
onChange={e => setProviderForm({ ...providerForm, providerName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
{missingFields.includes('senderId') && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Sender ID (6 letters)"
|
||||
maxLength={6}
|
||||
value={providerForm.senderId}
|
||||
onChange={e => setProviderForm({ ...providerForm, senderId: e.target.value.toUpperCase() })}
|
||||
className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg uppercase"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
{missingFields.includes('dltEntityId') && (
|
||||
<input
|
||||
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 className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-text-primary">Current Profile Summary</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
|
||||
<span className="text-text-muted">Provider</span>
|
||||
<span className="font-bold text-text-primary">
|
||||
{activeProfile.provider?.providerName || <span className="text-xs uppercase text-error-text">Missing</span>}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
|
||||
<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>}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
|
||||
<span className="text-text-muted">DLT Entity ID</span>
|
||||
<span className="font-bold text-text-primary">
|
||||
{activeProfile.provider?.dltEntityId || <span className="text-xs uppercase text-error-text">Missing</span>}
|
||||
</span>
|
||||
</li>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSetupComplete && (
|
||||
<div className="flex flex-col justify-center items-center h-full space-y-4">
|
||||
<p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
|
||||
<button
|
||||
onClick={() => navigate(eventsPath)}
|
||||
className="px-6 py-2 bg-primary-blue hover:bg-primary-dark text-white rounded-lg font-semibold text-sm transition w-full"
|
||||
>
|
||||
Continue to Events →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasProfiles && (
|
||||
<div className="space-y-4 pt-4 border-t border-border-soft">
|
||||
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3>
|
||||
{profiles.map(p => {
|
||||
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>
|
||||
{!activeProfile.executionReadiness?.isSetupComplete ? (
|
||||
<div className="rounded-lg border border-border-main bg-surface-white p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-text-primary">Complete the required fields</p>
|
||||
<form onSubmit={handleProviderSubmit} className="space-y-3">
|
||||
{missingInputs.map((input) => (
|
||||
<div key={input.key}>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
|
||||
<input
|
||||
type={input.secret ? 'password' : 'text'}
|
||||
value={inputForm[input.key] || ''}
|
||||
onChange={(event) => setInputForm((current) => ({
|
||||
...current,
|
||||
[input.key]: input.key === 'senderId'
|
||||
? event.target.value.toUpperCase()
|
||||
: event.target.value,
|
||||
}))}
|
||||
className="w-full rounded border border-border-main bg-page-bg px-3 py-2 text-sm text-text-primary focus:ring-1 focus:ring-primary-blue"
|
||||
placeholder={input.label}
|
||||
required={input.required !== false}
|
||||
/>
|
||||
</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
|
||||
onClick={() => handleEditClick(p)}
|
||||
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"
|
||||
type="submit"
|
||||
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>
|
||||
{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
|
||||
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"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{editingId && (
|
||||
<button onClick={handleAddClick} className="text-sm font-semibold text-primary-blue hover:text-primary-dark hover:underline">
|
||||
Switch to Add New
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className={`overflow-hidden rounded-xl border ${!hasProfiles ? '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 ${!hasProfiles ? 'border-b border-indigo-100 bg-indigo-50/60' : 'border-b border-border-main bg-table-header'}`}>
|
||||
<div>
|
||||
<h3 className="text-md font-bold text-text-primary">Add New Profile</h3>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label>
|
||||
<p className="mb-2 text-xs font-medium text-text-muted">
|
||||
Use a name you will recognize later, such as `Production SMS` or `Backup Provider`.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Raw cURL Command</label>
|
||||
<p className="mb-2 text-xs font-medium text-text-muted">
|
||||
Paste the full request exactly as supplied by your SMS provider. You can include the entire command.
|
||||
</p>
|
||||
<textarea
|
||||
value={formCurl}
|
||||
onChange={e => setFormCurl(e.target.value)}
|
||||
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"
|
||||
required
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
{!editingId && hasProfiles && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{!hasProfiles && (
|
||||
<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">
|
||||
This becomes the base for validating provider details and unlocking event template generation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Profile Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="e.g. Production SMS"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider cURL Command</label>
|
||||
<textarea
|
||||
value={formCurl}
|
||||
onChange={(event) => setFormCurl(event.target.value)}
|
||||
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 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
|
||||
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}
|
||||
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>
|
||||
</label>
|
||||
)}
|
||||
<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 && (
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddClick}
|
||||
type="submit"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</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 apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
function getMissingProviderFields(profile) {
|
||||
const provider = profile?.provider || {};
|
||||
const missing = [];
|
||||
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||
|
||||
if (!provider.providerName) missing.push('Provider Name');
|
||||
if (!provider.senderId) missing.push('Sender ID');
|
||||
if (!provider.dltEntityId) missing.push('DLT Entity ID');
|
||||
function normalizeCurlForDisplay(value) {
|
||||
if (!value) return '';
|
||||
|
||||
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) {
|
||||
return getMissingProviderFields(profile).length === 0;
|
||||
function stripWrappingQuotes(value) {
|
||||
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) {
|
||||
|
|
@ -28,15 +130,46 @@ function formatUpdatedAt(value) {
|
|||
}
|
||||
}
|
||||
|
||||
function buildProviderSummary(profile) {
|
||||
const provider = profile?.provider || {};
|
||||
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 getInitialFormValues(inputs = []) {
|
||||
return inputs.reduce((accumulator, input) => {
|
||||
accumulator[input.key] = input.secret ? '' : (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 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 }) {
|
||||
|
|
@ -48,11 +181,20 @@ function ProfileStatusPill({ complete }) {
|
|||
: 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{complete ? 'Complete' : 'Missing Fields'}
|
||||
{complete ? 'Ready' : 'Needs Fields'}
|
||||
</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() {
|
||||
const { businessId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -62,16 +204,11 @@ export default function Providers() {
|
|||
const [profiles, setProfiles] = useState([]);
|
||||
const [activeProfileId, setActiveProfileId] = useState('');
|
||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
providerName: '',
|
||||
senderId: '',
|
||||
dltEntityId: '',
|
||||
authKey: '',
|
||||
});
|
||||
const [formValues, setFormValues] = useState({});
|
||||
const [revealedProfiles, setRevealedProfiles] = useState({});
|
||||
const [showSecretsByProfileId, setShowSecretsByProfileId] = useState({});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [copiedProfileId, setCopiedProfileId] = useState('');
|
||||
const copyTimeoutRef = useRef(null);
|
||||
|
||||
const globalSmsPath = `/${businessId}/global-sms`;
|
||||
|
||||
|
|
@ -100,37 +237,41 @@ export default function Providers() {
|
|||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) || null;
|
||||
const selectedProfile = useMemo(
|
||||
() => profiles.find((profile) => profile.id === selectedProfileId) || null,
|
||||
[profiles, selectedProfileId],
|
||||
);
|
||||
const selectedProfileInputs = selectedProfile?.profileInputs || [];
|
||||
const isSelectedProfileRevealed = selectedProfile ? showSecretsByProfileId[selectedProfile.id] === true : false;
|
||||
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(() => {
|
||||
if (!selectedProfile) {
|
||||
setForm({
|
||||
providerName: '',
|
||||
senderId: '',
|
||||
dltEntityId: '',
|
||||
authKey: '',
|
||||
});
|
||||
setFormValues({});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = selectedProfile.provider || {};
|
||||
setForm({
|
||||
providerName: provider.providerName || '',
|
||||
senderId: provider.senderId || '',
|
||||
dltEntityId: provider.dltEntityId || '',
|
||||
authKey: provider.authKey || '',
|
||||
});
|
||||
setFormValues(getInitialFormValues(selectedProfile.profileInputs));
|
||||
}, [selectedProfile]);
|
||||
|
||||
function handleChange(field, value) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
const ensureRevealData = useCallback(async (profileId) => {
|
||||
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) {
|
||||
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) {
|
||||
if (!profile?.rawCurl) return;
|
||||
if (!profile?.id) return;
|
||||
|
||||
try {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API unavailable');
|
||||
}
|
||||
const revealData = await ensureRevealData(profile.id);
|
||||
if (!revealData?.rawCurl) return;
|
||||
|
||||
await navigator.clipboard.writeText(profile.rawCurl);
|
||||
setCopiedProfileId(profile.id);
|
||||
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
|
||||
copyTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedProfileId('');
|
||||
}, 1800);
|
||||
} catch {
|
||||
setError('Failed to copy the cURL command.');
|
||||
await navigator.clipboard.writeText(revealData.rawCurl);
|
||||
setSuccess(`Copied ${profile.name} cURL.`);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to copy the cURL command.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedProfile?.id) return;
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
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 {
|
||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, {
|
||||
provider: {
|
||||
providerName: form.providerName,
|
||||
senderId: form.senderId.toUpperCase(),
|
||||
dltEntityId: form.dltEntityId,
|
||||
authKey: form.authKey,
|
||||
},
|
||||
});
|
||||
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
|
||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
|
||||
|
||||
await loadProfiles();
|
||||
await refreshOnboardingState(businessId).catch(() => null);
|
||||
|
|
@ -221,7 +364,7 @@ export default function Providers() {
|
|||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -232,7 +375,7 @@ export default function Providers() {
|
|||
<div>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -252,6 +395,7 @@ export default function Providers() {
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{success}
|
||||
|
|
@ -293,7 +437,7 @@ export default function Providers() {
|
|||
<div className="space-y-3">
|
||||
{profiles.map((profile) => {
|
||||
const isActive = profile.id === activeProfileId;
|
||||
const complete = isProviderSetupComplete(profile);
|
||||
const complete = profile.executionReadiness?.isSetupComplete === true;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -305,7 +449,7 @@ export default function Providers() {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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 className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{isActive && (
|
||||
|
|
@ -352,10 +496,10 @@ export default function Providers() {
|
|||
Active profile
|
||||
</span>
|
||||
)}
|
||||
<ProfileStatusPill complete={isProviderSetupComplete(selectedProfile)} />
|
||||
<ProfileStatusPill complete={selectedProfile.executionReadiness?.isSetupComplete === true} />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -371,17 +515,17 @@ export default function Providers() {
|
|||
)}
|
||||
<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"
|
||||
>
|
||||
{copiedProfileId === selectedProfile.id ? 'Copied' : 'Copy cURL'}
|
||||
{showSecretsByProfileId[selectedProfile.id] ? 'Hide Values' : 'Reveal Values'}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Edit cURL
|
||||
Copy cURL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -389,130 +533,169 @@ export default function Providers() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-6 px-6 py-6">
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-950">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-gray-800 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Preview</p>
|
||||
</div>
|
||||
<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">
|
||||
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<code>{selectedProfile.rawCurl}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_260px]">
|
||||
<form onSubmit={handleSave} className="overflow-hidden rounded-2xl border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-gray-900">Provider Details</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
These fields are stored against this profile and are used during template publishing.
|
||||
</p>
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px] xl:items-start">
|
||||
<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 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">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<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">
|
||||
{selectedCurlView.method}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{selectedCurlView.url || 'Endpoint not detected from stored cURL'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-slate-400">
|
||||
{isSelectedProfileRevealed
|
||||
? 'Saved values are currently rendered inside this request preview.'
|
||||
: 'Sensitive values stay masked until you explicitly reveal them.'}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 px-5 py-5">
|
||||
<div>
|
||||
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}>
|
||||
Provider Name {!form.providerName && <span className="text-error-text">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerName}
|
||||
onChange={(event) => handleChange('providerName', event.target.value)}
|
||||
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`}
|
||||
placeholder="e.g. MSG91, Gupshup"
|
||||
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shell View</p>
|
||||
<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">
|
||||
<code>{selectedCurlView.command || 'No cURL stored.'}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{selectedCurlView.payload && (
|
||||
<div>
|
||||
<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 className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}>
|
||||
DLT Sender ID {!form.senderId && <span className="text-error-text">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.senderId}
|
||||
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>
|
||||
{curlWarnings.length > 0 && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">Warnings</p>
|
||||
<ul className="mt-2 space-y-2 text-sm text-amber-900">
|
||||
{curlWarnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</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 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 ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Saving…
|
||||
</>
|
||||
) : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{isSelectedProfileRevealed ? (
|
||||
<form onSubmit={handleSave} className="overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-sm">
|
||||
<div className="border-b border-gray-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-gray-900">Stored Profile Values</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
These fields appear only in reveal mode and stay tied to this immutable cURL profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-2xl border border-gray-200 bg-gray-50 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Current Status</p>
|
||||
<ul className="mt-4 space-y-3 text-sm">
|
||||
<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">Profile State</p>
|
||||
<p className="mt-2 font-medium text-gray-900">
|
||||
{selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
|
||||
<div className="space-y-4 px-5 py-5">
|
||||
{selectedProfileInputs.length > 0 ? selectedProfileInputs.map((input) => (
|
||||
<div key={input.key} className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4">
|
||||
<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'}`}>
|
||||
{input.label} {input.required ? <span className="text-error-text">*</span> : null}
|
||||
</label>
|
||||
<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>
|
||||
</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">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>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</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