diff --git a/apps/dashboard/src/components/subscribers/timezone-select.tsx b/apps/dashboard/src/components/subscribers/timezone-select.tsx
index bb2631775e4..f16cfaca6a5 100644
--- a/apps/dashboard/src/components/subscribers/timezone-select.tsx
+++ b/apps/dashboard/src/components/subscribers/timezone-select.tsx
@@ -40,7 +40,7 @@ export function TimezoneSelect(props: TimezoneSelectProps) {
{value ? (
- {parseTimezone(value).label}
+ {parseTimezone(value)?.label ?? value}
) : (
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/scheduled-type.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/scheduled-type.tsx
index 819b29da5b4..66e3a08adae 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/scheduled-type.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest-delay-tabs/scheduled-type.tsx
@@ -1,5 +1,5 @@
import cronParser from 'cron-parser';
-import { useMemo } from 'react';
+import { useEffect, useMemo, useRef } from 'react';
import { RiInformation2Line } from 'react-icons/ri';
import { Hint, HintIcon } from '@/components/primitives/hint';
import { DaysOfWeek } from '@/components/workflow-editor/steps/digest-delay-tabs/days-of-week';
@@ -28,23 +28,25 @@ export const ScheduledType = ({
onValueChange: (cron: string) => void;
onError?: (error: unknown) => void;
}) => {
- const period = useMemo(() => {
+ const onErrorRef = useRef(onError);
+ onErrorRef.current = onError;
+
+ const { period, periodError } = useMemo(() => {
try {
const cronParts = parseCronString(value);
- return getPeriodFromCronParts(cronParts);
+
+ return { period: getPeriodFromCronParts(cronParts), periodError: null };
} catch (e) {
- onError?.(e);
- return PeriodValues.MINUTE;
+ return { period: PeriodValues.MINUTE, periodError: e };
}
- }, [value, onError]);
+ }, [value]);
- const { second, month, dayOfMonth, dayOfWeek, hour, minute } = useMemo(() => {
+ const { second, month, dayOfMonth, dayOfWeek, hour, minute, fieldsError } = useMemo(() => {
try {
const expression = cronParser.parseExpression(value);
- return toUiFields(expression.fields);
- } catch (e) {
- onError?.(e);
+ return { ...toUiFields(expression.fields), fieldsError: null };
+ } catch (e) {
return {
second: [],
minute: [],
@@ -52,9 +54,17 @@ export const ScheduledType = ({
dayOfMonth: [],
month: [],
dayOfWeek: [],
+ fieldsError: e,
};
}
- }, [value, onError]);
+ }, [value]);
+
+ useEffect(() => {
+ const error = periodError ?? fieldsError;
+ if (error) {
+ onErrorRef.current?.(error);
+ }
+ }, [periodError, fieldsError]);
const handleValueChange = (fields: Partial) => {
const cronFields = toCronFields({
diff --git a/libs/application-generic/src/usecases/create-workflow-v0/create-workflow.usecase.ts b/libs/application-generic/src/usecases/create-workflow-v0/create-workflow.usecase.ts
index fdb033dccf3..30608811957 100644
--- a/libs/application-generic/src/usecases/create-workflow-v0/create-workflow.usecase.ts
+++ b/libs/application-generic/src/usecases/create-workflow-v0/create-workflow.usecase.ts
@@ -17,7 +17,6 @@ import {
isBridgeWorkflow,
ResourceOriginEnum,
ResourceTypeEnum,
- slugify,
TriggerTypeEnum,
} from '@novu/shared';
import { PinoLogger } from 'nestjs-pino';
@@ -25,7 +24,7 @@ import { WorkflowWithPreferencesResponseDto } from '../../dtos/get-workflow-with
import { Instrument, InstrumentUsecase } from '../../instrumentation';
import { AnalyticsService, ContentService } from '../../services';
import { ResourceValidatorService } from '../../services/resource-validator.service';
-import { isVariantEmpty, PlatformException, shortId } from '../../utils';
+import { isVariantEmpty, PlatformException, shortId, slugifyOrRandom } from '../../utils';
import { MANAGE_TRANSLATIONS, TRANSLATIONS_SERVICE } from '../../utils/constants';
import { NotificationStep, NotificationStepVariantCommand } from '../../value-objects';
import { CreateChange, CreateChangeCommand } from '../create-change';
@@ -197,7 +196,7 @@ export class CreateWorkflowV0 {
* For non-bridge workflows, we use a slugified version of the workflow name
* as the trigger identifier to provide a better trigger DX.
*/
- triggerIdentifier = slugify(command.name);
+ triggerIdentifier = slugifyOrRandom(command.name);
}
return triggerIdentifier;
diff --git a/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts b/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts
index bde731d56d7..657916e145e 100644
--- a/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts
+++ b/libs/application-generic/src/usecases/upsert-workflow/upsert-workflow.usecase.ts
@@ -14,7 +14,6 @@ import {
ResourceOriginEnum,
ResourceTypeEnum,
StepTypeEnum,
- slugify,
WebhookEventEnum,
WebhookObjectTypeEnum,
WorkflowCreationSourceEnum,
@@ -28,7 +27,13 @@ import { WorkflowResponseDto } from '../../dtos/workflow/workflow-response.dto';
import { Instrument, InstrumentUsecase } from '../../instrumentation';
import { EmailControlType } from '../../schemas/control';
import { AnalyticsService } from '../../services';
-import { computeWorkflowStatus, removeBrandingFromHtml, shortId, stepTypeToControlSchema } from '../../utils';
+import {
+ computeWorkflowStatus,
+ removeBrandingFromHtml,
+ shortId,
+ slugifyOrRandom,
+ stepTypeToControlSchema,
+} from '../../utils';
import { isStringifiedMailyJSONContent } from '../../utils/maily-utils';
import { isStepResolverActive } from '../../utils/step-resolver-control-state';
import { NotificationStep } from '../../value-objects';
@@ -155,7 +160,7 @@ export class UpsertWorkflowUseCase {
tags: workflowDto.tags || [],
userPreferences: workflowDto.preferences?.user ?? null,
defaultPreferences: workflowDto.preferences?.workflow ?? DEFAULT_WORKFLOW_PREFERENCES,
- triggerIdentifier: preserveWorkflowId ? workflowDto.workflowId : slugify(workflowDto.name),
+ triggerIdentifier: preserveWorkflowId ? workflowDto.workflowId : slugifyOrRandom(workflowDto.name),
status: computeWorkflowStatus(isWorkflowActive, steps),
payloadSchema: workflowDto.payloadSchema,
validatePayload: workflowDto.validatePayload,
@@ -298,7 +303,7 @@ export class UpsertWorkflowUseCase {
@Instrument()
private generateUniqueStepId(step: UpsertStepDataCommand, previousSteps: NotificationStep[]): string {
- const slug = slugify(step.name);
+ const slug = slugifyOrRandom(step.name);
let finalStepId = slug;
let attempts = 0;
diff --git a/libs/application-generic/src/utils/build-slug.ts b/libs/application-generic/src/utils/build-slug.ts
index f12ef290cc5..02edd638d53 100644
--- a/libs/application-generic/src/utils/build-slug.ts
+++ b/libs/application-generic/src/utils/build-slug.ts
@@ -1,5 +1,6 @@
-import { ShortIsPrefixEnum, Slug, slugify } from '@novu/shared';
+import { ShortIsPrefixEnum, Slug } from '@novu/shared';
import { encodeBase62 } from './base62';
+import { slugifyOrRandom } from './slugify-or-random';
const SLUG_DELIMITER = '_';
@@ -8,5 +9,5 @@ const SLUG_DELIMITER = '_';
* @returns The slug for the entity, example: slug: "workflow-name_wf_AbC1Xyz9KlmNOpQr"
*/
export function buildSlug(entityName: string, shortIdPrefix: ShortIsPrefixEnum, internalId: string): Slug {
- return `${slugify(entityName)}${SLUG_DELIMITER}${shortIdPrefix}${encodeBase62(internalId)}`;
+ return `${slugifyOrRandom(entityName)}${SLUG_DELIMITER}${shortIdPrefix}${encodeBase62(internalId)}`;
}
diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts
index 147a230dd46..221488ab899 100644
--- a/libs/application-generic/src/utils/index.ts
+++ b/libs/application-generic/src/utils/index.ts
@@ -2,6 +2,7 @@ export * from './base62';
export * from './bridge';
export * from './build-slug';
export * from './build-variables';
+export * from './slugify-or-random';
export * from './buildBridgeEndpointUrl';
export * from './compute-workflow-status';
export * from './create-schema';
diff --git a/libs/application-generic/src/utils/slugify-or-random.ts b/libs/application-generic/src/utils/slugify-or-random.ts
new file mode 100644
index 00000000000..dfc436b94c8
--- /dev/null
+++ b/libs/application-generic/src/utils/slugify-or-random.ts
@@ -0,0 +1,14 @@
+import { slugify } from '@novu/shared';
+import { shortId } from './generate-id';
+
+/**
+ * Returns slugify(name) if it produces a non-empty result,
+ * otherwise falls back to a random short ID.
+ * Handles names in non-Latin scripts (CJK, Arabic, etc.) whose characters
+ * are entirely stripped during transliteration.
+ */
+export function slugifyOrRandom(name: string): string {
+ const slug = slugify(name);
+
+ return slug || shortId();
+}
diff --git a/packages/shared/src/utils/slugify/slugify.spec.ts b/packages/shared/src/utils/slugify/slugify.spec.ts
index 0c1386a5b8b..544815d2541 100644
--- a/packages/shared/src/utils/slugify/slugify.spec.ts
+++ b/packages/shared/src/utils/slugify/slugify.spec.ts
@@ -529,6 +529,16 @@ describe('slugify', () => {
}
});
+ it('returns empty string for CJK-only input', () => {
+ expect(slugify('テストメール')).toBe('');
+ expect(slugify('你好世界')).toBe('');
+ expect(slugify('알림')).toBe('');
+ });
+
+ it('preserves ASCII parts in mixed-script input', () => {
+ expect(slugify('Test テスト')).toBe('test');
+ });
+
it('normalizes the string', () => {
const slug = decodeURIComponent('a%CC%8Aa%CC%88o%CC%88-123'); // åäö-123
expect(slugify(slug), 'aao-123');