Actual curl being used/whitelisting is pending on commerce side, this will take a few days

This commit is contained in:
Ritul Jadhav 2026-04-08 17:27:31 +05:30
parent 5508f094a5
commit b80d9404c4
15 changed files with 5278 additions and 1113 deletions

View File

@ -11,6 +11,8 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apk add --no-cache curl
COPY server/package*.json ./ COPY server/package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev

View File

@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar';
import Businesses from './pages/Businesses'; import Businesses from './pages/Businesses';
import Providers from './pages/Providers'; import Providers from './pages/Providers';
import GlobalSms from './pages/GlobalSms'; import GlobalSms from './pages/GlobalSms';
import Analytics from './pages/Analytics';
import Events from './pages/Events'; import Events from './pages/Events';
import Templates from './pages/Templates'; import Templates from './pages/Templates';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -111,6 +112,9 @@ export default function App() {
<Route path="/:businessId/global-sms" element={ <Route path="/:businessId/global-sms" element={
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard> <BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
} /> } />
<Route path="/:businessId/analytics" element={
<BusinessGuard><SubLayout><Analytics /></SubLayout></BusinessGuard>
} />
<Route path="/:businessId/events" element={ <Route path="/:businessId/events" element={
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard> <BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
} /> } />

View File

@ -2,6 +2,11 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const SVG_ICONS = { const SVG_ICONS = {
analytics: (
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 20V10m5 10V4m5 16v-6M4 20h16" />
</svg>
),
globalSms: ( globalSms: (
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
@ -92,10 +97,12 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
const location = useLocation(); const location = useLocation();
const businessImage = getSidebarBusinessImage(activeBusiness); const businessImage = getSidebarBusinessImage(activeBusiness);
const analyticsPath = `/${activeBusinessId}/analytics`;
const globalSmsPath = `/${activeBusinessId}/global-sms`; const globalSmsPath = `/${activeBusinessId}/global-sms`;
const eventsPath = `/${activeBusinessId}/events`; const eventsPath = `/${activeBusinessId}/events`;
const templatesPath = `/${activeBusinessId}/templates`; const templatesPath = `/${activeBusinessId}/templates`;
const isAnalyticsRoute = location.pathname === analyticsPath;
const isGlobalSmsRoute = location.pathname === globalSmsPath; const isGlobalSmsRoute = location.pathname === globalSmsPath;
const isEventsRoute = location.pathname === eventsPath; const isEventsRoute = location.pathname === eventsPath;
const isTemplatesRoute = location.pathname === templatesPath; const isTemplatesRoute = location.pathname === templatesPath;
@ -206,6 +213,27 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-3 pt-5"> <nav className="flex-1 px-3 pt-5">
<div className="mb-4">
{isSetupComplete ? (
<NavLink
to={analyticsPath}
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold transition-colors ${
isAnalyticsRoute
? 'bg-gray-100 text-gray-900'
: 'text-gray-600 hover:bg-page-bg hover:text-gray-900'
}`}
>
{SVG_ICONS.analytics}
<span className="flex-1 truncate">Analytics</span>
</NavLink>
) : (
<div className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-gray-300 cursor-not-allowed select-none">
{SVG_ICONS.analytics}
<span className="flex-1 truncate">Analytics</span>
</div>
)}
</div>
<div className="space-y-1"> <div className="space-y-1">
{stepItems.map((item, index) => ( {stepItems.map((item, index) => (
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2"> <div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">

View File

@ -126,6 +126,10 @@ export default function TemplateDetailWorkspaceModal({
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet'; const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
const provider = boundProfile?.provider || {}; const provider = boundProfile?.provider || {};
const samplePayloadText = JSON.stringify(samplePayload, null, 2); const samplePayloadText = JSON.stringify(samplePayload, null, 2);
const executionMeta = template?.executionMeta || {};
const executionInputCount = Array.isArray(template?.requiredInputs) ? template.requiredInputs.length : 0;
const fallbackCount = previewState.fallbackPlaceholders.length;
const unresolvedCount = previewState.unresolvedPlaceholders.length;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
@ -201,7 +205,7 @@ export default function TemplateDetailWorkspaceModal({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Preview</p> <p className="text-sm font-semibold text-gray-700">Preview</p>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500"> <span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
Sample render Deterministic sample render
</span> </span>
</div> </div>
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4"> <div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
@ -209,6 +213,13 @@ export default function TemplateDetailWorkspaceModal({
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'} {renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
</p> </p>
</div> </div>
{(fallbackCount > 0 || unresolvedCount > 0) && (
<p className={`text-xs font-medium ${unresolvedCount > 0 ? 'text-amber-700' : 'text-gray-500'}`}>
{unresolvedCount > 0
? `${unresolvedCount} placeholder${unresolvedCount === 1 ? '' : 's'} still need explicit mapping.`
: `${fallbackCount} placeholder${fallbackCount === 1 ? '' : 's'} used deterministic sample fallback values.`}
</p>
)}
</section> </section>
</div> </div>
</div> </div>
@ -254,6 +265,29 @@ export default function TemplateDetailWorkspaceModal({
</div> </div>
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Render Strategy</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{executionMeta.renderStrategy === 'deterministic_sample_payload'
? 'Deterministic sample payload'
: 'Template variable mapping'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Execution Inputs</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{executionInputCount} stored input{executionInputCount === 1 ? '' : 's'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Template Variables</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{Number.isFinite(executionMeta.placeholderCount) ? executionMeta.placeholderCount : 0} placeholder{executionMeta.placeholderCount === 1 ? '' : 's'}
</div>
</div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label> <label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700"> <div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">

View File

@ -83,6 +83,19 @@ export default function TestSmsModal({ businessId, template, onClose }) {
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'} {result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>} {result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
</div> </div>
{result.renderedContent && (
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Rendered SMS Content</label>
<pre className="p-3 bg-white border border-gray-200 rounded-lg text-xs font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap break-words">
{result.renderedContent}
</pre>
{Array.isArray(result.renderState?.fallbackPlaceholders) && result.renderState.fallbackPlaceholders.length > 0 && (
<p className="mt-2 text-xs font-medium text-gray-500">
{result.renderState.fallbackPlaceholders.length} placeholder{result.renderState.fallbackPlaceholders.length === 1 ? '' : 's'} used deterministic sample fallback values.
</p>
)}
</div>
)}
{result.response && ( {result.response && (
<div> <div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>

View File

@ -1,82 +1,96 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import apiClient from '../api/client'; import apiClient from '../api/client';
function getMissingProviderFields(profile) { const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
const provider = profile?.provider || {};
const missing = []; function buildProfilePatchPayload(inputs = [], values = {}) {
if (!provider.providerName) missing.push('providerName'); const provider = {};
if (!provider.senderId) missing.push('senderId'); const profileInputValues = {};
if (!provider.dltEntityId) missing.push('dltEntityId');
return missing; inputs.forEach((input) => {
const rawValue = String(values[input.key] ?? '').trim();
if (!rawValue) return;
if (BASE_PROFILE_KEYS.has(input.key)) {
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
return;
}
profileInputValues[input.key] = rawValue;
});
return {
...(Object.keys(provider).length > 0 ? { provider } : {}),
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
};
}
function getInitialValues(inputs = []) {
return inputs.reduce((accumulator, input) => {
accumulator[input.key] = input.value || '';
return accumulator;
}, {});
} }
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) { export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
const [profile, setProfile] = useState(boundProfile); const [profile, setProfile] = useState(boundProfile);
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); const [profileForm, setProfileForm] = useState({});
const [templateId, setTemplateId] = useState(''); const [templateId, setTemplateId] = useState('');
const [toNumber, setToNumber] = useState(''); const [toNumber, setToNumber] = useState('');
const [savingProvider, setSavingProvider] = useState(false); const [savingProfile, setSavingProfile] = useState(false);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [step, setStep] = useState('provider'); const [step, setStep] = useState('profile');
const missingInputs = useMemo(
() => profile?.executionReadiness?.missingProfileInputs || [],
[profile],
);
useEffect(() => { useEffect(() => {
setProfile(boundProfile); setProfile(boundProfile);
setProviderForm({
providerName: boundProfile?.provider?.providerName || '',
senderId: boundProfile?.provider?.senderId || '',
dltEntityId: boundProfile?.provider?.dltEntityId || '',
});
}, [boundProfile]); }, [boundProfile]);
const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]); useEffect(() => {
setProfileForm(getInitialValues(missingInputs));
}, [missingInputs]);
useEffect(() => { useEffect(() => {
if (!boundProfile) { if (!boundProfile) {
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.'); setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
setStep('provider'); setStep('profile');
return; return;
} }
setError(''); setError('');
setStep(missingFields.length > 0 ? 'provider' : 'publish'); setStep(missingInputs.length > 0 ? 'profile' : 'publish');
}, [boundProfile, missingFields]); }, [boundProfile, missingInputs]);
async function handleProviderSubmit(e) { async function handleProfileSubmit(event) {
e.preventDefault(); event.preventDefault();
if (!profile?.id) return; if (!profile?.id || missingInputs.length === 0) return;
setSavingProvider(true); setSavingProfile(true);
setError(''); setError('');
try { try {
const payload = buildProfilePatchPayload(missingInputs, profileForm);
const res = await apiClient.patch( const res = await apiClient.patch(
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`, `/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
{ payload,
provider: {
providerName: providerForm.providerName,
senderId: providerForm.senderId.toUpperCase(),
dltEntityId: providerForm.dltEntityId,
},
}
); );
setProfile(res.data); setProfile(res.data);
setProviderForm({ setStep(res.data?.executionReadiness?.missingProfileInputs?.length > 0 ? 'profile' : 'publish');
providerName: res.data?.provider?.providerName || '',
senderId: res.data?.provider?.senderId || '',
dltEntityId: res.data?.provider?.dltEntityId || '',
});
setStep(getMissingProviderFields(res.data).length > 0 ? 'provider' : 'publish');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to save provider details'); setError(err.response?.data?.error || 'Failed to save required profile fields');
} finally { } finally {
setSavingProvider(false); setSavingProfile(false);
} }
} }
async function handlePublish(e) { async function handlePublish(event) {
e.preventDefault(); event.preventDefault();
if (!templateId.trim() || !toNumber.trim()) return; if (!templateId.trim() || !toNumber.trim()) return;
setPublishing(true); setPublishing(true);
@ -90,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
await Promise.resolve(onSuccess()); await Promise.resolve(onSuccess());
} catch (err) { } catch (err) {
if (err.response?.data?.missingFields?.length) { if (err.response?.data?.missingFields?.length) {
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`); setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`);
setStep('provider'); setStep('profile');
} else { } else {
setError(err.response?.data?.error || 'Failed to publish template'); setError(err.response?.data?.error || 'Failed to publish template');
} }
@ -103,130 +117,101 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
const isProfileMissing = !profile?.id; const isProfileMissing = !profile?.id;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-900/50 pb-10 pt-10 backdrop-blur-sm">
<div className="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto"> <div className="my-auto w-full max-w-md rounded-lg border border-border-main bg-surface-white p-5">
<div className="w-12 h-12 rounded-full bg-white border border-gray-200 flex items-center justify-center mx-auto mb-4"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-white">
<span className="text-xl"></span> <span className="text-xl"></span>
</div> </div>
<h3 className="text-lg font-bold text-text-primary text-center mb-1"> <h3 className="mb-1 text-center text-lg font-bold text-text-primary">
{step === 'provider' ? 'Complete Provider Details' : 'Publish Template'} {step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'}
</h3> </h3>
<p className="text-sm text-text-muted text-center mb-1"> <p className="mb-1 text-center text-sm text-text-muted">
{step === 'provider' {step === 'profile'
? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.' ? 'Complete the required fields on the bound cURL profile before publishing.'
: 'Provide the DLT template ID and destination number to complete publish.'} : 'Provide the DLT template ID and destination number to complete publish.'}
</p> </p>
<p className="text-sm font-semibold text-text-primary text-center mb-2 capitalize"> <p className="mb-2 text-center text-sm font-semibold capitalize text-text-primary">
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')} {template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
</p> </p>
{profile && ( {profile && (
<p className="text-xs text-text-muted text-center mb-6 uppercase tracking-wide font-semibold"> <p className="mb-6 text-center text-xs font-semibold uppercase tracking-wide text-text-muted">
Bound Profile: {profile.name} Bound Profile: {profile.name}
</p> </p>
)} )}
{error && ( {error && (
<div className="mb-4 px-4 py-2 rounded-md text-error-text bg-white border border-gray-200 text-sm font-medium"> <div className="mb-4 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text">
{error} {error}
</div> </div>
)} )}
{step === 'provider' ? ( {step === 'profile' ? (
<form onSubmit={handleProviderSubmit} className="space-y-4"> <form onSubmit={handleProfileSubmit} className="space-y-4">
{missingFields.includes('providerName') && ( {missingInputs.map((input) => (
<div> <div key={input.key}>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label> <label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
<input <input
type="text" type={input.secret ? 'password' : 'text'}
value={providerForm.providerName} value={profileForm[input.key] || ''}
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))} onChange={(event) => setProfileForm((current) => ({
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" ...current,
placeholder="e.g. MSG91" [input.key]: input.key === 'senderId'
autoFocus ? event.target.value.toUpperCase()
required : event.target.value,
}))}
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
placeholder={input.label}
required={input.required !== false}
autoFocus={input.key === missingInputs[0]?.key}
/> />
</div> </div>
)} ))}
{missingFields.includes('senderId') && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Sender ID</label>
<input
type="text"
value={providerForm.senderId}
onChange={e => setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))}
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="6 CHARS"
maxLength={6}
required
/>
</div>
)}
{missingFields.includes('dltEntityId') && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Entity ID</label>
<input
type="text"
value={providerForm.dltEntityId}
onChange={e => setProviderForm(prev => ({ ...prev, dltEntityId: e.target.value }))}
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="19-digit DLT PE ID"
required
/>
</div>
)}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={savingProvider} disabled={savingProfile}
className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50" className="flex-1 rounded-lg border border-border-main py-2 text-sm font-medium text-text-primary transition hover:bg-page-bg disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={savingProvider || isProfileMissing || missingFields.some(field => { disabled={savingProfile || isProfileMissing || missingInputs.some((input) => !String(profileForm[input.key] || '').trim())}
if (field === 'providerName') return !providerForm.providerName.trim(); className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
if (field === 'senderId') return !providerForm.senderId.trim();
if (field === 'dltEntityId') return !providerForm.dltEntityId.trim();
return false;
})}
className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{savingProvider ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Details'} {savingProfile ? 'Saving…' : 'Save Details'}
</button> </button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={handlePublish} className="space-y-4"> <form onSubmit={handlePublish} className="space-y-4">
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label> <label className="mb-1.5 block text-sm font-semibold text-text-primary">DLT Template ID</label>
<input <input
type="text" type="text"
value={templateId} value={templateId}
onChange={e => setTemplateId(e.target.value)} onChange={(event) => setTemplateId(event.target.value)}
placeholder="e.g. 1234567890987654321" placeholder="e.g. 1234567890987654321"
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
autoFocus autoFocus
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Destination Phone Number</label> <label className="mb-1.5 block text-sm font-semibold text-text-primary">Destination Phone Number</label>
<input <input
type="text" type="text"
value={toNumber} value={toNumber}
onChange={e => setToNumber(e.target.value)} onChange={(event) => setToNumber(event.target.value)}
placeholder="e.g. 919876543210" placeholder="e.g. 919876543210"
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm" className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
required required
/> />
<p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p> <p className="mt-1 text-xs text-text-muted">This sends the publish-triggering SMS request.</p>
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
@ -234,16 +219,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={publishing} disabled={publishing}
className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50" className="flex-1 rounded-lg border border-border-main py-2 text-sm font-medium text-text-primary transition hover:bg-page-bg disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={publishing || !templateId.trim() || !toNumber.trim()} disabled={publishing || !templateId.trim() || !toNumber.trim()}
className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2" className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
> >
{publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing</> : 'Publish'} {publishing ? 'Publishing…' : 'Publish'}
</button> </button>
</div> </div>
</form> </form>

View File

@ -14,11 +14,11 @@ export function BusinessProvider({ children }) {
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false); const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const updateReadyState = useCallback((activeProfile, templates = []) => { const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => {
const hasProfile = !!activeProfile; const hasProfile = !!activeProfile;
setHasGlobalSms(hasProfile); const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride;
const p = activeProfile?.provider || {}; setHasGlobalSms(hasGlobalSmsProfiles);
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId; const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true;
setIsSetupComplete(nextIsSetupComplete); setIsSetupComplete(nextIsSetupComplete);
const nextHasSelectedTemplates = Array.isArray(templates) const nextHasSelectedTemplates = Array.isArray(templates)
? templates.some((template) => !!template?.selectedTemplate) ? templates.some((template) => !!template?.selectedTemplate)
@ -26,7 +26,7 @@ export function BusinessProvider({ children }) {
setHasSelectedTemplates(nextHasSelectedTemplates); setHasSelectedTemplates(nextHasSelectedTemplates);
return { return {
hasGlobalSms: hasProfile, hasGlobalSms: hasGlobalSmsProfiles,
isSetupComplete: nextIsSetupComplete, isSetupComplete: nextIsSetupComplete,
hasSelectedTemplates: nextHasSelectedTemplates, hasSelectedTemplates: nextHasSelectedTemplates,
}; };
@ -51,7 +51,11 @@ export function BusinessProvider({ children }) {
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })), apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
]); ]);
return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []); return updateReadyState(
smsRes.data?.activeProfile,
templatesRes.data?.templates || [],
smsRes.data?.hasProfiles === true,
);
}, [activeBusiness?.businessId, updateReadyState]); }, [activeBusiness?.businessId, updateReadyState]);
// On mount: rehydrate from sessionStorage and refresh from API // On mount: rehydrate from sessionStorage and refresh from API
@ -75,7 +79,11 @@ export function BusinessProvider({ children }) {
]), ]),
]); ]);
setActiveBusinessState(bizRes.data); setActiveBusinessState(bizRes.data);
updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []); updateReadyState(
smsRes[0].data?.activeProfile,
smsRes[1].data?.templates || [],
smsRes[0].data?.hasProfiles === true,
);
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ sessionStorage.setItem(SESSION_KEY, JSON.stringify({
businessId, businessId,
companyId: runtimeCompanyId || companyId || '', companyId: runtimeCompanyId || companyId || '',

View File

@ -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>
);
}

View File

@ -116,6 +116,10 @@ function buildSelectedTemplatePreview(template = {}) {
variableMap: template?.variableMap && typeof template.variableMap === 'object' variableMap: template?.variableMap && typeof template.variableMap === 'object'
? template.variableMap ? template.variableMap
: {}, : {},
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
? template.executionMeta
: {},
curlProfileId: String(template?.curlProfileId || '').trim(), curlProfileId: String(template?.curlProfileId || '').trim(),
}; };
} }
@ -205,6 +209,7 @@ function createVariantDraft(text = '') {
currentText: text, currentText: text,
validationStatus: 'idle', validationStatus: 'idle',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}; };
} }
@ -562,9 +567,20 @@ function TemplateGenerationWorkspaceModal({
</div> </div>
)} )}
{validationStatus === 'rejected' && currentMatchesCheckedText && activeDraft?.why && ( {validationStatus === 'rejected' && currentMatchesCheckedText && (activeDraft?.issues?.length > 0 || activeDraft?.why) && (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700"> <div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
<span className="font-semibold">Why it did not pass:</span> {activeDraft.why} <p className="font-semibold">Why it did not pass:</p>
{activeDraft?.issues?.length > 0 ? (
<ul className="mt-2 list-disc space-y-1 pl-5">
{activeDraft.issues.map((issue, index) => (
<li key={`${issue.code || 'issue'}-${index}`}>
{issue.message}
</li>
))}
</ul>
) : (
<p className="mt-2">{activeDraft.why}</p>
)}
</div> </div>
)} )}
</div> </div>
@ -733,7 +749,7 @@ export default function Events() {
} = buildTemplateUiState(templates); } = buildTemplateUiState(templates);
setEvents(eventsRes.data.events || []); setEvents(eventsRes.data.events || []);
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl); setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl);
setVariants(nextVariants); setVariants(nextVariants);
setGenState(nextGenState); setGenState(nextGenState);
setTemplateStatusBySlug(nextTemplateStatusBySlug); setTemplateStatusBySlug(nextTemplateStatusBySlug);
@ -1033,6 +1049,7 @@ export default function Events() {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: 'checking', validationStatus: 'checking',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}, },
})); }));
@ -1043,12 +1060,24 @@ export default function Events() {
editedTemplate, editedTemplate,
}); });
const issues = Array.isArray(res.data?.issues)
? res.data.issues
.filter((issue) => issue && typeof issue === 'object')
.map((issue) => ({
code: String(issue.code || '').trim(),
message: String(issue.message || '').trim(),
evidence: String(issue.evidence || '').trim(),
}))
.filter((issue) => issue.message)
: [];
setVariantDrafts((currentDrafts) => ({ setVariantDrafts((currentDrafts) => ({
...currentDrafts, ...currentDrafts,
[draftKey]: { [draftKey]: {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: res.data?.approved ? 'approved' : 'rejected', validationStatus: res.data?.approved ? 'approved' : 'rejected',
why: res.data?.why || '', why: String(res.data?.why || issues[0]?.message || ''),
issues,
lastCheckedText: editedTemplate, lastCheckedText: editedTemplate,
}, },
})); }));
@ -1060,6 +1089,7 @@ export default function Events() {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: 'idle', validationStatus: 'idle',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}, },
})); }));
@ -1114,6 +1144,7 @@ export default function Events() {
currentText: nextText, currentText: nextText,
validationStatus: 'idle', validationStatus: 'idle',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}, },
})); }));

View File

@ -1,56 +1,175 @@
import { useState, useEffect, useCallback } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
function formatUpdatedAt(value) {
if (!value) return 'Not updated yet';
try {
return new Date(value).toLocaleString();
} catch {
return 'Not updated yet';
}
}
function buildProfilePatchPayload(inputs = [], values = {}) {
const provider = {};
const profileInputValues = {};
inputs.forEach((input) => {
const rawValue = String(values[input.key] ?? '').trim();
if (!rawValue) return;
if (BASE_PROFILE_KEYS.has(input.key)) {
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
return;
}
profileInputValues[input.key] = rawValue;
});
return {
...(Object.keys(provider).length > 0 ? { provider } : {}),
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
};
}
function getInputInitialValues(inputs = []) {
return inputs.reduce((accumulator, input) => {
accumulator[input.key] = input.value || '';
return accumulator;
}, {});
}
function getProfileSummary(profile) {
const parts = [];
const provider = profile?.provider || {};
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
if (provider.providerName) parts.push(provider.providerName);
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
if (provider.dltEntityId) parts.push('DLT ready');
if (missingCount > 0) parts.push(`${missingCount} required field${missingCount === 1 ? '' : 's'} pending`);
return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.';
}
function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) {
if (!preview) return null;
const impactedTemplates = Array.isArray(preview.impactedTemplates) ? preview.impactedTemplates : [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white shadow-xl">
<div className="border-b border-gray-200 px-6 py-5">
<h3 className="text-lg font-bold text-gray-900">Delete cURL Profile</h3>
<p className="mt-1 text-sm text-gray-500">
{preview.profile?.name || 'This profile'} will be deleted. Bound templates will be removed, but event definitions will stay.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{impactedTemplates.length > 0 ? (
<>
<p className="text-sm font-semibold text-gray-900">Affected templates</p>
<div className="max-h-72 space-y-3 overflow-y-auto">
{impactedTemplates.map((template) => (
<div key={`${template.eventSlug}-${template.status}`} className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900">{template.eventLabel || template.eventSlug}</p>
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
{template.status || 'generated'}
</span>
</div>
{template.templateId && (
<p className="mt-2 font-mono text-xs text-gray-500">DLT Template ID: {template.templateId}</p>
)}
</div>
))}
</div>
</>
) : (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
No templates are currently bound to this profile.
</div>
)}
</div>
<div className="flex gap-3 border-t border-gray-200 px-6 py-4">
<button
type="button"
onClick={onCancel}
disabled={deleting}
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={deleting}
className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Delete Profile'}
</button>
</div>
</div>
</div>
);
}
export default function GlobalSms() { export default function GlobalSms() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness(); const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [profiles, setProfiles] = useState([]); const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(null); const [activeProfileId, setActiveProfileId] = useState(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingInputs, setSavingInputs] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
// Form state for Create / Edit Profile
const [editingId, setEditingId] = useState(null);
const [formName, setFormName] = useState(''); const [formName, setFormName] = useState('');
const [formCurl, setFormCurl] = useState(''); const [formCurl, setFormCurl] = useState('');
const [formSetActive, setFormSetActive] = useState(true); const [formSetActive, setFormSetActive] = useState(true);
const [inputForm, setInputForm] = useState({});
const [revealedProfiles, setRevealedProfiles] = useState({});
const [visibleProfileIds, setVisibleProfileIds] = useState({});
const [deletePreview, setDeletePreview] = useState(null);
const [deletingProfileId, setDeletingProfileId] = useState('');
// Form state for Missing Provider Fields const activeProfile = useMemo(
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); () => profiles.find((profile) => profile.id === activeProfileId) || null,
const [savingProvider, setSavingProvider] = useState(false); [profiles, activeProfileId],
);
const missingInputs = activeProfile?.executionReadiness?.missingProfileInputs || [];
const hasProfiles = profiles.length > 0;
const eventsPath = `/${businessId}/events`; const eventsPath = `/${businessId}/events`;
const analyticsPath = `/${businessId}/analytics`;
const loadProfiles = useCallback(async () => { const loadProfiles = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data.profiles || []; const fetchedProfiles = res.data?.profiles || [];
const fetchActiveId = res.data.activeProfileId; const nextActiveProfileId = res.data?.activeProfileId || null;
const nextActiveProfile = fetchedProfiles.find((profile) => profile.id === nextActiveProfileId) || null;
const nextIsSetupComplete = nextActiveProfile?.executionReadiness?.isSetupComplete === true;
setProfiles(fetchedProfiles); setProfiles(fetchedProfiles);
setActiveProfileId(fetchActiveId); setActiveProfileId(nextActiveProfileId);
setHasGlobalSms(fetchedProfiles.length > 0);
setIsSetupComplete(nextIsSetupComplete);
const activeProfile = fetchedProfiles.find(p => p.id === fetchActiveId) || null; return {
const hasProfile = !!activeProfile; activeProfile: nextActiveProfile,
setHasGlobalSms(hasProfile); hasProfile: !!nextActiveProfile,
complete: nextIsSetupComplete,
const p = activeProfile?.provider || {}; };
const complete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
setIsSetupComplete(complete);
setProviderForm({
providerName: p.providerName || '',
senderId: p.senderId || '',
dltEntityId: p.dltEntityId || '',
});
return { activeProfile, hasProfile, complete };
} catch { } catch {
setError('Failed to load cURL profiles'); setError('Failed to load cURL profiles');
setHasGlobalSms(false); setHasGlobalSms(false);
@ -66,81 +185,39 @@ export default function GlobalSms() {
}, [loadProfiles]); }, [loadProfiles]);
useEffect(() => { useEffect(() => {
const editProfileId = searchParams.get('editProfile'); setInputForm(getInputInitialValues(missingInputs));
if (!editProfileId || profiles.length === 0) return; }, [activeProfileId, missingInputs]);
const nextParams = new URLSearchParams(searchParams); const ensureRevealData = useCallback(async (profileId) => {
nextParams.delete('editProfile'); if (revealedProfiles[profileId]) return revealedProfiles[profileId];
const matchingProfile = profiles.find((profile) => profile.id === editProfileId); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
if (matchingProfile) { setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
setEditingId(matchingProfile.id); return res.data;
setFormName(matchingProfile.name); }, [businessId, revealedProfiles]);
setFormCurl(matchingProfile.rawCurl);
setFormSetActive(false);
setError('');
setSuccess('');
} else {
setError('The requested profile could not be found.');
}
setSearchParams(nextParams, { replace: true }); async function handleSubmit(event) {
}, [profiles, searchParams, setSearchParams]); event.preventDefault();
const activeProfile = profiles.find(p => p.id === activeProfileId) || null;
const hasProfiles = profiles.length > 0;
const isCreatingFirstProfile = !hasProfiles && !editingId;
const pData = activeProfile?.provider || {};
const missingFields = [];
if (activeProfile && !pData.providerName) missingFields.push('providerName');
if (activeProfile && !pData.senderId) missingFields.push('senderId');
if (activeProfile && !pData.dltEntityId) missingFields.push('dltEntityId');
function handleAddClick() {
setEditingId(null);
setFormName('');
setFormCurl('');
setFormSetActive(profiles.length === 0);
setError('');
setSuccess('');
}
function handleEditClick(profile) {
setEditingId(profile.id);
setFormName(profile.name);
setFormCurl(profile.rawCurl);
setFormSetActive(false);
setError('');
setSuccess('');
}
async function handleSubmit(e) {
e.preventDefault();
if (!formName.trim() || !formCurl.trim()) return; if (!formName.trim() || !formCurl.trim()) return;
setSaving(true); setSaving(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
const shouldAutoAdvance = !isSetupComplete; const shouldAutoAdvance = !isSetupComplete;
try { try {
if (editingId) {
await apiClient.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`, { await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
name: formName, name: formName.trim(),
rawCurl: formCurl, rawCurl: formCurl.trim(),
setActive: formSetActive, setActive: formSetActive,
}); });
setSuccess('Profile created successfully.');
}
const nextState = await loadProfiles();
setFormName(''); setFormName('');
setFormCurl(''); setFormCurl('');
setEditingId(null); setFormSetActive(true);
setSuccess('Profile created successfully.');
const nextState = await loadProfiles();
if (shouldAutoAdvance && nextState.complete) { if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath); navigate(eventsPath);
} }
@ -151,21 +228,15 @@ export default function GlobalSms() {
} }
} }
async function handleDelete(id) { async function handleActivate(profileId) {
if (!window.confirm('Delete this cURL profile?')) return;
try {
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${id}`);
await loadProfiles();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete profile');
}
}
async function handleActivate(id) {
const shouldAutoAdvance = !isSetupComplete; const shouldAutoAdvance = !isSetupComplete;
setError('');
setSuccess('');
try { try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${id}/activate`); await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`);
const nextState = await loadProfiles(); const nextState = await loadProfiles();
setSuccess('Active profile updated.');
if (shouldAutoAdvance && nextState.complete) { if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath); navigate(eventsPath);
} }
@ -174,151 +245,221 @@ export default function GlobalSms() {
} }
} }
async function handleProviderSubmit(e) { async function handleCopyCurl(profile) {
e.preventDefault(); try {
if (!activeProfileId) return; const revealData = await ensureRevealData(profile.id);
setSavingProvider(true); const textToCopy = revealData?.rawCurl || profile.maskedCurl || '';
if (!textToCopy) return;
await navigator.clipboard.writeText(textToCopy);
setSuccess(`Copied ${profile.name} cURL.`);
} catch {
setError('Failed to copy the cURL command.');
}
}
async function handleToggleCurl(profile) {
setError('');
if (!visibleProfileIds[profile.id]) {
try {
await ensureRevealData(profile.id);
} catch (err) {
setError(err.response?.data?.error || 'Failed to reveal stored cURL');
return;
}
}
setVisibleProfileIds((current) => ({
...current,
[profile.id]: !current[profile.id],
}));
}
async function handleProviderSubmit(event) {
event.preventDefault();
if (!activeProfileId || missingInputs.length === 0) return;
setSavingInputs(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
const shouldAutoAdvance = !isSetupComplete; const shouldAutoAdvance = !isSetupComplete;
try { try {
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, { const payload = buildProfilePatchPayload(missingInputs, inputForm);
provider: { await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
providerName: providerForm.providerName, setSuccess('Required profile fields saved.');
senderId: providerForm.senderId.toUpperCase(),
dltEntityId: providerForm.dltEntityId,
}
});
setSuccess('Provider details saved successfully!');
const nextState = await loadProfiles(); const nextState = await loadProfiles();
if (shouldAutoAdvance && nextState.complete) { if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath); navigate(eventsPath);
} }
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to save provider details'); setError(err.response?.data?.error || 'Failed to save required profile fields');
} finally { } finally {
setSavingProvider(false); setSavingInputs(false);
}
}
async function handleDeleteRequest(profile) {
setError('');
setSuccess('');
try {
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/delete-impact`);
setDeletePreview(res.data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load delete impact');
}
}
async function handleDeleteConfirm() {
if (!deletePreview?.profile?.id) return;
setDeletingProfileId(deletePreview.profile.id);
setError('');
setSuccess('');
try {
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${deletePreview.profile.id}`);
setDeletePreview(null);
setVisibleProfileIds((current) => {
const nextState = { ...current };
delete nextState[deletePreview.profile.id];
return nextState;
});
setRevealedProfiles((current) => {
const nextState = { ...current };
delete nextState[deletePreview.profile.id];
return nextState;
});
await loadProfiles();
setSuccess('Profile deleted successfully.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete profile');
} finally {
setDeletingProfileId('');
} }
} }
if (loading) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<span className="w-8 h-8 border-4 border-spinner-track border-t-primary-blue rounded-full animate-spin" /> <span className="h-8 w-8 animate-spin rounded-full border-4 border-spinner-track border-t-primary-blue" />
</div> </div>
); );
} }
return ( return (
<div className="max-w-4xl mx-auto space-y-8 pb-12"> <>
<DeleteProfileModal
preview={deletePreview}
deleting={deletingProfileId === deletePreview?.profile?.id}
onCancel={() => setDeletePreview(null)}
onConfirm={handleDeleteConfirm}
/>
<div className="mx-auto max-w-4xl space-y-8 pb-12">
<div> <div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Omni-channel SMS</h2> <h2 className="mb-2 text-2xl font-bold text-text-primary">Omni-channel SMS</h2>
<p className="text-sm text-text-muted"> <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. Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.
</p> </p>
</div> </div>
{error && ( {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"> <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} {error}
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">&times;</button> <button type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
&times;
</button>
</div> </div>
)} )}
{success && ( {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"> <div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
{success} {success}
<button onClick={() => setSuccess('')} className="text-gray-700 hover:opacity-75 font-bold">&times;</button> <button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-700 hover:opacity-75">
&times;
</button>
</div> </div>
)} )}
{/* Active Profile Setup Review Block */} {activeProfile ? (
{activeProfile && ( <div className={`rounded-lg border p-5 ${activeProfile.executionReadiness?.isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'}`}>
<div className={`p-5 rounded-lg border ${isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'} `}> <div className="mb-4 flex items-center gap-3">
<div className="flex items-center gap-3 mb-4"> <h3 className="text-lg font-bold text-text-primary">Active Setup: {activeProfile.name}</h3>
<h3 className="font-bold text-text-primary text-lg">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">
{isSetupComplete ? ( {activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'}
<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>
) : (
<span className="px-3 py-1 bg-white text-gray-700 border border-gray-200 rounded-full text-xs font-bold uppercase tracking-wide">Missing Information</span>
)}
</div> </div>
<div className="grid md:grid-cols-2 gap-5"> <div className="grid gap-5 md:grid-cols-2">
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium text-text-primary">Parsed Provider Data:</p> <p className="text-sm font-medium text-text-primary">Current Profile Summary</p>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft"> <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="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> <span className="font-bold text-text-primary">
{activeProfile.provider?.providerName || <span className="text-xs uppercase text-error-text">Missing</span>}
</span>
</li> </li>
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft"> <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="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> <span className="font-bold text-text-primary">
{activeProfile.provider?.senderId || <span className="text-xs uppercase text-error-text">Missing</span>}
</span>
</li> </li>
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft"> <li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
<span className="text-text-muted">Entity ID:</span> <span className="text-text-muted">DLT Entity ID</span>
<span className="font-bold text-text-primary">{pData.dltEntityId || <span className="text-error-text text-xs uppercase">Missing</span>}</span> <span className="font-bold text-text-primary">
{activeProfile.provider?.dltEntityId || <span className="text-xs uppercase text-error-text">Missing</span>}
</span>
</li> </li>
<li className="flex justify-between items-center bg-surface-white p-2 rounded border border-border-soft"> <li className="rounded border border-border-soft bg-surface-white p-3">
<span className="text-text-muted">Auth Key:</span> <p className="text-xs font-semibold uppercase tracking-wide text-text-muted">Setup Status</p>
<span className="font-semibold text-text-primary font-mono">{pData.authKey ? '••••••••' : 'None setup'}</span> <p className="mt-2 text-sm text-text-primary">{getProfileSummary(activeProfile)}</p>
</li> </li>
</ul> </ul>
</div> </div>
{!isSetupComplete && ( {!activeProfile.executionReadiness?.isSetupComplete ? (
<div className="bg-surface-white p-4 rounded-lg border border-border-main "> <div className="rounded-lg border border-border-main bg-surface-white p-4">
<p className="text-sm font-semibold text-text-primary mb-3">Please fill in the missing fields:</p> <p className="mb-3 text-sm font-semibold text-text-primary">Complete the required fields</p>
<form onSubmit={handleProviderSubmit} className="space-y-3"> <form onSubmit={handleProviderSubmit} className="space-y-3">
{missingFields.includes('providerName') && ( {missingInputs.map((input) => (
<div key={input.key}>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
<input <input
type="text" type={input.secret ? 'password' : 'text'}
placeholder="Provider Name (e.g. MSG91)" value={inputForm[input.key] || ''}
value={providerForm.providerName} onChange={(event) => setInputForm((current) => ({
onChange={e => setProviderForm({ ...providerForm, providerName: e.target.value })} ...current,
className="w-full px-3 py-2 border border-border-main rounded text-sm focus:ring-1 focus:ring-primary-blue bg-page-bg" [input.key]: input.key === 'senderId'
required ? 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>
{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 <button
type="submit" type="submit"
disabled={savingProvider} disabled={savingInputs || missingInputs.some((input) => !String(inputForm[input.key] || '').trim())}
className="w-full py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-bold rounded transition disabled:opacity-50" className="w-full rounded bg-primary-blue py-2 text-sm font-bold text-white transition hover:bg-primary-dark disabled:opacity-50"
> >
{savingProvider ? 'Saving...' : 'Save Required Details'} {savingInputs ? 'Saving...' : 'Save Required Details'}
</button> </button>
</form> </form>
</div> </div>
)} ) : (
<div className="flex h-full flex-col items-center justify-center space-y-4">
{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> <p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
<button <button
type="button"
onClick={() => navigate(eventsPath)} 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" 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 Continue to Events
</button> </button>
@ -326,70 +467,82 @@ export default function GlobalSms() {
)} )}
</div> </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 && ( {hasProfiles && (
<div className="space-y-4 pt-4 border-t border-border-soft"> <div className="space-y-4 border-t border-border-soft pt-4">
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3> <h3 className="text-lg font-bold text-text-primary">Saved Profiles</h3>
{profiles.map(p => { {profiles.map((profile) => {
const isActive = p.id === activeProfileId; 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 ( return (
<div <div
key={p.id} key={profile.id}
className={`rounded-xl border p-5 transition-colors ${ className={`rounded-xl border p-5 transition-colors ${isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'}`}
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="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-3"> <div className="mb-3 flex flex-wrap items-center gap-3">
<h3 className="truncate text-base font-bold text-text-primary">{p.name}</h3> <h3 className="truncate text-base font-bold text-text-primary">{profile.name}</h3>
{isActive && ( {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"> <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 Active Profile
</span> </span>
)} )}
{p.isDefault && !isActive && ( <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'}`}>
<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"> {profile.executionReadiness?.isSetupComplete ? 'Ready' : 'Needs Fields'}
Default
</span> </span>
)}
</div> </div>
<p className="mb-3 text-xs font-medium text-text-muted"> <p className="mb-2 text-xs font-medium text-text-muted">Updated: {formatUpdatedAt(profile.updatedAt)}</p>
Updated: {new Date(p.updatedAt).toLocaleString()} <p className="mb-3 text-sm text-text-muted">{getProfileSummary(profile)}</p>
</p>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950"> <div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
<div className="border-b border-gray-800 px-4 py-2"> <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">Raw cURL</p> <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> </div>
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100"> <pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
<code>{p.rawCurl}</code> <code>{displayCurl || 'No cURL stored.'}</code>
</pre> </pre>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end"> <div className="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
{!isActive && ( {!isActive && (
<button <button
onClick={() => handleActivate(p.id)} 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" className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
> >
Use this cURL Use this cURL
</button> </button>
)} )}
<button <button
onClick={() => handleEditClick(p)} 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" 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"
> >
Edit Copy
</button> </button>
{profiles.length > 1 && (
<button <button
onClick={() => handleDelete(p.id)} type="button"
onClick={() => handleDeleteRequest(profile)}
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-error-text hover:bg-red-50 hover:text-error-text" className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-error-text hover:bg-red-50 hover:text-error-text"
> >
Delete Delete
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -398,33 +551,18 @@ export default function GlobalSms() {
</div> </div>
)} )}
{/* Inline Form (Create / Edit) */} <div className={`overflow-hidden rounded-xl border ${!hasProfiles ? 'border-indigo-100 bg-white shadow-sm' : 'border-border-main bg-surface-white'}`}>
<div <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'}`}>
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> <div>
<h3 className="font-bold text-text-primary text-md"> <h3 className="text-md font-bold text-text-primary">Add New Profile</h3>
{editingId ? 'Edit Profile' : isCreatingFirstProfile ? 'Create Your First cURL Profile' : 'Add New Profile'}
</h3>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">
Give this profile a recognizable name, then paste the full provider cURL command below. Paste a provider cURL exactly once. After validation, the stored cURL becomes immutable and can only be replaced by creating a new profile.
</p> </p>
</div> </div>
{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>
<div className="p-6"> <div className="p-6">
{isCreatingFirstProfile && ( {!hasProfiles && (
<div className="mb-6 rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-4"> <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="text-sm font-semibold text-text-primary">Start by adding a cURL profile</p>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">
@ -432,68 +570,55 @@ export default function GlobalSms() {
</p> </p>
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label> <label className="mb-1.5 block text-sm font-semibold text-text-primary">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 <input
type="text" type="text"
value={formName} value={formName}
onChange={e => setFormName(e.target.value)} onChange={(event) => setFormName(event.target.value)}
placeholder="e.g. Production SMS, Staging Twilio" 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 placeholder-placeholder-bg transition focus:outline-none focus:ring-2 focus:ring-primary-blue" 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 required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Raw cURL Command</label> <label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider 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 <textarea
value={formCurl} value={formCurl}
onChange={e => setFormCurl(e.target.value)} onChange={(event) => setFormCurl(event.target.value)}
placeholder="curl --request POST --url ..." 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" 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 required
spellCheck="false" spellCheck="false"
/> />
</div> </div>
{!editingId && hasProfiles && (
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex cursor-pointer items-center gap-2">
<input <input
type="checkbox" type="checkbox"
className="w-4 h-4 text-primary-blue rounded border-border-main focus:ring-primary-blue" className="h-4 w-4 rounded border-border-main text-primary-blue focus:ring-primary-blue"
checked={formSetActive} checked={formSetActive}
onChange={e => setFormSetActive(e.target.checked)} onChange={(event) => setFormSetActive(event.target.checked)}
/> />
<span className="text-sm font-semibold text-text-primary">Set as active profile immediately</span> <span className="text-sm font-semibold text-text-primary">Set as active profile immediately</span>
</label> </label>
)}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button
type="submit" type="submit"
disabled={saving} 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" 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="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : (editingId ? 'Update Profile' : 'Save Profile')} {saving ? 'Saving…' : 'Save Profile'}
</button> </button>
{editingId && (
<button
type="button"
onClick={handleAddClick}
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"
>
Cancel Edit
</button>
)}
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

View File

@ -1,21 +1,123 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
function getMissingProviderFields(profile) { const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
const provider = profile?.provider || {};
const missing = [];
if (!provider.providerName) missing.push('Provider Name'); function normalizeCurlForDisplay(value) {
if (!provider.senderId) missing.push('Sender ID'); if (!value) return '';
if (!provider.dltEntityId) missing.push('DLT Entity ID');
return missing; return String(value)
.trim()
.replace(/\r\n/g, '\n')
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\\t/g, ' ')
.replace(/\\'/g, '\'')
.replace(/\\"/g, '"');
} }
function isProviderSetupComplete(profile) { function stripWrappingQuotes(value) {
return getMissingProviderFields(profile).length === 0; if (!value || value.length < 2) return value;
if (
(value.startsWith('\'') && value.endsWith('\''))
|| (value.startsWith('"') && value.endsWith('"'))
) {
return value.slice(1, -1);
}
return value;
}
function formatCurlCommand(normalizedCurl) {
if (!normalizedCurl) return '';
let output = normalizedCurl;
if (!output.includes('\n')) {
output = output
.replace(/^curl\s+/, 'curl\n ')
.replace(/\s+(--request|-X|--url|--header|-H|--data-raw|--data|-d|--compressed|--location|--insecure|--fail)\b/g, '\n $1');
}
return output.replace(/\n{3,}/g, '\n\n').trim();
}
function extractCurlBody(normalizedCurl) {
if (!normalizedCurl) return '';
const quotedMatch = normalizedCurl.match(
/(?:--data-raw|--data|-d)\s+(["'])([\s\S]*?)\1(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
);
if (quotedMatch?.[2]) return stripWrappingQuotes(quotedMatch[2].trim());
const braceMatch = normalizedCurl.match(
/(?:--data-raw|--data|-d)\s+({[\s\S]*})(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
);
return braceMatch?.[1]?.trim() || '';
}
function buildCurlViewModel(value) {
const normalizedCurl = normalizeCurlForDisplay(value);
const headers = [
...normalizedCurl.matchAll(/(?:--header|-H)\s+(?:"([^"]+)"|'([^']+)')/g),
]
.map((match) => (match[1] || match[2] || '').trim())
.filter(Boolean);
const methodMatch = normalizedCurl.match(/(?:--request|-X)\s+([A-Z]+)/i);
const method = (methodMatch?.[1] || (/(?:--data-raw|--data|-d)\b/i.test(normalizedCurl) ? 'POST' : 'GET')).toUpperCase();
const url = normalizedCurl.match(/https?:\/\/[^\s'"]+/i)?.[0] || '';
const rawBody = extractCurlBody(normalizedCurl);
let payload = stripWrappingQuotes(rawBody || '').trim();
let prettyPayload = '';
let payloadFormat = '';
if (payload) {
try {
const parsed = JSON.parse(payload);
prettyPayload = JSON.stringify(parsed, null, 2);
payloadFormat = 'json';
} catch {
prettyPayload = payload;
payloadFormat = 'text';
}
}
let host = '';
try {
host = url ? new URL(url).host : '';
} catch {
host = '';
}
const shellLines = [];
if (url) {
shellLines.push('curl \\');
shellLines.push(` --request ${method} \\`);
shellLines.push(` --url '${url}'${headers.length || rawBody ? ' \\' : ''}`);
headers.forEach((header, index) => {
const hasTrailingSection = index < headers.length - 1 || Boolean(rawBody);
shellLines.push(` --header '${header}'${hasTrailingSection ? ' \\' : ''}`);
});
if (rawBody) {
shellLines.push(` --data-raw '${payloadFormat === 'json' ? '<payload shown below>' : rawBody}'`);
}
}
return {
command: shellLines.length > 0 ? shellLines.join('\n') : formatCurlCommand(normalizedCurl),
headers,
host,
method,
payload: prettyPayload,
payloadFormat,
url,
};
} }
function formatUpdatedAt(value) { function formatUpdatedAt(value) {
@ -28,15 +130,46 @@ function formatUpdatedAt(value) {
} }
} }
function buildProviderSummary(profile) { function buildProfilePatchPayload(inputs = [], values = {}) {
const provider = profile?.provider || {}; const provider = {};
const profileInputValues = {};
inputs.forEach((input) => {
const rawValue = String(values[input.key] ?? '').trim();
if (!rawValue) return;
if (BASE_PROFILE_KEYS.has(input.key)) {
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
return;
}
profileInputValues[input.key] = rawValue;
});
return {
...(Object.keys(provider).length > 0 ? { provider } : {}),
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
};
}
function getInitialFormValues(inputs = []) {
return inputs.reduce((accumulator, input) => {
accumulator[input.key] = input.secret ? '' : (input.value || '');
return accumulator;
}, {});
}
function getProfileSummary(profile) {
const parts = []; const parts = [];
const provider = profile?.provider || {};
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
if (provider.providerName) parts.push(provider.providerName); if (provider.providerName) parts.push(provider.providerName);
if (provider.senderId) parts.push(`Sender ${provider.senderId}`); if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
if (provider.dltEntityId) parts.push('DLT added'); if (provider.dltEntityId) parts.push('DLT ready');
if (missingCount > 0) parts.push(`${missingCount} pending`);
return parts.length > 0 ? parts.join(' • ') : 'Provider details not completed yet'; return parts.join(' • ') || 'Profile saved. Complete the required fields to use it everywhere.';
} }
function ProfileStatusPill({ complete }) { function ProfileStatusPill({ complete }) {
@ -48,11 +181,20 @@ function ProfileStatusPill({ complete }) {
: 'border-amber-200 bg-amber-50 text-amber-700' : 'border-amber-200 bg-amber-50 text-amber-700'
}`} }`}
> >
{complete ? 'Complete' : 'Missing Fields'} {complete ? 'Ready' : 'Needs Fields'}
</span> </span>
); );
} }
function InspectorRow({ label, value, valueClassName = '' }) {
return (
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">{label}</p>
<p className={`mt-2 text-sm font-medium text-gray-900 ${valueClassName}`}>{value}</p>
</div>
);
}
export default function Providers() { export default function Providers() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -62,16 +204,11 @@ export default function Providers() {
const [profiles, setProfiles] = useState([]); const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(''); const [activeProfileId, setActiveProfileId] = useState('');
const [selectedProfileId, setSelectedProfileId] = useState(''); const [selectedProfileId, setSelectedProfileId] = useState('');
const [form, setForm] = useState({ const [formValues, setFormValues] = useState({});
providerName: '', const [revealedProfiles, setRevealedProfiles] = useState({});
senderId: '', const [showSecretsByProfileId, setShowSecretsByProfileId] = useState({});
dltEntityId: '',
authKey: '',
});
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [copiedProfileId, setCopiedProfileId] = useState('');
const copyTimeoutRef = useRef(null);
const globalSmsPath = `/${businessId}/global-sms`; const globalSmsPath = `/${businessId}/global-sms`;
@ -100,37 +237,41 @@ export default function Providers() {
loadProfiles(); loadProfiles();
}, [loadProfiles]); }, [loadProfiles]);
useEffect(() => () => { const selectedProfile = useMemo(
if (copyTimeoutRef.current) { () => profiles.find((profile) => profile.id === selectedProfileId) || null,
clearTimeout(copyTimeoutRef.current); [profiles, selectedProfileId],
} );
}, []); const selectedProfileInputs = selectedProfile?.profileInputs || [];
const isSelectedProfileRevealed = selectedProfile ? showSecretsByProfileId[selectedProfile.id] === true : false;
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) || null; const selectedRevealData = selectedProfile ? revealedProfiles[selectedProfile.id] : null;
const selectedDisplayCurl = selectedProfile
? (isSelectedProfileRevealed
? (selectedRevealData?.rawCurl || selectedProfile.maskedCurl)
: selectedProfile.maskedCurl)
: '';
const selectedCurlView = useMemo(
() => buildCurlViewModel(selectedDisplayCurl),
[selectedDisplayCurl],
);
const missingInputCount = selectedProfile?.executionReadiness?.missingProfileInputs?.length || 0;
const curlWarnings = selectedProfile?.curlAnalysis?.warnings || [];
useEffect(() => { useEffect(() => {
if (!selectedProfile) { if (!selectedProfile) {
setForm({ setFormValues({});
providerName: '',
senderId: '',
dltEntityId: '',
authKey: '',
});
return; return;
} }
const provider = selectedProfile.provider || {}; setFormValues(getInitialFormValues(selectedProfile.profileInputs));
setForm({
providerName: provider.providerName || '',
senderId: provider.senderId || '',
dltEntityId: provider.dltEntityId || '',
authKey: provider.authKey || '',
});
}, [selectedProfile]); }, [selectedProfile]);
function handleChange(field, value) { const ensureRevealData = useCallback(async (profileId) => {
setForm((prev) => ({ ...prev, [field]: value })); if (revealedProfiles[profileId]) return revealedProfiles[profileId];
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
return res.data;
}, [businessId, revealedProfiles]);
function handleSelectProfile(profileId) { function handleSelectProfile(profileId) {
setSelectedProfileId(profileId); setSelectedProfileId(profileId);
@ -160,53 +301,55 @@ export default function Providers() {
} }
} }
async function handleToggleReveal(profile) {
if (!profile?.id) return;
const shouldReveal = !showSecretsByProfileId[profile.id];
if (shouldReveal) {
try {
const revealData = await ensureRevealData(profile.id);
const revealedValues = (revealData?.profileInputs || []).reduce((accumulator, input) => {
accumulator[input.key] = input.value || '';
return accumulator;
}, {});
setFormValues((current) => ({ ...current, ...revealedValues }));
} catch (err) {
setError(err.response?.data?.error || 'Failed to reveal saved values');
return;
}
}
setShowSecretsByProfileId((current) => ({
...current,
[profile.id]: shouldReveal,
}));
}
async function handleCopyCurl(profile) { async function handleCopyCurl(profile) {
if (!profile?.rawCurl) return; if (!profile?.id) return;
try { try {
if (!navigator?.clipboard?.writeText) { const revealData = await ensureRevealData(profile.id);
throw new Error('Clipboard API unavailable'); if (!revealData?.rawCurl) return;
}
await navigator.clipboard.writeText(profile.rawCurl); await navigator.clipboard.writeText(revealData.rawCurl);
setCopiedProfileId(profile.id); setSuccess(`Copied ${profile.name} cURL.`);
} catch (err) {
if (copyTimeoutRef.current) { setError(err.response?.data?.error || 'Failed to copy the cURL command.');
clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = window.setTimeout(() => {
setCopiedProfileId('');
}, 1800);
} catch {
setError('Failed to copy the cURL command.');
} }
} }
async function handleSave(event) { async function handleSave(event) {
event.preventDefault(); event.preventDefault();
if (!selectedProfile?.id) return; if (!selectedProfile?.id) return;
setSaving(true); setSaving(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
if (form.senderId && !/^[A-Za-z]{6}$/.test(form.senderId)) {
setError('DLT Sender ID must be exactly 6 alphabet characters');
setSaving(false);
return;
}
try { try {
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, { const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
provider: { await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
providerName: form.providerName,
senderId: form.senderId.toUpperCase(),
dltEntityId: form.dltEntityId,
authKey: form.authKey,
},
});
await loadProfiles(); await loadProfiles();
await refreshOnboardingState(businessId).catch(() => null); await refreshOnboardingState(businessId).catch(() => null);
@ -221,7 +364,7 @@ export default function Providers() {
if (loading) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
</div> </div>
); );
} }
@ -232,7 +375,7 @@ export default function Providers() {
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1> <h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
<p className="mt-1 text-sm font-medium text-gray-500"> <p className="mt-1 text-sm font-medium text-gray-500">
Review the provider details stored against each saved cURL profile. Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
</p> </p>
</div> </div>
<button <button
@ -252,6 +395,7 @@ export default function Providers() {
</button> </button>
</div> </div>
)} )}
{success && ( {success && (
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700"> <div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
{success} {success}
@ -293,7 +437,7 @@ export default function Providers() {
<div className="space-y-3"> <div className="space-y-3">
{profiles.map((profile) => { {profiles.map((profile) => {
const isActive = profile.id === activeProfileId; const isActive = profile.id === activeProfileId;
const complete = isProviderSetupComplete(profile); const complete = profile.executionReadiness?.isSetupComplete === true;
return ( return (
<button <button
@ -305,7 +449,7 @@ export default function Providers() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p> <p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p>
<p className="mt-1 text-sm leading-relaxed text-gray-500">{buildProviderSummary(profile)}</p> <p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p>
</div> </div>
<div className="flex shrink-0 flex-wrap justify-end gap-2"> <div className="flex shrink-0 flex-wrap justify-end gap-2">
{isActive && ( {isActive && (
@ -352,10 +496,10 @@ export default function Providers() {
Active profile Active profile
</span> </span>
)} )}
<ProfileStatusPill complete={isProviderSetupComplete(selectedProfile)} /> <ProfileStatusPill complete={selectedProfile.executionReadiness?.isSetupComplete === true} />
</div> </div>
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
Review the exact saved request, then update the provider fields tied to this profile. The stored cURL is immutable after validation. You can review it, reveal it, and update the profile fields it depends on.
</p> </p>
</div> </div>
@ -371,17 +515,17 @@ export default function Providers() {
)} )}
<button <button
type="button" type="button"
onClick={() => handleCopyCurl(selectedProfile)} onClick={() => handleToggleReveal(selectedProfile)}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue" className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
> >
{copiedProfileId === selectedProfile.id ? 'Copied' : 'Copy cURL'} {showSecretsByProfileId[selectedProfile.id] ? 'Hide Values' : 'Reveal Values'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => navigate(`${globalSmsPath}?editProfile=${encodeURIComponent(selectedProfile.id)}`)} onClick={() => handleCopyCurl(selectedProfile)}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue" className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
> >
Edit cURL Copy cURL
</button> </button>
</div> </div>
</div> </div>
@ -389,88 +533,149 @@ export default function Providers() {
</div> </div>
<div className="space-y-6 px-6 py-6"> <div className="space-y-6 px-6 py-6">
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-950"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px] xl:items-start">
<div className="flex items-center justify-between gap-4 border-b border-gray-800 px-4 py-3"> <div className="overflow-hidden rounded-[28px] border border-slate-200 bg-slate-950 shadow-[0_28px_60px_-42px_rgba(15,23,42,0.75)]">
<div> <div className="border-b border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.2),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.96))] px-5 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Preview</p> <div className="flex flex-wrap items-start gap-3">
<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> </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"> <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)} Updated {formatUpdatedAt(selectedProfile.updatedAt)}
</span> </span>
</div> </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> </div>
<div className="space-y-5 px-5 py-5"> <div className="space-y-5 px-5 py-5">
<div> <div>
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}> <p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shell View</p>
Provider Name {!form.providerName && <span className="text-error-text">*</span>} <pre className="max-h-[26rem] overflow-y-auto overscroll-contain rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-[13px] leading-7 text-slate-100 shadow-inner">
</label> <code>{selectedCurlView.command || 'No cURL stored.'}</code>
<input </pre>
type="text"
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"
/>
</div> </div>
<div className="grid gap-5 sm:grid-cols-2"> {selectedCurlView.payload && (
<div> <div>
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}> <div className="mb-3 flex items-center justify-between gap-3">
DLT Sender ID {!form.senderId && <span className="text-error-text">*</span>} <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Request Payload</p>
</label> <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">
<input {selectedCurlView.payloadFormat === 'json' ? 'JSON' : 'Text'}
type="text" </span>
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>
</div> </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">
<div> <code>{selectedCurlView.payload}</code>
<label className={`mb-1.5 block text-sm font-semibold tracking-wide ${!form.dltEntityId ? 'text-error-text' : 'text-text-primary'}`}> </pre>
DLT Entity ID {!form.dltEntityId && <span className="text-error-text">*</span>} </div>
</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> </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> <div>
<label className="mb-1.5 block text-sm font-semibold tracking-wide text-text-primary"> <p className="text-sm font-semibold text-gray-900">Profile Inspector</p>
API Auth Key <span className="text-xs font-normal text-text-muted">(Optional)</span> <p className="mt-1 text-sm text-gray-500">
</label> Keep the request front and center, then reveal stored values only when you need to inspect or edit them.
<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> </p>
</div> </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>
{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>
{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>
<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>
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4"> <div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
@ -479,40 +684,18 @@ export default function Providers() {
disabled={saving} 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" 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 ? 'Saving…' : 'Save Changes'}
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Saving
</>
) : 'Save Configuration'}
</button> </button>
</div> </div>
</form> </form>
) : (
<aside className="rounded-2xl border border-gray-200 bg-gray-50 p-5"> <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">Current Status</p> <p className="text-sm font-semibold text-gray-900">Values stay hidden by default</p>
<ul className="mt-4 space-y-3 text-sm"> <p className="mt-2 text-sm leading-relaxed text-gray-500">
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3"> Reveal mode will render stored values inside the cURL on the left and open the editable field inspector here.
<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'}
</p> </p>
</li> </div>
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3"> )}
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Provider Setup</p>
<p className="mt-2 font-medium text-gray-900">
{isProviderSetupComplete(selectedProfile)
? 'All mandatory provider fields are complete.'
: getMissingProviderFields(selectedProfile).join(', ')}
</p>
</li>
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Auth Key</p>
<p className="mt-2 font-medium text-gray-900">
{selectedProfile.provider?.authKey ? 'Saved on this profile' : 'Not added'}
</p>
</li>
</ul>
</aside> </aside>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -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,
};

View File

@ -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