Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ed05ce1
feat(region): implement region management with dynamic API and WebSoc…
merrcury Sep 29, 2025
325357a
chore: update subproject commit reference in .source
merrcury Sep 29, 2025
df8a22a
chore: update ENTRYPOINT in Dockerfiles to use dynamic SECRET_NAME va…
merrcury Sep 29, 2025
4874ab3
chore: update deploy.yml to enhance environment options and standardi…
merrcury Sep 29, 2025
3084c68
feat(region): enhance region detection and management
merrcury Sep 30, 2025
1304c26
refactor(region): streamline region redirection logic in RegionProvider
merrcury Sep 30, 2025
c2112d7
refactor(auth, region): remove console logs and streamline organizati…
merrcury Sep 30, 2025
f12bc1d
refactor(region): enhance logging and refine organization creation flow
merrcury Sep 30, 2025
615167e
refactor(config): simplify API and WebSocket hostname assignments
merrcury Sep 30, 2025
5a8cb25
refactor(config): simplify DASHBOARD_URL_SG assignment
merrcury Sep 30, 2025
8a4e455
feat(region): implement multi-region configuration and dynamic loading
merrcury Sep 30, 2025
afcd19b
refactor(region): enhance region configuration logic and defaults
merrcury Sep 30, 2025
60a4564
feat(env): add multi-region configuration to .example.env
merrcury Sep 30, 2025
ad7f074
refactor(region): remove outdated comments on region detection
merrcury Sep 30, 2025
f1d5b03
refactor(region): clean up comments and streamline region handling
merrcury Sep 30, 2025
bbb8e10
fix(region): improve API call handling during region switching
merrcury Sep 30, 2025
454aebe
refactor(region): remove region switching logic from API client and c…
merrcury Sep 30, 2025
ea9d64c
refactor(identity): improve user and organization identification logic
merrcury Sep 30, 2025
bb11165
refactor(region): enhance region name retrieval in organization creat…
merrcury Oct 1, 2025
37e0663
refactor(region): standardize default region handling in context and …
merrcury Oct 1, 2025
37402d0
update(subproject): update subproject commit reference to latest version
merrcury Oct 8, 2025
d6a450d
Merge branch 'next' of github.com:novuhq/novu into feat/singleClerk
merrcury Oct 8, 2025
14469fb
update(subproject): change subproject commit reference to 15113238a9dc
merrcury Oct 8, 2025
bf7958a
update(subproject): change subproject commit reference to 6c74fe64b80f
merrcury Oct 8, 2025
f7277d1
chore(workflow): add NX_CLOUD_ACCESS_TOKEN environment variable to de…
merrcury Oct 8, 2025
2ef3e02
refactor(docker): optimize Dockerfile and update build scripts for im…
merrcury Oct 8, 2025
ead844a
feat(workflow): add environment filtering to deployment logic in GitH…
merrcury Oct 8, 2025
9c320ef
fix(workflow): update production environment names and conditions in …
merrcury Oct 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 72 additions & 20 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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" \
Expand Down
2 changes: 1 addition & 1 deletion .source
2 changes: 1 addition & 1 deletion apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
33 changes: 33 additions & 0 deletions apps/dashboard/.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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_<CODE>=<url>
# VITE_API_HOSTNAME_<CODE>=<url>
# VITE_WEBSOCKET_HOSTNAME_<CODE>=<url>
# VITE_AWS_REGION_<CODE>=<aws-region>
# VITE_REGION_NAME_<CODE>=<display-name>
# VITE_REGION_FLAG_<CODE>=<emoji-flag>
# See MULTI_REGION_SETUP.md for detailed instructions
7 changes: 3 additions & 4 deletions apps/dashboard/src/api/api.client.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -62,7 +61,7 @@ const request = async <T>(
}
}

const baseUrl = API_HOSTNAME ?? 'https://api.novu.co';
const baseUrl = apiHostnameManager.getHostname();
const response = await fetch(`${baseUrl}/${version}${endpoint}`, config);

if (!response.ok) {
Expand Down
43 changes: 40 additions & 3 deletions apps/dashboard/src/components/auth/create-organization.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -60,7 +61,41 @@ function FormContainer({ children }: FormContainerProps) {
}

function OrganizationForm() {
return <OrganizationListForm appearance={FORM_APPEARANCE} {...ORGANIZATION_FORM_CONFIG} />;
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 (
<div className="relative">
{/* Region selector - only visible on Page 2 (form page), aligned with form content */}
{showRegionSelector && (
<div className="absolute -top-14 left-4 z-20">
<RegionSelector />
</div>
)}

<OrganizationListForm appearance={FORM_APPEARANCE} {...ORGANIZATION_FORM_CONFIG} />
</div>
);
}

function OrganizationFormSection() {
Expand Down Expand Up @@ -113,6 +148,7 @@ function PageContent() {

export default function OrganizationCreate() {
const { organization } = useOrganization();
const { selectedRegion } = useRegion();
const track = useTelemetry();

useEffect(() => {
Expand All @@ -122,9 +158,10 @@ export default function OrganizationCreate() {
location: 'web',
organizationId: organization.id,
organizationName: organization.name,
region: selectedRegion,
});
}
}, [organization, track]);
}, [organization, track, selectedRegion]);

return (
<div className="flex w-full flex-1 flex-row items-center justify-center">
Expand Down
6 changes: 3 additions & 3 deletions apps/dashboard/src/components/auth/inbox-preview-content.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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',
},
Expand Down
6 changes: 3 additions & 3 deletions apps/dashboard/src/components/confirmation-modal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -54,8 +55,10 @@ export const HeaderNavigation = (props: HeaderNavigationProps) => {
)}
{!hideBridgeUrl ? <EditBridgeUrlButton /> : null}
{!(IS_SELF_HOSTED && IS_ENTERPRISE) && <CustomerSupportButton />}
<div className="flex pr-0.5">
<div className="flex items-center gap-2">
<InboxButton />
<div className="h-4 w-px bg-neutral-200" />
<RegionSelector />
</div>
<UserProfile />
</div>
Expand Down
Loading
Loading