Fixes in dockerfile

This commit is contained in:
Ritul-Work 2026-03-26 16:52:15 +05:30
parent 0ee351d316
commit 83b48a3aca
10 changed files with 713 additions and 374 deletions

View File

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

View File

@ -9,19 +9,21 @@ 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">
<Link {hasGlobalSms && (
to={`/${activeBusinessId}/settings`} <Link
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" to={`/${activeBusinessId}/settings`}
title="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> <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> </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 />;
} }

View File

@ -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 || '', }, [boundProfile]);
dltEntityId: res.data?.dltEntityId || ''
});
} catch {
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> <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> <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>

View File

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

View File

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

View File

@ -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`),
setForm({ apiClient.get(`/api/businesses/${businessId}/providers`),
providerName: res.data.providerName || 'MSG91', ]);
senderId: res.data.senderId || '',
dltEntityId: res.data.dltEntityId || '', setActiveProfile(activeRes.data?.activeProfile || null);
authKey: res.data.authKey || '', setForm({
}); providerName: providerRes.data?.providerName || '',
} senderId: providerRes.data?.senderId || '',
} catch { dltEntityId: providerRes.data?.dltEntityId || '',
// no providers yet keep defaults authKey: providerRes.data?.authKey || '',
});
} catch (err) {
setError(err.response?.data?.error || 'Failed to load provider configuration');
} 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 && (

View File

@ -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')}
@ -103,96 +109,110 @@ export default function Templates() {
const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs; const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs;
if (visibleTemplates.length === 0) { if (visibleTemplates.length === 0) {
return ( return (
<div className="text-center py-12 bg-surface-white border border-border-dashed rounded-xl"> <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> <p className="text-text-muted text-sm font-medium">No templates in {activeTab === 'published' ? 'Published' : 'Pending'}.</p>
</div> </div>
); );
} }
return ( return (
<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"> {tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')}
{tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')} </h3>
</h3> <p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
<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}
</div> </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>
{/* Template ID (if whitelisted) */} <div className="p-6 space-y-4">
{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">Bound cURL Profile</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"> {boundProfile ? (
{tmpl.templateId} <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">
</p> <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>
)}
{/* Variable map */}
{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">Selected Template</label>
<div className="flex flex-wrap gap-2"> <div className="p-4 rounded-lg bg-gray-50 border border-gray-200 font-mono text-sm text-gray-800 leading-relaxed break-words">
{Object.entries(tmpl.variableMap).map(([key, val]) => ( {tmpl.selectedTemplate}
<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> </div>
)}
{/* Actions */} {tmpl.templateId && (
<div className="flex items-center gap-3 pt-2"> <div>
{tmpl.status === 'pending_whitelisting' && ( <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
<button <p className="font-mono text-sm text-indigo-700 bg-indigo-50 border border-indigo-100 px-3 py-2 rounded-lg inline-block">
onClick={() => setWhitelistTarget(tmpl)} {tmpl.templateId}
className="px-4 py-2 rounded-lg bg-orange-text hover:bg-[#c97b45] text-white text-sm font-semibold transition shadow-sm" </p>
> </div>
Publish
</button>
)} )}
{tmpl.status === 'whitelisted' && (
<button {tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
onClick={() => setTestTarget(tmpl)} <div>
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" <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">
<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> {Object.entries(tmpl.variableMap).map(([key, val]) => (
Test SMS <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">
</button> <span className="font-mono text-indigo-700 font-bold">{key}</span>
)} <span className="text-gray-400"></span>
{tmpl.status === 'pending_whitelisting' && ( <span className="font-medium text-gray-700">{val}</span>
<p className="text-xs text-text-muted font-medium">Submit to DLT portal, then enter your Template ID here.</p> </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> );
); })}
})} </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}
/> />

View File

@ -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) => {

View File

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

View File

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