diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ecd466bb484..b70adc3bcb5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,37 +15,42 @@ 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: 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 + - production-apse1 + - production-us-and-eu 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,13 +89,19 @@ 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 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 @@ -121,7 +132,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 @@ -130,7 +162,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 @@ -192,8 +245,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 +255,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 +356,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 +379,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,22 +403,21 @@ 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: | 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" \ -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' || - 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" \ diff --git a/.source b/.source index 599511b4dd4..6c74fe64b80 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 599511b4dd42223af514093b1bc0f425eedec2b4 +Subproject commit 6c74fe64b80f2c6507e062de2569da0e782133f8 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/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 diff --git a/apps/dashboard/src/api/api.client.ts b/apps/dashboard/src/api/api.client.ts index 85a50eb8c72..995c5328eb9 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 @@ -62,7 +61,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..266b803a056 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,41 @@ 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); + } + }); + + // 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 +148,7 @@ function PageContent() { export default function OrganizationCreate() { const { organization } = useOrganization(); + const { selectedRegion } = useRegion(); const track = useTelemetry(); useEffect(() => { @@ -122,9 +158,10 @@ export default function OrganizationCreate() { location: 'web', organizationId: organization.id, organizationName: organization.name, + region: selectedRegion, }); } - }, [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 790e5c34cd0..f7bb3d415eb 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'; @@ -120,11 +121,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/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..f1e6df76d50 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -28,6 +28,8 @@ 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 PLAIN_SUPPORT_CHAT_APP_ID = import.meta.env.VITE_PLAIN_SUPPORT_CHAT_APP_ID; export const ONBOARDING_DEMO_WORKFLOW_ID = 'onboarding-demo-workflow'; @@ -41,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/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/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-config.ts b/apps/dashboard/src/context/region/region-config.ts new file mode 100644 index 00000000000..ca994888fb9 --- /dev/null +++ b/apps/dashboard/src/context/region/region-config.ts @@ -0,0 +1,133 @@ +/** + * 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); + + // 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 === baseRegionCode; + + // 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 + 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 + 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 + if (!dashboardUrl || !apiHostname || !websocketHostname) { + if (!isBaseRegion) { + console.warn(`Skipping region ${code}: missing required environment variables`); + 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) + * This is determined dynamically from VITE_REGIONS environment variable + */ +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-context.tsx b/apps/dashboard/src/context/region/region-context.tsx new file mode 100644 index 00000000000..1b70ae98281 --- /dev/null +++ b/apps/dashboard/src/context/region/region-context.tsx @@ -0,0 +1,205 @@ +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 { DEFAULT_REGION } from './region-config'; +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'; + +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(() => { + const urlBasedRegion = detectRegionFromURL(); + return urlBasedRegion; + }); + + // Modal state for organization creation confirmation + const [orgCreationModal, setOrgCreationModal] = useState({ + open: false, + targetRegion: DEFAULT_REGION, + previousRegion: DEFAULT_REGION, + }); + + const getApiHostname = useCallback(() => getApiHostnameForRegion(selectedRegion), [selectedRegion]); + + const detectRegionFromCurrentOrg = useCallback( + () => detectRegionFromOrganization(currentOrganization), + [currentOrganization] + ); + + const findOrganizationForRegionCallback = useCallback( + (region: Region) => findOrganizationForRegion(region, userMemberships), + [userMemberships] + ); + + const handleSetSelectedRegion = async (region: Region) => { + const previousRegion = selectedRegion; + + if (previousRegion === region) { + return; + } + + setSelectedRegion(region); + + if (isInOnboardingFlow()) { + const targetDashboardUrl = getDashboardUrlForRegion(region); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + const newUrl = `${targetDashboardUrl}${currentPath}`; + + if (targetDashboardUrl !== window.location.origin) { + window.location.href = newUrl; + } else { + const newApiHostname = getApiHostnameForRegion(region); + const newWebSocketHostname = getWebSocketHostnameForRegion(region); + apiHostnameManager.setApiHostname(newApiHostname); + apiHostnameManager.setWebSocketHostname(newWebSocketHostname); + queryClient.clear(); + } + + return; + } + + const targetDashboardUrl = getDashboardUrlForRegion(region); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + + // Find and switch to an organization in the target region + const targetOrgMembership = findOrganizationForRegionCallback(region); + + if (targetOrgMembership && clerk) { + try { + await clerk.setActive({ + organization: targetOrgMembership.organization, + }); + + const newUrl = `${targetDashboardUrl}${currentPath}`; + + if (targetDashboardUrl !== window.location.origin) { + window.location.href = newUrl; + } else { + window.location.reload(); + } + } catch (error) { + setSelectedRegion(previousRegion); + } + } else { + setOrgCreationModal({ + open: true, + targetRegion: region, + previousRegion: previousRegion, + }); + } + }; + + // Auto-sync region when user switches to an organization from different region + useEffect(() => { + if (currentOrganization) { + 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) { + // DON'T redirect during organization creation if we're creating a NEW organization + // Only redirect if user selected an EXISTING organization + if (isInOrgCreation) { + // 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 { + const correctDashboardUrl = getDashboardUrlForRegion(detectedRegion); + const currentPath = window.location.pathname + window.location.search + window.location.hash; + const newUrl = `${correctDashboardUrl}${currentPath}`; + + if (correctDashboardUrl !== window.location.origin) { + window.location.href = newUrl; + return; + } + } + + setSelectedRegion(detectedRegion); + } else if (selectedRegion !== detectedRegion) { + setSelectedRegion(detectedRegion); + } + } + }, [currentOrganization, detectRegionFromCurrentOrg, selectedRegion, findOrganizationForRegionCallback, clerk]); + + // 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 = () => { + setOrgCreationModal({ open: false, targetRegion: DEFAULT_REGION, previousRegion: DEFAULT_REGION }); + + const targetDashboardUrl = getDashboardUrlForRegion(orgCreationModal.targetRegion); + const orgCreationPath = ROUTES.SIGNUP_ORGANIZATION_LIST; + const newUrl = `${targetDashboardUrl}${orgCreationPath}`; + + if (targetDashboardUrl !== window.location.origin) { + window.location.href = newUrl; + } else { + navigate(orgCreationPath); + } + }; + + // Handle organization creation cancellation + const handleCancelOrgCreation = () => { + setSelectedRegion(orgCreationModal.previousRegion); + setOrgCreationModal({ open: false, targetRegion: DEFAULT_REGION, previousRegion: DEFAULT_REGION }); + }; + + 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..81557dc916b --- /dev/null +++ b/apps/dashboard/src/context/region/region-modals.tsx @@ -0,0 +1,34 @@ +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 { + orgCreationModal: OrgCreationModalState; + onCancelOrgCreation: () => void; + onConfirmOrgCreation: () => void; +} + +export function RegionModals({ orgCreationModal, onCancelOrgCreation, onConfirmOrgCreation }: RegionModalsProps) { + const regionName = + getRegionConfig(orgCreationModal.targetRegion)?.name || orgCreationModal.targetRegion.toUpperCase(); + + return ( + + No organization was found in the {regionName} region. +
+
+ Would you like to create a new organization in the {regionName} 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..2c40c590451 --- /dev/null +++ b/apps/dashboard/src/context/region/region-selector.tsx @@ -0,0 +1,75 @@ +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 { REGIONS } from './region-config'; +import { useRegion } from './region-context'; + +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); + const isInOrgCreation = window.location.pathname.includes('/auth/organization-list'); + + if (IS_EU || !isLDReady || !isRegionSelectorEnabled) { + return null; + } + + 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..20d6b7ecdbb --- /dev/null +++ b/apps/dashboard/src/context/region/region-types.ts @@ -0,0 +1,22 @@ +// Region type is now dynamic based on configured regions +export type Region = string; + +// Type for organization public metadata +export interface OrganizationMetadata { + region?: string; // AWS region like 'us-east-1', 'ap-southeast-1', 'eu-central-1', etc. + externalOrgId?: string; + [key: string]: unknown; +} + +export interface RegionContextType { + selectedRegion: Region; + setSelectedRegion: (region: Region) => void; + getApiHostname: () => string; +} + +// 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..e8005499def --- /dev/null +++ b/apps/dashboard/src/context/region/region-utils.ts @@ -0,0 +1,118 @@ +import { type OrganizationMembershipResource, type OrganizationResource } from '@clerk/types'; +import { DEFAULT_REGION, getRegionCodeFromAws, getRegionConfig, REGIONS } from './region-config'; +import { type OrganizationMetadata, type Region } from './region-types'; + +export function getApiHostnameForRegion(region: Region): string { + 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 { + 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 DEFAULT_REGION; + + const orgMetadata = organization.publicMetadata as OrganizationMetadata; + const awsRegion = orgMetadata?.region; + + // No region metadata means default region + if (!awsRegion) { + return DEFAULT_REGION; + } + + // Map AWS region to region code + const regionCode = getRegionCodeFromAws(awsRegion); + return regionCode; +} + +export function findOrganizationForRegion( + region: Region, + userMemberships: { data?: OrganizationMembershipResource[] } +) { + // 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 awsRegion = orgMetadata?.region; + + // If no region metadata, assume default region + if (!awsRegion) { + const defaultConfig = getRegionConfig(DEFAULT_REGION); + return expectedAwsRegion === defaultConfig?.awsRegion; + } + + return awsRegion === expectedAwsRegion; + }); + + 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') + ); +} + +export function detectRegionFromURL(): Region { + const currentOrigin = window.location.origin; + 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 + const lowerOrigin = currentOrigin.toLowerCase(); + for (const region of REGIONS) { + if ( + lowerOrigin.includes(`${region.code}.`) || + lowerOrigin.includes(`.${region.code}.`) || + lowerOrigin.includes(`-${region.code}.`) + ) { + return region.code; + } + } + + // Default to base region + return DEFAULT_REGION; +} + +export function getDashboardUrlForRegion(region: Region): string { + 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; +} diff --git a/apps/dashboard/src/pages/api-keys.tsx b/apps/dashboard/src/pages/api-keys.tsx index 1b16fc38779..dc090b2329e 100644 --- a/apps/dashboard/src/pages/api-keys.tsx +++ b/apps/dashboard/src/pages/api-keys.tsx @@ -6,6 +6,9 @@ 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 { getRegionConfig } from '@/context/region/region-config'; +import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { PermissionsEnum } from '@novu/shared'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -17,7 +20,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 +39,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 +70,8 @@ export function ApiKeysPage() { return null; } - const region = window.location.hostname.includes('eu') ? 'EU' : 'US'; + // Use dynamic region from region selector + const region = getRegionConfig(selectedRegion)?.name || selectedRegion.toUpperCase(); return ( <> @@ -125,10 +130,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 +142,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..b71f84b9c81 --- /dev/null +++ b/apps/dashboard/src/utils/api-hostname-manager.ts @@ -0,0 +1,40 @@ +import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '@/config'; + +// Global hostname manager for both API and WebSocket endpoints +class HostnameManager { + private currentApiHostname: string; + private currentWebSocketHostname: string; + + 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(); + } +} + +export const apiHostnameManager = new HostnameManager(); diff --git a/apps/dashboard/src/utils/code-snippets.ts b/apps/dashboard/src/utils/code-snippets.ts index 4b9da1935a5..e8fe83e692f 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; @@ -102,7 +103,7 @@ export const generateTriggerCurlCommand = ({ payload, apiKey, context, - baseUrl = API_HOSTNAME ?? 'https://api.novu.co', + baseUrl = apiHostnameManager.getHostname(), addDashboardSource = true, }: TriggerCurlCommandOptions) => { const body = createTriggerRequestBody({ workflowId, to, payload, addDashboardSource, context }); @@ -128,7 +129,7 @@ export const generatePostmanCollection = ({ to, payload, apiKey, - baseUrl = API_HOSTNAME ?? 'https://api.novu.co', + baseUrl = apiHostnameManager.getHostname(), addDashboardSource = true, context, }: PostmanCollectionOptions) => { 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..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 @@ -44,4 +37,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" ] 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", diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index dc87d7e639a..229479a2134 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -72,6 +72,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',