-
- {showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
-
+
+
+ {showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
+
+ {refreshing && (
+
+
+ Refreshing list
+
+ )}
+
{showUnifiedSalesChannelView
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
@@ -732,7 +759,7 @@ export default function Businesses() {
{showModal && (
{ setShowModal(false); load(); }}
+ onClose={() => { setShowModal(false); load({ background: true }); }}
onJobStarted={handleBusinessJobStarted}
/>
)}
diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx
index f8df962..ee18b58 100644
--- a/client/src/pages/Events.jsx
+++ b/client/src/pages/Events.jsx
@@ -657,8 +657,10 @@ function TemplateGenerationWorkspaceModal({
export default function Events() {
const { businessId } = useParams();
const { refreshOnboardingState } = useBusiness();
+ const hasLoadedEventsRef = useRef(false);
const [events, setEvents] = useState([]);
- const [loading, setLoading] = useState(true);
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [addingEvent, setAddingEvent] = useState(false);
@@ -731,8 +733,12 @@ export default function Events() {
};
}, [templateWorkspace.slug]);
- const loadEvents = useCallback(async () => {
- setLoading(true);
+ const loadEvents = useCallback(async ({ background = false } = {}) => {
+ if (background) {
+ setRefreshing(true);
+ } else {
+ setInitialLoading(true);
+ }
try {
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/events`),
@@ -755,15 +761,21 @@ export default function Events() {
setTemplateStatusBySlug(nextTemplateStatusBySlug);
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
+ setError('');
} catch {
setError('Failed to load events');
} finally {
- setLoading(false);
+ if (background) {
+ setRefreshing(false);
+ } else {
+ hasLoadedEventsRef.current = true;
+ setInitialLoading(false);
+ }
}
}, [businessId]);
useEffect(() => {
- loadEvents();
+ loadEvents({ background: hasLoadedEventsRef.current });
}, [loadEvents]);
async function handleAddEvent(e) {
@@ -775,7 +787,7 @@ export default function Events() {
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
setNewLabel('');
setShowAddForm(false);
- await loadEvents();
+ await loadEvents({ background: true });
} catch (err) {
setError(err.response?.data?.error || 'Failed to add event');
} finally {
@@ -786,7 +798,7 @@ export default function Events() {
async function handleDelete(slug) {
try {
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
- await loadEvents();
+ await loadEvents({ background: true });
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete event');
}
@@ -1209,7 +1221,7 @@ export default function Events() {
handleGenerate(slug, { sessionId });
}
- if (loading) {
+ if (initialLoading) {
return (
@@ -1245,7 +1257,15 @@ export default function Events() {
-
Events
+
+
Events
+ {refreshing && (
+
+
+ Updating events
+
+ )}
+
Generate SMS templates for customer-facing lifecycle events.
diff --git a/client/src/pages/GlobalSms.jsx b/client/src/pages/GlobalSms.jsx
index 260eb98..c721d51 100644
--- a/client/src/pages/GlobalSms.jsx
+++ b/client/src/pages/GlobalSms.jsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
@@ -133,7 +133,9 @@ export default function GlobalSms() {
const { businessId } = useParams();
const navigate = useNavigate();
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
- const [loading, setLoading] = useState(true);
+ const hasLoadedProfilesRef = useRef(false);
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(null);
const [saving, setSaving] = useState(false);
@@ -159,9 +161,13 @@ export default function GlobalSms() {
const hasProfiles = profiles.length > 0;
const eventsPath = `/${businessId}/events`;
- const loadProfiles = useCallback(async () => {
+ const loadProfiles = useCallback(async ({ background = false } = {}) => {
try {
- setLoading(true);
+ if (background) {
+ setRefreshing(true);
+ } else {
+ setInitialLoading(true);
+ }
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = res.data?.activeProfileId || null;
@@ -172,6 +178,7 @@ export default function GlobalSms() {
setActiveProfileId(nextActiveProfileId);
setHasGlobalSms(fetchedProfiles.length > 0);
setIsSetupComplete(nextIsSetupComplete);
+ setError('');
return {
activeProfile: nextActiveProfile,
@@ -184,12 +191,17 @@ export default function GlobalSms() {
setIsSetupComplete(false);
return { activeProfile: null, hasProfile: false, complete: false };
} finally {
- setLoading(false);
+ if (background) {
+ setRefreshing(false);
+ } else {
+ hasLoadedProfilesRef.current = true;
+ setInitialLoading(false);
+ }
}
}, [businessId, setHasGlobalSms, setIsSetupComplete]);
useEffect(() => {
- loadProfiles();
+ loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]);
useEffect(() => {
@@ -223,7 +235,7 @@ export default function GlobalSms() {
setFormSetActive(true);
setSuccess('Profile created successfully.');
- const nextState = await loadProfiles();
+ const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
@@ -241,7 +253,7 @@ export default function GlobalSms() {
try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`);
- const nextState = await loadProfiles();
+ const nextState = await loadProfiles({ background: true });
setSuccess('Active profile updated.');
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
@@ -295,7 +307,7 @@ export default function GlobalSms() {
const payload = buildProfilePatchPayload(missingInputs, inputForm);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
setSuccess('Required profile fields saved.');
- const nextState = await loadProfiles();
+ const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
@@ -338,7 +350,7 @@ export default function GlobalSms() {
delete nextState[deletePreview.profile.id];
return nextState;
});
- await loadProfiles();
+ await loadProfiles({ background: true });
setSuccess('Profile deleted successfully.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete profile');
@@ -347,7 +359,7 @@ export default function GlobalSms() {
}
}
- if (loading) {
+ if (initialLoading) {
return (
@@ -366,7 +378,15 @@ export default function GlobalSms() {
-
Omni-channel SMS
+
+
Omni-channel SMS
+ {refreshing && (
+
+
+ Refreshing profiles
+
+ )}
+
Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.
diff --git a/client/src/pages/Providers.jsx b/client/src/pages/Providers.jsx
index 3ba93e0..035ec90 100644
--- a/client/src/pages/Providers.jsx
+++ b/client/src/pages/Providers.jsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
@@ -206,7 +206,9 @@ export default function Providers() {
const { businessId } = useParams();
const navigate = useNavigate();
const { refreshOnboardingState } = useBusiness();
- const [loading, setLoading] = useState(true);
+ const hasLoadedProfilesRef = useRef(false);
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState('');
@@ -219,9 +221,13 @@ export default function Providers() {
const globalSmsPath = `/${businessId}/global-sms`;
- const loadProfiles = useCallback(async () => {
+ const loadProfiles = useCallback(async ({ background = false } = {}) => {
try {
- setLoading(true);
+ if (background) {
+ setRefreshing(true);
+ } else {
+ setInitialLoading(true);
+ }
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = String(res.data?.activeProfileId || '');
@@ -233,15 +239,21 @@ export default function Providers() {
? currentSelectedProfileId
: ''
));
+ setError('');
} catch (err) {
setError(err.response?.data?.error || 'Failed to load provider profiles');
} finally {
- setLoading(false);
+ if (background) {
+ setRefreshing(false);
+ } else {
+ hasLoadedProfilesRef.current = true;
+ setInitialLoading(false);
+ }
}
}, [businessId]);
useEffect(() => {
- loadProfiles();
+ loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]);
const selectedProfile = useMemo(
@@ -300,7 +312,7 @@ export default function Providers() {
setSuccess('');
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
setSelectedProfileId(profile.id);
- await loadProfiles();
+ await loadProfiles({ background: true });
await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`${profile.name} is now the active profile.`);
} catch (err) {
@@ -358,7 +370,7 @@ export default function Providers() {
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
- await loadProfiles();
+ await loadProfiles({ background: true });
await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
} catch (err) {
@@ -368,7 +380,7 @@ export default function Providers() {
}
}
- if (loading) {
+ if (initialLoading) {
return (
@@ -380,7 +392,15 @@ export default function Providers() {
-
Provider Configuration
+
+
Provider Configuration
+ {refreshing && (
+
+
+ Refreshing profiles
+
+ )}
+
Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
diff --git a/client/src/pages/Templates.jsx b/client/src/pages/Templates.jsx
index 1aa81fe..da52ad7 100644
--- a/client/src/pages/Templates.jsx
+++ b/client/src/pages/Templates.jsx
@@ -82,9 +82,11 @@ function getTemplateSortRank(template) {
export default function Templates() {
const { businessId } = useParams();
const [searchParams] = useSearchParams();
+ const hasLoadedTemplatesRef = useRef(false);
const [templates, setTemplates] = useState([]);
const [profilesById, setProfilesById] = useState({});
- const [loading, setLoading] = useState(true);
+ const [initialLoading, setInitialLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [whitelistTarget, setWhitelistTarget] = useState(null);
const [testTarget, setTestTarget] = useState(null);
@@ -96,9 +98,13 @@ export default function Templates() {
const highlightTimeoutRef = useRef(null);
const handledFocusSlugRef = useRef('');
- const loadTemplates = useCallback(async () => {
- setLoading(true);
- setError('');
+ const loadTemplates = useCallback(async ({ background = false } = {}) => {
+ if (background) {
+ setRefreshing(true);
+ } else {
+ setInitialLoading(true);
+ setError('');
+ }
try {
const [templatesRes, profilesRes] = await Promise.all([
@@ -111,15 +117,21 @@ export default function Templates() {
setTemplates(allTemplates);
setProfilesById(profileMap);
+ setError('');
} catch {
setError('Failed to load templates');
} finally {
- setLoading(false);
+ if (background) {
+ setRefreshing(false);
+ } else {
+ hasLoadedTemplatesRef.current = true;
+ setInitialLoading(false);
+ }
}
}, [businessId]);
useEffect(() => {
- loadTemplates();
+ loadTemplates({ background: hasLoadedTemplatesRef.current });
}, [loadTemplates]);
useEffect(() => () => {
@@ -179,7 +191,7 @@ export default function Templates() {
async function handleWhitelistSuccess() {
setWhitelistTarget(null);
- await loadTemplates();
+ await loadTemplates({ background: true });
}
async function handleRuntimeToggle(template) {
@@ -207,7 +219,7 @@ export default function Templates() {
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
: null;
- if (loading) {
+ if (initialLoading) {
return (
@@ -218,7 +230,15 @@ export default function Templates() {
return (
-
Templates
+
+
Templates
+ {refreshing && (
+
+
+ Refreshing templates
+
+ )}
+
Manage template runtime, whitelisting, and testing from one place.
diff --git a/server/routes/businesses.js b/server/routes/businesses.js
index 5636ccf..fd7218c 100644
--- a/server/routes/businesses.js
+++ b/server/routes/businesses.js
@@ -2436,6 +2436,108 @@ function getAnalyticsEventStatusLabel(status) {
}
}
+const ANALYTICS_STATUS_SCOPES = new Set([
+ 'all',
+ 'live',
+ 'paused',
+ 'pending',
+ 'custom',
+ 'not_configured',
+]);
+
+function normalizeAnalyticsStatusScope(value) {
+ const normalizedValue = normalizeText(value).toLowerCase();
+ return ANALYTICS_STATUS_SCOPES.has(normalizedValue) ? normalizedValue : 'all';
+}
+
+function parseAnalyticsEventSlugs(value) {
+ const rawValues = Array.isArray(value) ? value : [value];
+ return [...new Set(
+ rawValues
+ .flatMap((entry) => String(entry || '').split(','))
+ .map((entry) => slugify(entry))
+ .filter(Boolean)
+ )];
+}
+
+function parsePaginationInteger(value, fallback, { min = 1, max = 100 } = {}) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) return fallback;
+ return Math.min(max, Math.max(min, parsed));
+}
+
+function sortAnalyticsEventRows(rows = []) {
+ return [...rows].sort((left, right) => {
+ const statusRank = {
+ live: 0,
+ paused: 1,
+ pending: 2,
+ custom: 3,
+ not_configured: 4,
+ };
+ const rankDiff = (statusRank[left.status] ?? 99) - (statusRank[right.status] ?? 99);
+ if (rankDiff !== 0) return rankDiff;
+
+ const triggerDiff = (right.totalTriggerCount || 0) - (left.totalTriggerCount || 0);
+ if (triggerDiff !== 0) return triggerDiff;
+
+ return left.eventLabel.localeCompare(right.eventLabel);
+ });
+}
+
+function filterAnalyticsEventRows(rows = [], filters = {}) {
+ const statusScope = normalizeAnalyticsStatusScope(filters.statusScope);
+ const selectedEventSlugs = new Set(parseAnalyticsEventSlugs(filters.eventSlugs));
+
+ return rows.filter((row) => {
+ if (statusScope !== 'all' && row.status !== statusScope) {
+ return false;
+ }
+
+ if (selectedEventSlugs.size > 0 && !selectedEventSlugs.has(normalizeText(row.eventSlug))) {
+ return false;
+ }
+
+ return true;
+ });
+}
+
+function paginateAnalyticsRows(rows = [], page, pageSize) {
+ const safePageSize = parsePaginationInteger(pageSize, 5, { min: 1, max: 100 });
+ const totalItems = rows.length;
+ const totalPages = Math.max(1, Math.ceil(totalItems / safePageSize));
+ const currentPage = Math.min(parsePaginationInteger(page, 1, { min: 1, max: totalPages }), totalPages);
+ const startIndex = (currentPage - 1) * safePageSize;
+
+ return {
+ rows: rows.slice(startIndex, startIndex + safePageSize),
+ pagination: {
+ page: currentPage,
+ pageSize: safePageSize,
+ totalItems,
+ totalPages,
+ },
+ };
+}
+
+function hasExplicitAnalyticsFilter(filters = {}) {
+ return normalizeAnalyticsStatusScope(filters.statusScope) !== 'all'
+ || parseAnalyticsEventSlugs(filters.eventSlugs).length > 0;
+}
+
+function buildEmptyOverviewMetrics() {
+ return {
+ triggeredToday: 0,
+ totalTriggered: 0,
+ failedLast24Hours: 0,
+ deliveryRate: {
+ rate: null,
+ mode: 'no_data',
+ },
+ chart: [],
+ };
+}
+
async function loadBusinessTemplates(bizRoot) {
const templateFolder = `${bizRoot}/templates`;
const slugs = await listTemplateFiles(templateFolder).catch(() => []);
@@ -2449,6 +2551,50 @@ async function loadBusinessTemplates(bizRoot) {
return templates;
}
+async function buildAnalyticsEventRows({ companyId, businessId, bizRoot }) {
+ const [eventsData, templates, analyticsEventMetrics] = await Promise.all([
+ fetchJSON(bizRoot, 'events').catch(() => null),
+ loadBusinessTemplates(bizRoot),
+ getEventMetrics({ companyId, businessId }),
+ ]);
+
+ const mergedEvents = mergeDefaultEvents(eventsData || {});
+ const templateBySlug = new Map(
+ templates.map((template) => [normalizeText(template?.eventSlug), template])
+ );
+ const analyticsBySlug = new Map(
+ analyticsEventMetrics.map((metric) => [normalizeText(metric.eventSlug), metric])
+ );
+
+ const rows = (mergedEvents.events || []).map((event) => {
+ const slug = normalizeText(event?.slug);
+ const template = templateBySlug.get(slug) || null;
+ const metric = analyticsBySlug.get(slug) || null;
+ const status = getAnalyticsEventStatus(event, template);
+
+ return {
+ eventSlug: slug,
+ eventLabel: normalizeText(event?.label) || titleCaseFromSlug(slug),
+ status,
+ statusLabel: getAnalyticsEventStatusLabel(status),
+ triggeredToday: metric?.triggeredToday || 0,
+ totalTriggerCount: metric?.totalTriggerCount || 0,
+ deliveryRate: metric?.deliveryRate?.rate ?? null,
+ deliveryRateMode: metric?.deliveryRate?.mode || 'no_data',
+ lastTriggeredAt: metric?.lastTriggeredAt || null,
+ actionPath: normalizeText(template?.selectedTemplate)
+ ? `/${businessId}/templates?event=${encodeURIComponent(slug)}`
+ : `/${businessId}/events`,
+ };
+ });
+
+ return {
+ rows: sortAnalyticsEventRows(rows),
+ mergedEvents,
+ templates,
+ };
+}
+
function hydrateProfile(profile = {}) {
const provider = normalizeProvider(profile.provider, profile.updatedAt);
const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, provider);
@@ -2864,17 +3010,23 @@ router.get('/:businessId/analytics/overview', async (req, res) => {
const { businessId } = req.params;
const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId);
+ const analyticsFilters = {
+ statusScope: req.query?.statusScope,
+ eventSlugs: req.query?.eventSlugs,
+ };
- const [overviewMetrics, eventsData, templates] = await Promise.all([
- getOverviewMetrics({ companyId, businessId }),
- fetchJSON(bizRoot, 'events').catch(() => null),
- loadBusinessTemplates(bizRoot),
- ]);
-
- const mergedEvents = mergeDefaultEvents(eventsData || {});
- const activeEventsCount = templates.filter((template) => (
- template?.status === 'whitelisted' && template?.isRuntimeEnabled !== false
- )).length;
+ const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot });
+ const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters);
+ const scopedEventSlugs = filteredRows.map((row) => row.eventSlug);
+ const hasExplicitFilter = hasExplicitAnalyticsFilter(analyticsFilters);
+ const overviewMetrics = hasExplicitFilter && scopedEventSlugs.length === 0
+ ? buildEmptyOverviewMetrics()
+ : await getOverviewMetrics({
+ companyId,
+ businessId,
+ eventSlugs: hasExplicitFilter ? scopedEventSlugs : [],
+ });
+ const activeEventsCount = filteredRows.filter((row) => row.status === 'live').length;
res.json({
metrics: {
@@ -2884,7 +3036,7 @@ router.get('/:businessId/analytics/overview', async (req, res) => {
deliveryRate: overviewMetrics.deliveryRate.rate,
deliveryRateMode: overviewMetrics.deliveryRate.mode,
activeEvents: activeEventsCount,
- totalEvents: Array.isArray(mergedEvents.events) ? mergedEvents.events.length : 0,
+ totalEvents: filteredRows.length,
},
chart: overviewMetrics.chart,
});
@@ -2900,59 +3052,22 @@ router.get('/:businessId/analytics/events', async (req, res) => {
const { businessId } = req.params;
const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId);
+ const analyticsFilters = {
+ statusScope: req.query?.statusScope,
+ eventSlugs: req.query?.eventSlugs,
+ };
+ const page = req.query?.page;
+ const pageSize = req.query?.pageSize;
- const [eventsData, templates, analyticsEventMetrics] = await Promise.all([
- fetchJSON(bizRoot, 'events').catch(() => null),
- loadBusinessTemplates(bizRoot),
- getEventMetrics({ companyId, businessId }),
- ]);
+ const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot });
+ const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters);
+ const paginatedRows = paginateAnalyticsRows(filteredRows, page, pageSize);
- const mergedEvents = mergeDefaultEvents(eventsData || {});
- const templateBySlug = new Map(
- templates.map((template) => [normalizeText(template?.eventSlug), template])
- );
- const analyticsBySlug = new Map(
- analyticsEventMetrics.map((metric) => [normalizeText(metric.eventSlug), metric])
- );
-
- const rows = (mergedEvents.events || []).map((event) => {
- const slug = normalizeText(event?.slug);
- const template = templateBySlug.get(slug) || null;
- const metric = analyticsBySlug.get(slug) || null;
- const status = getAnalyticsEventStatus(event, template);
-
- return {
- eventSlug: slug,
- eventLabel: normalizeText(event?.label) || titleCaseFromSlug(slug),
- status,
- statusLabel: getAnalyticsEventStatusLabel(status),
- triggeredToday: metric?.triggeredToday || 0,
- totalTriggerCount: metric?.totalTriggerCount || 0,
- deliveryRate: metric?.deliveryRate?.rate ?? null,
- deliveryRateMode: metric?.deliveryRate?.mode || 'no_data',
- lastTriggeredAt: metric?.lastTriggeredAt || null,
- actionPath: normalizeText(template?.selectedTemplate)
- ? `/${businessId}/templates?event=${encodeURIComponent(slug)}`
- : `/${businessId}/events`,
- };
- }).sort((left, right) => {
- const statusRank = {
- live: 0,
- paused: 1,
- pending: 2,
- custom: 3,
- not_configured: 4,
- };
- const rankDiff = (statusRank[left.status] ?? 99) - (statusRank[right.status] ?? 99);
- if (rankDiff !== 0) return rankDiff;
-
- const triggerDiff = (right.totalTriggerCount || 0) - (left.totalTriggerCount || 0);
- if (triggerDiff !== 0) return triggerDiff;
-
- return left.eventLabel.localeCompare(right.eventLabel);
+ res.json({
+ events: paginatedRows.rows,
+ allEvents: allRows,
+ pagination: paginatedRows.pagination,
});
-
- res.json({ events: rows });
} catch (err) {
const status = /Analytics database is not configured/i.test(err.message) ? 503 : 500;
res.status(status).json({ error: err.message });
diff --git a/server/services/analyticsStore.js b/server/services/analyticsStore.js
index a7dcb36..2f54a3a 100644
--- a/server/services/analyticsStore.js
+++ b/server/services/analyticsStore.js
@@ -9,6 +9,16 @@ function normalizeInteger(value) {
return Number.isInteger(value) ? value : null;
}
+function normalizeTextList(values = []) {
+ if (!Array.isArray(values)) return [];
+
+ return [...new Set(
+ values
+ .map((value) => normalizeText(value))
+ .filter(Boolean)
+ )];
+}
+
function getConnectionString() {
return normalizeText(
process.env.FDK_STORAGE_CONNECTION_STRING
@@ -123,7 +133,7 @@ function extractProviderMessageId(value, depth = 0) {
return '';
}
-function buildExecutionFilters({ companyId, businessId }) {
+function buildExecutionFilters({ companyId, businessId, eventSlugs }) {
const values = [];
const conditions = [];
@@ -137,6 +147,12 @@ function buildExecutionFilters({ companyId, businessId }) {
conditions.push(`business_id = $${values.length}`);
}
+ const normalizedEventSlugs = normalizeTextList(eventSlugs);
+ if (normalizedEventSlugs.length > 0) {
+ values.push(normalizedEventSlugs);
+ conditions.push(`event_slug = ANY($${values.length})`);
+ }
+
if (conditions.length === 0) {
throw new Error('Analytics queries require at least one scope filter');
}