From 4f9fd366109a00812ae72ffd494cbbab59b139b3 Mon Sep 17 00:00:00 2001 From: Ritul Date: Mon, 6 Apr 2026 12:37:53 +0530 Subject: [PATCH] fixing route checks in pixelbin --- client/src/components/BusinessReviewModal.jsx | 88 ++-- .../TemplateDetailWorkspaceModal.jsx | 318 ++++++++++++ client/src/pages/Events.jsx | 87 ++-- client/src/pages/Templates.jsx | 468 +++++++++++------- client/src/utils/templateWorkspace.js | 449 +++++++++++++++++ server/routes/businesses.js | 65 ++- server/services/storagePaths.js | 2 +- 7 files changed, 1212 insertions(+), 265 deletions(-) create mode 100644 client/src/components/TemplateDetailWorkspaceModal.jsx create mode 100644 client/src/utils/templateWorkspace.js diff --git a/client/src/components/BusinessReviewModal.jsx b/client/src/components/BusinessReviewModal.jsx index 83c588c..bf729fb 100644 --- a/client/src/components/BusinessReviewModal.jsx +++ b/client/src/components/BusinessReviewModal.jsx @@ -59,30 +59,43 @@ function extractCdnUrls(business) { return normalizeUniqueStrings(business?.relevantImagePaths); } -function normalizeScrapeLinks(value) { - if (!Array.isArray(value)) return []; +const DISPLAY_JSON_OMIT_KEYS = new Set([ + 'cdnUrls', + 'href', + 'link', + 'links', + 'logos', + 'navigation', + 'screenshots', + 'socialLinks', + 'startUrl', + 'summaryText', + 'topImages', + 'url', +]); - const seen = new Set(); - return value - .map((entry) => { - if (typeof entry === 'string') { - const href = normalizeText(entry); - return href ? { href, label: href } : null; - } +function sanitizeDisplayJson(value) { + if (Array.isArray(value)) { + return value + .map((entry) => sanitizeDisplayJson(entry)) + .filter((entry) => entry !== undefined); + } - if (!entry || typeof entry !== 'object') return null; + if (!value || typeof value !== 'object') { + return value; + } - const href = normalizeText(entry.href || entry.url || entry.link); - if (!href) return null; + const sanitized = {}; + Object.entries(value).forEach(([key, entryValue]) => { + if (DISPLAY_JSON_OMIT_KEYS.has(key)) return; - const label = normalizeText(entry.text || entry.title || entry.label || href); - return { href, label }; - }) - .filter((entry) => { - if (!entry || seen.has(entry.href)) return false; - seen.add(entry.href); - return true; - }); + const nextValue = sanitizeDisplayJson(entryValue); + if (nextValue !== undefined) { + sanitized[key] = nextValue; + } + }); + + return sanitized; } function formatPrettyJson(value) { @@ -131,7 +144,7 @@ function extractAboutText(business) { const homepageExcerpt = normalizeText(scrapeJson?.homepage?.excerpt); if (homepageExcerpt) return homepageExcerpt; - return normalizeText(scrapeJson?.summaryText); + return ''; } export default function BusinessReviewModal({ business, onClose }) { @@ -144,8 +157,7 @@ export default function BusinessReviewModal({ business, onClose }) { const colors = extractColors(business); const aboutText = extractAboutText(business); const cdnUrls = extractCdnUrls(business); - const links = normalizeScrapeLinks(business?.scrapeArtifacts?.links); - const prettyJson = useMemo(() => formatPrettyJson(business?.scrapeArtifacts?.json), [business]); + const prettyJson = useMemo(() => formatPrettyJson(sanitizeDisplayJson(business?.scrapeArtifacts?.json)), [business]); useEffect(() => { const previousBodyOverflow = document.body.style.overflow; @@ -214,9 +226,6 @@ export default function BusinessReviewModal({ business, onClose }) { {cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'} - - {links.length} link{links.length === 1 ? '' : 's'} - {colors.length} color{colors.length === 1 ? '' : 's'} @@ -303,7 +312,7 @@ export default function BusinessReviewModal({ business, onClose }) {

Captured Data

-

Raw storefront data captured during onboarding.

+

Captured storefront data, with link-heavy fields hidden in this review.

@@ -312,31 +321,6 @@ export default function BusinessReviewModal({ business, onClose }) {
                 
)} - - {links.length > 0 && ( -
-
-

Links

-

Every discovered storefront link is available below.

-
-
-
- {links.map((link, index) => ( - -

{link.label}

-

{link.href}

-
- ))} -
-
-
- )} diff --git a/client/src/components/TemplateDetailWorkspaceModal.jsx b/client/src/components/TemplateDetailWorkspaceModal.jsx new file mode 100644 index 0000000..a1d0f7f --- /dev/null +++ b/client/src/components/TemplateDetailWorkspaceModal.jsx @@ -0,0 +1,318 @@ +import { useEffect, useMemo, useState } from 'react'; +import apiClient from '../api/client'; +import { + buildTemplateSampleRender, + getTemplateSamplePayload, + getTemplateWorkspaceDescription, +} from '../utils/templateWorkspace'; + +const STATUS_LABELS = { + pending_whitelisting: 'Pending Whitelisting', + whitelisted: 'Published', + generated: 'Generated', +}; + +function getStatusLabel(status) { + return STATUS_LABELS[String(status || '').trim()] || 'Draft'; +} + +function buildProfileMap(profiles = []) { + return Object.fromEntries((profiles || []).map((profile) => [profile.id, profile])); +} + +export default function TemplateDetailWorkspaceModal({ + businessId, + templateSlug, + initialTemplate = null, + initialProfile = null, + onClose, + onRequestPublish, + onRequestTest, +}) { + const [template, setTemplate] = useState(initialTemplate); + const [profilesById, setProfilesById] = useState(initialProfile?.id ? { [initialProfile.id]: initialProfile } : {}); + const [loading, setLoading] = useState(!initialTemplate); + const [error, setError] = useState(''); + + useEffect(() => { + setTemplate(initialTemplate); + }, [initialTemplate]); + + useEffect(() => { + if (!initialProfile?.id) return; + setProfilesById((currentProfiles) => ({ + ...currentProfiles, + [initialProfile.id]: initialProfile, + })); + }, [initialProfile]); + + useEffect(() => { + if (!templateSlug) return undefined; + + const previousBodyOverflow = document.body.style.overflow; + const previousBodyOverscroll = document.body.style.overscrollBehavior; + const previousHtmlOverflow = document.documentElement.style.overflow; + const previousHtmlOverscroll = document.documentElement.style.overscrollBehavior; + + document.body.style.overflow = 'hidden'; + document.body.style.overscrollBehavior = 'none'; + document.documentElement.style.overflow = 'hidden'; + document.documentElement.style.overscrollBehavior = 'none'; + + return () => { + document.body.style.overflow = previousBodyOverflow; + document.body.style.overscrollBehavior = previousBodyOverscroll; + document.documentElement.style.overflow = previousHtmlOverflow; + document.documentElement.style.overscrollBehavior = previousHtmlOverscroll; + }; + }, [templateSlug]); + + useEffect(() => { + if (!templateSlug) return undefined; + + let cancelled = false; + + async function loadWorkspace() { + setLoading(true); + setError(''); + + try { + const [templateRes, profilesRes] = await Promise.all([ + apiClient.get(`/api/businesses/${businessId}/templates/${templateSlug}`), + apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })), + ]); + + if (cancelled) return; + + setTemplate(templateRes.data || null); + setProfilesById((currentProfiles) => ({ + ...currentProfiles, + ...buildProfileMap(profilesRes.data?.profiles || []), + })); + } catch (err) { + if (cancelled) return; + setError(err.response?.data?.error || 'Failed to load template details'); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadWorkspace(); + + return () => { + cancelled = true; + }; + }, [businessId, templateSlug]); + + const boundProfile = useMemo(() => { + const profileId = String(template?.curlProfileId || '').trim(); + if (!profileId) return initialProfile || null; + return profilesById[profileId] || initialProfile || null; + }, [initialProfile, profilesById, template?.curlProfileId]); + + const samplePayload = useMemo(() => getTemplateSamplePayload(template || {}), [template]); + const previewState = useMemo( + () => buildTemplateSampleRender(template?.selectedTemplate, template?.variableMap, samplePayload), + [samplePayload, template?.selectedTemplate, template?.variableMap], + ); + const renderedPreview = previewState.text; + const description = useMemo(() => getTemplateWorkspaceDescription(template || {}), [template]); + + const isPublished = template?.status === 'whitelisted'; + const isPending = template?.status === 'pending_whitelisting'; + const hasBoundProfile = !!boundProfile; + const canPublish = typeof onRequestPublish === 'function' && isPending && hasBoundProfile; + const canTest = typeof onRequestTest === 'function' && isPublished && hasBoundProfile; + const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet'; + const provider = boundProfile?.provider || {}; + const samplePayloadText = JSON.stringify(samplePayload, null, 2); + + return ( +
+
+
+
+

Template Workspace

+

+ {template?.eventLabel || template?.eventSlug || 'Template'} +

+
+ +
+ +
+ {loading ? ( +
+
+
+ ) : ( +
+
+ {error && ( +
+ {error} +
+ )} + +
+

Content

+
+ +
+
+
+

Sample Payload

+

Hardcoded event payload for previewing this template

+
+ + JSON + +
+
+                    {samplePayloadText}
+                  
+
+ +
+
+
+

Message

+ + {String(template?.selectedTemplate || '').length} characters + +
+
+

+ {template?.selectedTemplate || 'No selected template yet.'} +

+
+
+ +
+
+

Preview

+ + Sample render + +
+
+

+ {renderedPreview || template?.selectedTemplate || 'Preview unavailable.'} +

+
+
+
+
+ + +
+ )} +
+ + {(canPublish || canTest) && ( +
+ {canPublish && ( + + )} + {canTest && ( + + )} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index b07a979..6454ae2 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -1,7 +1,8 @@ import axios from 'axios'; import { useState, useEffect, useCallback, useRef } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import apiClient from '../api/client'; +import TemplateDetailWorkspaceModal from '../components/TemplateDetailWorkspaceModal'; import { useBusiness } from '../context/BusinessContext'; const MAX_SMS_LENGTH = 160; @@ -319,7 +320,7 @@ function buildTemplateUiState(templates = []) { return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug }; } -function TemplateWorkspaceModal({ +function TemplateGenerationWorkspaceModal({ eventSlug, eventLabel, statusConfig, @@ -676,7 +677,6 @@ function TemplateWorkspaceModal({ export default function Events() { const { businessId } = useParams(); - const navigate = useNavigate(); const { refreshOnboardingState } = useBusiness(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -695,6 +695,7 @@ export default function Events() { const [templateStatusBySlug, setTemplateStatusBySlug] = useState({}); const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({}); const [templateWorkspace, setTemplateWorkspace] = useState({ slug: '', sessionId: 0 }); + const [templateViewerSlug, setTemplateViewerSlug] = useState(''); const [workspaceError, setWorkspaceError] = useState(''); const [showClosePrompt, setShowClosePrompt] = useState(false); const [discardingWorkspace, setDiscardingWorkspace] = useState(false); @@ -826,6 +827,10 @@ export default function Events() { setTemplateWorkspace({ slug: '', sessionId: 0 }); } + function closeTemplateViewer() { + setTemplateViewerSlug(''); + } + function getWorkspaceBaseState(slug) { if (selectedTemplateBySlug[slug]?.selectedTemplate) return 'selected'; if ((variants[slug] || []).length > 0) return 'done'; @@ -1024,6 +1029,10 @@ export default function Events() { openTemplateWorkspace(slug); } + function handleOpenTemplateViewer(slug) { + setTemplateViewerSlug(slug); + } + function handleOpenGenerateWorkspace(slug) { const sessionId = openTemplateWorkspace(slug); handleGenerate(slug, { sessionId }); @@ -1376,8 +1385,10 @@ export default function Events() { const templateStatus = templateStatusBySlug[event.slug] || 'unselected'; const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected; const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null; - const canViewTemplate = templateStatus !== 'unselected'; - const hasWorkspaceContent = eventVariants.length > 0 || !!selectedTemplatePreview; + const hasSelectedTemplate = !!selectedTemplatePreview; + const hasDraftWorkspace = eventVariants.length > 0; + const canOpenGenerationWorkspace = hasDraftWorkspace; + const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace; return (
@@ -1398,25 +1409,6 @@ export default function Events() { )}

{event.label}

- {selectedTemplatePreview && ( -
-

Selected Template

-

- {selectedTemplatePreview.selectedTemplate} -

-
- {selectedTemplatePreview.templateId ? ( - - Template ID {selectedTemplatePreview.templateId} - - ) : ( - - Template ID pending - - )} -
-
- )}
@@ -1429,28 +1421,33 @@ export default function Events() { {statusConfig.label} - {canViewTemplate && ( - - )}
{workspaceEvent && ( - )} + + {templateViewerSlug && ( + event.slug === templateViewerSlug)?.label || selectedTemplateBySlug[templateViewerSlug]?.eventSlug || '', + } : null} + onClose={closeTemplateViewer} + /> + )} ); } diff --git a/client/src/pages/Templates.jsx b/client/src/pages/Templates.jsx index a90ff9b..a9e2a6b 100644 --- a/client/src/pages/Templates.jsx +++ b/client/src/pages/Templates.jsx @@ -1,15 +1,84 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import apiClient from '../api/client'; +import TemplateDetailWorkspaceModal from '../components/TemplateDetailWorkspaceModal'; import WhitelistModal from '../components/WhitelistModal'; import TestSmsModal from '../components/TestSmsModal'; -const STATUS_CONFIG = { - generated: { label: 'Generated', bg: 'bg-page-bg', text: 'text-text-muted', border: 'border-border-main' }, - pending_whitelisting: { label: 'Pending Whitelisting', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' }, - whitelisted: { label: 'Published', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' }, +const MANAGEABLE_TEMPLATE_STATUSES = new Set(['pending_whitelisting', 'whitelisted']); + +const CARD_APPEARANCE = { + live: { + pillLabel: 'Live', + pillClassName: 'border-[#9dd3c5] bg-[#e9f6f1] text-[#2f7f74]', + accentClassName: 'border-l-[#2f7f74]', + switchTrackClassName: 'border-[#2f7f74] bg-[#2f7f74]', + description: 'Published template is active for runtime sending.', + }, + paused: { + pillLabel: 'Paused', + pillClassName: 'border-[#d6dde6] bg-[#f3f6fa] text-[#5f6f82]', + accentClassName: 'border-l-[#8fa0b3]', + switchTrackClassName: 'border-[#8fa0b3] bg-[#8fa0b3]', + description: 'Published template is available, but runtime sending is paused.', + }, + pending: { + pillLabel: 'Pending Whitelisting', + pillClassName: 'border-[#bfd0ff] bg-[#f2f6ff] text-[#4563d5]', + accentClassName: 'border-l-[#4563d5]', + switchTrackClassName: 'border-[#d8dee8] bg-[#eef1f5]', + description: 'Selected template is awaiting whitelisting before it can go live.', + }, }; +function matchesTemplateSearch(template, profile, rawSearchTerm) { + const searchTerm = rawSearchTerm.trim().toLowerCase(); + if (!searchTerm) return true; + + const candidates = [ + template?.eventLabel, + template?.eventSlug, + template?.templateId, + profile?.name, + profile?.id, + ] + .map((value) => String(value || '').toLowerCase()) + .filter(Boolean); + + return candidates.some((value) => value.includes(searchTerm)); +} + +function getTemplateDisplayName(template) { + return template?.eventLabel || String(template?.eventSlug || '').replace(/_/g, ' ') || 'Template'; +} + +function getCardAppearance(template) { + if (template?.status === 'whitelisted') { + return template?.isRuntimeEnabled === false ? CARD_APPEARANCE.paused : CARD_APPEARANCE.live; + } + + return CARD_APPEARANCE.pending; +} + +function formatDltTemplateId(templateId) { + const value = String(templateId || '').trim(); + return value || 'Pending'; +} + +function getBoundProfileSummary(template, profile) { + if (profile?.name) return profile.name; + if (template?.curlProfileId) return 'Profile missing'; + return 'Not bound yet'; +} + +function getTemplateSortRank(template) { + if (template?.status === 'whitelisted') { + return template?.isRuntimeEnabled === false ? 1 : 0; + } + + return 2; +} + export default function Templates() { const { businessId } = useParams(); const [searchParams] = useSearchParams(); @@ -19,31 +88,28 @@ export default function Templates() { const [error, setError] = useState(''); const [whitelistTarget, setWhitelistTarget] = useState(null); const [testTarget, setTestTarget] = useState(null); - const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending' + const [searchTerm, setSearchTerm] = useState(''); + const [runtimeUpdatingSlug, setRuntimeUpdatingSlug] = useState(''); const [highlightedEventSlug, setHighlightedEventSlug] = useState(''); + const [workspaceSlug, setWorkspaceSlug] = useState(''); const templateCardRefs = useRef({}); const highlightTimeoutRef = useRef(null); const handledFocusSlugRef = useRef(''); - const getTabForStatus = useCallback((status) => { - if (status === 'pending_whitelisting') return 'pending'; - if (status === 'whitelisted') return 'published'; - return null; - }, []); - const loadTemplates = useCallback(async () => { setLoading(true); setError(''); + try { 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])); + const allTemplates = (templatesRes.data.templates || []).filter((template) => template.selectedTemplate); + const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map((profile) => [profile.id, profile])); - setTemplates(all); + setTemplates(allTemplates); setProfilesById(profileMap); } catch { setError('Failed to load templates'); @@ -56,28 +122,38 @@ export default function Templates() { loadTemplates(); }, [loadTemplates]); - useEffect(() => { - return () => { - if (highlightTimeoutRef.current) { - window.clearTimeout(highlightTimeoutRef.current); - } - }; + useEffect(() => () => { + if (highlightTimeoutRef.current) { + window.clearTimeout(highlightTimeoutRef.current); + } }, []); + const manageableTemplates = useMemo( + () => templates.filter((template) => MANAGEABLE_TEMPLATE_STATUSES.has(template?.status)), + [templates], + ); + + const visibleTemplates = useMemo(() => { + return manageableTemplates + .filter((template) => ( + matchesTemplateSearch(template, template.curlProfileId ? profilesById[template.curlProfileId] || null : null, searchTerm) + )) + .sort((leftTemplate, rightTemplate) => { + const rankDifference = getTemplateSortRank(leftTemplate) - getTemplateSortRank(rightTemplate); + if (rankDifference !== 0) return rankDifference; + + return getTemplateDisplayName(leftTemplate).localeCompare(getTemplateDisplayName(rightTemplate)); + }); + }, [manageableTemplates, profilesById, searchTerm]); + useEffect(() => { const targetEventSlug = searchParams.get('event'); - if (!targetEventSlug || templates.length === 0) return; + if (!targetEventSlug || manageableTemplates.length === 0) return; if (handledFocusSlugRef.current === targetEventSlug) return; - const targetTemplate = templates.find(tmpl => tmpl.eventSlug === targetEventSlug); + const targetTemplate = manageableTemplates.find((template) => template.eventSlug === targetEventSlug); if (!targetTemplate) return; - const targetTab = getTabForStatus(targetTemplate.status); - if (targetTab && activeTab !== targetTab) { - setActiveTab(targetTab); - return; - } - const targetCard = templateCardRefs.current[targetEventSlug]; if (!targetCard) return; @@ -90,195 +166,243 @@ export default function Templates() { } highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedEventSlug(currentSlug => (currentSlug === targetEventSlug ? '' : currentSlug)); + setHighlightedEventSlug((currentSlug) => (currentSlug === targetEventSlug ? '' : currentSlug)); highlightTimeoutRef.current = null; }, 2200); - }, [activeTab, getTabForStatus, searchParams, templates]); + }, [manageableTemplates, searchParams]); + + useEffect(() => { + if (!workspaceSlug) return; + if (manageableTemplates.some((template) => template.eventSlug === workspaceSlug)) return; + setWorkspaceSlug(''); + }, [manageableTemplates, workspaceSlug]); async function handleWhitelistSuccess() { setWhitelistTarget(null); await loadTemplates(); } + async function handleRuntimeToggle(template) { + const nextRuntimeState = !(template?.isRuntimeEnabled !== false); + setRuntimeUpdatingSlug(template.eventSlug); + setError(''); + + try { + const res = await apiClient.patch( + `/api/businesses/${businessId}/templates/${template.eventSlug}/runtime`, + { isRuntimeEnabled: nextRuntimeState }, + ); + + setTemplates((currentTemplates) => currentTemplates.map((currentTemplate) => ( + currentTemplate.eventSlug === template.eventSlug ? res.data : currentTemplate + ))); + } catch (err) { + setError(err.response?.data?.error || 'Failed to update template runtime state'); + } finally { + setRuntimeUpdatingSlug(''); + } + } + + const currentWorkspaceTemplate = workspaceSlug + ? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null + : null; + if (loading) { return ( -
-
+
+
); } return ( -
-
-

Templates

-

Track whitelisting status and test your SMS templates.

+
+
+

Templates

+

+ Manage template runtime, whitelisting, and testing from one place. +

{error && ( -
+
{error} - +
)} -
- - +
+
+ setSearchTerm(event.target.value)} + placeholder="Search by event, profile, or DLT template ID" + className="w-full rounded-xl border border-gray-200 bg-white py-3 pl-4 pr-12 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-blue" + /> + {searchTerm && ( + + )} +
- {templates.length === 0 ? ( -
-
- + {manageableTemplates.length === 0 ? ( +
+
+ + +

No Templates Yet

-

Generate and select templates in the Events section first.

+

+ Generate and select templates in the Events section first. +

- ) : (() => { - const publishedTabs = templates.filter(t => t.status === 'whitelisted'); - const pendingTabs = templates.filter(t => t.status === 'pending_whitelisting'); - const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs; + ) : visibleTemplates.length === 0 ? ( +
+

+ No templates match "{searchTerm.trim()}". +

+
+ ) : ( +
+ {visibleTemplates.map((template) => { + const appearance = getCardAppearance(template); + const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null; + const isRuntimeEnabled = template.isRuntimeEnabled !== false; + const isRuntimeUpdating = runtimeUpdatingSlug === template.eventSlug; + const isBoundProfileMissing = !boundProfile; + const isPublished = template.status === 'whitelisted'; - if (visibleTemplates.length === 0) { - return ( -
-

No templates in {activeTab === 'published' ? 'Published' : 'Pending'}.

-
- ); - } - - return ( -
- {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 ( -
{ - if (node) { - templateCardRefs.current[tmpl.eventSlug] = node; - } else { - delete templateCardRefs.current[tmpl.eventSlug]; - } - }} - className={`rounded-lg bg-white border overflow-hidden transition-all duration-300 ${ - highlightedEventSlug === tmpl.eventSlug - ? 'border-primary-blue animate-pulse' - : 'border-gray-200' - }`} - > -
-
-

- {tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')} + return ( +
{ + if (node) { + templateCardRefs.current[template.eventSlug] = node; + } else { + delete templateCardRefs.current[template.eventSlug]; + } + }} + className={`overflow-hidden rounded-[28px] border border-gray-200 border-l-4 bg-white px-6 py-5 shadow-[0_12px_30px_rgba(15,23,42,0.06)] transition-all duration-300 ${appearance.accentClassName} ${ + highlightedEventSlug === template.eventSlug + ? 'ring-2 ring-primary-blue/30' + : 'hover:-translate-y-0.5 hover:shadow-[0_16px_34px_rgba(15,23,42,0.08)]' + }`} + > +
+
+
+

+ {getTemplateDisplayName(template)}

-

{tmpl.eventSlug}

+ + {isPublished && ( + + )} + {appearance.pillLabel} +
- - {statusCfg.label} - +

+ {appearance.description} +

-
-
- - {boundProfile ? ( -
- {boundProfile.name} - {boundProfile.id} -
- ) : ( -
- {boundProfileMessage} -
- )} -
+ +
-
- -
- {tmpl.selectedTemplate} -
-
- - {tmpl.templateId && ( +
+
+
- -

- {tmpl.templateId} +

Profile

+

+ {getBoundProfileSummary(template, boundProfile)}

- )} - - {tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
- -
- {Object.entries(tmpl.variableMap).map(([key, val]) => ( -
- {key} - - {val} -
- ))} -
+

DLT Template ID

+

+ {formatDltTemplateId(template.templateId)} +

- )} +
-
- {!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && ( +
+ + + {!isBoundProfileMissing && template.status === 'pending_whitelisting' && ( )} - {!isBoundProfileMissing && tmpl.status === 'whitelisted' && ( + + {!isBoundProfileMissing && isPublished && ( )} - {tmpl.status === 'pending_whitelisting' && !isBoundProfileMissing && ( -

Submit to the DLT portal, then complete publish from here.

- )}
+ + {isBoundProfileMissing && ( +

+ {template.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.'} +

+ )}
- ); - })} -
- ); - })()} +
+ ); + })} +

+ )} {whitelistTarget && ( setTestTarget(null)} /> )} + + {workspaceSlug && ( + setWorkspaceSlug('')} + onRequestPublish={(template) => setWhitelistTarget(template)} + onRequestTest={(template) => setTestTarget(template)} + /> + )}
); } diff --git a/client/src/utils/templateWorkspace.js b/client/src/utils/templateWorkspace.js new file mode 100644 index 0000000..7ee415d --- /dev/null +++ b/client/src/utils/templateWorkspace.js @@ -0,0 +1,449 @@ +const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g; + +const PLACEHOLDER_SAMPLE_FIELD_CANDIDATES = { + '{#var#}': ['firstName', 'customerName', 'fullName', 'brandName', 'eventDisplayName'], + '{#numeric#}': ['otp', 'amount', 'refundAmount', 'pincode', 'toNumber'], + '{#url#}': ['trackingUrl', 'url', 'trackUrl', 'trackingLink'], + '{#urlott#}': ['trackingUrl', 'url', 'trackUrl', 'trackingLink'], + '{#cbn#}': ['callbackNumber', 'toNumber', 'customerPhone', 'mobile', 'phone'], + '{#email#}': ['email', 'customerEmail'], + '{#alphanumeric#}': ['orderId', 'transactionId', 'shipmentId', 'awbNumber', 'awbNo'], +}; + +const EVENT_SAMPLE_OVERRIDES = { + payment_failed: { + shipment: { + payment_status: 'failed', + transaction_id: 'TXN9012457812', + amount: '2499', + failure_reason: 'UPI mandate expired', + }, + }, + payment_initiated: { + shipment: { + payment_status: 'initiated', + transaction_id: 'TXN9012457812', + amount: '2499', + }, + }, + refund_initiated: { + shipment: { + refund_status: 'initiated', + refund_amount: '2499', + refund_id: 'RFD1204982', + }, + }, + refund_completed: { + shipment: { + refund_status: 'completed', + refund_amount: '2499', + refund_id: 'RFD1204982', + }, + }, + out_for_delivery: { + shipment: { + otp: '482193', + estimated_delivery_slot: '6:00 PM to 8:00 PM', + }, + }, + delivery_attempt_failed: { + shipment: { + failure_reason: 'Customer unavailable', + callback_number: '919876543210', + }, + }, + delivery_done: { + shipment: { + delivered_at: '2026-04-06T14:18:00.000Z', + otp: '482193', + }, + }, + order_placed: { + shipment: { + payment_status: 'paid', + expected_dispatch_date: '2026-04-07', + }, + }, +}; + +function normalizeScalarText(value) { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return ''; +} + +function firstNonEmptyText(...values) { + for (const value of values) { + const normalized = normalizeScalarText(value); + if (normalized) return normalized; + } + return ''; +} + +function toCamelCase(text) { + return String(text || '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + .map((part, index) => { + const lower = part.toLowerCase(); + return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(''); +} + +function setValueIndexEntry(valueIndex, key, value) { + if (!key || valueIndex.has(key)) return; + valueIndex.set(key, value); +} + +function normalizeRenderableValue(value) { + return normalizeScalarText(value).replace(/\s+/g, ' ').trim(); +} + +function splitFullName(value) { + const fullName = normalizeRenderableValue(value); + if (!fullName) return { firstName: '', lastName: '', fullName: '' }; + + const parts = fullName.split(/\s+/).filter(Boolean); + return { + firstName: parts[0] || '', + lastName: parts.length > 1 ? parts.slice(1).join(' ') : '', + fullName, + }; +} + +function indexPayloadValues(value, pathParts = [], valueIndex = new Map()) { + if (Array.isArray(value)) { + value.forEach((entry) => indexPayloadValues(entry, pathParts, valueIndex)); + return valueIndex; + } + + if (value && typeof value === 'object') { + Object.entries(value).forEach(([key, entry]) => { + indexPayloadValues(entry, [...pathParts, key], valueIndex); + }); + return valueIndex; + } + + const normalizedValue = normalizeRenderableValue(value); + if (!normalizedValue || pathParts.length === 0) return valueIndex; + + const leafKey = toCamelCase(pathParts[pathParts.length - 1]); + const fullKey = toCamelCase(pathParts.join(' ')); + setValueIndexEntry(valueIndex, leafKey, normalizedValue); + setValueIndexEntry(valueIndex, fullKey, normalizedValue); + return valueIndex; +} + +function buildShipmentValueIndex(shipment) { + const valueIndex = indexPayloadValues(shipment); + const firstBag = shipment?.bags?.[0] || {}; + const customerName = splitFullName( + firstNonEmptyText( + shipment?.user?.first_name && shipment?.user?.last_name + ? `${shipment.user.first_name} ${shipment.user.last_name}` + : '', + shipment?.delivery_address?.name, + shipment?.delivery_address?.contact_person, + shipment?.billing_address?.name, + shipment?.billing_address?.contact_person, + ) + ); + const primaryTrackingUrl = firstNonEmptyText( + shipment?.delivery_partner_details?.track_url, + shipment?.meta?.tracking_url, + firstBag?.meta?.tracking_url, + shipment?.affiliate_details?.shipment_meta?.tracking_url, + ); + const primaryAwbNumber = firstNonEmptyText( + shipment?.delivery_partner_details?.awb_no, + shipment?.meta?.awb_number, + firstBag?.meta?.awb_number, + ); + const primaryCourierName = firstNonEmptyText( + shipment?.delivery_partner_details?.display_name, + shipment?.delivery_partner_details?.name, + shipment?.meta?.courier_partner_name, + firstBag?.meta?.dp_name, + ); + const brandName = firstNonEmptyText( + shipment?.bags?.[0]?.brand?.brand_name, + shipment?.bags?.[0]?.item?.attributes?.brand_name, + shipment?.affiliate_details?.company_affiliate_tag, + ); + const toNumber = firstNonEmptyText( + shipment?.user?.mobile, + shipment?.delivery_address?.phone, + shipment?.billing_address?.phone, + ); + const emailAddress = firstNonEmptyText( + shipment?.user?.email, + shipment?.delivery_address?.email, + shipment?.billing_address?.email, + ); + const eventKey = firstNonEmptyText( + shipment?.status, + shipment?.shipment_status?.status, + shipment?.shipment_status?.current_shipment_status, + ); + const eventDisplayName = firstNonEmptyText( + shipment?.shipment_status?.display_name, + shipment?.shipment_status?.current_shipment_status, + ); + const shipmentId = firstNonEmptyText( + shipment?.shipment_id, + shipment?.shipment_status?.shipment_id, + ); + const resolvedFullName = firstNonEmptyText( + shipment?.user?.first_name || shipment?.user?.last_name + ? `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim() + : '', + customerName.fullName, + ); + const resolvedFirstName = firstNonEmptyText(shipment?.user?.first_name, customerName.firstName); + const resolvedLastName = firstNonEmptyText(shipment?.user?.last_name, customerName.lastName); + + setValueIndexEntry(valueIndex, 'firstName', resolvedFirstName); + setValueIndexEntry(valueIndex, 'lastName', resolvedLastName); + setValueIndexEntry(valueIndex, 'fullName', resolvedFullName); + setValueIndexEntry(valueIndex, 'customerFirstName', resolvedFirstName); + setValueIndexEntry(valueIndex, 'customerLastName', resolvedLastName); + setValueIndexEntry(valueIndex, 'customerName', resolvedFullName); + setValueIndexEntry(valueIndex, 'phone', toNumber); + setValueIndexEntry(valueIndex, 'mobile', toNumber); + setValueIndexEntry(valueIndex, 'toNumber', toNumber); + setValueIndexEntry(valueIndex, 'customerPhone', toNumber); + setValueIndexEntry(valueIndex, 'customerMobile', toNumber); + setValueIndexEntry(valueIndex, 'email', emailAddress); + setValueIndexEntry(valueIndex, 'customerEmail', emailAddress); + setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id)); + setValueIndexEntry(valueIndex, 'orderNumber', normalizeRenderableValue(shipment?.order_id)); + setValueIndexEntry(valueIndex, 'shipmentId', shipmentId); + setValueIndexEntry(valueIndex, 'event', eventKey); + setValueIndexEntry(valueIndex, 'status', eventKey); + setValueIndexEntry(valueIndex, 'eventDisplayName', eventDisplayName); + setValueIndexEntry(valueIndex, 'displayName', eventDisplayName); + setValueIndexEntry(valueIndex, 'brandName', brandName); + setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl); + setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl); + setValueIndexEntry(valueIndex, 'trackingLink', primaryTrackingUrl); + setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl); + setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber); + setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber); + setValueIndexEntry(valueIndex, 'awb', primaryAwbNumber); + setValueIndexEntry(valueIndex, 'dpName', primaryCourierName); + setValueIndexEntry(valueIndex, 'courierName', primaryCourierName); + setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName); + + return valueIndex; +} + +function mergeDeep(baseValue, overrideValue) { + if (Array.isArray(baseValue) || Array.isArray(overrideValue)) { + return overrideValue !== undefined ? overrideValue : baseValue; + } + + if (baseValue && typeof baseValue === 'object' && overrideValue && typeof overrideValue === 'object') { + const nextValue = { ...baseValue }; + Object.entries(overrideValue).forEach(([key, value]) => { + nextValue[key] = key in nextValue ? mergeDeep(nextValue[key], value) : value; + }); + return nextValue; + } + + return overrideValue !== undefined ? overrideValue : baseValue; +} + +function titleCaseFromSlug(slug) { + return String(slug || '') + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function isRenderablePreviewValueForToken(token, value) { + const normalizedValue = normalizeRenderableValue(value); + if (!normalizedValue) return false; + + switch (token) { + case '{#numeric#}': + return /^\d+$/.test(normalizedValue); + case '{#url#}': + case '{#urlott#}': + return /^https?:\/\//i.test(normalizedValue); + case '{#cbn#}': + return /^\+?[0-9][0-9\s-]{5,}$/.test(normalizedValue); + case '{#email#}': + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedValue); + case '{#alphanumeric#}': + return /^[A-Za-z0-9]+$/.test(normalizedValue); + default: + return true; + } +} + +function resolvePlaceholderSampleFallback(token, shipmentValueIndex) { + const candidateFields = PLACEHOLDER_SAMPLE_FIELD_CANDIDATES[token] || []; + + for (const fieldName of candidateFields) { + const resolvedValue = shipmentValueIndex.get(fieldName) || ''; + if (isRenderablePreviewValueForToken(token, resolvedValue)) { + return { + fieldName, + value: resolvedValue, + }; + } + } + + return null; +} + +export function getTemplateSamplePayload(template = {}) { + const eventSlug = String(template?.eventSlug || '').trim(); + const eventLabel = String(template?.eventLabel || '').trim() || titleCaseFromSlug(eventSlug); + const brandName = String(template?.brandName || '').trim() || 'Your Brand'; + const override = EVENT_SAMPLE_OVERRIDES[eventSlug] || {}; + + const basePayload = { + payload: { + event: eventSlug, + company_id: 'dev_merchant_001', + application_id: 'application-demo-001', + shipment: { + application_id: 'application-demo-001', + order_id: 'FY5E53AFAA091115C235', + shipment_id: 'SHP784512', + status: eventSlug || 'order_placed', + shipment_status: { + status: eventSlug || 'order_placed', + current_shipment_status: eventSlug || 'order_placed', + display_name: eventLabel || 'Order Update', + shipment_id: 'SHP784512', + }, + user: { + first_name: 'Aarav', + last_name: 'Sharma', + mobile: '919876543210', + email: 'aarav.sharma@example.com', + }, + delivery_address: { + name: 'Aarav Sharma', + phone: '919876543210', + email: 'aarav.sharma@example.com', + city: 'Bengaluru', + pincode: '560001', + }, + billing_address: { + name: 'Aarav Sharma', + phone: '919876543210', + email: 'aarav.sharma@example.com', + }, + delivery_partner_details: { + display_name: 'Blue Dart', + track_url: 'https://tracking.example.com/SHP784512', + awb_no: '78451236985', + }, + affiliate_details: { + affiliate_id: 'application-demo-001', + company_affiliate_tag: brandName, + shipment_meta: { + tracking_url: 'https://tracking.example.com/SHP784512', + courier_partner_name: 'Blue Dart', + }, + }, + meta: { + tracking_url: 'https://tracking.example.com/SHP784512', + awb_number: '78451236985', + courier_partner_name: 'Blue Dart', + }, + bags: [ + { + brand: { brand_name: brandName }, + item: { + name: 'Midnight Duffle', + attributes: { brand_name: brandName }, + }, + meta: { + tracking_url: 'https://tracking.example.com/SHP784512', + awb_number: '78451236985', + }, + }, + ], + }, + }, + }; + + return mergeDeep(basePayload, override); +} + +export function buildTemplateSampleRender(templateText, variableMap = {}, samplePayload = {}) { + const text = String(templateText || ''); + if (!text) { + return { + text: '', + fallbackPlaceholders: [], + unresolvedPlaceholders: [], + }; + } + + const shipment = samplePayload?.payload?.shipment || samplePayload?.shipment || {}; + const shipmentValueIndex = buildShipmentValueIndex(shipment); + let placeholderIndex = 0; + const fallbackPlaceholders = []; + const unresolvedPlaceholders = []; + + const renderedText = text.replace(DLT_PLACEHOLDER_REGEX, (token) => { + const mappingKey = `${token}[${placeholderIndex}]`; + const mappedFieldName = normalizeScalarText(variableMap?.[mappingKey] || variableMap?.[token]); + placeholderIndex += 1; + + const resolvedMappedValue = mappedFieldName + ? shipmentValueIndex.get(toCamelCase(mappedFieldName)) || '' + : ''; + + if (resolvedMappedValue) return resolvedMappedValue; + + const fallback = resolvePlaceholderSampleFallback(token, shipmentValueIndex); + if (fallback) { + fallbackPlaceholders.push({ + mappingKey, + token, + mappedFieldName, + sampleFieldName: fallback.fieldName, + }); + return fallback.value; + } + + unresolvedPlaceholders.push({ + mappingKey, + token, + mappedFieldName, + }); + return token; + }); + + return { + text: renderedText, + fallbackPlaceholders, + unresolvedPlaceholders, + }; +} + +export function renderTemplateWithSamplePayload(templateText, variableMap = {}, samplePayload = {}) { + return buildTemplateSampleRender(templateText, variableMap, samplePayload).text; +} + +export function getTemplateWorkspaceDescription(template = {}) { + const eventLabel = String(template?.eventLabel || '').trim() || titleCaseFromSlug(template?.eventSlug); + if (!eventLabel) return 'Review the current SMS template, sample payload, and delivery details.'; + return `Use this SMS template for ${eventLabel.toLowerCase()} notifications.`; +} + +export function getTemplateWorkspaceVariableCount(template = {}) { + return (String(template?.selectedTemplate || '').match(DLT_PLACEHOLDER_REGEX) || []).length; +} diff --git a/server/routes/businesses.js b/server/routes/businesses.js index 8e1b405..283e879 100644 --- a/server/routes/businesses.js +++ b/server/routes/businesses.js @@ -176,6 +176,18 @@ function normalizeProvider(provider = {}, fallbackUpdatedAt = null) { }; } +function isTemplateRuntimeEnabled(template = {}) { + return template?.isRuntimeEnabled !== false; +} + +function withTemplateRuntimeDefaults(template) { + if (!template || typeof template !== 'object') return template; + return { + ...template, + isRuntimeEnabled: isTemplateRuntimeEnabled(template), + }; +} + function normalizeWebsiteUrl(value) { const rawValue = normalizeText(value); if (!rawValue) return ''; @@ -934,6 +946,13 @@ async function resolveTemplateRequest(context) { throw createHttpError(404, 'Whitelisted template not found'); } + if (!isTemplateRuntimeEnabled(tmpl)) { + throw createHttpError(409, 'Template runtime is paused', { + code: 'RUNTIME_DISABLED', + template: withTemplateRuntimeDefaults(tmpl), + }); + } + const resolvedTemplate = renderShipmentTemplate( tmpl.selectedTemplate, context.shipment, @@ -1670,6 +1689,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => { variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object' ? existingTemplate.variableMap : {}, + isRuntimeEnabled: isTemplateRuntimeEnabled(existingTemplate), selectedImagePath: existingTemplate?.selectedImagePath || '', updatedAt: new Date().toISOString(), }; @@ -1703,7 +1723,7 @@ router.get('/:businessId/templates', async (req, res) => { const templates = []; for (const slug of slugs) { const tmpl = await fetchJSON(folder, slug); - if (tmpl) templates.push(tmpl); + if (tmpl) templates.push(withTemplateRuntimeDefaults(tmpl)); } res.json({ templates }); } catch (err) { @@ -1717,12 +1737,39 @@ router.get('/:businessId/templates/:slug', async (req, res) => { const { businessId, slug } = req.params; const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); - res.json(tmpl); + res.json(withTemplateRuntimeDefaults(tmpl)); } catch (err) { res.status(500).json({ error: err.message }); } }); +// PATCH /api/businesses/:businessId/templates/:slug/runtime +router.patch('/:businessId/templates/:slug/runtime', async (req, res) => { + try { + const { businessId, slug } = req.params; + const nextRuntimeState = req.body?.isRuntimeEnabled; + + if (typeof nextRuntimeState !== 'boolean') { + return res.status(400).json({ error: 'isRuntimeEnabled must be a boolean' }); + } + + const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`; + const tmpl = await fetchJSON(folder, slug); + if (!tmpl) return res.status(404).json({ error: 'Template not found' }); + if (tmpl.status !== 'whitelisted') { + return res.status(400).json({ error: 'Only published templates can change runtime state' }); + } + + tmpl.isRuntimeEnabled = nextRuntimeState; + tmpl.updatedAt = new Date().toISOString(); + + await uploadJSON(folder, slug, tmpl); + res.json(withTemplateRuntimeDefaults(tmpl)); + } catch (err) { + sendRouteError(res, err); + } +}); + // POST /api/businesses/:businessId/templates/:slug/validate-edit router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => { try { @@ -1761,10 +1808,11 @@ router.post('/:businessId/templates/:slug/discard', async (req, res) => { if (!tmpl) return res.status(404).json({ error: 'Template not found' }); tmpl.generatedVariants = []; + tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl); tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); - res.json({ ok: true, template: tmpl }); + res.json({ ok: true, template: withTemplateRuntimeDefaults(tmpl) }); } catch (err) { sendRouteError(res, err); } @@ -1799,10 +1847,11 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => { tmpl.rawCurl = activeCurl; tmpl.processedCurl = processedCurl; tmpl.variableMap = variableMap; + tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl); tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); - res.json(tmpl); + res.json(withTemplateRuntimeDefaults(tmpl)); } catch (err) { console.error('Select error:', err.message); res.status(500).json({ error: err.message }); @@ -1827,10 +1876,11 @@ router.post('/:businessId/templates/:slug/whitelist', async (req, res) => { tmpl.templateId = String(templateId).trim(); tmpl.status = 'whitelisted'; + tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl); tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); - res.json(tmpl); + res.json(withTemplateRuntimeDefaults(tmpl)); } catch (err) { res.status(500).json({ error: err.message }); } @@ -1879,6 +1929,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => { // Mark template as whitelisted tmpl.templateId = normalizeText(templateId); tmpl.status = 'whitelisted'; + tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl); tmpl.updatedAt = new Date().toISOString(); await uploadJSON(folder, slug, tmpl); @@ -1895,13 +1946,13 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => { return res.status(502).json({ error: 'Template published but send failed', details: sendErr.message, - template: tmpl, + template: withTemplateRuntimeDefaults(tmpl), }); } res.json({ success: true, - template: tmpl, + template: withTemplateRuntimeDefaults(tmpl), sendResult, }); } catch (err) { diff --git a/server/services/storagePaths.js b/server/services/storagePaths.js index a5e583f..8d50f72 100644 --- a/server/services/storagePaths.js +++ b/server/services/storagePaths.js @@ -1,4 +1,4 @@ -const STORAGE_NAMESPACE = 'Omni-SMS Extension'; +const STORAGE_NAMESPACE = 'Omni-SMS_Extension'; function normalizeSegment(value) { return String(value || '').trim().replace(/^\/+|\/+$/g, '');