From 5183894109536f11cef18d3e09fa616d58b2d6a9 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 10:20:22 +0300 Subject: [PATCH 1/2] fix(api-service): fallback to random ID when slugify produces empty string for non-Latin names (#10946) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- .../create-workflow-v0/create-workflow.usecase.ts | 5 ++--- .../upsert-workflow/upsert-workflow.usecase.ts | 13 +++++++++---- libs/application-generic/src/utils/build-slug.ts | 5 +++-- libs/application-generic/src/utils/index.ts | 1 + .../src/utils/slugify-or-random.ts | 14 ++++++++++++++ packages/shared/src/utils/slugify/slugify.spec.ts | 10 ++++++++++ 6 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 libs/application-generic/src/utils/slugify-or-random.ts 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'); From 93c153730eda2ede0e603d3e58a86401de9f7bc6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 10:20:47 +0300 Subject: [PATCH 2/2] fix(dashboard): fix timezone crash and scheduled delay infinite loop (#10914) Co-authored-by: Cursor Agent Co-authored-by: Dima Grossman --- .../subscribers/timezone-select.tsx | 2 +- .../digest-delay-tabs/scheduled-type.tsx | 32 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) 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({