Fixes in dockerfile
This commit is contained in:
parent
0ee351d316
commit
83b48a3aca
10
Dockerfile
10
Dockerfile
|
|
@ -1,3 +1,12 @@
|
||||||
|
FROM node:20-alpine AS client-build
|
||||||
|
WORKDIR /client
|
||||||
|
|
||||||
|
COPY client/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY client/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
@ -9,6 +18,7 @@ COPY server/index.js ./
|
||||||
COPY server/config ./config
|
COPY server/config ./config
|
||||||
COPY server/routes ./routes
|
COPY server/routes ./routes
|
||||||
COPY server/services ./services
|
COPY server/services ./services
|
||||||
|
COPY --from=client-build /client/dist ./public
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ import Templates from './pages/Templates';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
function SubLayout({ children }) {
|
function SubLayout({ children }) {
|
||||||
const { activeBusinessId } = useBusiness();
|
const { activeBusinessId, hasGlobalSms } = useBusiness();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-page-bg">
|
<div className="flex min-h-screen bg-page-bg">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 ml-60 flex flex-col">
|
<main className="flex-1 ml-60 flex flex-col">
|
||||||
<header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
|
<header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
|
||||||
|
{hasGlobalSms && (
|
||||||
<Link
|
<Link
|
||||||
to={`/${activeBusinessId}/settings`}
|
to={`/${activeBusinessId}/settings`}
|
||||||
className="w-10 h-10 rounded-full hover:bg-refresh-hover text-gray-500 hover:text-primary-blue flex items-center justify-center transition-colors shadow-sm border border-border-soft"
|
className="w-10 h-10 rounded-full hover:bg-refresh-hover text-gray-500 hover:text-primary-blue flex items-center justify-center transition-colors shadow-sm border border-border-soft"
|
||||||
|
|
@ -22,6 +23,7 @@ function SubLayout({ children }) {
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 p-8 overflow-auto">
|
<div className="flex-1 p-8 overflow-auto">
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -31,8 +33,8 @@ function SubLayout({ children }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: redirect to / if no active business in session
|
// Guard: redirect to / if no active business in session.
|
||||||
// Also enforce cURL-first: redirect to global-sms if no cURL is saved yet.
|
// Also enforce cURL-first: only the cURL profile route is available until an active profile exists.
|
||||||
function BusinessGuard({ children, isGlobalSmsRoute }) {
|
function BusinessGuard({ children, isGlobalSmsRoute }) {
|
||||||
const { activeBusinessId, loading, hasGlobalSms } = useBusiness();
|
const { activeBusinessId, loading, hasGlobalSms } = useBusiness();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -49,10 +51,7 @@ function BusinessGuard({ children, isGlobalSmsRoute }) {
|
||||||
return <Navigate to="/" state={{ from: location }} replace />;
|
return <Navigate to="/" state={{ from: location }} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasGlobalSms && !isGlobalSmsRoute && !location.pathname.endsWith('/settings')) {
|
if (!hasGlobalSms && !isGlobalSmsRoute) {
|
||||||
// Only allow global SMS page if cURL constraint is not met yet
|
|
||||||
// Optionally allow settings, but strictly planning says "must go to Global SMS first".
|
|
||||||
// We enforce only global SMS by redirecting other pages.
|
|
||||||
return <Navigate to={`/${activeBusinessId}/global-sms`} replace />;
|
return <Navigate to={`/${activeBusinessId}/global-sms`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,106 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
export default function WhitelistModal({ businessId, template, onClose, onSuccess }) {
|
function getMissingProviderFields(profile) {
|
||||||
|
const provider = profile?.provider || {};
|
||||||
|
const missing = [];
|
||||||
|
if (!provider.providerName) missing.push('providerName');
|
||||||
|
if (!provider.senderId) missing.push('senderId');
|
||||||
|
if (!provider.dltEntityId) missing.push('dltEntityId');
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
|
||||||
|
const [profile, setProfile] = useState(boundProfile);
|
||||||
|
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [toNumber, setToNumber] = useState('');
|
const [toNumber, setToNumber] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [savingProvider, setSavingProvider] = useState(false);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [step, setStep] = useState('provider');
|
||||||
const [providers, setProviders] = useState(null);
|
|
||||||
const [form, setForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
|
||||||
const [loadingProviders, setLoadingProviders] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchProviders() {
|
setProfile(boundProfile);
|
||||||
try {
|
setProviderForm({
|
||||||
const res = await apiClient.get(`/api/businesses/${businessId}/providers`);
|
providerName: boundProfile?.provider?.providerName || '',
|
||||||
setProviders(res.data || {});
|
senderId: boundProfile?.provider?.senderId || '',
|
||||||
setForm({
|
dltEntityId: boundProfile?.provider?.dltEntityId || '',
|
||||||
providerName: res.data?.providerName || '',
|
|
||||||
senderId: res.data?.senderId || '',
|
|
||||||
dltEntityId: res.data?.dltEntityId || ''
|
|
||||||
});
|
});
|
||||||
} catch {
|
}, [boundProfile]);
|
||||||
setProviders({});
|
|
||||||
} finally {
|
|
||||||
setLoadingProviders(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchProviders();
|
|
||||||
}, [businessId]);
|
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!boundProfile) {
|
||||||
|
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
||||||
|
setStep('provider');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setStep(missingFields.length > 0 ? 'provider' : 'publish');
|
||||||
|
}, [boundProfile, missingFields]);
|
||||||
|
|
||||||
|
async function handleProviderSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!profile?.id) return;
|
||||||
|
|
||||||
|
setSavingProvider(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.patch(
|
||||||
|
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
||||||
|
{
|
||||||
|
provider: {
|
||||||
|
providerName: providerForm.providerName,
|
||||||
|
senderId: providerForm.senderId.toUpperCase(),
|
||||||
|
dltEntityId: providerForm.dltEntityId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setProfile(res.data);
|
||||||
|
setProviderForm({
|
||||||
|
providerName: res.data?.provider?.providerName || '',
|
||||||
|
senderId: res.data?.provider?.senderId || '',
|
||||||
|
dltEntityId: res.data?.provider?.dltEntityId || '',
|
||||||
|
});
|
||||||
|
setStep(getMissingProviderFields(res.data).length > 0 ? 'provider' : 'publish');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to save provider details');
|
||||||
|
} finally {
|
||||||
|
setSavingProvider(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublish(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!templateId.trim() || !toNumber.trim()) return;
|
if (!templateId.trim() || !toNumber.trim()) return;
|
||||||
setSaving(true);
|
|
||||||
|
setPublishing(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/api/businesses/${businessId}/templates/${template.eventSlug}/publish`, {
|
await apiClient.post(`/api/businesses/${businessId}/templates/${template.eventSlug}/publish`, {
|
||||||
templateId: templateId.trim(),
|
templateId: templateId.trim(),
|
||||||
toNumber: toNumber.trim(),
|
toNumber: toNumber.trim(),
|
||||||
providerName: form.providerName,
|
|
||||||
senderId: form.senderId.toUpperCase(),
|
|
||||||
dltEntityId: form.dltEntityId
|
|
||||||
});
|
});
|
||||||
onSuccess(template.eventSlug, templateId.trim());
|
await Promise.resolve(onSuccess());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.data?.missingFields) {
|
if (err.response?.data?.missingFields?.length) {
|
||||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
||||||
|
setStep('provider');
|
||||||
} else {
|
} else {
|
||||||
setError(err.response?.data?.error || 'Failed to publish template');
|
setError(err.response?.data?.error || 'Failed to publish template');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setPublishing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const missingName = !providers?.providerName;
|
const isProfileMissing = !profile?.id;
|
||||||
const missingSender = !providers?.senderId;
|
|
||||||
const missingDlt = !providers?.dltEntityId;
|
|
||||||
const hasMissingProviders = missingName || missingSender || missingDlt;
|
|
||||||
|
|
||||||
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 bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10">
|
||||||
|
|
@ -66,13 +108,23 @@ export default function WhitelistModal({ businessId, template, onClose, onSucces
|
||||||
<div className="w-12 h-12 rounded-full bg-orange-bg flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 rounded-full bg-orange-bg flex items-center justify-center mx-auto mb-4">
|
||||||
<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">Publish Template</h3>
|
|
||||||
|
<h3 className="text-lg font-bold text-text-primary text-center mb-1">
|
||||||
|
{step === 'provider' ? 'Complete Provider Details' : 'Publish Template'}
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-text-muted text-center mb-1">
|
<p className="text-sm text-text-muted text-center mb-1">
|
||||||
Provide your DLT details and a test number to publish:
|
{step === 'provider'
|
||||||
|
? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.'
|
||||||
|
: 'Provide the DLT template ID and destination number to complete publish.'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-text-primary text-center mb-6 capitalize">
|
<p className="text-sm font-semibold text-text-primary text-center mb-2 capitalize">
|
||||||
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
||||||
</p>
|
</p>
|
||||||
|
{profile && (
|
||||||
|
<p className="text-xs text-text-muted text-center mb-6 uppercase tracking-wide font-semibold">
|
||||||
|
Bound Profile: {profile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-4 py-2.5 rounded-md text-error-text bg-delayed-bg border border-delayed-border text-sm font-medium">
|
<div className="mb-4 px-4 py-2.5 rounded-md text-error-text bg-delayed-bg border border-delayed-border text-sm font-medium">
|
||||||
|
|
@ -80,12 +132,77 @@ export default function WhitelistModal({ businessId, template, onClose, onSucces
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadingProviders ? (
|
{step === 'provider' ? (
|
||||||
<div className="flex justify-center p-4">
|
<form onSubmit={handleProviderSubmit} className="space-y-4">
|
||||||
<span className="w-6 h-6 border-2 border-spinner-track border-t-primary-blue rounded-full animate-spin" />
|
{missingFields.includes('providerName') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={providerForm.providerName}
|
||||||
|
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||||
|
placeholder="e.g. MSG91"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
</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.5 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||||
|
placeholder="6 CHARS"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</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.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||||
|
placeholder="19-digit DLT PE ID"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={savingProvider}
|
||||||
|
className="flex-1 py-2.5 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingProvider || isProfileMissing || missingFields.some(field => {
|
||||||
|
if (field === 'providerName') return !providerForm.providerName.trim();
|
||||||
|
if (field === 'senderId') return !providerForm.senderId.trim();
|
||||||
|
if (field === 'dltEntityId') return !providerForm.dltEntityId.trim();
|
||||||
|
return false;
|
||||||
|
})}
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm 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'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} 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="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -109,69 +226,24 @@ export default function WhitelistModal({ businessId, template, onClose, onSucces
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
className="w-full px-4 py-2.5 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">Number to send the initial request on publish</p>
|
<p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasMissingProviders && (
|
|
||||||
<div className="pt-2 pb-2">
|
|
||||||
<p className="text-xs text-error-text font-bold mb-3">You MUST provide missing provider details before publishing.</p>
|
|
||||||
<div className="space-y-3 p-4 bg-delayed-bg border border-delayed-border rounded-lg">
|
|
||||||
{missingName && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-text-primary mb-1">Provider Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.providerName}
|
|
||||||
onChange={e => setForm({ ...form, providerName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{missingSender && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-text-primary mb-1">Sender ID (6 Chars)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.senderId}
|
|
||||||
onChange={e => setForm({ ...form, senderId: e.target.value.toUpperCase() })}
|
|
||||||
maxLength={6}
|
|
||||||
className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm font-mono uppercase"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{missingDlt && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-text-primary mb-1">DLT Entity ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.dltEntityId}
|
|
||||||
onChange={e => setForm({ ...form, dltEntityId: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 rounded border border-border-main bg-surface-white text-sm font-mono"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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={saving}
|
disabled={publishing}
|
||||||
className="flex-1 py-2.5 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
|
className="flex-1 py-2.5 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving || !templateId.trim() || !toNumber.trim() || (hasMissingProviders && (!form.providerName || !form.senderId || !form.dltEntityId))}
|
disabled={publishing || !templateId.trim() || !toNumber.trim()}
|
||||||
className="flex-1 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
className="flex-1 py-2.5 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing…</> : 'Publish'}
|
{publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing…</> : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
|
|
@ -15,26 +15,25 @@ export default function Events() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||||
|
|
||||||
async function loadEvents() {
|
const loadEvents = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [eventsRes, providersRes, globalSmsRes] = await Promise.all([
|
const [eventsRes, activeProfileRes] = await Promise.all([
|
||||||
apiClient.get(`/api/businesses/${businessId}/events`),
|
apiClient.get(`/api/businesses/${businessId}/events`),
|
||||||
apiClient.get(`/api/businesses/${businessId}/providers`).catch(() => ({ data: {} })),
|
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
|
||||||
apiClient.get(`/api/businesses/${businessId}/global-sms`).catch(() => ({ data: {} })),
|
|
||||||
]);
|
]);
|
||||||
setEvents(eventsRes.data.events || []);
|
setEvents(eventsRes.data.events || []);
|
||||||
const hasProviders = !!providersRes.data?.senderId;
|
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||||
const hasGlobalSms = !!globalSmsRes.data?.rawCurl;
|
|
||||||
setReadyToGenerate(hasProviders && hasGlobalSms);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load events');
|
setError('Failed to load events');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [businessId]);
|
||||||
|
|
||||||
useEffect(() => { loadEvents(); }, [businessId]);
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, [loadEvents]);
|
||||||
|
|
||||||
async function handleAddEvent(e) {
|
async function handleAddEvent(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -64,7 +63,7 @@ export default function Events() {
|
||||||
|
|
||||||
async function handleGenerate(slug) {
|
async function handleGenerate(slug) {
|
||||||
if (!readyToGenerate) {
|
if (!readyToGenerate) {
|
||||||
setError('Configure Provider and Global SMS cURL before generating templates.');
|
setError('Configure and activate a cURL profile before generating templates.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGenState(s => ({ ...s, [slug]: 'loading' }));
|
setGenState(s => ({ ...s, [slug]: 'loading' }));
|
||||||
|
|
@ -122,7 +121,7 @@ export default function Events() {
|
||||||
{!readyToGenerate && (
|
{!readyToGenerate && (
|
||||||
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2">
|
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2">
|
||||||
<span>⚠️</span>
|
<span>⚠️</span>
|
||||||
<span>Set up <strong>Provider</strong> and <strong>Global SMS cURL</strong> before generating templates.</span>
|
<span>Set up and activate a <strong>cURL profile</strong> before generating templates.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { 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';
|
||||||
|
|
@ -20,25 +20,23 @@ export default function GlobalSms() {
|
||||||
const [formCurl, setFormCurl] = useState('');
|
const [formCurl, setFormCurl] = useState('');
|
||||||
const [formSetActive, setFormSetActive] = useState(true);
|
const [formSetActive, setFormSetActive] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadProfiles = useCallback(async () => {
|
||||||
loadProfiles();
|
|
||||||
}, [businessId]);
|
|
||||||
|
|
||||||
async function loadProfiles() {
|
|
||||||
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`);
|
||||||
setProfiles(res.data.profiles || []);
|
setProfiles(res.data.profiles || []);
|
||||||
setActiveProfileId(res.data.activeProfileId);
|
setActiveProfileId(res.data.activeProfileId);
|
||||||
if (res.data.activeProfileId) {
|
setHasGlobalSms(!!res.data.activeProfileId);
|
||||||
setHasGlobalSms(true);
|
} catch {
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load cURL profiles');
|
setError('Failed to load cURL profiles');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [businessId, setHasGlobalSms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfiles();
|
||||||
|
}, [loadProfiles]);
|
||||||
|
|
||||||
function handleAddClick() {
|
function handleAddClick() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ export default function Providers() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeProfile, setActiveProfile] = useState(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
providerName: 'MSG91',
|
providerName: '',
|
||||||
senderId: '',
|
senderId: '',
|
||||||
dltEntityId: '',
|
dltEntityId: '',
|
||||||
authKey: '',
|
authKey: '',
|
||||||
|
|
@ -18,17 +19,20 @@ export default function Providers() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/api/businesses/${businessId}/providers`);
|
const [activeRes, providerRes] = await Promise.all([
|
||||||
if (res.data && res.data.providerName) {
|
apiClient.get(`/api/businesses/${businessId}/global-sms/active`),
|
||||||
|
apiClient.get(`/api/businesses/${businessId}/providers`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setActiveProfile(activeRes.data?.activeProfile || null);
|
||||||
setForm({
|
setForm({
|
||||||
providerName: res.data.providerName || 'MSG91',
|
providerName: providerRes.data?.providerName || '',
|
||||||
senderId: res.data.senderId || '',
|
senderId: providerRes.data?.senderId || '',
|
||||||
dltEntityId: res.data.dltEntityId || '',
|
dltEntityId: providerRes.data?.dltEntityId || '',
|
||||||
authKey: res.data.authKey || '',
|
authKey: providerRes.data?.authKey || '',
|
||||||
});
|
});
|
||||||
}
|
} catch (err) {
|
||||||
} catch {
|
setError(err.response?.data?.error || 'Failed to load provider configuration');
|
||||||
// no providers yet — keep defaults
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +55,13 @@ export default function Providers() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/api/businesses/${businessId}/providers`, form);
|
const res = await apiClient.post(`/api/businesses/${businessId}/providers`, form);
|
||||||
|
setForm({
|
||||||
|
providerName: res.data?.providerName || '',
|
||||||
|
senderId: res.data?.senderId || '',
|
||||||
|
dltEntityId: res.data?.dltEntityId || '',
|
||||||
|
authKey: res.data?.authKey || '',
|
||||||
|
});
|
||||||
setSuccess('Provider configuration saved successfully.');
|
setSuccess('Provider configuration saved successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to save configuration');
|
setError(err.response?.data?.error || 'Failed to save configuration');
|
||||||
|
|
@ -72,7 +82,12 @@ export default function Providers() {
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className="pb-5 mb-6 border-b border-gray-200">
|
<div className="pb-5 mb-6 border-b border-gray-200">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Provider Configuration</h1>
|
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Provider Configuration</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1 font-medium">Save your DLT-approved sender details so the extension can dispatch SMS via your vendor.</p>
|
<p className="text-sm text-gray-500 mt-1 font-medium">Edit the provider details stored on the active cURL profile.</p>
|
||||||
|
{activeProfile && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 font-semibold uppercase tracking-wide">
|
||||||
|
Active Profile: {activeProfile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import WhitelistModal from '../components/WhitelistModal';
|
import WhitelistModal from '../components/WhitelistModal';
|
||||||
|
|
@ -13,33 +13,41 @@ const STATUS_CONFIG = {
|
||||||
export default function Templates() {
|
export default function Templates() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [profilesById, setProfilesById] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
||||||
const [testTarget, setTestTarget] = useState(null);
|
const [testTarget, setTestTarget] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
||||||
|
|
||||||
async function loadTemplates() {
|
const loadTemplates = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/api/businesses/${businessId}/templates`);
|
const [templatesRes, profilesRes] = await Promise.all([
|
||||||
// Show all templates that have a selected template (status != generated or status exists)
|
apiClient.get(`/api/businesses/${businessId}/templates`),
|
||||||
const all = (res.data.templates || []).filter(t => t.selectedTemplate);
|
apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const all = (templatesRes.data.templates || []).filter(t => t.selectedTemplate);
|
||||||
|
const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map(profile => [profile.id, profile]));
|
||||||
|
|
||||||
setTemplates(all);
|
setTemplates(all);
|
||||||
|
setProfilesById(profileMap);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load templates');
|
setError('Failed to load templates');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [businessId]);
|
||||||
|
|
||||||
useEffect(() => { loadTemplates(); }, [businessId]);
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
function handleWhitelistSuccess(slug, templateId) {
|
async function handleWhitelistSuccess() {
|
||||||
setTemplates(ts => ts.map(t =>
|
|
||||||
t.eventSlug === slug ? { ...t, status: 'whitelisted', templateId } : t
|
|
||||||
));
|
|
||||||
setWhitelistTarget(null);
|
setWhitelistTarget(null);
|
||||||
|
await loadTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -52,7 +60,6 @@ export default function Templates() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="pb-5 mb-6 border-b border-gray-200">
|
<div className="pb-5 mb-6 border-b border-gray-200">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Templates</h1>
|
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Templates</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1 font-medium">Track whitelisting status and test your SMS templates.</p>
|
<p className="text-sm text-gray-500 mt-1 font-medium">Track whitelisting status and test your SMS templates.</p>
|
||||||
|
|
@ -65,7 +72,6 @@ export default function Templates() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex space-x-4 mb-6 border-b border-border-main">
|
<div className="flex space-x-4 mb-6 border-b border-border-main">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('published')}
|
onClick={() => setActiveTab('published')}
|
||||||
|
|
@ -114,10 +120,14 @@ export default function Templates() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{visibleTemplates.map(tmpl => {
|
{visibleTemplates.map(tmpl => {
|
||||||
const statusCfg = STATUS_CONFIG[tmpl.status] || STATUS_CONFIG.generated;
|
const statusCfg = STATUS_CONFIG[tmpl.status] || STATUS_CONFIG.generated;
|
||||||
|
const boundProfile = tmpl.curlProfileId ? profilesById[tmpl.curlProfileId] || null : null;
|
||||||
|
const isBoundProfileMissing = !boundProfile;
|
||||||
|
const boundProfileMessage = tmpl.curlProfileId
|
||||||
|
? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.'
|
||||||
|
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tmpl.eventSlug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
<div key={tmpl.eventSlug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||||
{/* Card header */}
|
|
||||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">
|
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">
|
||||||
|
|
@ -131,7 +141,20 @@ export default function Templates() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Template text */}
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bound cURL Profile</label>
|
||||||
|
{boundProfile ? (
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200 text-sm text-gray-700">
|
||||||
|
<span className="font-semibold">{boundProfile.name}</span>
|
||||||
|
<span className="text-gray-400 font-mono text-xs">{boundProfile.id}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-3 rounded-lg border border-red-200 bg-red-50 text-sm text-red-700 font-medium">
|
||||||
|
{boundProfileMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Selected Template</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Selected Template</label>
|
||||||
<div className="p-4 rounded-lg bg-gray-50 border border-gray-200 font-mono text-sm text-gray-800 leading-relaxed break-words">
|
<div className="p-4 rounded-lg bg-gray-50 border border-gray-200 font-mono text-sm text-gray-800 leading-relaxed break-words">
|
||||||
|
|
@ -139,7 +162,6 @@ export default function Templates() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template ID (if whitelisted) */}
|
|
||||||
{tmpl.templateId && (
|
{tmpl.templateId && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
|
||||||
|
|
@ -149,7 +171,6 @@ export default function Templates() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Variable map */}
|
|
||||||
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
|
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label>
|
||||||
|
|
@ -165,9 +186,8 @@ export default function Templates() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
{tmpl.status === 'pending_whitelisting' && (
|
{!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setWhitelistTarget(tmpl)}
|
onClick={() => setWhitelistTarget(tmpl)}
|
||||||
className="px-4 py-2 rounded-lg bg-orange-text hover:bg-[#c97b45] text-white text-sm font-semibold transition shadow-sm"
|
className="px-4 py-2 rounded-lg bg-orange-text hover:bg-[#c97b45] text-white text-sm font-semibold transition shadow-sm"
|
||||||
|
|
@ -175,7 +195,7 @@ export default function Templates() {
|
||||||
Publish
|
Publish
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{tmpl.status === 'whitelisted' && (
|
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setTestTarget(tmpl)}
|
onClick={() => setTestTarget(tmpl)}
|
||||||
className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm flex items-center gap-2"
|
className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition shadow-sm flex items-center gap-2"
|
||||||
|
|
@ -184,8 +204,8 @@ export default function Templates() {
|
||||||
Test SMS
|
Test SMS
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{tmpl.status === 'pending_whitelisting' && (
|
{tmpl.status === 'pending_whitelisting' && !isBoundProfileMissing && (
|
||||||
<p className="text-xs text-text-muted font-medium">Submit to DLT portal, then enter your Template ID here.</p>
|
<p className="text-xs text-text-muted font-medium">Submit to the DLT portal, then complete publish from here.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -200,6 +220,7 @@ export default function Templates() {
|
||||||
<WhitelistModal
|
<WhitelistModal
|
||||||
businessId={businessId}
|
businessId={businessId}
|
||||||
template={whitelistTarget}
|
template={whitelistTarget}
|
||||||
|
boundProfile={profilesById[whitelistTarget.curlProfileId] || null}
|
||||||
onClose={() => setWhitelistTarget(null)}
|
onClose={() => setWhitelistTarget(null)}
|
||||||
onSuccess={handleWhitelistSuccess}
|
onSuccess={handleWhitelistSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const businessesRoutes = require('./routes/businesses');
|
const businessesRoutes = require('./routes/businesses');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
const CLIENT_DIST_DIR = [
|
||||||
|
path.join(__dirname, 'public'),
|
||||||
|
path.join(__dirname, '..', 'client', 'dist'),
|
||||||
|
].find((dir) => fs.existsSync(path.join(dir, 'index.html')));
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
@ -16,8 +22,28 @@ app.get('/api/health', (req, res) => res.json({ ok: true, timestamp: new Date().
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/businesses', businessesRoutes);
|
app.use('/api/businesses', businessesRoutes);
|
||||||
|
|
||||||
// 404
|
// Serve the built client for same-origin deployment.
|
||||||
app.use('*', (req, res) => res.status(404).json({ error: 'Route not found' }));
|
if (CLIENT_DIST_DIR) {
|
||||||
|
app.use(express.static(CLIENT_DIST_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve JSON 404s for unknown API routes.
|
||||||
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Route not found' }));
|
||||||
|
|
||||||
|
// SPA fallback for non-API browser routes.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(404).json({ error: 'Route not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CLIENT_DIST_DIR) {
|
||||||
|
return res.status(404).json({ error: 'Route not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(path.join(CLIENT_DIST_DIR, 'index.html'), (err) => {
|
||||||
|
if (err) next(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { scrape } = require('../services/firecrawl');
|
const { scrape } = require('../services/firecrawl');
|
||||||
const { parseBrandContext, generateTemplates, processCurl } = require('../services/openai2');
|
const { parseBrandContext, generateTemplates, processCurl, validateCurlFields } = require('../services/openai2');
|
||||||
const { sendViaWorkflow } = require('../services/workflowSender');
|
const { sendViaWorkflow } = require('../services/workflowSender');
|
||||||
const {
|
const {
|
||||||
uploadJSON,
|
uploadJSON,
|
||||||
|
|
@ -40,6 +40,200 @@ async function saveIndex(merchantId, businesses) {
|
||||||
await uploadJSON(indexPath(merchantId), 'index', { businesses });
|
await uploadJSON(indexPath(merchantId), 'index', { businesses });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROVIDER_FIELDS = ['providerName', 'senderId', 'dltEntityId', 'authKey'];
|
||||||
|
|
||||||
|
function createHttpError(status, message, extra = {}) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
Object.assign(err, extra);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRouteError(res, err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
const body = { error: err.message };
|
||||||
|
if (err.code) body.code = err.code;
|
||||||
|
if (err.missingFields) body.missingFields = err.missingFields;
|
||||||
|
if (err.template) body.template = err.template;
|
||||||
|
if (err.details) body.details = err.details;
|
||||||
|
res.status(status).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSenderId(value) {
|
||||||
|
return normalizeText(value).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCurlCommand(rawCurl) {
|
||||||
|
return normalizeText(rawCurl).toLowerCase().startsWith('curl');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSenderId(senderId) {
|
||||||
|
if (!senderId) return null;
|
||||||
|
if (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId)) {
|
||||||
|
return 'Sender ID must be exactly 6 alphabetic characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProvider(provider = {}, fallbackUpdatedAt = null) {
|
||||||
|
const updatedAt = provider.updatedAt || fallbackUpdatedAt || new Date().toISOString();
|
||||||
|
return {
|
||||||
|
providerName: normalizeText(provider.providerName),
|
||||||
|
senderId: normalizeSenderId(provider.senderId),
|
||||||
|
dltEntityId: normalizeText(provider.dltEntityId),
|
||||||
|
authKey: normalizeText(provider.authKey),
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderPatch(input) {
|
||||||
|
if (!input || typeof input !== 'object') return null;
|
||||||
|
|
||||||
|
let hasField = false;
|
||||||
|
const patch = {};
|
||||||
|
for (const field of PROVIDER_FIELDS) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(input, field)) continue;
|
||||||
|
hasField = true;
|
||||||
|
patch[field] = field === 'senderId'
|
||||||
|
? normalizeSenderId(input[field])
|
||||||
|
: normalizeText(input[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasField ? patch : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProviderState(extractedProvider, currentProvider, providerPatch, options = {}) {
|
||||||
|
const { preserveCurrent = true, updatedAt = new Date().toISOString() } = options;
|
||||||
|
let merged = { ...normalizeProvider(extractedProvider, updatedAt), updatedAt };
|
||||||
|
|
||||||
|
if (preserveCurrent && currentProvider) {
|
||||||
|
const normalizedCurrent = normalizeProvider(currentProvider, updatedAt);
|
||||||
|
for (const field of PROVIDER_FIELDS) {
|
||||||
|
if (normalizedCurrent[field]) {
|
||||||
|
merged[field] = normalizedCurrent[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerPatch) {
|
||||||
|
for (const field of PROVIDER_FIELDS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(providerPatch, field)) {
|
||||||
|
merged[field] = providerPatch[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.updatedAt = updatedAt;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateProfile(profile = {}) {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
provider: normalizeProvider(profile.provider, profile.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateProfileData(profileData) {
|
||||||
|
const profiles = Array.isArray(profileData?.profiles)
|
||||||
|
? profileData.profiles.map(hydrateProfile)
|
||||||
|
: [];
|
||||||
|
return { profiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProfileState(bizRoot) {
|
||||||
|
const [rawProfileData, activeRec] = await Promise.all([
|
||||||
|
fetchJSON(bizRoot, 'global_sms_profiles'),
|
||||||
|
fetchJSON(bizRoot, 'active_curl_profile'),
|
||||||
|
]);
|
||||||
|
const profileData = hydrateProfileData(rawProfileData);
|
||||||
|
const activeProfileId = activeRec?.profileId || (profileData.profiles[0]?.id ?? null);
|
||||||
|
const activeProfile = profileData.profiles.find(p => p.id === activeProfileId) || profileData.profiles[0] || null;
|
||||||
|
|
||||||
|
return { profileData, activeProfile, activeProfileId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveProfile(bizRoot) {
|
||||||
|
try {
|
||||||
|
const { activeProfile } = await getProfileState(bizRoot);
|
||||||
|
return activeProfile;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBoundProfile(bizRoot, curlProfileId) {
|
||||||
|
if (!curlProfileId) {
|
||||||
|
throw createHttpError(
|
||||||
|
422,
|
||||||
|
'This template is not bound to a cURL profile. Re-select the template from Events before continuing.',
|
||||||
|
{ code: 'MISSING_BOUND_PROFILE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profileData } = await getProfileState(bizRoot);
|
||||||
|
const boundProfile = profileData.profiles.find(profile => profile.id === curlProfileId);
|
||||||
|
if (!boundProfile) {
|
||||||
|
throw createHttpError(
|
||||||
|
422,
|
||||||
|
'The cURL profile bound to this template no longer exists. Re-select the template from Events before continuing.',
|
||||||
|
{ code: 'BOUND_PROFILE_NOT_FOUND' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateCurlAndExtractProvider(rawCurl) {
|
||||||
|
try {
|
||||||
|
const validation = await validateCurlFields(rawCurl);
|
||||||
|
if (!validation.isValidCurl) {
|
||||||
|
throw createHttpError(422, validation.reason || 'The provided cURL is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = normalizeProvider(validation.provider);
|
||||||
|
const senderIdError = validateSenderId(provider.senderId);
|
||||||
|
if (senderIdError) {
|
||||||
|
throw createHttpError(422, senderIdError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status) throw err;
|
||||||
|
throw createHttpError(502, `cURL validation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfileProvider(profile, providerPatch, rawCurlOverride) {
|
||||||
|
const effectiveCurl = normalizeText(rawCurlOverride !== undefined ? rawCurlOverride : profile.rawCurl);
|
||||||
|
const extractedProvider = await validateCurlAndExtractProvider(effectiveCurl);
|
||||||
|
const preserveCurrent = rawCurlOverride === undefined;
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
profile.provider = mergeProviderState(
|
||||||
|
extractedProvider,
|
||||||
|
profile.provider,
|
||||||
|
providerPatch,
|
||||||
|
{ preserveCurrent, updatedAt }
|
||||||
|
);
|
||||||
|
profile.updatedAt = updatedAt;
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMissingMandatoryProviderFields(provider = {}) {
|
||||||
|
const normalized = normalizeProvider(provider);
|
||||||
|
const missing = [];
|
||||||
|
if (!normalized.providerName) missing.push('providerName');
|
||||||
|
if (!normalized.senderId) missing.push('senderId');
|
||||||
|
if (!normalized.dltEntityId) missing.push('dltEntityId');
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Business CRUD ────────────────────────────────────────────────────────────
|
// ─── Business CRUD ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/businesses
|
// GET /api/businesses
|
||||||
|
|
@ -151,58 +345,54 @@ router.delete('/:businessId', async (req, res) => {
|
||||||
// GET /api/businesses/:businessId/providers
|
// GET /api/businesses/:businessId/providers
|
||||||
router.get('/:businessId/providers', async (req, res) => {
|
router.get('/:businessId/providers', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers');
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
res.json(data || {});
|
const activeProfile = await getActiveProfile(bizRoot);
|
||||||
|
if (!activeProfile) {
|
||||||
|
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(activeProfile.provider || {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/businesses/:businessId/providers
|
// POST /api/businesses/:businessId/providers
|
||||||
// Mandatory fields for publish/send: providerName, senderId, dltEntityId.
|
|
||||||
// Per plan: these are NOT required to save — user can save partial config and is only
|
|
||||||
// blocked when switching a template to Published. senderId format is still validated
|
|
||||||
// if provided, so the stored value is always valid.
|
|
||||||
router.post('/:businessId/providers', async (req, res) => {
|
router.post('/:businessId/providers', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { providerName, senderId, dltEntityId, authKey } = req.body;
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
|
const providerPatch = getProviderPatch(req.body);
|
||||||
// If senderId is provided, it must still meet the format requirement
|
const senderIdError = validateSenderId(providerPatch?.senderId || '');
|
||||||
if (senderId && (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId))) {
|
if (senderIdError) {
|
||||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
return res.status(400).json({ error: senderIdError });
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const { profileData, activeProfile, activeProfileId } = await getProfileState(bizRoot);
|
||||||
providerName: providerName || '',
|
if (!activeProfile || !activeProfileId) {
|
||||||
senderId: senderId ? senderId.toUpperCase() : '',
|
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
|
||||||
dltEntityId: dltEntityId || '',
|
}
|
||||||
authKey: authKey || '',
|
|
||||||
updatedAt: new Date().toISOString(),
|
const profile = profileData.profiles.find(item => item.id === activeProfileId);
|
||||||
};
|
await updateProfileProvider(profile, providerPatch);
|
||||||
await uploadJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers', config);
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||||
res.json(config);
|
|
||||||
|
res.json(profile.provider);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global SMS cURL (Compatibility layer — kept so existing sessions/frontend work) ────────────
|
// ─── Global SMS cURL (Compatibility layer) ───────────────────────────────────
|
||||||
// The new multi-profile system is below. These two routes delegate to the active profile.
|
// These routes delegate to the active/default profile model.
|
||||||
|
|
||||||
// GET /api/businesses/:businessId/global-sms
|
// GET /api/businesses/:businessId/global-sms
|
||||||
// Returns the active cURL profile's rawCurl (or legacy global_sms.json as fallback).
|
|
||||||
router.get('/:businessId/global-sms', async (req, res) => {
|
router.get('/:businessId/global-sms', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
const activeProfile = await getActiveProfile(bizRoot);
|
const activeProfile = await getActiveProfile(bizRoot);
|
||||||
if (activeProfile) {
|
res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {});
|
||||||
return res.json({ rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt });
|
|
||||||
}
|
|
||||||
// Fallback: legacy global_sms.json (present on businesses created before profile system)
|
|
||||||
const data = await fetchJSON(bizRoot, 'global_sms');
|
|
||||||
res.json(data || {});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -211,70 +401,57 @@ router.get('/:businessId/global-sms', async (req, res) => {
|
||||||
router.post('/:businessId/global-sms', async (req, res) => {
|
router.post('/:businessId/global-sms', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rawCurl } = req.body;
|
const { rawCurl } = req.body;
|
||||||
if (!rawCurl || !rawCurl.trim()) {
|
if (!normalizeText(rawCurl)) {
|
||||||
return res.status(400).json({ error: 'rawCurl is required' });
|
return res.status(400).json({ error: 'rawCurl is required' });
|
||||||
}
|
}
|
||||||
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
|
if (!isValidCurlCommand(rawCurl)) {
|
||||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
const { profileData } = await getProfileState(bizRoot);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const normalizedCurl = normalizeText(rawCurl);
|
||||||
|
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
|
||||||
|
|
||||||
// Find or create the default profile
|
// Find or create the default profile
|
||||||
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
|
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
|
||||||
if (defaultProfile) {
|
if (defaultProfile) {
|
||||||
defaultProfile.rawCurl = rawCurl.trim();
|
defaultProfile.rawCurl = normalizedCurl;
|
||||||
|
defaultProfile.provider = extractedProvider;
|
||||||
defaultProfile.updatedAt = now;
|
defaultProfile.updatedAt = now;
|
||||||
} else {
|
} else {
|
||||||
defaultProfile = { id: uuidv4(), name: 'Default', rawCurl: rawCurl.trim(), isDefault: true, createdAt: now, updatedAt: now };
|
defaultProfile = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: 'Default',
|
||||||
|
rawCurl: normalizedCurl,
|
||||||
|
isDefault: true,
|
||||||
|
provider: extractedProvider,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
profileData.profiles.push(defaultProfile);
|
profileData.profiles.push(defaultProfile);
|
||||||
}
|
}
|
||||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now });
|
await uploadJSON(bizRoot, 'active_curl_profile', { profileId: defaultProfile.id, updatedAt: now });
|
||||||
|
|
||||||
res.json({ rawCurl: rawCurl.trim(), updatedAt: now });
|
res.json({ rawCurl: normalizedCurl, updatedAt: now });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── cURL Profile Helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function getActiveProfile(bizRoot) {
|
|
||||||
try {
|
|
||||||
const [profileData, activeRec] = await Promise.all([
|
|
||||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
|
||||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
|
||||||
]);
|
|
||||||
if (!profileData?.profiles?.length) return null;
|
|
||||||
if (activeRec?.profileId) {
|
|
||||||
const found = profileData.profiles.find(p => p.id === activeRec.profileId);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
// Fall back to first profile
|
|
||||||
return profileData.profiles[0];
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── cURL Profiles CRUD ────────────────────────────────────────────────────────
|
// ─── cURL Profiles CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/businesses/:businessId/global-sms/profiles
|
// GET /api/businesses/:businessId/global-sms/profiles
|
||||||
router.get('/:businessId/global-sms/profiles', async (req, res) => {
|
router.get('/:businessId/global-sms/profiles', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
const [profileData, activeRec] = await Promise.all([
|
const { profileData, activeProfileId } = await getProfileState(bizRoot);
|
||||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
const profiles = profileData.profiles || [];
|
||||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
|
||||||
]);
|
|
||||||
const profiles = profileData?.profiles || [];
|
|
||||||
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
|
|
||||||
res.json({ profiles, activeProfileId });
|
res.json({ profiles, activeProfileId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -282,21 +459,31 @@ router.get('/:businessId/global-sms/profiles', async (req, res) => {
|
||||||
router.post('/:businessId/global-sms/profiles', async (req, res) => {
|
router.post('/:businessId/global-sms/profiles', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, rawCurl, setActive } = req.body;
|
const { name, rawCurl, setActive } = req.body;
|
||||||
if (!name || !String(name).trim()) {
|
if (!normalizeText(name)) {
|
||||||
return res.status(400).json({ error: 'name is required' });
|
return res.status(400).json({ error: 'name is required' });
|
||||||
}
|
}
|
||||||
if (!rawCurl || !rawCurl.trim()) {
|
if (!normalizeText(rawCurl)) {
|
||||||
return res.status(400).json({ error: 'rawCurl is required' });
|
return res.status(400).json({ error: 'rawCurl is required' });
|
||||||
}
|
}
|
||||||
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
|
if (!isValidCurlCommand(rawCurl)) {
|
||||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
const { profileData } = await getProfileState(bizRoot);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const normalizedCurl = normalizeText(rawCurl);
|
||||||
|
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
|
||||||
|
|
||||||
const newProfile = { id: uuidv4(), name: String(name).trim(), rawCurl: rawCurl.trim(), isDefault: false, createdAt: now, updatedAt: now };
|
const newProfile = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: normalizeText(name),
|
||||||
|
rawCurl: normalizedCurl,
|
||||||
|
isDefault: false,
|
||||||
|
provider: extractedProvider,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
profileData.profiles.push(newProfile);
|
profileData.profiles.push(newProfile);
|
||||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||||
|
|
||||||
|
|
@ -307,7 +494,7 @@ router.post('/:businessId/global-sms/profiles', async (req, res) => {
|
||||||
|
|
||||||
res.json(newProfile);
|
res.json(newProfile);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -316,24 +503,35 @@ router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) =>
|
||||||
try {
|
try {
|
||||||
const { businessId, profileId } = req.params;
|
const { businessId, profileId } = req.params;
|
||||||
const { name, rawCurl } = req.body;
|
const { name, rawCurl } = req.body;
|
||||||
|
const providerPatch = getProviderPatch(req.body.provider || req.body);
|
||||||
|
|
||||||
if (rawCurl !== undefined && !rawCurl.trim().toLowerCase().startsWith('curl')) {
|
if (name !== undefined && !normalizeText(name)) {
|
||||||
|
return res.status(400).json({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
if (rawCurl !== undefined && !normalizeText(rawCurl)) {
|
||||||
|
return res.status(400).json({ error: 'rawCurl is required' });
|
||||||
|
}
|
||||||
|
if (rawCurl !== undefined && !isValidCurlCommand(rawCurl)) {
|
||||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||||
}
|
}
|
||||||
|
const senderIdError = validateSenderId(providerPatch?.senderId || '');
|
||||||
|
if (senderIdError) {
|
||||||
|
return res.status(400).json({ error: senderIdError });
|
||||||
|
}
|
||||||
|
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
const { profileData } = await getProfileState(bizRoot);
|
||||||
const profile = profileData.profiles.find(p => p.id === profileId);
|
const profile = profileData.profiles.find(p => p.id === profileId);
|
||||||
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
||||||
|
|
||||||
if (name !== undefined) profile.name = String(name).trim();
|
if (name !== undefined) profile.name = normalizeText(name);
|
||||||
if (rawCurl !== undefined) profile.rawCurl = rawCurl.trim();
|
if (rawCurl !== undefined) profile.rawCurl = normalizeText(rawCurl);
|
||||||
profile.updatedAt = new Date().toISOString();
|
await updateProfileProvider(profile, providerPatch, rawCurl !== undefined ? profile.rawCurl : undefined);
|
||||||
|
|
||||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||||
res.json(profile);
|
res.json(profile);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -342,7 +540,7 @@ router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) =>
|
||||||
try {
|
try {
|
||||||
const { businessId, profileId } = req.params;
|
const { businessId, profileId } = req.params;
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
const { profileData } = await getProfileState(bizRoot);
|
||||||
|
|
||||||
const idx = profileData.profiles.findIndex(p => p.id === profileId);
|
const idx = profileData.profiles.findIndex(p => p.id === profileId);
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Profile not found' });
|
if (idx === -1) return res.status(404).json({ error: 'Profile not found' });
|
||||||
|
|
@ -361,7 +559,7 @@ router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) =>
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -370,14 +568,14 @@ router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req,
|
||||||
try {
|
try {
|
||||||
const { businessId, profileId } = req.params;
|
const { businessId, profileId } = req.params;
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||||
const profileData = await fetchJSON(bizRoot, 'global_sms_profiles') || { profiles: [] };
|
const { profileData } = await getProfileState(bizRoot);
|
||||||
const profile = profileData.profiles.find(p => p.id === profileId);
|
const profile = profileData.profiles.find(p => p.id === profileId);
|
||||||
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
||||||
|
|
||||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() });
|
await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() });
|
||||||
res.json({ activeProfileId: profileId });
|
res.json({ activeProfileId: profileId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -385,16 +583,10 @@ router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req,
|
||||||
router.get('/:businessId/global-sms/active', async (req, res) => {
|
router.get('/:businessId/global-sms/active', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||||
const [profileData, activeRec] = await Promise.all([
|
const { activeProfile, activeProfileId } = await getProfileState(bizRoot);
|
||||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
|
||||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
|
||||||
]);
|
|
||||||
const profiles = profileData?.profiles || [];
|
|
||||||
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
|
|
||||||
const activeProfile = profiles.find(p => p.id === activeProfileId) || profiles[0] || null;
|
|
||||||
res.json({ activeProfile, activeProfileId });
|
res.json({ activeProfile, activeProfileId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -458,19 +650,11 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => {
|
||||||
const { businessId, slug } = req.params;
|
const { businessId, slug } = req.params;
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||||
|
|
||||||
// Requirements check
|
const context = await fetchJSON(bizRoot, 'context');
|
||||||
const [context, providers] = await Promise.all([
|
|
||||||
fetchJSON(bizRoot, 'context'),
|
|
||||||
fetchJSON(bizRoot, 'providers'),
|
|
||||||
]);
|
|
||||||
if (!context) return res.status(400).json({ error: 'Business context not found.' });
|
if (!context) return res.status(400).json({ error: 'Business context not found.' });
|
||||||
if (!providers?.senderId) return res.status(400).json({ error: 'Provider details must be configured before generating templates.' });
|
|
||||||
|
|
||||||
// Require an active cURL profile (new system), falling back to legacy global_sms.json
|
|
||||||
const activeProfile = await getActiveProfile(bizRoot);
|
const activeProfile = await getActiveProfile(bizRoot);
|
||||||
const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
|
if (!activeProfile?.rawCurl) {
|
||||||
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
|
|
||||||
if (!activeCurl) {
|
|
||||||
return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' });
|
return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -487,7 +671,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => {
|
||||||
selectedTemplate: null,
|
selectedTemplate: null,
|
||||||
status: 'generated',
|
status: 'generated',
|
||||||
templateId: '',
|
templateId: '',
|
||||||
curlProfileId: activeProfile?.id || null,
|
curlProfileId: activeProfile.id,
|
||||||
rawCurl: '',
|
rawCurl: '',
|
||||||
processedCurl: '',
|
processedCurl: '',
|
||||||
variableMap: {},
|
variableMap: {},
|
||||||
|
|
@ -557,11 +741,9 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
||||||
const tmpl = await fetchJSON(folder, slug);
|
const tmpl = await fetchJSON(folder, slug);
|
||||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
|
||||||
// Resolve active cURL (new profile system first, legacy fallback)
|
|
||||||
const activeProfile = await getActiveProfile(bizRoot);
|
const activeProfile = await getActiveProfile(bizRoot);
|
||||||
const legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
|
const activeCurl = activeProfile?.rawCurl || null;
|
||||||
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
|
if (!activeProfile?.id || !activeCurl) {
|
||||||
if (!activeCurl) {
|
|
||||||
return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' });
|
return res.status(400).json({ error: 'A cURL profile must be configured and active before selecting a template' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,7 +753,7 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
||||||
tmpl.selectedTemplate = selectedVariant;
|
tmpl.selectedTemplate = selectedVariant;
|
||||||
tmpl.generatedVariants = []; // discard non-selected variants
|
tmpl.generatedVariants = []; // discard non-selected variants
|
||||||
tmpl.status = 'pending_whitelisting';
|
tmpl.status = 'pending_whitelisting';
|
||||||
tmpl.curlProfileId = activeProfile?.id || null; // snapshot which profile was used
|
tmpl.curlProfileId = activeProfile.id; // snapshot which profile was used
|
||||||
tmpl.rawCurl = activeCurl;
|
tmpl.rawCurl = activeCurl;
|
||||||
tmpl.processedCurl = processedCurl;
|
tmpl.processedCurl = processedCurl;
|
||||||
tmpl.variableMap = variableMap;
|
tmpl.variableMap = variableMap;
|
||||||
|
|
@ -618,12 +800,12 @@ router.post('/:businessId/templates/:slug/whitelist', async (req, res) => {
|
||||||
router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { businessId, slug } = req.params;
|
const { businessId, slug } = req.params;
|
||||||
const { templateId, toNumber, providerName, senderId, dltEntityId, authKey } = req.body;
|
const { templateId, toNumber } = req.body;
|
||||||
|
|
||||||
if (!templateId || !String(templateId).trim()) {
|
if (!normalizeText(templateId)) {
|
||||||
return res.status(400).json({ error: 'templateId is required' });
|
return res.status(400).json({ error: 'templateId is required' });
|
||||||
}
|
}
|
||||||
if (!toNumber || !String(toNumber).trim()) {
|
if (!normalizeText(toNumber)) {
|
||||||
return res.status(400).json({ error: 'toNumber is required' });
|
return res.status(400).json({ error: 'toNumber is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -637,38 +819,23 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' });
|
return res.status(400).json({ error: 'Template must be in pending_whitelisting status to publish' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge any submitted provider fields over stored values
|
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
|
||||||
const storedProviders = await fetchJSON(bizRoot, 'providers') || {};
|
const missingFields = getMissingMandatoryProviderFields(boundProfile.provider);
|
||||||
const mergedProviders = {
|
if (missingFields.length > 0) {
|
||||||
providerName: providerName || storedProviders.providerName || '',
|
|
||||||
senderId: senderId ? senderId.toUpperCase() : (storedProviders.senderId || ''),
|
|
||||||
dltEntityId: dltEntityId || storedProviders.dltEntityId || '',
|
|
||||||
authKey: authKey || storedProviders.authKey || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate mandatory fields
|
|
||||||
const missing = [];
|
|
||||||
if (!mergedProviders.providerName) missing.push('providerName');
|
|
||||||
if (!mergedProviders.senderId) missing.push('senderId');
|
|
||||||
if (!mergedProviders.dltEntityId) missing.push('dltEntityId');
|
|
||||||
if (missing.length > 0) {
|
|
||||||
return res.status(422).json({
|
return res.status(422).json({
|
||||||
error: 'Missing mandatory provider fields',
|
error: 'Missing mandatory provider fields',
|
||||||
missingFields: missing,
|
missingFields,
|
||||||
|
code: 'MISSING_BOUND_PROFILE_FIELDS',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate senderId format
|
const senderIdError = validateSenderId(boundProfile.provider.senderId);
|
||||||
if (mergedProviders.senderId.length !== 6 || !/^[A-Za-z]+$/.test(mergedProviders.senderId)) {
|
if (senderIdError) {
|
||||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
return res.status(400).json({ error: senderIdError });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist any updated provider data
|
|
||||||
const updatedProviders = { ...mergedProviders, updatedAt: new Date().toISOString() };
|
|
||||||
await uploadJSON(bizRoot, 'providers', updatedProviders);
|
|
||||||
|
|
||||||
// Mark template as whitelisted
|
// Mark template as whitelisted
|
||||||
tmpl.templateId = String(templateId).trim();
|
tmpl.templateId = normalizeText(templateId);
|
||||||
tmpl.status = 'whitelisted';
|
tmpl.status = 'whitelisted';
|
||||||
tmpl.updatedAt = new Date().toISOString();
|
tmpl.updatedAt = new Date().toISOString();
|
||||||
await uploadJSON(folder, slug, tmpl);
|
await uploadJSON(folder, slug, tmpl);
|
||||||
|
|
@ -677,8 +844,8 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
||||||
let sendResult;
|
let sendResult;
|
||||||
try {
|
try {
|
||||||
sendResult = await sendViaWorkflow({
|
sendResult = await sendViaWorkflow({
|
||||||
senderId: mergedProviders.senderId,
|
senderId: boundProfile.provider.senderId,
|
||||||
toNumber: String(toNumber).trim(),
|
toNumber: normalizeText(toNumber),
|
||||||
content: tmpl.selectedTemplate || '',
|
content: tmpl.selectedTemplate || '',
|
||||||
});
|
});
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
|
|
@ -697,7 +864,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Publish error:', err.message);
|
console.error('Publish error:', err.message);
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -708,7 +875,7 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { businessId, slug } = req.params;
|
const { businessId, slug } = req.params;
|
||||||
const { toNumber } = req.body;
|
const { toNumber } = req.body;
|
||||||
if (!toNumber) return res.status(400).json({ error: 'toNumber is required' });
|
if (!normalizeText(toNumber)) return res.status(400).json({ error: 'toNumber is required' });
|
||||||
|
|
||||||
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
const bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||||
const folder = `${bizRoot}/templates`;
|
const folder = `${bizRoot}/templates`;
|
||||||
|
|
@ -725,16 +892,25 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => {
|
||||||
// Per plan: Published (whitelisted) templates use the workflow sender module,
|
// Per plan: Published (whitelisted) templates use the workflow sender module,
|
||||||
// not the legacy cURL execution path. The cURL code below remains but is not
|
// not the legacy cURL execution path. The cURL code below remains but is not
|
||||||
// reached for whitelisted templates in this branch.
|
// reached for whitelisted templates in this branch.
|
||||||
const providers = await fetchJSON(bizRoot, 'providers') || {};
|
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
|
||||||
if (!providers.senderId) {
|
if (!boundProfile.provider?.senderId) {
|
||||||
return res.status(422).json({ error: 'Provider senderId is required for sending' });
|
return res.status(422).json({
|
||||||
|
error: 'Provider senderId is required for sending',
|
||||||
|
missingFields: ['senderId'],
|
||||||
|
code: 'MISSING_BOUND_PROFILE_FIELDS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderIdError = validateSenderId(boundProfile.provider.senderId);
|
||||||
|
if (senderIdError) {
|
||||||
|
return res.status(400).json({ error: senderIdError });
|
||||||
}
|
}
|
||||||
|
|
||||||
let smsResult;
|
let smsResult;
|
||||||
try {
|
try {
|
||||||
smsResult = await sendViaWorkflow({
|
smsResult = await sendViaWorkflow({
|
||||||
senderId: providers.senderId,
|
senderId: boundProfile.provider.senderId,
|
||||||
toNumber: String(toNumber).trim(),
|
toNumber: normalizeText(toNumber),
|
||||||
content: tmpl.selectedTemplate || '',
|
content: tmpl.selectedTemplate || '',
|
||||||
});
|
});
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
|
|
@ -762,7 +938,7 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => {
|
||||||
res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data });
|
res.json({ success: true, statusCode: legacyResult.status, response: legacyResult.data });
|
||||||
*/
|
*/
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
sendRouteError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ const axios = require('axios');
|
||||||
const WORKFLOW_URL_SCRAPE = process.env.WORKFLOW_URL_SCRAPE;
|
const WORKFLOW_URL_SCRAPE = process.env.WORKFLOW_URL_SCRAPE;
|
||||||
const WORKFLOW_URL_TEMPLATE = process.env.WORKFLOW_URL_TEMPLATE;
|
const WORKFLOW_URL_TEMPLATE = process.env.WORKFLOW_URL_TEMPLATE;
|
||||||
const WORKFLOW_URL_CHECK_CURL = process.env.WORKFLOW_URL_CHECK_CURL;
|
const WORKFLOW_URL_CHECK_CURL = process.env.WORKFLOW_URL_CHECK_CURL;
|
||||||
|
const WORKFLOW_VALIDATE_FIELDS = process.env.WORKFLOW_VALIDATE_FIELDS;
|
||||||
|
|
||||||
if (!WORKFLOW_URL_SCRAPE) throw new Error('Missing WORKFLOW_URL_SCRAPE environment variable');
|
if (!WORKFLOW_URL_SCRAPE) throw new Error('Missing WORKFLOW_URL_SCRAPE environment variable');
|
||||||
if (!WORKFLOW_URL_TEMPLATE) throw new Error('Missing WORKFLOW_URL_TEMPLATE environment variable');
|
if (!WORKFLOW_URL_TEMPLATE) throw new Error('Missing WORKFLOW_URL_TEMPLATE environment variable');
|
||||||
if (!WORKFLOW_URL_CHECK_CURL) throw new Error('Missing WORKFLOW_URL_CHECK_CURL environment variable');
|
if (!WORKFLOW_URL_CHECK_CURL) throw new Error('Missing WORKFLOW_URL_CHECK_CURL environment variable');
|
||||||
|
if (!WORKFLOW_VALIDATE_FIELDS) throw new Error('Missing WORKFLOW_VALIDATE_FIELDS environment variable');
|
||||||
|
|
||||||
const TRAI_RULES_TEXT = '1) Max 160 chars. 2) Dynamic vars use {#var#}. 3) Transactional: no promo/URLs unless required. 4) Sender ID DLT-compliant. 5) Allowed punctuation only. 6) Must match event type. 7) Avoid URLs unless explicitly needed. 8) Start with event/order context.';
|
const TRAI_RULES_TEXT = '1) Max 160 chars. 2) Dynamic vars use {#var#}. 3) Transactional: no promo/URLs unless required. 4) Sender ID DLT-compliant. 5) Allowed punctuation only. 6) Must match event type. 7) Avoid URLs unless explicitly needed. 8) Start with event/order context.';
|
||||||
|
|
||||||
|
|
@ -130,4 +132,25 @@ async function processCurl(rawCurl, approvedTemplate, eventSlug) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { parseBrandContext, generateTemplates, processCurl };
|
async function validateCurlFields(rawCurl) {
|
||||||
|
const payload = {
|
||||||
|
curl_b64: Buffer.from(String(rawCurl || ''), 'utf8').toString('base64'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await postWorkflow(WORKFLOW_VALIDATE_FIELDS, payload);
|
||||||
|
const output = typeof data === 'string' ? parseJsonField(data, {}) : (data || {});
|
||||||
|
const isValidCurl = output.is_valid_curl === true || String(output.is_valid_curl).toLowerCase() === 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValidCurl,
|
||||||
|
provider: {
|
||||||
|
providerName: String(output.provider_name || '').trim(),
|
||||||
|
senderId: String(output.dlt_sender_id || '').trim().toUpperCase(),
|
||||||
|
dltEntityId: String(output.dlt_entity_id || '').trim(),
|
||||||
|
authKey: String(output.api_auth_key || '').trim(),
|
||||||
|
},
|
||||||
|
reason: String(output.reason || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseBrandContext, generateTemplates, processCurl, validateCurlFields };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user