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
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
|
@ -9,6 +18,7 @@ COPY server/index.js ./
|
|||
COPY server/config ./config
|
||||
COPY server/routes ./routes
|
||||
COPY server/services ./services
|
||||
COPY --from=client-build /client/dist ./public
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "index.js"]
|
||||
|
|
|
|||
|
|
@ -9,19 +9,21 @@ import Templates from './pages/Templates';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
function SubLayout({ children }) {
|
||||
const { activeBusinessId } = useBusiness();
|
||||
const { activeBusinessId, hasGlobalSms } = useBusiness();
|
||||
return (
|
||||
<div className="flex min-h-screen bg-page-bg">
|
||||
<Sidebar />
|
||||
<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">
|
||||
<Link
|
||||
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"
|
||||
title="Settings"
|
||||
>
|
||||
<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>
|
||||
{hasGlobalSms && (
|
||||
<Link
|
||||
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"
|
||||
title="Settings"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</header>
|
||||
<div className="flex-1 p-8 overflow-auto">
|
||||
{children}
|
||||
|
|
@ -31,8 +33,8 @@ function SubLayout({ children }) {
|
|||
);
|
||||
}
|
||||
|
||||
// Guard: redirect to / if no active business in session
|
||||
// Also enforce cURL-first: redirect to global-sms if no cURL is saved yet.
|
||||
// Guard: redirect to / if no active business in session.
|
||||
// Also enforce cURL-first: only the cURL profile route is available until an active profile exists.
|
||||
function BusinessGuard({ children, isGlobalSmsRoute }) {
|
||||
const { activeBusinessId, loading, hasGlobalSms } = useBusiness();
|
||||
const location = useLocation();
|
||||
|
|
@ -49,10 +51,7 @@ function BusinessGuard({ children, isGlobalSmsRoute }) {
|
|||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (!hasGlobalSms && !isGlobalSmsRoute && !location.pathname.endsWith('/settings')) {
|
||||
// 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.
|
||||
if (!hasGlobalSms && !isGlobalSmsRoute) {
|
||||
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';
|
||||
|
||||
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 [toNumber, setToNumber] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [providers, setProviders] = useState(null);
|
||||
const [form, setForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
||||
const [loadingProviders, setLoadingProviders] = useState(true);
|
||||
const [step, setStep] = useState('provider');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProviders() {
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/providers`);
|
||||
setProviders(res.data || {});
|
||||
setForm({
|
||||
providerName: res.data?.providerName || '',
|
||||
senderId: res.data?.senderId || '',
|
||||
dltEntityId: res.data?.dltEntityId || ''
|
||||
});
|
||||
} catch {
|
||||
setProviders({});
|
||||
} finally {
|
||||
setLoadingProviders(false);
|
||||
}
|
||||
}
|
||||
fetchProviders();
|
||||
}, [businessId]);
|
||||
setProfile(boundProfile);
|
||||
setProviderForm({
|
||||
providerName: boundProfile?.provider?.providerName || '',
|
||||
senderId: boundProfile?.provider?.senderId || '',
|
||||
dltEntityId: boundProfile?.provider?.dltEntityId || '',
|
||||
});
|
||||
}, [boundProfile]);
|
||||
|
||||
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();
|
||||
if (!templateId.trim() || !toNumber.trim()) return;
|
||||
setSaving(true);
|
||||
|
||||
setPublishing(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/templates/${template.eventSlug}/publish`, {
|
||||
templateId: templateId.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) {
|
||||
if (err.response?.data?.missingFields) {
|
||||
if (err.response?.data?.missingFields?.length) {
|
||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
||||
setStep('provider');
|
||||
} else {
|
||||
setError(err.response?.data?.error || 'Failed to publish template');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const missingName = !providers?.providerName;
|
||||
const missingSender = !providers?.senderId;
|
||||
const missingDlt = !providers?.dltEntityId;
|
||||
const hasMissingProviders = missingName || missingSender || missingDlt;
|
||||
const isProfileMissing = !profile?.id;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10">
|
||||
|
|
@ -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">
|
||||
<span className="text-xl">✅</span>
|
||||
</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">
|
||||
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 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, ' ')}
|
||||
</p>
|
||||
{profile && (
|
||||
<p className="text-xs text-text-muted text-center mb-6 uppercase tracking-wide font-semibold">
|
||||
Bound Profile: {profile.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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">
|
||||
|
|
@ -80,12 +132,77 @@ export default function WhitelistModal({ businessId, template, onClose, onSucces
|
|||
</div>
|
||||
)}
|
||||
|
||||
{loadingProviders ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<span className="w-6 h-6 border-2 border-spinner-track border-t-primary-blue rounded-full animate-spin" />
|
||||
</div>
|
||||
{step === 'provider' ? (
|
||||
<form onSubmit={handleProviderSubmit} className="space-y-4">
|
||||
{missingFields.includes('providerName') && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label>
|
||||
<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"
|
||||
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>
|
||||
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
|
|
@ -15,26 +15,25 @@ export default function Events() {
|
|||
const [error, setError] = useState('');
|
||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||
|
||||
async function loadEvents() {
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
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}/providers`).catch(() => ({ data: {} })),
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms`).catch(() => ({ data: {} })),
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
|
||||
]);
|
||||
setEvents(eventsRes.data.events || []);
|
||||
const hasProviders = !!providersRes.data?.senderId;
|
||||
const hasGlobalSms = !!globalSmsRes.data?.rawCurl;
|
||||
setReadyToGenerate(hasProviders && hasGlobalSms);
|
||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||
} catch {
|
||||
setError('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [businessId]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [businessId]);
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
async function handleAddEvent(e) {
|
||||
e.preventDefault();
|
||||
|
|
@ -64,7 +63,7 @@ export default function Events() {
|
|||
|
||||
async function handleGenerate(slug) {
|
||||
if (!readyToGenerate) {
|
||||
setError('Configure Provider and Global SMS cURL before generating templates.');
|
||||
setError('Configure and activate a cURL profile before generating templates.');
|
||||
return;
|
||||
}
|
||||
setGenState(s => ({ ...s, [slug]: 'loading' }));
|
||||
|
|
@ -122,7 +121,7 @@ export default function Events() {
|
|||
{!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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
|
@ -20,25 +20,23 @@ export default function GlobalSms() {
|
|||
const [formCurl, setFormCurl] = useState('');
|
||||
const [formSetActive, setFormSetActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [businessId]);
|
||||
|
||||
async function loadProfiles() {
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
||||
setProfiles(res.data.profiles || []);
|
||||
setActiveProfileId(res.data.activeProfileId);
|
||||
if (res.data.activeProfileId) {
|
||||
setHasGlobalSms(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setHasGlobalSms(!!res.data.activeProfileId);
|
||||
} catch {
|
||||
setError('Failed to load cURL profiles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [businessId, setHasGlobalSms]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfiles();
|
||||
}, [loadProfiles]);
|
||||
|
||||
function handleAddClick() {
|
||||
setEditingId(null);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ export default function Providers() {
|
|||
const { businessId } = useParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeProfile, setActiveProfile] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
providerName: 'MSG91',
|
||||
providerName: '',
|
||||
senderId: '',
|
||||
dltEntityId: '',
|
||||
authKey: '',
|
||||
|
|
@ -18,17 +19,20 @@ export default function Providers() {
|
|||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/providers`);
|
||||
if (res.data && res.data.providerName) {
|
||||
setForm({
|
||||
providerName: res.data.providerName || 'MSG91',
|
||||
senderId: res.data.senderId || '',
|
||||
dltEntityId: res.data.dltEntityId || '',
|
||||
authKey: res.data.authKey || '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// no providers yet — keep defaults
|
||||
const [activeRes, providerRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms/active`),
|
||||
apiClient.get(`/api/businesses/${businessId}/providers`),
|
||||
]);
|
||||
|
||||
setActiveProfile(activeRes.data?.activeProfile || null);
|
||||
setForm({
|
||||
providerName: providerRes.data?.providerName || '',
|
||||
senderId: providerRes.data?.senderId || '',
|
||||
dltEntityId: providerRes.data?.dltEntityId || '',
|
||||
authKey: providerRes.data?.authKey || '',
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load provider configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -51,7 +55,13 @@ export default function Providers() {
|
|||
return;
|
||||
}
|
||||
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.');
|
||||
} catch (err) {
|
||||
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="pb-5 mb-6 border-b border-gray-200">
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
import WhitelistModal from '../components/WhitelistModal';
|
||||
|
|
@ -13,33 +13,41 @@ const STATUS_CONFIG = {
|
|||
export default function Templates() {
|
||||
const { businessId } = useParams();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [profilesById, setProfilesById] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
||||
const [testTarget, setTestTarget] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
||||
|
||||
async function loadTemplates() {
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await apiClient.get(`/api/businesses/${businessId}/templates`);
|
||||
// Show all templates that have a selected template (status != generated or status exists)
|
||||
const all = (res.data.templates || []).filter(t => t.selectedTemplate);
|
||||
const [templatesRes, profilesRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}/templates`),
|
||||
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);
|
||||
setProfilesById(profileMap);
|
||||
} catch {
|
||||
setError('Failed to load templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [businessId]);
|
||||
|
||||
useEffect(() => { loadTemplates(); }, [businessId]);
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
function handleWhitelistSuccess(slug, templateId) {
|
||||
setTemplates(ts => ts.map(t =>
|
||||
t.eventSlug === slug ? { ...t, status: 'whitelisted', templateId } : t
|
||||
));
|
||||
async function handleWhitelistSuccess() {
|
||||
setWhitelistTarget(null);
|
||||
await loadTemplates();
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -52,7 +60,6 @@ export default function Templates() {
|
|||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="pb-5 mb-6 border-b border-gray-200">
|
||||
<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>
|
||||
|
|
@ -65,7 +72,6 @@ export default function Templates() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-4 mb-6 border-b border-border-main">
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
|
|
@ -103,96 +109,110 @@ export default function Templates() {
|
|||
const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs;
|
||||
|
||||
if (visibleTemplates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-surface-white border border-border-dashed rounded-xl">
|
||||
<p className="text-text-muted text-sm font-medium">No templates in {activeTab === 'published' ? 'Published' : 'Pending'}.</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="text-center py-12 bg-surface-white border border-border-dashed rounded-xl">
|
||||
<p className="text-text-muted text-sm font-medium">No templates in {activeTab === 'published' ? 'Published' : 'Pending'}.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{visibleTemplates.map(tmpl => {
|
||||
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 (
|
||||
<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>
|
||||
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">
|
||||
{tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold border ${statusCfg.bg} ${statusCfg.text} ${statusCfg.border}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">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">
|
||||
{tmpl.selectedTemplate}
|
||||
return (
|
||||
<div key={tmpl.eventSlug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">
|
||||
{tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold border ${statusCfg.bg} ${statusCfg.text} ${statusCfg.border}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Template ID (if whitelisted) */}
|
||||
{tmpl.templateId && (
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
|
||||
<p className="font-mono text-sm text-indigo-700 bg-indigo-50 border border-indigo-100 px-3 py-2 rounded-lg inline-block">
|
||||
{tmpl.templateId}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Variable map */}
|
||||
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(tmpl.variableMap).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
|
||||
<span className="font-mono text-indigo-700 font-bold">{key}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="font-medium text-gray-700">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
<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">
|
||||
{tmpl.selectedTemplate}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{tmpl.status === 'pending_whitelisting' && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
{tmpl.templateId && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
|
||||
<p className="font-mono text-sm text-indigo-700 bg-indigo-50 border border-indigo-100 px-3 py-2 rounded-lg inline-block">
|
||||
{tmpl.templateId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{tmpl.status === 'whitelisted' && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
Test SMS
|
||||
</button>
|
||||
)}
|
||||
{tmpl.status === 'pending_whitelisting' && (
|
||||
<p className="text-xs text-text-muted font-medium">Submit to DLT portal, then enter your Template ID here.</p>
|
||||
|
||||
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(tmpl.variableMap).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
|
||||
<span className="font-mono text-indigo-700 font-bold">{key}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="font-medium text-gray-700">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
Test SMS
|
||||
</button>
|
||||
)}
|
||||
{tmpl.status === 'pending_whitelisting' && !isBoundProfileMissing && (
|
||||
<p className="text-xs text-text-muted font-medium">Submit to the DLT portal, then complete publish from here.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
|
@ -200,6 +220,7 @@ export default function Templates() {
|
|||
<WhitelistModal
|
||||
businessId={businessId}
|
||||
template={whitelistTarget}
|
||||
boundProfile={profilesById[whitelistTarget.curlProfileId] || null}
|
||||
onClose={() => setWhitelistTarget(null)}
|
||||
onSuccess={handleWhitelistSuccess}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const businessesRoutes = require('./routes/businesses');
|
||||
|
||||
const app = express();
|
||||
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(express.json({ limit: '10mb' }));
|
||||
|
|
@ -16,8 +22,28 @@ app.get('/api/health', (req, res) => res.json({ ok: true, timestamp: new Date().
|
|||
// Routes
|
||||
app.use('/api/businesses', businessesRoutes);
|
||||
|
||||
// 404
|
||||
app.use('*', (req, res) => res.status(404).json({ error: 'Route not found' }));
|
||||
// Serve the built client for same-origin deployment.
|
||||
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
|
||||
app.use((err, req, res, next) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
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 {
|
||||
uploadJSON,
|
||||
|
|
@ -40,6 +40,200 @@ async function saveIndex(merchantId, 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses
|
||||
|
|
@ -151,58 +345,54 @@ router.delete('/:businessId', async (req, res) => {
|
|||
// GET /api/businesses/:businessId/providers
|
||||
router.get('/:businessId/providers', async (req, res) => {
|
||||
try {
|
||||
const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers');
|
||||
res.json(data || {});
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
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) {
|
||||
res.status(500).json({ error: err.message });
|
||||
sendRouteError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
try {
|
||||
const { providerName, senderId, dltEntityId, authKey } = req.body;
|
||||
|
||||
// If senderId is provided, it must still meet the format requirement
|
||||
if (senderId && (senderId.length !== 6 || !/^[A-Za-z]+$/.test(senderId))) {
|
||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const providerPatch = getProviderPatch(req.body);
|
||||
const senderIdError = validateSenderId(providerPatch?.senderId || '');
|
||||
if (senderIdError) {
|
||||
return res.status(400).json({ error: senderIdError });
|
||||
}
|
||||
|
||||
const config = {
|
||||
providerName: providerName || '',
|
||||
senderId: senderId ? senderId.toUpperCase() : '',
|
||||
dltEntityId: dltEntityId || '',
|
||||
authKey: authKey || '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await uploadJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'providers', config);
|
||||
res.json(config);
|
||||
const { profileData, activeProfile, activeProfileId } = await getProfileState(bizRoot);
|
||||
if (!activeProfile || !activeProfileId) {
|
||||
return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' });
|
||||
}
|
||||
|
||||
const profile = profileData.profiles.find(item => item.id === activeProfileId);
|
||||
await updateProfileProvider(profile, providerPatch);
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
|
||||
res.json(profile.provider);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
sendRouteError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Global SMS cURL (Compatibility layer — kept so existing sessions/frontend work) ────────────
|
||||
// The new multi-profile system is below. These two routes delegate to the active profile.
|
||||
// ─── Global SMS cURL (Compatibility layer) ───────────────────────────────────
|
||||
// These routes delegate to the active/default profile model.
|
||||
|
||||
// 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) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const activeProfile = await getActiveProfile(bizRoot);
|
||||
if (activeProfile) {
|
||||
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 || {});
|
||||
res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {});
|
||||
} 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) => {
|
||||
try {
|
||||
const { rawCurl } = req.body;
|
||||
if (!rawCurl || !rawCurl.trim()) {
|
||||
if (!normalizeText(rawCurl)) {
|
||||
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' });
|
||||
}
|
||||
|
||||
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 normalizedCurl = normalizeText(rawCurl);
|
||||
const extractedProvider = await validateCurlAndExtractProvider(normalizedCurl);
|
||||
|
||||
// Find or create the default profile
|
||||
let defaultProfile = profileData.profiles.find(p => p.name === 'Default');
|
||||
if (defaultProfile) {
|
||||
defaultProfile.rawCurl = rawCurl.trim();
|
||||
defaultProfile.rawCurl = normalizedCurl;
|
||||
defaultProfile.provider = extractedProvider;
|
||||
defaultProfile.updatedAt = now;
|
||||
} 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);
|
||||
}
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
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) {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/businesses/:businessId/global-sms/profiles
|
||||
router.get('/:businessId/global-sms/profiles', async (req, res) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const [profileData, activeRec] = await Promise.all([
|
||||
fetchJSON(bizRoot, 'global_sms_profiles'),
|
||||
fetchJSON(bizRoot, 'active_curl_profile'),
|
||||
]);
|
||||
const profiles = profileData?.profiles || [];
|
||||
const activeProfileId = activeRec?.profileId || (profiles[0]?.id ?? null);
|
||||
const { profileData, activeProfileId } = await getProfileState(bizRoot);
|
||||
const profiles = profileData.profiles || [];
|
||||
res.json({ profiles, activeProfileId });
|
||||
} 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) => {
|
||||
try {
|
||||
const { name, rawCurl, setActive } = req.body;
|
||||
if (!name || !String(name).trim()) {
|
||||
if (!normalizeText(name)) {
|
||||
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' });
|
||||
}
|
||||
if (!rawCurl.trim().toLowerCase().startsWith('curl')) {
|
||||
if (!isValidCurlCommand(rawCurl)) {
|
||||
return res.status(400).json({ error: 'rawCurl must be a valid cURL command' });
|
||||
}
|
||||
|
||||
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 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);
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
|
||||
|
|
@ -307,7 +494,7 @@ router.post('/:businessId/global-sms/profiles', async (req, res) => {
|
|||
|
||||
res.json(newProfile);
|
||||
} 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 {
|
||||
const { businessId, profileId } = req.params;
|
||||
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' });
|
||||
}
|
||||
const senderIdError = validateSenderId(providerPatch?.senderId || '');
|
||||
if (senderIdError) {
|
||||
return res.status(400).json({ error: senderIdError });
|
||||
}
|
||||
|
||||
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);
|
||||
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
||||
|
||||
if (name !== undefined) profile.name = String(name).trim();
|
||||
if (rawCurl !== undefined) profile.rawCurl = rawCurl.trim();
|
||||
profile.updatedAt = new Date().toISOString();
|
||||
if (name !== undefined) profile.name = normalizeText(name);
|
||||
if (rawCurl !== undefined) profile.rawCurl = normalizeText(rawCurl);
|
||||
await updateProfileProvider(profile, providerPatch, rawCurl !== undefined ? profile.rawCurl : undefined);
|
||||
|
||||
await uploadJSON(bizRoot, 'global_sms_profiles', profileData);
|
||||
res.json(profile);
|
||||
} 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 {
|
||||
const { businessId, profileId } = req.params;
|
||||
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);
|
||||
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 });
|
||||
} 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 {
|
||||
const { businessId, profileId } = req.params;
|
||||
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);
|
||||
if (!profile) return res.status(404).json({ error: 'Profile not found' });
|
||||
|
||||
await uploadJSON(bizRoot, 'active_curl_profile', { profileId, updatedAt: new Date().toISOString() });
|
||||
res.json({ activeProfileId: profileId });
|
||||
} 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) => {
|
||||
try {
|
||||
const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId);
|
||||
const [profileData, activeRec] = await Promise.all([
|
||||
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;
|
||||
const { activeProfile, activeProfileId } = await getProfileState(bizRoot);
|
||||
res.json({ activeProfile, activeProfileId });
|
||||
} 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 bizRoot = businessRoot(MERCHANT_ID(), businessId);
|
||||
|
||||
// Requirements check
|
||||
const [context, providers] = await Promise.all([
|
||||
fetchJSON(bizRoot, 'context'),
|
||||
fetchJSON(bizRoot, 'providers'),
|
||||
]);
|
||||
const context = await fetchJSON(bizRoot, 'context');
|
||||
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 legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
|
||||
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
|
||||
if (!activeCurl) {
|
||||
if (!activeProfile?.rawCurl) {
|
||||
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,
|
||||
status: 'generated',
|
||||
templateId: '',
|
||||
curlProfileId: activeProfile?.id || null,
|
||||
curlProfileId: activeProfile.id,
|
||||
rawCurl: '',
|
||||
processedCurl: '',
|
||||
variableMap: {},
|
||||
|
|
@ -557,11 +741,9 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
|||
const tmpl = await fetchJSON(folder, slug);
|
||||
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 legacyGlobalSms = activeProfile ? null : await fetchJSON(bizRoot, 'global_sms');
|
||||
const activeCurl = activeProfile?.rawCurl || legacyGlobalSms?.rawCurl || null;
|
||||
if (!activeCurl) {
|
||||
const activeCurl = activeProfile?.rawCurl || null;
|
||||
if (!activeProfile?.id || !activeCurl) {
|
||||
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.generatedVariants = []; // discard non-selected variants
|
||||
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.processedCurl = processedCurl;
|
||||
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) => {
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
if (!toNumber || !String(toNumber).trim()) {
|
||||
if (!normalizeText(toNumber)) {
|
||||
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' });
|
||||
}
|
||||
|
||||
// Merge any submitted provider fields over stored values
|
||||
const storedProviders = await fetchJSON(bizRoot, 'providers') || {};
|
||||
const mergedProviders = {
|
||||
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) {
|
||||
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
|
||||
const missingFields = getMissingMandatoryProviderFields(boundProfile.provider);
|
||||
if (missingFields.length > 0) {
|
||||
return res.status(422).json({
|
||||
error: 'Missing mandatory provider fields',
|
||||
missingFields: missing,
|
||||
missingFields,
|
||||
code: 'MISSING_BOUND_PROFILE_FIELDS',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate senderId format
|
||||
if (mergedProviders.senderId.length !== 6 || !/^[A-Za-z]+$/.test(mergedProviders.senderId)) {
|
||||
return res.status(400).json({ error: 'Sender ID must be exactly 6 alphabetic characters' });
|
||||
const senderIdError = validateSenderId(boundProfile.provider.senderId);
|
||||
if (senderIdError) {
|
||||
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
|
||||
tmpl.templateId = String(templateId).trim();
|
||||
tmpl.templateId = normalizeText(templateId);
|
||||
tmpl.status = 'whitelisted';
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
|
|
@ -677,8 +844,8 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
|||
let sendResult;
|
||||
try {
|
||||
sendResult = await sendViaWorkflow({
|
||||
senderId: mergedProviders.senderId,
|
||||
toNumber: String(toNumber).trim(),
|
||||
senderId: boundProfile.provider.senderId,
|
||||
toNumber: normalizeText(toNumber),
|
||||
content: tmpl.selectedTemplate || '',
|
||||
});
|
||||
} catch (sendErr) {
|
||||
|
|
@ -697,7 +864,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
|||
});
|
||||
} catch (err) {
|
||||
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 {
|
||||
const { businessId, slug } = req.params;
|
||||
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 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,
|
||||
// not the legacy cURL execution path. The cURL code below remains but is not
|
||||
// reached for whitelisted templates in this branch.
|
||||
const providers = await fetchJSON(bizRoot, 'providers') || {};
|
||||
if (!providers.senderId) {
|
||||
return res.status(422).json({ error: 'Provider senderId is required for sending' });
|
||||
const boundProfile = await getBoundProfile(bizRoot, tmpl.curlProfileId);
|
||||
if (!boundProfile.provider?.senderId) {
|
||||
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;
|
||||
try {
|
||||
smsResult = await sendViaWorkflow({
|
||||
senderId: providers.senderId,
|
||||
toNumber: String(toNumber).trim(),
|
||||
senderId: boundProfile.provider.senderId,
|
||||
toNumber: normalizeText(toNumber),
|
||||
content: tmpl.selectedTemplate || '',
|
||||
});
|
||||
} 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 });
|
||||
*/
|
||||
} 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_TEMPLATE = process.env.WORKFLOW_URL_TEMPLATE;
|
||||
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_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_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.';
|
||||
|
||||
|
|
@ -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