From ed05ce18b12a119c79b873ffccae41aa0ca29edf Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Mon, 29 Sep 2025 13:23:55 +0530 Subject: [PATCH 01/27] feat(region): implement region management with dynamic API and WebSocket hostnames - Introduced a new `RegionProvider` to manage region selection and API hostname updates. - Added `RegionSelector` component for users to select their region. - Integrated `apiHostnameManager` to handle API and WebSocket hostnames based on selected region. - Updated various components and pages to utilize the new region context and hostname management. - Implemented region detection from organization metadata for improved user experience. --- apps/dashboard/src/api/api.client.ts | 12 +- .../components/auth/create-organization.tsx | 53 ++- .../components/auth/inbox-preview-content.tsx | 6 +- .../src/components/confirmation-modal.tsx | 6 +- .../header-navigation/header-navigation.tsx | 11 +- .../dashboard/src/components/inbox-button.tsx | 19 +- .../components/configuration-group.tsx | 16 +- .../ai-prompts/simple-prompt-getter.ts | 5 +- .../welcome/framework-guides.instructions.tsx | 10 +- apps/dashboard/src/config/index.ts | 5 + apps/dashboard/src/context/region/index.ts | 5 + .../src/context/region/region-context.tsx | 308 ++++++++++++++++++ .../src/context/region/region-modals.tsx | 32 ++ .../src/context/region/region-selector.tsx | 51 +++ .../src/context/region/region-types.ts | 27 ++ .../src/context/region/region-utils.ts | 87 +++++ apps/dashboard/src/pages/api-keys.tsx | 29 +- apps/dashboard/src/routes/root.tsx | 18 +- .../src/utils/api-hostname-manager.ts | 49 +++ apps/dashboard/src/utils/code-snippets.ts | 5 +- packages/shared/src/types/feature-flags.ts | 1 + 21 files changed, 694 insertions(+), 61 deletions(-) create mode 100644 apps/dashboard/src/context/region/index.ts create mode 100644 apps/dashboard/src/context/region/region-context.tsx create mode 100644 apps/dashboard/src/context/region/region-modals.tsx create mode 100644 apps/dashboard/src/context/region/region-selector.tsx create mode 100644 apps/dashboard/src/context/region/region-types.ts create mode 100644 apps/dashboard/src/context/region/region-utils.ts create mode 100644 apps/dashboard/src/utils/api-hostname-manager.ts diff --git a/apps/dashboard/src/api/api.client.ts b/apps/dashboard/src/api/api.client.ts index 85a50eb8c72..78d7053ca41 100644 --- a/apps/dashboard/src/api/api.client.ts +++ b/apps/dashboard/src/api/api.client.ts @@ -1,7 +1,6 @@ -import type { IEnvironment } from '@novu/shared'; -import { API_HOSTNAME } from '@/config'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { getToken } from '@/utils/auth'; - +import { IEnvironment } from '@novu/shared'; // This is how we import the speakeasy autogenerated Novu SDK that is CJS in a the Dashboard ESM project with Vite // Read more at https://github.com/vitejs/vite/issues/5668#issuecomment-968117934 @@ -39,6 +38,11 @@ const request = async ( const { body, environment, headers, method = 'GET', version = 'v1', signal } = options || {}; try { + // Prevent API calls during region switching to avoid failed requests + if (apiHostnameManager.isCurrentlyRegionSwitching()) { + console.log('Blocking API call during region switching'); + } + const jwt = await getToken(); const config: RequestInit = { method, @@ -62,7 +66,7 @@ const request = async ( } } - const baseUrl = API_HOSTNAME ?? 'https://api.novu.co'; + const baseUrl = apiHostnameManager.getHostname(); const response = await fetch(`${baseUrl}/${version}${endpoint}`, config); if (!response.ok) { diff --git a/apps/dashboard/src/components/auth/create-organization.tsx b/apps/dashboard/src/components/auth/create-organization.tsx index 8e536c259b3..6ed604f14f5 100644 --- a/apps/dashboard/src/components/auth/create-organization.tsx +++ b/apps/dashboard/src/components/auth/create-organization.tsx @@ -1,5 +1,6 @@ +import { RegionSelector, useRegion } from '@/context/region'; import { OrganizationList as OrganizationListForm, useOrganization } from '@clerk/clerk-react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useTelemetry } from '../../hooks/use-telemetry'; import { clerkSignupAppearance } from '../../utils/clerk-appearance'; import { ROUTES } from '../../utils/routes'; @@ -60,7 +61,42 @@ function FormContainer({ children }: FormContainerProps) { } function OrganizationForm() { - return ; + const [showRegionSelector, setShowRegionSelector] = useState(false); + + useEffect(() => { + // Watch for DOM changes to detect when we're on the form page (Page 2) + const observer = new MutationObserver(() => { + // Check if the organization creation form (with name input) is visible + const nameInput = document.querySelector('input[name="name"]'); + const isOnFormPage = !!nameInput; + + if (isOnFormPage !== showRegionSelector) { + setShowRegionSelector(isOnFormPage); + console.log(`Organization form page visibility changed: ${isOnFormPage ? 'Page 2 (Form)' : 'Page 1 (List)'}`); + } + }); + + // Start observing + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); + }, [showRegionSelector]); + + return ( +
+ {/* Region selector - only visible on Page 2 (form page), aligned with form content */} + {showRegionSelector && ( +
+ +
+ )} + + +
+ ); } function OrganizationFormSection() { @@ -113,6 +149,7 @@ function PageContent() { export default function OrganizationCreate() { const { organization } = useOrganization(); + const { selectedRegion } = useRegion(); const track = useTelemetry(); useEffect(() => { @@ -122,9 +159,19 @@ export default function OrganizationCreate() { location: 'web', organizationId: organization.id, organizationName: organization.name, + region: selectedRegion, }); + + // Set the region metadata for the newly created organization + const regionMetadata = selectedRegion === 'singapore' ? 'ap-southeast-1' : 'us-east-1'; + + console.log(`New organization "${organization.name}" created in region: ${regionMetadata}`); + + // Note: Organization metadata with region should be set via backend API or Clerk webhook + // For now, we track this information in telemetry and localStorage + console.log(`Organization created in ${selectedRegion} region with metadata: ${regionMetadata}`); } - }, [organization, track]); + }, [organization, track, selectedRegion]); return (
diff --git a/apps/dashboard/src/components/auth/inbox-preview-content.tsx b/apps/dashboard/src/components/auth/inbox-preview-content.tsx index c2a8832631c..2c6bb1db619 100644 --- a/apps/dashboard/src/components/auth/inbox-preview-content.tsx +++ b/apps/dashboard/src/components/auth/inbox-preview-content.tsx @@ -1,6 +1,6 @@ +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { useUser } from '@clerk/clerk-react'; import { Inbox, InboxContent, InboxProps } from '@novu/react'; -import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '../../config'; import { useAuth } from '../../context/auth/hooks'; import { useFetchEnvironments } from '../../context/environment/hooks'; @@ -32,8 +32,8 @@ export function InboxPreviewContent() { const configuration: InboxProps = { applicationIdentifier: currentEnvironment?.identifier, subscriberId: user?.externalId as string, - backendUrl: API_HOSTNAME ?? 'https://api.novu.co', - socketUrl: WEBSOCKET_HOSTNAME ?? 'https://ws.novu.co', + backendUrl: apiHostnameManager.getHostname(), + socketUrl: apiHostnameManager.getWebSocketHostname(), localization: { 'notifications.emptyNotice': 'Click Send Notification to see your first notification', }, diff --git a/apps/dashboard/src/components/confirmation-modal.tsx b/apps/dashboard/src/components/confirmation-modal.tsx index c985389afe7..0d7a796361d 100644 --- a/apps/dashboard/src/components/confirmation-modal.tsx +++ b/apps/dashboard/src/components/confirmation-modal.tsx @@ -1,6 +1,3 @@ -import { ReactNode } from 'react'; -import { IconType } from 'react-icons'; -import { RiAlertFill } from 'react-icons/ri'; import { Button } from '@/components/primitives/button'; import { Dialog, @@ -12,6 +9,9 @@ import { DialogPortal, DialogTitle, } from '@/components/primitives/dialog'; +import { ReactNode } from 'react'; +import { IconType } from 'react-icons'; +import { RiAlertFill } from 'react-icons/ri'; type ConfirmationModalProps = { open: boolean; diff --git a/apps/dashboard/src/components/header-navigation/header-navigation.tsx b/apps/dashboard/src/components/header-navigation/header-navigation.tsx index ff44b89c496..7d7ada46e60 100644 --- a/apps/dashboard/src/components/header-navigation/header-navigation.tsx +++ b/apps/dashboard/src/components/header-navigation/header-navigation.tsx @@ -1,11 +1,12 @@ -import { EnvironmentTypeEnum, FeatureFlagsKeysEnum, PermissionsEnum } from '@novu/shared'; -import { HTMLAttributes, ReactNode } from 'react'; -import { RiSearchLine } from 'react-icons/ri'; import { useCommandPalette } from '@/components/command-palette/hooks/use-command-palette'; import { InboxButton } from '@/components/inbox-button'; import { UserProfile } from '@/components/user-profile'; +import { RegionSelector } from '@/context/region/region-selector'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { cn } from '@/utils/ui'; +import { EnvironmentTypeEnum, FeatureFlagsKeysEnum, PermissionsEnum } from '@novu/shared'; +import { HTMLAttributes, ReactNode } from 'react'; +import { RiSearchLine } from 'react-icons/ri'; import { IS_ENTERPRISE, IS_SELF_HOSTED } from '../../config'; import { useEnvironment } from '../../context/environment/hooks'; import { useHasPermission } from '../../hooks/use-has-permission'; @@ -54,8 +55,10 @@ export const HeaderNavigation = (props: HeaderNavigationProps) => { )} {!hideBridgeUrl ? : null} {!(IS_SELF_HOSTED && IS_ENTERPRISE) && } -
+
+
+
diff --git a/apps/dashboard/src/components/inbox-button.tsx b/apps/dashboard/src/components/inbox-button.tsx index 1183c6c053e..21259912c98 100644 --- a/apps/dashboard/src/components/inbox-button.tsx +++ b/apps/dashboard/src/components/inbox-button.tsx @@ -1,11 +1,12 @@ -import { useUser } from '@clerk/clerk-react'; -import { Bell, Inbox, InboxContent, useNovu } from '@novu/react'; -import { useEffect, useState } from 'react'; import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '@/components/primitives/popover'; -import { API_HOSTNAME, APP_ID, IS_SELF_HOSTED, WEBSOCKET_HOSTNAME } from '@/config'; +import { APP_ID, IS_SELF_HOSTED } from '@/config'; import { useAuth } from '@/context/auth/hooks'; import { useEnvironment } from '@/context/environment/hooks'; import { useWorkflowEditorPage } from '@/hooks/use-workflow-editor-page'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; +import { useUser } from '@clerk/clerk-react'; +import { Bell, Inbox, InboxContent, useNovu } from '@novu/react'; +import { useEffect, useState } from 'react'; import { HeaderButton } from './header-navigation/header-button'; import { InboxBellFilledDev } from './icons/inbox-bell-filled-dev'; @@ -115,11 +116,15 @@ export const InboxButton = () => { /** * We want to ensure our staging environment is using the production API and WebSocket endpoints. */ - backendUrl={API_HOSTNAME === 'https://api.novu-staging.co' && !isTestPage ? 'https://api.novu.co' : API_HOSTNAME} + backendUrl={ + apiHostnameManager.getHostname() === 'https://api.novu-staging.co' && !isTestPage + ? 'https://api.novu.co' + : apiHostnameManager.getHostname() + } socketUrl={ - WEBSOCKET_HOSTNAME === 'https://socket.novu-staging.co' && !isTestPage + apiHostnameManager.getWebSocketHostname() === 'https://socket.novu-staging.co' && !isTestPage ? 'https://ws.novu.co' - : WEBSOCKET_HOSTNAME + : apiHostnameManager.getWebSocketHostname() } localization={{ 'inbox.filters.labels.default': `Inbox${localizationTestSuffix}`, diff --git a/apps/dashboard/src/components/integrations/components/configuration-group.tsx b/apps/dashboard/src/components/integrations/components/configuration-group.tsx index 323c3c68fa1..cc8da672e1b 100644 --- a/apps/dashboard/src/components/integrations/components/configuration-group.tsx +++ b/apps/dashboard/src/components/integrations/components/configuration-group.tsx @@ -1,3 +1,10 @@ +import { CopyButton } from '@/components/primitives/copy-button'; +import { FormLabel } from '@/components/primitives/form/form'; +import { Input } from '@/components/primitives/input'; +import { LoadingIndicator } from '@/components/primitives/loading-indicator'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; +import { fadeIn } from '@/utils/animation'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { ConfigConfiguration, ConfigConfigurationGroup, @@ -10,13 +17,6 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useRef, useState } from 'react'; import { Control, useWatch } from 'react-hook-form'; import { RiCheckLine, RiCloseLine } from 'react-icons/ri'; -import { CopyButton } from '@/components/primitives/copy-button'; -import { FormLabel } from '@/components/primitives/form/form'; -import { Input } from '@/components/primitives/input'; -import { LoadingIndicator } from '@/components/primitives/loading-indicator'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; -import { fadeIn } from '@/utils/animation'; -import { API_HOSTNAME } from '../../../config'; import { useEnvironment } from '../../../context/environment/hooks'; import { useAutoConfigureIntegration } from '../../../hooks/use-auto-configure-integration'; import { InlineToast } from '../../primitives/inline-toast'; @@ -24,7 +24,7 @@ import { IntegrationFormData } from '../types'; import { CredentialSection } from './credential-section'; function generateInboundWebhookUrl(environmentId: string, integrationId?: string): string { - const baseUrl = API_HOSTNAME ?? 'https://api.novu.co'; + const baseUrl = apiHostnameManager.getHostname(); return `${baseUrl}/v2/inbound-webhooks/delivery-providers/${environmentId}/${integrationId}`; } diff --git a/apps/dashboard/src/components/welcome/ai-prompts/simple-prompt-getter.ts b/apps/dashboard/src/components/welcome/ai-prompts/simple-prompt-getter.ts index 4e69c5fca56..952499634ed 100644 --- a/apps/dashboard/src/components/welcome/ai-prompts/simple-prompt-getter.ts +++ b/apps/dashboard/src/components/welcome/ai-prompts/simple-prompt-getter.ts @@ -1,4 +1,5 @@ -import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '../../../config'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; +import { API_HOSTNAME } from '../../../config'; import { getAngularPromptString, getJavaScriptPromptString, @@ -40,7 +41,7 @@ function getWebSocketUrl(url: string): string { function getRegionConfig(region: 'us' | 'eu'): RegionConfig | null { if (region === 'eu') { return { - socketUrl: getWebSocketUrl(WEBSOCKET_HOSTNAME), + socketUrl: getWebSocketUrl(apiHostnameManager.getWebSocketHostname()), backendUrl: API_HOSTNAME, }; } diff --git a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx index 431ee67c52f..ad3d246edc7 100644 --- a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx +++ b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx @@ -1,5 +1,6 @@ +import { API_HOSTNAME, IS_EU } from '@/config'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { RiAngularjsFill, RiJavascriptFill, RiNextjsFill, RiReactjsFill, RiRemixRunFill } from 'react-icons/ri'; -import { API_HOSTNAME, IS_EU, WEBSOCKET_HOSTNAME } from '@/config'; import { Language } from '../primitives/code-block'; import { getFrameworkPrompt } from './ai-prompts/simple-prompt-getter'; @@ -26,7 +27,8 @@ export interface InstallationStep { } const isDefaultApi = API_HOSTNAME === 'https://api.novu.co'; -const isDefaultWs = WEBSOCKET_HOSTNAME === 'https://ws.novu.co'; +const currentWebSocketHostname = apiHostnameManager.getWebSocketHostname(); +const isDefaultWs = currentWebSocketHostname === 'https://ws.novu.co'; // Convert https:// to wss:// for WebSocket URLs const getWebSocketUrl = (url: string) => { @@ -34,10 +36,10 @@ const getWebSocketUrl = (url: string) => { return url.replace(/^https:\/\//, 'wss://'); }; -const websocketUrl = getWebSocketUrl(WEBSOCKET_HOSTNAME); +const websocketUrl = getWebSocketUrl(currentWebSocketHostname); // Shared helpers to minimize duplication -const cliFlags = `${isDefaultApi && IS_EU ? ' --region=eu' : ''}${!isDefaultApi ? ` --backendUrl ${API_HOSTNAME}` : ''}${!isDefaultWs ? ` --socketUrl ${WEBSOCKET_HOSTNAME}` : ''}`; +const cliFlags = `${isDefaultApi && IS_EU ? ' --region=eu' : ''}${!isDefaultApi ? ` --backendUrl ${API_HOSTNAME}` : ''}${!isDefaultWs ? ` --socketUrl ${currentWebSocketHostname}` : ''}`; function optionalAttrProps(indent: string): string { return `${!isDefaultApi ? `\n${indent}${`backendUrl="${API_HOSTNAME}"`}` : ''}${!isDefaultWs ? `\n${indent}${`socketUrl="${websocketUrl}"`}` : ''}`; diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index bd73c9668ce..51c77f20f3b 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -15,10 +15,15 @@ export const APP_ID = import.meta.env.VITE_NOVU_APP_ID || ''; export const API_HOSTNAME = window._env_?.VITE_API_HOSTNAME || import.meta.env.VITE_API_HOSTNAME; +export const API_HOSTNAME_SG = window._env_?.VITE_API_HOSTNAME_SG || import.meta.env.VITE_API_HOSTNAME_SG; + export const IS_EU = API_HOSTNAME === 'https://eu.api.novu.co'; export const WEBSOCKET_HOSTNAME = window._env_?.VITE_WEBSOCKET_HOSTNAME || import.meta.env.VITE_WEBSOCKET_HOSTNAME; +export const WEBSOCKET_HOSTNAME_SG = + window._env_?.VITE_WEBSOCKET_HOSTNAME_SG || import.meta.env.VITE_WEBSOCKET_HOSTNAME_SG; + export const INTERCOM_APP_ID = import.meta.env.VITE_INTERCOM_APP_ID; export const SEGMENT_KEY = import.meta.env.VITE_SEGMENT_KEY; diff --git a/apps/dashboard/src/context/region/index.ts b/apps/dashboard/src/context/region/index.ts new file mode 100644 index 00000000000..c80a9292744 --- /dev/null +++ b/apps/dashboard/src/context/region/index.ts @@ -0,0 +1,5 @@ +export * from './region-context'; +export { RegionModals } from './region-modals'; +export { RegionSelector } from './region-selector'; +export * from './region-types'; +export * from './region-utils'; diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx new file mode 100644 index 00000000000..551737ea1ae --- /dev/null +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -0,0 +1,308 @@ +import { apiHostnameManager } from '@/utils/api-hostname-manager'; +import { ROUTES } from '@/utils/routes'; +import { useClerk, useOrganization, useOrganizationList } from '@clerk/clerk-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { RegionModals } from './region-modals'; +import { type OrgCreationModalState, type Region, type RegionContextType } from './region-types'; +import { + detectRegionFromOrganization, + findOrganizationForRegion, + getApiHostnameForRegion, + getWebSocketHostnameForRegion, + isInOnboardingFlow, +} from './region-utils'; + +const RegionContext = createContext(undefined); + +export function useRegion() { + const context = useContext(RegionContext); + if (!context) { + throw new Error('useRegion must be used within a RegionProvider'); + } + return context; +} + +interface RegionProviderProps { + children: ReactNode; +} + +export function RegionProvider({ children }: RegionProviderProps) { + const queryClient = useQueryClient(); + const clerk = useClerk(); + const navigate = useNavigate(); + const { organization: currentOrganization } = useOrganization(); + const { userMemberships } = useOrganizationList({ + userMemberships: { infinite: true }, + }); + + const [selectedRegion, setSelectedRegion] = useState(() => { + // Check if we're creating an org for a specific region first + const regionForNewOrg = localStorage.getItem('novu-region-for-new-org'); + if (regionForNewOrg === 'singapore' || regionForNewOrg === 'us') { + return regionForNewOrg as Region; + } + + // Otherwise, check saved preference + const savedRegion = localStorage.getItem('novu-selected-region'); + return savedRegion === 'singapore' || savedRegion === 'us' ? (savedRegion as Region) : 'us'; + }); + + // Flag to prevent conflicts between manual region selection and auto-sync + const [isManualRegionChange, setIsManualRegionChange] = useState(false); + + // Modal state for organization creation confirmation + const [orgCreationModal, setOrgCreationModal] = useState({ + open: false, + targetRegion: 'us', + previousRegion: 'us', + }); + + // Flag to track if we're waiting for user decision on org creation + const [isPendingOrgCreation, setIsPendingOrgCreation] = useState(false); + + const getApiHostname = useCallback(() => getApiHostnameForRegion(selectedRegion), [selectedRegion]); + + const detectRegionFromCurrentOrg = useCallback( + () => detectRegionFromOrganization(currentOrganization), + [currentOrganization] + ); + + const findOrganizationForRegionCallback = useCallback( + (region: Region) => findOrganizationForRegion(region, userMemberships), + [userMemberships.data] + ); + + const handleSetSelectedRegion = async (region: Region) => { + const previousRegion = selectedRegion; + console.log(`Manual region selection: ${previousRegion} β†’ ${region}`); + + // Set flag to prevent auto-sync during manual change + setIsManualRegionChange(true); + setSelectedRegion(region); + + // If we're in organization creation flow, update everything without refresh for better UX + if (isInOnboardingFlow()) { + console.log('In organization creation flow, updating API hostname for new region:', region); + localStorage.setItem('novu-selected-region', region); + localStorage.setItem('novu-region-for-new-org', region); + + // Update API and WebSocket hostnames for org creation + const newApiHostname = getApiHostnameForRegion(region); + const newWebSocketHostname = getWebSocketHostnameForRegion(region); + apiHostnameManager.setApiHostname(newApiHostname); + apiHostnameManager.setWebSocketHostname(newWebSocketHostname); + + // Clear any cached queries to ensure fresh data from new region + queryClient.clear(); + + // Reset flags and let components re-render naturally + setIsManualRegionChange(false); + + console.log('Updated API hostname without refresh for better UX'); + return; + } + + // Dashboard flow - check for organizations and handle accordingly + if (previousRegion !== region) { + // Set region switching flag to block API calls + apiHostnameManager.setRegionSwitching(true); + console.log('Region switching started - blocking API calls'); + + // Clear React Query caches + queryClient.getQueryCache().clear(); + queryClient.getMutationCache().clear(); + + // Update API and WebSocket hostnames first + const newApiHostname = getApiHostnameForRegion(region); + const newWebSocketHostname = getWebSocketHostnameForRegion(region); + apiHostnameManager.setApiHostname(newApiHostname); + apiHostnameManager.setWebSocketHostname(newWebSocketHostname); + + // Find and switch to an organization in the selected region + const targetOrgMembership = findOrganizationForRegionCallback(region); + + if (targetOrgMembership && clerk) { + try { + console.log(`Switching to organization "${targetOrgMembership.organization.name}" for ${region} region`); + + // Update localStorage since we have a valid organization + localStorage.setItem('novu-selected-region', region); + + // Switch to the organization for the selected region + await clerk.setActive({ + organization: targetOrgMembership.organization, + }); + + // Immediate refresh after successful org switch + window.location.reload(); + } catch (error) { + console.error('Failed to switch organization:', error); + // Reset flag on error and revert region + apiHostnameManager.setRegionSwitching(false); + setSelectedRegion(previousRegion); + } + } else { + console.log(`No organization found for region: ${region}, showing creation confirmation`); + + // Set pending flag to prevent any automatic resets + setIsPendingOrgCreation(true); + + // Show modal to confirm organization creation + setOrgCreationModal({ + open: true, + targetRegion: region, + previousRegion: previousRegion, + }); + + // Don't reset manual change flag while modal is open - exit early + return; + } + } + + // Only reset flags if we're not pending org creation decision + if (!isPendingOrgCreation) { + // Reset flag after a delay + setTimeout(() => { + if (!isPendingOrgCreation) { + // Double check in case modal opened during timeout + setIsManualRegionChange(false); + } + }, 2000); + } + }; + + // Auto-sync region when user switches to an organization from different region + useEffect(() => { + if (currentOrganization) { + // Clean up the org creation flag if we successfully have an organization + const regionForNewOrg = localStorage.getItem('novu-region-for-new-org'); + if (regionForNewOrg) { + console.log('Organization creation completed, cleaning up region flag'); + localStorage.removeItem('novu-region-for-new-org'); + + // Reset any pending flags that might interfere with normal operation + setIsManualRegionChange(false); + setIsPendingOrgCreation(false); + apiHostnameManager.setRegionSwitching(false); + } + + // Don't auto-switch regions during onboarding flows + if (isInOnboardingFlow()) { + console.log('In onboarding flow, preserving current region selection:', selectedRegion); + return; + } + + // Don't auto-sync if we're in the middle of a manual region change + if (isManualRegionChange) { + console.log('Manual region change in progress, skipping auto-sync'); + return; + } + + const detectedRegion = detectRegionFromCurrentOrg(); + + // If the selected organization belongs to a different region, auto-switch + if (detectedRegion !== selectedRegion) { + console.log( + `Auto-sync: Organization "${currentOrganization.name}" belongs to ${detectedRegion} region, switching from ${selectedRegion}` + ); + + // Set region switching flag to block API calls + apiHostnameManager.setRegionSwitching(true); + console.log('Auto region switching started - blocking API calls'); + + setSelectedRegion(detectedRegion); + localStorage.setItem('novu-selected-region', detectedRegion); + + // Clear all React Query caches immediately + queryClient.getQueryCache().clear(); + queryClient.getMutationCache().clear(); + + // Update API and WebSocket hostnames immediately + const newApiHostname = getApiHostnameForRegion(detectedRegion); + const newWebSocketHostname = getWebSocketHostnameForRegion(detectedRegion); + apiHostnameManager.setApiHostname(newApiHostname); + apiHostnameManager.setWebSocketHostname(newWebSocketHostname); + + // Immediate refresh to use new region's API + window.location.reload(); + } else { + console.log(`Organization "${currentOrganization.name}" matches current region: ${selectedRegion}`); + } + } + }, [currentOrganization, detectRegionFromCurrentOrg, selectedRegion, isManualRegionChange, queryClient]); + + // Initialize API and WebSocket hostnames on region changes + useEffect(() => { + const apiHostname = getApiHostnameForRegion(selectedRegion); + const webSocketHostname = getWebSocketHostnameForRegion(selectedRegion); + apiHostnameManager.setApiHostname(apiHostname); + apiHostnameManager.setWebSocketHostname(webSocketHostname); + }, [selectedRegion]); + + // Handle organization creation confirmation + const handleConfirmOrgCreation = () => { + console.log(`Confirmed organization creation for region: ${orgCreationModal.targetRegion}`); + + // Store the target region for the creation flow + localStorage.setItem('novu-region-for-new-org', orgCreationModal.targetRegion); + + // Update localStorage since we're proceeding with the new region + localStorage.setItem('novu-selected-region', orgCreationModal.targetRegion); + + // Reset flags and close modal + setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); + setIsPendingOrgCreation(false); + setIsManualRegionChange(false); + + // Navigate to organization creation (API hostname already set to target region) + navigate(ROUTES.SIGNUP_ORGANIZATION_LIST); + }; + + // Handle organization creation cancellation + const handleCancelOrgCreation = () => { + console.log( + `Cancelled organization creation, reverting from ${orgCreationModal.targetRegion} back to ${orgCreationModal.previousRegion}` + ); + + // Revert region and localStorage to previous values + setSelectedRegion(orgCreationModal.previousRegion); + localStorage.setItem('novu-selected-region', orgCreationModal.previousRegion); + + // Update API and WebSocket hostnames back to previous region + const previousApiHostname = getApiHostnameForRegion(orgCreationModal.previousRegion); + const previousWebSocketHostname = getWebSocketHostnameForRegion(orgCreationModal.previousRegion); + apiHostnameManager.setApiHostname(previousApiHostname); + apiHostnameManager.setWebSocketHostname(previousWebSocketHostname); + + // Reset the region switching flag to allow API calls again + apiHostnameManager.setRegionSwitching(false); + + // Close modal and reset all flags + setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); + setIsPendingOrgCreation(false); + setIsManualRegionChange(false); + + console.log(`Reverted to previous region: ${orgCreationModal.previousRegion}`); + }; + + const value: RegionContextType = { + selectedRegion, + setSelectedRegion: handleSetSelectedRegion, + getApiHostname, + }; + + return ( + + {children} + + + + ); +} diff --git a/apps/dashboard/src/context/region/region-modals.tsx b/apps/dashboard/src/context/region/region-modals.tsx new file mode 100644 index 00000000000..2d04e77ec32 --- /dev/null +++ b/apps/dashboard/src/context/region/region-modals.tsx @@ -0,0 +1,32 @@ +import { RiAddLine } from 'react-icons/ri'; +import { ConfirmationModal } from '@/components/confirmation-modal'; +import { type OrgCreationModalState } from './region-types'; + +interface RegionModalsProps { + orgCreationModal: OrgCreationModalState; + onCancelOrgCreation: () => void; + onConfirmOrgCreation: () => void; +} + +export function RegionModals({ orgCreationModal, onCancelOrgCreation, onConfirmOrgCreation }: RegionModalsProps) { + return ( + + No organization was found in the{' '} + {orgCreationModal.targetRegion === 'singapore' ? 'Singapore' : 'US'} region. +
+
+ Would you like to create a new organization in the{' '} + {orgCreationModal.targetRegion === 'singapore' ? 'Singapore' : 'US'} region? + + } + confirmButtonText="Create Organization" + confirmTrailingIcon={RiAddLine} + /> + ); +} diff --git a/apps/dashboard/src/context/region/region-selector.tsx b/apps/dashboard/src/context/region/region-selector.tsx new file mode 100644 index 00000000000..9dd20bf1acd --- /dev/null +++ b/apps/dashboard/src/context/region/region-selector.tsx @@ -0,0 +1,51 @@ +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { Globe } from 'lucide-react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; +import { IS_EU } from '@/config'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { useRegion } from './region-context'; +import { type Region } from './region-types'; + +const REGION_OPTIONS: Array<{ value: Region; label: string; flag: string }> = [ + { value: 'us', label: 'US', flag: 'πŸ‡ΊπŸ‡Έ' }, + { value: 'singapore', label: 'Singapore', flag: 'πŸ‡ΈπŸ‡¬' }, +]; + +export function RegionSelector() { + const { selectedRegion, setSelectedRegion } = useRegion(); + const isRegionSelectorEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_REGION_SELECTOR_ENABLED, false); + + // Check if we're in organization creation flow + const isInOrgCreation = window.location.pathname.includes('/auth/organization-list'); + + // Hide region selector for EU users, but always show during org creation if feature is enabled + if (IS_EU || (!isRegionSelectorEnabled && !isInOrgCreation)) { + return null; + } + + // Match header button proportions - slim and consistent with other header elements + const triggerClassName = isInOrgCreation + ? 'h-8 w-auto min-w-[120px] border border-neutral-200 bg-background text-sm shadow-sm focus:ring-2 focus:ring-ring/20' + : 'h-[26px] w-auto min-w-[100px] border border-neutral-200/50 bg-background text-xs shadow-sm focus:ring-1 focus:ring-ring/20 px-2'; + + return ( + + ); +} diff --git a/apps/dashboard/src/context/region/region-types.ts b/apps/dashboard/src/context/region/region-types.ts new file mode 100644 index 00000000000..c6fd277f360 --- /dev/null +++ b/apps/dashboard/src/context/region/region-types.ts @@ -0,0 +1,27 @@ +export type Region = 'us' | 'singapore'; + +// Type for organization public metadata +export interface OrganizationMetadata { + region?: 'us-east-1' | 'ap-southeast-1'; + externalOrgId?: string; + [key: string]: unknown; +} + +export interface RegionContextType { + selectedRegion: Region; + setSelectedRegion: (region: Region) => void; + getApiHostname: () => string; +} + +// Map UI regions to organization metadata regions +export const REGION_METADATA_MAP = { + us: 'us-east-1', + singapore: 'ap-southeast-1', +} as const; + +// Modal state types +export interface OrgCreationModalState { + open: boolean; + targetRegion: Region; + previousRegion: Region; +} diff --git a/apps/dashboard/src/context/region/region-utils.ts b/apps/dashboard/src/context/region/region-utils.ts new file mode 100644 index 00000000000..5cf02572d1c --- /dev/null +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -0,0 +1,87 @@ +import { API_HOSTNAME, API_HOSTNAME_SG, WEBSOCKET_HOSTNAME, WEBSOCKET_HOSTNAME_SG } from '@/config'; +import { type OrganizationMetadata, REGION_METADATA_MAP, type Region } from './region-types'; + +export function getApiHostnameForRegion(region: Region): string { + switch (region) { + case 'singapore': + return API_HOSTNAME_SG || API_HOSTNAME; + case 'us': + default: + return API_HOSTNAME; + } +} + +export function getWebSocketHostnameForRegion(region: Region): string { + switch (region) { + case 'singapore': + return WEBSOCKET_HOSTNAME_SG || WEBSOCKET_HOSTNAME; + case 'us': + default: + return WEBSOCKET_HOSTNAME; + } +} + +export function detectRegionFromOrganization(organization: any): Region { + if (!organization) return 'us'; + + const orgMetadata = organization.publicMetadata as OrganizationMetadata; + const orgRegion = orgMetadata?.region; + + console.log('Detecting region from current org:', organization.name, 'metadata:', orgMetadata); + + // No region metadata means US (default behavior) + if (!orgRegion) { + console.log('No region metadata found, defaulting to US'); + return 'us'; + } + + // Explicit region mapping + if (orgRegion === 'us-east-1') { + return 'us'; + } + + if (orgRegion === 'ap-southeast-1') { + return 'singapore'; + } + + // Fallback to US for any unknown region + console.log('Unknown region metadata:', orgRegion, 'defaulting to US'); + return 'us'; +} + +export function findOrganizationForRegion(region: Region, userMemberships: any) { + const expectedMetadataRegion = REGION_METADATA_MAP[region]; + + console.log('Looking for organization with region:', expectedMetadataRegion); + console.log( + 'Available organizations:', + userMemberships.data?.map((m: any) => ({ + name: m.organization.name, + metadata: m.organization.publicMetadata, + })) + ); + + const found = userMemberships.data?.find((membership: any) => { + const orgMetadata = membership.organization.publicMetadata as OrganizationMetadata; + const orgRegion = orgMetadata?.region; + + // If no region metadata, assume us-east-1 + if (!orgRegion) { + return expectedMetadataRegion === 'us-east-1'; + } + + return orgRegion === expectedMetadataRegion; + }); + + console.log('Found organization for region:', found?.organization.name); + return found; +} + +export function isInOnboardingFlow(): boolean { + return ( + window.location.pathname.includes('/onboarding') || + window.location.pathname.includes('/inbox-usecase') || + window.location.pathname.includes('/inbox-embed') || + window.location.pathname.includes('/auth/organization-list') + ); +} diff --git a/apps/dashboard/src/pages/api-keys.tsx b/apps/dashboard/src/pages/api-keys.tsx index 1b16fc38779..d31b89faa43 100644 --- a/apps/dashboard/src/pages/api-keys.tsx +++ b/apps/dashboard/src/pages/api-keys.tsx @@ -6,6 +6,8 @@ import { Input } from '@/components/primitives/input'; import { Skeleton } from '@/components/primitives/skeleton'; import { ExternalLink } from '@/components/shared/external-link'; import { useEnvironment } from '@/context/environment/hooks'; +import { useRegion } from '@/context/region'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { PermissionsEnum } from '@novu/shared'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -17,7 +19,7 @@ import { HelpTooltipIndicator } from '../components/primitives/help-tooltip-indi import { showErrorToast, showSuccessToast } from '../components/primitives/sonner-helpers'; import { Tooltip, TooltipContent, TooltipTrigger } from '../components/primitives/tooltip'; import { RegenerateApiKeysDialog } from '../components/regenerate-api-keys-dialog'; -import { API_HOSTNAME, IS_SELF_HOSTED, WEBSOCKET_HOSTNAME } from '../config'; +import { IS_SELF_HOSTED } from '../config'; import { useFetchApiKeys, useRegenerateApiKeys } from '../hooks/use-fetch-api-keys'; import { useHasPermission } from '../hooks/use-has-permission'; @@ -36,6 +38,7 @@ interface ApiKeysFormData { export function ApiKeysPage() { const apiKeysQuery = useFetchApiKeys(); const { currentEnvironment } = useEnvironment(); + const { selectedRegion } = useRegion(); const apiKeys = apiKeysQuery.data?.data; const isLoading = apiKeysQuery.isLoading; const [isRegenerateDialogOpen, setIsRegenerateDialogOpen] = useState(false); @@ -66,7 +69,8 @@ export function ApiKeysPage() { return null; } - const region = window.location.hostname.includes('eu') ? 'EU' : 'US'; + // Use dynamic region from region selector + const region = selectedRegion === 'singapore' ? 'Singapore' : 'US'; return ( <> @@ -125,10 +129,9 @@ export function ApiKeysPage() { API URLs

- {IS_SELF_HOSTED + {IS_SELF_HOSTED ? 'API and WebSocket endpoints for your self-hosted Novu instance. ' - : `API and WebSocket URLs for Novu Cloud in the ${region} region. ` - } + : `API and WebSocket URLs for Novu Cloud in the ${region} region. `} Learn more @@ -138,19 +141,19 @@ export function ApiKeysPage() {

diff --git a/apps/dashboard/src/routes/root.tsx b/apps/dashboard/src/routes/root.tsx index 0ff71ed8101..3077a785f34 100644 --- a/apps/dashboard/src/routes/root.tsx +++ b/apps/dashboard/src/routes/root.tsx @@ -6,13 +6,13 @@ import { AuthProvider } from '@/context/auth/auth-provider'; import { ClerkProvider } from '@/context/clerk-provider'; import { EscapeKeyManagerProvider } from '@/context/escape-key-manager/escape-key-manager'; import { IdentityProvider } from '@/context/identity-provider'; +import { RegionProvider } from '@/context/region'; import { SegmentProvider } from '@/context/segment'; import { ErrorBoundary, withProfiler } from '@sentry/react'; import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { HelmetProvider } from 'react-helmet-async'; import { Outlet } from 'react-router-dom'; - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -68,13 +68,15 @@ const RootRouteInternal = () => { - - - - - - - + + + + + + + + + diff --git a/apps/dashboard/src/utils/api-hostname-manager.ts b/apps/dashboard/src/utils/api-hostname-manager.ts new file mode 100644 index 00000000000..735fd9f8bdb --- /dev/null +++ b/apps/dashboard/src/utils/api-hostname-manager.ts @@ -0,0 +1,49 @@ +import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '@/config'; + +// Global hostname manager for both API and WebSocket endpoints +class HostnameManager { + private currentApiHostname: string; + private currentWebSocketHostname: string; + private isRegionSwitching: boolean = false; + + constructor() { + // Initialize with US hostnames (default) + this.currentApiHostname = API_HOSTNAME ?? 'https://api.novu.co'; + this.currentWebSocketHostname = WEBSOCKET_HOSTNAME ?? 'https://ws.novu.co'; + } + + setApiHostname(hostname: string) { + this.currentApiHostname = hostname; + } + + getApiHostname(): string { + return this.currentApiHostname; + } + + setWebSocketHostname(hostname: string) { + this.currentWebSocketHostname = hostname; + } + + getWebSocketHostname(): string { + return this.currentWebSocketHostname; + } + + // Convenience methods for backward compatibility + setHostname(hostname: string) { + this.setApiHostname(hostname); + } + + getHostname(): string { + return this.getApiHostname(); + } + + setRegionSwitching(switching: boolean) { + this.isRegionSwitching = switching; + } + + isCurrentlyRegionSwitching(): boolean { + return this.isRegionSwitching; + } +} + +export const apiHostnameManager = new HostnameManager(); diff --git a/apps/dashboard/src/utils/code-snippets.ts b/apps/dashboard/src/utils/code-snippets.ts index f6739f44a08..1000c795b53 100644 --- a/apps/dashboard/src/utils/code-snippets.ts +++ b/apps/dashboard/src/utils/code-snippets.ts @@ -1,4 +1,5 @@ import { API_HOSTNAME, IS_EU, IS_SELF_HOSTED } from '@/config'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; export type CodeSnippet = { identifier: string; @@ -98,7 +99,7 @@ export const generateTriggerCurlCommand = ({ to, payload, apiKey, - baseUrl = API_HOSTNAME ?? 'https://api.novu.co', + baseUrl = apiHostnameManager.getHostname(), addDashboardSource = true, }: TriggerCurlCommandOptions) => { const body = createTriggerRequestBody({ workflowId, to, payload, addDashboardSource }); @@ -123,7 +124,7 @@ export const generatePostmanCollection = ({ to, payload, apiKey, - baseUrl = API_HOSTNAME ?? 'https://api.novu.co', + baseUrl = apiHostnameManager.getHostname(), addDashboardSource = true, }: PostmanCollectionOptions) => { const body = createTriggerRequestBody({ workflowId, to, payload, addDashboardSource }); diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 346e76b891b..1634ef67516 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -71,6 +71,7 @@ export enum FeatureFlagsKeysEnum { IS_SUBSCRIBERS_SCHEDULE_ENABLED = 'IS_SUBSCRIBERS_SCHEDULE_ENABLED', IS_GET_PREFERENCES_DISABLED = 'IS_GET_PREFERENCES_DISABLED', IS_THROTTLE_STEP_ENABLED = 'IS_THROTTLE_STEP_ENABLED', + IS_REGION_SELECTOR_ENABLED = 'IS_REGION_SELECTOR_ENABLED', // Numeric flags MAX_WORKFLOW_LIMIT_NUMBER = 'MAX_WORKFLOW_LIMIT_NUMBER', From 325357a80b97b9be7da7a04dee9640cd1c9138c7 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Mon, 29 Sep 2025 14:22:28 +0530 Subject: [PATCH 02/27] chore: update subproject commit reference in .source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 482b66950ee..64aee0f0dec 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 482b66950ee33295c4629e07f575dada28fa01fd +Subproject commit 64aee0f0dec5639ac0a17195fb64179de459001a From df8a22aee7a5f71c8a205c108451b3fcc6605c64 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 00:32:29 +0530 Subject: [PATCH 03/27] chore: update ENTRYPOINT in Dockerfiles to use dynamic SECRET_NAME variable --- apps/api/Dockerfile | 2 +- apps/worker/Dockerfile | 2 +- apps/ws/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 9607ac87842..329a057c40a 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -69,4 +69,4 @@ RUN --mount=type=cache,id=pnpm-store-api,target=/root/.pnpm-store\ ENV NEW_RELIC_NO_CONFIG_FILE=true WORKDIR /usr/src/app/apps/api -ENTRYPOINT [ "sh", "-c", "node dist/dotenvcreate.mjs -s=novu/api -r=$NOVU_REGION -e=$NOVU_ENTERPRISE -v=$NODE_ENV -h=$IS_SELF_HOSTED && pm2-runtime start dist/main.js -i max" ] +ENTRYPOINT [ "sh", "-c", "node dist/dotenvcreate.mjs -s=$SECRET_NAME -r=$NOVU_REGION -e=$NOVU_ENTERPRISE -v=$NODE_ENV -h=$IS_SELF_HOSTED && pm2-runtime start dist/main.js -i max" ] diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index c25ab04ad06..c025a775725 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -72,4 +72,4 @@ RUN --mount=type=cache,id=pnpm-store-worker,target=/root/.pnpm-store\ ENV NEW_RELIC_NO_CONFIG_FILE=true WORKDIR /usr/src/app/apps/worker -ENTRYPOINT [ "sh", "-c", "node dist/dotenvcreate.mjs -s=novu/worker -r=$NOVU_REGION -e=$NOVU_ENTERPRISE -v=$NODE_ENV -h=$IS_SELF_HOSTED && pm2-runtime start dist/main.js -i max" ] +ENTRYPOINT [ "sh", "-c", "node dist/dotenvcreate.mjs -s=$SECRET_NAME -r=$NOVU_REGION -e=$NOVU_ENTERPRISE -v=$NODE_ENV -h=$IS_SELF_HOSTED && pm2-runtime start dist/main.js -i max" ] diff --git a/apps/ws/Dockerfile b/apps/ws/Dockerfile index 37acf93d803..8a749669962 100644 --- a/apps/ws/Dockerfile +++ b/apps/ws/Dockerfile @@ -44,4 +44,4 @@ RUN cp src/.env.test dist/.env.test RUN cp src/.env.development dist/.env.development RUN cp src/.env.production dist/.env.production -ENTRYPOINT [ "sh", "-c", "node dist/dotenvcreate.mjs -s=novu/ws -r=$NOVU_REGION -e=$NOVU_ENTERPRISE -v=$NODE_ENV -h=$IS_SELF_HOSTED && pm2-runtime start dist/main.js -i max" ] +ENTRYPOINT [ "sh", "-c", "node dist/dotenvcreate.mjs -s=$SECRET_NAME -r=$NOVU_REGION -e=$NOVU_ENTERPRISE -v=$NODE_ENV -h=$IS_SELF_HOSTED && pm2-runtime start dist/main.js -i max" ] From 4874ab3888e4cd0ee17b7e050f7fece4f56f0d96 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 02:28:18 +0530 Subject: [PATCH 04/27] chore: update deploy.yml to enhance environment options and standardize descriptions --- .github/workflows/deploy.yml | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ecd466bb484..0c2519a5f6b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,33 +19,34 @@ on: workflow_dispatch: inputs: environment: - description: 'Environment to deploy to' + description: "Environment to deploy to" required: true type: choice default: staging options: - staging + - staging-apse1 - production-us - production-eu - production-both deploy_api: - description: 'Deploy API' + description: "Deploy API" required: true type: boolean default: true deploy_worker: - description: 'Deploy Worker' + description: "Deploy Worker" required: true type: boolean default: false deploy_ws: - description: 'Deploy WS' + description: "Deploy WS" required: true type: boolean default: false deploy_webhook: - description: 'Deploy Webhook' + description: "Deploy Webhook" required: true type: boolean default: false @@ -84,6 +85,9 @@ jobs: if [ "${{ github.event.inputs.environment }}" == "staging" ]; then envs+=("\"staging-eu\"") fi + if [ "${{ github.event.inputs.environment }}" == "staging-apse1" ]; then + envs+=("\"staging-apse1\"") + fi if [ "${{ github.event.inputs.environment }}" == "production-us" ]; then envs+=("\"prod-us\"") fi @@ -192,8 +196,8 @@ jobs: - name: Setup Node Version uses: actions/setup-node@v4 with: - node-version: '20.19.0' - cache: 'pnpm' + node-version: "20.19.0" + cache: "pnpm" - name: Install Dependencies shell: bash @@ -202,7 +206,7 @@ jobs: - name: Set Up Docker Buildx uses: docker/setup-buildx-action@v3 with: - driver-opts: 'image=moby/buildkit:v0.13.1' + driver-opts: "image=moby/buildkit:v0.13.1" - name: Prepare Variables run: echo "BULL_MQ_PRO_NPM_TOKEN=${{ secrets.BULL_MQ_PRO_NPM_TOKEN }}" >> $GITHUB_ENV @@ -303,7 +307,7 @@ jobs: SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ matrix.service }} with: - version: '${{ github.sha }}' + version: "${{ github.sha }}" version_prefix: v environment: ${{vars.SENTRY_ENV}} ignore_empty: true @@ -326,9 +330,9 @@ jobs: region: EU apiKey: ${{ secrets.NEW_RELIC_API_KEY }} guid: ${{ matrix.nr == 'api' && secrets.NEW_RELIC_API_GUID || matrix.nr == 'worker' && secrets.NEW_RELIC_Worker_GUID }} - version: '${{ github.sha }}' - user: '${{ github.actor }}' - description: 'Novu Cloud Deployment' + version: "${{ github.sha }}" + user: "${{ github.actor }}" + description: "Novu Cloud Deployment" sync_novu_state: needs: [deploy, prepare-matrix] @@ -350,7 +354,6 @@ jobs: needs.deploy.result == 'success' && (contains(github.event.inputs.environment, 'production')) environment: ${{ fromJson(needs.prepare-matrix.outputs.env_matrix).environment[0] }} - steps: - name: Send webhook notification for US production if: | @@ -361,7 +364,7 @@ jobs: -H "Content-Type: application/json" \ -H "x-api-key: ${{ secrets.BUG0_SECRET_KEY }}" \ -d '{"url": "https://dashboard.novu.co", "source": "novuhq-novu", "prod": "true"}' - + - name: Send webhook notification for EU production if: | github.event.inputs.environment == 'production-eu' || From 3084c68f20a07b00ad16e22348629d84d0e3874e Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 14:48:23 +0530 Subject: [PATCH 05/27] feat(region): enhance region detection and management - Added URL-based region detection to replace localStorage approach for improved reliability. - Introduced new constants for dashboard URLs specific to regions. - Updated `RegionProvider` to utilize URL detection for initial region selection and organization switching. - Enhanced `findOrganizationForRegion` and `getDashboardUrlForRegion` functions to support new detection logic. - Improved logging for better debugging and user feedback during region changes. --- apps/dashboard/src/config/index.ts | 4 + .../src/context/region/region-context.tsx | 264 +++++++----------- .../src/context/region/region-utils.ts | 75 ++++- 3 files changed, 178 insertions(+), 165 deletions(-) diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index 51c77f20f3b..e97ec81a14f 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -33,6 +33,10 @@ export const MIXPANEL_KEY = import.meta.env.VITE_MIXPANEL_KEY; export const LEGACY_DASHBOARD_URL = window._env_?.VITE_LEGACY_DASHBOARD_URL || import.meta.env.VITE_LEGACY_DASHBOARD_URL; +export const DASHBOARD_URL = window._env_?.VITE_DASHBOARD_URL || import.meta.env.VITE_DASHBOARD_URL; + +export const DASHBOARD_URL_SG = window._env_?.VITE_DASHBOARD_URL_SG || import.meta.env.VITE_DASHBOARD_URL_SG; + export const PLAIN_SUPPORT_CHAT_APP_ID = import.meta.env.VITE_PLAIN_SUPPORT_CHAT_APP_ID; export const ONBOARDING_DEMO_WORKFLOW_ID = 'onboarding-demo-workflow'; diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index 551737ea1ae..3f1448a3f02 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -9,8 +9,10 @@ import { RegionModals } from './region-modals'; import { type OrgCreationModalState, type Region, type RegionContextType } from './region-types'; import { detectRegionFromOrganization, + detectRegionFromURL, findOrganizationForRegion, getApiHostnameForRegion, + getDashboardUrlForRegion, getWebSocketHostnameForRegion, isInOnboardingFlow, } from './region-utils'; @@ -38,21 +40,13 @@ export function RegionProvider({ children }: RegionProviderProps) { userMemberships: { infinite: true }, }); + // Initialize region based on URL instead of localStorage const [selectedRegion, setSelectedRegion] = useState(() => { - // Check if we're creating an org for a specific region first - const regionForNewOrg = localStorage.getItem('novu-region-for-new-org'); - if (regionForNewOrg === 'singapore' || regionForNewOrg === 'us') { - return regionForNewOrg as Region; - } - - // Otherwise, check saved preference - const savedRegion = localStorage.getItem('novu-selected-region'); - return savedRegion === 'singapore' || savedRegion === 'us' ? (savedRegion as Region) : 'us'; + const urlBasedRegion = detectRegionFromURL(); + console.log('Initial region detection from URL:', urlBasedRegion); + return urlBasedRegion; }); - // Flag to prevent conflicts between manual region selection and auto-sync - const [isManualRegionChange, setIsManualRegionChange] = useState(false); - // Modal state for organization creation confirmation const [orgCreationModal, setOrgCreationModal] = useState({ open: false, @@ -60,9 +54,6 @@ export function RegionProvider({ children }: RegionProviderProps) { previousRegion: 'us', }); - // Flag to track if we're waiting for user decision on org creation - const [isPendingOrgCreation, setIsPendingOrgCreation] = useState(false); - const getApiHostname = useCallback(() => getApiHostnameForRegion(selectedRegion), [selectedRegion]); const detectRegionFromCurrentOrg = useCallback( @@ -72,22 +63,23 @@ export function RegionProvider({ children }: RegionProviderProps) { const findOrganizationForRegionCallback = useCallback( (region: Region) => findOrganizationForRegion(region, userMemberships), - [userMemberships.data] + [userMemberships] ); const handleSetSelectedRegion = async (region: Region) => { const previousRegion = selectedRegion; console.log(`Manual region selection: ${previousRegion} β†’ ${region}`); - // Set flag to prevent auto-sync during manual change - setIsManualRegionChange(true); + if (previousRegion === region) { + console.log('Same region selected, no action needed'); + return; + } + setSelectedRegion(region); - // If we're in organization creation flow, update everything without refresh for better UX + // If we're in organization creation flow, update API hostname without redirect if (isInOnboardingFlow()) { console.log('In organization creation flow, updating API hostname for new region:', region); - localStorage.setItem('novu-selected-region', region); - localStorage.setItem('novu-region-for-new-org', region); // Update API and WebSocket hostnames for org creation const newApiHostname = getApiHostnameForRegion(region); @@ -98,141 +90,101 @@ export function RegionProvider({ children }: RegionProviderProps) { // Clear any cached queries to ensure fresh data from new region queryClient.clear(); - // Reset flags and let components re-render naturally - setIsManualRegionChange(false); - - console.log('Updated API hostname without refresh for better UX'); + console.log('Updated API hostname for onboarding flow without redirect'); return; } - // Dashboard flow - check for organizations and handle accordingly - if (previousRegion !== region) { - // Set region switching flag to block API calls - apiHostnameManager.setRegionSwitching(true); - console.log('Region switching started - blocking API calls'); + // For region switching in dashboard - redirect to the appropriate dashboard URL + const targetDashboardUrl = getDashboardUrlForRegion(region); + const currentPath = window.location.pathname + window.location.search + window.location.hash; - // Clear React Query caches - queryClient.getQueryCache().clear(); - queryClient.getMutationCache().clear(); + // Find and switch to an organization in the target region + const targetOrgMembership = findOrganizationForRegionCallback(region); - // Update API and WebSocket hostnames first - const newApiHostname = getApiHostnameForRegion(region); - const newWebSocketHostname = getWebSocketHostnameForRegion(region); - apiHostnameManager.setApiHostname(newApiHostname); - apiHostnameManager.setWebSocketHostname(newWebSocketHostname); - - // Find and switch to an organization in the selected region - const targetOrgMembership = findOrganizationForRegionCallback(region); + if (targetOrgMembership && clerk) { + try { + console.log(`Switching to organization "${targetOrgMembership.organization.name}" for ${region} region`); - if (targetOrgMembership && clerk) { - try { - console.log(`Switching to organization "${targetOrgMembership.organization.name}" for ${region} region`); - - // Update localStorage since we have a valid organization - localStorage.setItem('novu-selected-region', region); + // Switch to the organization for the selected region + await clerk.setActive({ + organization: targetOrgMembership.organization, + }); - // Switch to the organization for the selected region - await clerk.setActive({ - organization: targetOrgMembership.organization, - }); + // Redirect to the correct dashboard URL for the target region + const newUrl = `${targetDashboardUrl}${currentPath}`; + console.log('Redirecting to:', newUrl); - // Immediate refresh after successful org switch + if (targetDashboardUrl !== window.location.origin) { + window.location.href = newUrl; + } else { + // Same dashboard URL - just refresh to update the region window.location.reload(); - } catch (error) { - console.error('Failed to switch organization:', error); - // Reset flag on error and revert region - apiHostnameManager.setRegionSwitching(false); - setSelectedRegion(previousRegion); } - } else { - console.log(`No organization found for region: ${region}, showing creation confirmation`); - - // Set pending flag to prevent any automatic resets - setIsPendingOrgCreation(true); - - // Show modal to confirm organization creation - setOrgCreationModal({ - open: true, - targetRegion: region, - previousRegion: previousRegion, - }); - - // Don't reset manual change flag while modal is open - exit early - return; + } catch (error) { + console.error('Failed to switch organization:', error); + // Revert region on error + setSelectedRegion(previousRegion); } - } - - // Only reset flags if we're not pending org creation decision - if (!isPendingOrgCreation) { - // Reset flag after a delay - setTimeout(() => { - if (!isPendingOrgCreation) { - // Double check in case modal opened during timeout - setIsManualRegionChange(false); - } - }, 2000); + } else { + console.log(`No organization found for region: ${region}, showing creation confirmation`); + + // Show modal to confirm organization creation + setOrgCreationModal({ + open: true, + targetRegion: region, + previousRegion: previousRegion, + }); } }; // Auto-sync region when user switches to an organization from different region useEffect(() => { - if (currentOrganization) { - // Clean up the org creation flag if we successfully have an organization - const regionForNewOrg = localStorage.getItem('novu-region-for-new-org'); - if (regionForNewOrg) { - console.log('Organization creation completed, cleaning up region flag'); - localStorage.removeItem('novu-region-for-new-org'); - - // Reset any pending flags that might interfere with normal operation - setIsManualRegionChange(false); - setIsPendingOrgCreation(false); - apiHostnameManager.setRegionSwitching(false); - } - - // Don't auto-switch regions during onboarding flows - if (isInOnboardingFlow()) { - console.log('In onboarding flow, preserving current region selection:', selectedRegion); - return; - } - - // Don't auto-sync if we're in the middle of a manual region change - if (isManualRegionChange) { - console.log('Manual region change in progress, skipping auto-sync'); - return; - } - + if (currentOrganization && !isInOnboardingFlow()) { const detectedRegion = detectRegionFromCurrentOrg(); - - // If the selected organization belongs to a different region, auto-switch - if (detectedRegion !== selectedRegion) { - console.log( - `Auto-sync: Organization "${currentOrganization.name}" belongs to ${detectedRegion} region, switching from ${selectedRegion}` - ); - - // Set region switching flag to block API calls - apiHostnameManager.setRegionSwitching(true); - console.log('Auto region switching started - blocking API calls'); - + const urlRegion = detectRegionFromURL(); + + console.log('Region detection:', { + fromOrg: detectedRegion, + fromURL: urlRegion, + selected: selectedRegion, + orgName: currentOrganization.name, + }); + + // If the URL indicates we should be in a different region than the organization, + // it means we need to find and switch to an organization in the URL's region + if (urlRegion !== detectedRegion) { + console.log(`URL region (${urlRegion}) doesn't match organization region (${detectedRegion})`); + + const targetOrgMembership = findOrganizationForRegionCallback(urlRegion); + + if (targetOrgMembership && clerk) { + console.log( + `Switching to organization "${targetOrgMembership.organization.name}" for URL region: ${urlRegion}` + ); + + clerk + .setActive({ + organization: targetOrgMembership.organization, + }) + .then(() => { + // Update selected region to match URL + setSelectedRegion(urlRegion); + }) + .catch((error) => { + console.error('Failed to auto-switch organization for URL region:', error); + }); + } else if (targetOrgMembership === undefined) { + console.log(`No organization found for URL region: ${urlRegion}, staying with current organization`); + // Update the selected region to match the current organization since we can't switch + setSelectedRegion(detectedRegion); + } + } else if (selectedRegion !== detectedRegion) { + // URL and organization match, but our selected region is wrong - update it + console.log(`Updating selected region from ${selectedRegion} to ${detectedRegion} to match organization`); setSelectedRegion(detectedRegion); - localStorage.setItem('novu-selected-region', detectedRegion); - - // Clear all React Query caches immediately - queryClient.getQueryCache().clear(); - queryClient.getMutationCache().clear(); - - // Update API and WebSocket hostnames immediately - const newApiHostname = getApiHostnameForRegion(detectedRegion); - const newWebSocketHostname = getWebSocketHostnameForRegion(detectedRegion); - apiHostnameManager.setApiHostname(newApiHostname); - apiHostnameManager.setWebSocketHostname(newWebSocketHostname); - - // Immediate refresh to use new region's API - window.location.reload(); - } else { - console.log(`Organization "${currentOrganization.name}" matches current region: ${selectedRegion}`); } } - }, [currentOrganization, detectRegionFromCurrentOrg, selectedRegion, isManualRegionChange, queryClient]); + }, [currentOrganization, detectRegionFromCurrentOrg, selectedRegion, findOrganizationForRegionCallback, clerk]); // Initialize API and WebSocket hostnames on region changes useEffect(() => { @@ -240,25 +192,29 @@ export function RegionProvider({ children }: RegionProviderProps) { const webSocketHostname = getWebSocketHostnameForRegion(selectedRegion); apiHostnameManager.setApiHostname(apiHostname); apiHostnameManager.setWebSocketHostname(webSocketHostname); + + console.log('Updated API hostname for region:', selectedRegion, apiHostname); }, [selectedRegion]); // Handle organization creation confirmation const handleConfirmOrgCreation = () => { console.log(`Confirmed organization creation for region: ${orgCreationModal.targetRegion}`); - // Store the target region for the creation flow - localStorage.setItem('novu-region-for-new-org', orgCreationModal.targetRegion); + // Close modal + setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); - // Update localStorage since we're proceeding with the new region - localStorage.setItem('novu-selected-region', orgCreationModal.targetRegion); + // Redirect to the correct dashboard URL for organization creation + const targetDashboardUrl = getDashboardUrlForRegion(orgCreationModal.targetRegion); + const orgCreationPath = ROUTES.SIGNUP_ORGANIZATION_LIST; + const newUrl = `${targetDashboardUrl}${orgCreationPath}`; - // Reset flags and close modal - setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); - setIsPendingOrgCreation(false); - setIsManualRegionChange(false); + console.log('Redirecting to organization creation:', newUrl); - // Navigate to organization creation (API hostname already set to target region) - navigate(ROUTES.SIGNUP_ORGANIZATION_LIST); + if (targetDashboardUrl !== window.location.origin) { + window.location.href = newUrl; + } else { + navigate(orgCreationPath); + } }; // Handle organization creation cancellation @@ -267,23 +223,11 @@ export function RegionProvider({ children }: RegionProviderProps) { `Cancelled organization creation, reverting from ${orgCreationModal.targetRegion} back to ${orgCreationModal.previousRegion}` ); - // Revert region and localStorage to previous values + // Revert region setSelectedRegion(orgCreationModal.previousRegion); - localStorage.setItem('novu-selected-region', orgCreationModal.previousRegion); - - // Update API and WebSocket hostnames back to previous region - const previousApiHostname = getApiHostnameForRegion(orgCreationModal.previousRegion); - const previousWebSocketHostname = getWebSocketHostnameForRegion(orgCreationModal.previousRegion); - apiHostnameManager.setApiHostname(previousApiHostname); - apiHostnameManager.setWebSocketHostname(previousWebSocketHostname); - - // Reset the region switching flag to allow API calls again - apiHostnameManager.setRegionSwitching(false); - // Close modal and reset all flags + // Close modal setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); - setIsPendingOrgCreation(false); - setIsManualRegionChange(false); console.log(`Reverted to previous region: ${orgCreationModal.previousRegion}`); }; diff --git a/apps/dashboard/src/context/region/region-utils.ts b/apps/dashboard/src/context/region/region-utils.ts index 5cf02572d1c..0d2aeddc503 100644 --- a/apps/dashboard/src/context/region/region-utils.ts +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -1,4 +1,12 @@ -import { API_HOSTNAME, API_HOSTNAME_SG, WEBSOCKET_HOSTNAME, WEBSOCKET_HOSTNAME_SG } from '@/config'; +import { + API_HOSTNAME, + API_HOSTNAME_SG, + DASHBOARD_URL, + DASHBOARD_URL_SG, + WEBSOCKET_HOSTNAME, + WEBSOCKET_HOSTNAME_SG, +} from '@/config'; +import { type OrganizationMembershipResource, type OrganizationResource } from '@clerk/types'; import { type OrganizationMetadata, REGION_METADATA_MAP, type Region } from './region-types'; export function getApiHostnameForRegion(region: Region): string { @@ -21,7 +29,7 @@ export function getWebSocketHostnameForRegion(region: Region): string { } } -export function detectRegionFromOrganization(organization: any): Region { +export function detectRegionFromOrganization(organization: OrganizationResource | null | undefined): Region { if (!organization) return 'us'; const orgMetadata = organization.publicMetadata as OrganizationMetadata; @@ -49,19 +57,22 @@ export function detectRegionFromOrganization(organization: any): Region { return 'us'; } -export function findOrganizationForRegion(region: Region, userMemberships: any) { +export function findOrganizationForRegion( + region: Region, + userMemberships: { data?: OrganizationMembershipResource[] } +) { const expectedMetadataRegion = REGION_METADATA_MAP[region]; console.log('Looking for organization with region:', expectedMetadataRegion); console.log( 'Available organizations:', - userMemberships.data?.map((m: any) => ({ + userMemberships.data?.map((m) => ({ name: m.organization.name, metadata: m.organization.publicMetadata, })) ); - const found = userMemberships.data?.find((membership: any) => { + const found = userMemberships.data?.find((membership) => { const orgMetadata = membership.organization.publicMetadata as OrganizationMetadata; const orgRegion = orgMetadata?.region; @@ -85,3 +96,57 @@ export function isInOnboardingFlow(): boolean { window.location.pathname.includes('/auth/organization-list') ); } + +/** + * Detects the current region based on the dashboard URL + * This replaces the localStorage-based approach with a more reliable URL-based detection + */ +export function detectRegionFromURL(): Region { + const currentOrigin = window.location.origin; + + console.log('Detecting region from URL:', currentOrigin); + console.log('DASHBOARD_URL:', DASHBOARD_URL); + console.log('DASHBOARD_URL_SG:', DASHBOARD_URL_SG); + + // If we have specific dashboard URLs configured, use them for detection + if (DASHBOARD_URL_SG && DASHBOARD_URL) { + // Normalize URLs for comparison (remove trailing slashes) + const normalizeUrl = (url: string) => url.replace(/\/$/, ''); + const currentNormalized = normalizeUrl(currentOrigin); + const sgNormalized = normalizeUrl(DASHBOARD_URL_SG); + const usNormalized = normalizeUrl(DASHBOARD_URL); + + if (currentNormalized === sgNormalized) { + console.log('Detected Singapore region from URL match'); + return 'singapore'; + } + + if (currentNormalized === usNormalized) { + console.log('Detected US region from URL match'); + return 'us'; + } + } + + // Fallback: detect based on domain patterns + if (currentOrigin.includes('sg.') || currentOrigin.includes('singapore.') || currentOrigin.includes('asia.')) { + console.log('Detected Singapore region from domain pattern'); + return 'singapore'; + } + + // Default to US region + console.log('Defaulting to US region'); + return 'us'; +} + +/** + * Gets the dashboard URL for a specific region + */ +export function getDashboardUrlForRegion(region: Region): string { + switch (region) { + case 'singapore': + return DASHBOARD_URL_SG || DASHBOARD_URL || window.location.origin; + case 'us': + default: + return DASHBOARD_URL || window.location.origin; + } +} From 1304c26cd2a5309037d820cb7d42b866d4afddc1 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 14:55:42 +0530 Subject: [PATCH 06/27] refactor(region): streamline region redirection logic in RegionProvider - Updated region handling to redirect users to the correct dashboard URL if the URL region does not match the organization's region. - Improved logging for clarity on region mismatches and redirection actions. - Simplified the logic for updating the selected region state when the URL and organization match. --- .../src/context/region/region-context.tsx | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index 3f1448a3f02..bdb60230a13 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -150,36 +150,30 @@ export function RegionProvider({ children }: RegionProviderProps) { orgName: currentOrganization.name, }); - // If the URL indicates we should be in a different region than the organization, - // it means we need to find and switch to an organization in the URL's region + // If the URL region doesn't match the organization region, + // redirect to the correct dashboard URL for the organization's region if (urlRegion !== detectedRegion) { - console.log(`URL region (${urlRegion}) doesn't match organization region (${detectedRegion})`); - - const targetOrgMembership = findOrganizationForRegionCallback(urlRegion); - - if (targetOrgMembership && clerk) { - console.log( - `Switching to organization "${targetOrgMembership.organization.name}" for URL region: ${urlRegion}` - ); - - clerk - .setActive({ - organization: targetOrgMembership.organization, - }) - .then(() => { - // Update selected region to match URL - setSelectedRegion(urlRegion); - }) - .catch((error) => { - console.error('Failed to auto-switch organization for URL region:', error); - }); - } else if (targetOrgMembership === undefined) { - console.log(`No organization found for URL region: ${urlRegion}, staying with current organization`); - // Update the selected region to match the current organization since we can't switch + console.log( + `URL region (${urlRegion}) doesn't match organization region (${detectedRegion}) for org "${currentOrganization.name}"` + ); + console.log(`Redirecting to ${detectedRegion} dashboard URL for current organization`); + + // Redirect to the correct dashboard URL for the organization's region + const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + const newUrl = `${correctDashboardUrl}${currentPath}`; + + console.log('Redirecting to correct region dashboard:', newUrl); + + if (correctDashboardUrl !== window.location.origin) { + // Different dashboard URL - redirect + window.location.href = newUrl; + } else { + // Same dashboard URL but wrong region state - just update the region setSelectedRegion(detectedRegion); } } else if (selectedRegion !== detectedRegion) { - // URL and organization match, but our selected region is wrong - update it + // URL and organization match, but our selected region state is wrong - update it console.log(`Updating selected region from ${selectedRegion} to ${detectedRegion} to match organization`); setSelectedRegion(detectedRegion); } From c2112d7cf483e517de91c9f8d767f58bbd5cbb63 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 15:10:24 +0530 Subject: [PATCH 07/27] refactor(auth, region): remove console logs and streamline organization creation flow - Eliminated unnecessary console logs in `create-organization.tsx` and `region-context.tsx` for cleaner code and improved performance. - Updated organization creation flow to focus on telemetry tracking without logging sensitive information. - Enhanced region handling logic to ensure consistent URL redirection during organization creation and selection. --- .../components/auth/create-organization.tsx | 6 +- .../src/context/region/region-context.tsx | 103 ++++++++---------- .../src/context/region/region-utils.ts | 22 ---- 3 files changed, 46 insertions(+), 85 deletions(-) diff --git a/apps/dashboard/src/components/auth/create-organization.tsx b/apps/dashboard/src/components/auth/create-organization.tsx index 6ed604f14f5..54bc87998a7 100644 --- a/apps/dashboard/src/components/auth/create-organization.tsx +++ b/apps/dashboard/src/components/auth/create-organization.tsx @@ -72,7 +72,6 @@ function OrganizationForm() { if (isOnFormPage !== showRegionSelector) { setShowRegionSelector(isOnFormPage); - console.log(`Organization form page visibility changed: ${isOnFormPage ? 'Page 2 (Form)' : 'Page 1 (List)'}`); } }); @@ -165,11 +164,8 @@ export default function OrganizationCreate() { // Set the region metadata for the newly created organization const regionMetadata = selectedRegion === 'singapore' ? 'ap-southeast-1' : 'us-east-1'; - console.log(`New organization "${organization.name}" created in region: ${regionMetadata}`); - // Note: Organization metadata with region should be set via backend API or Clerk webhook - // For now, we track this information in telemetry and localStorage - console.log(`Organization created in ${selectedRegion} region with metadata: ${regionMetadata}`); + // For now, we track this information in telemetry } }, [organization, track, selectedRegion]); diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index bdb60230a13..207d4884c71 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -43,7 +43,6 @@ export function RegionProvider({ children }: RegionProviderProps) { // Initialize region based on URL instead of localStorage const [selectedRegion, setSelectedRegion] = useState(() => { const urlBasedRegion = detectRegionFromURL(); - console.log('Initial region detection from URL:', urlBasedRegion); return urlBasedRegion; }); @@ -68,29 +67,32 @@ export function RegionProvider({ children }: RegionProviderProps) { const handleSetSelectedRegion = async (region: Region) => { const previousRegion = selectedRegion; - console.log(`Manual region selection: ${previousRegion} β†’ ${region}`); if (previousRegion === region) { - console.log('Same region selected, no action needed'); return; } setSelectedRegion(region); - // If we're in organization creation flow, update API hostname without redirect + // If we're in organization creation flow, redirect to maintain URL consistency if (isInOnboardingFlow()) { - console.log('In organization creation flow, updating API hostname for new region:', region); - - // Update API and WebSocket hostnames for org creation - const newApiHostname = getApiHostnameForRegion(region); - const newWebSocketHostname = getWebSocketHostnameForRegion(region); - apiHostnameManager.setApiHostname(newApiHostname); - apiHostnameManager.setWebSocketHostname(newWebSocketHostname); - - // Clear any cached queries to ensure fresh data from new region - queryClient.clear(); + // Redirect to the correct dashboard URL for the selected region + const targetDashboardUrl = getDashboardUrlForRegion(region); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + const newUrl = `${targetDashboardUrl}${currentPath}`; + + if (targetDashboardUrl !== window.location.origin) { + // Different dashboard URL - redirect to maintain consistency + window.location.href = newUrl; + } else { + // Same dashboard URL - just update API hostnames + const newApiHostname = getApiHostnameForRegion(region); + const newWebSocketHostname = getWebSocketHostnameForRegion(region); + apiHostnameManager.setApiHostname(newApiHostname); + apiHostnameManager.setWebSocketHostname(newWebSocketHostname); + queryClient.clear(); + } - console.log('Updated API hostname for onboarding flow without redirect'); return; } @@ -103,8 +105,6 @@ export function RegionProvider({ children }: RegionProviderProps) { if (targetOrgMembership && clerk) { try { - console.log(`Switching to organization "${targetOrgMembership.organization.name}" for ${region} region`); - // Switch to the organization for the selected region await clerk.setActive({ organization: targetOrgMembership.organization, @@ -112,7 +112,6 @@ export function RegionProvider({ children }: RegionProviderProps) { // Redirect to the correct dashboard URL for the target region const newUrl = `${targetDashboardUrl}${currentPath}`; - console.log('Redirecting to:', newUrl); if (targetDashboardUrl !== window.location.origin) { window.location.href = newUrl; @@ -126,8 +125,6 @@ export function RegionProvider({ children }: RegionProviderProps) { setSelectedRegion(previousRegion); } } else { - console.log(`No organization found for region: ${region}, showing creation confirmation`); - // Show modal to confirm organization creation setOrgCreationModal({ open: true, @@ -139,42 +136,44 @@ export function RegionProvider({ children }: RegionProviderProps) { // Auto-sync region when user switches to an organization from different region useEffect(() => { - if (currentOrganization && !isInOnboardingFlow()) { + if (currentOrganization) { const detectedRegion = detectRegionFromCurrentOrg(); const urlRegion = detectRegionFromURL(); - - console.log('Region detection:', { - fromOrg: detectedRegion, - fromURL: urlRegion, - selected: selectedRegion, - orgName: currentOrganization.name, - }); + const isInOrgCreation = isInOnboardingFlow(); // If the URL region doesn't match the organization region, // redirect to the correct dashboard URL for the organization's region if (urlRegion !== detectedRegion) { - console.log( - `URL region (${urlRegion}) doesn't match organization region (${detectedRegion}) for org "${currentOrganization.name}"` - ); - console.log(`Redirecting to ${detectedRegion} dashboard URL for current organization`); - - // Redirect to the correct dashboard URL for the organization's region - const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); - const currentPath = window.location.pathname + window.location.search + window.location.hash; - const newUrl = `${correctDashboardUrl}${currentPath}`; - - console.log('Redirecting to correct region dashboard:', newUrl); - - if (correctDashboardUrl !== window.location.origin) { - // Different dashboard URL - redirect - window.location.href = newUrl; + // During organization creation flow, still redirect when selecting existing organizations + // from different regions to maintain URL consistency + if (isInOrgCreation) { + // In org creation: redirect to maintain URL consistency when switching to existing orgs + const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + const newUrl = `${correctDashboardUrl}${currentPath}`; + + if (correctDashboardUrl !== window.location.origin) { + // Different dashboard URL - redirect to maintain consistency + window.location.href = newUrl; + return; + } } else { - // Same dashboard URL but wrong region state - just update the region - setSelectedRegion(detectedRegion); + // Normal dashboard flow: redirect to correct region + const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + const newUrl = `${correctDashboardUrl}${currentPath}`; + + if (correctDashboardUrl !== window.location.origin) { + // Different dashboard URL - redirect + window.location.href = newUrl; + return; + } } + + // Same dashboard URL but wrong region state - just update the region + setSelectedRegion(detectedRegion); } else if (selectedRegion !== detectedRegion) { // URL and organization match, but our selected region state is wrong - update it - console.log(`Updating selected region from ${selectedRegion} to ${detectedRegion} to match organization`); setSelectedRegion(detectedRegion); } } @@ -186,14 +185,10 @@ export function RegionProvider({ children }: RegionProviderProps) { const webSocketHostname = getWebSocketHostnameForRegion(selectedRegion); apiHostnameManager.setApiHostname(apiHostname); apiHostnameManager.setWebSocketHostname(webSocketHostname); - - console.log('Updated API hostname for region:', selectedRegion, apiHostname); }, [selectedRegion]); // Handle organization creation confirmation const handleConfirmOrgCreation = () => { - console.log(`Confirmed organization creation for region: ${orgCreationModal.targetRegion}`); - // Close modal setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); @@ -202,8 +197,6 @@ export function RegionProvider({ children }: RegionProviderProps) { const orgCreationPath = ROUTES.SIGNUP_ORGANIZATION_LIST; const newUrl = `${targetDashboardUrl}${orgCreationPath}`; - console.log('Redirecting to organization creation:', newUrl); - if (targetDashboardUrl !== window.location.origin) { window.location.href = newUrl; } else { @@ -213,17 +206,11 @@ export function RegionProvider({ children }: RegionProviderProps) { // Handle organization creation cancellation const handleCancelOrgCreation = () => { - console.log( - `Cancelled organization creation, reverting from ${orgCreationModal.targetRegion} back to ${orgCreationModal.previousRegion}` - ); - // Revert region setSelectedRegion(orgCreationModal.previousRegion); // Close modal setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); - - console.log(`Reverted to previous region: ${orgCreationModal.previousRegion}`); }; const value: RegionContextType = { diff --git a/apps/dashboard/src/context/region/region-utils.ts b/apps/dashboard/src/context/region/region-utils.ts index 0d2aeddc503..7bfe6b1e43b 100644 --- a/apps/dashboard/src/context/region/region-utils.ts +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -35,11 +35,8 @@ export function detectRegionFromOrganization(organization: OrganizationResource const orgMetadata = organization.publicMetadata as OrganizationMetadata; const orgRegion = orgMetadata?.region; - console.log('Detecting region from current org:', organization.name, 'metadata:', orgMetadata); - // No region metadata means US (default behavior) if (!orgRegion) { - console.log('No region metadata found, defaulting to US'); return 'us'; } @@ -53,7 +50,6 @@ export function detectRegionFromOrganization(organization: OrganizationResource } // Fallback to US for any unknown region - console.log('Unknown region metadata:', orgRegion, 'defaulting to US'); return 'us'; } @@ -63,15 +59,6 @@ export function findOrganizationForRegion( ) { const expectedMetadataRegion = REGION_METADATA_MAP[region]; - console.log('Looking for organization with region:', expectedMetadataRegion); - console.log( - 'Available organizations:', - userMemberships.data?.map((m) => ({ - name: m.organization.name, - metadata: m.organization.publicMetadata, - })) - ); - const found = userMemberships.data?.find((membership) => { const orgMetadata = membership.organization.publicMetadata as OrganizationMetadata; const orgRegion = orgMetadata?.region; @@ -84,7 +71,6 @@ export function findOrganizationForRegion( return orgRegion === expectedMetadataRegion; }); - console.log('Found organization for region:', found?.organization.name); return found; } @@ -104,10 +90,6 @@ export function isInOnboardingFlow(): boolean { export function detectRegionFromURL(): Region { const currentOrigin = window.location.origin; - console.log('Detecting region from URL:', currentOrigin); - console.log('DASHBOARD_URL:', DASHBOARD_URL); - console.log('DASHBOARD_URL_SG:', DASHBOARD_URL_SG); - // If we have specific dashboard URLs configured, use them for detection if (DASHBOARD_URL_SG && DASHBOARD_URL) { // Normalize URLs for comparison (remove trailing slashes) @@ -117,24 +99,20 @@ export function detectRegionFromURL(): Region { const usNormalized = normalizeUrl(DASHBOARD_URL); if (currentNormalized === sgNormalized) { - console.log('Detected Singapore region from URL match'); return 'singapore'; } if (currentNormalized === usNormalized) { - console.log('Detected US region from URL match'); return 'us'; } } // Fallback: detect based on domain patterns if (currentOrigin.includes('sg.') || currentOrigin.includes('singapore.') || currentOrigin.includes('asia.')) { - console.log('Detected Singapore region from domain pattern'); return 'singapore'; } // Default to US region - console.log('Defaulting to US region'); return 'us'; } From f12bc1d7d26f9d734b3499a5039539180d726032 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 15:23:15 +0530 Subject: [PATCH 08/27] refactor(region): enhance logging and refine organization creation flow - Improved debug logging in `RegionProvider` to provide better insights during region switching and organization creation. - Updated logic to prevent redirection during organization creation when selecting existing organizations, ensuring a smoother user experience. - Clarified comments and streamlined the handling of region mismatches for better maintainability. --- .../src/context/region/region-context.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index 207d4884c71..13d98292ea8 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -76,7 +76,7 @@ export function RegionProvider({ children }: RegionProviderProps) { // If we're in organization creation flow, redirect to maintain URL consistency if (isInOnboardingFlow()) { - // Redirect to the correct dashboard URL for the selected region + // Redirect to the correct dashboard URL for the selected region to maintain consistency const targetDashboardUrl = getDashboardUrlForRegion(region); const currentPath = window.location.pathname + window.location.search + window.location.hash; const newUrl = `${targetDashboardUrl}${currentPath}`; @@ -120,7 +120,6 @@ export function RegionProvider({ children }: RegionProviderProps) { window.location.reload(); } } catch (error) { - console.error('Failed to switch organization:', error); // Revert region on error setSelectedRegion(previousRegion); } @@ -140,23 +139,16 @@ export function RegionProvider({ children }: RegionProviderProps) { const detectedRegion = detectRegionFromCurrentOrg(); const urlRegion = detectRegionFromURL(); const isInOrgCreation = isInOnboardingFlow(); - // If the URL region doesn't match the organization region, // redirect to the correct dashboard URL for the organization's region if (urlRegion !== detectedRegion) { - // During organization creation flow, still redirect when selecting existing organizations - // from different regions to maintain URL consistency + // DON'T redirect during organization creation if we're creating a NEW organization + // Only redirect if user selected an EXISTING organization if (isInOrgCreation) { - // In org creation: redirect to maintain URL consistency when switching to existing orgs - const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); - const currentPath = window.location.pathname + window.location.search + window.location.hash; - const newUrl = `${correctDashboardUrl}${currentPath}`; - - if (correctDashboardUrl !== window.location.origin) { - // Different dashboard URL - redirect to maintain consistency - window.location.href = newUrl; - return; - } + // Just update the selected region state, don't redirect + // This allows user to create org in region different from current URL + setSelectedRegion(urlRegion); + return; } else { // Normal dashboard flow: redirect to correct region const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); From 615167e6a11a5e955bcc7b8be61c16006a42f06d Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 15:25:11 +0530 Subject: [PATCH 09/27] refactor(config): simplify API and WebSocket hostname assignments - Removed fallback logic for `API_HOSTNAME_SG` and `WEBSOCKET_HOSTNAME_SG`, directly assigning values from environment variables. - Streamlined configuration for better clarity and maintainability. --- apps/dashboard/src/config/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index e97ec81a14f..a712940889f 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -15,14 +15,13 @@ export const APP_ID = import.meta.env.VITE_NOVU_APP_ID || ''; export const API_HOSTNAME = window._env_?.VITE_API_HOSTNAME || import.meta.env.VITE_API_HOSTNAME; -export const API_HOSTNAME_SG = window._env_?.VITE_API_HOSTNAME_SG || import.meta.env.VITE_API_HOSTNAME_SG; +export const API_HOSTNAME_SG = import.meta.env.VITE_API_HOSTNAME_SG; export const IS_EU = API_HOSTNAME === 'https://eu.api.novu.co'; export const WEBSOCKET_HOSTNAME = window._env_?.VITE_WEBSOCKET_HOSTNAME || import.meta.env.VITE_WEBSOCKET_HOSTNAME; -export const WEBSOCKET_HOSTNAME_SG = - window._env_?.VITE_WEBSOCKET_HOSTNAME_SG || import.meta.env.VITE_WEBSOCKET_HOSTNAME_SG; +export const WEBSOCKET_HOSTNAME_SG = import.meta.env.VITE_WEBSOCKET_HOSTNAME_SG; export const INTERCOM_APP_ID = import.meta.env.VITE_INTERCOM_APP_ID; From 5a8cb2506cdb9b32209c15a62d62fe46e8113302 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 15:25:23 +0530 Subject: [PATCH 10/27] refactor(config): simplify DASHBOARD_URL_SG assignment - Removed fallback logic for `DASHBOARD_URL_SG`, directly assigning the value from environment variables for improved clarity and maintainability. --- apps/dashboard/src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index a712940889f..af50e15a7da 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -34,7 +34,7 @@ export const LEGACY_DASHBOARD_URL = export const DASHBOARD_URL = window._env_?.VITE_DASHBOARD_URL || import.meta.env.VITE_DASHBOARD_URL; -export const DASHBOARD_URL_SG = window._env_?.VITE_DASHBOARD_URL_SG || import.meta.env.VITE_DASHBOARD_URL_SG; +export const DASHBOARD_URL_SG = meta.env.VITE_DASHBOARD_URL_SG; export const PLAIN_SUPPORT_CHAT_APP_ID = import.meta.env.VITE_PLAIN_SUPPORT_CHAT_APP_ID; From 8a4e455e35bb785853027efe353a3a7172100a59 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 15:46:13 +0530 Subject: [PATCH 11/27] feat(region): implement multi-region configuration and dynamic loading - Introduced a new `region-config.ts` file to manage multi-region setup, allowing dynamic loading of region-specific configurations from environment variables. - Updated `RegionSelector` to dynamically generate region options based on the configured regions. - Refactored region-related utility functions to utilize the new configuration system, enhancing maintainability and flexibility. - Improved region detection logic to support multiple regions and fallback mechanisms. --- apps/dashboard/src/config/index.ts | 18 ++- .../src/context/region/region-config.ts | 134 +++++++++++++++++ .../src/context/region/region-selector.tsx | 16 ++- .../src/context/region/region-types.ts | 11 +- .../src/context/region/region-utils.ts | 135 +++++++++--------- 5 files changed, 224 insertions(+), 90 deletions(-) create mode 100644 apps/dashboard/src/context/region/region-config.ts diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index af50e15a7da..f1e6df76d50 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -15,14 +15,10 @@ export const APP_ID = import.meta.env.VITE_NOVU_APP_ID || ''; export const API_HOSTNAME = window._env_?.VITE_API_HOSTNAME || import.meta.env.VITE_API_HOSTNAME; -export const API_HOSTNAME_SG = import.meta.env.VITE_API_HOSTNAME_SG; - export const IS_EU = API_HOSTNAME === 'https://eu.api.novu.co'; export const WEBSOCKET_HOSTNAME = window._env_?.VITE_WEBSOCKET_HOSTNAME || import.meta.env.VITE_WEBSOCKET_HOSTNAME; -export const WEBSOCKET_HOSTNAME_SG = import.meta.env.VITE_WEBSOCKET_HOSTNAME_SG; - export const INTERCOM_APP_ID = import.meta.env.VITE_INTERCOM_APP_ID; export const SEGMENT_KEY = import.meta.env.VITE_SEGMENT_KEY; @@ -34,8 +30,6 @@ export const LEGACY_DASHBOARD_URL = export const DASHBOARD_URL = window._env_?.VITE_DASHBOARD_URL || import.meta.env.VITE_DASHBOARD_URL; -export const DASHBOARD_URL_SG = meta.env.VITE_DASHBOARD_URL_SG; - export const PLAIN_SUPPORT_CHAT_APP_ID = import.meta.env.VITE_PLAIN_SUPPORT_CHAT_APP_ID; export const ONBOARDING_DEMO_WORKFLOW_ID = 'onboarding-demo-workflow'; @@ -49,3 +43,15 @@ if (!IS_SELF_HOSTED && !CLERK_PUBLISHABLE_KEY) { } export const SELF_HOSTED_UPGRADE_REDIRECT_URL = 'https://go.novu.co/hosted-upgrade'; + +/** + * Helper function to get environment variable with window._env_ fallback + * Used by the multi-region configuration system + */ +export function getEnvVar(key: string, fallback: string = ''): string { + return ( + (window._env_ as Record)?.[key] || + (import.meta.env as Record)[key] || + fallback + ); +} diff --git a/apps/dashboard/src/context/region/region-config.ts b/apps/dashboard/src/context/region/region-config.ts new file mode 100644 index 00000000000..303f7c1f935 --- /dev/null +++ b/apps/dashboard/src/context/region/region-config.ts @@ -0,0 +1,134 @@ +/** + * Region Configuration + * + * This file defines the multi-region setup for the dashboard. + * To add a new region: + * 1. Add the environment variables in .env: + * - VITE_REGIONS (comma-separated list of region codes) + * - VITE_DASHBOARD_URL_ + * - VITE_API_HOSTNAME_ + * - VITE_WEBSOCKET_HOSTNAME_ + * 2. The system will automatically detect and use the new region + */ + +import { API_HOSTNAME, DASHBOARD_URL, getEnvVar, WEBSOCKET_HOSTNAME } from '@/config'; + +export interface RegionConfig { + code: string; + name: string; + flag: string; + dashboardUrl: string; + apiHostname: string; + websocketHostname: string; + awsRegion: string; // e.g., 'us-east-1', 'ap-southeast-1' +} + +/** + * Parse regions from environment variables + * Format: VITE_REGIONS=us,singapore,eu,india + */ +function parseRegionsFromEnv(): RegionConfig[] { + // Get the list of region codes from VITE_REGIONS + const regionsEnv = getEnvVar('VITE_REGIONS', 'us'); + const regionCodes = regionsEnv + .split(',') + .map((code) => code.trim()) + .filter(Boolean); + + const regions: RegionConfig[] = []; + + for (const code of regionCodes) { + const upperCode = code.toUpperCase(); + const isBaseRegion = code === 'us'; + + // US (base region) uses env vars without suffix, all others use _SUFFIX + // e.g., VITE_DASHBOARD_URL for US, VITE_DASHBOARD_URL_SG for Singapore + const dashboardUrl = isBaseRegion ? DASHBOARD_URL : getEnvVar(`VITE_DASHBOARD_URL_${upperCode}`, ''); + + const apiHostname = isBaseRegion ? API_HOSTNAME : getEnvVar(`VITE_API_HOSTNAME_${upperCode}`, ''); + + const websocketHostname = isBaseRegion ? WEBSOCKET_HOSTNAME : getEnvVar(`VITE_WEBSOCKET_HOSTNAME_${upperCode}`, ''); + + // AWS region mapping (can have suffix for all regions including US) + const awsRegion = getEnvVar(`VITE_AWS_REGION_${upperCode}`, isBaseRegion ? 'us-east-1' : ''); + + const regionName = getEnvVar(`VITE_REGION_NAME_${upperCode}`, code.toUpperCase()); + const regionFlag = getEnvVar(`VITE_REGION_FLAG_${upperCode}`, '🌍'); + + // Debug logging + console.log(`πŸ” Parsing region: ${code}`, { + dashboardUrl, + apiHostname, + websocketHostname, + awsRegion, + isBaseRegion, + }); + + // Skip if essential config is missing (except for US which has defaults) + if (!dashboardUrl || !apiHostname || !websocketHostname) { + if (code !== 'us') { + console.warn(`❌ Skipping region ${code}: missing required environment variables`); + console.warn('Missing values:', { + dashboardUrl: dashboardUrl || 'MISSING', + apiHostname: apiHostname || 'MISSING', + websocketHostname: websocketHostname || 'MISSING', + }); + continue; + } + } + + regions.push({ + code: code.toLowerCase(), + name: regionName, + flag: regionFlag, + dashboardUrl, + apiHostname, + websocketHostname, + awsRegion, + }); + } + + return regions; +} + +/** + * All configured regions + */ +export const REGIONS: RegionConfig[] = parseRegionsFromEnv(); + +/** + * Map of region code to region config + */ +export const REGION_MAP = new Map(REGIONS.map((region) => [region.code, region])); + +/** + * Map of AWS region to region code + * Used for detecting region from organization metadata + */ +export const AWS_REGION_TO_CODE_MAP = new Map(REGIONS.map((region) => [region.awsRegion, region.code])); + +/** + * Default region (first region in the list, typically 'us') + */ +export const DEFAULT_REGION = REGIONS[0]?.code || 'us'; + +/** + * Validate that at least one region is configured + */ +if (REGIONS.length === 0) { + console.error('No regions configured! Please set VITE_REGIONS environment variable.'); +} + +/** + * Helper to get region config by code + */ +export function getRegionConfig(code: string): RegionConfig | undefined { + return REGION_MAP.get(code.toLowerCase()); +} + +/** + * Helper to get region code from AWS region + */ +export function getRegionCodeFromAws(awsRegion: string): string { + return AWS_REGION_TO_CODE_MAP.get(awsRegion) || DEFAULT_REGION; +} diff --git a/apps/dashboard/src/context/region/region-selector.tsx b/apps/dashboard/src/context/region/region-selector.tsx index 9dd20bf1acd..31566b2741e 100644 --- a/apps/dashboard/src/context/region/region-selector.tsx +++ b/apps/dashboard/src/context/region/region-selector.tsx @@ -1,15 +1,17 @@ -import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { Globe } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { IS_EU } from '@/config'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { Globe } from 'lucide-react'; +import { REGIONS } from './region-config'; import { useRegion } from './region-context'; -import { type Region } from './region-types'; -const REGION_OPTIONS: Array<{ value: Region; label: string; flag: string }> = [ - { value: 'us', label: 'US', flag: 'πŸ‡ΊπŸ‡Έ' }, - { value: 'singapore', label: 'Singapore', flag: 'πŸ‡ΈπŸ‡¬' }, -]; +// Dynamically load region options from configuration +const REGION_OPTIONS = REGIONS.map((region) => ({ + value: region.code, + label: region.name, + flag: region.flag, +})); export function RegionSelector() { const { selectedRegion, setSelectedRegion } = useRegion(); diff --git a/apps/dashboard/src/context/region/region-types.ts b/apps/dashboard/src/context/region/region-types.ts index c6fd277f360..20d6b7ecdbb 100644 --- a/apps/dashboard/src/context/region/region-types.ts +++ b/apps/dashboard/src/context/region/region-types.ts @@ -1,8 +1,9 @@ -export type Region = 'us' | 'singapore'; +// Region type is now dynamic based on configured regions +export type Region = string; // Type for organization public metadata export interface OrganizationMetadata { - region?: 'us-east-1' | 'ap-southeast-1'; + region?: string; // AWS region like 'us-east-1', 'ap-southeast-1', 'eu-central-1', etc. externalOrgId?: string; [key: string]: unknown; } @@ -13,12 +14,6 @@ export interface RegionContextType { getApiHostname: () => string; } -// Map UI regions to organization metadata regions -export const REGION_METADATA_MAP = { - us: 'us-east-1', - singapore: 'ap-southeast-1', -} as const; - // Modal state types export interface OrgCreationModalState { open: boolean; diff --git a/apps/dashboard/src/context/region/region-utils.ts b/apps/dashboard/src/context/region/region-utils.ts index 7bfe6b1e43b..1a643fc2eba 100644 --- a/apps/dashboard/src/context/region/region-utils.ts +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -1,74 +1,68 @@ -import { - API_HOSTNAME, - API_HOSTNAME_SG, - DASHBOARD_URL, - DASHBOARD_URL_SG, - WEBSOCKET_HOSTNAME, - WEBSOCKET_HOSTNAME_SG, -} from '@/config'; import { type OrganizationMembershipResource, type OrganizationResource } from '@clerk/types'; -import { type OrganizationMetadata, REGION_METADATA_MAP, type Region } from './region-types'; +import { DEFAULT_REGION, getRegionCodeFromAws, getRegionConfig, REGIONS } from './region-config'; +import { type OrganizationMetadata, type Region } from './region-types'; export function getApiHostnameForRegion(region: Region): string { - switch (region) { - case 'singapore': - return API_HOSTNAME_SG || API_HOSTNAME; - case 'us': - default: - return API_HOSTNAME; + const config = getRegionConfig(region); + if (config) { + return config.apiHostname; } + + // Fallback to default region + const defaultConfig = getRegionConfig(DEFAULT_REGION); + return defaultConfig?.apiHostname || ''; } export function getWebSocketHostnameForRegion(region: Region): string { - switch (region) { - case 'singapore': - return WEBSOCKET_HOSTNAME_SG || WEBSOCKET_HOSTNAME; - case 'us': - default: - return WEBSOCKET_HOSTNAME; + const config = getRegionConfig(region); + if (config) { + return config.websocketHostname; } + + // Fallback to default region + const defaultConfig = getRegionConfig(DEFAULT_REGION); + return defaultConfig?.websocketHostname || ''; } export function detectRegionFromOrganization(organization: OrganizationResource | null | undefined): Region { - if (!organization) return 'us'; + if (!organization) return DEFAULT_REGION; const orgMetadata = organization.publicMetadata as OrganizationMetadata; - const orgRegion = orgMetadata?.region; - - // No region metadata means US (default behavior) - if (!orgRegion) { - return 'us'; - } + const awsRegion = orgMetadata?.region; - // Explicit region mapping - if (orgRegion === 'us-east-1') { - return 'us'; + // No region metadata means default region + if (!awsRegion) { + return DEFAULT_REGION; } - if (orgRegion === 'ap-southeast-1') { - return 'singapore'; - } - - // Fallback to US for any unknown region - return 'us'; + // Map AWS region to region code + const regionCode = getRegionCodeFromAws(awsRegion); + return regionCode; } export function findOrganizationForRegion( region: Region, userMemberships: { data?: OrganizationMembershipResource[] } ) { - const expectedMetadataRegion = REGION_METADATA_MAP[region]; + // Get the AWS region for the requested region code + const regionConfig = getRegionConfig(region); + if (!regionConfig) { + return undefined; + } + + const expectedAwsRegion = regionConfig.awsRegion; const found = userMemberships.data?.find((membership) => { const orgMetadata = membership.organization.publicMetadata as OrganizationMetadata; - const orgRegion = orgMetadata?.region; + const awsRegion = orgMetadata?.region; - // If no region metadata, assume us-east-1 - if (!orgRegion) { - return expectedMetadataRegion === 'us-east-1'; + // If no region metadata, assume default region + if (!awsRegion) { + const defaultConfig = getRegionConfig(DEFAULT_REGION); + return expectedAwsRegion === defaultConfig?.awsRegion; } - return orgRegion === expectedMetadataRegion; + return awsRegion === expectedAwsRegion; }); return found; @@ -89,42 +83,45 @@ export function isInOnboardingFlow(): boolean { */ export function detectRegionFromURL(): Region { const currentOrigin = window.location.origin; - - // If we have specific dashboard URLs configured, use them for detection - if (DASHBOARD_URL_SG && DASHBOARD_URL) { - // Normalize URLs for comparison (remove trailing slashes) - const normalizeUrl = (url: string) => url.replace(/\/$/, ''); - const currentNormalized = normalizeUrl(currentOrigin); - const sgNormalized = normalizeUrl(DASHBOARD_URL_SG); - const usNormalized = normalizeUrl(DASHBOARD_URL); - - if (currentNormalized === sgNormalized) { - return 'singapore'; - } - - if (currentNormalized === usNormalized) { - return 'us'; + const normalizeUrl = (url: string) => url.replace(/\/$/, ''); + const currentNormalized = normalizeUrl(currentOrigin); + + // Try to match current URL with any configured region's dashboard URL + for (const region of REGIONS) { + const regionDashboardUrl = normalizeUrl(region.dashboardUrl); + if (currentNormalized === regionDashboardUrl) { + return region.code; } } - // Fallback: detect based on domain patterns - if (currentOrigin.includes('sg.') || currentOrigin.includes('singapore.') || currentOrigin.includes('asia.')) { - return 'singapore'; + // Fallback: detect based on domain patterns in region codes + // e.g., if origin contains 'sg.' and we have a 'singapore' region + const lowerOrigin = currentOrigin.toLowerCase(); + for (const region of REGIONS) { + // Check if origin contains region code or common patterns + if ( + lowerOrigin.includes(`${region.code}.`) || + lowerOrigin.includes(`.${region.code}.`) || + lowerOrigin.includes(`-${region.code}.`) + ) { + return region.code; + } } - // Default to US region - return 'us'; + // Default to base region + return DEFAULT_REGION; } /** * Gets the dashboard URL for a specific region */ export function getDashboardUrlForRegion(region: Region): string { - switch (region) { - case 'singapore': - return DASHBOARD_URL_SG || DASHBOARD_URL || window.location.origin; - case 'us': - default: - return DASHBOARD_URL || window.location.origin; + const config = getRegionConfig(region); + if (config) { + return config.dashboardUrl; } + + // Fallback to default region or current origin + const defaultConfig = getRegionConfig(DEFAULT_REGION); + return defaultConfig?.dashboardUrl || window.location.origin; } From afcd19beafa25eba4d465e53a716c37885048a43 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 16:04:47 +0530 Subject: [PATCH 12/27] refactor(region): enhance region configuration logic and defaults - Updated region parsing to dynamically determine the base region from the VITE_REGIONS environment variable. - Simplified the assignment of environment variables for dashboard URLs, API hostnames, and region flags based on whether the region is the base region or not. - Improved comments for clarity on the configuration logic and fallback mechanisms for non-base regions. - Enhanced logging to provide better insights when essential configuration is missing. --- .../src/context/region/region-config.ts | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/src/context/region/region-config.ts b/apps/dashboard/src/context/region/region-config.ts index 303f7c1f935..a248cd46362 100644 --- a/apps/dashboard/src/context/region/region-config.ts +++ b/apps/dashboard/src/context/region/region-config.ts @@ -35,44 +35,44 @@ function parseRegionsFromEnv(): RegionConfig[] { .map((code) => code.trim()) .filter(Boolean); + // First region in the list is the base region + const baseRegionCode = regionCodes[0] || 'us'; + const regions: RegionConfig[] = []; for (const code of regionCodes) { const upperCode = code.toUpperCase(); - const isBaseRegion = code === 'us'; + const isBaseRegion = code === baseRegionCode; - // US (base region) uses env vars without suffix, all others use _SUFFIX - // e.g., VITE_DASHBOARD_URL for US, VITE_DASHBOARD_URL_SG for Singapore + // Base region (first in VITE_REGIONS) uses env vars without suffix, all others use _SUFFIX + // e.g., if base is 'us': VITE_DASHBOARD_URL for base, VITE_DASHBOARD_URL_SG for others const dashboardUrl = isBaseRegion ? DASHBOARD_URL : getEnvVar(`VITE_DASHBOARD_URL_${upperCode}`, ''); const apiHostname = isBaseRegion ? API_HOSTNAME : getEnvVar(`VITE_API_HOSTNAME_${upperCode}`, ''); const websocketHostname = isBaseRegion ? WEBSOCKET_HOSTNAME : getEnvVar(`VITE_WEBSOCKET_HOSTNAME_${upperCode}`, ''); - // AWS region mapping (can have suffix for all regions including US) - const awsRegion = getEnvVar(`VITE_AWS_REGION_${upperCode}`, isBaseRegion ? 'us-east-1' : ''); - - const regionName = getEnvVar(`VITE_REGION_NAME_${upperCode}`, code.toUpperCase()); - const regionFlag = getEnvVar(`VITE_REGION_FLAG_${upperCode}`, '🌍'); - - // Debug logging - console.log(`πŸ” Parsing region: ${code}`, { - dashboardUrl, - apiHostname, - websocketHostname, - awsRegion, - isBaseRegion, - }); - - // Skip if essential config is missing (except for US which has defaults) + // AWS region mapping - base region uses NO suffix, others use suffix + // Example: VITE_AWS_REGION (base), VITE_AWS_REGION_SG (others) + const baseAwsRegion = baseRegionCode === 'us' ? 'us-east-1' : ''; + const awsRegion = isBaseRegion + ? getEnvVar('VITE_AWS_REGION', baseAwsRegion) + : getEnvVar(`VITE_AWS_REGION_${upperCode}`, ''); + + // Region display name and flag - base region uses NO suffix, others use suffix + const defaultName = code.toUpperCase(); + const defaultFlag = isBaseRegion && code === 'us' ? 'πŸ‡ΊπŸ‡Έ' : '🌍'; + const regionName = isBaseRegion + ? getEnvVar('VITE_REGION_NAME', defaultName) + : getEnvVar(`VITE_REGION_NAME_${upperCode}`, defaultName); + const regionFlag = isBaseRegion + ? getEnvVar('VITE_REGION_FLAG', defaultFlag) + : getEnvVar(`VITE_REGION_FLAG_${upperCode}`, defaultFlag); + + // Skip if essential config is missing (except for base region which has defaults) if (!dashboardUrl || !apiHostname || !websocketHostname) { - if (code !== 'us') { - console.warn(`❌ Skipping region ${code}: missing required environment variables`); - console.warn('Missing values:', { - dashboardUrl: dashboardUrl || 'MISSING', - apiHostname: apiHostname || 'MISSING', - websocketHostname: websocketHostname || 'MISSING', - }); + if (!isBaseRegion) { + console.warn(`Skipping region ${code}: missing required environment variables`); continue; } } @@ -108,7 +108,8 @@ export const REGION_MAP = new Map(REGIONS.map((region) => export const AWS_REGION_TO_CODE_MAP = new Map(REGIONS.map((region) => [region.awsRegion, region.code])); /** - * Default region (first region in the list, typically 'us') + * Default region (first region in the list) + * This is determined dynamically from VITE_REGIONS environment variable */ export const DEFAULT_REGION = REGIONS[0]?.code || 'us'; From 60a4564bdf94f633d28b3b90d9c4e221c6c4c1e4 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 16:10:41 +0530 Subject: [PATCH 13/27] feat(env): add multi-region configuration to .example.env - Introduced environment variables for multi-region support, allowing configuration for both US and Singapore regions. - Added detailed comments to guide users on how to extend the configuration for additional regions. - Defined base and additional region-specific URLs, API hostnames, and AWS regions for improved deployment flexibility. --- apps/dashboard/.example.env | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/dashboard/.example.env b/apps/dashboard/.example.env index 442a62755f8..b03555978a4 100644 --- a/apps/dashboard/.example.env +++ b/apps/dashboard/.example.env @@ -9,3 +9,36 @@ VITE_INTERCOM_APP_ID= VITE_GTM= VITE_SELF_HOSTED= VITE_PLAIN_SUPPORT_CHAT_APP_ID= + +# Multi-Region Configuration +# List of region codes (comma-separated). FIRST region is the base/default region. +# Use SHORT codes that match your env var suffixes (e.g., 'sg' not 'singapore') +# The base region uses env vars WITHOUT suffix (VITE_API_HOSTNAME, not VITE_API_HOSTNAME_XX) +VITE_REGIONS=us,sg + +# Base Region - NO suffix required (everything uses base env vars) +VITE_DASHBOARD_URL=http://localhost:4201 +# VITE_API_HOSTNAME and VITE_WEBSOCKET_HOSTNAME are already defined above +VITE_AWS_REGION=us-east-1 +# VITE_REGION_NAME=US +# VITE_REGION_FLAG=πŸ‡ΊπŸ‡Έ + +# Additional Region Configuration +# For each additional region, add variables with _REGIONCODE suffix (uppercase): + +# Singapore Region +VITE_DASHBOARD_URL_SG=http://localhost:4202 +VITE_API_HOSTNAME_SG=http://localhost:3200 +VITE_WEBSOCKET_HOSTNAME_SG=http://localhost:3003 +VITE_AWS_REGION_SG=ap-southeast-1 +VITE_REGION_NAME_SG=Singapore +VITE_REGION_FLAG_SG=πŸ‡ΈπŸ‡¬ + +# To add more regions, follow this pattern: +# VITE_DASHBOARD_URL_= +# VITE_API_HOSTNAME_= +# VITE_WEBSOCKET_HOSTNAME_= +# VITE_AWS_REGION_= +# VITE_REGION_NAME_= +# VITE_REGION_FLAG_= +# See MULTI_REGION_SETUP.md for detailed instructions From ad7f074dc39498b46971a2901ec69147ceff657e Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 16:40:28 +0530 Subject: [PATCH 14/27] refactor(region): remove outdated comments on region detection - Eliminated comments regarding the localStorage-based region initialization in `region-context.tsx` and `region-utils.ts`, as the logic has been updated to rely solely on URL-based detection for improved reliability and clarity. --- apps/dashboard/src/context/region/region-context.tsx | 1 - apps/dashboard/src/context/region/region-utils.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index 13d98292ea8..297f55212c8 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -40,7 +40,6 @@ export function RegionProvider({ children }: RegionProviderProps) { userMemberships: { infinite: true }, }); - // Initialize region based on URL instead of localStorage const [selectedRegion, setSelectedRegion] = useState(() => { const urlBasedRegion = detectRegionFromURL(); return urlBasedRegion; diff --git a/apps/dashboard/src/context/region/region-utils.ts b/apps/dashboard/src/context/region/region-utils.ts index 1a643fc2eba..e34e08bd930 100644 --- a/apps/dashboard/src/context/region/region-utils.ts +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -79,7 +79,6 @@ export function isInOnboardingFlow(): boolean { /** * Detects the current region based on the dashboard URL - * This replaces the localStorage-based approach with a more reliable URL-based detection */ export function detectRegionFromURL(): Region { const currentOrigin = window.location.origin; From f1d5b038117b804fb026e8d2ed9e35223fff0903 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 16:48:33 +0530 Subject: [PATCH 15/27] refactor(region): clean up comments and streamline region handling - Removed outdated comments in `region-config.ts`, `region-context.tsx`, and `region-utils.ts` to enhance clarity and maintainability. - Simplified comments related to environment variable usage and region mapping for better understanding of the configuration logic. - Improved overall readability of the region handling code by eliminating unnecessary explanations. --- .../src/context/region/region-config.ts | 10 ++++------ .../src/context/region/region-context.tsx | 19 ------------------- .../src/context/region/region-utils.ts | 10 +--------- 3 files changed, 5 insertions(+), 34 deletions(-) diff --git a/apps/dashboard/src/context/region/region-config.ts b/apps/dashboard/src/context/region/region-config.ts index a248cd46362..ca994888fb9 100644 --- a/apps/dashboard/src/context/region/region-config.ts +++ b/apps/dashboard/src/context/region/region-config.ts @@ -44,22 +44,20 @@ function parseRegionsFromEnv(): RegionConfig[] { const upperCode = code.toUpperCase(); const isBaseRegion = code === baseRegionCode; - // Base region (first in VITE_REGIONS) uses env vars without suffix, all others use _SUFFIX - // e.g., if base is 'us': VITE_DASHBOARD_URL for base, VITE_DASHBOARD_URL_SG for others + // Base region uses env vars without suffix, others use _SUFFIX const dashboardUrl = isBaseRegion ? DASHBOARD_URL : getEnvVar(`VITE_DASHBOARD_URL_${upperCode}`, ''); const apiHostname = isBaseRegion ? API_HOSTNAME : getEnvVar(`VITE_API_HOSTNAME_${upperCode}`, ''); const websocketHostname = isBaseRegion ? WEBSOCKET_HOSTNAME : getEnvVar(`VITE_WEBSOCKET_HOSTNAME_${upperCode}`, ''); - // AWS region mapping - base region uses NO suffix, others use suffix - // Example: VITE_AWS_REGION (base), VITE_AWS_REGION_SG (others) + // AWS region mapping const baseAwsRegion = baseRegionCode === 'us' ? 'us-east-1' : ''; const awsRegion = isBaseRegion ? getEnvVar('VITE_AWS_REGION', baseAwsRegion) : getEnvVar(`VITE_AWS_REGION_${upperCode}`, ''); - // Region display name and flag - base region uses NO suffix, others use suffix + // Region display name and flag const defaultName = code.toUpperCase(); const defaultFlag = isBaseRegion && code === 'us' ? 'πŸ‡ΊπŸ‡Έ' : '🌍'; const regionName = isBaseRegion @@ -69,7 +67,7 @@ function parseRegionsFromEnv(): RegionConfig[] { ? getEnvVar('VITE_REGION_FLAG', defaultFlag) : getEnvVar(`VITE_REGION_FLAG_${upperCode}`, defaultFlag); - // Skip if essential config is missing (except for base region which has defaults) + // Skip if essential config is missing if (!dashboardUrl || !apiHostname || !websocketHostname) { if (!isBaseRegion) { console.warn(`Skipping region ${code}: missing required environment variables`); diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index 297f55212c8..abc6c3cd6a4 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -73,18 +73,14 @@ export function RegionProvider({ children }: RegionProviderProps) { setSelectedRegion(region); - // If we're in organization creation flow, redirect to maintain URL consistency if (isInOnboardingFlow()) { - // Redirect to the correct dashboard URL for the selected region to maintain consistency const targetDashboardUrl = getDashboardUrlForRegion(region); const currentPath = window.location.pathname + window.location.search + window.location.hash; const newUrl = `${targetDashboardUrl}${currentPath}`; if (targetDashboardUrl !== window.location.origin) { - // Different dashboard URL - redirect to maintain consistency window.location.href = newUrl; } else { - // Same dashboard URL - just update API hostnames const newApiHostname = getApiHostnameForRegion(region); const newWebSocketHostname = getWebSocketHostnameForRegion(region); apiHostnameManager.setApiHostname(newApiHostname); @@ -95,7 +91,6 @@ export function RegionProvider({ children }: RegionProviderProps) { return; } - // For region switching in dashboard - redirect to the appropriate dashboard URL const targetDashboardUrl = getDashboardUrlForRegion(region); const currentPath = window.location.pathname + window.location.search + window.location.hash; @@ -104,26 +99,21 @@ export function RegionProvider({ children }: RegionProviderProps) { if (targetOrgMembership && clerk) { try { - // Switch to the organization for the selected region await clerk.setActive({ organization: targetOrgMembership.organization, }); - // Redirect to the correct dashboard URL for the target region const newUrl = `${targetDashboardUrl}${currentPath}`; if (targetDashboardUrl !== window.location.origin) { window.location.href = newUrl; } else { - // Same dashboard URL - just refresh to update the region window.location.reload(); } } catch (error) { - // Revert region on error setSelectedRegion(previousRegion); } } else { - // Show modal to confirm organization creation setOrgCreationModal({ open: true, targetRegion: region, @@ -149,22 +139,18 @@ export function RegionProvider({ children }: RegionProviderProps) { setSelectedRegion(urlRegion); return; } else { - // Normal dashboard flow: redirect to correct region const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); const currentPath = window.location.pathname + window.location.search + window.location.hash; const newUrl = `${correctDashboardUrl}${currentPath}`; if (correctDashboardUrl !== window.location.origin) { - // Different dashboard URL - redirect window.location.href = newUrl; return; } } - // Same dashboard URL but wrong region state - just update the region setSelectedRegion(detectedRegion); } else if (selectedRegion !== detectedRegion) { - // URL and organization match, but our selected region state is wrong - update it setSelectedRegion(detectedRegion); } } @@ -180,10 +166,8 @@ export function RegionProvider({ children }: RegionProviderProps) { // Handle organization creation confirmation const handleConfirmOrgCreation = () => { - // Close modal setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); - // Redirect to the correct dashboard URL for organization creation const targetDashboardUrl = getDashboardUrlForRegion(orgCreationModal.targetRegion); const orgCreationPath = ROUTES.SIGNUP_ORGANIZATION_LIST; const newUrl = `${targetDashboardUrl}${orgCreationPath}`; @@ -197,10 +181,7 @@ export function RegionProvider({ children }: RegionProviderProps) { // Handle organization creation cancellation const handleCancelOrgCreation = () => { - // Revert region setSelectedRegion(orgCreationModal.previousRegion); - - // Close modal setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); }; diff --git a/apps/dashboard/src/context/region/region-utils.ts b/apps/dashboard/src/context/region/region-utils.ts index e34e08bd930..e8005499def 100644 --- a/apps/dashboard/src/context/region/region-utils.ts +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -77,9 +77,6 @@ export function isInOnboardingFlow(): boolean { ); } -/** - * Detects the current region based on the dashboard URL - */ export function detectRegionFromURL(): Region { const currentOrigin = window.location.origin; const normalizeUrl = (url: string) => url.replace(/\/$/, ''); @@ -93,11 +90,9 @@ export function detectRegionFromURL(): Region { } } - // Fallback: detect based on domain patterns in region codes - // e.g., if origin contains 'sg.' and we have a 'singapore' region + // Fallback: detect based on domain patterns const lowerOrigin = currentOrigin.toLowerCase(); for (const region of REGIONS) { - // Check if origin contains region code or common patterns if ( lowerOrigin.includes(`${region.code}.`) || lowerOrigin.includes(`.${region.code}.`) || @@ -111,9 +106,6 @@ export function detectRegionFromURL(): Region { return DEFAULT_REGION; } -/** - * Gets the dashboard URL for a specific region - */ export function getDashboardUrlForRegion(region: Region): string { const config = getRegionConfig(region); if (config) { From bbb8e10c8ae7c1016310f4d07c931828ca5eae68 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 23:00:29 +0530 Subject: [PATCH 16/27] fix(region): improve API call handling during region switching - Updated the API client to wait for region switching to complete before making API calls, enhancing reliability during transitions. - Removed outdated comments in the organization creation flow to streamline the code and focus on telemetry tracking. - Refined region switching logic in the RegionProvider to ensure accurate state management during region changes. --- apps/dashboard/src/api/api.client.ts | 7 ++++--- apps/dashboard/src/components/auth/create-organization.tsx | 6 ------ apps/dashboard/src/context/region/region-context.tsx | 4 ++++ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/dashboard/src/api/api.client.ts b/apps/dashboard/src/api/api.client.ts index 78d7053ca41..faa9287103a 100644 --- a/apps/dashboard/src/api/api.client.ts +++ b/apps/dashboard/src/api/api.client.ts @@ -38,9 +38,10 @@ const request = async ( const { body, environment, headers, method = 'GET', version = 'v1', signal } = options || {}; try { - // Prevent API calls during region switching to avoid failed requests - if (apiHostnameManager.isCurrentlyRegionSwitching()) { - console.log('Blocking API call during region switching'); + // Wait for region switching to complete before making API calls + while (apiHostnameManager.isCurrentlyRegionSwitching()) { + console.log('Waiting for region switching to complete...'); + await new Promise((resolve) => setTimeout(resolve, 100)); } const jwt = await getToken(); diff --git a/apps/dashboard/src/components/auth/create-organization.tsx b/apps/dashboard/src/components/auth/create-organization.tsx index 54bc87998a7..266b803a056 100644 --- a/apps/dashboard/src/components/auth/create-organization.tsx +++ b/apps/dashboard/src/components/auth/create-organization.tsx @@ -160,12 +160,6 @@ export default function OrganizationCreate() { organizationName: organization.name, region: selectedRegion, }); - - // Set the region metadata for the newly created organization - const regionMetadata = selectedRegion === 'singapore' ? 'ap-southeast-1' : 'us-east-1'; - - // Note: Organization metadata with region should be set via backend API or Clerk webhook - // For now, we track this information in telemetry } }, [organization, track, selectedRegion]); diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index abc6c3cd6a4..c00cf7a8635 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -71,6 +71,7 @@ export function RegionProvider({ children }: RegionProviderProps) { return; } + apiHostnameManager.setRegionSwitching(true); setSelectedRegion(region); if (isInOnboardingFlow()) { @@ -86,6 +87,7 @@ export function RegionProvider({ children }: RegionProviderProps) { apiHostnameManager.setApiHostname(newApiHostname); apiHostnameManager.setWebSocketHostname(newWebSocketHostname); queryClient.clear(); + apiHostnameManager.setRegionSwitching(false); } return; @@ -111,9 +113,11 @@ export function RegionProvider({ children }: RegionProviderProps) { window.location.reload(); } } catch (error) { + apiHostnameManager.setRegionSwitching(false); setSelectedRegion(previousRegion); } } else { + apiHostnameManager.setRegionSwitching(false); setOrgCreationModal({ open: true, targetRegion: region, From 454aebe6fca9330884e2f907a817101d65b9c18d Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 23:09:38 +0530 Subject: [PATCH 17/27] refactor(region): remove region switching logic from API client and context - Eliminated the region switching checks from the API client, streamlining API call handling. - Removed redundant region switching state management from the RegionProvider to enhance clarity and maintainability. - Cleaned up the HostnameManager by removing the region switching state, simplifying the overall logic. --- apps/dashboard/src/api/api.client.ts | 6 ------ apps/dashboard/src/context/region/region-context.tsx | 4 ---- apps/dashboard/src/utils/api-hostname-manager.ts | 9 --------- 3 files changed, 19 deletions(-) diff --git a/apps/dashboard/src/api/api.client.ts b/apps/dashboard/src/api/api.client.ts index faa9287103a..995c5328eb9 100644 --- a/apps/dashboard/src/api/api.client.ts +++ b/apps/dashboard/src/api/api.client.ts @@ -38,12 +38,6 @@ const request = async ( const { body, environment, headers, method = 'GET', version = 'v1', signal } = options || {}; try { - // Wait for region switching to complete before making API calls - while (apiHostnameManager.isCurrentlyRegionSwitching()) { - console.log('Waiting for region switching to complete...'); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const jwt = await getToken(); const config: RequestInit = { method, diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index c00cf7a8635..abc6c3cd6a4 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -71,7 +71,6 @@ export function RegionProvider({ children }: RegionProviderProps) { return; } - apiHostnameManager.setRegionSwitching(true); setSelectedRegion(region); if (isInOnboardingFlow()) { @@ -87,7 +86,6 @@ export function RegionProvider({ children }: RegionProviderProps) { apiHostnameManager.setApiHostname(newApiHostname); apiHostnameManager.setWebSocketHostname(newWebSocketHostname); queryClient.clear(); - apiHostnameManager.setRegionSwitching(false); } return; @@ -113,11 +111,9 @@ export function RegionProvider({ children }: RegionProviderProps) { window.location.reload(); } } catch (error) { - apiHostnameManager.setRegionSwitching(false); setSelectedRegion(previousRegion); } } else { - apiHostnameManager.setRegionSwitching(false); setOrgCreationModal({ open: true, targetRegion: region, diff --git a/apps/dashboard/src/utils/api-hostname-manager.ts b/apps/dashboard/src/utils/api-hostname-manager.ts index 735fd9f8bdb..b71f84b9c81 100644 --- a/apps/dashboard/src/utils/api-hostname-manager.ts +++ b/apps/dashboard/src/utils/api-hostname-manager.ts @@ -4,7 +4,6 @@ import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '@/config'; class HostnameManager { private currentApiHostname: string; private currentWebSocketHostname: string; - private isRegionSwitching: boolean = false; constructor() { // Initialize with US hostnames (default) @@ -36,14 +35,6 @@ class HostnameManager { getHostname(): string { return this.getApiHostname(); } - - setRegionSwitching(switching: boolean) { - this.isRegionSwitching = switching; - } - - isCurrentlyRegionSwitching(): boolean { - return this.isRegionSwitching; - } } export const apiHostnameManager = new HostnameManager(); From ea9d64ccbededfa0a85761e7cd7553b3dffe56fc Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 30 Sep 2025 23:53:11 +0530 Subject: [PATCH 18/27] refactor(identity): improve user and organization identification logic - Renamed identification flags for clarity and separated user and organization identification states. - Enhanced user identification logic to ensure proper tracking with LaunchDarkly. - Streamlined organization identification process, ensuring accurate monitoring and telemetry tracking. - Cleaned up unused variables and improved overall readability of the IdentityProvider component. --- .../src/context/identity-provider.tsx | 31 ++++++++++----- .../src/context/region/region-context.tsx | 4 +- .../src/context/region/region-selector.tsx | 38 +++++++++++++++---- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/apps/dashboard/src/context/identity-provider.tsx b/apps/dashboard/src/context/identity-provider.tsx index 393f2d392ad..f9696844008 100644 --- a/apps/dashboard/src/context/identity-provider.tsx +++ b/apps/dashboard/src/context/identity-provider.tsx @@ -8,14 +8,29 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { const ldClient = useLDClient(); const segment = useSegment(); const { currentUser, currentOrganization } = useAuth(); - const hasIdentified = useRef(false); + const hasIdentifiedUser = useRef(false); + const hasIdentifiedOrg = useRef(false); - const hasExternalId = currentUser?._id; - const hasOrganization = currentOrganization && currentOrganization._id; - const shouldMonitor = hasExternalId && hasOrganization; + useEffect(() => { + if (!currentUser || !ldClient || hasIdentifiedUser.current) return; + + ldClient.identify({ + kind: 'user', + key: currentUser._id, + firstName: currentUser.firstName, + lastName: currentUser.lastName, + email: currentUser.email, + }); + + hasIdentifiedUser.current = true; + }, [ldClient, currentUser]); useEffect(() => { - if (!currentOrganization || !currentUser || hasIdentified.current) return; + if (!currentOrganization || !currentUser || hasIdentifiedOrg.current) return; + + const hasExternalId = currentUser._id; + const hasOrganization = currentOrganization._id; + const shouldMonitor = hasExternalId && hasOrganization; if (shouldMonitor) { segment.identify(currentUser); @@ -27,9 +42,7 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { }); setSentryTags({ - // user tags 'user.createdAt': currentUser.createdAt, - // organization tags 'organization.id': currentOrganization._id, 'organization.name': currentOrganization.name, 'organization.tier': currentOrganization.apiServiceLevel, @@ -57,8 +70,8 @@ export function IdentityProvider({ children }: { children: React.ReactNode }) { sentrySetUser(null); } - hasIdentified.current = true; - }, [ldClient, currentOrganization, currentUser, segment, shouldMonitor]); + hasIdentifiedOrg.current = true; + }, [ldClient, currentOrganization, currentUser, segment]); return <>{children}; } diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index abc6c3cd6a4..7e0a7bdb1ba 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -1,9 +1,9 @@ -import { apiHostnameManager } from '@/utils/api-hostname-manager'; -import { ROUTES } from '@/utils/routes'; import { useClerk, useOrganization, useOrganizationList } from '@clerk/clerk-react'; import { useQueryClient } from '@tanstack/react-query'; import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; +import { ROUTES } from '@/utils/routes'; import { RegionModals } from './region-modals'; import { type OrgCreationModalState, type Region, type RegionContextType } from './region-types'; diff --git a/apps/dashboard/src/context/region/region-selector.tsx b/apps/dashboard/src/context/region/region-selector.tsx index 31566b2741e..2c40c590451 100644 --- a/apps/dashboard/src/context/region/region-selector.tsx +++ b/apps/dashboard/src/context/region/region-selector.tsx @@ -1,31 +1,53 @@ +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { useLDClient } from 'launchdarkly-react-client-sdk'; +import { Globe } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { IS_EU } from '@/config'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; -import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { Globe } from 'lucide-react'; import { REGIONS } from './region-config'; import { useRegion } from './region-context'; -// Dynamically load region options from configuration const REGION_OPTIONS = REGIONS.map((region) => ({ value: region.code, label: region.name, flag: region.flag, })); +function useLaunchDarklyReady() { + const ldClient = useLDClient(); + const [isReady, setIsReady] = useState(!ldClient); + + useEffect(() => { + if (!ldClient) { + setIsReady(true); + return; + } + + const waitForReady = async () => { + try { + await ldClient.waitUntilReady?.(); + } finally { + setIsReady(true); + } + }; + + waitForReady(); + }, [ldClient]); + + return isReady; +} + export function RegionSelector() { const { selectedRegion, setSelectedRegion } = useRegion(); + const isLDReady = useLaunchDarklyReady(); const isRegionSelectorEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_REGION_SELECTOR_ENABLED, false); - - // Check if we're in organization creation flow const isInOrgCreation = window.location.pathname.includes('/auth/organization-list'); - // Hide region selector for EU users, but always show during org creation if feature is enabled - if (IS_EU || (!isRegionSelectorEnabled && !isInOrgCreation)) { + if (IS_EU || !isLDReady || !isRegionSelectorEnabled) { return null; } - // Match header button proportions - slim and consistent with other header elements const triggerClassName = isInOrgCreation ? 'h-8 w-auto min-w-[120px] border border-neutral-200 bg-background text-sm shadow-sm focus:ring-2 focus:ring-ring/20' : 'h-[26px] w-auto min-w-[100px] border border-neutral-200/50 bg-background text-xs shadow-sm focus:ring-1 focus:ring-ring/20 px-2'; From bb11165c364a3438f4de2d32895b8260b3500473 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 1 Oct 2025 14:48:03 +0530 Subject: [PATCH 19/27] refactor(region): enhance region name retrieval in organization creation modal - Added a utility function to dynamically retrieve the region name based on the target region in the organization creation modal. - Updated the modal description to use the new region name retrieval logic, improving clarity and maintainability of the code. --- apps/dashboard/src/context/region/region-modals.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/context/region/region-modals.tsx b/apps/dashboard/src/context/region/region-modals.tsx index 2d04e77ec32..81557dc916b 100644 --- a/apps/dashboard/src/context/region/region-modals.tsx +++ b/apps/dashboard/src/context/region/region-modals.tsx @@ -1,5 +1,6 @@ -import { RiAddLine } from 'react-icons/ri'; import { ConfirmationModal } from '@/components/confirmation-modal'; +import { RiAddLine } from 'react-icons/ri'; +import { getRegionConfig } from './region-config'; import { type OrgCreationModalState } from './region-types'; interface RegionModalsProps { @@ -9,6 +10,9 @@ interface RegionModalsProps { } export function RegionModals({ orgCreationModal, onCancelOrgCreation, onConfirmOrgCreation }: RegionModalsProps) { + const regionName = + getRegionConfig(orgCreationModal.targetRegion)?.name || orgCreationModal.targetRegion.toUpperCase(); + return ( - No organization was found in the{' '} - {orgCreationModal.targetRegion === 'singapore' ? 'Singapore' : 'US'} region. + No organization was found in the {regionName} region.

- Would you like to create a new organization in the{' '} - {orgCreationModal.targetRegion === 'singapore' ? 'Singapore' : 'US'} region? + Would you like to create a new organization in the {regionName} region? } confirmButtonText="Create Organization" From 37e066390bc57a2b1da3c9bb87f163425bdc7f6a Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 1 Oct 2025 14:51:52 +0530 Subject: [PATCH 20/27] refactor(region): standardize default region handling in context and API keys - Updated the organization creation modal to use a default region constant instead of hardcoded values, enhancing maintainability. - Modified the API keys page to dynamically retrieve the region name based on the selected region configuration, improving clarity and flexibility in region handling. --- .../src/context/region/region-context.tsx | 14 +++++++------- apps/dashboard/src/pages/api-keys.tsx | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/context/region/region-context.tsx b/apps/dashboard/src/context/region/region-context.tsx index 7e0a7bdb1ba..1b70ae98281 100644 --- a/apps/dashboard/src/context/region/region-context.tsx +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -1,10 +1,10 @@ +import { apiHostnameManager } from '@/utils/api-hostname-manager'; +import { ROUTES } from '@/utils/routes'; import { useClerk, useOrganization, useOrganizationList } from '@clerk/clerk-react'; import { useQueryClient } from '@tanstack/react-query'; import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { apiHostnameManager } from '@/utils/api-hostname-manager'; -import { ROUTES } from '@/utils/routes'; - +import { DEFAULT_REGION } from './region-config'; import { RegionModals } from './region-modals'; import { type OrgCreationModalState, type Region, type RegionContextType } from './region-types'; import { @@ -48,8 +48,8 @@ export function RegionProvider({ children }: RegionProviderProps) { // Modal state for organization creation confirmation const [orgCreationModal, setOrgCreationModal] = useState({ open: false, - targetRegion: 'us', - previousRegion: 'us', + targetRegion: DEFAULT_REGION, + previousRegion: DEFAULT_REGION, }); const getApiHostname = useCallback(() => getApiHostnameForRegion(selectedRegion), [selectedRegion]); @@ -166,7 +166,7 @@ export function RegionProvider({ children }: RegionProviderProps) { // Handle organization creation confirmation const handleConfirmOrgCreation = () => { - setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); + setOrgCreationModal({ open: false, targetRegion: DEFAULT_REGION, previousRegion: DEFAULT_REGION }); const targetDashboardUrl = getDashboardUrlForRegion(orgCreationModal.targetRegion); const orgCreationPath = ROUTES.SIGNUP_ORGANIZATION_LIST; @@ -182,7 +182,7 @@ export function RegionProvider({ children }: RegionProviderProps) { // Handle organization creation cancellation const handleCancelOrgCreation = () => { setSelectedRegion(orgCreationModal.previousRegion); - setOrgCreationModal({ open: false, targetRegion: 'us', previousRegion: 'us' }); + setOrgCreationModal({ open: false, targetRegion: DEFAULT_REGION, previousRegion: DEFAULT_REGION }); }; const value: RegionContextType = { diff --git a/apps/dashboard/src/pages/api-keys.tsx b/apps/dashboard/src/pages/api-keys.tsx index d31b89faa43..dc090b2329e 100644 --- a/apps/dashboard/src/pages/api-keys.tsx +++ b/apps/dashboard/src/pages/api-keys.tsx @@ -7,6 +7,7 @@ import { Skeleton } from '@/components/primitives/skeleton'; import { ExternalLink } from '@/components/shared/external-link'; import { useEnvironment } from '@/context/environment/hooks'; import { useRegion } from '@/context/region'; +import { getRegionConfig } from '@/context/region/region-config'; import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { PermissionsEnum } from '@novu/shared'; import { useState } from 'react'; @@ -70,7 +71,7 @@ export function ApiKeysPage() { } // Use dynamic region from region selector - const region = selectedRegion === 'singapore' ? 'Singapore' : 'US'; + const region = getRegionConfig(selectedRegion)?.name || selectedRegion.toUpperCase(); return ( <> From 37402d0d40420675c3a77835a7eb4307c3a27bc1 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 14:48:13 +0530 Subject: [PATCH 21/27] update(subproject): update subproject commit reference to latest version --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 64aee0f0dec..380b780df49 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 64aee0f0dec5639ac0a17195fb64179de459001a +Subproject commit 380b780df496f4725b0b345220d72f4be8b013fb From 14469fb1ebb92fa46be8a6971095673fa515d39c Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 16:32:34 +0530 Subject: [PATCH 22/27] update(subproject): change subproject commit reference to 15113238a9dc --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 380b780df49..15113238a9d 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 380b780df496f4725b0b345220d72f4be8b013fb +Subproject commit 15113238a9dcdd0c56036fba92cc2dedde38e768 From bf7958a080508d1462b55b920edc66d0004736f0 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 16:50:29 +0530 Subject: [PATCH 23/27] update(subproject): change subproject commit reference to 6c74fe64b80f --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 15113238a9d..6c74fe64b80 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 15113238a9dcdd0c56036fba92cc2dedde38e768 +Subproject commit 6c74fe64b80f2c6507e062de2569da0e782133f8 From f7277d13f9fa4d8922857dea2ab87d38b11a6d9f Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 19:55:43 +0530 Subject: [PATCH 24/27] chore(workflow): add NX_CLOUD_ACCESS_TOKEN environment variable to deploy workflow --- .github/workflows/deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c2519a5f6b..1551f6d6d77 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,6 +15,9 @@ description: | It builds Docker images, pushes them to Amazon ECR, and deploys them to Amazon ECS. Additionally, it creates Sentry releases and New Relic deployment markers. +env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + on: workflow_dispatch: inputs: From 2ef3e02adf1416efa8a04010c10913859aefaa29 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 20:25:42 +0530 Subject: [PATCH 25/27] refactor(docker): optimize Dockerfile and update build scripts for improved package handling --- apps/ws/Dockerfile | 31 ++++++++++++------------------- apps/ws/package.json | 3 ++- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/ws/Dockerfile b/apps/ws/Dockerfile index 8a749669962..a1c1c69946b 100644 --- a/apps/ws/Dockerfile +++ b/apps/ws/Dockerfile @@ -10,31 +10,24 @@ RUN npm --no-update-notifier --no-fund --global install pm2 pnpm@10.16.1&& \ USER 1000 WORKDIR /usr/src/app -COPY --chown=1000:1000 .npmrc . -COPY --chown=1000:1000 .npmrc-cloud . +FROM dev_base AS dev +ARG PACKAGE_PATH -COPY --chown=1000:1000 package.json . - -COPY --chown=1000:1000 apps/ws ./apps/ws -COPY --chown=1000:1000 libs/dal ./libs/dal -COPY --chown=1000:1000 packages/shared ./packages/shared -COPY --chown=1000:1000 libs/testing ./libs/testing -COPY --chown=1000:1000 libs/application-generic ./libs/application-generic -COPY --chown=1000:1000 packages/framework ./packages/framework -COPY --chown=1000:1000 packages/stateless ./packages/stateless -COPY --chown=1000:1000 packages/providers ./packages/providers - -COPY --chown=1000:1000 ["tsconfig.json","nx.json","pnpm-workspace.yaml","pnpm-lock.yaml", ".npmrc", "./"] +COPY --chown=1000:1000 ./meta . +COPY --chown=1000:1000 ./deps . +COPY --chown=1000:1000 ./pkg . RUN --mount=type=secret,id=BULL_MQ_PRO_NPM_TOKEN,uid=1000 export BULL_MQ_PRO_NPM_TOKEN=$(cat /run/secrets/BULL_MQ_PRO_NPM_TOKEN) && \ if [ -n "${BULL_MQ_PRO_NPM_TOKEN}" ] ; then echo 'Building with Enterprise Edition of Novu'; rm -f .npmrc ; cp .npmrc-cloud .npmrc ; fi -RUN --mount=type=secret,id=BULL_MQ_PRO_NPM_TOKEN,uid=1000 export BULL_MQ_PRO_NPM_TOKEN=$(cat /run/secrets/BULL_MQ_PRO_NPM_TOKEN) && \ - pnpm install --verbose && \ - pnpm build:ws +RUN --mount=type=cache,id=pnpm-store-ws,target=/root/.pnpm-store\ + --mount=type=secret,id=BULL_MQ_PRO_NPM_TOKEN,uid=1000 export BULL_MQ_PRO_NPM_TOKEN=$(cat /run/secrets/BULL_MQ_PRO_NPM_TOKEN) && \ + pnpm install --filter "novuhq" --filter "{${PACKAGE_PATH}}..."\ + --frozen-lockfile\ + --unsafe-perm\ + --reporter=silent -RUN --mount=type=secret,id=BULL_MQ_PRO_NPM_TOKEN,uid=1000 export BULL_MQ_PRO_NPM_TOKEN=$(cat /run/secrets/BULL_MQ_PRO_NPM_TOKEN) && \ - if $BULL_MQ_PRO_NPM_TOKEN ; then rm -f .npmrc ; fi +RUN --mount=type=secret,id=BULL_MQ_PRO_NPM_TOKEN,uid=1000 export BULL_MQ_PRO_NPM_TOKEN=$(cat /run/secrets/BULL_MQ_PRO_NPM_TOKEN) && NODE_ENV=production NX_DAEMON=false pnpm build:ws WORKDIR /usr/src/app/apps/ws diff --git a/apps/ws/package.json b/apps/ws/package.json index d7bcf2cab64..0b6e505b981 100644 --- a/apps/ws/package.json +++ b/apps/ws/package.json @@ -8,7 +8,8 @@ "scripts": { "prebuild": "rimraf dist", "build": "nest build", - "docker:build": "BULL_MQ_PRO_NPM_TOKEN=${BULL_MQ_PRO_NPM_TOKEN} docker buildx build --secret id=BULL_MQ_PRO_NPM_TOKEN -f ./Dockerfile -t novu-ws ./../.. --load $DOCKER_BUILD_ARGUMENTS", + "docker:build": "pnpm --silent --workspace-root pnpm-context -- apps/ws/Dockerfile | BULL_MQ_PRO_NPM_TOKEN=${BULL_MQ_PRO_NPM_TOKEN} docker buildx build --secret id=BULL_MQ_PRO_NPM_TOKEN --build-arg PACKAGE_PATH=apps/ws - -t novu-ws --load $DOCKER_BUILD_ARGUMENTS", + "docker:build:depot": "pnpm --silent --workspace-root pnpm-context -- apps/ws/Dockerfile | depot build --build-arg PACKAGE_PATH=apps/ws - -t novu-ws --load", "start": "pnpm start:dev", "start:dev": "nest start --watch", "start:test": "cross-env NODE_ENV=test nest start", From ead844a859296401977904d9ca09c2e3cd5c7a7e Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 21:22:23 +0530 Subject: [PATCH 26/27] feat(workflow): add environment filtering to deployment logic in GitHub Actions --- .github/workflows/deploy.yml | 46 ++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1551f6d6d77..adedc2f49f2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -128,7 +128,28 @@ jobs: service_name=$(echo "$worker_service" | jq -r '.service') task_name=$(echo "$worker_service" | jq -r '.task_name') image=$(echo "$worker_service" | jq -r '.image') - deploy_matrix+=("{\"cluster_name\": \"$cluster_name\", \"container_name\": \"$container_name\", \"service_name\": \"$service_name\", \"task_name\": \"$task_name\", \"image\": \"$image\"}") + + # Check if service has environments filter, otherwise deploy to all + allowed_envs=$(echo "$worker_service" | jq -r '.environments // empty') + should_deploy=false + + if [ -z "$allowed_envs" ]; then + # No environment filter, deploy to all environments + should_deploy=true + else + # Check if any of the selected environments match the allowed environments + for env in "${envs[@]}"; do + env_clean=$(echo "$env" | tr -d '"') + if echo "$allowed_envs" | jq -e --arg env "$env_clean" 'index($env) != null' > /dev/null; then + should_deploy=true + break + fi + done + fi + + if [ "$should_deploy" == "true" ]; then + deploy_matrix+=("{\"cluster_name\": \"$cluster_name\", \"container_name\": \"$container_name\", \"service_name\": \"$service_name\", \"task_name\": \"$task_name\", \"image\": \"$image\"}") + fi done elif [ "$service" == "\"api\"" ]; then for api_service in $(echo "$API_SERVICE" | jq -c '.[]'); do @@ -137,7 +158,28 @@ jobs: service_name=$(echo "$api_service" | jq -r '.service') task_name=$(echo "$api_service" | jq -r '.task_name') image=$(echo "$api_service" | jq -r '.image') - deploy_matrix+=("{\"cluster_name\": \"$cluster_name\", \"container_name\": \"$container_name\", \"service_name\": \"$service_name\", \"task_name\": \"$task_name\", \"image\": \"$image\"}") + + # Check if service has environments filter, otherwise deploy to all + allowed_envs=$(echo "$api_service" | jq -r '.environments // empty') + should_deploy=false + + if [ -z "$allowed_envs" ]; then + # No environment filter, deploy to all environments + should_deploy=true + else + # Check if any of the selected environments match the allowed environments + for env in "${envs[@]}"; do + env_clean=$(echo "$env" | tr -d '"') + if echo "$allowed_envs" | jq -e --arg env "$env_clean" 'index($env) != null' > /dev/null; then + should_deploy=true + break + fi + done + fi + + if [ "$should_deploy" == "true" ]; then + deploy_matrix+=("{\"cluster_name\": \"$cluster_name\", \"container_name\": \"$container_name\", \"service_name\": \"$service_name\", \"task_name\": \"$task_name\", \"image\": \"$image\"}") + fi done elif [ "$service" == "\"ws\"" ]; then cluster_name=ws-cluster From 9c320efa07f23ac094ba7725cf292788e05020c5 Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Wed, 8 Oct 2025 21:44:13 +0530 Subject: [PATCH 27/27] fix(workflow): update production environment names and conditions in deployment logic --- .github/workflows/deploy.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index adedc2f49f2..b70adc3bcb5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,8 @@ on: - staging-apse1 - production-us - production-eu - - production-both + - production-apse1 + - production-us-and-eu deploy_api: description: "Deploy API" @@ -97,7 +98,10 @@ jobs: if [ "${{ github.event.inputs.environment }}" == "production-eu" ]; then envs+=("\"prod-eu\"") fi - if [ "${{ github.event.inputs.environment }}" == "production-both" ]; then + if [ "${{ github.event.inputs.environment }}" == "production-apse1" ]; then + envs+=("\"prod-apse1\"") + fi + if [ "${{ github.event.inputs.environment }}" == "production-us-and-eu" ]; then envs+=("\"prod-us\"") envs+=("\"prod-eu\"") fi @@ -403,7 +407,7 @@ jobs: - name: Send webhook notification for US production if: | github.event.inputs.environment == 'production-us' || - github.event.inputs.environment == 'production-both' + github.event.inputs.environment == 'production-us-and-eu' run: | curl -X POST https://webhooks.bug0.com/integrations/test/run \ -H "Content-Type: application/json" \ @@ -413,7 +417,7 @@ jobs: - name: Send webhook notification for EU production if: | github.event.inputs.environment == 'production-eu' || - github.event.inputs.environment == 'production-both' + github.event.inputs.environment == 'production-us-and-eu' run: | curl -X POST https://webhooks.bug0.com/integrations/test/run \ -H "Content-Type: application/json" \